1.1 --- a/imiptools/client.py Thu Oct 12 23:14:06 2017 +0200
1.2 +++ b/imiptools/client.py Fri Oct 13 15:07:43 2017 +0200
1.3 @@ -19,13 +19,14 @@
1.4 this program. If not, see <http://www.gnu.org/licenses/>.
1.5 """
1.6
1.7 +from collections import OrderedDict
1.8 from datetime import datetime, timedelta
1.9 from imiptools.config import settings
1.10 from imiptools.data import Object, check_delegation, get_address, get_uri, \
1.11 - get_recurrence_periods, \
1.12 + get_main_period, get_recurrence_periods, \
1.13 get_window_end, is_new_object, make_freebusy, \
1.14 - make_uid, to_part, uri_dict, uri_item, uri_items, \
1.15 - uri_parts, uri_values
1.16 + make_uid, new_object, to_part, uri_dict, uri_item, \
1.17 + uri_items, uri_parts, uri_values
1.18 from imiptools.dates import check_permitted_values, format_datetime, \
1.19 get_datetime, get_default_timezone, \
1.20 get_duration, get_time, get_timestamp, \
1.21 @@ -261,6 +262,74 @@
1.22 start=(future_only and self.get_window_start() or None),
1.23 end=(not explicit_only and self.get_window_end() or None))
1.24
1.25 + def get_updated_periods(self, obj):
1.26 +
1.27 + """
1.28 + Return the periods provided by 'obj' and associated recurrence
1.29 + instances. Each original period is returned in a tuple with a
1.30 + corresponding updated period which may be the same or which may be None
1.31 + if the period is cancelled. A list of these tuples is returned.
1.32 + """
1.33 +
1.34 + uid = obj.get_uid()
1.35 + recurrenceid = obj.get_recurrenceid()
1.36 +
1.37 + updated = []
1.38 +
1.39 + # Consider separate recurrences in isolation from the parent if
1.40 + # specified.
1.41 +
1.42 + if recurrenceid:
1.43 + for period in self.get_periods(obj):
1.44 + updated.append((period, period))
1.45 + return updated
1.46 +
1.47 + # For parent events, identify retained and replaced periods.
1.48 +
1.49 + recurrenceids = self.get_recurrences(uid)
1.50 +
1.51 + for period in self.get_periods(obj):
1.52 + recurrenceid = period.is_replaced(recurrenceids)
1.53 +
1.54 + # For parent event periods, obtain any replacement instead of the
1.55 + # replaced period.
1.56 +
1.57 + if recurrenceid:
1.58 + recurrence = self.get_stored_object(uid, recurrenceid)
1.59 + periods = recurrence and self.get_periods(recurrence)
1.60 +
1.61 + # Active periods are obtained.
1.62 +
1.63 + if periods:
1.64 +
1.65 + # Recurrence instances are assumed to provide only one
1.66 + # period.
1.67 +
1.68 + replacement = periods[0]
1.69 +
1.70 + # Redefine the origin of periods replacing recurrences and
1.71 + # not the main period, leaving DTSTART as the means of
1.72 + # identifying the main period.
1.73 +
1.74 + if replacement.origin == "DTSTART" and \
1.75 + period.origin != "DTSTART":
1.76 +
1.77 + replacement.origin = "DTSTART-RECUR"
1.78 +
1.79 + updated.append((period, replacement))
1.80 +
1.81 + # Cancelled periods yield None.
1.82 +
1.83 + else:
1.84 + updated.append((period, None))
1.85 +
1.86 + # Otherwise, retain the known period.
1.87 +
1.88 + else:
1.89 + updated.append((period, period))
1.90 +
1.91 + return updated
1.92 +
1.93 def get_main_period(self, obj):
1.94
1.95 "Return the main period defined by 'obj'."
1.96 @@ -423,6 +492,13 @@
1.97 self.sequence = obj and self.obj.get_value("SEQUENCE")
1.98 self.dtstamp = obj and self.obj.get_value("DTSTAMP")
1.99
1.100 + def new_object(self, objtype):
1.101 +
1.102 + "Initialise a new object for the client with the given 'objtype'."
1.103 +
1.104 + self.set_object(new_object(objtype, self.user, self.get_user_attributes()))
1.105 + return self.obj
1.106 +
1.107 def load_object(self, uid, recurrenceid):
1.108
1.109 "Load the object with the given 'uid' and 'recurrenceid'."
1.110 @@ -446,6 +522,12 @@
1.111
1.112 return True
1.113
1.114 + def is_attendee(self):
1.115 +
1.116 + "Return whether the current user is an attendee in the current object."
1.117 +
1.118 + return self.obj.get_value_map("ATTENDEE").has_key(self.user)
1.119 +
1.120 def is_organiser(self):
1.121
1.122 """
1.123 @@ -461,11 +543,11 @@
1.124 parent = self.get_parent_object()
1.125 return parent and parent.has_recurrence(self.get_tzid(), self.obj.get_recurrenceid())
1.126
1.127 - def get_recurrences(self):
1.128 + def get_recurrences(self, uid=None):
1.129
1.130 "Return the current object's recurrence identifiers."
1.131
1.132 - return self.store.get_recurrences(self.user, self.uid)
1.133 + return self.store.get_recurrences(self.user, uid or self.uid)
1.134
1.135 def get_periods(self, obj=None, explicit_only=False, future_only=False):
1.136
1.137 @@ -473,43 +555,14 @@
1.138
1.139 return Client.get_periods(self, obj or self.obj, explicit_only, future_only)
1.140
1.141 - def get_updated_periods(self):
1.142 + def get_updated_periods(self, obj=None):
1.143
1.144 """
1.145 Return the periods provided by the current object and associated
1.146 - recurrence instances. Each original period is returned in a tuple with
1.147 - a corresponding updated period which may be the same or which may be
1.148 - None if the period is cancelled. A list of these tuples is returned.
1.149 + recurrence instances.
1.150 """
1.151
1.152 - updated = []
1.153 - recurrenceids = self.get_recurrences()
1.154 -
1.155 - for period in self.get_periods():
1.156 - recurrenceid = period.is_replaced(recurrenceids)
1.157 -
1.158 - # Obtain any replacement instead of the replaced period.
1.159 -
1.160 - if recurrenceid:
1.161 - obj = self.get_stored_object(self.uid, recurrenceid)
1.162 - periods = obj and Client.get_periods(self, obj)
1.163 -
1.164 - # Active periods are obtained. Cancelled periods yield None.
1.165 -
1.166 - if periods:
1.167 - p = periods[0]
1.168 - if p.origin == "DTSTART" and period.origin != "DTSTART":
1.169 - p.origin = "DTSTART-RECUR"
1.170 - updated.append((period, p))
1.171 - else:
1.172 - updated.append((period, None))
1.173 -
1.174 - # Otherwise, retain the known period.
1.175 -
1.176 - else:
1.177 - updated.append((period, period))
1.178 -
1.179 - return updated
1.180 + return Client.get_updated_periods(self, obj or self.obj)
1.181
1.182 def get_main_period(self, obj=None):
1.183
1.184 @@ -547,12 +600,17 @@
1.185 obj = obj or self.obj
1.186 calendar_uri = self.messenger and get_uri(self.messenger.sender)
1.187
1.188 - for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")):
1.189 - if attendee != self.user:
1.190 - if attendee_attr.get("SENT-BY") == calendar_uri:
1.191 - del attendee_attr["SENT-BY"]
1.192 - else:
1.193 - attendee_attr["SENT-BY"] = calendar_uri
1.194 + for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE") or []):
1.195 +
1.196 + # Fix up the SENT-BY attribute for this user.
1.197 +
1.198 + if attendee == self.user:
1.199 + self.update_sender_attr(attendee_attr)
1.200 +
1.201 + # Remove any conflicting SENT-BY attributes for other users.
1.202 +
1.203 + elif attendee_attr.get("SENT-BY") == calendar_uri:
1.204 + del attendee_attr["SENT-BY"]
1.205
1.206 def get_sending_attendee(self):
1.207
1.208 @@ -562,7 +620,7 @@
1.209
1.210 senders = self.senders or self.messenger and [self.messenger.sender] or []
1.211
1.212 - for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")):
1.213 + for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE") or []):
1.214 if get_address(attendee) in senders or \
1.215 get_address(attendee_attr.get("SENT-BY")) in senders:
1.216 return get_uri(attendee)
1.217 @@ -641,86 +699,58 @@
1.218
1.219 return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node())
1.220
1.221 - def update_attendees(self, attendees, removed):
1.222 + def update_attendees(self, to_invite, to_cancel, to_modify):
1.223
1.224 """
1.225 - Update the attendees in the current object with the given 'attendees'
1.226 - and 'removed' attendee lists.
1.227 -
1.228 - A tuple is returned containing two items: a list of the attendees whose
1.229 - attendance is being proposed (in a counter-proposal), a list of the
1.230 - attendees whose attendance should be cancelled.
1.231 + Update the attendees in the current object with the given 'to_invite',
1.232 + 'to_cancel' and 'to_modify' attendee mappings.
1.233 """
1.234
1.235 - to_cancel = []
1.236 -
1.237 - existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or [])
1.238 - existing_attendees_map = dict(existing_attendees)
1.239 + attendees = uri_items(self.obj.get_items("ATTENDEE") or [])
1.240 + attendee_map = OrderedDict(attendees)
1.241
1.242 - # Added attendees are those from the supplied collection not already
1.243 - # present in the object.
1.244 + # Normalise the identities.
1.245
1.246 - added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees])
1.247 - removed = uri_values(removed)
1.248 -
1.249 - if added or removed:
1.250 -
1.251 - # The organiser can remove existing attendees.
1.252 + to_invite = uri_dict(to_invite)
1.253 + to_cancel = uri_dict(to_cancel)
1.254 + to_modify = uri_dict(to_modify)
1.255
1.256 - if removed and self.is_organiser():
1.257 - remaining = []
1.258 + if self.is_organiser():
1.259
1.260 - for attendee, attendee_attr in existing_attendees:
1.261 - if attendee in removed:
1.262 -
1.263 - # Only when an event has not been published can
1.264 - # attendees be silently removed.
1.265 + # Remove uninvited attendees.
1.266
1.267 - if self.obj.is_shared():
1.268 - to_cancel.append((attendee, attendee_attr))
1.269 - else:
1.270 - remaining.append((attendee, attendee_attr))
1.271 + for attendee in to_cancel.keys():
1.272 + if attendee_map.has_key(attendee):
1.273 + del attendee_map[attendee]
1.274
1.275 - existing_attendees = remaining
1.276 -
1.277 - # Attendees (when countering) must only include the current user and
1.278 - # any added attendees.
1.279 + # Attendees (when countering) must only include the current user and
1.280 + # any added attendees.
1.281
1.282 - elif not self.is_organiser():
1.283 - existing_attendees = []
1.284 -
1.285 - # Both organisers and attendees (when countering) can add attendees.
1.286 -
1.287 - if added:
1.288 + else:
1.289 + attr = attendee_map.get(self.user) or self.get_user_attributes()
1.290 + attendee_map = {self.user : attr}
1.291
1.292 - # Obtain a mapping from URIs to name details.
1.293 -
1.294 - attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)])
1.295 + # Update modified attendees.
1.296
1.297 - for attendee in added:
1.298 - attendee = attendee.strip()
1.299 - if attendee:
1.300 - cn = attendee_map.get(attendee)
1.301 - attendee_attr = {"CN" : cn} or {}
1.302 + for attendee, attr in to_modify.items():
1.303 + existing_attr = attendee_map.get(attendee)
1.304 + if existing_attr:
1.305 + existing_attr.update(attr)
1.306
1.307 - # Only the organiser can reset the participation attributes.
1.308 + # Add newly-invited attendees, applicable for organisers and attendees
1.309 + # (when countering).
1.310
1.311 - if self.is_organiser():
1.312 - attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})
1.313 -
1.314 - existing_attendees.append((attendee, attendee_attr))
1.315 + for attendee, attr in to_invite.items():
1.316 + if not attendee_map.has_key(attendee):
1.317
1.318 - # Attendees (when countering) must only include the current user and
1.319 - # any added attendees.
1.320 + # Only the organiser can reset the participation attributes.
1.321
1.322 - if not self.is_organiser() and self.user not in existing_attendees:
1.323 - user_attr = self.get_user_attributes()
1.324 - user_attr.update(existing_attendees_map.get(self.user) or {})
1.325 - existing_attendees.append((self.user, user_attr))
1.326 + if self.is_organiser():
1.327 + attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})
1.328
1.329 - self.obj["ATTENDEE"] = existing_attendees
1.330 + attendee_map[attendee] = attr
1.331
1.332 - return added, to_cancel
1.333 + self.obj["ATTENDEE"] = attendee_map.items()
1.334
1.335 def update_participation(self, partstat=None):
1.336
1.337 @@ -743,7 +773,7 @@
1.338
1.339 return attendee_attr
1.340
1.341 - def update_event(self, changed=False):
1.342 + def update_event_version(self, changed=False):
1.343
1.344 """
1.345 Update the event version information and details for sending. Where
1.346 @@ -754,16 +784,6 @@
1.347
1.348 if self.is_organiser():
1.349 self.update_sender()
1.350 - else:
1.351 - # Reply only on behalf of this user.
1.352 -
1.353 - attendee_attr = self.update_participation()
1.354 -
1.355 - if not attendee_attr:
1.356 - return False
1.357 -
1.358 - if not changed:
1.359 - self.obj["ATTENDEE"] = [(self.user, attendee_attr)]
1.360
1.361 # Process attendee SENT-BY usage, timestamp and sequence details
1.362 # appropriately for the sender's role.
1.363 @@ -774,6 +794,23 @@
1.364
1.365 return True
1.366
1.367 + def update_event_from_periods(self, to_set, to_exclude):
1.368 +
1.369 + """
1.370 + Set the periods in any redefined event from the 'to_set' list, excluding
1.371 + the main period if it appears in 'to_exclude'.
1.372 + """
1.373 +
1.374 + if to_set:
1.375 + self.obj.set_periods(to_set)
1.376 +
1.377 + # Exclude only the main period, if appropriate.
1.378 +
1.379 + if to_exclude:
1.380 + main = get_main_period(to_exclude)
1.381 + if main:
1.382 + self.obj.update_exceptions([main], [])
1.383 +
1.384 # General message generation methods.
1.385
1.386 def get_recipients(self, obj=None):
1.387 @@ -908,25 +945,40 @@
1.388
1.389 return rescheduled_parts
1.390
1.391 - def make_update_message(self, recipients, to_unschedule=None, to_reschedule=None):
1.392 + def make_update_message(self, recipients, update_parent=False,
1.393 + to_unschedule=None, to_reschedule=None,
1.394 + all_unscheduled=None, all_rescheduled=None,
1.395 + to_add=None):
1.396
1.397 """
1.398 Prepare event updates from the organiser of an event for the given
1.399 - 'recipients', using the period collections 'to_unschedule' and
1.400 - 'to_reschedule'.
1.401 + 'recipients', including the parent event if 'update_parent' is set to a
1.402 + true value.
1.403 +
1.404 + Additional parts are provided by the 'to_unschedule' and 'to_reschedule'
1.405 + collections. Alternatively, where the parent event is being updated, the
1.406 + 'all_unscheduled' and 'all_rescheduled' period collections are included.
1.407 +
1.408 + The 'to_add' period collection augments the existing periods.
1.409 """
1.410
1.411 - # Start with the parent object and augment it with the given
1.412 - # amendments providing cancelled and modified occurrence information.
1.413 + parts = []
1.414
1.415 - parts = [self.object_to_part("REQUEST", self.obj)]
1.416 - unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL")
1.417 - rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "REQUEST")
1.418 + if update_parent:
1.419 + parts.append(self.object_to_part("REQUEST", self.obj))
1.420 + unscheduled = all_unscheduled
1.421 + rescheduled = all_rescheduled
1.422 + else:
1.423 + unscheduled = to_unschedule
1.424 + rescheduled = to_reschedule
1.425
1.426 - return self.make_message(parts + unscheduled_parts + rescheduled_parts,
1.427 - recipients)
1.428 + parts += self.get_rescheduled_parts(unscheduled, "CANCEL")
1.429 + parts += self.get_rescheduled_parts(rescheduled, "REQUEST")
1.430 + parts += self.get_rescheduled_parts(to_add, "ADD")
1.431 + return self.make_message(parts, recipients)
1.432
1.433 - def make_self_update_message(self, to_unschedule=None, to_reschedule=None):
1.434 + def make_self_update_message(self, all_unscheduled=None, all_rescheduled=None,
1.435 + to_add=None):
1.436
1.437 """
1.438 Prepare event updates to be sent from the organiser of an event to
1.439 @@ -934,34 +986,51 @@
1.440 """
1.441
1.442 parts = [self.object_to_part("PUBLISH", self.obj)]
1.443 - unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL")
1.444 - rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "PUBLISH")
1.445 - return self.make_message_for_self(parts + unscheduled_parts + rescheduled_parts)
1.446 + parts += self.get_rescheduled_parts(all_unscheduled, "CANCEL")
1.447 + parts += self.get_rescheduled_parts(all_rescheduled, "PUBLISH")
1.448 + parts += self.get_rescheduled_parts(to_add, "ADD")
1.449 + return self.make_message_for_self(parts)
1.450
1.451 - def make_cancel_object(self, to_cancel=None):
1.452 + def make_response_message(self, recipients, update_parent=False,
1.453 + all_rescheduled=None, to_reschedule=None):
1.454
1.455 """
1.456 - Prepare an event cancellation object involving the participants in the
1.457 - 'to_cancel' list.
1.458 + Prepare a response to 'recipients', including the parent event if
1.459 + 'update_parent' is set to a true value, incorporating 'all_rescheduled'
1.460 + periods, of which there may be indicated periods 'to_reschedule'.
1.461 """
1.462
1.463 - if to_cancel:
1.464 - obj = self.obj.copy()
1.465 - obj["ATTENDEE"] = to_cancel
1.466 - else:
1.467 - obj = self.obj
1.468 + parts = [self.object_to_part(update_parent and "COUNTER" or "REPLY", self.obj)]
1.469 +
1.470 + # Determine existing replaced periods that are not newly rescheduled.
1.471 +
1.472 + rescheduled_unmodified = set(all_rescheduled or []).difference(to_reschedule or [])
1.473 +
1.474 + if rescheduled_unmodified:
1.475 + parts += self.get_rescheduled_parts(rescheduled_unmodified, update_parent and "COUNTER" or "REPLY")
1.476
1.477 - return obj
1.478 + # Suggest details for newly rescheduled periods.
1.479
1.480 - def make_cancel_message(self, recipients, obj):
1.481 + if to_reschedule:
1.482 + parts += self.get_rescheduled_parts(to_reschedule, "COUNTER")
1.483 +
1.484 + return self.make_message(parts, recipients, bcc_sender=True)
1.485 +
1.486 + def make_cancel_message(self, to_cancel=None):
1.487
1.488 """
1.489 - Prepare an event cancellation message to 'recipients' using the details
1.490 - in 'obj'.
1.491 + Prepare an event cancellation message involving the participants in the
1.492 + 'to_cancel' mapping.
1.493 """
1.494
1.495 + if not to_cancel:
1.496 + return None
1.497 +
1.498 + obj = self.obj.copy()
1.499 + obj["ATTENDEE"] = to_cancel.items()
1.500 +
1.501 parts = [self.object_to_part("CANCEL", obj)]
1.502 - return self.make_message(parts, recipients)
1.503 + return self.make_message(parts, to_cancel.keys())
1.504
1.505 def make_cancel_message_for_self(self, obj):
1.506
1.507 @@ -970,36 +1039,23 @@
1.508 parts = [self.object_to_part("CANCEL", obj)]
1.509 return self.make_message_for_self(parts)
1.510
1.511 - def make_response_message(self, recipients, changed=False):
1.512 -
1.513 - """
1.514 - Prepare a response to 'recipients' for the current object with the
1.515 - indicated 'changed' state.
1.516 - """
1.517 -
1.518 - # NOTE: Might need updating to include rescheduled objects.
1.519 -
1.520 - parts = [self.object_to_part(changed and "COUNTER" or "REPLY", self.obj)]
1.521 - return self.make_message(parts, recipients, bcc_sender=True)
1.522 -
1.523 # Action methods.
1.524
1.525 - def process_declined_counter(self, attendee):
1.526 + def send_declined_counter_to_attendee(self, attendee):
1.527
1.528 - "Process a declined counter-proposal."
1.529 + "Send a declined counter-proposal to 'attendee'."
1.530
1.531 # Obtain the counter-proposal for the attendee.
1.532
1.533 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee)
1.534 if not obj:
1.535 - return False
1.536 + return
1.537
1.538 - method = "DECLINECOUNTER"
1.539 self.update_senders(obj)
1.540 obj.update_dtstamp()
1.541 obj.update_sequence()
1.542
1.543 - parts = [self.object_to_part(method, obj)]
1.544 + parts = [self.object_to_part("DECLINECOUNTER", obj)]
1.545
1.546 # Create and send the response.
1.547
1.548 @@ -1007,86 +1063,67 @@
1.549 message = self.make_message(parts, recipients, bcc_sender=True)
1.550 self.send_message(message, recipients, bcc_sender=True)
1.551
1.552 - return True
1.553 -
1.554 - def process_received_request(self, changed=False):
1.555 + def send_response_to_organiser(self, all_rescheduled=None, to_reschedule=None,
1.556 + changed=False):
1.557
1.558 """
1.559 - Process the current request for the current user. Return whether any
1.560 - action was taken. If 'changed' is set to a true value, or if 'attendees'
1.561 - is specified and differs from the stored attendees, a counter-proposal
1.562 - will be sent instead of a reply.
1.563 + Send a response to the organiser describing attendance and proposed
1.564 + amendments to the event.
1.565 +
1.566 + If 'all_rescheduled' is specified, it provides details of separate
1.567 + recurrence instances for which a response needs to be generated.
1.568 +
1.569 + If 'to_reschedule' provides rescheduled periods, these will be sent as
1.570 + counter-proposals.
1.571 +
1.572 + If 'changed' is set to a true value, a counter-proposal will be sent for
1.573 + the entire event instead of a reply.
1.574 """
1.575
1.576 - if not self.update_event(changed):
1.577 - return False
1.578 -
1.579 - # Create and send the response.
1.580 -
1.581 recipients = self.get_recipients()
1.582 - message = self.make_response_message(recipients, changed)
1.583 + message = self.make_response_message(recipients, all_rescheduled,
1.584 + to_reschedule, changed)
1.585 self.send_message(message, recipients, bcc_sender=True)
1.586
1.587 - return True
1.588 -
1.589 - def process_created_request(self, method, to_cancel=None,
1.590 - to_unschedule=None, to_reschedule=None):
1.591 + def send_update_to_recipients(self, to_unschedule=None, to_reschedule=None):
1.592
1.593 """
1.594 - Process the current request, sending a created request of the given
1.595 - 'method' to attendees. Return whether any action was taken.
1.596 -
1.597 - If 'to_cancel' is specified, a list of participants to be sent cancel
1.598 - messages is provided.
1.599 + Send cancellations for each of the recurrences 'to_unschedule' along
1.600 + with modifications for each of the recurrences 'to_reschedule'.
1.601 + """
1.602
1.603 - If 'to_unschedule' is specified, a list of periods to be unscheduled is
1.604 - provided.
1.605 + recipients = self.get_recipients()
1.606 + message = self.make_update_message(recipients, to_unschedule, to_reschedule)
1.607 + self.send_message(message, recipients)
1.608
1.609 - If 'to_reschedule' is specified, a list of periods to be rescheduled is
1.610 - provided.
1.611 + def send_publish_to_self(self, all_unscheduled=None, all_rescheduled=None):
1.612
1.613 - Note that this method, although similar to get_message_parts, processes
1.614 - the core object and the explicitly-specified objects, not the separate
1.615 - recurrence instances that are already stored.
1.616 + """
1.617 + Send published event details incorporating 'all_unscheduled' and
1.618 + 'all_rescheduled' periods.
1.619 """
1.620
1.621 - self.update_event()
1.622 -
1.623 - if method == "REQUEST":
1.624 -
1.625 - # Send the updated event, along with a cancellation for each of the
1.626 - # unscheduled occurrences.
1.627 + # Since the organiser can update the SEQUENCE but this can leave any
1.628 + # mail/calendar client lagging, issue a PUBLISH message to the
1.629 + # user's address.
1.630
1.631 - recipients = self.get_recipients()
1.632 - message = self.make_update_message(recipients, to_unschedule, to_reschedule)
1.633 - self.send_message(message, recipients)
1.634 + recipients = self.get_recipients()
1.635 + message = self.make_self_update_message(all_unscheduled, all_rescheduled)
1.636 + self.send_message_to_self(message)
1.637
1.638 - # Since the organiser can update the SEQUENCE but this can leave any
1.639 - # mail/calendar client lagging, issue a PUBLISH message to the
1.640 - # user's address.
1.641 -
1.642 - message = self.make_self_update_message(to_unschedule, to_reschedule)
1.643 - self.send_message_to_self(message)
1.644 + def send_cancel_to_recipients(self, to_cancel=None):
1.645
1.646 - # When cancelling, replace the attendees with those for whom the event
1.647 - # is now cancelled.
1.648 + "Send a cancellation to all uninvited attendees in 'to_cancel'."
1.649
1.650 - if method == "CANCEL" or to_cancel:
1.651 -
1.652 - # Send a cancellation to all uninvited attendees.
1.653 + message = self.make_cancel_message(to_cancel)
1.654 + self.send_message(message, to_cancel.keys())
1.655
1.656 - obj = self.make_cancel_object(to_cancel)
1.657 - recipients = self.get_recipients(obj)
1.658 - message = self.make_cancel_message(recipients, obj)
1.659 - self.send_message(message, recipients)
1.660 + def send_cancel_to_self(self):
1.661
1.662 - # Issue a CANCEL message to the user's address.
1.663 + "Issue a CANCEL message to the user's address."
1.664
1.665 - if method == "CANCEL":
1.666 - message = self.make_cancel_message_for_self(obj)
1.667 - self.send_message_to_self(message)
1.668 -
1.669 - return True
1.670 + message = self.make_cancel_message_for_self(self.obj)
1.671 + self.send_message_to_self(message)
1.672
1.673 # Object-related tests.
1.674
2.1 --- a/imipweb/data.py Thu Oct 12 23:14:06 2017 +0200
2.2 +++ b/imipweb/data.py Fri Oct 13 15:07:43 2017 +0200
2.3 @@ -19,18 +19,23 @@
2.4 this program. If not, see <http://www.gnu.org/licenses/>.
2.5 """
2.6
2.7 +from collections import OrderedDict
2.8 +from copy import copy
2.9 from datetime import datetime, timedelta
2.10 +from imiptools.client import ClientForObject
2.11 +from imiptools.data import get_main_period
2.12 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \
2.13 format_datetime, get_datetime, \
2.14 get_datetime_attributes, get_end_of_day, \
2.15 to_date, to_utc_datetime, to_timezone
2.16 from imiptools.period import RecurringPeriod
2.17 +from itertools import chain
2.18
2.19 # General editing abstractions.
2.20
2.21 class State:
2.22
2.23 - "Manage computed state."
2.24 + "Manage editing state."
2.25
2.26 def __init__(self, callables):
2.27
2.28 @@ -41,11 +46,26 @@
2.29 """
2.30
2.31 self.state = {}
2.32 + self.original = {}
2.33 self.callables = callables
2.34
2.35 def get_callable(self, key):
2.36 return self.callables.get(key, lambda: None)
2.37
2.38 + def ensure_original(self, key):
2.39 +
2.40 + "Ensure the original state for the given 'key'."
2.41 +
2.42 + if not self.original.has_key(key):
2.43 + self.original[key] = self.get_callable(key)()
2.44 +
2.45 + def get_original(self, key):
2.46 +
2.47 + "Return the original state for the given 'key'."
2.48 +
2.49 + self.ensure_original(key)
2.50 + return copy(self.original[key])
2.51 +
2.52 def get(self, key, reset=False):
2.53
2.54 """
2.55 @@ -57,21 +77,562 @@
2.56 """
2.57
2.58 if reset or not self.state.has_key(key):
2.59 - self.state[key] = self.get_callable(key)()
2.60 + self.state[key] = self.get_original(key)
2.61
2.62 return self.state[key]
2.63
2.64 def set(self, key, value):
2.65 +
2.66 + "Set the state of 'key' to 'value'."
2.67 +
2.68 + self.ensure_original(key)
2.69 self.state[key] = value
2.70
2.71 + def has_changed(self, key):
2.72 +
2.73 + "Return whether 'key' has changed during editing."
2.74 +
2.75 + return self.get_original(key) != self.get(key)
2.76 +
2.77 + # Dictionary emulation methods.
2.78 +
2.79 def __getitem__(self, key):
2.80 return self.get(key)
2.81
2.82 def __setitem__(self, key, value):
2.83 self.set(key, value)
2.84
2.85 - def has_changed(self, key):
2.86 - return self.get_callable(key)() != self.get(key)
2.87 +
2.88 +
2.89 +# Object editing abstractions.
2.90 +
2.91 +class EditingClient(ClientForObject):
2.92 +
2.93 + "A simple calendar client."
2.94 +
2.95 + def __init__(self, user, messenger, store, preferences_dir):
2.96 + ClientForObject.__init__(self, None, user, messenger, store,
2.97 + preferences_dir=preferences_dir)
2.98 + self.reset()
2.99 +
2.100 + # Editing state.
2.101 +
2.102 + def reset(self):
2.103 +
2.104 + "Reset the editing state."
2.105 +
2.106 + self.state = State({
2.107 + "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []),
2.108 + "organiser" : lambda: self.obj.get_value("ORGANIZER"),
2.109 + "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()),
2.110 + "suggested_attendees" : self.get_suggested_attendees,
2.111 + "suggested_periods" : self.get_suggested_periods,
2.112 + "summary" : lambda: self.obj.get_value("SUMMARY"),
2.113 + })
2.114 +
2.115 + # Access to stored and current information.
2.116 +
2.117 + def get_stored_periods(self):
2.118 +
2.119 + """
2.120 + Return the stored, unrevised, integral periods for the event, excluding
2.121 + revisions from separate recurrence instances.
2.122 + """
2.123 +
2.124 + return event_periods_from_periods(self.get_periods())
2.125 +
2.126 + def get_unedited_periods(self):
2.127 +
2.128 + """
2.129 + Return the original, unedited periods including revisions from separate
2.130 + recurrence instances.
2.131 + """
2.132 +
2.133 + return event_periods_from_updated_periods(self.get_updated_periods())
2.134 +
2.135 + def get_counters(self):
2.136 +
2.137 + "Return a counter-proposal mapping from attendees to objects."
2.138 +
2.139 + # Get counter-proposals for the specific object.
2.140 +
2.141 + attendees = self.store.get_counters(self.user, self.uid, self.recurrenceid)
2.142 + d = {}
2.143 +
2.144 + for attendee in attendees:
2.145 + if not d.has_key(attendee):
2.146 + d[attendee] = []
2.147 + d[attendee].append(self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee))
2.148 +
2.149 + return d
2.150 +
2.151 + def get_suggested_attendees(self):
2.152 +
2.153 + "For all counter-proposals, return suggested attendee items."
2.154 +
2.155 + existing = self.state.get("attendees")
2.156 + l = []
2.157 + for attendee, objects in self.get_counters().items():
2.158 + for obj in objects:
2.159 + for suggested, attr in obj.get_items("ATTENDEE"):
2.160 + if suggested not in existing:
2.161 + l.append((attendee, (suggested, attr)))
2.162 + return l
2.163 +
2.164 + def get_suggested_periods(self):
2.165 +
2.166 + "For all counter-proposals, return suggested event periods."
2.167 +
2.168 + existing = self.state.get("periods")
2.169 +
2.170 + # Get active periods for filtering of suggested periods.
2.171 +
2.172 + active = []
2.173 + for p in existing:
2.174 + if not p.cancelled:
2.175 + active.append(p)
2.176 +
2.177 + suggested = []
2.178 +
2.179 + for attendee, objects in self.get_counters().items():
2.180 +
2.181 + # For each object, obtain suggested periods.
2.182 +
2.183 + for obj in objects:
2.184 +
2.185 + # Obtain the current periods for the object providing the
2.186 + # suggested periods.
2.187 +
2.188 + updated = self.get_updated_periods(obj)
2.189 + suggestions = event_periods_from_updated_periods(updated)
2.190 +
2.191 + # Compare current periods with suggested periods.
2.192 +
2.193 + new = set(suggestions).difference(active)
2.194 +
2.195 + # Treat each specific recurrence as affecting only the original
2.196 + # period.
2.197 +
2.198 + if obj.get_recurrenceid():
2.199 + removed = []
2.200 + else:
2.201 + removed = set(active).difference(suggestions)
2.202 +
2.203 + # Associate new and removed periods with the attendee.
2.204 +
2.205 + for period in new:
2.206 + suggested.append((attendee, period, "add"))
2.207 +
2.208 + for period in removed:
2.209 + suggested.append((attendee, period, "remove"))
2.210 +
2.211 + return suggested
2.212 +
2.213 + # Validation methods.
2.214 +
2.215 + def get_checked_periods(self):
2.216 +
2.217 + """
2.218 + Check the edited periods and return objects representing them, setting
2.219 + the "periods" state. If errors occur, raise an exception and set the
2.220 + "errors" state.
2.221 + """
2.222 +
2.223 + self.state["period_errors"] = errors = {}
2.224 + try:
2.225 + periods = event_periods_from_periods(self.state.get("periods"))
2.226 + self.state["periods"] = form_periods_from_periods(periods)
2.227 + return periods
2.228 +
2.229 + except PeriodError, exc:
2.230 +
2.231 + # Obtain error and period index details from the exception,
2.232 + # collecting errors for each index position.
2.233 +
2.234 + for err, index in exc.args:
2.235 + l = errors.get(index)
2.236 + if not l:
2.237 + l = errors[index] = []
2.238 + l.append(err)
2.239 + raise
2.240 +
2.241 + # Update result computation.
2.242 +
2.243 + def classify_attendee_changes(self):
2.244 +
2.245 + "Classify the attendees in the event."
2.246 +
2.247 + original = self.state.get_original("attendees")
2.248 + current = self.state.get("attendees")
2.249 + return classify_attendee_changes(original, current)
2.250 +
2.251 + def classify_attendee_operations(self):
2.252 +
2.253 + "Classify attendee update operations."
2.254 +
2.255 + new, modified, unmodified, removed = self.classify_attendee_changes()
2.256 +
2.257 + if self.is_organiser():
2.258 + to_invite = new
2.259 + to_cancel = removed
2.260 + to_modify = modified
2.261 + else:
2.262 + to_invite = new
2.263 + to_cancel = {}
2.264 + to_modify = modified
2.265 +
2.266 + return to_invite, to_cancel, to_modify
2.267 +
2.268 + def classify_period_changes(self):
2.269 +
2.270 + "Classify changes in the updated periods for the edited event."
2.271 +
2.272 + updated = self.combine_periods_for_comparison()
2.273 + return classify_period_changes(updated)
2.274 +
2.275 + def classify_periods(self):
2.276 +
2.277 + "Classify the updated periods for the edited event."
2.278 +
2.279 + updated = self.combine_periods()
2.280 + return classify_periods(updated)
2.281 +
2.282 + def combine_periods(self):
2.283 +
2.284 + "Combine stored and checked edited periods to make updated periods."
2.285 +
2.286 + stored = self.get_stored_periods()
2.287 + current = self.get_checked_periods()
2.288 + return combine_periods(stored, current)
2.289 +
2.290 + def combine_periods_for_comparison(self):
2.291 +
2.292 + "Combine unedited and checked edited periods to make updated periods."
2.293 +
2.294 + original = self.get_unedited_periods()
2.295 + current = self.get_checked_periods()
2.296 + return combine_periods(original, current)
2.297 +
2.298 + def classify_period_operations(self):
2.299 +
2.300 + "Classify period update operations."
2.301 +
2.302 + new, replaced, retained, cancelled = self.classify_periods()
2.303 +
2.304 + modified, unmodified, removed = self.classify_period_changes()
2.305 +
2.306 + is_organiser = self.is_organiser()
2.307 + is_shared = self.obj.is_shared()
2.308 +
2.309 + return classify_period_operations(new, replaced, retained, cancelled,
2.310 + modified, removed,
2.311 + is_organiser, is_shared)
2.312 +
2.313 + def properties_changed(self):
2.314 +
2.315 + "Test for changes in event details."
2.316 +
2.317 + is_changed = []
2.318 +
2.319 + if self.is_organiser():
2.320 + for name in ["summary"]:
2.321 + if self.state.has_changed(name):
2.322 + is_changed.append(name)
2.323 +
2.324 + return is_changed
2.325 +
2.326 + def finish(self):
2.327 +
2.328 + "Finish editing, writing edited details to the object."
2.329 +
2.330 + if self.state.get("finished"):
2.331 + return
2.332 +
2.333 + is_changed = self.properties_changed()
2.334 +
2.335 + # Determine period modification operations.
2.336 +
2.337 + self.state["period_operations"] = \
2.338 + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
2.339 + all_unscheduled, all_rescheduled = \
2.340 + self.classify_period_operations()
2.341 +
2.342 + # Determine attendee modifications.
2.343 +
2.344 + self.state["attendee_operations"] = \
2.345 + to_invite, to_cancel, to_modify = \
2.346 + self.classify_attendee_operations()
2.347 +
2.348 + self.state["attendees_to_cancel"] = to_cancel
2.349 +
2.350 + # Update event details.
2.351 +
2.352 + if self.can_edit_properties():
2.353 + self.obj.set_value("SUMMARY", self.state.get("summary"))
2.354 +
2.355 + self.update_attendees(to_invite, to_cancel, to_modify)
2.356 + self.update_event_from_periods(to_set, to_exclude)
2.357 +
2.358 + # Classify the nature of any update.
2.359 +
2.360 + if is_changed or to_set or to_invite:
2.361 + self.state["changed"] = "complete"
2.362 + elif to_reschedule or to_unschedule or to_add:
2.363 + self.state["changed"] = "incremental"
2.364 +
2.365 + self.state["finished"] = self.update_event_version(is_changed)
2.366 +
2.367 + # Update preparation.
2.368 +
2.369 + def have_update(self):
2.370 +
2.371 + "Return whether an update can be prepared and sent."
2.372 +
2.373 + return not self.is_organiser() or \
2.374 + self.obj.is_shared() and self.state.get("changed") and \
2.375 + self.have_other_attendees()
2.376 +
2.377 + def have_other_attendees(self):
2.378 +
2.379 + "Return whether any attendees other than the user are present."
2.380 +
2.381 + attendees = self.state.get("attendees")
2.382 + return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1)
2.383 +
2.384 + def prepare_cancel_message(self):
2.385 +
2.386 + "Prepare the cancel message for uninvited attendees."
2.387 +
2.388 + to_cancel = self.state.get("attendees_to_cancel")
2.389 + return self.make_cancel_message(to_cancel)
2.390 +
2.391 + def prepare_publish_message(self):
2.392 +
2.393 + "Prepare the publishing message for the updated event."
2.394 +
2.395 + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
2.396 + all_unscheduled, all_rescheduled = self.state.get("period_operations")
2.397 +
2.398 + return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add)
2.399 +
2.400 + def prepare_update_message(self):
2.401 +
2.402 + "Prepare the update message for the updated event."
2.403 +
2.404 + if not self.have_update():
2.405 + return None
2.406 +
2.407 + # Obtain operation details.
2.408 +
2.409 + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
2.410 + all_unscheduled, all_rescheduled = self.state.get("period_operations")
2.411 +
2.412 + # Prepare the message.
2.413 +
2.414 + recipients = self.get_recipients()
2.415 + update_parent = self.state["changed"] == "complete"
2.416 +
2.417 + if self.is_organiser():
2.418 + return self.make_update_message(recipients, update_parent,
2.419 + to_unschedule, to_reschedule,
2.420 + all_unscheduled, all_rescheduled,
2.421 + to_add)
2.422 + else:
2.423 + return self.make_response_message(recipients, update_parent,
2.424 + all_rescheduled, to_reschedule)
2.425 +
2.426 + # Modification methods.
2.427 +
2.428 + def add_attendee(self, uri=None):
2.429 +
2.430 + "Add a blank attendee."
2.431 +
2.432 + attendees = self.state.get("attendees")
2.433 + attendees[uri or ""] = {}
2.434 +
2.435 + def add_suggested_attendee(self, index):
2.436 +
2.437 + "Add the suggested attendee at 'index' to the event."
2.438 +
2.439 + attendees = self.state.get("attendees")
2.440 + suggested_attendees = self.state.get("suggested_attendees")
2.441 + try:
2.442 + attendee, (suggested, attr) = suggested_attendees[index]
2.443 + self.add_attendee(suggested)
2.444 + except IndexError:
2.445 + pass
2.446 +
2.447 + def add_period(self):
2.448 +
2.449 + "Add a copy of the main period as a new recurrence."
2.450 +
2.451 + current = self.state.get("periods")
2.452 + new = get_main_period(current).copy()
2.453 + new.origin = "RDATE"
2.454 + new.replacement = False
2.455 + new.recurrenceid = False
2.456 + new.cancelled = False
2.457 + current.append(new)
2.458 +
2.459 + def apply_suggested_period(self, index):
2.460 +
2.461 + "Apply the suggested period at 'index' to the event."
2.462 +
2.463 + current = self.state.get("periods")
2.464 + suggested = self.state.get("suggested_periods")
2.465 +
2.466 + try:
2.467 + attendee, period, operation = suggested[index]
2.468 + period = form_period_from_period(period)
2.469 +
2.470 + # Cancel any removed periods.
2.471 +
2.472 + if operation == "remove":
2.473 + for p in current:
2.474 + if p == period:
2.475 + p.cancelled = True
2.476 + break
2.477 +
2.478 + # Add or replace any other suggestions.
2.479 +
2.480 + elif operation == "add":
2.481 +
2.482 + # Make the status of the period compatible.
2.483 +
2.484 + period.cancelled = False
2.485 + period.origin = "DTSTART-RECUR"
2.486 +
2.487 + # Either replace or add the period.
2.488 +
2.489 + recurrenceid = period.get_recurrenceid()
2.490 +
2.491 + for i, p in enumerate(current):
2.492 + if p.get_recurrenceid() == recurrenceid:
2.493 + current[i] = period
2.494 + break
2.495 +
2.496 + # Add as a new period.
2.497 +
2.498 + else:
2.499 + period.recurrenceid = None
2.500 + current.append(period)
2.501 +
2.502 + except IndexError:
2.503 + pass
2.504 +
2.505 + def cancel_periods(self, indexes, cancelled=True):
2.506 +
2.507 + """
2.508 + Set cancellation state for periods with the given 'indexes', indicating
2.509 + 'cancelled' as a true or false value. New periods will be removed if
2.510 + cancelled.
2.511 + """
2.512 +
2.513 + periods = self.state.get("periods")
2.514 + to_remove = []
2.515 + removed = 0
2.516 +
2.517 + for index in indexes:
2.518 + p = periods[index]
2.519 +
2.520 + # Make replacements from existing periods and cancel them.
2.521 +
2.522 + if p.recurrenceid:
2.523 + p.replacement = True
2.524 + p.cancelled = cancelled
2.525 +
2.526 + # Remove new periods completely.
2.527 +
2.528 + elif cancelled:
2.529 + to_remove.append(index - removed)
2.530 + removed += 1
2.531 +
2.532 + for index in to_remove:
2.533 + del periods[index]
2.534 +
2.535 + def edit_attendance(self, partstat):
2.536 +
2.537 + "Set the 'partstat' of the current user, if attending."
2.538 +
2.539 + attendees = self.state.get("attendees")
2.540 + attr = attendees.get(self.user)
2.541 +
2.542 + if attr:
2.543 + new_attr = {}
2.544 + new_attr.update(attr)
2.545 + new_attr["PARTSTAT"] = partstat
2.546 + attendees[self.user] = new_attr
2.547 +
2.548 + def can_edit_attendee(self, index):
2.549 +
2.550 + """
2.551 + Return whether the attendee at 'index' can be edited, requiring either
2.552 + the organiser and an unshared event, or a new attendee.
2.553 + """
2.554 +
2.555 + attendees = self.state.get("attendees")
2.556 + attendee = attendees.keys()[index]
2.557 +
2.558 + try:
2.559 + attr = attendees[attendee]
2.560 + if self.is_organiser() and not self.obj.is_shared() or not attr:
2.561 + return (attendee, attr)
2.562 + except IndexError:
2.563 + pass
2.564 +
2.565 + return None
2.566 +
2.567 + def can_remove_attendee(self, index):
2.568 +
2.569 + """
2.570 + Return whether the attendee at 'index' can be removed, requiring either
2.571 + the organiser or a new attendee.
2.572 + """
2.573 +
2.574 + attendees = self.state.get("attendees")
2.575 + attendee = attendees.keys()[index]
2.576 +
2.577 + try:
2.578 + attr = attendees[attendee]
2.579 + if self.is_organiser() or not attr:
2.580 + return (attendee, attr)
2.581 + except IndexError:
2.582 + pass
2.583 +
2.584 + return None
2.585 +
2.586 + def remove_attendees(self, indexes):
2.587 +
2.588 + "Remove attendee at 'index'."
2.589 +
2.590 + attendees = self.state.get("attendees")
2.591 + to_remove = []
2.592 +
2.593 + for index in indexes:
2.594 + attendee_item = self.can_remove_attendee(index)
2.595 + if attendee_item:
2.596 + attendee, attr = attendee_item
2.597 + to_remove.append(attendee)
2.598 +
2.599 + for key in to_remove:
2.600 + del attendees[key]
2.601 +
2.602 + def can_edit_period(self, index):
2.603 +
2.604 + """
2.605 + Return the period at 'index' for editing or None if it cannot be edited.
2.606 + """
2.607 +
2.608 + try:
2.609 + return self.state.get("periods")[index]
2.610 + except IndexError:
2.611 + return None
2.612 +
2.613 + def can_edit_properties(self):
2.614 +
2.615 + "Return whether general event properties can be edited."
2.616 +
2.617 + return self.is_organiser()
2.618
2.619
2.620
2.621 @@ -95,11 +656,23 @@
2.622 return dt, get_datetime_attributes(dt)
2.623
2.624 def get_recurrenceid(self):
2.625 +
2.626 + """
2.627 + Return a recurrence identity to be used to associate stored periods with
2.628 + edited periods.
2.629 + """
2.630 +
2.631 if not self.recurrenceid:
2.632 return RecurringPeriod.get_recurrenceid(self)
2.633 return self.recurrenceid
2.634
2.635 def get_recurrenceid_item(self):
2.636 +
2.637 + """
2.638 + Return a recurrence identifier value and datetime properties for use in
2.639 + specifying the RECURRENCE-ID property.
2.640 + """
2.641 +
2.642 if not self.recurrenceid:
2.643 return RecurringPeriod.get_recurrenceid_item(self)
2.644 return self._get_recurrenceid_item()
2.645 @@ -232,7 +805,8 @@
2.646 return "FormPeriod%r" % (self.as_tuple(),)
2.647
2.648 def copy(self):
2.649 - return FormPeriod(*self.as_tuple())
2.650 + args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:]
2.651 + return FormPeriod(*args)
2.652
2.653 def as_event_period(self, index=None):
2.654
2.655 @@ -337,6 +911,9 @@
2.656 def as_tuple(self):
2.657 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr
2.658
2.659 + def copy(self):
2.660 + return FormDate(*self.as_tuple())
2.661 +
2.662 def reset(self):
2.663 self.dt = None
2.664
2.665 @@ -470,17 +1047,30 @@
2.666 def periods_from_updated_periods(updated_periods, fn):
2.667
2.668 """
2.669 - Return periods from the given 'updated_periods' created using 'fn, setting
2.670 + Return periods from the given 'updated_periods' created using 'fn', setting
2.671 replacement, cancelled and recurrence identifier details.
2.672 +
2.673 + This function should be used to produce editing-related periods from the
2.674 + general updated periods provided by the client abstractions.
2.675 """
2.676
2.677 periods = []
2.678
2.679 for sp, p in updated_periods:
2.680 +
2.681 + # Stored periods with corresponding current periods.
2.682 +
2.683 if p:
2.684 period = fn(p)
2.685 - if sp != p:
2.686 +
2.687 + # Replacements are identified by comparing object identities, since
2.688 + # a replacement will not be provided by the same object.
2.689 +
2.690 + if sp is not p:
2.691 period.replacement = True
2.692 +
2.693 + # Stored periods without corresponding current periods.
2.694 +
2.695 else:
2.696 period = fn(sp)
2.697 period.replacement = True
2.698 @@ -519,7 +1109,10 @@
2.699
2.700 def combine_periods(old, new):
2.701
2.702 - "Combine 'old' and 'new' periods for comparison."
2.703 + """
2.704 + Combine 'old' and 'new' periods for comparison, making a list of (old, new)
2.705 + updated period tuples.
2.706 + """
2.707
2.708 old_by_recurrenceid, _new_periods = periods_by_recurrence(old)
2.709 new_by_recurrenceid, new_periods = periods_by_recurrence(new)
2.710 @@ -528,59 +1121,151 @@
2.711
2.712 for recurrenceid, op in old_by_recurrenceid.items():
2.713 np = new_by_recurrenceid.get(recurrenceid)
2.714 - if np and not np.cancelled:
2.715 +
2.716 + # Old period has corresponding new period that is not cancelled.
2.717 +
2.718 + if np and not (np.cancelled and not op.cancelled):
2.719 combined.append((op, np))
2.720 +
2.721 + # No corresponding new, uncancelled period.
2.722 +
2.723 else:
2.724 combined.append((op, None))
2.725
2.726 + # New periods without corresponding old periods are genuinely new.
2.727 +
2.728 for np in new_periods:
2.729 combined.append((None, np))
2.730
2.731 + # Note that new periods should not have recurrence identifiers, and if
2.732 + # imported from other events, they should have such identifiers removed.
2.733 +
2.734 return combined
2.735
2.736 def classify_periods(updated_periods):
2.737
2.738 """
2.739 Using the 'updated_periods', being a list of (stored, current) periods,
2.740 - return a tuple containing collections of new, changed, unchanged and removed
2.741 - periods.
2.742 + return a tuple containing collections of new, replaced, retained and
2.743 + cancelled periods.
2.744
2.745 - Note that changed and unchanged indicate the presence or absence of
2.746 - differences between the original event periods and the current periods, not
2.747 + Note that replaced and retained indicate the presence or absence of
2.748 + differences between the original event periods and the current periods that
2.749 + would need to be represented using separate recurrence instances, not
2.750 whether any editing operations have changed the periods.
2.751 """
2.752
2.753 new = []
2.754 - changed = []
2.755 - unchanged = []
2.756 - removed = []
2.757 + replaced = []
2.758 + retained = []
2.759 + cancelled = []
2.760
2.761 for sp, p in updated_periods:
2.762 +
2.763 + # Stored periods...
2.764 +
2.765 if sp:
2.766 +
2.767 + # With cancelled or absent current periods.
2.768 +
2.769 if not p or p.cancelled:
2.770 - removed.append(sp)
2.771 + cancelled.append(sp)
2.772 +
2.773 + # With differing or replacement current periods.
2.774 +
2.775 elif p != sp or p.replacement:
2.776 - changed.append(p)
2.777 + replaced.append(p)
2.778 if not p.replacement:
2.779 p.new_replacement = True
2.780 +
2.781 + # With retained, not differing current periods.
2.782 +
2.783 else:
2.784 - unchanged.append(p)
2.785 + retained.append(p)
2.786 if p.new_replacement:
2.787 p.new_replacement = False
2.788 +
2.789 + # New periods without corresponding stored periods.
2.790 +
2.791 elif p:
2.792 new.append(p)
2.793
2.794 - return new, changed, unchanged, removed
2.795 + return new, replaced, retained, cancelled
2.796
2.797 -def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared):
2.798 +def classify_period_changes(updated_periods):
2.799
2.800 """
2.801 - Classify the operations for the update of an event. Return the unscheduled
2.802 - periods, rescheduled periods, excluded periods, and the periods to be set in
2.803 - the object to replace the existing stored periods.
2.804 + Using the 'updated_periods', being a list of (original, current) periods,
2.805 + return a tuple containing collections of modified, unmodified and removed
2.806 + periods.
2.807 """
2.808
2.809 - active_periods = new + unchanged + changed
2.810 + modified = []
2.811 + unmodified = []
2.812 + removed = []
2.813 +
2.814 + for op, p in updated_periods:
2.815 +
2.816 + # Test for periods cancelled, reinstated or changed, or left unmodified
2.817 + # during editing.
2.818 +
2.819 + if op:
2.820 + if not op.cancelled and (not p or p.cancelled):
2.821 + removed.append(op)
2.822 + elif op.cancelled and not p.cancelled or p != op:
2.823 + modified.append(p)
2.824 + else:
2.825 + unmodified.append(p)
2.826 +
2.827 + # New periods are always modifications.
2.828 +
2.829 + elif p:
2.830 + modified.append(p)
2.831 +
2.832 + return modified, unmodified, removed
2.833 +
2.834 +def classify_period_operations(new, replaced, retained, cancelled,
2.835 + modified, removed,
2.836 + is_organiser, is_shared):
2.837 +
2.838 + """
2.839 + Classify the operations for the update of an event. For updates modifying
2.840 + shared events, return periods for descheduling and rescheduling (where these
2.841 + operations can modify the event), and periods for exclusion and application
2.842 + (where these operations redefine the event).
2.843 +
2.844 + To define the new state of the event, details of the complete set of
2.845 + unscheduled and rescheduled periods are also provided.
2.846 + """
2.847 +
2.848 + active_periods = new + replaced + retained
2.849 +
2.850 + # Modified replaced and retained recurrences are used for incremental
2.851 + # updates.
2.852 +
2.853 + replaced_modified = select_recurrences(replaced, modified).values()
2.854 + retained_modified = select_recurrences(retained, modified).values()
2.855 +
2.856 + # Unmodified replaced and retained recurrences are used in the complete
2.857 + # event summary.
2.858 +
2.859 + replaced_unmodified = subtract_recurrences(replaced, modified).values()
2.860 + retained_unmodified = subtract_recurrences(retained, modified).values()
2.861 +
2.862 + # Obtain the removed periods in terms of existing periods. These are used in
2.863 + # incremental updates.
2.864 +
2.865 + cancelled_removed = select_recurrences(cancelled, removed).values()
2.866 +
2.867 + # Reinstated periods are previously-cancelled periods that are now modified
2.868 + # periods, and they appear in updates.
2.869 +
2.870 + reinstated = select_recurrences(modified, cancelled).values()
2.871 +
2.872 + # Get cancelled periods without reinstated periods. These appear in complete
2.873 + # event summaries.
2.874 +
2.875 + cancelled_unmodified = subtract_recurrences(cancelled, modified).values()
2.876
2.877 # As organiser...
2.878
2.879 @@ -592,42 +1277,91 @@
2.880
2.881 # For shared events...
2.882 # New periods should cause the event to be redefined.
2.883 + # Other changes should also cause event redefinition.
2.884 + # Event redefinition should only occur if no replacement periods exist.
2.885
2.886 - if not is_shared or new:
2.887 + if not is_shared or new and not replaced:
2.888 + to_set = active_periods
2.889 to_unschedule = []
2.890 to_reschedule = []
2.891 - to_set = active_periods
2.892 + to_add = []
2.893 + all_unscheduled = []
2.894 + all_rescheduled = []
2.895
2.896 # Changed periods should be rescheduled separately.
2.897 # Removed periods should be cancelled separately.
2.898
2.899 else:
2.900 - to_unschedule = removed
2.901 - to_reschedule = changed
2.902 to_set = []
2.903 + to_unschedule = cancelled_removed
2.904 + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated))
2.905 + to_add = new
2.906 + all_unscheduled = cancelled_unmodified
2.907 + all_rescheduled = list(chain(replaced_unmodified, to_reschedule))
2.908
2.909 # As attendee...
2.910
2.911 else:
2.912 to_unschedule = []
2.913 + to_add = []
2.914
2.915 # Changed periods without new or removed periods are proposed as
2.916 # separate changes.
2.917
2.918 if not new and not removed:
2.919 + to_set = []
2.920 to_exclude = []
2.921 - to_reschedule = changed
2.922 - to_set = []
2.923 + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated))
2.924 + all_unscheduled = list(cancelled_unmodified)
2.925 + all_rescheduled = list(chain(replaced_unmodified, to_reschedule))
2.926
2.927 # Otherwise, the event is defined in terms of new periods and
2.928 # exceptions for removed periods.
2.929
2.930 else:
2.931 - to_exclude = removed
2.932 + to_set = active_periods
2.933 + to_exclude = cancelled
2.934 to_reschedule = []
2.935 - to_set = active_periods
2.936 + all_unscheduled = []
2.937 + all_rescheduled = []
2.938 +
2.939 + return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled
2.940 +
2.941 +def get_period_mapping(periods):
2.942 +
2.943 + "Return a mapping of recurrence identifiers to the given 'periods."
2.944 +
2.945 + d, new = periods_by_recurrence(periods)
2.946 + return d
2.947 +
2.948 +def select_recurrences(source, selected):
2.949 +
2.950 + "Restrict 'source' to the recurrences referenced by 'selected'."
2.951 +
2.952 + mapping = get_period_mapping(source)
2.953
2.954 - return to_unschedule, to_reschedule, to_exclude, to_set
2.955 + recurrenceids = get_recurrenceids(selected)
2.956 + for recurrenceid in mapping.keys():
2.957 + if not recurrenceid in recurrenceids:
2.958 + del mapping[recurrenceid]
2.959 + return mapping
2.960 +
2.961 +def subtract_recurrences(source, selected):
2.962 +
2.963 + "Remove from 'source' the recurrences referenced by 'selected'."
2.964 +
2.965 + mapping = get_period_mapping(source)
2.966 +
2.967 + for recurrenceid in get_recurrenceids(selected):
2.968 + if mapping.has_key(recurrenceid):
2.969 + del mapping[recurrenceid]
2.970 + return mapping
2.971 +
2.972 +def get_recurrenceids(periods):
2.973 +
2.974 + "Return the recurrence identifiers employed by 'periods'."
2.975 +
2.976 + return map(lambda p: p.get_recurrenceid(), periods)
2.977
2.978
2.979
2.980 @@ -866,6 +1600,51 @@
2.981
2.982
2.983
2.984 +# Attendee processing.
2.985 +
2.986 +def classify_attendee_changes(original, current):
2.987 +
2.988 + """
2.989 + Return categories of attendees given the 'original' and 'current'
2.990 + collections of attendees.
2.991 + """
2.992 +
2.993 + new = {}
2.994 + modified = {}
2.995 + unmodified = {}
2.996 +
2.997 + # Check current attendees against the original ones.
2.998 +
2.999 + for attendee, attendee_attr in current.items():
2.1000 + original_attr = original.get(attendee)
2.1001 +
2.1002 + # New attendee if missing original details.
2.1003 +
2.1004 + if not original_attr:
2.1005 + new[attendee] = attendee_attr
2.1006 +
2.1007 + # Details unchanged for existing attendee.
2.1008 +
2.1009 + elif attendee_attr == original_attr:
2.1010 + unmodified[attendee] = attendee_attr
2.1011 +
2.1012 + # Details changed for existing attendee.
2.1013 +
2.1014 + else:
2.1015 + modified[attendee] = attendee_attr
2.1016 +
2.1017 + removed = {}
2.1018 +
2.1019 + # Check for removed attendees.
2.1020 +
2.1021 + for attendee, attendee_attr in original.items():
2.1022 + if not current.has_key(attendee):
2.1023 + removed[attendee] = attendee_attr
2.1024 +
2.1025 + return new, modified, unmodified, removed
2.1026 +
2.1027 +
2.1028 +
2.1029 # Utilities.
2.1030
2.1031 def filter_duplicates(l):