# HG changeset patch # User Paul Boddie # Date 1427382706 -3600 # Node ID 8a940a37057996621fca20dfbcf2686cdd23c2c7 # Parent 9b8237cc00bd26e56f3d5a111fda59a714d12c39 Moved calendar and event presentation into separate classes, also moving common Web resource functionality into its own class. diff -r 9b8237cc00bd -r 8a940a370579 imip_manager.py --- a/imip_manager.py Thu Mar 26 00:27:06 2015 +0100 +++ b/imip_manager.py Thu Mar 26 16:11:46 2015 +0100 @@ -24,1907 +24,17 @@ LIBRARY_PATH = "/var/lib/imip-agent" -from datetime import date, datetime, timedelta -import babel.dates -import pytz import sys - sys.path.append(LIBRARY_PATH) -from imiptools.client import Client, update_attendees -from imiptools.data import get_address, get_uri, get_window_end, Object, \ - uri_dict, uri_values -from imiptools.dates import format_datetime, format_time, to_date, get_datetime, \ - get_datetime_item, get_end_of_day, get_period_item, \ - get_start_of_day, get_start_of_next_day, get_timestamp, \ - ends_on_same_day, to_timezone -from imiptools.mail import Messenger -from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ - convert_periods, get_freebusy_details, \ - get_scale, have_conflict, get_slots, get_spans, \ - partition_by_day, remove_period, remove_affected_period, \ - update_freebusy -from imipweb.env import CGIEnvironment -from imipweb.handler import ManagerHandler -import imip_store -import markup +from imipweb.calendar import CalendarPage +from imipweb.event import EventPage +from imipweb.resource import Resource -class Manager(Client): +class Manager(Resource): "A simple manager application." - def __init__(self, messenger=None): - self.messenger = messenger or Messenger() - self.encoding = "utf-8" - self.env = CGIEnvironment(self.encoding) - - user = self.env.get_user() - Client.__init__(self, user and get_uri(user) or None) - - self.locale = None - self.requests = None - - self.out = self.env.get_output() - self.page = markup.page() - self.html_ids = None - - self.store = imip_store.FileStore() - self.objects = {} - - try: - self.publisher = imip_store.FilePublisher() - except OSError: - self.publisher = None - - def _suffixed_name(self, name, index=None): - return index is not None and "%s-%d" % (name, index) or name - - def _simple_suffixed_name(self, name, suffix, index=None): - return index is not None and "%s-%s" % (name, suffix) or name - - def _get_identifiers(self, path_info): - parts = path_info.lstrip("/").split("/") - if len(parts) == 1: - return parts[0], None - else: - return parts[:2] - - def _get_object(self, uid, recurrenceid=None): - if self.objects.has_key((uid, recurrenceid)): - return self.objects[(uid, recurrenceid)] - - fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None - obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment) - return obj - - def _get_recurrences(self, uid): - return self.store.get_recurrences(self.user, uid) - - def _get_requests(self): - if self.requests is None: - cancellations = self.store.get_cancellations(self.user) - requests = set(self.store.get_requests(self.user)) - self.requests = requests.difference(cancellations) - return self.requests - - def _get_request_summary(self): - summary = [] - for uid, recurrenceid in self._get_requests(): - obj = self._get_object(uid, recurrenceid) - if obj: - periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) - recurrenceids = self._get_recurrences(uid) - - # Convert the periods to more substantial free/busy items. - - for start, end in periods: - - # Subtract any recurrences from the free/busy details of a - # parent object. - - if recurrenceid or start not in recurrenceids: - summary.append(( - start, end, uid, - obj.get_value("TRANSP"), - recurrenceid, - obj.get_value("SUMMARY"), - obj.get_value("ORGANIZER") - )) - return summary - - # Preference methods. - - def get_user_locale(self): - if not self.locale: - self.locale = self.get_preferences().get("LANG", "en") - return self.locale - - # Prettyprinting of dates and times. - - def format_date(self, dt, format): - return self._format_datetime(babel.dates.format_date, dt, format) - - def format_time(self, dt, format): - return self._format_datetime(babel.dates.format_time, dt, format) - - def format_datetime(self, dt, format): - return self._format_datetime( - isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, - dt, format) - - def _format_datetime(self, fn, dt, format): - return fn(dt, format=format, locale=self.get_user_locale()) - - # Data management methods. - - def remove_request(self, uid, recurrenceid=None): - return self.store.dequeue_request(self.user, uid, recurrenceid) - - def remove_event(self, uid, recurrenceid=None): - return self.store.remove_event(self.user, uid, recurrenceid) - - def update_freebusy(self, uid, recurrenceid, obj): - - """ - Update stored free/busy details for the event with the given 'uid' and - 'recurrenceid' having a representation of 'obj'. - """ - - is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE")) - - freebusy = self.store.get_freebusy(self.user) - - update_freebusy(freebusy, - obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()), - is_only_organiser and "ORG" or obj.get_value("TRANSP"), - uid, recurrenceid, - obj.get_value("SUMMARY"), - obj.get_value("ORGANIZER")) - - # Subtract any recurrences from the free/busy details of a parent - # object. - - for recurrenceid in self._get_recurrences(uid): - remove_affected_period(freebusy, uid, recurrenceid) - - self.store.set_freebusy(self.user, freebusy) - - def remove_from_freebusy(self, uid, recurrenceid=None): - freebusy = self.store.get_freebusy(self.user) - remove_period(freebusy, uid, recurrenceid) - self.store.set_freebusy(self.user, freebusy) - - # Presentation methods. - - def new_page(self, title): - self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) - self.html_ids = set() - - def status(self, code, message): - self.header("Status", "%s %s" % (code, message)) - - def header(self, header, value): - print >>self.out, "%s: %s" % (header, value) - - def no_user(self): - self.status(403, "Forbidden") - self.new_page(title="Forbidden") - self.page.p("You are not logged in and thus cannot access scheduling requests.") - - def no_page(self): - self.status(404, "Not Found") - self.new_page(title="Not Found") - self.page.p("No page is provided at the given address.") - - def redirect(self, url): - self.status(302, "Redirect") - self.header("Location", url) - self.new_page(title="Redirect") - self.page.p("Redirecting to: %s" % url) - - def link_to(self, uid, recurrenceid=None): - if recurrenceid: - return self.env.new_url("/".join([uid, recurrenceid])) - else: - return self.env.new_url(uid) - - # Request logic methods. - - def handle_newevent(self): - - """ - Handle any new event operation, creating a new event and redirecting to - the event page for further activity. - """ - - # Handle a submitted form. - - args = self.env.get_args() - - if not args.has_key("newevent"): - return - - # Create a new event using the available information. - - slots = args.get("slot", []) - participants = args.get("participants", []) - - if not slots: - return - - # Obtain the user's timezone. - - tzid = self.get_tzid() - - # Coalesce the selected slots. - - slots.sort() - coalesced = [] - last = None - - for slot in slots: - start, end = slot.split("-") - start = get_datetime(start, {"TZID" : tzid}) - end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) - - if last: - last_start, last_end = last - - # Merge adjacent dates and datetimes. - - if start == last_end or \ - not isinstance(start, datetime) and \ - get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): - - last = last_start, end - continue - - # Handle datetimes within dates. - # Datetime periods are within single days and are therefore - # discarded. - - elif not isinstance(last_start, datetime) and \ - get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): - - continue - - # Add separate dates and datetimes. - - else: - coalesced.append(last) - - last = start, end - - if last: - coalesced.append(last) - - # Invent a unique identifier. - - utcnow = get_timestamp() - uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) - - # Create a calendar object and store it as a request. - - record = [] - rwrite = record.append - - # Define a single occurrence if only one coalesced slot exists. - - start, end = coalesced[0] - start_value, start_attr = get_datetime_item(start, tzid) - end_value, end_attr = get_datetime_item(end, tzid) - - rwrite(("UID", {}, uid)) - rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) - rwrite(("DTSTAMP", {}, utcnow)) - rwrite(("DTSTART", start_attr, start_value)) - rwrite(("DTEND", end_attr, end_value)) - rwrite(("ORGANIZER", {}, self.user)) - - participants = uri_values(filter(None, participants)) - - for participant in participants: - rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) - - if self.user not in participants: - rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user)) - - # Define additional occurrences if many slots are defined. - - rdates = [] - - for start, end in coalesced[1:]: - start_value, start_attr = get_datetime_item(start, tzid) - end_value, end_attr = get_datetime_item(end, tzid) - rdates.append("%s/%s" % (start_value, end_value)) - - if rdates: - rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) - - node = ("VEVENT", {}, record) - - self.store.set_event(self.user, uid, None, node=node) - self.store.queue_request(self.user, uid) - - # Redirect to the object (or the first of the objects), where instead of - # attendee controls, there will be organiser controls. - - self.redirect(self.link_to(uid)) - - def handle_request(self, uid, recurrenceid, obj): - - """ - Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as - the object's representation, returning an error if one occurred, or None - if the request was successfully handled. - """ - - # Handle a submitted form. - - args = self.env.get_args() - - # Get the possible actions. - - reply = args.has_key("reply") - discard = args.has_key("discard") - invite = args.has_key("invite") - cancel = args.has_key("cancel") - save = args.has_key("save") - ignore = args.has_key("ignore") - - have_action = reply or discard or invite or cancel or save or ignore - - if not have_action: - return ["action"] - - # If ignoring the object, return to the calendar. - - if ignore: - self.redirect(self.env.get_path()) - return None - - # Update the object. - - if args.has_key("summary"): - obj["SUMMARY"] = [(args["summary"][0], {})] - - attendees = uri_dict(obj.get_value_map("ATTENDEE")) - - if args.has_key("partstat"): - if attendees.has_key(self.user): - attendees[self.user]["PARTSTAT"] = args["partstat"][0] - if attendees[self.user].has_key("RSVP"): - del attendees[self.user]["RSVP"] - - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - - # Obtain the user's timezone and process datetime values. - - update = False - - if is_organiser: - periods, errors = self.handle_all_period_controls() - if errors: - return errors - elif periods: - self.set_period_in_object(obj, periods[0]) - self.set_periods_in_object(obj, periods[1:]) - - # Obtain any participants to be added or removed. - - removed = args.get("remove") - added = args.get("added") - - # Process any action. - - handled = True - - if reply or invite or cancel: - - handler = ManagerHandler(obj, self.user, self.messenger) - - # Process the object and remove it from the list of requests. - - if reply and handler.process_received_request(update) or \ - is_organiser and (invite or cancel) and \ - handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added): - - self.remove_request(uid, recurrenceid) - - # Save single user events. - - elif save: - to_cancel = update_attendees(obj, added, removed) - self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node()) - self.update_freebusy(uid, recurrenceid, obj) - self.remove_request(uid, recurrenceid) - - # Remove the request and the object. - - elif discard: - self.remove_from_freebusy(uid, recurrenceid) - self.remove_event(uid, recurrenceid) - self.remove_request(uid, recurrenceid) - - else: - handled = False - - # Upon handling an action, redirect to the main page. - - if handled: - self.redirect(self.env.get_path()) - - return None - - def handle_all_period_controls(self): - - """ - Handle datetime controls for a particular period, where 'index' may be - used to indicate a recurring period, or the main start and end datetimes - are handled. - """ - - args = self.env.get_args() - - periods = [] - - # Get the main period details. - - dtend_enabled = args.get("dtend-control", [None])[0] - dttimes_enabled = args.get("dttimes-control", [None])[0] - start_values = self.get_date_control_values("dtstart") - end_values = self.get_date_control_values("dtend") - - period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) - - if errors: - return None, errors - - periods.append(period) - - # Get the recurring period details. - - all_dtend_enabled = args.get("dtend-control-recur", []) - all_dttimes_enabled = args.get("dttimes-control-recur", []) - all_start_values = self.get_date_control_values("dtstart-recur", multiple=True) - all_end_values = self.get_date_control_values("dtend-recur", multiple=True) - - for index, (start_values, end_values, dtend_enabled, dttimes_enabled) in \ - enumerate(map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled)): - - dtend_enabled = str(index) in all_dtend_enabled - dttimes_enabled = str(index) in all_dttimes_enabled - period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) - - if errors: - return None, errors - - periods.append(period) - - return periods, None - - def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled): - - """ - Handle datetime controls for a particular period, described by the given - 'start_values' and 'end_values', with 'dtend_enabled' and - 'dttimes_enabled' affecting the usage of the provided values. - """ - - t = self.handle_date_control_values(start_values, dttimes_enabled) - if t: - dtstart, dtstart_attr = t - else: - return None, ["dtstart"] - - # Handle specified end datetimes. - - if dtend_enabled: - t = self.handle_date_control_values(end_values, dttimes_enabled) - if t: - dtend, dtend_attr = t - - # Convert end dates to iCalendar "next day" dates. - - if not isinstance(dtend, datetime): - dtend += timedelta(1) - else: - return None, ["dtend"] - - # Otherwise, treat the end date as the start date. Datetimes are - # handled by making the event occupy the rest of the day. - - else: - dtend = dtstart + timedelta(1) - dtend_attr = dtstart_attr - - if isinstance(dtstart, datetime): - dtend = get_start_of_day(dtend, attr["TZID"]) - - if dtstart >= dtend: - return None, ["dtstart", "dtend"] - - return ((dtstart, dtstart_attr), (dtend, dtend_attr)), None - - def handle_date_control_values(self, values, with_time=True): - - """ - Handle date control information for the given 'values', returning a - (datetime, attr) tuple, or None if the fields cannot be used to - construct a datetime object. - """ - - if not values or not values["date"]: - return None - elif with_time: - value = "%s%s" % (values["date"], values["time"]) - attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"} - dt = get_datetime(value, attr) - else: - attr = {"VALUE" : "DATE"} - dt = get_datetime(values["date"]) - - if dt: - return dt, attr - - return None - - def get_date_control_values(self, name, multiple=False): - - """ - Return a dictionary containing date, time and tzid entries for fields - starting with 'name'. - """ - - args = self.env.get_args() - - dates = args.get("%s-date" % name, []) - hours = args.get("%s-hour" % name, []) - minutes = args.get("%s-minute" % name, []) - seconds = args.get("%s-second" % name, []) - tzids = args.get("%s-tzid" % name, []) - - # Handle absent values by employing None values. - - field_values = map(None, dates, hours, minutes, seconds, tzids) - if not field_values and not multiple: - field_values = [(None, None, None, None, None)] - - all_values = [] - - for date, hour, minute, second, tzid in field_values: - - # Construct a usable dictionary of values. - - time = (hour or minute or second) and \ - "T%s%s%s" % ( - (hour or "").rjust(2, "0")[:2], - (minute or "").rjust(2, "0")[:2], - (second or "").rjust(2, "0")[:2] - ) or "" - - value = { - "date" : date, - "time" : time, - "tzid" : tzid or self.get_tzid() - } - - # Return a single value or append to a collection of all values. - - if not multiple: - return value - else: - all_values.append(value) - - return all_values - - def set_period_in_object(self, obj, period): - - "Set in the given 'obj' the given 'period' as the main start and end." - - (dtstart, dtstart_attr), (dtend, dtend_attr) = period - - return self.set_datetime_in_object(dtstart, dtstart_attr.get("TZID"), "DTSTART", obj) or \ - self.set_datetime_in_object(dtend, dtend_attr.get("TZID"), "DTEND", obj) - - def set_periods_in_object(self, obj, periods): - - "Set in the given 'obj' the given 'periods'." - - update = False - - old_values = obj.get_values("RDATE") - new_rdates = [] - - if obj.has_key("RDATE"): - del obj["RDATE"] - - for period in periods: - (dtstart, dtstart_attr), (dtend, dtend_attr) = period - tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") - new_rdates.append(get_period_item(dtstart, dtend, tzid)) - - obj["RDATE"] = new_rdates - - # NOTE: To do: calculate the update status. - return update - - def set_datetime_in_object(self, dt, tzid, property, obj): - - """ - Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether - an update has occurred. - """ - - if dt: - old_value = obj.get_value(property) - obj[property] = [get_datetime_item(dt, tzid)] - return format_datetime(dt) != old_value - - return False - - def handle_new_attendees(self, obj): - - "Add or remove new attendees. This does not affect the stored object." - - args = self.env.get_args() - - existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) - new_attendees = args.get("added", []) - new_attendee = args.get("attendee", [""])[0] - - if args.has_key("add"): - if new_attendee.strip(): - new_attendee = get_uri(new_attendee.strip()) - if new_attendee not in new_attendees and new_attendee not in existing_attendees: - new_attendees.append(new_attendee) - new_attendee = "" - - if args.has_key("removenew"): - removed_attendee = args["removenew"][0] - if removed_attendee in new_attendees: - new_attendees.remove(removed_attendee) - - return new_attendees, new_attendee - - def get_event_period(self, obj): - - """ - Return (dtstart, dtstart attributes), (dtend, dtend attributes) for - 'obj'. - """ - - dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") - if obj.has_key("DTEND"): - dtend, dtend_attr = obj.get_datetime_item("DTEND") - elif obj.has_key("DURATION"): - duration = obj.get_duration("DURATION") - dtend = dtstart + duration - dtend_attr = dtstart_attr - else: - dtend, dtend_attr = dtstart, dtstart_attr - return (dtstart, dtstart_attr), (dtend, dtend_attr) - - # Page fragment methods. - - def show_request_controls(self, obj): - - "Show form controls for a request concerning 'obj'." - - page = self.page - args = self.env.get_args() - - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - - attendees = uri_values((obj.get_values("ATTENDEE") or []) + filter(None, args.get("attendee", []))) - is_attendee = self.user in attendees - - is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() - - have_other_attendees = len(attendees) > (is_attendee and 1 or 0) - - # Show appropriate options depending on the role of the user. - - if is_attendee and not is_organiser: - page.p("An action is required for this request:") - - page.p() - page.input(name="reply", type="submit", value="Send reply") - page.add(" ") - page.input(name="discard", type="submit", value="Discard event") - page.add(" ") - page.input(name="ignore", type="submit", value="Do nothing for now") - page.p.close() - - if is_organiser: - page.p("As organiser, you can perform the following:") - - if have_other_attendees: - page.p() - page.input(name="invite", type="submit", value="Invite/notify attendees") - page.add(" ") - if is_request: - page.input(name="discard", type="submit", value="Discard event") - else: - page.input(name="cancel", type="submit", value="Cancel event") - page.add(" ") - page.input(name="ignore", type="submit", value="Do nothing for now") - page.p.close() - else: - page.p() - page.input(name="save", type="submit", value="Save event") - page.add(" ") - page.input(name="discard", type="submit", value="Discard event") - page.add(" ") - page.input(name="ignore", type="submit", value="Do nothing for now") - page.p.close() - - property_items = [ - ("SUMMARY", "Summary"), - ("DTSTART", "Start"), - ("DTEND", "End"), - ("ORGANIZER", "Organiser"), - ("ATTENDEE", "Attendee"), - ] - - partstat_items = [ - ("NEEDS-ACTION", "Not confirmed"), - ("ACCEPTED", "Attending"), - ("TENTATIVE", "Tentatively attending"), - ("DECLINED", "Not attending"), - ("DELEGATED", "Delegated"), - (None, "Not indicated"), - ] - - def show_object_on_page(self, uid, obj, error=None): - - """ - Show the calendar object with the given 'uid' and representation 'obj' - on the current page. If 'error' is given, show a suitable message. - """ - - page = self.page - page.form(method="POST") - - page.input(name="editing", type="hidden", value="true") - - args = self.env.get_args() - - # Obtain the user's timezone. - - tzid = self.get_tzid() - - # Obtain basic event information, showing any necessary editing controls. - - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - - if is_organiser: - new_attendees, new_attendee = self.handle_new_attendees(obj) - else: - new_attendees = [] - new_attendee = "" - - (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) - self.show_object_datetime_controls(dtstart, dtend) - - # Provide a summary of the object. - - page.table(class_="object", cellspacing=5, cellpadding=5) - page.thead() - page.tr() - page.th("Event", class_="mainheading", colspan=2) - page.tr.close() - page.thead.close() - page.tbody() - - for name, label in self.property_items: - field = name.lower() - - items = obj.get_items(name) or [] - rowspan = len(items) - - if name == "ATTENDEE": - rowspan += len(new_attendees) + 1 - elif not items: - continue - - page.tr() - page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan) - - # Handle datetimes specially. - - if name in ["DTSTART", "DTEND"]: - - # Obtain the datetime. - - if name == "DTSTART": - dt, attr = dtstart, dtstart_attr - - # Where no end datetime exists, use the start datetime as the - # basis of any potential datetime specified if dt-control is - # set. - - else: - dt, attr = dtend or dtstart, dtend_attr or dtstart_attr - - self.show_datetime_controls(obj, dt, attr, name == "DTSTART") - - page.tr.close() - - # Handle the summary specially. - - elif name == "SUMMARY": - value = args.get("summary", [obj.get_value(name)])[0] - - page.td() - if is_organiser: - page.input(name="summary", type="text", value=value, size=80) - else: - page.add(value) - page.td.close() - page.tr.close() - - # Handle potentially many values. - - else: - first = True - - for i, (value, attr) in enumerate(items): - if not first: - page.tr() - else: - first = False - - if name == "ATTENDEE": - value = get_uri(value) - - page.td(class_="objectvalue") - page.add(value) - page.add(" ") - - partstat = attr.get("PARTSTAT") - if value == self.user: - self._show_menu("partstat", partstat, self.partstat_items, "partstat") - else: - page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") - - if is_organiser: - if value in args.get("remove", []): - page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") - else: - page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") - page.label("Remove", for_="remove-%d" % i, class_="remove") - page.label("Uninvited", for_="remove-%d" % i, class_="removed") - - else: - page.td(class_="objectvalue") - page.add(value) - - page.td.close() - page.tr.close() - - # Allow more attendees to be specified. - - if is_organiser and name == "ATTENDEE": - for i, attendee in enumerate(new_attendees): - if not first: - page.tr() - else: - first = False - - page.td() - page.input(name="added", type="value", value=attendee) - page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") - page.label("Remove", for_="removenew-%d" % i, class_="remove") - page.td.close() - page.tr.close() - - if not first: - page.tr() - - page.td() - page.input(name="attendee", type="value", value=new_attendee) - page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") - page.label("Add", for_="add-%d" % i, class_="add") - page.td.close() - page.tr.close() - - page.tbody.close() - page.table.close() - - self.show_recurrences(obj) - self.show_conflicting_events(uid, obj) - self.show_request_controls(obj) - - page.form.close() - - def show_object_datetime_controls(self, start, end, index=None): - - """ - Show datetime-related controls if already active or if an object needs - them for the given 'start' to 'end' period. The given 'index' is used to - parameterise individual controls for dynamic manipulation. - """ - - page = self.page - args = self.env.get_args() - sn = self._suffixed_name - ssn = self._simple_suffixed_name - - # Add a dynamic stylesheet to permit the controls to modify the display. - # NOTE: The style details need to be coordinated with the static - # NOTE: stylesheet. - - if index is not None: - page.style(type="text/css") - - # Unlike the rules for object properties, these affect recurrence - # properties. - - page.add("""\ -input#dttimes-enable-%(index)d, -input#dtend-enable-%(index)d, -input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, -input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, -input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, -input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { - display: none; -}""" % {"index" : index}) - - page.style.close() - - dtend_control = args.get(ssn("dtend-control", "recur", index), []) - dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) - - dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control - dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control - - initial_load = not args.has_key("editing") - - dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) - dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) - - if dtend_enabled: - page.input(name=ssn("dtend-control", "recur", index), type="checkbox", - value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index), checked="checked") - else: - page.input(name=ssn("dtend-control", "recur", index), type="checkbox", - value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index)) - - if dttimes_enabled: - page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", - value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index), checked="checked") - else: - page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", - value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index)) - - def show_datetime_controls(self, obj, dt, attr, show_start): - - """ - Show datetime details from the given 'obj' for the datetime 'dt' and - attributes 'attr', showing start details if 'show_start' is set - to a true value. Details will appear as controls for organisers and - labels for attendees. - """ - - page = self.page - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - - # Change end dates to refer to the actual dates, not the iCalendar - # "next day" dates. - - if not show_start and not isinstance(dt, datetime): - dt -= timedelta(1) - - # Show controls for editing as organiser. - - if is_organiser: - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) - - if show_start: - page.div(class_="dt enabled") - self._show_date_controls("dtstart", dt, attr.get("TZID")) - page.br() - page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") - page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") - page.div.close() - - else: - page.div(class_="dt disabled") - page.label("Specify end date", for_="dtend-enable", class_="enable") - page.div.close() - page.div(class_="dt enabled") - self._show_date_controls("dtend", dt, attr.get("TZID")) - page.br() - page.label("End on same day", for_="dtend-enable", class_="disable") - page.div.close() - - page.td.close() - - # Show a label as attendee. - - else: - page.td(self.format_datetime(dt, "full")) - - def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start): - - """ - Show datetime details from the given 'obj' for the recurrence having the - given 'index', with the recurrence period described by the datetimes - 'start' and 'end', indicating the 'origin' of the period from the event - details, employing any 'recurrenceid' and 'recurrenceids' for the object - to configure the displayed information. - - If 'show_start' is set to a true value, the start details will be shown; - otherwise, the end details will be shown. - """ - - page = self.page - sn = self._suffixed_name - ssn = self._simple_suffixed_name - - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - - # Change end dates to refer to the actual dates, not the iCalendar - # "next day" dates. - - if not isinstance(end, datetime): - end -= timedelta(1) - - start_utc = format_datetime(to_timezone(start, "UTC")) - replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" - css = " ".join([ - replaced, - recurrenceid and start_utc == recurrenceid and "affected" or "" - ]) - - # Show controls for editing as organiser. - - if is_organiser and not replaced and origin != "RRULE": - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) - - if show_start: - page.div(class_="dt enabled") - self._show_date_controls(ssn("dtstart", "recur", index), start, None, index) - page.br() - page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") - page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") - page.div.close() - - else: - page.div(class_="dt disabled") - page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") - page.div.close() - page.div(class_="dt enabled") - self._show_date_controls(ssn("dtend", "recur", index), end, None, index) - page.br() - page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") - page.div.close() - - page.td.close() - - # Show label as attendee. - - else: - page.td(self.format_datetime(show_start and start or end, "long"), class_=css) - - def show_recurrences(self, obj): - - "Show recurrences for the object having the given representation 'obj'." - - page = self.page - is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user - - # Obtain any parent object if this object is a specific recurrence. - - uid = obj.get_value("UID") - recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) - - if recurrenceid: - obj = self._get_object(uid) - if not obj: - return - - page.p("This event modifies a recurring event.") - - # Obtain the periods associated with the event in the user's time zone. - - periods = obj.get_periods(self.get_tzid(), self.get_window_end(), origin=True) - recurrenceids = self._get_recurrences(uid) - - if len(periods) == 1: - return - - if is_organiser: - page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) - else: - page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) - - # Determine whether any periods are explicitly created or are part of a - # rule. - - explicit_periods = filter(lambda t: t[2] != "RRULE", periods) - - # Show each recurrence in a separate table if editable. - - if is_organiser and explicit_periods: - - for index, (start, end, origin) in enumerate(periods[1:]): - - # Isolate the controls from neighbouring tables. - - page.div() - - self.show_object_datetime_controls(start, end, index) - - # NOTE: Need to customise the TH classes according to errors and - # NOTE: index information. - - page.table(cellspacing=5, cellpadding=5, class_="recurrence") - page.caption("Occurrence") - page.tbody() - page.tr() - page.th("Start", class_="objectheading start") - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) - page.tr.close() - page.tr() - page.th("End", class_="objectheading end") - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) - page.tr.close() - page.tbody.close() - page.table.close() - - page.div.close() - - # Otherwise, use a compact single table. - - else: - page.table(cellspacing=5, cellpadding=5, class_="recurrence") - page.caption("Occurrences") - page.thead() - page.tr() - page.th("Start", class_="objectheading start") - page.th("End", class_="objectheading end") - page.tr.close() - page.thead.close() - page.tbody() - - # Show only subsequent periods if organiser, since the principal - # period will be the start and end datetimes. - - for index, (start, end, origin) in enumerate(is_organiser and periods[1:] or periods): - page.tr() - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) - self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) - page.tr.close() - page.tbody.close() - page.table.close() - - def show_conflicting_events(self, uid, obj): - - """ - Show conflicting events for the object having the given 'uid' and - representation 'obj'. - """ - - page = self.page - - # Obtain the user's timezone. - - tzid = self.get_tzid() - periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) - - # Indicate whether there are conflicting events. - - freebusy = self.store.get_freebusy(self.user) - - if freebusy: - - # Obtain any time zone details from the suggested event. - - _dtstart, attr = obj.get_item("DTSTART") - tzid = attr.get("TZID", tzid) - - # Show any conflicts. - - conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid] - - if conflicts: - page.p("This event conflicts with others:") - - page.table(cellspacing=5, cellpadding=5, class_="conflicts") - page.thead() - page.tr() - page.th("Event") - page.th("Start") - page.th("End") - page.tr.close() - page.thead.close() - page.tbody() - - for t in conflicts: - start, end, found_uid, transp, found_recurrenceid, summary = t[:6] - - # Provide details of any conflicting event. - - start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") - end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") - - page.tr() - - # Show the event summary for the conflicting event. - - page.td() - page.a(summary, href=self.link_to(found_uid)) - page.td.close() - - page.td(start) - page.td(end) - - page.tr.close() - - page.tbody.close() - page.table.close() - - def show_requests_on_page(self): - - "Show requests for the current user." - - page = self.page - - # NOTE: This list could be more informative, but it is envisaged that - # NOTE: the requests would be visited directly anyway. - - requests = self._get_requests() - - page.div(id="pending-requests") - - if requests: - page.p("Pending requests:") - - page.ul() - - for uid, recurrenceid in requests: - obj = self._get_object(uid, recurrenceid) - if obj: - page.li() - page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) - page.li.close() - - page.ul.close() - - else: - page.p("There are no pending requests.") - - page.div.close() - - def show_participants_on_page(self): - - "Show participants for scheduling purposes." - - page = self.page - args = self.env.get_args() - participants = args.get("participants", []) - - try: - for name, value in args.items(): - if name.startswith("remove-participant-"): - i = int(name[len("remove-participant-"):]) - del participants[i] - break - except ValueError: - pass - - # Trim empty participants. - - while participants and not participants[-1].strip(): - participants.pop() - - # Show any specified participants together with controls to remove and - # add participants. - - page.div(id="participants") - - page.p("Participants for scheduling:") - - for i, participant in enumerate(participants): - page.p() - page.input(name="participants", type="text", value=participant) - page.input(name="remove-participant-%d" % i, type="submit", value="Remove") - page.p.close() - - page.p() - page.input(name="participants", type="text") - page.input(name="add-participant", type="submit", value="Add") - page.p.close() - - page.div.close() - - return participants - - # Full page output methods. - - def show_object(self, path_info): - - "Show an object request using the given 'path_info' for the current user." - - uid, recurrenceid = self._get_identifiers(path_info) - obj = self._get_object(uid, recurrenceid) - - if not obj: - return False - - error = self.handle_request(uid, recurrenceid, obj) - - if not error: - return True - - self.new_page(title="Event") - self.show_object_on_page(uid, obj, error) - - return True - - def show_calendar(self): - - "Show the calendar for the current user." - - handled = self.handle_newevent() - - self.new_page(title="Calendar") - page = self.page - - # Form controls are used in various places on the calendar page. - - page.form(method="POST") - - self.show_requests_on_page() - participants = self.show_participants_on_page() - - # Show a button for scheduling a new event. - - page.p(class_="controls") - page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") - page.p.close() - - # Show controls for hiding empty days and busy slots. - # The positioning of the control, paragraph and table are important here. - - page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") - page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") - - page.p(class_="controls") - page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") - page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") - page.label("Show empty days", for_="showdays", class_="showdays disable") - page.label("Hide empty days", for_="showdays", class_="showdays enable") - page.input(name="reset", type="submit", value="Clear selections", id="reset") - page.label("Clear selections", for_="reset", class_="reset") - page.p.close() - - freebusy = self.store.get_freebusy(self.user) - - if not freebusy: - page.p("No events scheduled.") - return - - # Obtain the user's timezone. - - tzid = self.get_tzid() - - # Day view: start at the earliest known day and produce days until the - # latest known day, perhaps with expandable sections of empty days. - - # Month view: start at the earliest known month and produce months until - # the latest known month, perhaps with expandable sections of empty - # months. - - # Details of users to invite to new events could be superimposed on the - # calendar. - - # Requests are listed and linked to their tentative positions in the - # calendar. Other participants are also shown. - - request_summary = self._get_request_summary() - - period_groups = [request_summary, freebusy] - period_group_types = ["request", "freebusy"] - period_group_sources = ["Pending requests", "Your schedule"] - - for i, participant in enumerate(participants): - period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) - period_group_types.append("freebusy-part%d" % i) - period_group_sources.append(participant) - - groups = [] - group_columns = [] - group_types = period_group_types - group_sources = period_group_sources - all_points = set() - - # Obtain time point information for each group of periods. - - for periods in period_groups: - periods = convert_periods(periods, tzid) - - # Get the time scale with start and end points. - - scale = get_scale(periods) - - # Get the time slots for the periods. - - slots = get_slots(scale) - - # Add start of day time points for multi-day periods. - - add_day_start_points(slots, tzid) - - # Record the slots and all time points employed. - - groups.append(slots) - all_points.update([point for point, active in slots]) - - # Partition the groups into days. - - days = {} - partitioned_groups = [] - partitioned_group_types = [] - partitioned_group_sources = [] - - for slots, group_type, group_source in zip(groups, group_types, group_sources): - - # Propagate time points to all groups of time slots. - - add_slots(slots, all_points) - - # Count the number of columns employed by the group. - - columns = 0 - - # Partition the time slots by day. - - partitioned = {} - - for day, day_slots in partition_by_day(slots).items(): - - # Construct a list of time intervals within the day. - - intervals = [] - last = None - - for point, active in day_slots: - columns = max(columns, len(active)) - if last: - intervals.append((last, point)) - last = point - - if last: - intervals.append((last, None)) - - if not days.has_key(day): - days[day] = set() - - # Convert each partition to a mapping from points to active - # periods. - - partitioned[day] = dict(day_slots) - - # Record the divisions or intervals within each day. - - days[day].update(intervals) - - # Only include the requests column if it provides objects. - - if group_type != "request" or columns: - group_columns.append(columns) - partitioned_groups.append(partitioned) - partitioned_group_types.append(group_type) - partitioned_group_sources.append(group_source) - - # Add empty days. - - add_empty_days(days, tzid) - - # Show the controls permitting day selection. - - self.show_calendar_day_controls(days) - - # Show the calendar itself. - - page.table(cellspacing=5, cellpadding=5, class_="calendar") - self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) - self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) - page.table.close() - - # End the form region. - - page.form.close() - - # More page fragment methods. - - def show_calendar_day_controls(self, days): - - "Show controls for the given 'days' in the calendar." - - page = self.page - slots = self.env.get_args().get("slot", []) - - for day in days: - value, identifier = self._day_value_and_identifier(day) - self._slot_selector(value, identifier, slots) - - # Generate a dynamic stylesheet to allow day selections to colour - # specific days. - # NOTE: The style details need to be coordinated with the static - # NOTE: stylesheet. - - page.style(type="text/css") - - for day in days: - daystr = format_datetime(day) - page.add("""\ -input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, -input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { - background-color: #5f4; - text-decoration: underline; -} -""" % (daystr, daystr, daystr, daystr)) - - page.style.close() - - def show_calendar_participant_headings(self, group_types, group_sources, group_columns): - - """ - Show headings for the participants and other scheduling contributors, - defined by 'group_types', 'group_sources' and 'group_columns'. - """ - - page = self.page - - page.colgroup(span=1, id="columns-timeslot") - - for group_type, columns in zip(group_types, group_columns): - page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) - - page.thead() - page.tr() - page.th("", class_="emptyheading") - - for group_type, source, columns in zip(group_types, group_sources, group_columns): - page.th(source, - class_=(group_type == "request" and "requestheading" or "participantheading"), - colspan=max(columns, 1)) - - page.tr.close() - page.thead.close() - - def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): - - """ - Show calendar days, defined by a collection of 'days', the contributing - period information as 'partitioned_groups' (partitioned by day), the - 'partitioned_group_types' indicating the kind of contribution involved, - and the 'group_columns' defining the number of columns in each group. - """ - - page = self.page - - # Determine the number of columns required. Where participants provide - # no columns for events, one still needs to be provided for the - # participant itself. - - all_columns = sum([max(columns, 1) for columns in group_columns]) - - # Determine the days providing time slots. - - all_days = days.items() - all_days.sort() - - # Produce a heading and time points for each day. - - for day, intervals in all_days: - groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] - is_empty = True - - for slots in groups_for_day: - if not slots: - continue - - for active in slots.values(): - if active: - is_empty = False - break - - page.thead(class_="separator%s" % (is_empty and " empty" or "")) - page.tr() - page.th(class_="dayheading container", colspan=all_columns+1) - self._day_heading(day) - page.th.close() - page.tr.close() - page.thead.close() - - page.tbody(class_="points%s" % (is_empty and " empty" or "")) - self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) - page.tbody.close() - - def show_calendar_points(self, intervals, groups, group_types, group_columns): - - """ - Show the time 'intervals' along with period information from the given - 'groups', having the indicated 'group_types', each with the number of - columns given by 'group_columns'. - """ - - page = self.page - - # Obtain the user's timezone. - - tzid = self.get_tzid() - - # Produce a row for each interval. - - intervals = list(intervals) - intervals.sort() - - for point, endpoint in intervals: - continuation = point == get_start_of_day(point, tzid) - - # Some rows contain no period details and are marked as such. - - have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) - - css = " ".join([ - "slot", - have_active and "busy" or "empty", - continuation and "daystart" or "" - ]) - - page.tr(class_=css) - page.th(class_="timeslot") - self._time_point(point, endpoint) - page.th.close() - - # Obtain slots for the time point from each group. - - for columns, slots, group_type in zip(group_columns, groups, group_types): - active = slots and slots.get(point) - - # Where no periods exist for the given time interval, generate - # an empty cell. Where a participant provides no periods at all, - # the colspan is adjusted to be 1, not 0. - - if not active: - page.td(class_="empty container", colspan=max(columns, 1)) - self._empty_slot(point, endpoint) - page.td.close() - continue - - slots = slots.items() - slots.sort() - spans = get_spans(slots) - - empty = 0 - - # Show a column for each active period. - - for t in active: - if t and len(t) >= 2: - - # Flush empty slots preceding this one. - - if empty: - page.td(class_="empty container", colspan=empty) - self._empty_slot(point, endpoint) - page.td.close() - empty = 0 - - start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) - span = spans[key] - - # Produce a table cell only at the start of the period - # or when continued at the start of a day. - - if point == start or continuation: - - has_continued = continuation and point != start - will_continue = not ends_on_same_day(point, end, tzid) - is_organiser = organiser == self.user - - css = " ".join([ - "event", - has_continued and "continued" or "", - will_continue and "continues" or "", - is_organiser and "organising" or "attending" - ]) - - # Only anchor the first cell of events. - # Need to only anchor the first period for a recurring - # event. - - html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") - - if point == start and html_id not in self.html_ids: - page.td(class_=css, rowspan=span, id=html_id) - self.html_ids.add(html_id) - else: - page.td(class_=css, rowspan=span) - - # Only link to events if they are not being - # updated by requests. - - if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": - page.span(summary or "(Participant is busy)") - else: - page.a(summary, href=self.link_to(uid, recurrenceid)) - - page.td.close() - else: - empty += 1 - - # Pad with empty columns. - - empty = columns - len(active) - - if empty: - page.td(class_="empty container", colspan=empty) - self._empty_slot(point, endpoint) - page.td.close() - - page.tr.close() - - def _day_heading(self, day): - - """ - Generate a heading for 'day' of the following form: - - - """ - - page = self.page - daystr = format_datetime(day) - value, identifier = self._day_value_and_identifier(day) - page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) - - def _time_point(self, point, endpoint): - - """ - Generate headings for the 'point' to 'endpoint' period of the following - form: - - - 10:00:00 CET - """ - - page = self.page - tzid = self.get_tzid() - daystr = format_datetime(point.date()) - value, identifier = self._slot_value_and_identifier(point, endpoint) - slots = self.env.get_args().get("slot", []) - self._slot_selector(value, identifier, slots) - page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) - page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") - - def _slot_selector(self, value, identifier, slots): - - """ - Provide a timeslot control having the given 'value', employing the - indicated HTML 'identifier', and using the given 'slots' collection - to select any control whose 'value' is in this collection, unless the - "reset" request parameter has been asserted. - """ - - reset = self.env.get_args().has_key("reset") - page = self.page - if not reset and value in slots: - page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") - else: - page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") - - def _empty_slot(self, point, endpoint): - - "Show an empty slot label for the given 'point' and 'endpoint'." - - page = self.page - value, identifier = self._slot_value_and_identifier(point, endpoint) - page.label("Select/deselect period", class_="newevent popup", for_=identifier) - - def _day_value_and_identifier(self, day): - - "Return a day value and HTML identifier for the given 'day'." - - value = "%s-" % format_datetime(day) - identifier = "day-%s" % value - return value, identifier - - def _slot_value_and_identifier(self, point, endpoint): - - """ - Return a slot value and HTML identifier for the given 'point' and - 'endpoint'. - """ - - value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") - identifier = "slot-%s" % value - return value, identifier - - def _show_menu(self, name, default, items, class_="", index=None): - - """ - Show a select menu having the given 'name', set to the given 'default', - providing the given (value, label) 'items', and employing the given CSS - 'class_' if specified. - """ - - page = self.page - values = self.env.get_args().get(name, [default]) - if index is not None: - values = values[index:] - values = values and values[0:1] or [default] - - page.select(name=name, class_=class_) - for v, label in items: - if v is None: - continue - if v in values: - page.option(label, value=v, selected="selected") - else: - page.option(label, value=v) - page.select.close() - - def _show_date_controls(self, name, default, tzid, index=None): - - """ - Show date controls for a field with the given 'name' and 'default' value - and 'tzid'. - """ - - page = self.page - args = self.env.get_args() - - event_tzid = tzid or self.get_tzid() - - # Show dates for up to one week around the current date. - - base = to_date(default) - items = [] - for i in range(-7, 8): - d = base + timedelta(i) - items.append((format_datetime(d), self.format_date(d, "full"))) - - self._show_menu("%s-date" % name, format_datetime(base), items, index=index) - - # Show time details. - - default_time = isinstance(default, datetime) and default or None - - hour = args.get("%s-hour" % name, [])[index or 0:] - hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) - minute = args.get("%s-minute" % name, [])[index or 0:] - minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) - second = args.get("%s-second" % name, [])[index or 0:] - second = second and second[0] or "%02d" % (default_time and default_time.second or 0) - - page.span(class_="time enabled") - page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) - page.add(":") - page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) - page.add(":") - page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) - page.add(" ") - self._show_timezone_menu("%s-tzid" % name, event_tzid, index) - page.span.close() - - def _show_timezone_menu(self, name, default, index=None): - - """ - Show timezone controls using a menu with the given 'name', set to the - given 'default' unless a field of the given 'name' provides a value. - """ - - entries = [(tzid, tzid) for tzid in pytz.all_timezones] - self._show_menu(name, default, entries, index=index) - - # Incoming HTTP request direction. - def select_action(self): "Select the desired action and show the result." @@ -1932,8 +42,8 @@ path_info = self.env.get_path_info().strip("/") if not path_info: - self.show_calendar() - elif self.show_object(path_info): + CalendarPage(self).show() + elif EventPage(self).show(path_info): pass else: self.no_page() diff -r 9b8237cc00bd -r 8a940a370579 imipweb/calendar.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imipweb/calendar.py Thu Mar 26 16:11:46 2015 +0100 @@ -0,0 +1,722 @@ +#!/usr/bin/env python + +""" +A Web interface to an event calendar. + +Copyright (C) 2014, 2015 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from datetime import datetime +from imiptools.data import get_address, get_uri, uri_values +from imiptools.dates import format_datetime, get_datetime, \ + get_datetime_item, get_end_of_day, get_start_of_day, \ + get_start_of_next_day, get_timestamp, ends_on_same_day, \ + to_timezone +from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ + convert_periods, get_freebusy_details, \ + get_scale, get_slots, get_spans, partition_by_day +from imipweb.resource import Resource + +class CalendarPage(Resource): + + "A request handler for the calendar page." + + # Request logic methods. + + def handle_newevent(self): + + """ + Handle any new event operation, creating a new event and redirecting to + the event page for further activity. + """ + + # Handle a submitted form. + + args = self.env.get_args() + + if not args.has_key("newevent"): + return + + # Create a new event using the available information. + + slots = args.get("slot", []) + participants = args.get("participants", []) + + if not slots: + return + + # Obtain the user's timezone. + + tzid = self.get_tzid() + + # Coalesce the selected slots. + + slots.sort() + coalesced = [] + last = None + + for slot in slots: + start, end = slot.split("-") + start = get_datetime(start, {"TZID" : tzid}) + end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid) + + if last: + last_start, last_end = last + + # Merge adjacent dates and datetimes. + + if start == last_end or \ + not isinstance(start, datetime) and \ + get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid): + + last = last_start, end + continue + + # Handle datetimes within dates. + # Datetime periods are within single days and are therefore + # discarded. + + elif not isinstance(last_start, datetime) and \ + get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid): + + continue + + # Add separate dates and datetimes. + + else: + coalesced.append(last) + + last = start, end + + if last: + coalesced.append(last) + + # Invent a unique identifier. + + utcnow = get_timestamp() + uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) + + # Create a calendar object and store it as a request. + + record = [] + rwrite = record.append + + # Define a single occurrence if only one coalesced slot exists. + + start, end = coalesced[0] + start_value, start_attr = get_datetime_item(start, tzid) + end_value, end_attr = get_datetime_item(end, tzid) + + rwrite(("UID", {}, uid)) + rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) + rwrite(("DTSTAMP", {}, utcnow)) + rwrite(("DTSTART", start_attr, start_value)) + rwrite(("DTEND", end_attr, end_value)) + rwrite(("ORGANIZER", {}, self.user)) + + participants = uri_values(filter(None, participants)) + + for participant in participants: + rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant)) + + if self.user not in participants: + rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user)) + + # Define additional occurrences if many slots are defined. + + rdates = [] + + for start, end in coalesced[1:]: + start_value, start_attr = get_datetime_item(start, tzid) + end_value, end_attr = get_datetime_item(end, tzid) + rdates.append("%s/%s" % (start_value, end_value)) + + if rdates: + rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates)) + + node = ("VEVENT", {}, record) + + self.store.set_event(self.user, uid, None, node=node) + self.store.queue_request(self.user, uid) + + # Redirect to the object (or the first of the objects), where instead of + # attendee controls, there will be organiser controls. + + self.redirect(self.link_to(uid)) + + # Page fragment methods. + + def show_requests_on_page(self): + + "Show requests for the current user." + + page = self.page + + # NOTE: This list could be more informative, but it is envisaged that + # NOTE: the requests would be visited directly anyway. + + requests = self._get_requests() + + page.div(id="pending-requests") + + if requests: + page.p("Pending requests:") + + page.ul() + + for uid, recurrenceid in requests: + obj = self._get_object(uid, recurrenceid) + if obj: + page.li() + page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or "")) + page.li.close() + + page.ul.close() + + else: + page.p("There are no pending requests.") + + page.div.close() + + def show_participants_on_page(self): + + "Show participants for scheduling purposes." + + page = self.page + args = self.env.get_args() + participants = args.get("participants", []) + + try: + for name, value in args.items(): + if name.startswith("remove-participant-"): + i = int(name[len("remove-participant-"):]) + del participants[i] + break + except ValueError: + pass + + # Trim empty participants. + + while participants and not participants[-1].strip(): + participants.pop() + + # Show any specified participants together with controls to remove and + # add participants. + + page.div(id="participants") + + page.p("Participants for scheduling:") + + for i, participant in enumerate(participants): + page.p() + page.input(name="participants", type="text", value=participant) + page.input(name="remove-participant-%d" % i, type="submit", value="Remove") + page.p.close() + + page.p() + page.input(name="participants", type="text") + page.input(name="add-participant", type="submit", value="Add") + page.p.close() + + page.div.close() + + return participants + + # Full page output methods. + + def show(self): + + "Show the calendar for the current user." + + handled = self.handle_newevent() + + self.new_page(title="Calendar") + page = self.page + + # Form controls are used in various places on the calendar page. + + page.form(method="POST") + + self.show_requests_on_page() + participants = self.show_participants_on_page() + + # Show a button for scheduling a new event. + + page.p(class_="controls") + page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N") + page.p.close() + + # Show controls for hiding empty days and busy slots. + # The positioning of the control, paragraph and table are important here. + + page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D") + page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B") + + page.p(class_="controls") + page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable") + page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable") + page.label("Show empty days", for_="showdays", class_="showdays disable") + page.label("Hide empty days", for_="showdays", class_="showdays enable") + page.input(name="reset", type="submit", value="Clear selections", id="reset") + page.label("Clear selections", for_="reset", class_="reset") + page.p.close() + + freebusy = self.store.get_freebusy(self.user) + + if not freebusy: + page.p("No events scheduled.") + return + + # Obtain the user's timezone. + + tzid = self.get_tzid() + + # Day view: start at the earliest known day and produce days until the + # latest known day, perhaps with expandable sections of empty days. + + # Month view: start at the earliest known month and produce months until + # the latest known month, perhaps with expandable sections of empty + # months. + + # Details of users to invite to new events could be superimposed on the + # calendar. + + # Requests are listed and linked to their tentative positions in the + # calendar. Other participants are also shown. + + request_summary = self._get_request_summary() + + period_groups = [request_summary, freebusy] + period_group_types = ["request", "freebusy"] + period_group_sources = ["Pending requests", "Your schedule"] + + for i, participant in enumerate(participants): + period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant))) + period_group_types.append("freebusy-part%d" % i) + period_group_sources.append(participant) + + groups = [] + group_columns = [] + group_types = period_group_types + group_sources = period_group_sources + all_points = set() + + # Obtain time point information for each group of periods. + + for periods in period_groups: + periods = convert_periods(periods, tzid) + + # Get the time scale with start and end points. + + scale = get_scale(periods) + + # Get the time slots for the periods. + + slots = get_slots(scale) + + # Add start of day time points for multi-day periods. + + add_day_start_points(slots, tzid) + + # Record the slots and all time points employed. + + groups.append(slots) + all_points.update([point for point, active in slots]) + + # Partition the groups into days. + + days = {} + partitioned_groups = [] + partitioned_group_types = [] + partitioned_group_sources = [] + + for slots, group_type, group_source in zip(groups, group_types, group_sources): + + # Propagate time points to all groups of time slots. + + add_slots(slots, all_points) + + # Count the number of columns employed by the group. + + columns = 0 + + # Partition the time slots by day. + + partitioned = {} + + for day, day_slots in partition_by_day(slots).items(): + + # Construct a list of time intervals within the day. + + intervals = [] + last = None + + for point, active in day_slots: + columns = max(columns, len(active)) + if last: + intervals.append((last, point)) + last = point + + if last: + intervals.append((last, None)) + + if not days.has_key(day): + days[day] = set() + + # Convert each partition to a mapping from points to active + # periods. + + partitioned[day] = dict(day_slots) + + # Record the divisions or intervals within each day. + + days[day].update(intervals) + + # Only include the requests column if it provides objects. + + if group_type != "request" or columns: + group_columns.append(columns) + partitioned_groups.append(partitioned) + partitioned_group_types.append(group_type) + partitioned_group_sources.append(group_source) + + # Add empty days. + + add_empty_days(days, tzid) + + # Show the controls permitting day selection. + + self.show_calendar_day_controls(days) + + # Show the calendar itself. + + page.table(cellspacing=5, cellpadding=5, class_="calendar") + self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) + self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns) + page.table.close() + + # End the form region. + + page.form.close() + + # More page fragment methods. + + def show_calendar_day_controls(self, days): + + "Show controls for the given 'days' in the calendar." + + page = self.page + slots = self.env.get_args().get("slot", []) + + for day in days: + value, identifier = self._day_value_and_identifier(day) + self._slot_selector(value, identifier, slots) + + # Generate a dynamic stylesheet to allow day selections to colour + # specific days. + # NOTE: The style details need to be coordinated with the static + # NOTE: stylesheet. + + page.style(type="text/css") + + for day in days: + daystr = format_datetime(day) + page.add("""\ +input.newevent.selector#day-%s-:checked ~ table label.day.day-%s, +input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s { + background-color: #5f4; + text-decoration: underline; +} +""" % (daystr, daystr, daystr, daystr)) + + page.style.close() + + def show_calendar_participant_headings(self, group_types, group_sources, group_columns): + + """ + Show headings for the participants and other scheduling contributors, + defined by 'group_types', 'group_sources' and 'group_columns'. + """ + + page = self.page + + page.colgroup(span=1, id="columns-timeslot") + + for group_type, columns in zip(group_types, group_columns): + page.colgroup(span=max(columns, 1), id="columns-%s" % group_type) + + page.thead() + page.tr() + page.th("", class_="emptyheading") + + for group_type, source, columns in zip(group_types, group_sources, group_columns): + page.th(source, + class_=(group_type == "request" and "requestheading" or "participantheading"), + colspan=max(columns, 1)) + + page.tr.close() + page.thead.close() + + def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): + + """ + Show calendar days, defined by a collection of 'days', the contributing + period information as 'partitioned_groups' (partitioned by day), the + 'partitioned_group_types' indicating the kind of contribution involved, + and the 'group_columns' defining the number of columns in each group. + """ + + page = self.page + + # Determine the number of columns required. Where participants provide + # no columns for events, one still needs to be provided for the + # participant itself. + + all_columns = sum([max(columns, 1) for columns in group_columns]) + + # Determine the days providing time slots. + + all_days = days.items() + all_days.sort() + + # Produce a heading and time points for each day. + + for day, intervals in all_days: + groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] + is_empty = True + + for slots in groups_for_day: + if not slots: + continue + + for active in slots.values(): + if active: + is_empty = False + break + + page.thead(class_="separator%s" % (is_empty and " empty" or "")) + page.tr() + page.th(class_="dayheading container", colspan=all_columns+1) + self._day_heading(day) + page.th.close() + page.tr.close() + page.thead.close() + + page.tbody(class_="points%s" % (is_empty and " empty" or "")) + self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) + page.tbody.close() + + def show_calendar_points(self, intervals, groups, group_types, group_columns): + + """ + Show the time 'intervals' along with period information from the given + 'groups', having the indicated 'group_types', each with the number of + columns given by 'group_columns'. + """ + + page = self.page + + # Obtain the user's timezone. + + tzid = self.get_tzid() + + # Produce a row for each interval. + + intervals = list(intervals) + intervals.sort() + + for point, endpoint in intervals: + continuation = point == get_start_of_day(point, tzid) + + # Some rows contain no period details and are marked as such. + + have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None) + + css = " ".join([ + "slot", + have_active and "busy" or "empty", + continuation and "daystart" or "" + ]) + + page.tr(class_=css) + page.th(class_="timeslot") + self._time_point(point, endpoint) + page.th.close() + + # Obtain slots for the time point from each group. + + for columns, slots, group_type in zip(group_columns, groups, group_types): + active = slots and slots.get(point) + + # Where no periods exist for the given time interval, generate + # an empty cell. Where a participant provides no periods at all, + # the colspan is adjusted to be 1, not 0. + + if not active: + page.td(class_="empty container", colspan=max(columns, 1)) + self._empty_slot(point, endpoint) + page.td.close() + continue + + slots = slots.items() + slots.sort() + spans = get_spans(slots) + + empty = 0 + + # Show a column for each active period. + + for t in active: + if t and len(t) >= 2: + + # Flush empty slots preceding this one. + + if empty: + page.td(class_="empty container", colspan=empty) + self._empty_slot(point, endpoint) + page.td.close() + empty = 0 + + start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t) + span = spans[key] + + # Produce a table cell only at the start of the period + # or when continued at the start of a day. + + if point == start or continuation: + + has_continued = continuation and point != start + will_continue = not ends_on_same_day(point, end, tzid) + is_organiser = organiser == self.user + + css = " ".join([ + "event", + has_continued and "continued" or "", + will_continue and "continues" or "", + is_organiser and "organising" or "attending" + ]) + + # Only anchor the first cell of events. + # Need to only anchor the first period for a recurring + # event. + + html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "") + + if point == start and html_id not in self.html_ids: + page.td(class_=css, rowspan=span, id=html_id) + self.html_ids.add(html_id) + else: + page.td(class_=css, rowspan=span) + + # Only link to events if they are not being + # updated by requests. + + if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request": + page.span(summary or "(Participant is busy)") + else: + page.a(summary, href=self.link_to(uid, recurrenceid)) + + page.td.close() + else: + empty += 1 + + # Pad with empty columns. + + empty = columns - len(active) + + if empty: + page.td(class_="empty container", colspan=empty) + self._empty_slot(point, endpoint) + page.td.close() + + page.tr.close() + + def _day_heading(self, day): + + """ + Generate a heading for 'day' of the following form: + + + """ + + page = self.page + daystr = format_datetime(day) + value, identifier = self._day_value_and_identifier(day) + page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier) + + def _time_point(self, point, endpoint): + + """ + Generate headings for the 'point' to 'endpoint' period of the following + form: + + + 10:00:00 CET + """ + + page = self.page + tzid = self.get_tzid() + daystr = format_datetime(point.date()) + value, identifier = self._slot_value_and_identifier(point, endpoint) + slots = self.env.get_args().get("slot", []) + self._slot_selector(value, identifier, slots) + page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) + page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint") + + def _slot_selector(self, value, identifier, slots): + + """ + Provide a timeslot control having the given 'value', employing the + indicated HTML 'identifier', and using the given 'slots' collection + to select any control whose 'value' is in this collection, unless the + "reset" request parameter has been asserted. + """ + + reset = self.env.get_args().has_key("reset") + page = self.page + if not reset and value in slots: + page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked") + else: + page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector") + + def _empty_slot(self, point, endpoint): + + "Show an empty slot label for the given 'point' and 'endpoint'." + + page = self.page + value, identifier = self._slot_value_and_identifier(point, endpoint) + page.label("Select/deselect period", class_="newevent popup", for_=identifier) + + def _day_value_and_identifier(self, day): + + "Return a day value and HTML identifier for the given 'day'." + + value = "%s-" % format_datetime(day) + identifier = "day-%s" % value + return value, identifier + + def _slot_value_and_identifier(self, point, endpoint): + + """ + Return a slot value and HTML identifier for the given 'point' and + 'endpoint'. + """ + + value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "") + identifier = "slot-%s" % value + return value, identifier + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 9b8237cc00bd -r 8a940a370579 imipweb/event.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imipweb/event.py Thu Mar 26 16:11:46 2015 +0100 @@ -0,0 +1,1060 @@ +#!/usr/bin/env python + +""" +A Web interface to a calendar event. + +Copyright (C) 2014, 2015 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from datetime import datetime, timedelta +from imiptools.client import update_attendees +from imiptools.data import get_uri, uri_dict, uri_values +from imiptools.dates import format_datetime, to_date, get_datetime, \ + get_datetime_item, get_period_item, \ + get_start_of_day, to_timezone +from imiptools.mail import Messenger +from imiptools.period import have_conflict +from imipweb.handler import ManagerHandler +from imipweb.resource import Resource +import pytz + +class EventPage(Resource): + + "A request handler for the event page." + + def __init__(self, resource=None, messenger=None): + Resource.__init__(self, resource) + self.messenger = messenger or Messenger() + + # Request logic methods. + + def handle_request(self, uid, recurrenceid, obj): + + """ + Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as + the object's representation, returning an error if one occurred, or None + if the request was successfully handled. + """ + + # Handle a submitted form. + + args = self.env.get_args() + + # Get the possible actions. + + reply = args.has_key("reply") + discard = args.has_key("discard") + invite = args.has_key("invite") + cancel = args.has_key("cancel") + save = args.has_key("save") + ignore = args.has_key("ignore") + + have_action = reply or discard or invite or cancel or save or ignore + + if not have_action: + return ["action"] + + # If ignoring the object, return to the calendar. + + if ignore: + self.redirect(self.env.get_path()) + return None + + # Update the object. + + if args.has_key("summary"): + obj["SUMMARY"] = [(args["summary"][0], {})] + + attendees = uri_dict(obj.get_value_map("ATTENDEE")) + + if args.has_key("partstat"): + if attendees.has_key(self.user): + attendees[self.user]["PARTSTAT"] = args["partstat"][0] + if attendees[self.user].has_key("RSVP"): + del attendees[self.user]["RSVP"] + + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + # Obtain the user's timezone and process datetime values. + + update = False + + if is_organiser: + periods, errors = self.handle_all_period_controls() + if errors: + return errors + elif periods: + self.set_period_in_object(obj, periods[0]) + self.set_periods_in_object(obj, periods[1:]) + + # Obtain any participants to be added or removed. + + removed = args.get("remove") + added = args.get("added") + + # Process any action. + + handled = True + + if reply or invite or cancel: + + handler = ManagerHandler(obj, self.user, self.messenger) + + # Process the object and remove it from the list of requests. + + if reply and handler.process_received_request(update) or \ + is_organiser and (invite or cancel) and \ + handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added): + + self.remove_request(uid, recurrenceid) + + # Save single user events. + + elif save: + to_cancel = update_attendees(obj, added, removed) + self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node()) + self.update_freebusy(uid, recurrenceid, obj) + self.remove_request(uid, recurrenceid) + + # Remove the request and the object. + + elif discard: + self.remove_from_freebusy(uid, recurrenceid) + self.remove_event(uid, recurrenceid) + self.remove_request(uid, recurrenceid) + + else: + handled = False + + # Upon handling an action, redirect to the main page. + + if handled: + self.redirect(self.env.get_path()) + + return None + + def handle_all_period_controls(self): + + """ + Handle datetime controls for a particular period, where 'index' may be + used to indicate a recurring period, or the main start and end datetimes + are handled. + """ + + args = self.env.get_args() + + periods = [] + + # Get the main period details. + + dtend_enabled = args.get("dtend-control", [None])[0] + dttimes_enabled = args.get("dttimes-control", [None])[0] + start_values = self.get_date_control_values("dtstart") + end_values = self.get_date_control_values("dtend") + + period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) + + if errors: + return None, errors + + periods.append(period) + + # Get the recurring period details. + + all_dtend_enabled = args.get("dtend-control-recur", []) + all_dttimes_enabled = args.get("dttimes-control-recur", []) + all_start_values = self.get_date_control_values("dtstart-recur", multiple=True) + all_end_values = self.get_date_control_values("dtend-recur", multiple=True) + + for index, (start_values, end_values, dtend_enabled, dttimes_enabled) in \ + enumerate(map(None, all_start_values, all_end_values, all_dtend_enabled, all_dttimes_enabled)): + + dtend_enabled = str(index) in all_dtend_enabled + dttimes_enabled = str(index) in all_dttimes_enabled + period, errors = self.handle_period_controls(start_values, end_values, dtend_enabled, dttimes_enabled) + + if errors: + return None, errors + + periods.append(period) + + return periods, None + + def handle_period_controls(self, start_values, end_values, dtend_enabled, dttimes_enabled): + + """ + Handle datetime controls for a particular period, described by the given + 'start_values' and 'end_values', with 'dtend_enabled' and + 'dttimes_enabled' affecting the usage of the provided values. + """ + + t = self.handle_date_control_values(start_values, dttimes_enabled) + if t: + dtstart, dtstart_attr = t + else: + return None, ["dtstart"] + + # Handle specified end datetimes. + + if dtend_enabled: + t = self.handle_date_control_values(end_values, dttimes_enabled) + if t: + dtend, dtend_attr = t + + # Convert end dates to iCalendar "next day" dates. + + if not isinstance(dtend, datetime): + dtend += timedelta(1) + else: + return None, ["dtend"] + + # Otherwise, treat the end date as the start date. Datetimes are + # handled by making the event occupy the rest of the day. + + else: + dtend = dtstart + timedelta(1) + dtend_attr = dtstart_attr + + if isinstance(dtstart, datetime): + dtend = get_start_of_day(dtend, attr["TZID"]) + + if dtstart >= dtend: + return None, ["dtstart", "dtend"] + + return ((dtstart, dtstart_attr), (dtend, dtend_attr)), None + + def handle_date_control_values(self, values, with_time=True): + + """ + Handle date control information for the given 'values', returning a + (datetime, attr) tuple, or None if the fields cannot be used to + construct a datetime object. + """ + + if not values or not values["date"]: + return None + elif with_time: + value = "%s%s" % (values["date"], values["time"]) + attr = {"TZID" : values["tzid"], "VALUE" : "DATE-TIME"} + dt = get_datetime(value, attr) + else: + attr = {"VALUE" : "DATE"} + dt = get_datetime(values["date"]) + + if dt: + return dt, attr + + return None + + def get_date_control_values(self, name, multiple=False): + + """ + Return a dictionary containing date, time and tzid entries for fields + starting with 'name'. + """ + + args = self.env.get_args() + + dates = args.get("%s-date" % name, []) + hours = args.get("%s-hour" % name, []) + minutes = args.get("%s-minute" % name, []) + seconds = args.get("%s-second" % name, []) + tzids = args.get("%s-tzid" % name, []) + + # Handle absent values by employing None values. + + field_values = map(None, dates, hours, minutes, seconds, tzids) + if not field_values and not multiple: + field_values = [(None, None, None, None, None)] + + all_values = [] + + for date, hour, minute, second, tzid in field_values: + + # Construct a usable dictionary of values. + + time = (hour or minute or second) and \ + "T%s%s%s" % ( + (hour or "").rjust(2, "0")[:2], + (minute or "").rjust(2, "0")[:2], + (second or "").rjust(2, "0")[:2] + ) or "" + + value = { + "date" : date, + "time" : time, + "tzid" : tzid or self.get_tzid() + } + + # Return a single value or append to a collection of all values. + + if not multiple: + return value + else: + all_values.append(value) + + return all_values + + def set_period_in_object(self, obj, period): + + "Set in the given 'obj' the given 'period' as the main start and end." + + (dtstart, dtstart_attr), (dtend, dtend_attr) = period + + return self.set_datetime_in_object(dtstart, dtstart_attr.get("TZID"), "DTSTART", obj) or \ + self.set_datetime_in_object(dtend, dtend_attr.get("TZID"), "DTEND", obj) + + def set_periods_in_object(self, obj, periods): + + "Set in the given 'obj' the given 'periods'." + + update = False + + old_values = obj.get_values("RDATE") + new_rdates = [] + + if obj.has_key("RDATE"): + del obj["RDATE"] + + for period in periods: + (dtstart, dtstart_attr), (dtend, dtend_attr) = period + tzid = dtstart_attr.get("TZID") or dtend_attr.get("TZID") + new_rdates.append(get_period_item(dtstart, dtend, tzid)) + + obj["RDATE"] = new_rdates + + # NOTE: To do: calculate the update status. + return update + + def set_datetime_in_object(self, dt, tzid, property, obj): + + """ + Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether + an update has occurred. + """ + + if dt: + old_value = obj.get_value(property) + obj[property] = [get_datetime_item(dt, tzid)] + return format_datetime(dt) != old_value + + return False + + def handle_new_attendees(self, obj): + + "Add or remove new attendees. This does not affect the stored object." + + args = self.env.get_args() + + existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) + new_attendees = args.get("added", []) + new_attendee = args.get("attendee", [""])[0] + + if args.has_key("add"): + if new_attendee.strip(): + new_attendee = get_uri(new_attendee.strip()) + if new_attendee not in new_attendees and new_attendee not in existing_attendees: + new_attendees.append(new_attendee) + new_attendee = "" + + if args.has_key("removenew"): + removed_attendee = args["removenew"][0] + if removed_attendee in new_attendees: + new_attendees.remove(removed_attendee) + + return new_attendees, new_attendee + + def get_event_period(self, obj): + + """ + Return (dtstart, dtstart attributes), (dtend, dtend attributes) for + 'obj'. + """ + + dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") + if obj.has_key("DTEND"): + dtend, dtend_attr = obj.get_datetime_item("DTEND") + elif obj.has_key("DURATION"): + duration = obj.get_duration("DURATION") + dtend = dtstart + duration + dtend_attr = dtstart_attr + else: + dtend, dtend_attr = dtstart, dtstart_attr + return (dtstart, dtstart_attr), (dtend, dtend_attr) + + # Page fragment methods. + + def show_request_controls(self, obj): + + "Show form controls for a request concerning 'obj'." + + page = self.page + args = self.env.get_args() + + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + attendees = uri_values((obj.get_values("ATTENDEE") or []) + filter(None, args.get("attendee", []))) + is_attendee = self.user in attendees + + is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests() + + have_other_attendees = len(attendees) > (is_attendee and 1 or 0) + + # Show appropriate options depending on the role of the user. + + if is_attendee and not is_organiser: + page.p("An action is required for this request:") + + page.p() + page.input(name="reply", type="submit", value="Send reply") + page.add(" ") + page.input(name="discard", type="submit", value="Discard event") + page.add(" ") + page.input(name="ignore", type="submit", value="Do nothing for now") + page.p.close() + + if is_organiser: + page.p("As organiser, you can perform the following:") + + if have_other_attendees: + page.p() + page.input(name="invite", type="submit", value="Invite/notify attendees") + page.add(" ") + if is_request: + page.input(name="discard", type="submit", value="Discard event") + else: + page.input(name="cancel", type="submit", value="Cancel event") + page.add(" ") + page.input(name="ignore", type="submit", value="Do nothing for now") + page.p.close() + else: + page.p() + page.input(name="save", type="submit", value="Save event") + page.add(" ") + page.input(name="discard", type="submit", value="Discard event") + page.add(" ") + page.input(name="ignore", type="submit", value="Do nothing for now") + page.p.close() + + property_items = [ + ("SUMMARY", "Summary"), + ("DTSTART", "Start"), + ("DTEND", "End"), + ("ORGANIZER", "Organiser"), + ("ATTENDEE", "Attendee"), + ] + + partstat_items = [ + ("NEEDS-ACTION", "Not confirmed"), + ("ACCEPTED", "Attending"), + ("TENTATIVE", "Tentatively attending"), + ("DECLINED", "Not attending"), + ("DELEGATED", "Delegated"), + (None, "Not indicated"), + ] + + def show_object_on_page(self, uid, obj, error=None): + + """ + Show the calendar object with the given 'uid' and representation 'obj' + on the current page. If 'error' is given, show a suitable message. + """ + + page = self.page + page.form(method="POST") + + page.input(name="editing", type="hidden", value="true") + + args = self.env.get_args() + + # Obtain the user's timezone. + + tzid = self.get_tzid() + + # Obtain basic event information, showing any necessary editing controls. + + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + if is_organiser: + new_attendees, new_attendee = self.handle_new_attendees(obj) + else: + new_attendees = [] + new_attendee = "" + + (dtstart, dtstart_attr), (dtend, dtend_attr) = self.get_event_period(obj) + self.show_object_datetime_controls(dtstart, dtend) + + # Provide a summary of the object. + + page.table(class_="object", cellspacing=5, cellpadding=5) + page.thead() + page.tr() + page.th("Event", class_="mainheading", colspan=2) + page.tr.close() + page.thead.close() + page.tbody() + + for name, label in self.property_items: + field = name.lower() + + items = obj.get_items(name) or [] + rowspan = len(items) + + if name == "ATTENDEE": + rowspan += len(new_attendees) + 1 + elif not items: + continue + + page.tr() + page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""), rowspan=rowspan) + + # Handle datetimes specially. + + if name in ["DTSTART", "DTEND"]: + + # Obtain the datetime. + + if name == "DTSTART": + dt, attr = dtstart, dtstart_attr + + # Where no end datetime exists, use the start datetime as the + # basis of any potential datetime specified if dt-control is + # set. + + else: + dt, attr = dtend or dtstart, dtend_attr or dtstart_attr + + self.show_datetime_controls(obj, dt, attr, name == "DTSTART") + + page.tr.close() + + # Handle the summary specially. + + elif name == "SUMMARY": + value = args.get("summary", [obj.get_value(name)])[0] + + page.td() + if is_organiser: + page.input(name="summary", type="text", value=value, size=80) + else: + page.add(value) + page.td.close() + page.tr.close() + + # Handle potentially many values. + + else: + first = True + + for i, (value, attr) in enumerate(items): + if not first: + page.tr() + else: + first = False + + if name == "ATTENDEE": + value = get_uri(value) + + page.td(class_="objectvalue") + page.add(value) + page.add(" ") + + partstat = attr.get("PARTSTAT") + if value == self.user: + self._show_menu("partstat", partstat, self.partstat_items, "partstat") + else: + page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") + + if is_organiser: + if value in args.get("remove", []): + page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked") + else: + page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove") + page.label("Remove", for_="remove-%d" % i, class_="remove") + page.label("Uninvited", for_="remove-%d" % i, class_="removed") + + else: + page.td(class_="objectvalue") + page.add(value) + + page.td.close() + page.tr.close() + + # Allow more attendees to be specified. + + if is_organiser and name == "ATTENDEE": + for i, attendee in enumerate(new_attendees): + if not first: + page.tr() + else: + first = False + + page.td() + page.input(name="added", type="value", value=attendee) + page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove") + page.label("Remove", for_="removenew-%d" % i, class_="remove") + page.td.close() + page.tr.close() + + if not first: + page.tr() + + page.td() + page.input(name="attendee", type="value", value=new_attendee) + page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add") + page.label("Add", for_="add-%d" % i, class_="add") + page.td.close() + page.tr.close() + + page.tbody.close() + page.table.close() + + self.show_recurrences(obj) + self.show_conflicting_events(uid, obj) + self.show_request_controls(obj) + + page.form.close() + + def show_object_datetime_controls(self, start, end, index=None): + + """ + Show datetime-related controls if already active or if an object needs + them for the given 'start' to 'end' period. The given 'index' is used to + parameterise individual controls for dynamic manipulation. + """ + + page = self.page + args = self.env.get_args() + sn = self._suffixed_name + ssn = self._simple_suffixed_name + + # Add a dynamic stylesheet to permit the controls to modify the display. + # NOTE: The style details need to be coordinated with the static + # NOTE: stylesheet. + + if index is not None: + page.style(type="text/css") + + # Unlike the rules for object properties, these affect recurrence + # properties. + + page.add("""\ +input#dttimes-enable-%(index)d, +input#dtend-enable-%(index)d, +input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled, +input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled, +input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled, +input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled { + display: none; +}""" % {"index" : index}) + + page.style.close() + + dtend_control = args.get(ssn("dtend-control", "recur", index), []) + dttimes_control = args.get(ssn("dttimes-control", "recur", index), []) + + dtend_enabled = index is not None and str(index) in dtend_control or index is None and dtend_control + dttimes_enabled = index is not None and str(index) in dttimes_control or index is None and dttimes_control + + initial_load = not args.has_key("editing") + + dtend_enabled = dtend_enabled or initial_load and (isinstance(end, datetime) or start != end - timedelta(1)) + dttimes_enabled = dttimes_enabled or initial_load and (isinstance(start, datetime) or isinstance(end, datetime)) + + if dtend_enabled: + page.input(name=ssn("dtend-control", "recur", index), type="checkbox", + value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index), checked="checked") + else: + page.input(name=ssn("dtend-control", "recur", index), type="checkbox", + value=(index is not None and str(index) or "enable"), id=sn("dtend-enable", index)) + + if dttimes_enabled: + page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", + value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index), checked="checked") + else: + page.input(name=ssn("dttimes-control", "recur", index), type="checkbox", + value=(index is not None and str(index) or "enable"), id=sn("dttimes-enable", index)) + + def show_datetime_controls(self, obj, dt, attr, show_start): + + """ + Show datetime details from the given 'obj' for the datetime 'dt' and + attributes 'attr', showing start details if 'show_start' is set + to a true value. Details will appear as controls for organisers and + labels for attendees. + """ + + page = self.page + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + # Change end dates to refer to the actual dates, not the iCalendar + # "next day" dates. + + if not show_start and not isinstance(dt, datetime): + dt -= timedelta(1) + + # Show controls for editing as organiser. + + if is_organiser: + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + + if show_start: + page.div(class_="dt enabled") + self._show_date_controls("dtstart", dt, attr.get("TZID")) + page.br() + page.label("Specify times", for_="dttimes-enable", class_="time disabled enable") + page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable") + page.div.close() + + else: + page.div(class_="dt disabled") + page.label("Specify end date", for_="dtend-enable", class_="enable") + page.div.close() + page.div(class_="dt enabled") + self._show_date_controls("dtend", dt, attr.get("TZID")) + page.br() + page.label("End on same day", for_="dtend-enable", class_="disable") + page.div.close() + + page.td.close() + + # Show a label as attendee. + + else: + page.td(self.format_datetime(dt, "full")) + + def show_recurrence_controls(self, obj, index, start, end, origin, recurrenceid, recurrenceids, show_start): + + """ + Show datetime details from the given 'obj' for the recurrence having the + given 'index', with the recurrence period described by the datetimes + 'start' and 'end', indicating the 'origin' of the period from the event + details, employing any 'recurrenceid' and 'recurrenceids' for the object + to configure the displayed information. + + If 'show_start' is set to a true value, the start details will be shown; + otherwise, the end details will be shown. + """ + + page = self.page + sn = self._suffixed_name + ssn = self._simple_suffixed_name + + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + # Change end dates to refer to the actual dates, not the iCalendar + # "next day" dates. + + if not isinstance(end, datetime): + end -= timedelta(1) + + start_utc = format_datetime(to_timezone(start, "UTC")) + replaced = recurrenceids and start_utc in recurrenceids and "replaced" or "" + css = " ".join([ + replaced, + recurrenceid and start_utc == recurrenceid and "affected" or "" + ]) + + # Show controls for editing as organiser. + + if is_organiser and not replaced and origin != "RRULE": + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + + if show_start: + page.div(class_="dt enabled") + self._show_date_controls(ssn("dtstart", "recur", index), start, None, index) + page.br() + page.label("Specify times", for_=sn("dttimes-enable", index), class_="time disabled enable") + page.label("Specify dates only", for_=sn("dttimes-enable", index), class_="time enabled disable") + page.div.close() + + else: + page.div(class_="dt disabled") + page.label("Specify end date", for_=sn("dtend-enable", index), class_="enable") + page.div.close() + page.div(class_="dt enabled") + self._show_date_controls(ssn("dtend", "recur", index), end, None, index) + page.br() + page.label("End on same day", for_=sn("dtend-enable", index), class_="disable") + page.div.close() + + page.td.close() + + # Show label as attendee. + + else: + page.td(self.format_datetime(show_start and start or end, "long"), class_=css) + + def show_recurrences(self, obj): + + "Show recurrences for the object having the given representation 'obj'." + + page = self.page + is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user + + # Obtain any parent object if this object is a specific recurrence. + + uid = obj.get_value("UID") + recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID")) + + if recurrenceid: + obj = self._get_object(uid) + if not obj: + return + + page.p("This event modifies a recurring event.") + + # Obtain the periods associated with the event in the user's time zone. + + periods = obj.get_periods(self.get_tzid(), self.get_window_end(), origin=True) + recurrenceids = self._get_recurrences(uid) + + if len(periods) == 1: + return + + if is_organiser: + page.p("This event recurs on the following occasions within the next %d days:" % self.get_window_size()) + else: + page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) + + # Determine whether any periods are explicitly created or are part of a + # rule. + + explicit_periods = filter(lambda t: t[2] != "RRULE", periods) + + # Show each recurrence in a separate table if editable. + + if is_organiser and explicit_periods: + + for index, (start, end, origin) in enumerate(periods[1:]): + + # Isolate the controls from neighbouring tables. + + page.div() + + self.show_object_datetime_controls(start, end, index) + + # NOTE: Need to customise the TH classes according to errors and + # NOTE: index information. + + page.table(cellspacing=5, cellpadding=5, class_="recurrence") + page.caption("Occurrence") + page.tbody() + page.tr() + page.th("Start", class_="objectheading start") + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) + page.tr.close() + page.tr() + page.th("End", class_="objectheading end") + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) + page.tr.close() + page.tbody.close() + page.table.close() + + page.div.close() + + # Otherwise, use a compact single table. + + else: + page.table(cellspacing=5, cellpadding=5, class_="recurrence") + page.caption("Occurrences") + page.thead() + page.tr() + page.th("Start", class_="objectheading start") + page.th("End", class_="objectheading end") + page.tr.close() + page.thead.close() + page.tbody() + + # Show only subsequent periods if organiser, since the principal + # period will be the start and end datetimes. + + for index, (start, end, origin) in enumerate(is_organiser and periods[1:] or periods): + page.tr() + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, True) + self.show_recurrence_controls(obj, index, start, end, origin, recurrenceid, recurrenceids, False) + page.tr.close() + page.tbody.close() + page.table.close() + + def show_conflicting_events(self, uid, obj): + + """ + Show conflicting events for the object having the given 'uid' and + representation 'obj'. + """ + + page = self.page + + # Obtain the user's timezone. + + tzid = self.get_tzid() + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) + + # Indicate whether there are conflicting events. + + freebusy = self.store.get_freebusy(self.user) + + if freebusy: + + # Obtain any time zone details from the suggested event. + + _dtstart, attr = obj.get_item("DTSTART") + tzid = attr.get("TZID", tzid) + + # Show any conflicts. + + conflicts = [t for t in have_conflict(freebusy, periods, True) if t[2] != uid] + + if conflicts: + page.p("This event conflicts with others:") + + page.table(cellspacing=5, cellpadding=5, class_="conflicts") + page.thead() + page.tr() + page.th("Event") + page.th("Start") + page.th("End") + page.tr.close() + page.thead.close() + page.tbody() + + for t in conflicts: + start, end, found_uid, transp, found_recurrenceid, summary = t[:6] + + # Provide details of any conflicting event. + + start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long") + end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long") + + page.tr() + + # Show the event summary for the conflicting event. + + page.td() + page.a(summary, href=self.link_to(found_uid)) + page.td.close() + + page.td(start) + page.td(end) + + page.tr.close() + + page.tbody.close() + page.table.close() + + # Full page output methods. + + def show(self, path_info): + + "Show an object request using the given 'path_info' for the current user." + + uid, recurrenceid = self._get_identifiers(path_info) + obj = self._get_object(uid, recurrenceid) + + if not obj: + return False + + error = self.handle_request(uid, recurrenceid, obj) + + if not error: + return True + + self.new_page(title="Event") + self.show_object_on_page(uid, obj, error) + + return True + + # Utility methods. + + def _show_menu(self, name, default, items, class_="", index=None): + + """ + Show a select menu having the given 'name', set to the given 'default', + providing the given (value, label) 'items', and employing the given CSS + 'class_' if specified. + """ + + page = self.page + values = self.env.get_args().get(name, [default]) + if index is not None: + values = values[index:] + values = values and values[0:1] or [default] + + page.select(name=name, class_=class_) + for v, label in items: + if v is None: + continue + if v in values: + page.option(label, value=v, selected="selected") + else: + page.option(label, value=v) + page.select.close() + + def _show_date_controls(self, name, default, tzid, index=None): + + """ + Show date controls for a field with the given 'name' and 'default' value + and 'tzid'. + """ + + page = self.page + args = self.env.get_args() + + event_tzid = tzid or self.get_tzid() + + # Show dates for up to one week around the current date. + + base = to_date(default) + items = [] + for i in range(-7, 8): + d = base + timedelta(i) + items.append((format_datetime(d), self.format_date(d, "full"))) + + self._show_menu("%s-date" % name, format_datetime(base), items, index=index) + + # Show time details. + + default_time = isinstance(default, datetime) and default or None + + hour = args.get("%s-hour" % name, [])[index or 0:] + hour = hour and hour[0] or "%02d" % (default_time and default_time.hour or 0) + minute = args.get("%s-minute" % name, [])[index or 0:] + minute = minute and minute[0] or "%02d" % (default_time and default_time.minute or 0) + second = args.get("%s-second" % name, [])[index or 0:] + second = second and second[0] or "%02d" % (default_time and default_time.second or 0) + + page.span(class_="time enabled") + page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2) + page.add(":") + page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2) + page.add(":") + page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2) + page.add(" ") + self._show_timezone_menu("%s-tzid" % name, event_tzid, index) + page.span.close() + + def _show_timezone_menu(self, name, default, index=None): + + """ + Show timezone controls using a menu with the given 'name', set to the + given 'default' unless a field of the given 'name' provides a value. + """ + + entries = [(tzid, tzid) for tzid in pytz.all_timezones] + self._show_menu(name, default, entries, index=index) + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 9b8237cc00bd -r 8a940a370579 imipweb/resource.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imipweb/resource.py Thu Mar 26 16:11:46 2015 +0100 @@ -0,0 +1,212 @@ +#!/usr/bin/env python + +""" +Common resource functionality for Web calendar clients. + +Copyright (C) 2014, 2015 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from datetime import datetime +from imiptools.client import Client +from imiptools.data import get_uri, get_window_end, Object, uri_values +from imiptools.dates import format_datetime, format_time +from imiptools.period import remove_period, remove_affected_period, update_freebusy +from imipweb.env import CGIEnvironment +import babel.dates +import imip_store +import markup + +class Resource(Client): + + "A Web application resource and calendar client." + + def __init__(self, resource=None): + self.encoding = "utf-8" + self.env = CGIEnvironment(self.encoding) + + user = self.env.get_user() + Client.__init__(self, user and get_uri(user) or None) + + self.locale = None + self.requests = None + + self.out = resource and resource.out or self.env.get_output() + self.page = resource and resource.page or markup.page() + self.html_ids = None + + self.store = imip_store.FileStore() + self.objects = {} + + try: + self.publisher = imip_store.FilePublisher() + except OSError: + self.publisher = None + + # Presentation methods. + + def new_page(self, title): + self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css")) + self.html_ids = set() + + def status(self, code, message): + self.header("Status", "%s %s" % (code, message)) + + def header(self, header, value): + print >>self.out, "%s: %s" % (header, value) + + def no_user(self): + self.status(403, "Forbidden") + self.new_page(title="Forbidden") + self.page.p("You are not logged in and thus cannot access scheduling requests.") + + def no_page(self): + self.status(404, "Not Found") + self.new_page(title="Not Found") + self.page.p("No page is provided at the given address.") + + def redirect(self, url): + self.status(302, "Redirect") + self.header("Location", url) + self.new_page(title="Redirect") + self.page.p("Redirecting to: %s" % url) + + def link_to(self, uid, recurrenceid=None): + if recurrenceid: + return self.env.new_url("/".join([uid, recurrenceid])) + else: + return self.env.new_url(uid) + + # Access to objects. + + def _suffixed_name(self, name, index=None): + return index is not None and "%s-%d" % (name, index) or name + + def _simple_suffixed_name(self, name, suffix, index=None): + return index is not None and "%s-%s" % (name, suffix) or name + + def _get_identifiers(self, path_info): + parts = path_info.lstrip("/").split("/") + if len(parts) == 1: + return parts[0], None + else: + return parts[:2] + + def _get_object(self, uid, recurrenceid=None): + if self.objects.has_key((uid, recurrenceid)): + return self.objects[(uid, recurrenceid)] + + fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None + obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment) + return obj + + def _get_recurrences(self, uid): + return self.store.get_recurrences(self.user, uid) + + def _get_requests(self): + if self.requests is None: + cancellations = self.store.get_cancellations(self.user) + requests = set(self.store.get_requests(self.user)) + self.requests = requests.difference(cancellations) + return self.requests + + def _get_request_summary(self): + summary = [] + for uid, recurrenceid in self._get_requests(): + obj = self._get_object(uid, recurrenceid) + if obj: + periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()) + recurrenceids = self._get_recurrences(uid) + + # Convert the periods to more substantial free/busy items. + + for start, end in periods: + + # Subtract any recurrences from the free/busy details of a + # parent object. + + if recurrenceid or start not in recurrenceids: + summary.append(( + start, end, uid, + obj.get_value("TRANSP"), + recurrenceid, + obj.get_value("SUMMARY"), + obj.get_value("ORGANIZER") + )) + return summary + + # Preference methods. + + def get_user_locale(self): + if not self.locale: + self.locale = self.get_preferences().get("LANG", "en") + return self.locale + + # Prettyprinting of dates and times. + + def format_date(self, dt, format): + return self._format_datetime(babel.dates.format_date, dt, format) + + def format_time(self, dt, format): + return self._format_datetime(babel.dates.format_time, dt, format) + + def format_datetime(self, dt, format): + return self._format_datetime( + isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date, + dt, format) + + def _format_datetime(self, fn, dt, format): + return fn(dt, format=format, locale=self.get_user_locale()) + + # Data management methods. + + def remove_request(self, uid, recurrenceid=None): + return self.store.dequeue_request(self.user, uid, recurrenceid) + + def remove_event(self, uid, recurrenceid=None): + return self.store.remove_event(self.user, uid, recurrenceid) + + def update_freebusy(self, uid, recurrenceid, obj): + + """ + Update stored free/busy details for the event with the given 'uid' and + 'recurrenceid' having a representation of 'obj'. + """ + + is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE")) + + freebusy = self.store.get_freebusy(self.user) + + update_freebusy(freebusy, + obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end()), + is_only_organiser and "ORG" or obj.get_value("TRANSP"), + uid, recurrenceid, + obj.get_value("SUMMARY"), + obj.get_value("ORGANIZER")) + + # Subtract any recurrences from the free/busy details of a parent + # object. + + for recurrenceid in self._get_recurrences(uid): + remove_affected_period(freebusy, uid, recurrenceid) + + self.store.set_freebusy(self.user, freebusy) + + def remove_from_freebusy(self, uid, recurrenceid=None): + freebusy = self.store.get_freebusy(self.user) + remove_period(freebusy, uid, recurrenceid) + self.store.set_freebusy(self.user, freebusy) + +# vim: tabstop=4 expandtab shiftwidth=4