# HG changeset patch # User Paul Boddie # Date 1443479131 -7200 # Node ID e0ed770d17bea0884dddd26c729fc3e29a580b8a # Parent 79493ac5b434a2c27e68e2a193a9a6021bbeee23# Parent 9cf10fe21c3ae6dc6d65fc75c53b3bde2bc6b7b6 Merged changes from branch. diff -r 79493ac5b434 -r e0ed770d17be docs/preferences.txt --- a/docs/preferences.txt Thu Sep 24 19:13:39 2015 +0200 +++ b/docs/preferences.txt Tue Sep 29 00:25:31 2015 +0200 @@ -70,15 +70,16 @@ supporting this setting when counter-proposals are made during event scheduling. -This setting requires a value of one of the following forms: +This setting requires a value indicating a duration as described in the +iCalendar format specification: - - d +http://tools.ietf.org/html/rfc5545#section-3.3.6 For example: - 600 extend scheduling offers for 10 minutes - 1d extend offers for 1 day + PT10M extend scheduling offers for 10 minutes + PT600S extend scheduling offers for 600 seconds (10 minutes) + PT1D extend offers for 1 day freebusy_publishing ------------------- diff -r 79493ac5b434 -r e0ed770d17be htdocs/styles.css --- a/htdocs/styles.css Thu Sep 24 19:13:39 2015 +0200 +++ b/htdocs/styles.css Tue Sep 29 00:25:31 2015 +0200 @@ -1,7 +1,7 @@ /* Table styling. */ -table.calendar, table.conflicts, +table.counters, table.recurrence, table.object { border: 2px solid #000; @@ -23,9 +23,10 @@ background-color: #faa; } -th.dayheading, +caption.dayheading, th.mainheading { background-color: #f85; + width: 100%; } th.timeslot, @@ -50,6 +51,7 @@ td.event { background-color: #ff8; border: 2px solid #000; + width: 10em; } td.event.only-organising { @@ -111,10 +113,15 @@ text-decoration: line-through; } +.objectvalue.dtstart.excluded, .objectvalue.dtstart.replaced { vertical-align: top; } +table.counters tr.selected { + background-color: #ee2; +} + /* New event controls. */ .newevent-with-periods { @@ -127,13 +134,17 @@ display: none; } +input.newevent.selector:checked ~ p.newevent-with-periods { + display: block; +} + th.container, td.container { padding: 0; /* for regions covered by labels */ } -th.dayheading:hover, -th.dayheading:focus, +caption.dayheading:hover, +caption.dayheading:focus, th.timeslot:hover, th.timeslot:focus, td.container:hover, @@ -174,9 +185,8 @@ /* Hide calendar rows depending on the selected controls. */ -input#hidebusy:checked ~ .calendar tr.slot.busy, -input#showdays:not(:checked) ~ .calendar thead.separator.empty, -input#showdays:not(:checked) ~ .calendar tbody.points.empty, +input#hidebusy:checked ~ div.calendar tr.slot.busy, +input#showdays:not(:checked) ~ div.calendar .calendar.empty, /* Hiding/showing end datetimes and start/end times. */ @@ -206,10 +216,29 @@ /* Show slot endpoints when hiding adjacent busy periods. */ -input#hidebusy:checked ~ .calendar th.timeslot span.endpoint { +input#hidebusy:checked ~ div.calendar th.timeslot span.endpoint { display: block; } +/* Make calendar labels occupy cells completely. + See: http://stackoverflow.com/questions/2841484/how-can-a-label-completely-fill-its-parent-td +*/ + +tr.slot { + height: 0; +} + +th.timeslot, +td.empty { + height: 100%; +} + +label.timepoint, +label.newevent { + display: block; + min-height: 100%; +} + /* Style the labels. */ label.day, diff -r 79493ac5b434 -r e0ed770d17be imip_manager.py --- a/imip_manager.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imip_manager.py Tue Sep 29 00:25:31 2015 +0200 @@ -29,9 +29,9 @@ from imipweb.calendar import CalendarPage from imipweb.event import EventPage -from imipweb.resource import Resource +from imipweb.resource import ResourceClient -class Manager(Resource): +class Manager(ResourceClient): "A simple manager application." diff -r 79493ac5b434 -r e0ed770d17be imip_store.py --- a/imip_store.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imip_store.py Tue Sep 29 00:25:31 2015 +0200 @@ -367,7 +367,7 @@ indicated 'uid'. Only cancelled recurrences are returned. """ - filename = self.get_object_in_store(user, "cancelled", "recurrences", uid) + filename = self.get_object_in_store(user, "cancellations", "recurrences", uid) if not filename or not exists(filename): return [] @@ -757,29 +757,27 @@ """ for request in requests: - if request[:2] == (uid, recurrenceid): + if request[:2] == (uid, recurrenceid) and ( + not strict or + not request[2:] and not type or + request[2:] and request[2] == type): + return True + return False def get_counters(self, user, uid, recurrenceid=None): """ - For the given 'user', return a mapping of counter-proposals from other - users to nodes representing those proposals for the given 'uid' and - optional 'recurrenceid'. + For the given 'user', return a list of users from whom counter-proposals + have been received for the given 'uid' and optional 'recurrenceid'. """ filename = self.get_event_filename(user, uid, recurrenceid, "counters") - if not filename: + if not filename or not exists(filename): return False - counters = {} - - for other in listdir(filename): - counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) - counters[other] = self._get_object(user, counter_filename) - - return counters + return [name for name in listdir(filename) if isfile(join(filename, name))] def get_counter(self, user, other, uid, recurrenceid=None): diff -r 79493ac5b434 -r e0ed770d17be imiptools/client.py --- a/imiptools/client.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imiptools/client.py Tue Sep 29 00:25:31 2015 +0200 @@ -25,7 +25,7 @@ is_new_object, make_freebusy, to_part, \ uri_dict, uri_items, uri_values from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \ - get_timestamp, to_timezone + get_duration, get_time, get_timestamp from imiptools.period import can_schedule, remove_period, \ remove_additional_periods, remove_affected_period, \ update_freebusy @@ -139,28 +139,14 @@ def get_offer_period(self): - """ - Decode a specification of one of the following forms... - - - d - """ + "Decode a specification in the iCalendar duration format." prefs = self.get_preferences() duration = prefs and prefs.get("freebusy_offers", config.FREEBUSY_OFFER_DEFAULT) - if duration: - try: - if duration.endswith("d"): - return timedelta(days=int(duration[:-1])) - else: - return timedelta(seconds=int(duration)) - # NOTE: Should probably report an error somehow. + # NOTE: Should probably report an error somehow if None. - except ValueError: - return None - else: - return None + return duration and get_duration(duration) or None def get_organiser_replacement(self): prefs = self.get_preferences() @@ -281,16 +267,16 @@ # Store operations. - def get_stored_object(self, uid, recurrenceid, section=None): + def get_stored_object(self, uid, recurrenceid, section=None, username=None): """ Return the stored object for the current user, with the given 'uid' and - 'recurrenceid' from the given 'section' (if specified), or from the - standard object collection otherwise. + 'recurrenceid' from the given 'section' and for the given 'username' (if + specified), or from the standard object collection otherwise. """ if section == "counters": - fragment = self.store.get_counter(self.user, uid, recurrenceid) + fragment = self.store.get_counter(self.user, username, uid, recurrenceid) else: fragment = self.store.get_event(self.user, uid, recurrenceid) return fragment and Object(fragment) @@ -385,7 +371,7 @@ "Update the DTSTAMP in the current object." dtstamp = self.obj.get_utc_datetime("DTSTAMP") - utcnow = to_timezone(datetime.utcnow(), "UTC") + utcnow = get_time() self.dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) self.obj["DTSTAMP"] = [(self.dtstamp, {})] @@ -683,7 +669,7 @@ if offer: offer_period = self.get_offer_period() if offer_period: - expires = format_datetime(to_timezone(datetime.utcnow(), "UTC") + offer_period) + expires = get_timestamp(offer_period) else: return else: @@ -795,4 +781,85 @@ for attendee in attendees.keys(): self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) + # Convenience methods for updating free/busy details at the event level. + + def update_event_in_freebusy(self, for_organiser=True): + + """ + Update free/busy information when handling an object, doing so for the + organiser of an event if 'for_organiser' is set to a true value. + """ + + freebusy = self.store.get_freebusy(self.user) + + # Obtain the attendance attributes for this user, if available. + + self.update_freebusy_for_participant(freebusy, self.user, for_organiser) + + # Remove original recurrence details replaced by additional + # recurrences, as well as obsolete additional recurrences. + + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) + self.store.set_freebusy(self.user, freebusy) + + if self.publisher and self.is_sharing() and self.is_publishing(): + self.publisher.set_freebusy(self.user, freebusy) + + # Update free/busy provider information if the event may recur + # indefinitely. + + if self.possibly_recurring_indefinitely(): + self.store.append_freebusy_provider(self.user, self.obj) + + return True + + def remove_event_from_freebusy(self): + + "Remove free/busy information when handling an object." + + freebusy = self.store.get_freebusy(self.user) + + self.remove_from_freebusy(freebusy) + self.remove_freebusy_for_recurrences(freebusy) + self.store.set_freebusy(self.user, freebusy) + + if self.publisher and self.is_sharing() and self.is_publishing(): + self.publisher.set_freebusy(self.user, freebusy) + + # Update free/busy provider information if the event may recur + # indefinitely. + + if self.possibly_recurring_indefinitely(): + self.store.remove_freebusy_provider(self.user, self.obj) + + def update_event_in_freebusy_offers(self): + + "Update free/busy offers when handling an object." + + freebusy = self.store.get_freebusy_offers(self.user) + + # Obtain the attendance attributes for this user, if available. + + self.update_freebusy_for_participant(freebusy, self.user, offer=True) + + # Remove original recurrence details replaced by additional + # recurrences, as well as obsolete additional recurrences. + + self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) + self.store.set_freebusy_offers(self.user, freebusy) + + return True + + def remove_event_from_freebusy_offers(self): + + "Remove free/busy offers when handling an object." + + freebusy = self.store.get_freebusy_offers(self.user) + + self.remove_from_freebusy(freebusy) + self.remove_freebusy_for_recurrences(freebusy) + self.store.set_freebusy_offers(self.user, freebusy) + + return True + # vim: tabstop=4 expandtab shiftwidth=4 diff -r 79493ac5b434 -r e0ed770d17be imiptools/data.py --- a/imiptools/data.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imiptools/data.py Tue Sep 29 00:25:31 2015 +0200 @@ -409,6 +409,25 @@ return old_values != set(self.get_date_values("RDATE") or []) + def update_exceptions(self, excluded): + + """ + Update the exceptions to any rule by applying the list of 'excluded' + periods. + """ + + to_exclude = set(excluded).difference(self.get_date_values("EXDATE") or []) + if not to_exclude: + return False + + if not self.has_key("EXDATE"): + self["EXDATE"] = [] + + for p in to_exclude: + self["EXDATE"].append(get_period_item(p.get_start(), p.get_end())) + + return True + def correct_object(self, tzid, permitted_values): "Correct the object's period details." diff -r 79493ac5b434 -r e0ed770d17be imiptools/dates.py --- a/imiptools/dates.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imiptools/dates.py Tue Sep 29 00:25:31 2015 +0200 @@ -132,7 +132,10 @@ def get_duration(value): - "Return a duration for the given 'value'." + """ + Return a duration for the given 'value' as a timedelta object. + Where no valid duration is specified, None is returned. + """ if not value: return None @@ -433,11 +436,19 @@ else: return None, None -def get_timestamp(): +def get_timestamp(offset=None): "Return the current time as an iCalendar-compatible string." - return format_datetime(to_timezone(datetime.utcnow(), "UTC")) + offset = offset or timedelta(0) + return format_datetime(to_timezone(datetime.utcnow(), "UTC") + offset) + +def get_time(offset=None): + + "Return the current time." + + offset = offset or timedelta(0) + return to_timezone(datetime.utcnow(), "UTC") + offset def get_tzid(dtstart_attr, dtend_attr): diff -r 79493ac5b434 -r e0ed770d17be imiptools/handlers/common.py --- a/imiptools/handlers/common.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imiptools/handlers/common.py Tue Sep 29 00:25:31 2015 +0200 @@ -110,83 +110,4 @@ self.add_result("REFRESH", [get_address(organiser)], obj.to_part("REFRESH")) - def update_event_in_freebusy(self, for_organiser=True): - - """ - Update free/busy information when handling an object, doing so for the - organiser of an event if 'for_organiser' is set to a true value. - """ - - freebusy = self.store.get_freebusy(self.user) - - # Obtain the attendance attributes for this user, if available. - - self.update_freebusy_for_participant(freebusy, self.user, for_organiser) - - # Remove original recurrence details replaced by additional - # recurrences, as well as obsolete additional recurrences. - - self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) - self.store.set_freebusy(self.user, freebusy) - - if self.publisher and self.is_sharing() and self.is_publishing(): - self.publisher.set_freebusy(self.user, freebusy) - - # Update free/busy provider information if the event may recur - # indefinitely. - - if self.possibly_recurring_indefinitely(): - self.store.append_freebusy_provider(self.user, self.obj) - - return True - - def remove_event_from_freebusy(self): - - "Remove free/busy information when handling an object." - - freebusy = self.store.get_freebusy(self.user) - - self.remove_from_freebusy(freebusy) - self.remove_freebusy_for_recurrences(freebusy) - self.store.set_freebusy(self.user, freebusy) - - if self.publisher and self.is_sharing() and self.is_publishing(): - self.publisher.set_freebusy(self.user, freebusy) - - # Update free/busy provider information if the event may recur - # indefinitely. - - if self.possibly_recurring_indefinitely(): - self.store.remove_freebusy_provider(self.user, self.obj) - - def update_event_in_freebusy_offers(self): - - "Update free/busy offers when handling an object." - - freebusy = self.store.get_freebusy_offers(self.user) - - # Obtain the attendance attributes for this user, if available. - - self.update_freebusy_for_participant(freebusy, self.user, offer=True) - - # Remove original recurrence details replaced by additional - # recurrences, as well as obsolete additional recurrences. - - self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) - self.store.set_freebusy_offers(self.user, freebusy) - - return True - - def remove_event_from_freebusy_offers(self): - - "Remove free/busy offers when handling an object." - - freebusy = self.store.get_freebusy_offers(self.user) - - self.remove_from_freebusy(freebusy) - self.remove_freebusy_for_recurrences(freebusy) - self.store.set_freebusy_offers(self.user, freebusy) - - return True - # vim: tabstop=4 expandtab shiftwidth=4 diff -r 79493ac5b434 -r e0ed770d17be imipweb/calendar.py --- a/imipweb/calendar.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imipweb/calendar.py Tue Sep 29 00:25:31 2015 +0200 @@ -27,9 +27,9 @@ to_timezone from imiptools.period import add_day_start_points, add_empty_days, add_slots, \ get_scale, get_slots, get_spans, partition_by_day, Point -from imipweb.resource import Resource +from imipweb.resource import ResourceClient -class CalendarPage(Resource): +class CalendarPage(ResourceClient): "A request handler for the calendar page." @@ -46,13 +46,18 @@ args = self.env.get_args() - if not args.has_key("newevent"): + for key in args.keys(): + if key.startswith("newevent-"): + i = key[len("newevent-"):] + break + else: return # Create a new event using the available information. slots = args.get("slot", []) participants = args.get("participants", []) + summary = args.get("summary-%s" % i, [None])[0] if not slots: return @@ -120,7 +125,7 @@ end_value, end_attr = get_datetime_item(end, tzid) rwrite(("UID", {}, uid)) - rwrite(("SUMMARY", {}, "New event at %s" % utcnow)) + rwrite(("SUMMARY", {}, summary or ("New event at %s" % utcnow))) rwrite(("DTSTAMP", {}, utcnow)) rwrite(("DTSTART", start_attr, start_value)) rwrite(("DTEND", end_attr, end_value)) @@ -379,18 +384,7 @@ add_empty_days(days, tzid) - # Show the controls permitting day selection as well as the controls - # configuring the new event display. - - self.show_calendar_day_controls(days) - self.show_calendar_interval_controls(days) - - # Show a button for scheduling a new event. - - page.p(class_="controls") - page.input(name="newevent", type="submit", value="New event", id="newevent", class_="newevent-with-periods", accesskey="N") - page.span("Select days or periods for a new event.", class_="newevent-no-periods") - page.p.close() + page.p("Select days or periods for a new event.") # Show controls for hiding empty days and busy slots. # The positioning of the control, paragraph and table are important here. @@ -409,10 +403,7 @@ # 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() + self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) # End the form region. @@ -420,16 +411,12 @@ # More page fragment methods. - def show_calendar_day_controls(self, days): + def show_calendar_day_controls(self, day): - "Show controls for the given 'days' in the calendar." + "Show controls for the given 'day' 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) + daystr, dayid = self._day_value_and_identifier(day) # Generate a dynamic stylesheet to allow day selections to colour # specific days. @@ -438,13 +425,42 @@ page.style(type="text/css") + page.add("""\ +input.newevent.selector#%s:checked ~ table#region-%s label.day, +input.newevent.selector#%s:checked ~ table#region-%s label.timepoint { + background-color: #5f4; + text-decoration: underline; +} +""" % (dayid, dayid, dayid, dayid)) + + page.style.close() + + # Generate controls to select days. + + slots = self.env.get_args().get("slot", []) + value, identifier = self._day_value_and_identifier(day) + self._slot_selector(value, identifier, slots) + + def show_calendar_interval_controls(self, day, intervals): + + "Show controls for the intervals provided by 'day' and 'intervals'." + + page = self.page + daystr, dayid = self._day_value_and_identifier(day) + + # 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. + l = [] - for day in days: - daystr, dayid = self._day_value_and_identifier(day) + for point, endpoint in intervals: + timestr, timeid = self._slot_value_and_identifier(point, endpoint) l.append("""\ -input.newevent.selector#%s:checked ~ table label.day.day-%s, -input.newevent.selector#%s:checked ~ table label.timepoint.day-%s""" % (dayid, daystr, dayid, daystr)) +input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid)) + + page.style(type="text/css") page.add(",\n".join(l)) page.add(""" { @@ -455,59 +471,47 @@ page.style.close() - def show_calendar_interval_controls(self, days): + # Generate controls to select time periods. + + slots = self.env.get_args().get("slot", []) + last = None - "Show controls for the intervals provided by 'days'." + # Produce controls for the intervals/slots. Where instants in time are + # encountered, they are merged with the following slots, permitting the + # selection of contiguous time periods. However, the identifiers + # employed by controls corresponding to merged periods will encode the + # instant so that labels may reference them conveniently. - page = self.page - slots = self.env.get_args().get("slot", []) + intervals = list(intervals) + intervals.sort() + + for point, endpoint in intervals: - for day, intervals in days.items(): - for point, endpoint in intervals: - value, identifier = self._slot_value_and_identifier(point, endpoint) + # Merge any previous slot with this one, producing a control. + + if last: + _value, identifier = self._slot_value_and_identifier(last, last) + value, _identifier = self._slot_value_and_identifier(last, endpoint) 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. + # If representing an instant, hold the slot for merging. - page.style(type="text/css") + if endpoint and point.point == endpoint.point: + last = point - l = []; l2 = []; l3 = [] + # If not representing an instant, produce a control. - for day, intervals in days.items(): - for point, endpoint in intervals: - daystr, dayid = self._day_value_and_identifier(day) - timestr, timeid = self._slot_value_and_identifier(point, endpoint) - l.append("""\ -input.newevent.selector#%s:checked ~ p .newevent-no-periods, -input.newevent.selector#%s:checked ~ p .newevent-no-periods""" % (dayid, timeid)) - l2.append("""\ -input.newevent.selector#%s:checked ~ p .newevent-with-periods, -input.newevent.selector#%s:checked ~ p .newevent-with-periods""" % (dayid, timeid)) - l3.append("""\ -input.newevent.selector#%s:checked ~ table label.timepoint[for=%s]""" % (timeid, timeid)) + else: + value, identifier = self._slot_value_and_identifier(point, endpoint) + self._slot_selector(value, identifier, slots) + last = None - page.add(",\n".join(l)) - page.add(""" { - display: none; -}""") + # Produce a control for any unmerged slot. - page.add(",\n".join(l2)) - page.add(""" { - display: inline; -} -""") - - page.add(",\n".join(l3)) - page.add(""" { - background-color: #5f4; - text-decoration: underline; -} -""") - - page.style.close() + if last: + _value, identifier = self._slot_value_and_identifier(last, last) + value, _identifier = self._slot_value_and_identifier(last, endpoint) + self._slot_selector(value, identifier, slots) def show_calendar_participant_headings(self, group_types, group_sources, group_columns): @@ -535,13 +539,15 @@ page.tr.close() page.thead.close() - def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns): + def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, + partitioned_group_sources, 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. + the 'partitioned_group_sources' indicating the origin of each group, and + the 'group_columns' defining the number of columns in each group. """ page = self.page @@ -559,6 +565,8 @@ # Produce a heading and time points for each day. + i = 0 + for day, intervals in all_days: groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups] is_empty = True @@ -572,18 +580,46 @@ 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) + daystr, dayid = self._day_value_and_identifier(day) + + # Put calendar tables within elements for quicker CSS selection. + + page.div(class_="calendar") + + # Show the controls permitting day selection as well as the controls + # configuring the new event display. + + self.show_calendar_day_controls(day) + self.show_calendar_interval_controls(day, intervals) + + # Show an actual table containing the day information. + + page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid) + + page.caption(class_="dayheading container separator") self._day_heading(day) - page.th.close() - page.tr.close() - page.thead.close() + page.caption.close() - page.tbody(class_="points%s" % (is_empty and " empty" or "")) + self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns) + + page.tbody(class_="points") self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns) page.tbody.close() + page.table.close() + + # Show a button for scheduling a new event. + + page.p(class_="newevent-with-periods") + page.label("Summary:") + page.input(name="summary-%d" % i, type="text") + page.input(name="newevent-%d" % i, type="submit", value="New event", accesskey="N") + page.p.close() + + page.div.close() + + i += 1 + def show_calendar_points(self, intervals, groups, group_types, group_columns): """ @@ -629,12 +665,16 @@ ]) page.tr(class_=css) + + # Produce a time interval heading, spanning two rows if this point + # represents an instant. + if point.indicator == Point.PRINCIPAL: - page.th(class_="timeslot") + timestr, timeid = self._slot_value_and_identifier(point, endpoint) + page.th(class_="timeslot", id="region-%s" % timeid, + rowspan=(endpoint and point.point == endpoint.point and 2 or 1)) self._time_point(point, endpoint) - else: - page.th() - page.th.close() + page.th.close() # Obtain slots for the time point from each group. @@ -687,7 +727,8 @@ "event", has_continued and "continued" or "", will_continue and "continues" or "", - p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending" + p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending", + self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "", ]) # Only anchor the first cell of events. @@ -710,13 +751,12 @@ page.span(p.summary or "(Participant is busy)") - # Link to counter-proposals. + # Link to requests and events (including ones for + # which counter-proposals exist). elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True): - page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, "counter")) - - # Link to requests and events (including ones for - # which counter-proposals exist). + page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, + {"counter" : self._period_identifier(p)})) else: page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid)) @@ -739,13 +779,12 @@ """ 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) + page.label(self.format_date(day, "full"), class_="day", for_=identifier) def _time_point(self, point, endpoint): @@ -753,15 +792,14 @@ 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.point.date()) value, identifier = self._slot_value_and_identifier(point, endpoint) - page.label(self.format_time(point.point, "long"), class_="timepoint day-%s" % daystr, for_=identifier) + page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier) page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint") def _slot_selector(self, value, identifier, slots): @@ -813,4 +851,7 @@ identifier = "slot-%s" % value return value, identifier + def _period_identifier(self, period): + return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end())) + # vim: tabstop=4 expandtab shiftwidth=4 diff -r 79493ac5b434 -r e0ed770d17be imipweb/client.py --- a/imipweb/client.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imipweb/client.py Tue Sep 29 00:25:31 2015 +0200 @@ -130,7 +130,7 @@ for p in to_unschedule: if not p.origin: continue - obj["RECURRENCE-ID"] = [p.get_start_item()] + obj["RECURRENCE-ID"] = [(format_datetime(p.get_start()), p.get_start_attr())] parts.append(obj.to_part("CANCEL")) # Send the updated event, along with a cancellation for each of the diff -r 79493ac5b434 -r e0ed770d17be imipweb/env.py --- a/imipweb/env.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imipweb/env.py Tue Sep 29 00:25:31 2015 +0200 @@ -19,7 +19,7 @@ this program. If not, see . """ -import cgi, os, sys +import cgi, os, sys, urlparse getenv = os.environ.get setenv = os.environ.__setitem__ @@ -35,10 +35,13 @@ self.path = None self.path_info = None self.user = None + self.query_string = None def get_args(self): if self.args is None: if self.get_method() != "POST": + if not self.query_string: + self.query_string = getenv("QUERY_STRING") setenv("QUERY_STRING", "") args = cgi.parse(keep_blank_values=True) @@ -51,6 +54,11 @@ return self.args + def get_query(self): + if not self.query_string: + self.query_string = getenv("QUERY_STRING") + return urlparse.parse_qs(self.query_string or "", keep_blank_values=True) + def get_method(self): if self.method is None: self.method = getenv("REQUEST_METHOD") or "GET" diff -r 79493ac5b434 -r e0ed770d17be imipweb/event.py --- a/imipweb/event.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imipweb/event.py Tue Sep 29 00:25:31 2015 +0200 @@ -19,26 +19,20 @@ this program. If not, see . """ -from datetime import date, timedelta -from imiptools.data import get_uri, uri_dict, uri_values -from imiptools.dates import format_datetime, get_datetime_item, \ - to_date, to_timezone +from imiptools.data import get_uri, uri_dict, uri_items, uri_values +from imiptools.dates import format_datetime, to_timezone from imiptools.mail import Messenger from imiptools.period import have_conflict -from imipweb.data import EventPeriod, \ - event_period_from_period, form_period_from_period, \ - FormDate, FormPeriod, PeriodError +from imipweb.data import EventPeriod, event_period_from_period, FormPeriod, PeriodError from imipweb.client import ManagerClient -from imipweb.resource import Resource -import pytz +from imipweb.resource import DateTimeFormUtilities, FormUtilities, ResourceClientForObject -class EventPage(Resource): - - "A request handler for the event page." +class EventPageFragment(ResourceClientForObject, DateTimeFormUtilities, FormUtilities): - def __init__(self, resource=None, messenger=None): - Resource.__init__(self, resource) - self.messenger = messenger or Messenger() + "A resource presenting the details of an event." + + def __init__(self, resource=None): + ResourceClientForObject.__init__(self, resource) # Various property values and labels. @@ -59,414 +53,131 @@ (None, "Not indicated"), ] - # Access to stored object information. - - def is_organiser(self, obj): - return get_uri(obj.get_value("ORGANIZER")) == self.user - - def get_stored_attendees(self, obj): - return uri_values(obj.get_values("ATTENDEE") or []) - - def get_stored_main_period(self, obj): + def can_remove_recurrence(self, recurrence): """ - Return the main event period for the given 'obj'. + Return whether the 'recurrence' can be removed from the current object + without notification. + """ + + return self.can_edit_recurrence(recurrence) and recurrence.origin != "RRULE" + + def can_edit_recurrence(self, recurrence): + + "Return whether 'recurrence' can be edited." + + return self.recurrence_is_new(recurrence) or not self.obj.is_shared() + + def recurrence_is_new(self, recurrence): + + "Return whether 'recurrence' is new to the current object." + + return recurrence not in self.get_stored_recurrences() + + def can_remove_attendee(self, attendee): + + """ + Return whether 'attendee' can be removed from the current object without + notification. """ - dtstart, dtstart_attr = obj.get_datetime_item("DTSTART") + return self.can_edit_attendee(attendee) or attendee == self.user + + def can_edit_attendee(self, attendee): + + "Return whether 'attendee' can be edited by an organiser." + + return self.attendee_is_new(attendee) or not self.obj.is_shared() + + def attendee_is_new(self, attendee): + + "Return whether 'attendee' is new to the current object." + + return attendee not in self.get_stored_attendees() - 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 + # Access to stored object information. + + def is_organiser(self): + return get_uri(self.obj.get_value("ORGANIZER")) == self.user + def get_stored_attendees(self): + return uri_values(self.obj.get_values("ATTENDEE") or []) + + def get_stored_main_period(self): + + "Return the main event period for the current object." + + (dtstart, dtstart_attr), (dtend, dtend_attr) = self.obj.get_main_period_items(self.get_tzid()) return EventPeriod(dtstart, dtend, self.get_tzid(), None, dtstart_attr, dtend_attr) - def get_stored_recurrences(self, obj): + def get_stored_recurrences(self): - "Return recurrences computed using the given 'obj'." + "Return recurrences computed using the current object." recurrences = [] - for period in self.get_periods(obj): + for period in self.get_periods(self.obj): if period.origin != "DTSTART": recurrences.append(period) return recurrences - # Request logic methods. - - def is_initial_load(self): - - "Return whether the event is being loaded and shown for the first time." - - return not self.env.get_args().has_key("editing") - - def handle_request(self, obj): - - """ - Handle actions involving the given 'obj' as an 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() - uid = obj.get_uid() - recurrenceid = obj.get_recurrenceid() - - # Get the possible actions. - - reply = args.has_key("reply") - discard = args.has_key("discard") - create = args.has_key("create") - cancel = args.has_key("cancel") - ignore = args.has_key("ignore") - save = args.has_key("save") - - have_action = reply or discard or create or cancel or ignore or save - - 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. - - single_user = False - - if reply or create or cancel or save: - - # Update principal event details if organiser. - - if self.is_organiser(obj): - - # Update time periods (main and recurring). - - try: - period = self.handle_main_period() - except PeriodError, exc: - return exc.args - - try: - periods = self.handle_recurrence_periods() - except PeriodError, exc: - return exc.args - - # Set the periods in the object, first obtaining removed and - # modified period information. - - to_unschedule = self.get_removed_periods() - - obj.set_period(period) - obj.set_periods(periods) - - # Update summary. - - if args.has_key("summary"): - obj["SUMMARY"] = [(args["summary"][0], {})] - - # Obtain any participants and those to be removed. + # Access to current object information. - attendees = self.get_attendees_from_page() - removed = [attendees[int(i)] for i in args.get("remove", [])] - to_cancel = self.update_attendees(obj, attendees, removed) - single_user = not attendees or attendees == [self.user] - - # Update attendee participation for the current user. - - if args.has_key("partstat"): - self.update_participation(obj, args["partstat"][0]) - - # Process any action. - - invite = not save and create and not single_user - save = save or create and single_user - - handled = True - - if reply or invite or cancel: - - client = ManagerClient(obj, self.user, self.messenger) - - # Process the object and remove it from the list of requests. - - if reply and client.process_received_request(): - self.remove_request(uid, recurrenceid) - - elif self.is_organiser(obj) and (invite or cancel): - - # Invitation, uninvitation and unscheduling... - - if client.process_created_request( - invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): - - self.remove_request(uid, recurrenceid) - - # Save single user events. - - elif save: - 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_main_period(self): - - "Return period details for the main start/end period in an event." - - return self.get_main_period().as_event_period() - - def handle_recurrence_periods(self): - - "Return period details for the recurrences specified for an event." - - return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences())] - - def get_date_control_values(self, name, multiple=False, tzid_name=None): - - """ - Return a dictionary containing date, time and tzid entries for fields - starting with 'name'. If 'multiple' is set to a true value, many - dictionaries will be returned corresponding to a collection of - datetimes. If 'tzid_name' is specified, the time zone information will - be acquired from a field starting with 'tzid_name' instead of 'name'. - """ + def get_current_main_period(self): + return self.get_stored_main_period() - 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" % (tzid_name or name), []) - - # Handle absent values by employing None values. - - field_values = map(None, dates, hours, minutes, seconds, tzids) - - if not field_values and not multiple: - all_values = FormDate() - else: - all_values = [] - for date, hour, minute, second, tzid in field_values: - value = FormDate(date, hour, minute, second, 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 get_current_main_period(self, obj): - - """ - Return the currently active main period for 'obj' depending on whether - editing has begun or whether the object has just been loaded. - """ - - if self.is_initial_load() or not self.is_organiser(obj): - return self.get_stored_main_period(obj) - else: - return self.get_main_period() - - def get_main_period(self): - - "Return the main period defined in the event form." - - args = self.env.get_args() - - dtend_enabled = args.get("dtend-control", [None])[0] - dttimes_enabled = args.get("dttimes-control", [None])[0] - start = self.get_date_control_values("dtstart") - end = self.get_date_control_values("dtend") - - return FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid()) - - def get_current_recurrences(self, obj): - - """ - Return recurrences for 'obj' using the original object where no editing - is in progress, using form data otherwise. - """ - - if self.is_initial_load() or not self.is_organiser(obj): - return self.get_stored_recurrences(obj) - else: - return self.get_recurrences() - - def get_recurrences(self): - - "Return the recurrences defined in the event form." - - args = self.env.get_args() - - all_dtend_enabled = args.get("dtend-control-recur", []) - all_dttimes_enabled = args.get("dttimes-control-recur", []) - all_starts = self.get_date_control_values("dtstart-recur", multiple=True) - all_ends = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") - all_origins = args.get("recur-origin", []) - - periods = [] + def get_current_recurrences(self): + return self.get_stored_recurrences() - for index, (start, end, dtend_enabled, dttimes_enabled, origin) in \ - enumerate(map(None, all_starts, all_ends, all_dtend_enabled, all_dttimes_enabled, all_origins)): - - dtend_enabled = str(index) in all_dtend_enabled - dttimes_enabled = str(index) in all_dttimes_enabled - period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), origin) - periods.append(period) - - return periods - - def get_removed_periods(self): - - "Return a list of recurrence periods to remove upon updating an event." - - to_unschedule = [] - args = self.env.get_args() - for i in args.get("recur-remove", []): - to_unschedule.append(periods[int(i)]) - return to_unschedule - - def get_current_attendees(self, obj): - - """ - Return attendees for 'obj' depending on whether the object is being - edited. - """ - - if self.is_initial_load() or not self.is_organiser(obj): - return self.get_stored_attendees(obj) - else: - return self.get_attendees_from_page() - - def get_attendees_from_page(self): - - """ - Return attendees from the request, normalised for iCalendar purposes, - and without duplicates. - """ - - args = self.env.get_args() - - attendees = args.get("attendee", []) - unique_attendees = set() - ordered_attendees = [] - - for attendee in attendees: - if not attendee.strip(): - continue - attendee = get_uri(attendee) - if attendee not in unique_attendees: - unique_attendees.add(attendee) - ordered_attendees.append(attendee) - - return ordered_attendees - - def update_attendees_from_page(self, obj): - - "Add or remove attendees. This does not affect the stored object." - - args = self.env.get_args() - - attendees = self.get_attendees_from_page() - existing_attendees = self.get_stored_attendees(obj) - - if args.has_key("add"): - attendees.append("") - - # Only actually remove attendees if the event is unsent, if the attendee - # is new, or if it is the current user being removed. - - if args.has_key("remove"): - for i in args["remove"]: - try: - attendee = attendees[int(i)] - except IndexError: - continue - - existing = attendee in existing_attendees - - if not existing or not obj.is_shared() or attendee == self.user: - attendees.remove(attendee) - - return attendees + def get_current_attendees(self): + return self.get_stored_attendees() # Page fragment methods. - def show_request_controls(self, obj): + def show_request_controls(self): - "Show form controls for a request concerning 'obj'." + "Show form controls for a request." page = self.page args = self.env.get_args() - attendees = self.get_current_attendees(obj) + attendees = self.get_current_attendees() is_attendee = self.user in attendees - is_request = self._have_request(obj.get_uid(), obj.get_recurrenceid()) + is_request = self._have_request(self.uid, self.recurrenceid) # Show appropriate options depending on the role of the user. - if is_attendee and not self.is_organiser(obj): + if is_attendee and not self.is_organiser(): page.p("An action is required for this request:") page.p() - self._control("reply", "submit", "Send reply") + self.control("reply", "submit", "Send reply") page.add(" ") - self._control("discard", "submit", "Discard event") + self.control("discard", "submit", "Discard event") page.add(" ") - self._control("ignore", "submit", "Do nothing for now") + self.control("ignore", "submit", "Do nothing for now") page.p.close() - if self.is_organiser(obj): + if self.is_organiser(): page.p("As organiser, you can perform the following:") page.p() - self._control("create", "submit", not obj.is_shared() and "Create event" or "Update event") + self.control("create", "submit", not self.obj.is_shared() and "Create event" or "Update event") page.add(" ") - if obj.is_shared() and not is_request: - self._control("cancel", "submit", "Cancel event") + if self.obj.is_shared() and not is_request: + self.control("cancel", "submit", "Cancel event") else: - self._control("discard", "submit", "Discard event") + self.control("discard", "submit", "Discard event") page.add(" ") - self._control("save", "submit", "Save without sending") + self.control("save", "submit", "Save without sending") page.p.close() - def show_object_on_page(self, obj, errors=None): + def show_object_on_page(self, errors=None): """ - Show the calendar object with the representation 'obj' on the current - page. If 'errors' is given, show a suitable message for the different - errors provided. + Show the calendar object on the current page. If 'errors' is given, show + a suitable message for the different errors provided. """ page = self.page @@ -474,26 +185,21 @@ # Add a hidden control to help determine whether editing has already begun. - self._control("editing", "hidden", "true") + self.control("editing", "hidden", "true") - uid = obj.get_uid() args = self.env.get_args() # Obtain basic event information, generating any necessary editing controls. - if self.is_initial_load() or not self.is_organiser(obj): - attendees = self.get_stored_attendees(obj) - else: - attendees = self.update_attendees_from_page(obj) - - p = self.get_current_main_period(obj) - self.show_object_datetime_controls(p) + attendees = self.get_current_attendees() + period = self.get_current_main_period() + self.show_object_datetime_controls(period) # Obtain any separate recurrences for this event. - recurrenceid = obj.get_recurrenceid() - recurrenceids = self._get_active_recurrences(uid) - replaced = not recurrenceid and p.is_replaced(recurrenceids) + recurrenceids = self._get_active_recurrences(self.uid) + replaced = not self.recurrenceid and period.is_replaced(recurrenceids) + excluded = period not in self.get_periods(self.obj) # Provide a summary of the object. @@ -508,7 +214,7 @@ for name, label in self.property_items: field = name.lower() - items = obj.get_items(name) or [] + items = uri_items(self.obj.get_items(name) or []) rowspan = len(items) if name == "ATTENDEE": @@ -522,7 +228,7 @@ # Handle datetimes specially. if name in ["DTSTART", "DTEND"]: - if not replaced: + if not replaced and not excluded: # Obtain the datetime. @@ -532,23 +238,36 @@ # basis of any potential datetime specified if dt-control is # set. - self.show_datetime_controls(obj, is_start and p.get_form_start() or p.get_form_end(), is_start) + self.show_datetime_controls(is_start and period.get_form_start() or period.get_form_end(), is_start) elif name == "DTSTART": - page.td(class_="objectvalue %s replaced" % field, rowspan=2) - page.a("First occurrence replaced by a separate event", href=self.link_to(uid, replaced)) - page.td.close() + + # Replaced occurrences link to their replacements. + + if replaced: + page.td(class_="objectvalue %s replaced" % field, rowspan=2) + page.a("First occurrence replaced by a separate event", href=self.link_to(self.uid, replaced)) + page.td.close() + + # NOTE: Should provide a way of editing recurrences when the + # NOTE: first occurrence is excluded, plus a way of + # NOTE: reinstating the occurrence. + + elif excluded: + page.td(class_="objectvalue %s excluded" % field, rowspan=2) + page.add("First occurrence excluded") + page.td.close() page.tr.close() # Handle the summary specially. elif name == "SUMMARY": - value = args.get("summary", [obj.get_value(name)])[0] + value = args.get("summary", [self.obj.get_value(name)])[0] page.td(class_="objectvalue summary") - if self.is_organiser(obj): - self._control("summary", "text", value, size=80) + if self.is_organiser(): + self.control("summary", "text", value, size=80) else: page.add(value) page.td.close() @@ -568,17 +287,17 @@ # Obtain details of attendees to supply attributes. - self.show_attendee(obj, i, value, attendee_map.get(value)) + self.show_attendee(i, value, attendee_map.get(value)) page.tr.close() # Allow more attendees to be specified. - if self.is_organiser(obj): + if self.is_organiser(): if not first: page.tr() page.td() - self._control("add", "submit", "add", id="add", class_="add") + self.control("add", "submit", "add", id="add", class_="add") page.label("Add attendee", for_="add", class_="add") page.td.close() page.tr.close() @@ -602,59 +321,62 @@ page.tbody.close() page.table.close() - self.show_recurrences(obj, errors) - self.show_conflicting_events(obj) - self.show_request_controls(obj) + self.show_recurrences(errors) + self.show_counters() + self.show_conflicting_events() + self.show_request_controls() page.form.close() - def show_attendee(self, obj, i, attendee, attendee_attr): + def show_attendee(self, i, attendee, attendee_attr): """ - For the given object 'obj', show the attendee in position 'i' with the - given 'attendee' value, having 'attendee_attr' as any stored attributes. + For the current object, show the attendee in position 'i' with the given + 'attendee' value, having 'attendee_attr' as any stored attributes. """ page = self.page args = self.env.get_args() - existing = attendee_attr is not None partstat = attendee_attr and attendee_attr.get("PARTSTAT") page.td(class_="objectvalue") # Show a form control as organiser for new attendees. - if self.is_organiser(obj) and (not existing or not obj.is_shared()): - self._control("attendee", "value", attendee, size="40") + if self.is_organiser() and self.can_edit_attendee(attendee): + self.control("attendee", "value", attendee, size="40") else: - self._control("attendee", "hidden", attendee) + self.control("attendee", "hidden", attendee) page.add(attendee) page.add(" ") # Show participation status, editable for the current user. if attendee == self.user: - self._show_menu("partstat", partstat, self.partstat_items, "partstat") + self.menu("partstat", partstat, self.partstat_items, "partstat") # Allow the participation indicator to act as a submit # button in order to refresh the page and show a control for # the current user, if indicated. - elif self.is_organiser(obj) and not existing: - self._control("partstat-refresh", "submit", "refresh", id="partstat-%d" % i, class_="refresh") + elif self.is_organiser() and self.attendee_is_new(attendee): + self.control("partstat-refresh", "submit", "refresh", id="partstat-%d" % i, class_="refresh") page.label(dict(self.partstat_items).get(partstat, ""), for_="partstat-%s" % i, class_="partstat") + + # Otherwise, just show a label with the participation status. + else: page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") # Permit organisers to remove attendees. - if self.is_organiser(obj): + if self.is_organiser(): # Permit the removal of newly-added attendees. - remove_type = (not existing or not obj.is_shared() or attendee == self.user) and "submit" or "checkbox" - self._control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") + remove_type = self.can_remove_attendee(attendee) and "submit" or "checkbox" + self.control("remove", remove_type, str(i), str(i) in args.get("remove", []), id="remove-%d" % i, class_="remove") page.label("Remove", for_="remove-%d" % i, class_="remove") page.label(for_="remove-%d" % i, class_="removed") @@ -664,48 +386,44 @@ page.td.close() - def show_recurrences(self, obj, errors=None): + def show_recurrences(self, errors=None): """ - Show recurrences for the object having the given representation 'obj'. - If 'errors' is given, show a suitable message for the different errors - provided. + Show recurrences for the current object. If 'errors' is given, show a + suitable message for the different errors provided. """ page = self.page # Obtain any parent object if this object is a specific recurrence. - uid = obj.get_uid() - recurrenceid = obj.get_recurrenceid() - - if recurrenceid: - parent = self.get_stored_object(uid, None) + if self.recurrenceid: + parent = self.get_stored_object(self.uid, None) if not parent: return page.p() - page.a("This event modifies a recurring event.", href=self.link_to(uid)) + page.a("This event modifies a recurring event.", href=self.link_to(self.uid)) page.p.close() # Obtain the periods associated with the event. # NOTE: Add a control to add recurrences here. - recurrences = self.get_current_recurrences(obj) + recurrences = self.get_current_recurrences() if len(recurrences) < 1: return - recurrenceids = self._get_recurrences(uid) + recurrenceids = self._get_recurrences(self.uid) page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) # Show each recurrence in a separate table if editable. - if self.is_organiser(obj) and recurrences: + if self.is_organiser() and recurrences: for index, period in enumerate(recurrences): - self.show_recurrence(obj, index, period, recurrenceid, recurrenceids, errors) + self.show_recurrence(index, period, self.recurrenceid, recurrenceids, errors) # Otherwise, use a compact single table. @@ -722,21 +440,21 @@ for index, period in enumerate(recurrences): page.tr() - self.show_recurrence_label(period, recurrenceid, recurrenceids, True) - self.show_recurrence_label(period, recurrenceid, recurrenceids, False) + self.show_recurrence_label(period, self.recurrenceid, recurrenceids, True) + self.show_recurrence_label(period, self.recurrenceid, recurrenceids, False) page.tr.close() page.tbody.close() page.table.close() - def show_recurrence(self, obj, index, period, recurrenceid, recurrenceids, errors=None): + def show_recurrence(self, index, period, recurrenceid, recurrenceids, errors=None): """ - Show recurrence controls for a recurrence provided by 'obj' with the - given 'index' position in the list of periods, the given 'period' - details, where a 'recurrenceid' indicates any specific recurrence, and - where 'recurrenceids' indicates all known additional recurrences for the - object. + Show recurrence controls for a recurrence provided by the current object + with the given 'index' position in the list of periods, the given + 'period' details, where a 'recurrenceid' indicates any specific + recurrence, and where 'recurrenceids' indicates all known additional + recurrences for the object. If 'errors' is given, show a suitable message for the different errors provided. @@ -761,12 +479,12 @@ page.tr() error = errors and ("dtstart", index) in errors and " error" or "" page.th("Start", class_="objectheading start%s" % error) - self.show_recurrence_controls(obj, index, period, recurrenceid, recurrenceids, True) + self.show_recurrence_controls(index, period, recurrenceid, recurrenceids, True) page.tr.close() page.tr() error = errors and ("dtend", index) in errors and " error" or "" page.th("End", class_="objectheading end%s" % error) - self.show_recurrence_controls(obj, index, period, recurrenceid, recurrenceids, False) + self.show_recurrence_controls(index, period, recurrenceid, recurrenceids, False) page.tr.close() # Permit the removal of recurrences. @@ -776,8 +494,9 @@ page.th("") page.td() - remove_type = not obj.is_shared() or not period.origin and "submit" or "checkbox" - self._control("recur-remove", remove_type, str(index), + remove_type = self.can_remove_recurrence(period) and "submit" or "checkbox" + + self.control("recur-remove", remove_type, str(index), str(index) in args.get("recur-remove", []), id="recur-remove-%d" % index, class_="remove") @@ -795,53 +514,107 @@ page.div.close() - def show_conflicting_events(self, obj): + def show_counters(self): - """ - Show conflicting events for the object having the representation 'obj'. - """ + "Show any counter-proposals for the current object." page = self.page - uid = obj.get_uid() - recurrenceid = obj.get_recurrenceid() - recurrenceids = self._get_active_recurrences(uid) + query = self.env.get_query() + counter = query.get("counter", [None])[0] + + attendees = self._get_counters(self.uid, self.recurrenceid) + tzid = self.get_tzid() + + if not attendees: + return + + page.p("The following counter-proposals have been received for this event:") + + page.table(cellspacing=5, cellpadding=5, class_="counters") + page.thead() + page.tr() + page.th("Attendee", rowspan=2) + page.th("Periods", colspan=2) + page.tr.close() + page.tr() + page.th("Start") + page.th("End") + page.tr.close() + page.thead.close() + page.tbody() + + for attendee in attendees: + obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) + periods = self.get_periods(obj) + + first = True + for p in periods: + identifier = "%s-%s" % (format_datetime(p.get_start_point()), format_datetime(p.get_end_point())) + css = identifier == counter and "selected" or "" + + if first: + page.tr(rowspan=len(periods), class_=css) + page.td(attendee) + first = False + else: + page.tr(class_=css) + + start = self.format_datetime(to_timezone(p.get_start(), tzid), "long") + end = self.format_datetime(to_timezone(p.get_end(), tzid), "long") + + page.td(start) + page.td(end) + + page.tr.close() + + page.tbody.close() + page.table.close() + + def show_conflicting_events(self): + + "Show conflicting events for the current object." + + page = self.page + recurrenceids = self._get_active_recurrences(self.uid) # Obtain the user's timezone. tzid = self.get_tzid() - periods = self.get_periods(obj) + periods = self.get_periods(self.obj) # Indicate whether there are conflicting events. conflicts = [] - attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) + attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE")) - for participant in self.get_current_attendees(obj): + for participant in self.get_current_attendees(): if participant == self.user: freebusy = self.store.get_freebusy(participant) + elif participant: + freebusy = self.store.get_freebusy_for_other(self.user, participant) else: - freebusy = self.store.get_freebusy_for_other(self.user, participant) + continue if not freebusy: continue # Obtain any time zone details from the suggested event. - _dtstart, attr = obj.get_item("DTSTART") + _dtstart, attr = self.obj.get_item("DTSTART") tzid = attr.get("TZID", tzid) # Show any conflicts with periods of actual attendance. participant_attr = attendee_map.get(participant) partstat = participant_attr and participant_attr.get("PARTSTAT") - recurrences = obj.get_recurrence_start_points(recurrenceids, tzid) + recurrences = self.obj.get_recurrence_start_points(recurrenceids, tzid) for p in have_conflict(freebusy, periods, True): - if not recurrenceid and p.is_replaced(recurrences): + if not self.recurrenceid and p.is_replaced(recurrences): continue if ( # Unidentified or different event - (p.uid != uid or recurrenceid and p.recurrenceid and p.recurrenceid != recurrenceid) and + (p.uid != self.uid or self.recurrenceid and p.recurrenceid and p.recurrenceid != self.recurrenceid) and # Different period or unclear participation with the same period (p not in periods or not partstat in ("ACCEPTED", "TENTATIVE")) and # Participant not limited to organising @@ -893,188 +666,396 @@ page.tbody.close() page.table.close() - # Generation of controls within page fragments. +class EventPage(EventPageFragment): + + "A request handler for the event page." + + def __init__(self, resource=None, messenger=None): + ResourceClientForObject.__init__(self, resource) + self.messenger = messenger or Messenger() - def show_object_datetime_controls(self, period, index=None): + # Request logic methods. + + def is_initial_load(self): + + "Return whether the event is being loaded and shown for the first time." + + return not self.env.get_args().has_key("editing") + + def handle_request(self): """ - Show datetime-related controls if already active or if an object needs - them for the given 'period'. The given 'index' is used to parameterise - individual controls for dynamic manipulation. + Handle actions involving the current object, returning an error if one + occurred, or None if the request was successfully handled. """ - p = form_period_from_period(period) + # Handle a submitted form. - page = self.page args = self.env.get_args() - _id = self.element_identifier - _name = self.element_name - _enable = self.element_enable + + # Get the possible actions. + + reply = args.has_key("reply") + discard = args.has_key("discard") + create = args.has_key("create") + cancel = args.has_key("cancel") + ignore = args.has_key("ignore") + save = args.has_key("save") - # 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. + have_action = reply or discard or create or cancel or ignore or save + + if not have_action: + return ["action"] - if index is not None: - page.style(type="text/css") + # If ignoring the object, return to the calendar. - # Unlike the rules for object properties, these affect recurrence - # properties. + if ignore: + self.redirect(self.env.get_path()) + return None + + # Update the object. - 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}) + single_user = False + + if reply or create or cancel or save: + + # Update principal event details if organiser. + + if self.is_organiser(): + + # Update time periods (main and recurring). - page.style.close() + try: + period = self.handle_main_period() + except PeriodError, exc: + return exc.args - self._control( - _name("dtend-control", "recur", index), "checkbox", - _enable(index), p.end_enabled, - id=_id("dtend-enable", index) - ) + try: + periods = self.handle_recurrence_periods() + except PeriodError, exc: + return exc.args + + # Set the periods in the object, first obtaining removed and + # modified period information. + + to_unschedule, to_exclude = self.get_removed_periods(periods) + + self.obj.set_period(period) + self.obj.set_periods(periods) + self.obj.update_exceptions(to_exclude) - self._control( - _name("dttimes-control", "recur", index), "checkbox", - _enable(index), p.times_enabled, - id=_id("dttimes-enable", index) - ) + # Update summary. + + if args.has_key("summary"): + self.obj["SUMMARY"] = [(args["summary"][0], {})] + + # Obtain any participants and those to be removed. - def show_datetime_controls(self, obj, formdate, show_start): + attendees = self.get_attendees_from_page() + removed = [attendees[int(i)] for i in args.get("remove", [])] + to_cancel = self.update_attendees(self.obj, attendees, removed) + single_user = not attendees or attendees == [self.user] + + # Update attendee participation for the current user. - """ - Show datetime details from the given 'obj' for the 'formdate', showing - start details if 'show_start' is set to a true value. Details will - appear as controls for organisers and labels for attendees. - """ + if args.has_key("partstat"): + self.update_participation(self.obj, args["partstat"][0]) + + # Process any action. - page = self.page + invite = not save and create and not single_user + save = save or create and single_user - # Show controls for editing as organiser. + handled = True - if self.is_organiser(obj): - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + if reply or invite or cancel: + + client = ManagerClient(self.obj, self.user, self.messenger) - if show_start: - page.div(class_="dt enabled") - self._show_date_controls("dtstart", formdate) - 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() + # Process the object and remove it from the list of requests. + + if reply and client.process_received_request(): + self.remove_request(self.uid, self.recurrenceid) + + elif self.is_organiser() and (invite or cancel): + + # Invitation, uninvitation and unscheduling... + + if client.process_created_request( + invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): + + self.remove_request(self.uid, self.recurrenceid) - 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", formdate) - page.br() - page.label("End on same day", for_="dtend-enable", class_="disable") - page.div.close() + # Save single user events. + + elif save: + self.store.set_event(self.user, self.uid, self.recurrenceid, node=self.obj.to_node()) + self.update_event_in_freebusy() + self.remove_request(self.uid, self.recurrenceid) - page.td.close() + # Remove the request and the object. - # Show a label as attendee. + elif discard: + self.remove_event_from_freebusy() + self.remove_event(self.uid, self.recurrenceid) + self.remove_request(self.uid, self.recurrenceid) else: - dt = formdate.as_datetime() - if dt: - page.td(self.format_datetime(dt, "full")) - else: - page.td("(Unrecognised date)") + handled = False + + # Upon handling an action, redirect to the main page. + + if handled: + self.redirect(self.env.get_path()) + + return None + + def handle_main_period(self): + + "Return period details for the main start/end period in an event." + + return self.get_main_period_from_page().as_event_period() + + def handle_recurrence_periods(self): + + "Return period details for the recurrences specified for an event." + + return [p.as_event_period(i) for i, p in enumerate(self.get_recurrences_from_page())] + + # Access to form-originating object information. + + def get_main_period_from_page(self): + + "Return the main period defined in the event form." + + args = self.env.get_args() + + dtend_enabled = args.get("dtend-control", [None])[0] + dttimes_enabled = args.get("dttimes-control", [None])[0] + start = self.get_date_control_values("dtstart") + end = self.get_date_control_values("dtend") + + period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid()) + + # Handle absent main period details. + + if not period.get_start(): + return self.get_stored_main_period() + else: + return period + + def get_recurrences_from_page(self): + + "Return the recurrences defined in the event form." - def show_recurrence_controls(self, obj, index, period, recurrenceid, recurrenceids, show_start): + args = self.env.get_args() + + all_dtend_enabled = args.get("dtend-control-recur", []) + all_dttimes_enabled = args.get("dttimes-control-recur", []) + all_starts = self.get_date_control_values("dtstart-recur", multiple=True) + all_ends = self.get_date_control_values("dtend-recur", multiple=True, tzid_name="dtstart-recur") + all_origins = args.get("recur-origin", []) + + periods = [] + + for index, (start, end, dtend_enabled, dttimes_enabled, origin) in \ + enumerate(map(None, all_starts, all_ends, all_dtend_enabled, all_dttimes_enabled, all_origins)): + + dtend_enabled = str(index) in all_dtend_enabled + dttimes_enabled = str(index) in all_dttimes_enabled + period = FormPeriod(start, end, dtend_enabled, dttimes_enabled, self.get_tzid(), origin) + periods.append(period) + + return periods + + def set_recurrences_in_page(self, recurrences): + + "Set the recurrences defined in the event form." + + args = self.env.get_args() + + args["dtend-control-recur"] = [] + args["dttimes-control-recur"] = [] + args["recur-origin"] = [] + + all_starts = [] + all_ends = [] + + for index, period in enumerate(recurrences): + if period.end_enabled: + args["dtend-control-recur"].append(str(index)) + if period.times_enabled: + args["dttimes-control-recur"].append(str(index)) + args["recur-origin"].append(period.origin or "") + + all_starts.append(period.get_form_start()) + all_ends.append(period.get_form_end()) + + self.set_date_control_values("dtstart-recur", all_starts) + self.set_date_control_values("dtend-recur", all_ends, tzid_name="dtstart-recur") + + def get_removed_periods(self, periods): """ - Show datetime details from the given 'obj' for the recurrence having the - given 'index', with the recurrence period described by 'period', - indicating a start, end and origin of the period from the event details, - employing any 'recurrenceid' and 'recurrenceids' for the object to - configure the displayed information. + Return those from the recurrence 'periods' to remove upon updating an + event along with those to exclude in a tuple of the form (unscheduled, + excluded). + """ + + args = self.env.get_args() + to_unschedule = [] + to_exclude = [] - If 'show_start' is set to a true value, the start details will be shown; - otherwise, the end details will be shown. + for i in args.get("recur-remove", []): + try: + period = periods[int(i)] + except (IndexError, ValueError): + continue + + if not self.can_edit_recurrence(period): + to_unschedule.append(period) + else: + to_exclude.append(period) + + return to_unschedule, to_exclude + + def get_attendees_from_page(self): + + """ + Return attendees from the request, normalised for iCalendar purposes. """ - page = self.page - _id = self.element_identifier - _name = self.element_name + args = self.env.get_args() + attendees = [] + + for attendee in args.get("attendee", []): + if attendee.strip(): + attendee = get_uri(attendee) + attendees.append(attendee) - p = event_period_from_period(period) - replaced = not recurrenceid and p.is_replaced(recurrenceids) + return attendees + + def update_attendees_from_page(self): - # Show controls for editing as organiser. + "Add or remove attendees. This does not affect the stored object." + + args = self.env.get_args() + + attendees = self.get_attendees_from_page() - if self.is_organiser(obj) and not replaced: - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + if args.has_key("add"): + attendees.append("") - read_only = period.origin == "RRULE" + # Only actually remove attendees if the event is unsent, if the attendee + # is new, or if it is the current user being removed. + + if args.has_key("remove"): + still_to_remove = [] - if show_start: - page.div(class_="dt enabled") - self._show_date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only) - if not read_only: - page.br() - page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") - page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") - page.div.close() + for i in args["remove"]: + try: + attendee = attendees[int(i)] + except IndexError: + continue + + if self.can_remove_attendee(attendee): + attendees.remove(attendee) + else: + still_to_remove.append(i) - # Put the origin somewhere. + args["remove"] = still_to_remove + + args["attendee"] = attendees + return attendees + + def update_recurrences_from_page(self): + + "Add or remove recurrences. This does not affect the stored object." - self._control("recur-origin", "hidden", p.origin or "") + args = self.env.get_args() + + recurrences = self.get_recurrences_from_page() + + # NOTE: Addition of recurrences to be supported. + + # Only actually remove recurrences if the event is unsent, or if the + # recurrence is new, but only for explicit recurrences. - else: - page.div(class_="dt disabled") - if not read_only: - page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") - page.div.close() - page.div(class_="dt enabled") - self._show_date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only) - if not read_only: - page.br() - page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") - page.div.close() + if args.has_key("recur-remove"): + still_to_remove = [] + + for i in args["recur-remove"]: + try: + recurrence = recurrences[int(i)] + except IndexError: + continue - page.td.close() + if self.can_remove_recurrence(recurrence): + recurrences.remove(recurrence) + else: + still_to_remove.append(i) - # Show label as attendee. + args["recur-remove"] = still_to_remove - else: - self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start) + self.set_recurrences_in_page(recurrences) + return recurrences - def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start): + # Access to current object information. + + def get_current_main_period(self): """ - Show datetime details for the given 'period', employing any - 'recurrenceid' and 'recurrenceids' for the object to configure the - displayed information. + Return the currently active main period for the current object depending + on whether editing has begun or whether the object has just been loaded. + """ - If 'show_start' is set to a true value, the start details will be shown; - otherwise, the end details will be shown. + if self.is_initial_load() or not self.is_organiser(): + return self.get_stored_main_period() + else: + return self.get_main_period_from_page() + + def get_current_recurrences(self): + + """ + Return recurrences for the current object using the original object + details where no editing is in progress, using form data otherwise. """ - page = self.page + if self.is_initial_load() or not self.is_organiser(): + return self.get_stored_recurrences() + else: + return self.get_recurrences_from_page() + + def update_current_recurrences(self): - p = event_period_from_period(period) - replaced = not recurrenceid and p.is_replaced(recurrenceids) + "Return an updated collection of recurrences for the current object." + + if self.is_initial_load() or not self.is_organiser(): + return self.get_stored_recurrences() + else: + return self.update_recurrences_from_page() + + def get_current_attendees(self): - css = " ".join([ - replaced and "replaced" or "", - p.is_affected(recurrenceid) and "affected" or "" - ]) + """ + Return attendees for the current object depending on whether the object + has been edited or instead provides such information from its stored + form. + """ - formdate = show_start and p.get_form_start() or p.get_form_end() - dt = formdate.as_datetime() - if dt: - page.td(self.format_datetime(dt, "long"), class_=css) + if self.is_initial_load() or not self.is_organiser(): + return self.get_stored_attendees() else: - page.td("(Unrecognised date)") + return self.get_attendees_from_page() + + def update_current_attendees(self): + + "Return an updated collection of attendees for the current object." + + if self.is_initial_load() or not self.is_organiser(): + return self.get_stored_attendees() + else: + return self.update_attendees_from_page() # Full page output methods. @@ -1082,146 +1063,24 @@ "Show an object request using the given 'path_info' for the current user." - uid, recurrenceid, section = self.get_identifiers(path_info) - obj = self.get_stored_object(uid, recurrenceid, section) + uid, recurrenceid = self.get_identifiers(path_info) + obj = self.get_stored_object(uid, recurrenceid) + self.set_object(obj) if not obj: return False - errors = self.handle_request(obj) + errors = self.handle_request() if not errors: return True + self.update_current_attendees() + self.update_current_recurrences() + self.new_page(title="Event") - self.show_object_on_page(obj, errors) + self.show_object_on_page(errors) return True - # Utility methods. - - def _control(self, name, type, value, selected=False, **kw): - - """ - Show a control with the given 'name', 'type' and 'value', with - 'selected' indicating whether it should be selected (checked or - equivalent), and with keyword arguments setting other properties. - """ - - page = self.page - if selected: - page.input(name=name, type=type, value=value, checked=selected, **kw) - else: - page.input(name=name, type=type, value=value, **kw) - - 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, index=None, show_tzid=True, read_only=False): - - """ - Show date controls for a field with the given 'name' and 'default' form - date value. - - If 'index' is specified, default field values will be overridden by the - element from a collection of existing form values with the specified - index; otherwise, field values will be overridden by a single form - value. - - If 'show_tzid' is set to a false value, the time zone menu will not be - provided. - - If 'read_only' is set to a true value, the controls will be hidden and - labels will be employed instead. - """ - - page = self.page - - # Show dates for up to one week around the current date. - - dt = default.as_datetime() - if not dt: - dt = date.today() - - base = to_date(dt) - - # Show a date label with a hidden field if read-only. - - if read_only: - self._control("%s-date" % name, "hidden", format_datetime(base)) - page.span(self.format_date(base, "long")) - - # Show dates for up to one week around the current date. - # NOTE: Support paging to other dates. - - else: - 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. - - page.span(class_="time enabled") - - if read_only: - page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) - self._control("%s-hour" % name, "hidden", default.get_hour()) - self._control("%s-minute" % name, "hidden", default.get_minute()) - self._control("%s-second" % name, "hidden", default.get_second()) - else: - self._control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) - page.add(":") - self._control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) - page.add(":") - self._control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) - - # Show time zone details. - - if show_tzid: - page.add(" ") - tzid = default.get_tzid() or self.get_tzid() - - # Show a label if read-only or a menu otherwise. - - if read_only: - self._control("%s-tzid" % name, "hidden", tzid) - page.span(tzid) - else: - self._show_timezone_menu("%s-tzid" % name, 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 79493ac5b434 -r e0ed770d17be imipweb/resource.py --- a/imipweb/resource.py Thu Sep 24 19:13:39 2015 +0200 +++ b/imipweb/resource.py Tue Sep 29 00:25:31 2015 +0200 @@ -19,27 +19,34 @@ this program. If not, see . """ -from datetime import datetime -from imiptools.client import Client +from datetime import datetime, timedelta +from imiptools.client import Client, ClientForObject from imiptools.data import get_uri, uri_values -from imiptools.dates import get_recurrence_start_point +from imiptools.dates import format_datetime, get_recurrence_start_point, to_date from imiptools.period import remove_period, remove_affected_period +from imipweb.data import event_period_from_period, form_period_from_period, FormDate from imipweb.env import CGIEnvironment +from urllib import urlencode import babel.dates import imip_store import markup +import pytz -class Resource(Client): +class Resource: - "A Web application resource and calendar client." + "A Web application resource." def __init__(self, resource=None): + + """ + Initialise a resource, allowing it to share the environment of any given + existing 'resource'. + """ + 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.objects = {} self.locale = None self.requests = None @@ -47,14 +54,6 @@ 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): @@ -83,30 +82,19 @@ self.new_page(title="Redirect") self.page.p("Redirecting to: %s" % url) - def link_to(self, uid, recurrenceid=None, section=None): + def link_to(self, uid, recurrenceid=None, args=None): """ - Return a link to an object with the given 'uid', 'recurrenceid' and - 'section'. See get_identifiers for the decoding of such links. + Return a link to an object with the given 'uid' and 'recurrenceid'. + See get_identifiers for the decoding of such links. + + If 'args' is specified, the given dictionary is encoded and included. """ path = [uid] if recurrenceid: path.append(recurrenceid) - if section: - path.append(section) - return self.env.new_url("/".join(path)) - - # Control naming helpers. - - def element_identifier(self, name, index=None): - return index is not None and "%s-%d" % (name, index) or name - - def element_name(self, name, suffix, index=None): - return index is not None and "%s-%s" % (name, suffix) or name - - def element_enable(self, index=None): - return index is not None and str(index) or "enable" + return "%s%s" % (self.env.new_url("/".join(path)), args and ("?%s" % urlencode(args)) or "") # Access to objects. @@ -122,26 +110,18 @@ # UID only. if len(parts) == 1: - return parts[0], None, None - - # UID and RECURRENCE-ID or UID and section. + return parts[0], None - elif len(parts) == 2: - if parts[1] == "counter": - return parts[0], None, "counters" - else: - return parts[0], parts[1], parts[2] == "counter" and "counters" or None - - # UID, RECURRENCE-ID and section. + # UID and RECURRENCE-ID. else: - return parts[:3] + return parts[:2] - def _get_object(self, uid, recurrenceid=None, section=None): - if self.objects.has_key((uid, recurrenceid, section)): - return self.objects[(uid, recurrenceid, section)] + def _get_object(self, uid, recurrenceid=None, section=None, username=None): + if self.objects.has_key((uid, recurrenceid, section, username)): + return self.objects[(uid, recurrenceid, section, username)] - obj = self.objects[(uid, recurrenceid, section)] = self.get_stored_object(uid, recurrenceid, section) + obj = self.objects[(uid, recurrenceid, section, username)] = self.get_stored_object(uid, recurrenceid, section, username) return obj def _get_recurrences(self, uid): @@ -158,6 +138,9 @@ def _have_request(self, uid, recurrenceid=None, type=None, strict=False): return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict) + def _get_counters(self, uid, recurrenceid=None): + return self.store.get_counters(self.user, uid, recurrenceid) + def _get_request_summary(self): "Return a list of periods comprising the request summary." @@ -165,15 +148,27 @@ summary = [] for uid, recurrenceid, request_type in self._get_requests(): - obj = self.get_stored_object(uid, recurrenceid) - if obj: - recurrenceids = self._get_active_recurrences(uid) + + # Obtain either normal objects or counter-proposals. + + if not request_type: + objs = [self._get_object(uid, recurrenceid)] + elif request_type == "COUNTER": + objs = [] + for attendee in self.store.get_counters(self.user, uid, recurrenceid): + objs.append(self._get_object(uid, recurrenceid, "counters", attendee)) - # Obtain only active periods, not those replaced by redefined - # recurrences, converting to free/busy periods. + # For each object, obtain the periods involved. + + for obj in objs: + if obj: + recurrenceids = self._get_active_recurrences(uid) - for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()): - summary.append(obj.get_freebusy_period(p)) + # Obtain only active periods, not those replaced by redefined + # recurrences, converting to free/busy periods. + + for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()): + summary.append(obj.get_freebusy_period(p)) return summary @@ -208,55 +203,403 @@ def remove_event(self, uid, recurrenceid=None): return self.store.remove_event(self.user, uid, recurrenceid) - def update_freebusy(self, uid, recurrenceid, obj): +class ResourceClient(Resource, Client): + + "A Web application resource and calendar client." + + def __init__(self, resource=None): + Resource.__init__(self, resource) + user = self.env.get_user() + Client.__init__(self, user and get_uri(user) or None) + +class ResourceClientForObject(Resource, ClientForObject): + + "A Web application resource and calendar client for a specific object." + + def __init__(self, resource=None): + Resource.__init__(self, resource) + user = self.env.get_user() + ClientForObject.__init__(self, None, user and get_uri(user) or None) + +class FormUtilities: + + "Utility methods resource mix-in." + + def control(self, name, type, value, selected=False, **kw): + + """ + Show a control with the given 'name', 'type' and 'value', with + 'selected' indicating whether it should be selected (checked or + equivalent), and with keyword arguments setting other properties. + """ + + page = self.page + if type in ("checkbox", "radio") and selected: + page.input(name=name, type=type, value=value, checked=selected, **kw) + else: + page.input(name=name, type=type, value=value, **kw) + + def 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 date_controls(self, name, default, index=None, show_tzid=True, read_only=False): """ - Update stored free/busy details for the event with the given 'uid' and - 'recurrenceid' having a representation of 'obj'. + Show date controls for a field with the given 'name' and 'default' form + date value. + + If 'index' is specified, default field values will be overridden by the + element from a collection of existing form values with the specified + index; otherwise, field values will be overridden by a single form + value. + + If 'show_tzid' is set to a false value, the time zone menu will not be + provided. + + If 'read_only' is set to a true value, the controls will be hidden and + labels will be employed instead. + """ + + page = self.page + + # Show dates for up to one week around the current date. + + dt = default.as_datetime() + if not dt: + dt = date.today() + + base = to_date(dt) + + # Show a date label with a hidden field if read-only. + + if read_only: + self.control("%s-date" % name, "hidden", format_datetime(base)) + page.span(self.format_date(base, "long")) + + # Show dates for up to one week around the current date. + # NOTE: Support paging to other dates. + + else: + items = [] + for i in range(-7, 8): + d = base + timedelta(i) + items.append((format_datetime(d), self.format_date(d, "full"))) + self.menu("%s-date" % name, format_datetime(base), items, index=index) + + # Show time details. + + page.span(class_="time enabled") + + if read_only: + page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second())) + self.control("%s-hour" % name, "hidden", default.get_hour()) + self.control("%s-minute" % name, "hidden", default.get_minute()) + self.control("%s-second" % name, "hidden", default.get_second()) + else: + self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2) + page.add(":") + self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2) + page.add(":") + self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2) + + # Show time zone details. + + if show_tzid: + page.add(" ") + tzid = default.get_tzid() or self.get_tzid() + + # Show a label if read-only or a menu otherwise. + + if read_only: + self.control("%s-tzid" % name, "hidden", tzid) + page.span(tzid) + else: + self.timezone_menu("%s-tzid" % name, tzid, index) + + page.span.close() + + def 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.menu(name, default, entries, index=index) + +class DateTimeFormUtilities: + + "Date/time control methods resource mix-in." + + # Control naming helpers. + + def element_identifier(self, name, index=None): + return index is not None and "%s-%d" % (name, index) or name + + def element_name(self, name, suffix, index=None): + return index is not None and "%s-%s" % (name, suffix) or name + + def element_enable(self, index=None): + return index is not None and str(index) or "enable" + + def show_object_datetime_controls(self, period, index=None): + + """ + Show datetime-related controls if already active or if an object needs + them for the given 'period'. The given 'index' is used to parameterise + individual controls for dynamic manipulation. """ - is_only_organiser = self.user not in uri_values(obj.get_values("ATTENDEE")) + p = form_period_from_period(period) + + page = self.page + args = self.env.get_args() + _id = self.element_identifier + _name = self.element_name + _enable = self.element_enable - freebusy = self.store.get_freebusy(self.user) + # 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. - Client.update_freebusy(self, freebusy, self.get_periods(obj), - is_only_organiser and "ORG" or obj.get_value("TRANSP"), - uid, recurrenceid, - obj.get_value("SUMMARY"), - obj.get_value("ORGANIZER")) + 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() + + self.control( + _name("dtend-control", "recur", index), "checkbox", + _enable(index), p.end_enabled, + id=_id("dtend-enable", index) + ) + + self.control( + _name("dttimes-control", "recur", index), "checkbox", + _enable(index), p.times_enabled, + id=_id("dttimes-enable", index) + ) + + def show_datetime_controls(self, formdate, show_start): + + """ + Show datetime details from the current object for the 'formdate', + 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 + + # Show controls for editing as organiser. - # Subtract any recurrences from the free/busy details of a parent - # object. + if self.is_organiser(): + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + + if show_start: + page.div(class_="dt enabled") + self.date_controls("dtstart", formdate) + 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() - for recurrenceid in self._get_recurrences(uid): - remove_affected_period(freebusy, uid, obj.get_recurrence_start_point(recurrenceid, self.get_tzid())) + 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.date_controls("dtend", formdate) + page.br() + page.label("End on same day", for_="dtend-enable", class_="disable") + page.div.close() + + page.td.close() + + # Show a label as attendee. - self.store.set_freebusy(self.user, freebusy) - self.publish_freebusy(freebusy) + else: + dt = formdate.as_datetime() + if dt: + page.td(self.format_datetime(dt, "full")) + else: + page.td("(Unrecognised date)") + + def show_recurrence_controls(self, index, period, recurrenceid, recurrenceids, show_start): + + """ + Show datetime details from the current object for the recurrence having + the given 'index', with the recurrence period described by 'period', + indicating a start, end and origin of the period from the event details, + employing any 'recurrenceid' and 'recurrenceids' for the object to + configure the displayed information. - # Update free/busy provider information if the event may recur - # indefinitely. + 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 + _id = self.element_identifier + _name = self.element_name + + p = event_period_from_period(period) + replaced = not recurrenceid and p.is_replaced(recurrenceids) + + # Show controls for editing as organiser. + + if self.is_organiser() and not replaced: + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + + read_only = period.origin == "RRULE" - if obj.possibly_recurring_indefinitely(): - self.store.append_freebusy_provider(self.user, obj) + if show_start: + page.div(class_="dt enabled") + self.date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only) + if not read_only: + page.br() + page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable") + page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable") + page.div.close() + + # Put the origin somewhere. + + self.control("recur-origin", "hidden", p.origin or "") + + else: + page.div(class_="dt disabled") + if not read_only: + page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") + page.div.close() + page.div(class_="dt enabled") + self.date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only) + if not read_only: + page.br() + page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") + page.div.close() - 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) - self.publish_freebusy(freebusy) + page.td.close() + + # Show label as attendee. + + else: + self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start) + + def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start): + + """ + Show datetime details for the given 'period', 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 + + p = event_period_from_period(period) + replaced = not recurrenceid and p.is_replaced(recurrenceids) + + css = " ".join([ + replaced and "replaced" or "", + p.is_affected(recurrenceid) and "affected" or "" + ]) - # Update free/busy provider information if the event may recur - # indefinitely. + formdate = show_start and p.get_form_start() or p.get_form_end() + dt = formdate.as_datetime() + if dt: + page.td(self.format_datetime(dt, "long"), class_=css) + else: + page.td("(Unrecognised date)") + + def get_date_control_values(self, name, multiple=False, tzid_name=None): + + """ + Return a form date object representing fields starting with 'name'. If + 'multiple' is set to a true value, many date objects will be returned + corresponding to a collection of datetimes. - if obj.possibly_recurring_indefinitely(): - self.store.remove_freebusy_provider(self.user, obj) + If 'tzid_name' is specified, the time zone information will be acquired + from fields starting with 'tzid_name' instead of '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" % (tzid_name or name), []) + + # Handle absent values by employing None values. + + field_values = map(None, dates, hours, minutes, seconds, tzids) - def publish_freebusy(self, freebusy): + if not field_values and not multiple: + all_values = FormDate() + else: + all_values = [] + for date, hour, minute, second, tzid in field_values: + value = FormDate(date, hour, minute, second, 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 - "Publish the details if configured to share them." + def set_date_control_values(self, name, formdates, tzid_name=None): + + """ + Replace form fields starting with 'name' using the values of the given + 'formdates'. - if self.publisher and self.is_sharing() and self.is_publishing(): - self.publisher.set_freebusy(self.user, freebusy) + If 'tzid_name' is specified, the time zone information will be stored in + fields starting with 'tzid_name' instead of 'name'. + """ + + args = self.env.get_args() + + args["%s-date" % name] = [d.date for d in formdates] + args["%s-hour" % name] = [d.hour for d in formdates] + args["%s-minute" % name] = [d.minute for d in formdates] + args["%s-second" % name] = [d.second for d in formdates] + args["%s-tzid" % (tzid_name or name)] = [d.tzid for d in formdates] # vim: tabstop=4 expandtab shiftwidth=4 diff -r 79493ac5b434 -r e0ed770d17be tests/test_resource_invitation_constraints.sh --- a/tests/test_resource_invitation_constraints.sh Thu Sep 24 19:13:39 2015 +0200 +++ b/tests/test_resource_invitation_constraints.sh Tue Sep 29 00:25:31 2015 +0200 @@ -35,7 +35,7 @@ echo 'Europe/Oslo' > "$PREFS/$USER/TZID" echo 'share' > "$PREFS/$USER/freebusy_sharing" echo '10,12,14,16,18:0,15,30,45' > "$PREFS/$USER/permitted_times" -echo '60' > "$PREFS/$USER/freebusy_offers" +echo 'PT60S' > "$PREFS/$USER/freebusy_offers" "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-sauna-all.txt" 2>> $ERROR \ | "$SHOWMAIL" \ diff -r 79493ac5b434 -r e0ed770d17be tests/test_resource_invitation_constraints_alternative.sh --- a/tests/test_resource_invitation_constraints_alternative.sh Thu Sep 24 19:13:39 2015 +0200 +++ b/tests/test_resource_invitation_constraints_alternative.sh Tue Sep 29 00:25:31 2015 +0200 @@ -35,7 +35,7 @@ echo 'Europe/Oslo' > "$PREFS/$USER/TZID" echo 'share' > "$PREFS/$USER/freebusy_sharing" echo '10,12,14,16,18:0,15,30,45' > "$PREFS/$USER/permitted_times" -echo '60' > "$PREFS/$USER/freebusy_offers" +echo 'PT60S' > "$PREFS/$USER/freebusy_offers" "$RESOURCE_SCRIPT" $ARGS < "$TEMPLATES/fb-request-sauna-all.txt" 2>> $ERROR \ | "$SHOWMAIL" \ diff -r 79493ac5b434 -r e0ed770d17be vContent.py --- a/vContent.py Thu Sep 24 19:13:39 2015 +0200 +++ b/vContent.py Tue Sep 29 00:25:31 2015 +0200 @@ -62,6 +62,12 @@ pass +class WriteError(Exception): + + "General writing errors." + + pass + # Reader and parser classes. class Reader: @@ -570,12 +576,15 @@ encoding = parameters.get("ENCODING") charset = parameters.get("CHARSET") - if encoding == "QUOTED-PRINTABLE": - value = quopri.encodestring(value.encode(charset or "iso-8859-1")) - elif encoding == "BASE64": - value = base64.encodestring(value) + try: + if encoding == "QUOTED-PRINTABLE": + value = quopri.encodestring(value.encode(charset or "iso-8859-1")) + elif encoding == "BASE64": + value = base64.encodestring(value) - return self.encode_content(value) + return self.encode_content(value) + except TypeError: + raise WriteError, "Property %r value with parameters %r cannot be encoded: %r" % (name, parameters, value) # Overrideable methods.