# HG changeset patch # User Paul Boddie # Date 1444664523 -7200 # Node ID 317174543da97492d0653b18b7f624dd4b9baae5 # Parent 260691542423f1b187afa9c50598fa1fc1cc8d8d Added initial support for attendee modification of objects and the sending of COUNTER messages when the attendee list or periods are modified. diff -r 260691542423 -r 317174543da9 imiptools/client.py --- a/imiptools/client.py Mon Oct 12 17:41:06 2015 +0200 +++ b/imiptools/client.py Mon Oct 12 17:42:03 2015 +0200 @@ -23,7 +23,7 @@ from imiptools import config from imiptools.data import Object, get_address, get_uri, get_window_end, \ is_new_object, make_freebusy, to_part, \ - uri_dict, uri_items, uri_values + uri_dict, uri_items, uri_parts, uri_values from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \ get_duration, get_timestamp from imiptools.period import can_schedule, remove_period, \ @@ -194,65 +194,6 @@ # Common operations on calendar data. - def update_attendees(self, obj, attendees, removed): - - """ - Update the attendees in 'obj' with the given 'attendees' and 'removed' - attendee lists. A list is returned containing the attendees whose - attendance should be cancelled. - """ - - to_cancel = [] - - existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) - added = set(attendees).difference(existing_attendees) - - if added or removed: - attendees = uri_items(obj.get_items("ATTENDEE") or []) - sequence = obj.get_value("SEQUENCE") - - if removed: - remaining = [] - - for attendee, attendee_attr in attendees: - if attendee in removed: - - # Without a sequence number, assume that the event has not - # been published and that attendees can be silently removed. - - if sequence is not None: - to_cancel.append((attendee, attendee_attr)) - else: - remaining.append((attendee, attendee_attr)) - - attendees = remaining - - if added: - for attendee in added: - attendee = attendee.strip() - if attendee: - attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) - - obj["ATTENDEE"] = attendees - - return to_cancel - - def update_participation(self, obj, partstat=None): - - """ - Update the participation in 'obj' of the user with the given 'partstat'. - """ - - attendee_attr = uri_dict(obj.get_value_map("ATTENDEE")).get(self.user) - if not attendee_attr: - return None - if partstat: - attendee_attr["PARTSTAT"] = partstat - if attendee_attr.has_key("RSVP"): - del attendee_attr["RSVP"] - self.update_sender(attendee_attr) - return attendee_attr - def update_sender(self, attr): "Update the SENT-BY attribute of the 'attr' sender metadata." @@ -358,6 +299,14 @@ return True + def is_organiser(self): + + """ + Return whether the current user is the organiser in the current object. + """ + + return get_uri(self.obj.get_value("ORGANIZER")) == self.user + # Object update methods. def update_recurrenceid(self): @@ -417,6 +366,105 @@ return True + def update_attendees(self, attendees, removed): + + """ + 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. + """ + + to_cancel = [] + + existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) + existing_attendees_map = dict(existing_attendees) + + # Added attendees are those from the supplied collection not already + # present in the object. + + added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) + + # NOTE: When countering, no removals will occur, but additions might. + + if added or removed: + + # The organiser can remove existing attendees. + + if removed and self.is_organiser(): + remaining = [] + + for attendee, attendee_attr in existing_attendees: + if attendee in removed: + + # Only when an event has not been published can + # attendees be silently removed. + + if obj.is_shared(): + to_cancel.append((attendee, attendee_attr)) + else: + remaining.append((attendee, attendee_attr)) + + existing_attendees = remaining + + # 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: + + # Obtain a mapping from URIs to name details. + + attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) + + for attendee in added: + attendee = attendee.strip() + if attendee: + cn = attendee_map.get(attendee) + attendee_attr = {"CN" : cn} or {} + + # Only the organiser can reset the participation attributes. + + if self.is_organiser(): + attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) + + existing_attendees.append((attendee, attendee_attr)) + + # Attendees (when countering) must only include the current user and + # any added attendees. + + 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)) + + self.obj["ATTENDEE"] = existing_attendees + + return added, to_cancel + + def update_participation(self, partstat=None): + + """ + Update the participation in the current object of the user with the + given 'partstat'. + """ + + attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user) + if not attendee_attr: + return None + if partstat: + attendee_attr["PARTSTAT"] = partstat + if attendee_attr.has_key("RSVP"): + del attendee_attr["RSVP"] + self.update_sender(attendee_attr) + return attendee_attr + # Object-related tests. def is_recognised_organiser(self, organiser): diff -r 260691542423 -r 317174543da9 imiptools/handlers/resource.py --- a/imiptools/handlers/resource.py Mon Oct 12 17:41:06 2015 +0200 +++ b/imiptools/handlers/resource.py Mon Oct 12 17:42:03 2015 +0200 @@ -88,7 +88,7 @@ # Refuse to schedule obviously invalid requests. except ValidityError: - attendee_attr = self.update_participation(self.obj, "DECLINED") + attendee_attr = self.update_participation("DECLINED") # With a valid request, determine whether the event can be scheduled. @@ -138,8 +138,7 @@ # Update free/busy information. if method == "REPLY": - attendee_attr = self.update_participation(self.obj, - scheduled and "ACCEPTED" or "DECLINED") + attendee_attr = self.update_participation(scheduled and "ACCEPTED" or "DECLINED") self.update_event_in_freebusy(for_organiser=False) self.remove_event_from_freebusy_offers() diff -r 260691542423 -r 317174543da9 imipweb/event.py --- a/imipweb/event.py Mon Oct 12 17:41:06 2015 +0200 +++ b/imipweb/event.py Mon Oct 12 17:42:03 2015 +0200 @@ -52,6 +52,9 @@ (None, "Not indicated"), ] + def can_change_object(self): + return self.is_organiser() or self._is_request() + def can_remove_recurrence(self, recurrence): """ @@ -80,7 +83,7 @@ notification. """ - return self.can_edit_attendee(attendee) or attendee == self.user + return self.can_edit_attendee(attendee) or attendee == self.user and self.is_organiser() def can_edit_attendee(self, attendee): @@ -96,9 +99,6 @@ # Access to stored object information. - def is_organiser(self): - return get_uri(self.obj.get_value("ORGANIZER")) == self.user - def get_stored_attendees(self): return [get_verbose_address(value, attr) for value, attr in self.obj.get_items("ATTENDEE") or []] @@ -141,7 +141,6 @@ attendees = uri_values(self.get_current_attendees()) is_attendee = self.user in attendees - is_request = self._have_request(self.uid, self.recurrenceid) if not self.obj.is_shared(): page.p("This event has not been shared.") @@ -166,7 +165,7 @@ self.control("create", "submit", "Update event") page.add(" ") - if self.obj.is_shared() and not is_request: + if self.obj.is_shared() and not self._is_request(): self.control("cancel", "submit", "Cancel event") else: self.control("discard", "submit", "Discard event") @@ -294,7 +293,7 @@ # Allow more attendees to be specified. - if self.is_organiser(): + if self.can_change_object(): if not first: page.tr() @@ -304,6 +303,8 @@ page.td.close() page.tr.close() + # NOTE: Permit attendees to suggest others for counter-proposals. + # Handle potentially many values of other kinds. else: @@ -349,8 +350,9 @@ page.td(class_="objectvalue") # Show a form control as organiser for new attendees. + # NOTE: Permit suggested attendee editing for counter-proposals. - if self.is_organiser() and self.can_edit_attendee(attendee_uri): + if self.can_change_object() and self.can_edit_attendee(attendee_uri): self.control("attendee", "value", attendee, size="40") else: self.control("attendee", "hidden", attendee) @@ -376,8 +378,9 @@ page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat") # Permit organisers to remove attendees. + # NOTE: Permit the removal of suggested attendees for counter-proposals. - if self.is_organiser(): + if self.can_change_object() and (self.can_remove_attendee(attendee_uri) or self.is_organiser()): # Permit the removal of newly-added attendees. @@ -425,8 +428,9 @@ page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size()) # Show each recurrence in a separate table if editable. + # NOTE: Allow recurrence editing for counter-proposals. - if self.is_organiser() and recurrences: + if self.can_change_object() and recurrences: for index, period in enumerate(recurrences): self.show_recurrence(index, period, self.recurrenceid, recurrenceids, errors) @@ -732,12 +736,14 @@ # Update the object. single_user = False + changed = False if reply or create or cancel or save: # Update principal event details if organiser. + # NOTE: Handle edited details for counter-proposals. - if self.is_organiser(): + if self.can_change_object(): # Update time periods (main and recurring). @@ -756,26 +762,32 @@ to_unschedule, to_exclude = self.get_removed_periods(periods) - self.obj.set_period(period) - self.obj.set_periods(periods) - self.obj.update_exceptions(to_exclude) + changed = self.obj.set_period(period) or changed + changed = self.obj.set_periods(periods) or changed + changed = self.obj.update_exceptions(to_exclude) or changed - # Update summary. + # Organiser-only changes... + + if self.is_organiser(): + + # Update summary. - if args.has_key("summary"): - self.obj["SUMMARY"] = [(args["summary"][0], {})] + if args.has_key("summary"): + self.obj["SUMMARY"] = [(args["summary"][0], {})] - # Obtain any participants and those to be removed. + # Obtain any new participants and those to be removed. - attendees = map(lambda s: s and get_uri(s), self.get_attendees_from_page()) - removed = [attendees[int(i)] for i in args.get("remove", [])] - to_cancel = self.update_attendees(self.obj, attendees, removed) - single_user = not attendees or attendees == [self.user] + if self.can_change_object(): + attendees = self.get_attendees_from_page() + removed = [attendees[int(i)] for i in args.get("remove", [])] + added, to_cancel = self.update_attendees(attendees, removed) + single_user = not attendees or attendees == [self.user] + changed = added or changed # Update attendee participation for the current user. if args.has_key("partstat"): - self.update_participation(self.obj, args["partstat"][0]) + self.update_participation(args["partstat"][0]) # Process any action. @@ -788,7 +800,7 @@ # Process the object and remove it from the list of requests. - if reply and self.process_received_request(): + if reply and self.process_received_request(changed): self.remove_request() elif self.is_organiser() and (invite or cancel): @@ -1069,7 +1081,7 @@ on whether editing has begun or whether the object has just been loaded. """ - if self.is_initial_load() or not self.is_organiser(): + if self.is_initial_load() or not self.can_change_object(): return self.get_stored_main_period() else: return self.get_main_period_from_page() @@ -1081,7 +1093,7 @@ details where no editing is in progress, using form data otherwise. """ - if self.is_initial_load() or not self.is_organiser(): + if self.is_initial_load() or not self.can_change_object(): return self.get_stored_recurrences() else: return self.get_recurrences_from_page() @@ -1090,7 +1102,7 @@ "Return an updated collection of recurrences for the current object." - if self.is_initial_load() or not self.is_organiser(): + if self.is_initial_load() or not self.can_change_object(): return self.get_stored_recurrences() else: return self.update_recurrences_from_page() @@ -1103,7 +1115,7 @@ form. """ - if self.is_initial_load() or not self.is_organiser(): + if self.is_initial_load() or not self.can_change_object(): return self.get_stored_attendees() else: return self.get_attendees_from_page() @@ -1112,7 +1124,7 @@ "Return an updated collection of attendees for the current object." - if self.is_initial_load() or not self.is_organiser(): + if self.is_initial_load() or not self.can_change_object(): return self.get_stored_attendees() else: return self.update_attendees_from_page() diff -r 260691542423 -r 317174543da9 imipweb/resource.py --- a/imipweb/resource.py Mon Oct 12 17:41:06 2015 +0200 +++ b/imipweb/resource.py Mon Oct 12 17:42:03 2015 +0200 @@ -138,6 +138,9 @@ def _have_request(self, uid, recurrenceid=None, type=None, strict=False): return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict) + def _is_request(self): + return self._have_request(self.uid, self.recurrenceid) + def _get_counters(self, uid, recurrenceid=None): return self.store.get_counters(self.user, uid, recurrenceid) @@ -280,24 +283,28 @@ self._send_message(get_address(self.user), [get_address(attendee)], parts=[obj.to_part(method)]) return True - def process_received_request(self): + def process_received_request(self, changed=False): """ Process the current request for the current user. Return whether any - action was taken. + 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. """ # Reply only on behalf of this user. - attendee_attr = self.update_participation(self.obj) + attendee_attr = self.update_participation() if not attendee_attr: return False - self.obj["ATTENDEE"] = [(self.user, attendee_attr)] + if not changed: + self.obj["ATTENDEE"] = [(self.user, attendee_attr)] + self.update_dtstamp() self.update_sequence(False) - self.send_message("REPLY", get_address(self.user), from_organiser=False) + self.send_message(changed and "COUNTER" or "REPLY", get_address(self.user), from_organiser=False) return True def process_created_request(self, method, to_cancel=None, to_unschedule=None): @@ -581,8 +588,9 @@ page = self.page # Show controls for editing as organiser. + # NOTE: Allow attendees to edit datetimes for counter-proposals. - if self.is_organiser(): + if self.can_change_object(): page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) if show_start: @@ -635,8 +643,9 @@ replaced = not recurrenceid and p.is_replaced(recurrenceids) # Show controls for editing as organiser. + # NOTE: Allow attendees to edit datetimes for counter-proposals. - if self.is_organiser() and not replaced: + if self.can_change_object() and not replaced: page.td(class_="objectvalue dt%s" % (show_start and "start" or "end")) read_only = period.origin == "RRULE"