imip-agent

Changeset

818:317174543da9
2015-10-12 Paul Boddie raw files shortlog changelog graph Added initial support for attendee modification of objects and the sending of COUNTER messages when the attendee list or periods are modified.
imiptools/client.py (file) imiptools/handlers/resource.py (file) imipweb/event.py (file) imipweb/resource.py (file)
     1.1 --- a/imiptools/client.py	Mon Oct 12 17:41:06 2015 +0200
     1.2 +++ b/imiptools/client.py	Mon Oct 12 17:42:03 2015 +0200
     1.3 @@ -23,7 +23,7 @@
     1.4  from imiptools import config
     1.5  from imiptools.data import Object, get_address, get_uri, get_window_end, \
     1.6                             is_new_object, make_freebusy, to_part, \
     1.7 -                           uri_dict, uri_items, uri_values
     1.8 +                           uri_dict, uri_items, uri_parts, uri_values
     1.9  from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \
    1.10                              get_duration, get_timestamp
    1.11  from imiptools.period import can_schedule, remove_period, \
    1.12 @@ -194,65 +194,6 @@
    1.13  
    1.14      # Common operations on calendar data.
    1.15  
    1.16 -    def update_attendees(self, obj, attendees, removed):
    1.17 -
    1.18 -        """
    1.19 -        Update the attendees in 'obj' with the given 'attendees' and 'removed'
    1.20 -        attendee lists. A list is returned containing the attendees whose
    1.21 -        attendance should be cancelled.
    1.22 -        """
    1.23 -
    1.24 -        to_cancel = []
    1.25 -
    1.26 -        existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])
    1.27 -        added = set(attendees).difference(existing_attendees)
    1.28 -
    1.29 -        if added or removed:
    1.30 -            attendees = uri_items(obj.get_items("ATTENDEE") or [])
    1.31 -            sequence = obj.get_value("SEQUENCE")
    1.32 -
    1.33 -            if removed:
    1.34 -                remaining = []
    1.35 -
    1.36 -                for attendee, attendee_attr in attendees:
    1.37 -                    if attendee in removed:
    1.38 -
    1.39 -                        # Without a sequence number, assume that the event has not
    1.40 -                        # been published and that attendees can be silently removed.
    1.41 -
    1.42 -                        if sequence is not None:
    1.43 -                            to_cancel.append((attendee, attendee_attr))
    1.44 -                    else:
    1.45 -                        remaining.append((attendee, attendee_attr))
    1.46 -
    1.47 -                attendees = remaining
    1.48 -
    1.49 -            if added:
    1.50 -                for attendee in added:
    1.51 -                    attendee = attendee.strip()
    1.52 -                    if attendee:
    1.53 -                        attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))
    1.54 -
    1.55 -            obj["ATTENDEE"] = attendees
    1.56 -
    1.57 -        return to_cancel
    1.58 -
    1.59 -    def update_participation(self, obj, partstat=None):
    1.60 -
    1.61 -        """
    1.62 -        Update the participation in 'obj' of the user with the given 'partstat'.
    1.63 -        """
    1.64 -
    1.65 -        attendee_attr = uri_dict(obj.get_value_map("ATTENDEE")).get(self.user)
    1.66 -        if not attendee_attr:
    1.67 -            return None
    1.68 -        if partstat:
    1.69 -            attendee_attr["PARTSTAT"] = partstat
    1.70 -        if attendee_attr.has_key("RSVP"):
    1.71 -            del attendee_attr["RSVP"]
    1.72 -        self.update_sender(attendee_attr)
    1.73 -        return attendee_attr
    1.74 -
    1.75      def update_sender(self, attr):
    1.76  
    1.77          "Update the SENT-BY attribute of the 'attr' sender metadata."
    1.78 @@ -358,6 +299,14 @@
    1.79  
    1.80          return True
    1.81  
    1.82 +    def is_organiser(self):
    1.83 +
    1.84 +        """
    1.85 +        Return whether the current user is the organiser in the current object.
    1.86 +        """
    1.87 +
    1.88 +        return get_uri(self.obj.get_value("ORGANIZER")) == self.user
    1.89 +
    1.90      # Object update methods.
    1.91  
    1.92      def update_recurrenceid(self):
    1.93 @@ -417,6 +366,105 @@
    1.94  
    1.95          return True
    1.96  
    1.97 +    def update_attendees(self, attendees, removed):
    1.98 +
    1.99 +        """
   1.100 +        Update the attendees in the current object with the given 'attendees'
   1.101 +        and 'removed' attendee lists.
   1.102 +
   1.103 +        A tuple is returned containing two items: a list of the attendees whose
   1.104 +        attendance is being proposed (in a counter-proposal), a list of the
   1.105 +        attendees whose attendance should be cancelled.
   1.106 +        """
   1.107 +
   1.108 +        to_cancel = []
   1.109 +
   1.110 +        existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or [])
   1.111 +        existing_attendees_map = dict(existing_attendees)
   1.112 +
   1.113 +        # Added attendees are those from the supplied collection not already
   1.114 +        # present in the object.
   1.115 +
   1.116 +        added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees])
   1.117 +
   1.118 +        # NOTE: When countering, no removals will occur, but additions might.
   1.119 +
   1.120 +        if added or removed:
   1.121 +
   1.122 +            # The organiser can remove existing attendees.
   1.123 +
   1.124 +            if removed and self.is_organiser():
   1.125 +                remaining = []
   1.126 +
   1.127 +                for attendee, attendee_attr in existing_attendees:
   1.128 +                    if attendee in removed:
   1.129 +
   1.130 +                        # Only when an event has not been published can
   1.131 +                        # attendees be silently removed.
   1.132 +
   1.133 +                        if obj.is_shared():
   1.134 +                            to_cancel.append((attendee, attendee_attr))
   1.135 +                    else:
   1.136 +                        remaining.append((attendee, attendee_attr))
   1.137 +
   1.138 +                existing_attendees = remaining
   1.139 +
   1.140 +            # Attendees (when countering) must only include the current user and
   1.141 +            # any added attendees.
   1.142 +
   1.143 +            elif not self.is_organiser():
   1.144 +                existing_attendees = []
   1.145 +
   1.146 +            # Both organisers and attendees (when countering) can add attendees.
   1.147 +
   1.148 +            if added:
   1.149 +
   1.150 +                # Obtain a mapping from URIs to name details.
   1.151 +
   1.152 +                attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)])
   1.153 +
   1.154 +                for attendee in added:
   1.155 +                    attendee = attendee.strip()
   1.156 +                    if attendee:
   1.157 +                        cn = attendee_map.get(attendee)
   1.158 +                        attendee_attr = {"CN" : cn} or {}
   1.159 +
   1.160 +                        # Only the organiser can reset the participation attributes.
   1.161 +
   1.162 +                        if self.is_organiser():
   1.163 +                            attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})
   1.164 +
   1.165 +                        existing_attendees.append((attendee, attendee_attr))
   1.166 +
   1.167 +            # Attendees (when countering) must only include the current user and
   1.168 +            # any added attendees.
   1.169 +
   1.170 +            if not self.is_organiser() and self.user not in existing_attendees:
   1.171 +                user_attr = self.get_user_attributes()
   1.172 +                user_attr.update(existing_attendees_map.get(self.user) or {})
   1.173 +                existing_attendees.append((self.user, user_attr))
   1.174 +
   1.175 +            self.obj["ATTENDEE"] = existing_attendees
   1.176 +
   1.177 +        return added, to_cancel
   1.178 +
   1.179 +    def update_participation(self, partstat=None):
   1.180 +
   1.181 +        """
   1.182 +        Update the participation in the current object of the user with the
   1.183 +        given 'partstat'.
   1.184 +        """
   1.185 +
   1.186 +        attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user)
   1.187 +        if not attendee_attr:
   1.188 +            return None
   1.189 +        if partstat:
   1.190 +            attendee_attr["PARTSTAT"] = partstat
   1.191 +        if attendee_attr.has_key("RSVP"):
   1.192 +            del attendee_attr["RSVP"]
   1.193 +        self.update_sender(attendee_attr)
   1.194 +        return attendee_attr
   1.195 +
   1.196      # Object-related tests.
   1.197  
   1.198      def is_recognised_organiser(self, organiser):
     2.1 --- a/imiptools/handlers/resource.py	Mon Oct 12 17:41:06 2015 +0200
     2.2 +++ b/imiptools/handlers/resource.py	Mon Oct 12 17:42:03 2015 +0200
     2.3 @@ -88,7 +88,7 @@
     2.4          # Refuse to schedule obviously invalid requests.
     2.5  
     2.6          except ValidityError:
     2.7 -            attendee_attr = self.update_participation(self.obj, "DECLINED")
     2.8 +            attendee_attr = self.update_participation("DECLINED")
     2.9  
    2.10          # With a valid request, determine whether the event can be scheduled.
    2.11  
    2.12 @@ -138,8 +138,7 @@
    2.13              # Update free/busy information.
    2.14  
    2.15              if method == "REPLY":
    2.16 -                attendee_attr = self.update_participation(self.obj,
    2.17 -                    scheduled and "ACCEPTED" or "DECLINED")
    2.18 +                attendee_attr = self.update_participation(scheduled and "ACCEPTED" or "DECLINED")
    2.19  
    2.20                  self.update_event_in_freebusy(for_organiser=False)
    2.21                  self.remove_event_from_freebusy_offers()
     3.1 --- a/imipweb/event.py	Mon Oct 12 17:41:06 2015 +0200
     3.2 +++ b/imipweb/event.py	Mon Oct 12 17:42:03 2015 +0200
     3.3 @@ -52,6 +52,9 @@
     3.4          (None, "Not indicated"),
     3.5          ]
     3.6  
     3.7 +    def can_change_object(self):
     3.8 +        return self.is_organiser() or self._is_request()
     3.9 +
    3.10      def can_remove_recurrence(self, recurrence):
    3.11  
    3.12          """
    3.13 @@ -80,7 +83,7 @@
    3.14          notification.
    3.15          """
    3.16  
    3.17 -        return self.can_edit_attendee(attendee) or attendee == self.user
    3.18 +        return self.can_edit_attendee(attendee) or attendee == self.user and self.is_organiser()
    3.19  
    3.20      def can_edit_attendee(self, attendee):
    3.21  
    3.22 @@ -96,9 +99,6 @@
    3.23  
    3.24      # Access to stored object information.
    3.25  
    3.26 -    def is_organiser(self):
    3.27 -        return get_uri(self.obj.get_value("ORGANIZER")) == self.user
    3.28 -
    3.29      def get_stored_attendees(self):
    3.30          return [get_verbose_address(value, attr) for value, attr in self.obj.get_items("ATTENDEE") or []]
    3.31  
    3.32 @@ -141,7 +141,6 @@
    3.33  
    3.34          attendees = uri_values(self.get_current_attendees())
    3.35          is_attendee = self.user in attendees
    3.36 -        is_request = self._have_request(self.uid, self.recurrenceid)
    3.37  
    3.38          if not self.obj.is_shared():
    3.39              page.p("This event has not been shared.")
    3.40 @@ -166,7 +165,7 @@
    3.41              self.control("create", "submit", "Update event")
    3.42              page.add(" ")
    3.43  
    3.44 -            if self.obj.is_shared() and not is_request:
    3.45 +            if self.obj.is_shared() and not self._is_request():
    3.46                  self.control("cancel", "submit", "Cancel event")
    3.47              else:
    3.48                  self.control("discard", "submit", "Discard event")
    3.49 @@ -294,7 +293,7 @@
    3.50  
    3.51                  # Allow more attendees to be specified.
    3.52  
    3.53 -                if self.is_organiser():
    3.54 +                if self.can_change_object():
    3.55                      if not first:
    3.56                          page.tr()
    3.57  
    3.58 @@ -304,6 +303,8 @@
    3.59                      page.td.close()
    3.60                      page.tr.close()
    3.61  
    3.62 +                # NOTE: Permit attendees to suggest others for counter-proposals.
    3.63 +
    3.64              # Handle potentially many values of other kinds.
    3.65  
    3.66              else:
    3.67 @@ -349,8 +350,9 @@
    3.68          page.td(class_="objectvalue")
    3.69  
    3.70          # Show a form control as organiser for new attendees.
    3.71 +        # NOTE: Permit suggested attendee editing for counter-proposals.
    3.72  
    3.73 -        if self.is_organiser() and self.can_edit_attendee(attendee_uri):
    3.74 +        if self.can_change_object() and self.can_edit_attendee(attendee_uri):
    3.75              self.control("attendee", "value", attendee, size="40")
    3.76          else:
    3.77              self.control("attendee", "hidden", attendee)
    3.78 @@ -376,8 +378,9 @@
    3.79              page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")
    3.80  
    3.81          # Permit organisers to remove attendees.
    3.82 +        # NOTE: Permit the removal of suggested attendees for counter-proposals.
    3.83  
    3.84 -        if self.is_organiser():
    3.85 +        if self.can_change_object() and (self.can_remove_attendee(attendee_uri) or self.is_organiser()):
    3.86  
    3.87              # Permit the removal of newly-added attendees.
    3.88  
    3.89 @@ -425,8 +428,9 @@
    3.90          page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())
    3.91  
    3.92          # Show each recurrence in a separate table if editable.
    3.93 +        # NOTE: Allow recurrence editing for counter-proposals.
    3.94  
    3.95 -        if self.is_organiser() and recurrences:
    3.96 +        if self.can_change_object() and recurrences:
    3.97  
    3.98              for index, period in enumerate(recurrences):
    3.99                  self.show_recurrence(index, period, self.recurrenceid, recurrenceids, errors)
   3.100 @@ -732,12 +736,14 @@
   3.101          # Update the object.
   3.102  
   3.103          single_user = False
   3.104 +        changed = False
   3.105  
   3.106          if reply or create or cancel or save:
   3.107  
   3.108              # Update principal event details if organiser.
   3.109 +            # NOTE: Handle edited details for counter-proposals.
   3.110  
   3.111 -            if self.is_organiser():
   3.112 +            if self.can_change_object():
   3.113  
   3.114                  # Update time periods (main and recurring).
   3.115  
   3.116 @@ -756,26 +762,32 @@
   3.117  
   3.118                  to_unschedule, to_exclude = self.get_removed_periods(periods)
   3.119  
   3.120 -                self.obj.set_period(period)
   3.121 -                self.obj.set_periods(periods)
   3.122 -                self.obj.update_exceptions(to_exclude)
   3.123 +                changed = self.obj.set_period(period) or changed
   3.124 +                changed = self.obj.set_periods(periods) or changed
   3.125 +                changed = self.obj.update_exceptions(to_exclude) or changed
   3.126  
   3.127 -                # Update summary.
   3.128 +                # Organiser-only changes...
   3.129 +
   3.130 +                if self.is_organiser():
   3.131 +
   3.132 +                    # Update summary.
   3.133  
   3.134 -                if args.has_key("summary"):
   3.135 -                    self.obj["SUMMARY"] = [(args["summary"][0], {})]
   3.136 +                    if args.has_key("summary"):
   3.137 +                        self.obj["SUMMARY"] = [(args["summary"][0], {})]
   3.138  
   3.139 -                # Obtain any participants and those to be removed.
   3.140 +                # Obtain any new participants and those to be removed.
   3.141  
   3.142 -                attendees = map(lambda s: s and get_uri(s), self.get_attendees_from_page())
   3.143 -                removed = [attendees[int(i)] for i in args.get("remove", [])]
   3.144 -                to_cancel = self.update_attendees(self.obj, attendees, removed)
   3.145 -                single_user = not attendees or attendees == [self.user]
   3.146 +                if self.can_change_object():
   3.147 +                    attendees = self.get_attendees_from_page()
   3.148 +                    removed = [attendees[int(i)] for i in args.get("remove", [])]
   3.149 +                    added, to_cancel = self.update_attendees(attendees, removed)
   3.150 +                    single_user = not attendees or attendees == [self.user]
   3.151 +                    changed = added or changed
   3.152  
   3.153              # Update attendee participation for the current user.
   3.154  
   3.155              if args.has_key("partstat"):
   3.156 -                self.update_participation(self.obj, args["partstat"][0])
   3.157 +                self.update_participation(args["partstat"][0])
   3.158  
   3.159          # Process any action.
   3.160  
   3.161 @@ -788,7 +800,7 @@
   3.162  
   3.163              # Process the object and remove it from the list of requests.
   3.164  
   3.165 -            if reply and self.process_received_request():
   3.166 +            if reply and self.process_received_request(changed):
   3.167                  self.remove_request()
   3.168  
   3.169              elif self.is_organiser() and (invite or cancel):
   3.170 @@ -1069,7 +1081,7 @@
   3.171          on whether editing has begun or whether the object has just been loaded.
   3.172          """
   3.173  
   3.174 -        if self.is_initial_load() or not self.is_organiser():
   3.175 +        if self.is_initial_load() or not self.can_change_object():
   3.176              return self.get_stored_main_period()
   3.177          else:
   3.178              return self.get_main_period_from_page()
   3.179 @@ -1081,7 +1093,7 @@
   3.180          details where no editing is in progress, using form data otherwise.
   3.181          """
   3.182  
   3.183 -        if self.is_initial_load() or not self.is_organiser():
   3.184 +        if self.is_initial_load() or not self.can_change_object():
   3.185              return self.get_stored_recurrences()
   3.186          else:
   3.187              return self.get_recurrences_from_page()
   3.188 @@ -1090,7 +1102,7 @@
   3.189  
   3.190          "Return an updated collection of recurrences for the current object."
   3.191  
   3.192 -        if self.is_initial_load() or not self.is_organiser():
   3.193 +        if self.is_initial_load() or not self.can_change_object():
   3.194              return self.get_stored_recurrences()
   3.195          else:
   3.196              return self.update_recurrences_from_page()
   3.197 @@ -1103,7 +1115,7 @@
   3.198          form.
   3.199          """
   3.200  
   3.201 -        if self.is_initial_load() or not self.is_organiser():
   3.202 +        if self.is_initial_load() or not self.can_change_object():
   3.203              return self.get_stored_attendees()
   3.204          else:
   3.205              return self.get_attendees_from_page()
   3.206 @@ -1112,7 +1124,7 @@
   3.207  
   3.208          "Return an updated collection of attendees for the current object."
   3.209  
   3.210 -        if self.is_initial_load() or not self.is_organiser():
   3.211 +        if self.is_initial_load() or not self.can_change_object():
   3.212              return self.get_stored_attendees()
   3.213          else:
   3.214              return self.update_attendees_from_page()
     4.1 --- a/imipweb/resource.py	Mon Oct 12 17:41:06 2015 +0200
     4.2 +++ b/imipweb/resource.py	Mon Oct 12 17:42:03 2015 +0200
     4.3 @@ -138,6 +138,9 @@
     4.4      def _have_request(self, uid, recurrenceid=None, type=None, strict=False):
     4.5          return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict)
     4.6  
     4.7 +    def _is_request(self):
     4.8 +        return self._have_request(self.uid, self.recurrenceid)
     4.9 +
    4.10      def _get_counters(self, uid, recurrenceid=None):
    4.11          return self.store.get_counters(self.user, uid, recurrenceid)
    4.12  
    4.13 @@ -280,24 +283,28 @@
    4.14          self._send_message(get_address(self.user), [get_address(attendee)], parts=[obj.to_part(method)])
    4.15          return True
    4.16  
    4.17 -    def process_received_request(self):
    4.18 +    def process_received_request(self, changed=False):
    4.19  
    4.20          """
    4.21          Process the current request for the current user. Return whether any
    4.22 -        action was taken.
    4.23 +        action was taken. If 'changed' is set to a true value, or if 'attendees'
    4.24 +        is specified and differs from the stored attendees, a counter-proposal
    4.25 +        will be sent instead of a reply.
    4.26          """
    4.27  
    4.28          # Reply only on behalf of this user.
    4.29  
    4.30 -        attendee_attr = self.update_participation(self.obj)
    4.31 +        attendee_attr = self.update_participation()
    4.32  
    4.33          if not attendee_attr:
    4.34              return False
    4.35  
    4.36 -        self.obj["ATTENDEE"] = [(self.user, attendee_attr)]
    4.37 +        if not changed:
    4.38 +            self.obj["ATTENDEE"] = [(self.user, attendee_attr)]
    4.39 +
    4.40          self.update_dtstamp()
    4.41          self.update_sequence(False)
    4.42 -        self.send_message("REPLY", get_address(self.user), from_organiser=False)
    4.43 +        self.send_message(changed and "COUNTER" or "REPLY", get_address(self.user), from_organiser=False)
    4.44          return True
    4.45  
    4.46      def process_created_request(self, method, to_cancel=None, to_unschedule=None):
    4.47 @@ -581,8 +588,9 @@
    4.48          page = self.page
    4.49  
    4.50          # Show controls for editing as organiser.
    4.51 +        # NOTE: Allow attendees to edit datetimes for counter-proposals.
    4.52  
    4.53 -        if self.is_organiser():
    4.54 +        if self.can_change_object():
    4.55              page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
    4.56  
    4.57              if show_start:
    4.58 @@ -635,8 +643,9 @@
    4.59          replaced = not recurrenceid and p.is_replaced(recurrenceids)
    4.60  
    4.61          # Show controls for editing as organiser.
    4.62 +        # NOTE: Allow attendees to edit datetimes for counter-proposals.
    4.63  
    4.64 -        if self.is_organiser() and not replaced:
    4.65 +        if self.can_change_object() and not replaced:
    4.66              page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))
    4.67  
    4.68              read_only = period.origin == "RRULE"