# HG changeset patch # User Paul Boddie # Date 1505759683 -7200 # Node ID 32d5819bb6ae0e9d379f1cc1ce1aa2dda4aa8f63 # Parent 7819b77d9330ef54bbe0ccb6129cc64817095aa6 Introduced many changes to the way edited periods are handled, maintaining period updates by comparing them to periods generated by each original event, thus generating the additional cancellation and modification recurrence instances when events are updated. diff -r 7819b77d9330 -r 32d5819bb6ae htdocs/styles.css --- a/htdocs/styles.css Fri Sep 15 00:03:38 2017 +0200 +++ b/htdocs/styles.css Mon Sep 18 20:34:43 2017 +0200 @@ -277,6 +277,9 @@ input.remove, input.remove:checked ~ label.remove, input.remove:not(:checked) ~ label.removed, +input.restore, +input.restore:checked ~ label.restore, +input.restore:not(:checked) ~ label.restored, /* Hide the participation refresh control, selected using a label. */ @@ -342,6 +345,8 @@ label.add, label.remove, label.removed, +label.restore, +label.restored, label.hidebusy, label.showdays, label.reset { @@ -350,21 +355,27 @@ label.add, label.remove, -label.removed { +label.removed, +label.restore, +label.restored { float: right; text-align: right; } label.add span.action, label.remove span.action, -label.removed span.action { +label.removed span.action, +label.restore span.action, +label.restored span.action { font-size: smaller; display: block; } p label.add, p label.remove, -p label.removed { +p label.removed, +p label.restore, +p label.restored { float: none; } @@ -379,6 +390,8 @@ label.add, label.remove, label.removed, +label.restore, +label.restored, label.hidebusy, label.showdays, label.reset { diff -r 7819b77d9330 -r 32d5819bb6ae imiptools/client.py --- a/imiptools/client.py Fri Sep 15 00:03:38 2017 +0200 +++ b/imiptools/client.py Mon Sep 18 20:34:43 2017 +0200 @@ -257,6 +257,22 @@ start=(future_only and self.get_window_start() or None), end=(not explicit_only and self.get_window_end() or None)) + def get_main_period(self, obj): + + "Return the main period defined by 'obj'." + + return obj.get_main_period(self.get_tzid()) + + def get_recurrence_periods(self, obj): + + "Return recurrence periods defined by 'obj'." + + l = [] + for p in Client.get_periods(self, obj): + if p.origin != "DTSTART": + l.append(p) + return l + # Store operations. def get_stored_object(self, uid, recurrenceid, section=None, username=None): @@ -441,6 +457,68 @@ parent = self.get_parent_object() return parent and parent.has_recurrence(self.get_tzid(), self.obj.get_recurrenceid()) + def get_recurrences(self): + + "Return the current object's recurrence identifiers." + + return self.store.get_recurrences(self.user, self.uid) + + def get_periods(self, obj=None, explicit_only=False, future_only=False): + + "Return the periods provided by the current object." + + return Client.get_periods(self, obj or self.obj, explicit_only, future_only) + + def get_updated_periods(self): + + """ + Return the periods provided by the current object and associated + recurrence instances. Each original period is returned in a tuple with + a corresponding updated period which may be the same or which may be + None if the period is cancelled. A list of these tuples is returned. + """ + + updated = [] + recurrenceids = self.get_recurrences() + + for period in self.get_periods(): + recurrenceid = period.is_replaced(recurrenceids) + + # Obtain any replacement instead of the replaced period. + + if recurrenceid: + obj = self.get_stored_object(self.uid, recurrenceid) + periods = obj and Client.get_periods(self, obj) + + # Active periods are obtained. Cancelled periods yield None. + + if periods: + p = periods[0] + if p.origin == "DTSTART" and period.origin != "DTSTART": + p.origin = "DTSTART-RECUR" + updated.append((period, p)) + else: + updated.append((period, None)) + + # Otherwise, retain the known period. + + else: + updated.append((period, period)) + + return updated + + def get_main_period(self, obj=None): + + "Return the main period defined by the current object." + + return Client.get_main_period(self, obj or self.obj) + + def get_recurrence_periods(self, obj=None): + + "Return the recurrence periods defined by the current object." + + return Client.get_recurrence_periods(self, obj or self.obj) + # Common operations on calendar data. def update_senders(self, obj=None): @@ -475,24 +553,36 @@ return None - def get_unscheduled_parts(self, periods): + def get_rescheduled_parts(self, periods, method): - "Return message parts describing unscheduled 'periods'." + """ + Return message parts describing rescheduled 'periods' affected by 'method'. + """ - unscheduled_parts = [] + rescheduled_parts = [] if periods: + + # Duplicate the core of the object without any period information. + obj = self.obj.copy() obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"]) for p in periods: if not p.origin: continue - obj["RECURRENCE-ID"] = obj["DTSTART"] = [(format_datetime(p.get_start()), p.get_start_attr())] - obj["DTEND"] = [(format_datetime(p.get_end()), p.get_end_attr())] - unscheduled_parts.append(self.object_to_part("CANCEL", obj)) + + # Set specific recurrence information. + + obj.set_datetime("DTSTART", p.get_start()) + obj.set_datetime("DTEND", p.get_end()) - return unscheduled_parts + dt, attr = p.get_recurrenceid_item() + obj["RECURRENCE-ID"] = [(format_datetime(dt), attr)] + + rescheduled_parts.append(self.object_to_part(method, obj)) + + return rescheduled_parts # Object update methods. @@ -778,7 +868,8 @@ get_address(self.user), self.obj, False, True) return True - def process_created_request(self, method, to_cancel=None, to_unschedule=None): + def process_created_request(self, method, to_cancel=None, + to_unschedule=None, to_reschedule=None): """ Process the current request, sending a created request of the given @@ -789,6 +880,9 @@ If 'to_unschedule' is specified, a list of periods to be unscheduled is provided. + + If 'to_reschedule' is specified, a list of periods to be rescheduled is + provided. """ # Here, the organiser should be the current user. @@ -801,23 +895,32 @@ self.update_sequence(True) if method == "REQUEST": - methods, parts = self.get_message_parts(self.obj, "REQUEST") + + # Start with the parent object and augment it with the given + # amendments. + + parts = [self.object_to_part(method, self.obj)] - # Add message parts with cancelled occurrence information. + # Add message parts with cancelled and modified occurrence + # information. - unscheduled_parts = self.get_unscheduled_parts(to_unschedule) + unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL") + rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "REQUEST") # Send the updated event, along with a cancellation for each of the # unscheduled occurrences. - self.send_message(parts + unscheduled_parts, get_address(organiser), self.obj, True, False) + self.send_message(parts + unscheduled_parts + rescheduled_parts, + get_address(organiser), self.obj, True, False) # Since the organiser can update the SEQUENCE but this can leave any # mail/calendar client lagging, issue a PUBLISH message to the # user's address. - methods, parts = self.get_message_parts(self.obj, "PUBLISH") - self.send_message_to_self(parts + unscheduled_parts) + parts = [self.object_to_part("PUBLISH", self.obj)] + rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "PUBLISH") + + self.send_message_to_self(parts + unscheduled_parts + rescheduled_parts) # When cancelling, replace the attendees with those for whom the event # is now cancelled. @@ -1037,18 +1140,6 @@ return self.recurrenceid and self.get_stored_object(self.uid, None) or None - def revert_cancellations(self, periods): - - """ - Restore cancelled recurrences corresponding to any of the given - 'periods'. - """ - - for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid): - obj = self.get_stored_object(self.uid, recurrenceid, "cancellations") - if set(self.get_periods(obj)).intersection(periods): - self.store.remove_cancellation(self.user, self.uid, recurrenceid) - # Convenience methods for modifying free/busy collections. def get_recurrence_start_point(self, recurrenceid): @@ -1213,7 +1304,7 @@ # Tidy up any obsolete recurrences. - self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) + self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) self.store.set_freebusy_for_other(self.user, freebusy, user) finally: @@ -1274,7 +1365,7 @@ # 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.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) self.store.set_freebusy(self.user, freebusy) if self.publisher and self.is_sharing() and self.is_publishing(): @@ -1320,7 +1411,7 @@ # 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.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) self.store.set_freebusy_offers(self.user, freebusy) return True diff -r 7819b77d9330 -r 32d5819bb6ae imiptools/period.py --- a/imiptools/period.py Fri Sep 15 00:03:38 2017 +0200 +++ b/imiptools/period.py Mon Sep 18 20:34:43 2017 +0200 @@ -22,7 +22,7 @@ from bisect import bisect_left, insort_left from datetime import date, datetime, timedelta from imiptools.dates import check_permitted_values, correct_datetime, \ - get_datetime, \ + format_datetime, get_datetime, \ get_datetime_attributes, \ get_recurrence_start, get_recurrence_start_point, \ get_start_of_day, \ @@ -301,6 +301,18 @@ return None + def get_recurrenceid(self): + + "Return a recurrence identifier to identify this period." + + return format_datetime(to_utc_datetime(self.get_start())) + + def get_recurrenceid_item(self): + + "Return datetime plus attributes for a recurrence identifier." + + return self.get_start(), get_datetime_attributes(self.get_start()) + # Value correction methods. def with_duration(self, duration): @@ -360,13 +372,13 @@ def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None): Period.__init__(self, start, end, tzid, origin) self.start_attr = start_attr - self.end_attr = end_attr + self.end_attr = end_attr or start_attr def get_start_attr(self): - return self.start_attr + return self.start_attr or {} def get_end_attr(self): - return self.end_attr + return self.end_attr or {} def as_tuple(self): return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr diff -r 7819b77d9330 -r 32d5819bb6ae imipweb/data.py --- a/imipweb/data.py Fri Sep 15 00:03:38 2017 +0200 +++ b/imipweb/data.py Mon Sep 18 20:34:43 2017 +0200 @@ -21,8 +21,9 @@ from datetime import datetime, timedelta from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ - format_datetime, get_datetime, get_end_of_day, \ - to_date + format_datetime, get_datetime, \ + get_datetime_attributes, get_end_of_day, \ + to_date, to_utc_datetime, to_timezone from imiptools.period import RecurringPeriod class PeriodError(Exception): @@ -36,27 +37,71 @@ """ def __init__(self, start, end, tzid=None, origin=None, start_attr=None, - end_attr=None, form_start=None, form_end=None, replaced=False): + end_attr=None, form_start=None, form_end=None, + replacement=False, cancelled=False, recurrenceid=None): """ - Initialise a period with the given 'start' and 'end' datetimes, together - with optional 'start_attr' and 'end_attr' metadata, 'form_start' and - 'form_end' values provided as textual input, and with an optional - 'origin' indicating the kind of period this object describes. + Initialise a period with the given 'start' and 'end' datetimes. + + The optional 'tzid' provides time zone information, and the optional + 'origin' indicates the kind of period this object describes. + + The optional 'start_attr' and 'end_attr' provide metadata for the start + and end datetimes respectively, and 'form_start' and 'form_end' are + values provided as textual input. + + The 'replacement' flag indicates whether the period is provided by a + separate recurrence instance. + + The 'cancelled' flag indicates whether a separate recurrence is + cancelled. + + The 'recurrenceid' describes the original identity of the period, + regardless of whether it is separate or not. """ RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) self.form_start = form_start self.form_end = form_end - self.replaced = replaced + + # Information about whether a separate recurrence provides this period + # and the original period identity. + + self.replacement = replacement + self.cancelled = cancelled + self.recurrenceid = recurrenceid def as_tuple(self): return self.start, self.end, self.tzid, self.origin, self.start_attr, \ - self.end_attr, self.form_start, self.form_end, self.replaced + self.end_attr, self.form_start, self.form_end, self.replacement, \ + self.cancelled, self.recurrenceid def __repr__(self): return "EventPeriod%r" % (self.as_tuple(),) + def copy(self): + return EventPeriod(*self.as_tuple()) + + def _get_recurrenceid_item(self): + + # Convert any stored identifier to the current time zone. + # NOTE: This should not be necessary, but is done for consistency with + # NOTE: the datetime properties. + + dt = get_datetime(self.recurrenceid) + dt = to_timezone(dt, self.tzid) + return dt, get_datetime_attributes(dt) + + def get_recurrenceid(self): + if not self.recurrenceid: + return RecurringPeriod.get_recurrenceid(self) + return self.recurrenceid + + def get_recurrenceid_item(self): + if not self.recurrenceid: + return RecurringPeriod.get_recurrenceid_item(self) + return self._get_recurrenceid_item() + def as_event_period(self): return self @@ -86,8 +131,9 @@ isinstance(self.start, datetime) or isinstance(self.end, datetime), self.tzid, self.origin, - self.replaced and True or False, - format_datetime(self.get_start_point()) + self.replacement, + self.cancelled, + self.recurrenceid ) def get_form_date(self, dt, attr=None): @@ -105,24 +151,28 @@ "A period whose information originates from a form." def __init__(self, start, end, end_enabled=True, times_enabled=True, - tzid=None, origin=None, replaced=False, recurrenceid=None): + tzid=None, origin=None, replacement=False, cancelled=False, + recurrenceid=None): self.start = start self.end = end self.end_enabled = end_enabled self.times_enabled = times_enabled self.tzid = tzid self.origin = origin - self.replaced = replaced + self.replacement = replacement + self.cancelled = cancelled self.recurrenceid = recurrenceid def as_tuple(self): - return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced, self.recurrenceid + return self.start, self.end, self.end_enabled, self.times_enabled, \ + self.tzid, self.origin, self.replacement, self.cancelled, \ + self.recurrenceid def __repr__(self): return "FormPeriod%r" % (self.as_tuple(),) - def is_changed(self): - return not self.recurrenceid or format_datetime(self.get_start_point()) != self.recurrenceid + def copy(self): + return FormPeriod(*self.as_tuple()) def as_event_period(self, index=None): @@ -154,7 +204,8 @@ return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, self.origin, dtstart_attr, dtend_attr, - self.start, self.end, self.replaced) + self.start, self.end, self.replacement, + self.cancelled, self.recurrenceid) # Period data methods. @@ -226,6 +277,9 @@ def as_tuple(self): return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr + def reset(self): + self.dt = None + def __repr__(self): return "FormDate%r" % (self.as_tuple(),) @@ -320,9 +374,16 @@ else: dtstart, dtstart_attr = period.get_start_item() dtend, dtend_attr = period.get_end_item() + if not isinstance(period, RecurringPeriod): dtend = end_date_to_calendar(dtend) - return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr) + + return EventPeriod(dtstart, dtend, period.tzid, period.origin, + dtstart_attr, dtend_attr, + recurrenceid=format_datetime(to_utc_datetime(dtstart))) + +def event_periods_from_periods(periods): + return map(event_period_from_period, periods) def form_period_from_period(period): @@ -339,84 +400,198 @@ else: return event_period_from_period(period).as_form_period() +def form_periods_from_periods(periods): + return map(form_period_from_period, periods) + -# Form period processing. +# Event period processing. -def get_existing_periods(periods, still_to_remove): +def periods_from_updated_periods(updated_periods, fn): """ - Find all periods that existed before editing, given 'periods', applying - the periods in 'still_to_remove' and producing retained, replaced and - to-remove collections containing these existing periods. + Return periods from the given 'updated_periods' created using 'fn, setting + replacement, cancelled and recurrence identifier details. + """ + + periods = [] + + for sp, p in updated_periods: + if p: + period = fn(p) + if sp != p: + period.replacement = True + else: + period = fn(sp) + period.replacement = True + period.cancelled = True + + # Replace the recurrence identifier with that of the original period. + + period.recurrenceid = sp.get_recurrenceid() + periods.append(period) + + return periods + +def event_periods_from_updated_periods(updated_periods): + return periods_from_updated_periods(updated_periods, event_period_from_period) + +def form_periods_from_updated_periods(updated_periods): + return periods_from_updated_periods(updated_periods, form_period_from_period) + +def get_main_period(periods): + for p in periods: + if p.origin == "DTSTART": + return p + return None + +def get_recurrence_periods(periods): + l = [] + for p in periods: + if p.origin != "DTSTART": + l.append(p) + return l + +def periods_by_recurrence(periods): + + """ + Return a mapping from recurrence identifier to period for 'periods' along + with a collection of unmapped periods. """ - retained = [] - replaced = [] - to_remove = [] + d = {} + new = [] for p in periods: - p = form_period_from_period(p) - if p.recurrenceid: - if p.replaced: - replaced.append(p) - elif p in still_to_remove: - to_remove.append(p) - else: - retained.append(p) + if not p.recurrenceid: + new.append(p) + else: + d[p.recurrenceid] = p + + return d, new + +def combine_periods(old, new): + + "Combine 'old' and 'new' periods for comparison." + + old_by_recurrenceid, _new_periods = periods_by_recurrence(old) + new_by_recurrenceid, new_periods = periods_by_recurrence(new) + + combined = [] + + for recurrenceid, op in old_by_recurrenceid.items(): + np = new_by_recurrenceid.get(recurrenceid) + if np and not np.cancelled: + combined.append((op, np)) + else: + combined.append((op, None)) + + for np in new_periods: + combined.append((None, np)) - return retained, replaced, to_remove + return combined + +def classify_periods(updated_periods): + + """ + Using the 'updated_periods', being a list of (stored, current) periods, + return a tuple containing collections of new, changed, unchanged, removed + periods. + """ + + new, changed, unchanged, removed = get_changed_periods(updated_periods) -def get_new_periods(periods): + changed = set(changed).difference(removed) + unchanged = set(unchanged).difference(removed) + + return new, list(changed), list(unchanged), removed + +def get_changed_periods(updated_periods): - "Return all periods introduced during editing, given 'periods'." + """ + Using the 'updated_periods', being a list of (stored, current) periods, + return a tuple containing collections of new, changed, unchanged and removed + periods. + + Note that changed and unchanged indicate the presence or absence of + differences between the original event periods and the current periods, not + whether any editing operations have changed the periods. + """ new = [] - for p in periods: - fp = form_period_from_period(p) - if not fp.recurrenceid: - new.append(p) - return new - -def get_changed_periods(periods): - - "Return changed and unchanged periods, given 'periods'." - changed = [] unchanged = [] + removed = [] - for p in periods: - fp = form_period_from_period(p) - if fp.is_changed(): - changed.append(p) - else: - unchanged.append(p) + for sp, p in updated_periods: + if sp: + if not p or p.cancelled: + removed.append(sp) + elif p != sp or p.replacement: + changed.append(p) + else: + unchanged.append(p) + elif p: + new.append(p) - return changed, unchanged + return new, changed, unchanged, removed -def classify_periods(periods, still_to_remove): +def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared): """ - From the recurrence 'periods', given details of those 'still_to_remove', - return a tuple containing collections of new, changed, unchanged, replaced - and to-be-removed periods. + Classify the operations for the update of an event. Return the unscheduled + periods, rescheduled periods, excluded periods, and the periods to be set in + the object to replace the existing stored periods. """ - retained, replaced, to_remove = get_existing_periods(periods, still_to_remove) + active_periods = new + unchanged + changed + + # As organiser... + + if is_organiser: + to_exclude = [] + + # For unshared events... + # All modifications redefine the event. - # Filter new periods with the existing period information. + # For shared events... + # New periods should cause the event to be redefined. - new = set(get_new_periods(periods)) + if not is_shared or new: + to_unschedule = [] + to_reschedule = [] + to_set = active_periods + + # Changed periods should be rescheduled separately. + # Removed periods should be cancelled separately. - new.difference_update(retained) - new.difference_update(replaced) - new.difference_update(to_remove) + else: + to_unschedule = removed + to_reschedule = changed + to_set = [] + + # As attendee... + + else: + to_unschedule = [] + + # Changed periods without new or removed periods are proposed as + # separate changes. - # Divide retained periods into changed and unchanged collections. + if not new and not removed: + to_exclude = [] + to_reschedule = changed + to_set = [] - changed, unchanged = get_changed_periods(retained) + # Otherwise, the event is defined in terms of new periods and + # exceptions for removed periods. - return list(new), changed, unchanged, replaced, to_remove + else: + to_exclude = removed + to_reschedule = [] + to_set = active_periods + + return to_unschedule, to_reschedule, to_exclude, to_set @@ -502,8 +677,8 @@ def get_period_control_values(args, start_name, end_name, end_enabled_name, times_enabled_name, origin=None, origin_name=None, - replaced_name=None, recurrenceid_name=None, - tzid=None): + replacement_name=None, cancelled_name=None, + recurrenceid_name=None, tzid=None): """ Return period values from fields found in 'args' prefixed with the given @@ -513,9 +688,13 @@ If 'origin' is specified, a single period with the given origin is returned. If 'origin_name' is specified, fields containing the name will - provide origin information, fields containing 'replaced_name' will indicate - periods that are replaced, and fields containing 'recurrenceid_name' will - indicate periods that have existing recurrence details from an event. + provide origin information. + + If specified, fields containing 'replacement_name' will indicate periods + provided by separate recurrences, fields containing 'cancelled_name' + will indicate periods that are replacements and cancelled, and fields + containing 'recurrenceid_name' will indicate periods that have existing + recurrence details from an event. If 'tzid' is specified, it will provide the time zone where no explicit time zone information is indicated in the field data. @@ -526,14 +705,15 @@ all_end_enabled = args.get(end_enabled_name, []) all_times_enabled = args.get(times_enabled_name, []) - # Get the origins of period data and whether the periods are replaced. + # Get the origins of period data and whether the periods are replacements. if origin: all_origins = [origin] else: all_origins = origin_name and args.get(origin_name, []) or [] - all_replaced = replaced_name and args.get(replaced_name, []) or [] + all_replacements = replacement_name and args.get(replacement_name, []) or [] + all_cancelled = cancelled_name and args.get(cancelled_name, []) or [] all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or [] # Get the start and end datetimes. @@ -552,10 +732,12 @@ end_enabled = str(index) in all_end_enabled times_enabled = str(index) in all_times_enabled - replaced = str(index) in all_replaced + replacement = str(index) in all_replacements + cancelled = str(index) in all_cancelled period = FormPeriod(start, end, end_enabled, times_enabled, tzid, - found_origin or origin, replaced, recurrenceid) + found_origin or origin, replacement, cancelled, + recurrenceid) periods.append(period) # Return a single period if a single origin was specified. @@ -567,8 +749,8 @@ def set_period_control_values(periods, args, start_name, end_name, end_enabled_name, times_enabled_name, - origin_name=None, replaced_name=None, - recurrenceid_name=None): + origin_name=None, replacement_name=None, + cancelled_name=None, recurrenceid_name=None): """ Using the given 'periods', replace form fields in 'args' prefixed with the @@ -577,9 +759,11 @@ (to enable times for periods). If 'origin_name' is specified, fields containing the name will provide - origin information, fields containing 'replaced_name' will indicate periods - that are replaced, and fields containing 'recurrenceid_name' will indicate - periods that have existing recurrence details from an event. + origin information, fields containing 'replacement_name' will indicate + periods provided by separate recurrences, fields containing 'cancelled_name' + will indicate periods that are replacements and cancelled, and fields + containing 'recurrenceid_name' will indicate periods that have existing + recurrence details from an event. """ # Record period settings separately. @@ -592,8 +776,11 @@ if origin_name: args[origin_name] = [] - if replaced_name: - args[replaced_name] = [] + if replacement_name: + args[replacement_name] = [] + + if cancelled_name: + args[cancelled_name] = [] if recurrenceid_name: args[recurrenceid_name] = [] @@ -617,8 +804,14 @@ # Add replacement information where controls are present to record it. - if replaced_name and period.replaced: - args[replaced_name].append(str(index)) + if replacement_name and period.replacement: + args[replacement_name].append(str(index)) + + # Add cancelled recurrence information where controls are present to + # record it. + + if cancelled_name and period.cancelled: + args[cancelled_name].append(str(index)) # Add recurrence identifiers where controls are present to record it. diff -r 7819b77d9330 -r 32d5819bb6ae imipweb/event.py --- a/imipweb/event.py Fri Sep 15 00:03:38 2017 +0200 +++ b/imipweb/event.py Mon Sep 18 20:34:43 2017 +0200 @@ -23,10 +23,11 @@ uri_parts, uri_values from imiptools.dates import format_datetime, to_timezone from imiptools.mail import Messenger -from imipweb.data import EventPeriod, event_period_from_period, \ - form_period_from_period, \ - classify_periods, filter_duplicates, \ - remove_from_collection, \ +from imipweb.data import event_periods_from_periods, \ + form_period_from_period, form_periods_from_updated_periods, \ + classify_operations, classify_periods, combine_periods, \ + get_recurrence_periods, \ + filter_duplicates, remove_from_collection, \ get_period_control_values, \ PeriodError from imipweb.resource import DateTimeFormUtilities, FormUtilities, ResourceClientForObject @@ -111,33 +112,16 @@ def get_stored_attendees(self): return [get_verbose_address(value, attr) for value, attr in self.obj.get_items("ATTENDEE") or []] - def get_stored_main_period(self): - - "Return the main event period for the current object." - - period = self.obj.get_main_period(self.get_tzid()) - return event_period_from_period(period) - - def get_stored_recurrences(self): - - "Return recurrences computed using the current object." - - recurrenceids = self._get_recurrences(self.uid) - recurrences = [] - for period in self.get_periods(self.obj): - period = event_period_from_period(period) - period.replaced = period.is_replaced(recurrenceids) - if period.origin != "DTSTART": - recurrences.append(period) - return recurrences - # Access to current object information. - def get_current_main_period(self): - return self.get_stored_main_period() + def get_stored_main_period(self): + return form_period_from_period(self.get_main_period()) - def get_current_recurrences(self): - return self.get_stored_recurrences() + def get_stored_recurrence_periods(self): + return get_recurrence_periods(form_periods_from_updated_periods(self.get_updated_periods())) + + get_current_main_period = get_stored_main_period + get_current_recurrence_periods = get_stored_recurrence_periods def get_current_attendees(self): return self.get_stored_attendees() @@ -216,15 +200,8 @@ attendees = self.get_current_attendees() period = self.get_current_main_period() - stored_period = self.get_stored_main_period() self.show_object_datetime_controls(period) - # Obtain any separate recurrences for this event. - - recurrenceids = self._get_recurrences(self.uid) - replaced = not self.recurrenceid and period.is_replaced(recurrenceids) - excluded = period == stored_period and period not in self.get_periods(self.obj) - # Provide a summary of the object. page.table(class_="object", cellspacing=5, cellpadding=5) @@ -259,41 +236,23 @@ # Handle datetimes specially. if name in ("DTSTART", "DTEND"): - if not replaced and not excluded: - - # Obtain the datetime. - - is_start = name == "DTSTART" + is_start = name == "DTSTART" - # Where no end datetime exists, use the start datetime as the - # basis of any potential datetime specified if dt-control is - # set. - - self.show_datetime_controls(is_start and period.get_form_start() or period.get_form_end(), is_start) - - elif name == "DTSTART": + # Obtain the datetime. - # Replaced occurrences link to their replacements. - - if replaced: - page.td(class_="objectvalue %s replaced" % field, rowspan=2, colspan=2) - page.a(_("First occurrence replaced by a separate event"), href=self.link_to(self.uid, replaced)) - page.td.close() + # Where no end datetime exists, use the start datetime as the + # basis of any potential datetime specified if dt-control is + # set. - # 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, colspan=2) - page.add(_("First occurrence excluded")) - page.td.close() + self.show_datetime_controls(is_start and period.get_form_start() or period.get_form_end(), is_start) + if is_start: + self.show_period_state(None, period) page.tr.close() # After the end datetime, show a control to add recurrences. - if name == "DTEND": + if not is_start: page.tr() page.td(colspan=2) self.control("recur-add", "submit", "add", id="recur-add", class_="add") @@ -452,7 +411,7 @@ # Obtain the periods associated with the event. - recurrences = self.get_current_recurrences() + recurrences = self.get_current_recurrence_periods() if len(recurrences) < 1: return @@ -501,13 +460,32 @@ self.show_recurrence_controls(index, period, recurrenceid, False) page.tr.close() + # Actions. + + page.tr() + page.th("") + page.td() + + # Permit the restoration of cancelled recurrences. + + if period.cancelled: + self.control("recur-restore", "checkbox", str(index), + period in self.get_state("recur-restore", list), + id="recur-restore-%d" % index, class_="restore") + + page.label(for_="recur-restore-%d" % index, class_="restore") + page.add(_("(Removed)")) + page.span(_("Restore"), class_="action") + page.label.close() + + page.label(for_="recur-restore-%d" % index, class_="restored") + page.add(_("(Restored)")) + page.span(_("Remove"), class_="action") + page.label.close() + # Permit the removal of recurrences. - if not period.replaced: - page.tr() - page.th("") - page.td() - + else: # Attendees can instantly remove recurrences and thus produce a # counter-proposal. Organisers may need to unschedule recurrences # instead. @@ -524,8 +502,8 @@ page.span(_("Re-add"), class_="action") page.label.close() - page.td.close() - page.tr.close() + page.td.close() + page.tr.close() page.tbody.close() page.table.close() @@ -628,15 +606,12 @@ page.thead.close() page.tbody() - recurrenceids = self._get_recurrences(self.uid) - suggested_periods = list(suggested_periods.items()) suggested_periods.sort() for attendee, periods in suggested_periods: first = True for p in periods: - replaced = not self.recurrenceid and p.is_replaced(recurrenceids) identifier = "%s-%s" % (format_datetime(p.get_start_point()), format_datetime(p.get_end_point())) css = identifier == counter and "selected" or "" @@ -647,9 +622,8 @@ # Show each period. - css = replaced and "replaced" or "" - page.td(start, class_=css) - page.td(end, class_=css) + page.td(start) + page.td(end) # Show attendees and controls alongside the first period in each # attendee's collection. @@ -838,42 +812,57 @@ single_user = False changed = False + to_cancel = [] + to_unschedule = [] + to_reschedule = [] - if reply or create or cancel or save: + if reply or create or save: - # Update time periods (main and recurring). + # Update time periods. try: - period = self.handle_main_period() - except PeriodError, exc: - return exc.args - - try: - periods = self.handle_recurrence_periods() + periods = self.handle_periods() except PeriodError, exc: return exc.args - # Set the periods in the object, first obtaining removed and - # modified period information. # NOTE: Currently, rules are not updated. - editable_periods, to_change, to_remove = self.classify_periods(periods) + # Obtain removed and modified period information. + + new, to_change, unchanged, removed = self.classify_periods(periods) + + # Determine the modifications required to represent the edits. - active_periods = editable_periods + to_change - to_unschedule = self.is_organiser() and to_remove or [] - to_exclude = not self.is_organiser() and to_remove or [] + to_unschedule, to_reschedule, to_exclude, to_set = \ + classify_operations(new, to_change, unchanged, removed, + self.is_organiser(), self.obj.is_shared()) + + # Set the periods in any redefined event. + + if to_set: + self.obj.set_periods(to_set) + + # Add and remove exceptions. - periods = set(periods) - changed = self.obj.set_period(period) or changed - changed = self.obj.set_periods(periods) or changed + if to_exclude: + self.obj.update_exceptions(to_exclude, to_set) + + # Obtain any new participants and those to be removed. - # Add and remove exceptions. + attendees = self.get_current_attendees() + removed = self.get_removed_attendees() + + added, to_cancel = self.update_attendees(attendees, removed) - changed = self.obj.update_exceptions(to_exclude, active_periods) or changed + # Determine the properties of the event for subsequent actions. + + single_user = not attendees or uri_values(attendees) == [self.user] + changed = added or to_set or to_change or removed - # Assert periods restored after cancellation. + # Update attendee participation for the current user. - changed = self.revert_cancellations(active_periods) or changed + if args.has_key("partstat"): + self.update_participation(args["partstat"][0]) # Organiser-only changes... @@ -884,20 +873,6 @@ if args.has_key("summary"): self.obj["SUMMARY"] = [(args["summary"][0], {})] - # Obtain any new participants and those to be removed. - - attendees = self.get_current_attendees() - removed = self.get_removed_attendees() - - added, to_cancel = self.update_attendees(attendees, removed) - single_user = not attendees or uri_values(attendees) == [self.user] - changed = added or changed - - # Update attendee participation for the current user. - - if args.has_key("partstat"): - self.update_participation(args["partstat"][0]) - # Process any action. invite = not save and create and not single_user @@ -915,10 +890,11 @@ elif self.is_organiser() and (invite or cancel): - # Invitation, uninvitation and unscheduling... + # Invitation, uninvitation and unscheduling, rescheduling... if self.process_created_request( - invite and "REQUEST" or "CANCEL", to_cancel, to_unschedule): + invite and "REQUEST" or "CANCEL", to_cancel, + to_unschedule, to_reschedule): self.remove_request() @@ -996,19 +972,16 @@ return None - def handle_main_period(self): - - "Return period details for the main start/end period in an event." + def handle_periods(self): - return self.get_current_main_period().as_event_period() - - def handle_recurrence_periods(self): - - "Return period details for the recurrences specified for an event." + "Return period details for the periods specified for an event." periods = [] - for i, p in enumerate(self.get_current_recurrences()): + periods.append(self.get_current_main_period().as_event_period()) + + for i, p in enumerate(self.get_current_recurrence_periods()): periods.append(p.as_event_period(i)) + return periods # Access to form-originating object information. @@ -1021,6 +994,9 @@ "dtstart", "dtend", "dtend-control", "dttimes-control", origin="DTSTART", + replacement_name="main-replacement", + cancelled_name="main-cancelled", + recurrenceid_name="main-id", tzid=self.get_tzid()) # Handle absent main period details. @@ -1037,7 +1013,9 @@ return get_period_control_values(self.env.get_args(), "dtstart-recur", "dtend-recur", "dtend-control-recur", "dttimes-control-recur", - origin_name="recur-origin", replaced_name="recur-replaced", + origin_name="recur-origin", + replacement_name="recur-replacement", + cancelled_name="recur-cancelled", recurrenceid_name="recur-id", tzid=self.get_tzid()) @@ -1045,16 +1023,17 @@ """ From the recurrence 'periods' and information provided in the request, - return a tuple containing the new and unchanged periods, the changed + return a tuple containing the new periods, unchanged periods, changed periods, and the periods to be removed. """ - # Get remaining periods and those whose removal is deferred. + # Combine original recurrences with the edited, updated recurrences. - new, changed, unchanged, replaced, to_remove = classify_periods(periods, - self.get_state("recur-remove", list)) + stored = event_periods_from_periods(self.get_recurrence_periods()) + current = get_recurrence_periods(periods) - return new + unchanged, changed, to_remove + updated = combine_periods(stored, current) + return classify_periods(updated) def get_attendees_from_page(self): @@ -1109,11 +1088,11 @@ # Only actually remove attendees if the event is unsent, if the attendee # is new, or if it is the current user being removed. - remove = args.has_key("remove") + remove = args.get("remove") if remove: - still_to_remove = remove_from_collection(attendees, - args["remove"], self.can_remove_attendee) + still_to_remove = remove_from_collection(attendees, remove, + self.can_remove_attendee) self.set_state("remove", still_to_remove) if add or add_suggested or remove: @@ -1129,22 +1108,36 @@ recurrences = self.get_recurrences_from_page() + # Add new recurrences by copying the main period. + add = args.has_key("recur-add") if add: - period = self.get_current_main_period().as_form_period() + period = self.get_current_main_period() period.origin = "RDATE" + period.replacement = False + period.cancelled = False + period.recurrenceid = None recurrences.append(period) # Only actually remove recurrences if the event is unsent, or if the # recurrence is new, but only for explicit recurrences. - remove = args.has_key("recur-remove") + remove = args.get("recur-remove") if remove: - still_to_remove = remove_from_collection(recurrences, - args["recur-remove"], self.can_remove_recurrence) - self.set_state("recur-remove", still_to_remove) + for p in remove_from_collection(recurrences, remove, + self.can_remove_recurrence): + p.replacement = True + p.cancelled = True + + # Restore previously-cancelled recurrences. + + restore = args.get("recur-restore") + + if restore: + for index in restore: + recurrences[int(index)].cancelled = False return recurrences @@ -1182,7 +1175,7 @@ return self.get_state("main", self.is_initial_load() and self.get_stored_main_period or self.get_main_period_from_page) - def get_current_recurrences(self): + def get_current_recurrence_periods(self): """ Return recurrences for the current object using the original object @@ -1190,14 +1183,14 @@ """ return self.get_state("recurrences", self.is_initial_load() and - self.get_stored_recurrences or self.get_recurrences_from_page) + self.get_stored_recurrence_periods or self.get_recurrences_from_page) def update_current_recurrences(self): "Return an updated collection of recurrences for the current object." return self.get_state("recurrences", self.is_initial_load() and - self.get_stored_recurrences or self.update_recurrences_from_page, + self.get_stored_recurrence_periods or self.update_recurrences_from_page, overwrite=True) def get_current_attendees(self): diff -r 7819b77d9330 -r 32d5819bb6ae imipweb/resource.py --- a/imipweb/resource.py Fri Sep 15 00:03:38 2017 +0200 +++ b/imipweb/resource.py Mon Sep 18 20:34:43 2017 +0200 @@ -542,85 +542,48 @@ # Show controls for editing. - if not period.replaced: - page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) + page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) - read_only = period.origin == "RRULE" + read_only = period.origin == "RRULE" - if show_start: + if show_start: + page.div(class_="dt enabled") + self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), 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() + + self.show_period_state(index, period) + else: + self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=read_only) + if not read_only: + page.div(class_="dt disabled") + page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") + page.div.close() page.div(class_="dt enabled") - self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), 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.label("End on same day", for_=_id("dtend-enable", index), class_="disable") page.div.close() - self.show_recurrence_state(index, period) - else: - self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=read_only) - if not read_only: - page.div(class_="dt disabled") - page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable") - page.div.close() - page.div(class_="dt enabled") - page.label("End on same day", for_=_id("dtend-enable", index), class_="disable") - page.div.close() - - page.td.close() - - # Show label as attendee. - - else: - self.show_recurrence_label(index, period, recurrenceid, show_start) - - def show_recurrence_label(self, index, period, recurrenceid, show_start): - - """ - Show datetime details from the current object for the recurrence having - the given 'index', for the given recurrence 'period', employing any - 'recurrenceid' 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.td.close() - page = self.page - _name = self.element_name - - try: - p = event_period_from_period(period) - except PeriodError, exc: - affected = False - else: - affected = p.is_affected(recurrenceid) - - period = form_period_from_period(period) - - css = " ".join([ - period.replaced and "replaced" or "", - affected and "affected" or "" - ]) - - formdate = show_start and period.get_form_start() or period.get_form_end() - dt = formdate.as_datetime() - if dt: - page.td(class_=css) - if show_start: - self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), read_only=True) - self.show_recurrence_state(index, period) - else: - self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=True) - page.td.close() - else: - page.td("(Unrecognised date)") - - def show_recurrence_state(self, index, period): + def show_period_state(self, index, period): "Insert at 'index' additional state held by 'period'." - self.control("recur-origin", "hidden", period.origin or "") - self.control("recur-replaced", "hidden", period.replaced and str(index) or "") - self.control("recur-id", "hidden", period.recurrenceid or "") + if index is not None: + prefix = "recur" + index = str(index) + else: + prefix = "main" + index = "0" + + if index is not None: + self.control("%s-origin" % prefix, "hidden", period.origin or "") + + self.control("%s-cancelled" % prefix, "hidden", period.cancelled and index or "") + self.control("%s-replacement" % prefix, "hidden", period.replacement and index or "") + self.control("%s-id" % prefix, "hidden", period.recurrenceid or "") # vim: tabstop=4 expandtab shiftwidth=4