# HG changeset patch # User Paul Boddie # Date 1507900063 -7200 # Node ID 1cd097facd796b5c9bd2e20612b4ce00b7e000a0 # Parent 5a8ef235ec4dbf117d6c3b4e1bd10d4ce5677965 Added generic event editing support, expanding and refining the functionality for classifying editing changes in period and attendee lists. Fixed event period initialisation from updated period information and the combination of period collections for comparison. Improved the initialisation of, and access to, original state information. Reorganised message preparation in the client functionality to permit more flexibility and to work with the revised editing framework. diff -r 5a8ef235ec4d -r 1cd097facd79 imiptools/client.py --- a/imiptools/client.py Thu Oct 12 23:14:06 2017 +0200 +++ b/imiptools/client.py Fri Oct 13 15:07:43 2017 +0200 @@ -19,13 +19,14 @@ this program. If not, see . """ +from collections import OrderedDict from datetime import datetime, timedelta from imiptools.config import settings from imiptools.data import Object, check_delegation, get_address, get_uri, \ - get_recurrence_periods, \ + get_main_period, get_recurrence_periods, \ get_window_end, is_new_object, make_freebusy, \ - make_uid, to_part, uri_dict, uri_item, uri_items, \ - uri_parts, uri_values + make_uid, new_object, to_part, uri_dict, uri_item, \ + uri_items, uri_parts, uri_values from imiptools.dates import check_permitted_values, format_datetime, \ get_datetime, get_default_timezone, \ get_duration, get_time, get_timestamp, \ @@ -261,6 +262,74 @@ start=(future_only and self.get_window_start() or None), end=(not explicit_only and self.get_window_end() or None)) + def get_updated_periods(self, obj): + + """ + Return the periods provided by 'obj' 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. + """ + + uid = obj.get_uid() + recurrenceid = obj.get_recurrenceid() + + updated = [] + + # Consider separate recurrences in isolation from the parent if + # specified. + + if recurrenceid: + for period in self.get_periods(obj): + updated.append((period, period)) + return updated + + # For parent events, identify retained and replaced periods. + + recurrenceids = self.get_recurrences(uid) + + for period in self.get_periods(obj): + recurrenceid = period.is_replaced(recurrenceids) + + # For parent event periods, obtain any replacement instead of the + # replaced period. + + if recurrenceid: + recurrence = self.get_stored_object(uid, recurrenceid) + periods = recurrence and self.get_periods(recurrence) + + # Active periods are obtained. + + if periods: + + # Recurrence instances are assumed to provide only one + # period. + + replacement = periods[0] + + # Redefine the origin of periods replacing recurrences and + # not the main period, leaving DTSTART as the means of + # identifying the main period. + + if replacement.origin == "DTSTART" and \ + period.origin != "DTSTART": + + replacement.origin = "DTSTART-RECUR" + + updated.append((period, replacement)) + + # Cancelled periods yield None. + + else: + updated.append((period, None)) + + # Otherwise, retain the known period. + + else: + updated.append((period, period)) + + return updated + def get_main_period(self, obj): "Return the main period defined by 'obj'." @@ -423,6 +492,13 @@ self.sequence = obj and self.obj.get_value("SEQUENCE") self.dtstamp = obj and self.obj.get_value("DTSTAMP") + def new_object(self, objtype): + + "Initialise a new object for the client with the given 'objtype'." + + self.set_object(new_object(objtype, self.user, self.get_user_attributes())) + return self.obj + def load_object(self, uid, recurrenceid): "Load the object with the given 'uid' and 'recurrenceid'." @@ -446,6 +522,12 @@ return True + def is_attendee(self): + + "Return whether the current user is an attendee in the current object." + + return self.obj.get_value_map("ATTENDEE").has_key(self.user) + def is_organiser(self): """ @@ -461,11 +543,11 @@ parent = self.get_parent_object() return parent and parent.has_recurrence(self.get_tzid(), self.obj.get_recurrenceid()) - def get_recurrences(self): + def get_recurrences(self, uid=None): "Return the current object's recurrence identifiers." - return self.store.get_recurrences(self.user, self.uid) + return self.store.get_recurrences(self.user, uid or self.uid) def get_periods(self, obj=None, explicit_only=False, future_only=False): @@ -473,43 +555,14 @@ return Client.get_periods(self, obj or self.obj, explicit_only, future_only) - def get_updated_periods(self): + def get_updated_periods(self, obj=None): """ 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. + recurrence instances. """ - 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 + return Client.get_updated_periods(self, obj or self.obj) def get_main_period(self, obj=None): @@ -547,12 +600,17 @@ obj = obj or self.obj calendar_uri = self.messenger and get_uri(self.messenger.sender) - for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")): - if attendee != self.user: - if attendee_attr.get("SENT-BY") == calendar_uri: - del attendee_attr["SENT-BY"] - else: - attendee_attr["SENT-BY"] = calendar_uri + for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE") or []): + + # Fix up the SENT-BY attribute for this user. + + if attendee == self.user: + self.update_sender_attr(attendee_attr) + + # Remove any conflicting SENT-BY attributes for other users. + + elif attendee_attr.get("SENT-BY") == calendar_uri: + del attendee_attr["SENT-BY"] def get_sending_attendee(self): @@ -562,7 +620,7 @@ senders = self.senders or self.messenger and [self.messenger.sender] or [] - for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): + for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE") or []): if get_address(attendee) in senders or \ get_address(attendee_attr.get("SENT-BY")) in senders: return get_uri(attendee) @@ -641,86 +699,58 @@ return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) - def update_attendees(self, attendees, removed): + def update_attendees(self, to_invite, to_cancel, to_modify): """ - Update the attendees in the current object with the given 'attendees' - and 'removed' attendee lists. - - A tuple is returned containing two items: a list of the attendees whose - attendance is being proposed (in a counter-proposal), a list of the - attendees whose attendance should be cancelled. + Update the attendees in the current object with the given 'to_invite', + 'to_cancel' and 'to_modify' attendee mappings. """ - to_cancel = [] - - existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) - existing_attendees_map = dict(existing_attendees) + attendees = uri_items(self.obj.get_items("ATTENDEE") or []) + attendee_map = OrderedDict(attendees) - # Added attendees are those from the supplied collection not already - # present in the object. + # Normalise the identities. - added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) - removed = uri_values(removed) - - if added or removed: - - # The organiser can remove existing attendees. + to_invite = uri_dict(to_invite) + to_cancel = uri_dict(to_cancel) + to_modify = uri_dict(to_modify) - if removed and self.is_organiser(): - remaining = [] + if self.is_organiser(): - for attendee, attendee_attr in existing_attendees: - if attendee in removed: - - # Only when an event has not been published can - # attendees be silently removed. + # Remove uninvited attendees. - if self.obj.is_shared(): - to_cancel.append((attendee, attendee_attr)) - else: - remaining.append((attendee, attendee_attr)) + for attendee in to_cancel.keys(): + if attendee_map.has_key(attendee): + del attendee_map[attendee] - existing_attendees = remaining - - # Attendees (when countering) must only include the current user and - # any added attendees. + # Attendees (when countering) must only include the current user and + # any added attendees. - elif not self.is_organiser(): - existing_attendees = [] - - # Both organisers and attendees (when countering) can add attendees. - - if added: + else: + attr = attendee_map.get(self.user) or self.get_user_attributes() + attendee_map = {self.user : attr} - # Obtain a mapping from URIs to name details. - - attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) + # Update modified attendees. - for attendee in added: - attendee = attendee.strip() - if attendee: - cn = attendee_map.get(attendee) - attendee_attr = {"CN" : cn} or {} + for attendee, attr in to_modify.items(): + existing_attr = attendee_map.get(attendee) + if existing_attr: + existing_attr.update(attr) - # Only the organiser can reset the participation attributes. + # Add newly-invited attendees, applicable for organisers and attendees + # (when countering). - if self.is_organiser(): - attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) - - existing_attendees.append((attendee, attendee_attr)) + for attendee, attr in to_invite.items(): + if not attendee_map.has_key(attendee): - # Attendees (when countering) must only include the current user and - # any added attendees. + # Only the organiser can reset the participation attributes. - if not self.is_organiser() and self.user not in existing_attendees: - user_attr = self.get_user_attributes() - user_attr.update(existing_attendees_map.get(self.user) or {}) - existing_attendees.append((self.user, user_attr)) + if self.is_organiser(): + attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) - self.obj["ATTENDEE"] = existing_attendees + attendee_map[attendee] = attr - return added, to_cancel + self.obj["ATTENDEE"] = attendee_map.items() def update_participation(self, partstat=None): @@ -743,7 +773,7 @@ return attendee_attr - def update_event(self, changed=False): + def update_event_version(self, changed=False): """ Update the event version information and details for sending. Where @@ -754,16 +784,6 @@ if self.is_organiser(): self.update_sender() - else: - # Reply only on behalf of this user. - - attendee_attr = self.update_participation() - - if not attendee_attr: - return False - - if not changed: - self.obj["ATTENDEE"] = [(self.user, attendee_attr)] # Process attendee SENT-BY usage, timestamp and sequence details # appropriately for the sender's role. @@ -774,6 +794,23 @@ return True + def update_event_from_periods(self, to_set, to_exclude): + + """ + Set the periods in any redefined event from the 'to_set' list, excluding + the main period if it appears in 'to_exclude'. + """ + + if to_set: + self.obj.set_periods(to_set) + + # Exclude only the main period, if appropriate. + + if to_exclude: + main = get_main_period(to_exclude) + if main: + self.obj.update_exceptions([main], []) + # General message generation methods. def get_recipients(self, obj=None): @@ -908,25 +945,40 @@ return rescheduled_parts - def make_update_message(self, recipients, to_unschedule=None, to_reschedule=None): + def make_update_message(self, recipients, update_parent=False, + to_unschedule=None, to_reschedule=None, + all_unscheduled=None, all_rescheduled=None, + to_add=None): """ Prepare event updates from the organiser of an event for the given - 'recipients', using the period collections 'to_unschedule' and - 'to_reschedule'. + 'recipients', including the parent event if 'update_parent' is set to a + true value. + + Additional parts are provided by the 'to_unschedule' and 'to_reschedule' + collections. Alternatively, where the parent event is being updated, the + 'all_unscheduled' and 'all_rescheduled' period collections are included. + + The 'to_add' period collection augments the existing periods. """ - # Start with the parent object and augment it with the given - # amendments providing cancelled and modified occurrence information. + parts = [] - parts = [self.object_to_part("REQUEST", self.obj)] - unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL") - rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "REQUEST") + if update_parent: + parts.append(self.object_to_part("REQUEST", self.obj)) + unscheduled = all_unscheduled + rescheduled = all_rescheduled + else: + unscheduled = to_unschedule + rescheduled = to_reschedule - return self.make_message(parts + unscheduled_parts + rescheduled_parts, - recipients) + parts += self.get_rescheduled_parts(unscheduled, "CANCEL") + parts += self.get_rescheduled_parts(rescheduled, "REQUEST") + parts += self.get_rescheduled_parts(to_add, "ADD") + return self.make_message(parts, recipients) - def make_self_update_message(self, to_unschedule=None, to_reschedule=None): + def make_self_update_message(self, all_unscheduled=None, all_rescheduled=None, + to_add=None): """ Prepare event updates to be sent from the organiser of an event to @@ -934,34 +986,51 @@ """ parts = [self.object_to_part("PUBLISH", self.obj)] - unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL") - rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "PUBLISH") - return self.make_message_for_self(parts + unscheduled_parts + rescheduled_parts) + parts += self.get_rescheduled_parts(all_unscheduled, "CANCEL") + parts += self.get_rescheduled_parts(all_rescheduled, "PUBLISH") + parts += self.get_rescheduled_parts(to_add, "ADD") + return self.make_message_for_self(parts) - def make_cancel_object(self, to_cancel=None): + def make_response_message(self, recipients, update_parent=False, + all_rescheduled=None, to_reschedule=None): """ - Prepare an event cancellation object involving the participants in the - 'to_cancel' list. + Prepare a response to 'recipients', including the parent event if + 'update_parent' is set to a true value, incorporating 'all_rescheduled' + periods, of which there may be indicated periods 'to_reschedule'. """ - if to_cancel: - obj = self.obj.copy() - obj["ATTENDEE"] = to_cancel - else: - obj = self.obj + parts = [self.object_to_part(update_parent and "COUNTER" or "REPLY", self.obj)] + + # Determine existing replaced periods that are not newly rescheduled. + + rescheduled_unmodified = set(all_rescheduled or []).difference(to_reschedule or []) + + if rescheduled_unmodified: + parts += self.get_rescheduled_parts(rescheduled_unmodified, update_parent and "COUNTER" or "REPLY") - return obj + # Suggest details for newly rescheduled periods. - def make_cancel_message(self, recipients, obj): + if to_reschedule: + parts += self.get_rescheduled_parts(to_reschedule, "COUNTER") + + return self.make_message(parts, recipients, bcc_sender=True) + + def make_cancel_message(self, to_cancel=None): """ - Prepare an event cancellation message to 'recipients' using the details - in 'obj'. + Prepare an event cancellation message involving the participants in the + 'to_cancel' mapping. """ + if not to_cancel: + return None + + obj = self.obj.copy() + obj["ATTENDEE"] = to_cancel.items() + parts = [self.object_to_part("CANCEL", obj)] - return self.make_message(parts, recipients) + return self.make_message(parts, to_cancel.keys()) def make_cancel_message_for_self(self, obj): @@ -970,36 +1039,23 @@ parts = [self.object_to_part("CANCEL", obj)] return self.make_message_for_self(parts) - def make_response_message(self, recipients, changed=False): - - """ - Prepare a response to 'recipients' for the current object with the - indicated 'changed' state. - """ - - # NOTE: Might need updating to include rescheduled objects. - - parts = [self.object_to_part(changed and "COUNTER" or "REPLY", self.obj)] - return self.make_message(parts, recipients, bcc_sender=True) - # Action methods. - def process_declined_counter(self, attendee): + def send_declined_counter_to_attendee(self, attendee): - "Process a declined counter-proposal." + "Send a declined counter-proposal to 'attendee'." # Obtain the counter-proposal for the attendee. obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) if not obj: - return False + return - method = "DECLINECOUNTER" self.update_senders(obj) obj.update_dtstamp() obj.update_sequence() - parts = [self.object_to_part(method, obj)] + parts = [self.object_to_part("DECLINECOUNTER", obj)] # Create and send the response. @@ -1007,86 +1063,67 @@ message = self.make_message(parts, recipients, bcc_sender=True) self.send_message(message, recipients, bcc_sender=True) - return True - - def process_received_request(self, changed=False): + def send_response_to_organiser(self, all_rescheduled=None, to_reschedule=None, + changed=False): """ - Process the current request for the current user. Return whether any - action was taken. If 'changed' is set to a true value, or if 'attendees' - is specified and differs from the stored attendees, a counter-proposal - will be sent instead of a reply. + Send a response to the organiser describing attendance and proposed + amendments to the event. + + If 'all_rescheduled' is specified, it provides details of separate + recurrence instances for which a response needs to be generated. + + If 'to_reschedule' provides rescheduled periods, these will be sent as + counter-proposals. + + If 'changed' is set to a true value, a counter-proposal will be sent for + the entire event instead of a reply. """ - if not self.update_event(changed): - return False - - # Create and send the response. - recipients = self.get_recipients() - message = self.make_response_message(recipients, changed) + message = self.make_response_message(recipients, all_rescheduled, + to_reschedule, changed) self.send_message(message, recipients, bcc_sender=True) - return True - - def process_created_request(self, method, to_cancel=None, - to_unschedule=None, to_reschedule=None): + def send_update_to_recipients(self, to_unschedule=None, to_reschedule=None): """ - Process the current request, sending a created request of the given - 'method' to attendees. Return whether any action was taken. - - If 'to_cancel' is specified, a list of participants to be sent cancel - messages is provided. + Send cancellations for each of the recurrences 'to_unschedule' along + with modifications for each of the recurrences 'to_reschedule'. + """ - If 'to_unschedule' is specified, a list of periods to be unscheduled is - provided. + recipients = self.get_recipients() + message = self.make_update_message(recipients, to_unschedule, to_reschedule) + self.send_message(message, recipients) - If 'to_reschedule' is specified, a list of periods to be rescheduled is - provided. + def send_publish_to_self(self, all_unscheduled=None, all_rescheduled=None): - Note that this method, although similar to get_message_parts, processes - the core object and the explicitly-specified objects, not the separate - recurrence instances that are already stored. + """ + Send published event details incorporating 'all_unscheduled' and + 'all_rescheduled' periods. """ - self.update_event() - - if method == "REQUEST": - - # Send the updated event, along with a cancellation for each of the - # unscheduled occurrences. + # 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. - recipients = self.get_recipients() - message = self.make_update_message(recipients, to_unschedule, to_reschedule) - self.send_message(message, recipients) + recipients = self.get_recipients() + message = self.make_self_update_message(all_unscheduled, all_rescheduled) + self.send_message_to_self(message) - # 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. - - message = self.make_self_update_message(to_unschedule, to_reschedule) - self.send_message_to_self(message) + def send_cancel_to_recipients(self, to_cancel=None): - # When cancelling, replace the attendees with those for whom the event - # is now cancelled. + "Send a cancellation to all uninvited attendees in 'to_cancel'." - if method == "CANCEL" or to_cancel: - - # Send a cancellation to all uninvited attendees. + message = self.make_cancel_message(to_cancel) + self.send_message(message, to_cancel.keys()) - obj = self.make_cancel_object(to_cancel) - recipients = self.get_recipients(obj) - message = self.make_cancel_message(recipients, obj) - self.send_message(message, recipients) + def send_cancel_to_self(self): - # Issue a CANCEL message to the user's address. + "Issue a CANCEL message to the user's address." - if method == "CANCEL": - message = self.make_cancel_message_for_self(obj) - self.send_message_to_self(message) - - return True + message = self.make_cancel_message_for_self(self.obj) + self.send_message_to_self(message) # Object-related tests. diff -r 5a8ef235ec4d -r 1cd097facd79 imipweb/data.py --- a/imipweb/data.py Thu Oct 12 23:14:06 2017 +0200 +++ b/imipweb/data.py Fri Oct 13 15:07:43 2017 +0200 @@ -19,18 +19,23 @@ this program. If not, see . """ +from collections import OrderedDict +from copy import copy from datetime import datetime, timedelta +from imiptools.client import ClientForObject +from imiptools.data import get_main_period from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ format_datetime, get_datetime, \ get_datetime_attributes, get_end_of_day, \ to_date, to_utc_datetime, to_timezone from imiptools.period import RecurringPeriod +from itertools import chain # General editing abstractions. class State: - "Manage computed state." + "Manage editing state." def __init__(self, callables): @@ -41,11 +46,26 @@ """ self.state = {} + self.original = {} self.callables = callables def get_callable(self, key): return self.callables.get(key, lambda: None) + def ensure_original(self, key): + + "Ensure the original state for the given 'key'." + + if not self.original.has_key(key): + self.original[key] = self.get_callable(key)() + + def get_original(self, key): + + "Return the original state for the given 'key'." + + self.ensure_original(key) + return copy(self.original[key]) + def get(self, key, reset=False): """ @@ -57,21 +77,562 @@ """ if reset or not self.state.has_key(key): - self.state[key] = self.get_callable(key)() + self.state[key] = self.get_original(key) return self.state[key] def set(self, key, value): + + "Set the state of 'key' to 'value'." + + self.ensure_original(key) self.state[key] = value + def has_changed(self, key): + + "Return whether 'key' has changed during editing." + + return self.get_original(key) != self.get(key) + + # Dictionary emulation methods. + def __getitem__(self, key): return self.get(key) def __setitem__(self, key, value): self.set(key, value) - def has_changed(self, key): - return self.get_callable(key)() != self.get(key) + + +# Object editing abstractions. + +class EditingClient(ClientForObject): + + "A simple calendar client." + + def __init__(self, user, messenger, store, preferences_dir): + ClientForObject.__init__(self, None, user, messenger, store, + preferences_dir=preferences_dir) + self.reset() + + # Editing state. + + def reset(self): + + "Reset the editing state." + + self.state = State({ + "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []), + "organiser" : lambda: self.obj.get_value("ORGANIZER"), + "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()), + "suggested_attendees" : self.get_suggested_attendees, + "suggested_periods" : self.get_suggested_periods, + "summary" : lambda: self.obj.get_value("SUMMARY"), + }) + + # Access to stored and current information. + + def get_stored_periods(self): + + """ + Return the stored, unrevised, integral periods for the event, excluding + revisions from separate recurrence instances. + """ + + return event_periods_from_periods(self.get_periods()) + + def get_unedited_periods(self): + + """ + Return the original, unedited periods including revisions from separate + recurrence instances. + """ + + return event_periods_from_updated_periods(self.get_updated_periods()) + + def get_counters(self): + + "Return a counter-proposal mapping from attendees to objects." + + # Get counter-proposals for the specific object. + + attendees = self.store.get_counters(self.user, self.uid, self.recurrenceid) + d = {} + + for attendee in attendees: + if not d.has_key(attendee): + d[attendee] = [] + d[attendee].append(self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee)) + + return d + + def get_suggested_attendees(self): + + "For all counter-proposals, return suggested attendee items." + + existing = self.state.get("attendees") + l = [] + for attendee, objects in self.get_counters().items(): + for obj in objects: + for suggested, attr in obj.get_items("ATTENDEE"): + if suggested not in existing: + l.append((attendee, (suggested, attr))) + return l + + def get_suggested_periods(self): + + "For all counter-proposals, return suggested event periods." + + existing = self.state.get("periods") + + # Get active periods for filtering of suggested periods. + + active = [] + for p in existing: + if not p.cancelled: + active.append(p) + + suggested = [] + + for attendee, objects in self.get_counters().items(): + + # For each object, obtain suggested periods. + + for obj in objects: + + # Obtain the current periods for the object providing the + # suggested periods. + + updated = self.get_updated_periods(obj) + suggestions = event_periods_from_updated_periods(updated) + + # Compare current periods with suggested periods. + + new = set(suggestions).difference(active) + + # Treat each specific recurrence as affecting only the original + # period. + + if obj.get_recurrenceid(): + removed = [] + else: + removed = set(active).difference(suggestions) + + # Associate new and removed periods with the attendee. + + for period in new: + suggested.append((attendee, period, "add")) + + for period in removed: + suggested.append((attendee, period, "remove")) + + return suggested + + # Validation methods. + + def get_checked_periods(self): + + """ + Check the edited periods and return objects representing them, setting + the "periods" state. If errors occur, raise an exception and set the + "errors" state. + """ + + self.state["period_errors"] = errors = {} + try: + periods = event_periods_from_periods(self.state.get("periods")) + self.state["periods"] = form_periods_from_periods(periods) + return periods + + except PeriodError, exc: + + # Obtain error and period index details from the exception, + # collecting errors for each index position. + + for err, index in exc.args: + l = errors.get(index) + if not l: + l = errors[index] = [] + l.append(err) + raise + + # Update result computation. + + def classify_attendee_changes(self): + + "Classify the attendees in the event." + + original = self.state.get_original("attendees") + current = self.state.get("attendees") + return classify_attendee_changes(original, current) + + def classify_attendee_operations(self): + + "Classify attendee update operations." + + new, modified, unmodified, removed = self.classify_attendee_changes() + + if self.is_organiser(): + to_invite = new + to_cancel = removed + to_modify = modified + else: + to_invite = new + to_cancel = {} + to_modify = modified + + return to_invite, to_cancel, to_modify + + def classify_period_changes(self): + + "Classify changes in the updated periods for the edited event." + + updated = self.combine_periods_for_comparison() + return classify_period_changes(updated) + + def classify_periods(self): + + "Classify the updated periods for the edited event." + + updated = self.combine_periods() + return classify_periods(updated) + + def combine_periods(self): + + "Combine stored and checked edited periods to make updated periods." + + stored = self.get_stored_periods() + current = self.get_checked_periods() + return combine_periods(stored, current) + + def combine_periods_for_comparison(self): + + "Combine unedited and checked edited periods to make updated periods." + + original = self.get_unedited_periods() + current = self.get_checked_periods() + return combine_periods(original, current) + + def classify_period_operations(self): + + "Classify period update operations." + + new, replaced, retained, cancelled = self.classify_periods() + + modified, unmodified, removed = self.classify_period_changes() + + is_organiser = self.is_organiser() + is_shared = self.obj.is_shared() + + return classify_period_operations(new, replaced, retained, cancelled, + modified, removed, + is_organiser, is_shared) + + def properties_changed(self): + + "Test for changes in event details." + + is_changed = [] + + if self.is_organiser(): + for name in ["summary"]: + if self.state.has_changed(name): + is_changed.append(name) + + return is_changed + + def finish(self): + + "Finish editing, writing edited details to the object." + + if self.state.get("finished"): + return + + is_changed = self.properties_changed() + + # Determine period modification operations. + + self.state["period_operations"] = \ + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = \ + self.classify_period_operations() + + # Determine attendee modifications. + + self.state["attendee_operations"] = \ + to_invite, to_cancel, to_modify = \ + self.classify_attendee_operations() + + self.state["attendees_to_cancel"] = to_cancel + + # Update event details. + + if self.can_edit_properties(): + self.obj.set_value("SUMMARY", self.state.get("summary")) + + self.update_attendees(to_invite, to_cancel, to_modify) + self.update_event_from_periods(to_set, to_exclude) + + # Classify the nature of any update. + + if is_changed or to_set or to_invite: + self.state["changed"] = "complete" + elif to_reschedule or to_unschedule or to_add: + self.state["changed"] = "incremental" + + self.state["finished"] = self.update_event_version(is_changed) + + # Update preparation. + + def have_update(self): + + "Return whether an update can be prepared and sent." + + return not self.is_organiser() or \ + self.obj.is_shared() and self.state.get("changed") and \ + self.have_other_attendees() + + def have_other_attendees(self): + + "Return whether any attendees other than the user are present." + + attendees = self.state.get("attendees") + return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1) + + def prepare_cancel_message(self): + + "Prepare the cancel message for uninvited attendees." + + to_cancel = self.state.get("attendees_to_cancel") + return self.make_cancel_message(to_cancel) + + def prepare_publish_message(self): + + "Prepare the publishing message for the updated event." + + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = self.state.get("period_operations") + + return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add) + + def prepare_update_message(self): + + "Prepare the update message for the updated event." + + if not self.have_update(): + return None + + # Obtain operation details. + + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = self.state.get("period_operations") + + # Prepare the message. + + recipients = self.get_recipients() + update_parent = self.state["changed"] == "complete" + + if self.is_organiser(): + return self.make_update_message(recipients, update_parent, + to_unschedule, to_reschedule, + all_unscheduled, all_rescheduled, + to_add) + else: + return self.make_response_message(recipients, update_parent, + all_rescheduled, to_reschedule) + + # Modification methods. + + def add_attendee(self, uri=None): + + "Add a blank attendee." + + attendees = self.state.get("attendees") + attendees[uri or ""] = {} + + def add_suggested_attendee(self, index): + + "Add the suggested attendee at 'index' to the event." + + attendees = self.state.get("attendees") + suggested_attendees = self.state.get("suggested_attendees") + try: + attendee, (suggested, attr) = suggested_attendees[index] + self.add_attendee(suggested) + except IndexError: + pass + + def add_period(self): + + "Add a copy of the main period as a new recurrence." + + current = self.state.get("periods") + new = get_main_period(current).copy() + new.origin = "RDATE" + new.replacement = False + new.recurrenceid = False + new.cancelled = False + current.append(new) + + def apply_suggested_period(self, index): + + "Apply the suggested period at 'index' to the event." + + current = self.state.get("periods") + suggested = self.state.get("suggested_periods") + + try: + attendee, period, operation = suggested[index] + period = form_period_from_period(period) + + # Cancel any removed periods. + + if operation == "remove": + for p in current: + if p == period: + p.cancelled = True + break + + # Add or replace any other suggestions. + + elif operation == "add": + + # Make the status of the period compatible. + + period.cancelled = False + period.origin = "DTSTART-RECUR" + + # Either replace or add the period. + + recurrenceid = period.get_recurrenceid() + + for i, p in enumerate(current): + if p.get_recurrenceid() == recurrenceid: + current[i] = period + break + + # Add as a new period. + + else: + period.recurrenceid = None + current.append(period) + + except IndexError: + pass + + def cancel_periods(self, indexes, cancelled=True): + + """ + Set cancellation state for periods with the given 'indexes', indicating + 'cancelled' as a true or false value. New periods will be removed if + cancelled. + """ + + periods = self.state.get("periods") + to_remove = [] + removed = 0 + + for index in indexes: + p = periods[index] + + # Make replacements from existing periods and cancel them. + + if p.recurrenceid: + p.replacement = True + p.cancelled = cancelled + + # Remove new periods completely. + + elif cancelled: + to_remove.append(index - removed) + removed += 1 + + for index in to_remove: + del periods[index] + + def edit_attendance(self, partstat): + + "Set the 'partstat' of the current user, if attending." + + attendees = self.state.get("attendees") + attr = attendees.get(self.user) + + if attr: + new_attr = {} + new_attr.update(attr) + new_attr["PARTSTAT"] = partstat + attendees[self.user] = new_attr + + def can_edit_attendee(self, index): + + """ + Return whether the attendee at 'index' can be edited, requiring either + the organiser and an unshared event, or a new attendee. + """ + + attendees = self.state.get("attendees") + attendee = attendees.keys()[index] + + try: + attr = attendees[attendee] + if self.is_organiser() and not self.obj.is_shared() or not attr: + return (attendee, attr) + except IndexError: + pass + + return None + + def can_remove_attendee(self, index): + + """ + Return whether the attendee at 'index' can be removed, requiring either + the organiser or a new attendee. + """ + + attendees = self.state.get("attendees") + attendee = attendees.keys()[index] + + try: + attr = attendees[attendee] + if self.is_organiser() or not attr: + return (attendee, attr) + except IndexError: + pass + + return None + + def remove_attendees(self, indexes): + + "Remove attendee at 'index'." + + attendees = self.state.get("attendees") + to_remove = [] + + for index in indexes: + attendee_item = self.can_remove_attendee(index) + if attendee_item: + attendee, attr = attendee_item + to_remove.append(attendee) + + for key in to_remove: + del attendees[key] + + def can_edit_period(self, index): + + """ + Return the period at 'index' for editing or None if it cannot be edited. + """ + + try: + return self.state.get("periods")[index] + except IndexError: + return None + + def can_edit_properties(self): + + "Return whether general event properties can be edited." + + return self.is_organiser() @@ -95,11 +656,23 @@ return dt, get_datetime_attributes(dt) def get_recurrenceid(self): + + """ + Return a recurrence identity to be used to associate stored periods with + edited periods. + """ + if not self.recurrenceid: return RecurringPeriod.get_recurrenceid(self) return self.recurrenceid def get_recurrenceid_item(self): + + """ + Return a recurrence identifier value and datetime properties for use in + specifying the RECURRENCE-ID property. + """ + if not self.recurrenceid: return RecurringPeriod.get_recurrenceid_item(self) return self._get_recurrenceid_item() @@ -232,7 +805,8 @@ return "FormPeriod%r" % (self.as_tuple(),) def copy(self): - return FormPeriod(*self.as_tuple()) + args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:] + return FormPeriod(*args) def as_event_period(self, index=None): @@ -337,6 +911,9 @@ def as_tuple(self): return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr + def copy(self): + return FormDate(*self.as_tuple()) + def reset(self): self.dt = None @@ -470,17 +1047,30 @@ def periods_from_updated_periods(updated_periods, fn): """ - Return periods from the given 'updated_periods' created using 'fn, setting + Return periods from the given 'updated_periods' created using 'fn', setting replacement, cancelled and recurrence identifier details. + + This function should be used to produce editing-related periods from the + general updated periods provided by the client abstractions. """ periods = [] for sp, p in updated_periods: + + # Stored periods with corresponding current periods. + if p: period = fn(p) - if sp != p: + + # Replacements are identified by comparing object identities, since + # a replacement will not be provided by the same object. + + if sp is not p: period.replacement = True + + # Stored periods without corresponding current periods. + else: period = fn(sp) period.replacement = True @@ -519,7 +1109,10 @@ def combine_periods(old, new): - "Combine 'old' and 'new' periods for comparison." + """ + Combine 'old' and 'new' periods for comparison, making a list of (old, new) + updated period tuples. + """ old_by_recurrenceid, _new_periods = periods_by_recurrence(old) new_by_recurrenceid, new_periods = periods_by_recurrence(new) @@ -528,59 +1121,151 @@ for recurrenceid, op in old_by_recurrenceid.items(): np = new_by_recurrenceid.get(recurrenceid) - if np and not np.cancelled: + + # Old period has corresponding new period that is not cancelled. + + if np and not (np.cancelled and not op.cancelled): combined.append((op, np)) + + # No corresponding new, uncancelled period. + else: combined.append((op, None)) + # New periods without corresponding old periods are genuinely new. + for np in new_periods: combined.append((None, np)) + # Note that new periods should not have recurrence identifiers, and if + # imported from other events, they should have such identifiers removed. + 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 and removed - periods. + return a tuple containing collections of new, replaced, retained and + cancelled periods. - Note that changed and unchanged indicate the presence or absence of - differences between the original event periods and the current periods, not + Note that replaced and retained indicate the presence or absence of + differences between the original event periods and the current periods that + would need to be represented using separate recurrence instances, not whether any editing operations have changed the periods. """ new = [] - changed = [] - unchanged = [] - removed = [] + replaced = [] + retained = [] + cancelled = [] for sp, p in updated_periods: + + # Stored periods... + if sp: + + # With cancelled or absent current periods. + if not p or p.cancelled: - removed.append(sp) + cancelled.append(sp) + + # With differing or replacement current periods. + elif p != sp or p.replacement: - changed.append(p) + replaced.append(p) if not p.replacement: p.new_replacement = True + + # With retained, not differing current periods. + else: - unchanged.append(p) + retained.append(p) if p.new_replacement: p.new_replacement = False + + # New periods without corresponding stored periods. + elif p: new.append(p) - return new, changed, unchanged, removed + return new, replaced, retained, cancelled -def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared): +def classify_period_changes(updated_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. + Using the 'updated_periods', being a list of (original, current) periods, + return a tuple containing collections of modified, unmodified and removed + periods. """ - active_periods = new + unchanged + changed + modified = [] + unmodified = [] + removed = [] + + for op, p in updated_periods: + + # Test for periods cancelled, reinstated or changed, or left unmodified + # during editing. + + if op: + if not op.cancelled and (not p or p.cancelled): + removed.append(op) + elif op.cancelled and not p.cancelled or p != op: + modified.append(p) + else: + unmodified.append(p) + + # New periods are always modifications. + + elif p: + modified.append(p) + + return modified, unmodified, removed + +def classify_period_operations(new, replaced, retained, cancelled, + modified, removed, + is_organiser, is_shared): + + """ + Classify the operations for the update of an event. For updates modifying + shared events, return periods for descheduling and rescheduling (where these + operations can modify the event), and periods for exclusion and application + (where these operations redefine the event). + + To define the new state of the event, details of the complete set of + unscheduled and rescheduled periods are also provided. + """ + + active_periods = new + replaced + retained + + # Modified replaced and retained recurrences are used for incremental + # updates. + + replaced_modified = select_recurrences(replaced, modified).values() + retained_modified = select_recurrences(retained, modified).values() + + # Unmodified replaced and retained recurrences are used in the complete + # event summary. + + replaced_unmodified = subtract_recurrences(replaced, modified).values() + retained_unmodified = subtract_recurrences(retained, modified).values() + + # Obtain the removed periods in terms of existing periods. These are used in + # incremental updates. + + cancelled_removed = select_recurrences(cancelled, removed).values() + + # Reinstated periods are previously-cancelled periods that are now modified + # periods, and they appear in updates. + + reinstated = select_recurrences(modified, cancelled).values() + + # Get cancelled periods without reinstated periods. These appear in complete + # event summaries. + + cancelled_unmodified = subtract_recurrences(cancelled, modified).values() # As organiser... @@ -592,42 +1277,91 @@ # For shared events... # New periods should cause the event to be redefined. + # Other changes should also cause event redefinition. + # Event redefinition should only occur if no replacement periods exist. - if not is_shared or new: + if not is_shared or new and not replaced: + to_set = active_periods to_unschedule = [] to_reschedule = [] - to_set = active_periods + to_add = [] + all_unscheduled = [] + all_rescheduled = [] # Changed periods should be rescheduled separately. # Removed periods should be cancelled separately. else: - to_unschedule = removed - to_reschedule = changed to_set = [] + to_unschedule = cancelled_removed + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) + to_add = new + all_unscheduled = cancelled_unmodified + all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) # As attendee... else: to_unschedule = [] + to_add = [] # Changed periods without new or removed periods are proposed as # separate changes. if not new and not removed: + to_set = [] to_exclude = [] - to_reschedule = changed - to_set = [] + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) + all_unscheduled = list(cancelled_unmodified) + all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) # Otherwise, the event is defined in terms of new periods and # exceptions for removed periods. else: - to_exclude = removed + to_set = active_periods + to_exclude = cancelled to_reschedule = [] - to_set = active_periods + all_unscheduled = [] + all_rescheduled = [] + + return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled + +def get_period_mapping(periods): + + "Return a mapping of recurrence identifiers to the given 'periods." + + d, new = periods_by_recurrence(periods) + return d + +def select_recurrences(source, selected): + + "Restrict 'source' to the recurrences referenced by 'selected'." + + mapping = get_period_mapping(source) - return to_unschedule, to_reschedule, to_exclude, to_set + recurrenceids = get_recurrenceids(selected) + for recurrenceid in mapping.keys(): + if not recurrenceid in recurrenceids: + del mapping[recurrenceid] + return mapping + +def subtract_recurrences(source, selected): + + "Remove from 'source' the recurrences referenced by 'selected'." + + mapping = get_period_mapping(source) + + for recurrenceid in get_recurrenceids(selected): + if mapping.has_key(recurrenceid): + del mapping[recurrenceid] + return mapping + +def get_recurrenceids(periods): + + "Return the recurrence identifiers employed by 'periods'." + + return map(lambda p: p.get_recurrenceid(), periods) @@ -866,6 +1600,51 @@ +# Attendee processing. + +def classify_attendee_changes(original, current): + + """ + Return categories of attendees given the 'original' and 'current' + collections of attendees. + """ + + new = {} + modified = {} + unmodified = {} + + # Check current attendees against the original ones. + + for attendee, attendee_attr in current.items(): + original_attr = original.get(attendee) + + # New attendee if missing original details. + + if not original_attr: + new[attendee] = attendee_attr + + # Details unchanged for existing attendee. + + elif attendee_attr == original_attr: + unmodified[attendee] = attendee_attr + + # Details changed for existing attendee. + + else: + modified[attendee] = attendee_attr + + removed = {} + + # Check for removed attendees. + + for attendee, attendee_attr in original.items(): + if not current.has_key(attendee): + removed[attendee] = attendee_attr + + return new, modified, unmodified, removed + + + # Utilities. def filter_duplicates(l):