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"