1.1 --- a/imipweb/data.py Thu Oct 12 23:14:06 2017 +0200
1.2 +++ b/imipweb/data.py Fri Oct 13 15:07:43 2017 +0200
1.3 @@ -19,18 +19,23 @@
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 copy import copy
1.9 from datetime import datetime, timedelta
1.10 +from imiptools.client import ClientForObject
1.11 +from imiptools.data import get_main_period
1.12 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \
1.13 format_datetime, get_datetime, \
1.14 get_datetime_attributes, get_end_of_day, \
1.15 to_date, to_utc_datetime, to_timezone
1.16 from imiptools.period import RecurringPeriod
1.17 +from itertools import chain
1.18
1.19 # General editing abstractions.
1.20
1.21 class State:
1.22
1.23 - "Manage computed state."
1.24 + "Manage editing state."
1.25
1.26 def __init__(self, callables):
1.27
1.28 @@ -41,11 +46,26 @@
1.29 """
1.30
1.31 self.state = {}
1.32 + self.original = {}
1.33 self.callables = callables
1.34
1.35 def get_callable(self, key):
1.36 return self.callables.get(key, lambda: None)
1.37
1.38 + def ensure_original(self, key):
1.39 +
1.40 + "Ensure the original state for the given 'key'."
1.41 +
1.42 + if not self.original.has_key(key):
1.43 + self.original[key] = self.get_callable(key)()
1.44 +
1.45 + def get_original(self, key):
1.46 +
1.47 + "Return the original state for the given 'key'."
1.48 +
1.49 + self.ensure_original(key)
1.50 + return copy(self.original[key])
1.51 +
1.52 def get(self, key, reset=False):
1.53
1.54 """
1.55 @@ -57,21 +77,562 @@
1.56 """
1.57
1.58 if reset or not self.state.has_key(key):
1.59 - self.state[key] = self.get_callable(key)()
1.60 + self.state[key] = self.get_original(key)
1.61
1.62 return self.state[key]
1.63
1.64 def set(self, key, value):
1.65 +
1.66 + "Set the state of 'key' to 'value'."
1.67 +
1.68 + self.ensure_original(key)
1.69 self.state[key] = value
1.70
1.71 + def has_changed(self, key):
1.72 +
1.73 + "Return whether 'key' has changed during editing."
1.74 +
1.75 + return self.get_original(key) != self.get(key)
1.76 +
1.77 + # Dictionary emulation methods.
1.78 +
1.79 def __getitem__(self, key):
1.80 return self.get(key)
1.81
1.82 def __setitem__(self, key, value):
1.83 self.set(key, value)
1.84
1.85 - def has_changed(self, key):
1.86 - return self.get_callable(key)() != self.get(key)
1.87 +
1.88 +
1.89 +# Object editing abstractions.
1.90 +
1.91 +class EditingClient(ClientForObject):
1.92 +
1.93 + "A simple calendar client."
1.94 +
1.95 + def __init__(self, user, messenger, store, preferences_dir):
1.96 + ClientForObject.__init__(self, None, user, messenger, store,
1.97 + preferences_dir=preferences_dir)
1.98 + self.reset()
1.99 +
1.100 + # Editing state.
1.101 +
1.102 + def reset(self):
1.103 +
1.104 + "Reset the editing state."
1.105 +
1.106 + self.state = State({
1.107 + "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []),
1.108 + "organiser" : lambda: self.obj.get_value("ORGANIZER"),
1.109 + "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()),
1.110 + "suggested_attendees" : self.get_suggested_attendees,
1.111 + "suggested_periods" : self.get_suggested_periods,
1.112 + "summary" : lambda: self.obj.get_value("SUMMARY"),
1.113 + })
1.114 +
1.115 + # Access to stored and current information.
1.116 +
1.117 + def get_stored_periods(self):
1.118 +
1.119 + """
1.120 + Return the stored, unrevised, integral periods for the event, excluding
1.121 + revisions from separate recurrence instances.
1.122 + """
1.123 +
1.124 + return event_periods_from_periods(self.get_periods())
1.125 +
1.126 + def get_unedited_periods(self):
1.127 +
1.128 + """
1.129 + Return the original, unedited periods including revisions from separate
1.130 + recurrence instances.
1.131 + """
1.132 +
1.133 + return event_periods_from_updated_periods(self.get_updated_periods())
1.134 +
1.135 + def get_counters(self):
1.136 +
1.137 + "Return a counter-proposal mapping from attendees to objects."
1.138 +
1.139 + # Get counter-proposals for the specific object.
1.140 +
1.141 + attendees = self.store.get_counters(self.user, self.uid, self.recurrenceid)
1.142 + d = {}
1.143 +
1.144 + for attendee in attendees:
1.145 + if not d.has_key(attendee):
1.146 + d[attendee] = []
1.147 + d[attendee].append(self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee))
1.148 +
1.149 + return d
1.150 +
1.151 + def get_suggested_attendees(self):
1.152 +
1.153 + "For all counter-proposals, return suggested attendee items."
1.154 +
1.155 + existing = self.state.get("attendees")
1.156 + l = []
1.157 + for attendee, objects in self.get_counters().items():
1.158 + for obj in objects:
1.159 + for suggested, attr in obj.get_items("ATTENDEE"):
1.160 + if suggested not in existing:
1.161 + l.append((attendee, (suggested, attr)))
1.162 + return l
1.163 +
1.164 + def get_suggested_periods(self):
1.165 +
1.166 + "For all counter-proposals, return suggested event periods."
1.167 +
1.168 + existing = self.state.get("periods")
1.169 +
1.170 + # Get active periods for filtering of suggested periods.
1.171 +
1.172 + active = []
1.173 + for p in existing:
1.174 + if not p.cancelled:
1.175 + active.append(p)
1.176 +
1.177 + suggested = []
1.178 +
1.179 + for attendee, objects in self.get_counters().items():
1.180 +
1.181 + # For each object, obtain suggested periods.
1.182 +
1.183 + for obj in objects:
1.184 +
1.185 + # Obtain the current periods for the object providing the
1.186 + # suggested periods.
1.187 +
1.188 + updated = self.get_updated_periods(obj)
1.189 + suggestions = event_periods_from_updated_periods(updated)
1.190 +
1.191 + # Compare current periods with suggested periods.
1.192 +
1.193 + new = set(suggestions).difference(active)
1.194 +
1.195 + # Treat each specific recurrence as affecting only the original
1.196 + # period.
1.197 +
1.198 + if obj.get_recurrenceid():
1.199 + removed = []
1.200 + else:
1.201 + removed = set(active).difference(suggestions)
1.202 +
1.203 + # Associate new and removed periods with the attendee.
1.204 +
1.205 + for period in new:
1.206 + suggested.append((attendee, period, "add"))
1.207 +
1.208 + for period in removed:
1.209 + suggested.append((attendee, period, "remove"))
1.210 +
1.211 + return suggested
1.212 +
1.213 + # Validation methods.
1.214 +
1.215 + def get_checked_periods(self):
1.216 +
1.217 + """
1.218 + Check the edited periods and return objects representing them, setting
1.219 + the "periods" state. If errors occur, raise an exception and set the
1.220 + "errors" state.
1.221 + """
1.222 +
1.223 + self.state["period_errors"] = errors = {}
1.224 + try:
1.225 + periods = event_periods_from_periods(self.state.get("periods"))
1.226 + self.state["periods"] = form_periods_from_periods(periods)
1.227 + return periods
1.228 +
1.229 + except PeriodError, exc:
1.230 +
1.231 + # Obtain error and period index details from the exception,
1.232 + # collecting errors for each index position.
1.233 +
1.234 + for err, index in exc.args:
1.235 + l = errors.get(index)
1.236 + if not l:
1.237 + l = errors[index] = []
1.238 + l.append(err)
1.239 + raise
1.240 +
1.241 + # Update result computation.
1.242 +
1.243 + def classify_attendee_changes(self):
1.244 +
1.245 + "Classify the attendees in the event."
1.246 +
1.247 + original = self.state.get_original("attendees")
1.248 + current = self.state.get("attendees")
1.249 + return classify_attendee_changes(original, current)
1.250 +
1.251 + def classify_attendee_operations(self):
1.252 +
1.253 + "Classify attendee update operations."
1.254 +
1.255 + new, modified, unmodified, removed = self.classify_attendee_changes()
1.256 +
1.257 + if self.is_organiser():
1.258 + to_invite = new
1.259 + to_cancel = removed
1.260 + to_modify = modified
1.261 + else:
1.262 + to_invite = new
1.263 + to_cancel = {}
1.264 + to_modify = modified
1.265 +
1.266 + return to_invite, to_cancel, to_modify
1.267 +
1.268 + def classify_period_changes(self):
1.269 +
1.270 + "Classify changes in the updated periods for the edited event."
1.271 +
1.272 + updated = self.combine_periods_for_comparison()
1.273 + return classify_period_changes(updated)
1.274 +
1.275 + def classify_periods(self):
1.276 +
1.277 + "Classify the updated periods for the edited event."
1.278 +
1.279 + updated = self.combine_periods()
1.280 + return classify_periods(updated)
1.281 +
1.282 + def combine_periods(self):
1.283 +
1.284 + "Combine stored and checked edited periods to make updated periods."
1.285 +
1.286 + stored = self.get_stored_periods()
1.287 + current = self.get_checked_periods()
1.288 + return combine_periods(stored, current)
1.289 +
1.290 + def combine_periods_for_comparison(self):
1.291 +
1.292 + "Combine unedited and checked edited periods to make updated periods."
1.293 +
1.294 + original = self.get_unedited_periods()
1.295 + current = self.get_checked_periods()
1.296 + return combine_periods(original, current)
1.297 +
1.298 + def classify_period_operations(self):
1.299 +
1.300 + "Classify period update operations."
1.301 +
1.302 + new, replaced, retained, cancelled = self.classify_periods()
1.303 +
1.304 + modified, unmodified, removed = self.classify_period_changes()
1.305 +
1.306 + is_organiser = self.is_organiser()
1.307 + is_shared = self.obj.is_shared()
1.308 +
1.309 + return classify_period_operations(new, replaced, retained, cancelled,
1.310 + modified, removed,
1.311 + is_organiser, is_shared)
1.312 +
1.313 + def properties_changed(self):
1.314 +
1.315 + "Test for changes in event details."
1.316 +
1.317 + is_changed = []
1.318 +
1.319 + if self.is_organiser():
1.320 + for name in ["summary"]:
1.321 + if self.state.has_changed(name):
1.322 + is_changed.append(name)
1.323 +
1.324 + return is_changed
1.325 +
1.326 + def finish(self):
1.327 +
1.328 + "Finish editing, writing edited details to the object."
1.329 +
1.330 + if self.state.get("finished"):
1.331 + return
1.332 +
1.333 + is_changed = self.properties_changed()
1.334 +
1.335 + # Determine period modification operations.
1.336 +
1.337 + self.state["period_operations"] = \
1.338 + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
1.339 + all_unscheduled, all_rescheduled = \
1.340 + self.classify_period_operations()
1.341 +
1.342 + # Determine attendee modifications.
1.343 +
1.344 + self.state["attendee_operations"] = \
1.345 + to_invite, to_cancel, to_modify = \
1.346 + self.classify_attendee_operations()
1.347 +
1.348 + self.state["attendees_to_cancel"] = to_cancel
1.349 +
1.350 + # Update event details.
1.351 +
1.352 + if self.can_edit_properties():
1.353 + self.obj.set_value("SUMMARY", self.state.get("summary"))
1.354 +
1.355 + self.update_attendees(to_invite, to_cancel, to_modify)
1.356 + self.update_event_from_periods(to_set, to_exclude)
1.357 +
1.358 + # Classify the nature of any update.
1.359 +
1.360 + if is_changed or to_set or to_invite:
1.361 + self.state["changed"] = "complete"
1.362 + elif to_reschedule or to_unschedule or to_add:
1.363 + self.state["changed"] = "incremental"
1.364 +
1.365 + self.state["finished"] = self.update_event_version(is_changed)
1.366 +
1.367 + # Update preparation.
1.368 +
1.369 + def have_update(self):
1.370 +
1.371 + "Return whether an update can be prepared and sent."
1.372 +
1.373 + return not self.is_organiser() or \
1.374 + self.obj.is_shared() and self.state.get("changed") and \
1.375 + self.have_other_attendees()
1.376 +
1.377 + def have_other_attendees(self):
1.378 +
1.379 + "Return whether any attendees other than the user are present."
1.380 +
1.381 + attendees = self.state.get("attendees")
1.382 + return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1)
1.383 +
1.384 + def prepare_cancel_message(self):
1.385 +
1.386 + "Prepare the cancel message for uninvited attendees."
1.387 +
1.388 + to_cancel = self.state.get("attendees_to_cancel")
1.389 + return self.make_cancel_message(to_cancel)
1.390 +
1.391 + def prepare_publish_message(self):
1.392 +
1.393 + "Prepare the publishing message for the updated event."
1.394 +
1.395 + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
1.396 + all_unscheduled, all_rescheduled = self.state.get("period_operations")
1.397 +
1.398 + return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add)
1.399 +
1.400 + def prepare_update_message(self):
1.401 +
1.402 + "Prepare the update message for the updated event."
1.403 +
1.404 + if not self.have_update():
1.405 + return None
1.406 +
1.407 + # Obtain operation details.
1.408 +
1.409 + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
1.410 + all_unscheduled, all_rescheduled = self.state.get("period_operations")
1.411 +
1.412 + # Prepare the message.
1.413 +
1.414 + recipients = self.get_recipients()
1.415 + update_parent = self.state["changed"] == "complete"
1.416 +
1.417 + if self.is_organiser():
1.418 + return self.make_update_message(recipients, update_parent,
1.419 + to_unschedule, to_reschedule,
1.420 + all_unscheduled, all_rescheduled,
1.421 + to_add)
1.422 + else:
1.423 + return self.make_response_message(recipients, update_parent,
1.424 + all_rescheduled, to_reschedule)
1.425 +
1.426 + # Modification methods.
1.427 +
1.428 + def add_attendee(self, uri=None):
1.429 +
1.430 + "Add a blank attendee."
1.431 +
1.432 + attendees = self.state.get("attendees")
1.433 + attendees[uri or ""] = {}
1.434 +
1.435 + def add_suggested_attendee(self, index):
1.436 +
1.437 + "Add the suggested attendee at 'index' to the event."
1.438 +
1.439 + attendees = self.state.get("attendees")
1.440 + suggested_attendees = self.state.get("suggested_attendees")
1.441 + try:
1.442 + attendee, (suggested, attr) = suggested_attendees[index]
1.443 + self.add_attendee(suggested)
1.444 + except IndexError:
1.445 + pass
1.446 +
1.447 + def add_period(self):
1.448 +
1.449 + "Add a copy of the main period as a new recurrence."
1.450 +
1.451 + current = self.state.get("periods")
1.452 + new = get_main_period(current).copy()
1.453 + new.origin = "RDATE"
1.454 + new.replacement = False
1.455 + new.recurrenceid = False
1.456 + new.cancelled = False
1.457 + current.append(new)
1.458 +
1.459 + def apply_suggested_period(self, index):
1.460 +
1.461 + "Apply the suggested period at 'index' to the event."
1.462 +
1.463 + current = self.state.get("periods")
1.464 + suggested = self.state.get("suggested_periods")
1.465 +
1.466 + try:
1.467 + attendee, period, operation = suggested[index]
1.468 + period = form_period_from_period(period)
1.469 +
1.470 + # Cancel any removed periods.
1.471 +
1.472 + if operation == "remove":
1.473 + for p in current:
1.474 + if p == period:
1.475 + p.cancelled = True
1.476 + break
1.477 +
1.478 + # Add or replace any other suggestions.
1.479 +
1.480 + elif operation == "add":
1.481 +
1.482 + # Make the status of the period compatible.
1.483 +
1.484 + period.cancelled = False
1.485 + period.origin = "DTSTART-RECUR"
1.486 +
1.487 + # Either replace or add the period.
1.488 +
1.489 + recurrenceid = period.get_recurrenceid()
1.490 +
1.491 + for i, p in enumerate(current):
1.492 + if p.get_recurrenceid() == recurrenceid:
1.493 + current[i] = period
1.494 + break
1.495 +
1.496 + # Add as a new period.
1.497 +
1.498 + else:
1.499 + period.recurrenceid = None
1.500 + current.append(period)
1.501 +
1.502 + except IndexError:
1.503 + pass
1.504 +
1.505 + def cancel_periods(self, indexes, cancelled=True):
1.506 +
1.507 + """
1.508 + Set cancellation state for periods with the given 'indexes', indicating
1.509 + 'cancelled' as a true or false value. New periods will be removed if
1.510 + cancelled.
1.511 + """
1.512 +
1.513 + periods = self.state.get("periods")
1.514 + to_remove = []
1.515 + removed = 0
1.516 +
1.517 + for index in indexes:
1.518 + p = periods[index]
1.519 +
1.520 + # Make replacements from existing periods and cancel them.
1.521 +
1.522 + if p.recurrenceid:
1.523 + p.replacement = True
1.524 + p.cancelled = cancelled
1.525 +
1.526 + # Remove new periods completely.
1.527 +
1.528 + elif cancelled:
1.529 + to_remove.append(index - removed)
1.530 + removed += 1
1.531 +
1.532 + for index in to_remove:
1.533 + del periods[index]
1.534 +
1.535 + def edit_attendance(self, partstat):
1.536 +
1.537 + "Set the 'partstat' of the current user, if attending."
1.538 +
1.539 + attendees = self.state.get("attendees")
1.540 + attr = attendees.get(self.user)
1.541 +
1.542 + if attr:
1.543 + new_attr = {}
1.544 + new_attr.update(attr)
1.545 + new_attr["PARTSTAT"] = partstat
1.546 + attendees[self.user] = new_attr
1.547 +
1.548 + def can_edit_attendee(self, index):
1.549 +
1.550 + """
1.551 + Return whether the attendee at 'index' can be edited, requiring either
1.552 + the organiser and an unshared event, or a new attendee.
1.553 + """
1.554 +
1.555 + attendees = self.state.get("attendees")
1.556 + attendee = attendees.keys()[index]
1.557 +
1.558 + try:
1.559 + attr = attendees[attendee]
1.560 + if self.is_organiser() and not self.obj.is_shared() or not attr:
1.561 + return (attendee, attr)
1.562 + except IndexError:
1.563 + pass
1.564 +
1.565 + return None
1.566 +
1.567 + def can_remove_attendee(self, index):
1.568 +
1.569 + """
1.570 + Return whether the attendee at 'index' can be removed, requiring either
1.571 + the organiser or a new attendee.
1.572 + """
1.573 +
1.574 + attendees = self.state.get("attendees")
1.575 + attendee = attendees.keys()[index]
1.576 +
1.577 + try:
1.578 + attr = attendees[attendee]
1.579 + if self.is_organiser() or not attr:
1.580 + return (attendee, attr)
1.581 + except IndexError:
1.582 + pass
1.583 +
1.584 + return None
1.585 +
1.586 + def remove_attendees(self, indexes):
1.587 +
1.588 + "Remove attendee at 'index'."
1.589 +
1.590 + attendees = self.state.get("attendees")
1.591 + to_remove = []
1.592 +
1.593 + for index in indexes:
1.594 + attendee_item = self.can_remove_attendee(index)
1.595 + if attendee_item:
1.596 + attendee, attr = attendee_item
1.597 + to_remove.append(attendee)
1.598 +
1.599 + for key in to_remove:
1.600 + del attendees[key]
1.601 +
1.602 + def can_edit_period(self, index):
1.603 +
1.604 + """
1.605 + Return the period at 'index' for editing or None if it cannot be edited.
1.606 + """
1.607 +
1.608 + try:
1.609 + return self.state.get("periods")[index]
1.610 + except IndexError:
1.611 + return None
1.612 +
1.613 + def can_edit_properties(self):
1.614 +
1.615 + "Return whether general event properties can be edited."
1.616 +
1.617 + return self.is_organiser()
1.618
1.619
1.620
1.621 @@ -95,11 +656,23 @@
1.622 return dt, get_datetime_attributes(dt)
1.623
1.624 def get_recurrenceid(self):
1.625 +
1.626 + """
1.627 + Return a recurrence identity to be used to associate stored periods with
1.628 + edited periods.
1.629 + """
1.630 +
1.631 if not self.recurrenceid:
1.632 return RecurringPeriod.get_recurrenceid(self)
1.633 return self.recurrenceid
1.634
1.635 def get_recurrenceid_item(self):
1.636 +
1.637 + """
1.638 + Return a recurrence identifier value and datetime properties for use in
1.639 + specifying the RECURRENCE-ID property.
1.640 + """
1.641 +
1.642 if not self.recurrenceid:
1.643 return RecurringPeriod.get_recurrenceid_item(self)
1.644 return self._get_recurrenceid_item()
1.645 @@ -232,7 +805,8 @@
1.646 return "FormPeriod%r" % (self.as_tuple(),)
1.647
1.648 def copy(self):
1.649 - return FormPeriod(*self.as_tuple())
1.650 + args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:]
1.651 + return FormPeriod(*args)
1.652
1.653 def as_event_period(self, index=None):
1.654
1.655 @@ -337,6 +911,9 @@
1.656 def as_tuple(self):
1.657 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr
1.658
1.659 + def copy(self):
1.660 + return FormDate(*self.as_tuple())
1.661 +
1.662 def reset(self):
1.663 self.dt = None
1.664
1.665 @@ -470,17 +1047,30 @@
1.666 def periods_from_updated_periods(updated_periods, fn):
1.667
1.668 """
1.669 - Return periods from the given 'updated_periods' created using 'fn, setting
1.670 + Return periods from the given 'updated_periods' created using 'fn', setting
1.671 replacement, cancelled and recurrence identifier details.
1.672 +
1.673 + This function should be used to produce editing-related periods from the
1.674 + general updated periods provided by the client abstractions.
1.675 """
1.676
1.677 periods = []
1.678
1.679 for sp, p in updated_periods:
1.680 +
1.681 + # Stored periods with corresponding current periods.
1.682 +
1.683 if p:
1.684 period = fn(p)
1.685 - if sp != p:
1.686 +
1.687 + # Replacements are identified by comparing object identities, since
1.688 + # a replacement will not be provided by the same object.
1.689 +
1.690 + if sp is not p:
1.691 period.replacement = True
1.692 +
1.693 + # Stored periods without corresponding current periods.
1.694 +
1.695 else:
1.696 period = fn(sp)
1.697 period.replacement = True
1.698 @@ -519,7 +1109,10 @@
1.699
1.700 def combine_periods(old, new):
1.701
1.702 - "Combine 'old' and 'new' periods for comparison."
1.703 + """
1.704 + Combine 'old' and 'new' periods for comparison, making a list of (old, new)
1.705 + updated period tuples.
1.706 + """
1.707
1.708 old_by_recurrenceid, _new_periods = periods_by_recurrence(old)
1.709 new_by_recurrenceid, new_periods = periods_by_recurrence(new)
1.710 @@ -528,59 +1121,151 @@
1.711
1.712 for recurrenceid, op in old_by_recurrenceid.items():
1.713 np = new_by_recurrenceid.get(recurrenceid)
1.714 - if np and not np.cancelled:
1.715 +
1.716 + # Old period has corresponding new period that is not cancelled.
1.717 +
1.718 + if np and not (np.cancelled and not op.cancelled):
1.719 combined.append((op, np))
1.720 +
1.721 + # No corresponding new, uncancelled period.
1.722 +
1.723 else:
1.724 combined.append((op, None))
1.725
1.726 + # New periods without corresponding old periods are genuinely new.
1.727 +
1.728 for np in new_periods:
1.729 combined.append((None, np))
1.730
1.731 + # Note that new periods should not have recurrence identifiers, and if
1.732 + # imported from other events, they should have such identifiers removed.
1.733 +
1.734 return combined
1.735
1.736 def classify_periods(updated_periods):
1.737
1.738 """
1.739 Using the 'updated_periods', being a list of (stored, current) periods,
1.740 - return a tuple containing collections of new, changed, unchanged and removed
1.741 - periods.
1.742 + return a tuple containing collections of new, replaced, retained and
1.743 + cancelled periods.
1.744
1.745 - Note that changed and unchanged indicate the presence or absence of
1.746 - differences between the original event periods and the current periods, not
1.747 + Note that replaced and retained indicate the presence or absence of
1.748 + differences between the original event periods and the current periods that
1.749 + would need to be represented using separate recurrence instances, not
1.750 whether any editing operations have changed the periods.
1.751 """
1.752
1.753 new = []
1.754 - changed = []
1.755 - unchanged = []
1.756 - removed = []
1.757 + replaced = []
1.758 + retained = []
1.759 + cancelled = []
1.760
1.761 for sp, p in updated_periods:
1.762 +
1.763 + # Stored periods...
1.764 +
1.765 if sp:
1.766 +
1.767 + # With cancelled or absent current periods.
1.768 +
1.769 if not p or p.cancelled:
1.770 - removed.append(sp)
1.771 + cancelled.append(sp)
1.772 +
1.773 + # With differing or replacement current periods.
1.774 +
1.775 elif p != sp or p.replacement:
1.776 - changed.append(p)
1.777 + replaced.append(p)
1.778 if not p.replacement:
1.779 p.new_replacement = True
1.780 +
1.781 + # With retained, not differing current periods.
1.782 +
1.783 else:
1.784 - unchanged.append(p)
1.785 + retained.append(p)
1.786 if p.new_replacement:
1.787 p.new_replacement = False
1.788 +
1.789 + # New periods without corresponding stored periods.
1.790 +
1.791 elif p:
1.792 new.append(p)
1.793
1.794 - return new, changed, unchanged, removed
1.795 + return new, replaced, retained, cancelled
1.796
1.797 -def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared):
1.798 +def classify_period_changes(updated_periods):
1.799
1.800 """
1.801 - Classify the operations for the update of an event. Return the unscheduled
1.802 - periods, rescheduled periods, excluded periods, and the periods to be set in
1.803 - the object to replace the existing stored periods.
1.804 + Using the 'updated_periods', being a list of (original, current) periods,
1.805 + return a tuple containing collections of modified, unmodified and removed
1.806 + periods.
1.807 """
1.808
1.809 - active_periods = new + unchanged + changed
1.810 + modified = []
1.811 + unmodified = []
1.812 + removed = []
1.813 +
1.814 + for op, p in updated_periods:
1.815 +
1.816 + # Test for periods cancelled, reinstated or changed, or left unmodified
1.817 + # during editing.
1.818 +
1.819 + if op:
1.820 + if not op.cancelled and (not p or p.cancelled):
1.821 + removed.append(op)
1.822 + elif op.cancelled and not p.cancelled or p != op:
1.823 + modified.append(p)
1.824 + else:
1.825 + unmodified.append(p)
1.826 +
1.827 + # New periods are always modifications.
1.828 +
1.829 + elif p:
1.830 + modified.append(p)
1.831 +
1.832 + return modified, unmodified, removed
1.833 +
1.834 +def classify_period_operations(new, replaced, retained, cancelled,
1.835 + modified, removed,
1.836 + is_organiser, is_shared):
1.837 +
1.838 + """
1.839 + Classify the operations for the update of an event. For updates modifying
1.840 + shared events, return periods for descheduling and rescheduling (where these
1.841 + operations can modify the event), and periods for exclusion and application
1.842 + (where these operations redefine the event).
1.843 +
1.844 + To define the new state of the event, details of the complete set of
1.845 + unscheduled and rescheduled periods are also provided.
1.846 + """
1.847 +
1.848 + active_periods = new + replaced + retained
1.849 +
1.850 + # Modified replaced and retained recurrences are used for incremental
1.851 + # updates.
1.852 +
1.853 + replaced_modified = select_recurrences(replaced, modified).values()
1.854 + retained_modified = select_recurrences(retained, modified).values()
1.855 +
1.856 + # Unmodified replaced and retained recurrences are used in the complete
1.857 + # event summary.
1.858 +
1.859 + replaced_unmodified = subtract_recurrences(replaced, modified).values()
1.860 + retained_unmodified = subtract_recurrences(retained, modified).values()
1.861 +
1.862 + # Obtain the removed periods in terms of existing periods. These are used in
1.863 + # incremental updates.
1.864 +
1.865 + cancelled_removed = select_recurrences(cancelled, removed).values()
1.866 +
1.867 + # Reinstated periods are previously-cancelled periods that are now modified
1.868 + # periods, and they appear in updates.
1.869 +
1.870 + reinstated = select_recurrences(modified, cancelled).values()
1.871 +
1.872 + # Get cancelled periods without reinstated periods. These appear in complete
1.873 + # event summaries.
1.874 +
1.875 + cancelled_unmodified = subtract_recurrences(cancelled, modified).values()
1.876
1.877 # As organiser...
1.878
1.879 @@ -592,42 +1277,91 @@
1.880
1.881 # For shared events...
1.882 # New periods should cause the event to be redefined.
1.883 + # Other changes should also cause event redefinition.
1.884 + # Event redefinition should only occur if no replacement periods exist.
1.885
1.886 - if not is_shared or new:
1.887 + if not is_shared or new and not replaced:
1.888 + to_set = active_periods
1.889 to_unschedule = []
1.890 to_reschedule = []
1.891 - to_set = active_periods
1.892 + to_add = []
1.893 + all_unscheduled = []
1.894 + all_rescheduled = []
1.895
1.896 # Changed periods should be rescheduled separately.
1.897 # Removed periods should be cancelled separately.
1.898
1.899 else:
1.900 - to_unschedule = removed
1.901 - to_reschedule = changed
1.902 to_set = []
1.903 + to_unschedule = cancelled_removed
1.904 + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated))
1.905 + to_add = new
1.906 + all_unscheduled = cancelled_unmodified
1.907 + all_rescheduled = list(chain(replaced_unmodified, to_reschedule))
1.908
1.909 # As attendee...
1.910
1.911 else:
1.912 to_unschedule = []
1.913 + to_add = []
1.914
1.915 # Changed periods without new or removed periods are proposed as
1.916 # separate changes.
1.917
1.918 if not new and not removed:
1.919 + to_set = []
1.920 to_exclude = []
1.921 - to_reschedule = changed
1.922 - to_set = []
1.923 + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated))
1.924 + all_unscheduled = list(cancelled_unmodified)
1.925 + all_rescheduled = list(chain(replaced_unmodified, to_reschedule))
1.926
1.927 # Otherwise, the event is defined in terms of new periods and
1.928 # exceptions for removed periods.
1.929
1.930 else:
1.931 - to_exclude = removed
1.932 + to_set = active_periods
1.933 + to_exclude = cancelled
1.934 to_reschedule = []
1.935 - to_set = active_periods
1.936 + all_unscheduled = []
1.937 + all_rescheduled = []
1.938 +
1.939 + return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled
1.940 +
1.941 +def get_period_mapping(periods):
1.942 +
1.943 + "Return a mapping of recurrence identifiers to the given 'periods."
1.944 +
1.945 + d, new = periods_by_recurrence(periods)
1.946 + return d
1.947 +
1.948 +def select_recurrences(source, selected):
1.949 +
1.950 + "Restrict 'source' to the recurrences referenced by 'selected'."
1.951 +
1.952 + mapping = get_period_mapping(source)
1.953
1.954 - return to_unschedule, to_reschedule, to_exclude, to_set
1.955 + recurrenceids = get_recurrenceids(selected)
1.956 + for recurrenceid in mapping.keys():
1.957 + if not recurrenceid in recurrenceids:
1.958 + del mapping[recurrenceid]
1.959 + return mapping
1.960 +
1.961 +def subtract_recurrences(source, selected):
1.962 +
1.963 + "Remove from 'source' the recurrences referenced by 'selected'."
1.964 +
1.965 + mapping = get_period_mapping(source)
1.966 +
1.967 + for recurrenceid in get_recurrenceids(selected):
1.968 + if mapping.has_key(recurrenceid):
1.969 + del mapping[recurrenceid]
1.970 + return mapping
1.971 +
1.972 +def get_recurrenceids(periods):
1.973 +
1.974 + "Return the recurrence identifiers employed by 'periods'."
1.975 +
1.976 + return map(lambda p: p.get_recurrenceid(), periods)
1.977
1.978
1.979
1.980 @@ -866,6 +1600,51 @@
1.981
1.982
1.983
1.984 +# Attendee processing.
1.985 +
1.986 +def classify_attendee_changes(original, current):
1.987 +
1.988 + """
1.989 + Return categories of attendees given the 'original' and 'current'
1.990 + collections of attendees.
1.991 + """
1.992 +
1.993 + new = {}
1.994 + modified = {}
1.995 + unmodified = {}
1.996 +
1.997 + # Check current attendees against the original ones.
1.998 +
1.999 + for attendee, attendee_attr in current.items():
1.1000 + original_attr = original.get(attendee)
1.1001 +
1.1002 + # New attendee if missing original details.
1.1003 +
1.1004 + if not original_attr:
1.1005 + new[attendee] = attendee_attr
1.1006 +
1.1007 + # Details unchanged for existing attendee.
1.1008 +
1.1009 + elif attendee_attr == original_attr:
1.1010 + unmodified[attendee] = attendee_attr
1.1011 +
1.1012 + # Details changed for existing attendee.
1.1013 +
1.1014 + else:
1.1015 + modified[attendee] = attendee_attr
1.1016 +
1.1017 + removed = {}
1.1018 +
1.1019 + # Check for removed attendees.
1.1020 +
1.1021 + for attendee, attendee_attr in original.items():
1.1022 + if not current.has_key(attendee):
1.1023 + removed[attendee] = attendee_attr
1.1024 +
1.1025 + return new, modified, unmodified, removed
1.1026 +
1.1027 +
1.1028 +
1.1029 # Utilities.
1.1030
1.1031 def filter_duplicates(l):