imip-agent

Change of imipweb/data.py

1346:822261876a73
imipweb/data.py client-editing-simplification
     1.1 --- a/imipweb/data.py	Wed Oct 18 01:03:42 2017 +0200
     1.2 +++ b/imipweb/data.py	Wed Oct 18 13:24:59 2017 +0200
     1.3 @@ -1,7 +1,7 @@
     1.4  #!/usr/bin/env python
     1.5  
     1.6  """
     1.7 -User interface data abstractions.
     1.8 +Web user interface operations.
     1.9  
    1.10  Copyright (C) 2014, 2015, 2017 Paul Boddie <paul@boddie.org.uk>
    1.11  
    1.12 @@ -19,1424 +19,7 @@
    1.13  this program.  If not, see <http://www.gnu.org/licenses/>.
    1.14  """
    1.15  
    1.16 -from collections import OrderedDict
    1.17 -from copy import copy
    1.18 -from datetime import datetime, timedelta
    1.19 -from imiptools.client import ClientForObject
    1.20 -from imiptools.data import get_main_period
    1.21 -from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \
    1.22 -                            format_datetime, get_datetime, \
    1.23 -                            get_datetime_attributes, get_end_of_day, \
    1.24 -                            to_date, to_utc_datetime, to_timezone
    1.25 -from imiptools.period import get_overlapping_members, RecurringPeriod
    1.26 -from itertools import chain
    1.27 -
    1.28 -# General editing abstractions.
    1.29 -
    1.30 -class State:
    1.31 -
    1.32 -    "Manage editing state."
    1.33 -
    1.34 -    def __init__(self, callables):
    1.35 -
    1.36 -        """
    1.37 -        Define state variable initialisation using the given 'callables', which
    1.38 -        is a mapping that defines a callable for each variable name that is
    1.39 -        invoked when the variable is first requested.
    1.40 -        """
    1.41 -
    1.42 -        self.state = {}
    1.43 -        self.original = {}
    1.44 -        self.callables = callables
    1.45 -
    1.46 -    def get_callable(self, key):
    1.47 -        return self.callables.get(key, lambda: None)
    1.48 -
    1.49 -    def ensure_original(self, key):
    1.50 -
    1.51 -        "Ensure the original state for the given 'key'."
    1.52 -
    1.53 -        if not self.original.has_key(key):
    1.54 -            self.original[key] = self.get_callable(key)()
    1.55 -
    1.56 -    def get_original(self, key):
    1.57 -
    1.58 -        "Return the original state for the given 'key'."
    1.59 -
    1.60 -        self.ensure_original(key)
    1.61 -        return copy(self.original[key])
    1.62 -
    1.63 -    def get(self, key, reset=False):
    1.64 -
    1.65 -        """
    1.66 -        Return state for the given 'key', using the configured callable to
    1.67 -        compute and set the state if no state is already defined.
    1.68 -
    1.69 -        If 'reset' is set to a true value, compute and return the state using
    1.70 -        the configured callable regardless of any existing state.
    1.71 -        """
    1.72 -
    1.73 -        if reset or not self.state.has_key(key):
    1.74 -            self.state[key] = self.get_original(key)
    1.75 -
    1.76 -        return self.state[key]
    1.77 -
    1.78 -    def set(self, key, value):
    1.79 -
    1.80 -        "Set the state of 'key' to 'value'."
    1.81 -
    1.82 -        self.ensure_original(key)
    1.83 -        self.state[key] = value
    1.84 -
    1.85 -    def has_changed(self, key):
    1.86 -
    1.87 -        "Return whether 'key' has changed during editing."
    1.88 -
    1.89 -        return self.get_original(key) != self.get(key)
    1.90 -
    1.91 -    # Dictionary emulation methods.
    1.92 -
    1.93 -    def __getitem__(self, key):
    1.94 -        return self.get(key)
    1.95 -
    1.96 -    def __setitem__(self, key, value):
    1.97 -        self.set(key, value)
    1.98 -
    1.99 -
   1.100 -
   1.101 -# Object editing abstractions.
   1.102 -
   1.103 -class EditingClient(ClientForObject):
   1.104 -
   1.105 -    "A simple calendar client."
   1.106 -
   1.107 -    def __init__(self, user, messenger, store, journal, preferences_dir):
   1.108 -        ClientForObject.__init__(self, None, user, messenger, store,
   1.109 -                                 journal=journal,
   1.110 -                                 preferences_dir=preferences_dir)
   1.111 -        self.reset()
   1.112 -
   1.113 -    # Editing state.
   1.114 -
   1.115 -    def reset(self):
   1.116 -
   1.117 -        "Reset the editing state."
   1.118 -
   1.119 -        self.state = State({
   1.120 -            "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []),
   1.121 -            "organiser" : lambda: self.obj.get_value("ORGANIZER"),
   1.122 -            "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()),
   1.123 -            "suggested_attendees" : self.get_suggested_attendees,
   1.124 -            "suggested_periods" : self.get_suggested_periods,
   1.125 -            "summary" : lambda: self.obj.get_value("SUMMARY"),
   1.126 -            })
   1.127 -
   1.128 -    # Access to stored and current information.
   1.129 -
   1.130 -    def get_stored_periods(self):
   1.131 -
   1.132 -        """
   1.133 -        Return the stored, unrevised, integral periods for the event, excluding
   1.134 -        revisions from separate recurrence instances.
   1.135 -        """
   1.136 -
   1.137 -        return event_periods_from_periods(self.get_periods())
   1.138 -
   1.139 -    def get_unedited_periods(self):
   1.140 -
   1.141 -        """
   1.142 -        Return the original, unedited periods including revisions from separate
   1.143 -        recurrence instances.
   1.144 -        """
   1.145 -
   1.146 -        return event_periods_from_updated_periods(self.get_updated_periods())
   1.147 -
   1.148 -    def get_counters(self):
   1.149 -
   1.150 -        "Return a counter-proposal mapping from attendees to objects."
   1.151 -
   1.152 -        d = {}
   1.153 -
   1.154 -        # Get counter-proposals for the specific object.
   1.155 -
   1.156 -        recurrenceids = [self.recurrenceid]
   1.157 -
   1.158 -        # And for all recurrences associated with a parent object.
   1.159 -
   1.160 -        if not self.recurrenceid:
   1.161 -            recurrenceids += self.store.get_counter_recurrences(self.user, self.uid)
   1.162 -
   1.163 -        # Map attendees to objects.
   1.164 -
   1.165 -        for recurrenceid in recurrenceids:
   1.166 -            attendees = self.store.get_counters(self.user, self.uid, recurrenceid)
   1.167 -            for attendee in attendees:
   1.168 -                if not d.has_key(attendee):
   1.169 -                    d[attendee] = []
   1.170 -                d[attendee].append(self.get_stored_object(self.uid, recurrenceid, "counters", attendee))
   1.171 -
   1.172 -        return d
   1.173 -
   1.174 -    def get_suggested_attendees(self):
   1.175 -
   1.176 -        "For all counter-proposals, return suggested attendee items."
   1.177 -
   1.178 -        existing = self.state.get("attendees")
   1.179 -        l = []
   1.180 -        for attendee, objects in self.get_counters().items():
   1.181 -            for obj in objects:
   1.182 -                for suggested, attr in obj.get_items("ATTENDEE"):
   1.183 -                    if suggested not in existing:
   1.184 -                        l.append((attendee, (suggested, attr)))
   1.185 -        return l
   1.186 -
   1.187 -    def get_suggested_periods(self):
   1.188 -
   1.189 -        "For all counter-proposals, return suggested event periods."
   1.190 -
   1.191 -        existing = self.state.get("periods")
   1.192 -
   1.193 -        # Get active periods for filtering of suggested periods.
   1.194 -
   1.195 -        active = []
   1.196 -        for p in existing:
   1.197 -            if not p.cancelled:
   1.198 -                active.append(p)
   1.199 -
   1.200 -        suggested = []
   1.201 -
   1.202 -        for attendee, objects in self.get_counters().items():
   1.203 -
   1.204 -            # For each object, obtain suggested periods.
   1.205 -
   1.206 -            for obj in objects:
   1.207 -
   1.208 -                # Obtain the current periods for the object providing the
   1.209 -                # suggested periods.
   1.210 -
   1.211 -                updated = self.get_updated_periods(obj)
   1.212 -                suggestions = event_periods_from_updated_periods(updated)
   1.213 -
   1.214 -                # Compare current periods with suggested periods.
   1.215 -
   1.216 -                new = set(suggestions).difference(active)
   1.217 -
   1.218 -                # Treat each specific recurrence as affecting only the original
   1.219 -                # period.
   1.220 -
   1.221 -                if obj.get_recurrenceid():
   1.222 -                    removed = []
   1.223 -                else:
   1.224 -                    removed = set(active).difference(suggestions)
   1.225 -
   1.226 -                # Associate new and removed periods with the attendee.
   1.227 -
   1.228 -                for period in new:
   1.229 -                    suggested.append((attendee, period, "add"))
   1.230 -
   1.231 -                for period in removed:
   1.232 -                    suggested.append((attendee, period, "remove"))
   1.233 -
   1.234 -        return suggested
   1.235 -
   1.236 -    # Validation methods.
   1.237 -
   1.238 -    def get_checked_periods(self):
   1.239 -
   1.240 -        """
   1.241 -        Check the edited periods and return objects representing them, setting
   1.242 -        the "periods" state. If errors occur, raise an exception and set the
   1.243 -        "errors" state.
   1.244 -        """
   1.245 -
   1.246 -        self.state["period_errors"] = errors = {}
   1.247 -
   1.248 -        # Basic validation.
   1.249 -
   1.250 -        try:
   1.251 -            periods = event_periods_from_periods(self.state.get("periods"))
   1.252 -
   1.253 -        except PeriodError, exc:
   1.254 -
   1.255 -            # Obtain error and period index details from the exception,
   1.256 -            # collecting errors for each index position.
   1.257 -
   1.258 -            for err, index in exc.args:
   1.259 -                l = errors.get(index)
   1.260 -                if not l:
   1.261 -                    l = errors[index] = []
   1.262 -                l.append(err)
   1.263 -            raise
   1.264 -
   1.265 -        # Check for overlapping periods.
   1.266 -
   1.267 -        overlapping = get_overlapping_members(periods)
   1.268 -
   1.269 -        for period in overlapping:
   1.270 -            for index, p in enumerate(periods):
   1.271 -                if period is p:
   1.272 -                    errors[index] = ["overlap"]
   1.273 -
   1.274 -        if overlapping:
   1.275 -            raise PeriodError
   1.276 -
   1.277 -        self.state["periods"] = form_periods_from_periods(periods)
   1.278 -        return periods
   1.279 -
   1.280 -    # Update result computation.
   1.281 -
   1.282 -    def classify_attendee_changes(self):
   1.283 -
   1.284 -        "Classify the attendees in the event."
   1.285 -
   1.286 -        original = self.state.get_original("attendees")
   1.287 -        current = self.state.get("attendees")
   1.288 -        return classify_attendee_changes(original, current)
   1.289 -
   1.290 -    def classify_attendee_operations(self):
   1.291 -
   1.292 -        "Classify attendee update operations."
   1.293 -
   1.294 -        new, modified, unmodified, removed = self.classify_attendee_changes()
   1.295 -
   1.296 -        if self.is_organiser():
   1.297 -            to_invite = new
   1.298 -            to_cancel = removed
   1.299 -            to_modify = modified
   1.300 -        else:
   1.301 -            to_invite = new
   1.302 -            to_cancel = {}
   1.303 -            to_modify = modified
   1.304 -
   1.305 -        return to_invite, to_cancel, to_modify
   1.306 -
   1.307 -    def classify_period_changes(self):
   1.308 -
   1.309 -        "Classify changes in the updated periods for the edited event."
   1.310 -
   1.311 -        updated = self.combine_periods_for_comparison()
   1.312 -        return classify_period_changes(updated)
   1.313 -
   1.314 -    def classify_periods(self):
   1.315 -
   1.316 -        "Classify the updated periods for the edited event."
   1.317 -
   1.318 -        updated = self.combine_periods()
   1.319 -        return classify_periods(updated)
   1.320 -
   1.321 -    def combine_periods(self):
   1.322 -
   1.323 -        "Combine stored and checked edited periods to make updated periods."
   1.324 -
   1.325 -        stored = self.get_stored_periods()
   1.326 -        current = self.get_checked_periods()
   1.327 -        return combine_periods(stored, current)
   1.328 -
   1.329 -    def combine_periods_for_comparison(self):
   1.330 -
   1.331 -        "Combine unedited and checked edited periods to make updated periods."
   1.332 -
   1.333 -        original = self.get_unedited_periods()
   1.334 -        current = self.get_checked_periods()
   1.335 -        return combine_periods(original, current)
   1.336 -
   1.337 -    def classify_period_operations(self, is_changed=False):
   1.338 -
   1.339 -        "Classify period update operations."
   1.340 -
   1.341 -        new, replaced, retained, cancelled, obsolete = self.classify_periods()
   1.342 -
   1.343 -        modified, unmodified, removed = self.classify_period_changes()
   1.344 -
   1.345 -        is_organiser = self.is_organiser()
   1.346 -        is_shared = self.obj.is_shared()
   1.347 -
   1.348 -        return classify_period_operations(new, replaced, retained, cancelled,
   1.349 -                                          obsolete, modified, removed,
   1.350 -                                          is_organiser, is_shared, is_changed)
   1.351 -
   1.352 -    def properties_changed(self):
   1.353 -
   1.354 -        "Test for changes in event details."
   1.355 -
   1.356 -        is_changed = []
   1.357 -
   1.358 -        for name in ["summary"]:
   1.359 -            if self.state.has_changed(name):
   1.360 -                is_changed.append(name)
   1.361 -
   1.362 -        return is_changed
   1.363 -
   1.364 -    def finish(self):
   1.365 -
   1.366 -        "Finish editing, writing edited details to the object."
   1.367 -
   1.368 -        if self.state.get("finished"):
   1.369 -            return
   1.370 -
   1.371 -        is_changed = self.properties_changed()
   1.372 -
   1.373 -        # Determine attendee modifications.
   1.374 -
   1.375 -        self.state["attendee_operations"] = \
   1.376 -            to_invite, to_cancel, to_modify = \
   1.377 -                self.classify_attendee_operations()
   1.378 -
   1.379 -        self.state["attendees_to_cancel"] = to_cancel
   1.380 -
   1.381 -        # Determine period modification operations.
   1.382 -        # Use property changes and attendee suggestions to affect the result for
   1.383 -        # attendee responses.
   1.384 -
   1.385 -        is_changed = is_changed or to_invite
   1.386 -
   1.387 -        self.state["period_operations"] = \
   1.388 -            to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
   1.389 -            all_unscheduled, all_rescheduled = \
   1.390 -                self.classify_period_operations(is_changed)
   1.391 -
   1.392 -        # Determine whole event update status.
   1.393 -
   1.394 -        is_changed = is_changed or to_set
   1.395 -
   1.396 -        # Update event details.
   1.397 -
   1.398 -        if self.can_edit_properties():
   1.399 -            self.obj.set_value("SUMMARY", self.state.get("summary"))
   1.400 -
   1.401 -        self.update_attendees(to_invite, to_cancel, to_modify)
   1.402 -        self.update_event_from_periods(to_set, to_exclude)
   1.403 -
   1.404 -        # Classify the nature of any update.
   1.405 -
   1.406 -        if is_changed:
   1.407 -            self.state["changed"] = "complete"
   1.408 -        elif to_reschedule or to_unschedule or to_add:
   1.409 -            self.state["changed"] = "incremental"
   1.410 -
   1.411 -        self.state["finished"] = self.update_event_version(is_changed)
   1.412 -
   1.413 -    # Update preparation.
   1.414 -
   1.415 -    def have_update(self):
   1.416 -
   1.417 -        "Return whether an update can be prepared and sent."
   1.418 -
   1.419 -        return not self.is_organiser() or \
   1.420 -               not self.obj.is_shared() or \
   1.421 -               self.obj.is_shared() and self.state.get("changed") and \
   1.422 -                   self.have_other_attendees()
   1.423 -
   1.424 -    def have_other_attendees(self):
   1.425 -
   1.426 -        "Return whether any attendees other than the user are present."
   1.427 -
   1.428 -        attendees = self.state.get("attendees")
   1.429 -        return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1)
   1.430 -
   1.431 -    def prepare_cancel_message(self):
   1.432 -
   1.433 -        "Prepare the cancel message for uninvited attendees."
   1.434 -
   1.435 -        to_cancel = self.state.get("attendees_to_cancel")
   1.436 -        return self.make_cancel_message(to_cancel)
   1.437 -
   1.438 -    def prepare_publish_message(self):
   1.439 -
   1.440 -        "Prepare the publishing message for the updated event."
   1.441 -
   1.442 -        to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
   1.443 -            all_unscheduled, all_rescheduled = self.state.get("period_operations")
   1.444 -
   1.445 -        return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add)
   1.446 -
   1.447 -    def prepare_update_message(self):
   1.448 -
   1.449 -        "Prepare the update message for the updated event."
   1.450 -
   1.451 -        if not self.have_update():
   1.452 -            return None
   1.453 -
   1.454 -        # Obtain operation details.
   1.455 -
   1.456 -        to_unschedule, to_reschedule, to_add, to_exclude, to_set, \
   1.457 -            all_unscheduled, all_rescheduled = self.state.get("period_operations")
   1.458 -
   1.459 -        # Prepare the message.
   1.460 -
   1.461 -        recipients = self.get_recipients()
   1.462 -        update_parent = self.state["changed"] == "complete"
   1.463 -
   1.464 -        if self.is_organiser():
   1.465 -            return self.make_update_message(recipients, update_parent,
   1.466 -                                            to_unschedule, to_reschedule,
   1.467 -                                            all_unscheduled, all_rescheduled,
   1.468 -                                            to_add)
   1.469 -        else:
   1.470 -            return self.make_response_message(recipients, update_parent,
   1.471 -                                              all_rescheduled, to_reschedule)
   1.472 -
   1.473 -    # Modification methods.
   1.474 -
   1.475 -    def add_attendee(self, uri=None):
   1.476 -
   1.477 -        "Add a blank attendee."
   1.478 -
   1.479 -        attendees = self.state.get("attendees")
   1.480 -        attendees[uri or ""] = {"PARTSTAT" : "NEEDS-ACTION"}
   1.481 -
   1.482 -    def add_suggested_attendee(self, index):
   1.483 -
   1.484 -        "Add the suggested attendee at 'index' to the event."
   1.485 -
   1.486 -        attendees = self.state.get("attendees")
   1.487 -        suggested_attendees = self.state.get("suggested_attendees")
   1.488 -        try:
   1.489 -            attendee, (suggested, attr) = suggested_attendees[index]
   1.490 -            self.add_attendee(suggested)
   1.491 -        except IndexError:
   1.492 -            pass
   1.493 -
   1.494 -    def add_period(self):
   1.495 -
   1.496 -        "Add a copy of the main period as a new recurrence."
   1.497 -
   1.498 -        current = self.state.get("periods")
   1.499 -        new = get_main_period(current).copy()
   1.500 -        new.origin = "RDATE"
   1.501 -        new.replacement = False
   1.502 -        new.recurrenceid = False
   1.503 -        new.cancelled = False
   1.504 -        current.append(new)
   1.505 -
   1.506 -    def apply_suggested_period(self, index):
   1.507 -
   1.508 -        "Apply the suggested period at 'index' to the event."
   1.509 -
   1.510 -        current = self.state.get("periods")
   1.511 -        suggested = self.state.get("suggested_periods")
   1.512 -
   1.513 -        try:
   1.514 -            attendee, period, operation = suggested[index]
   1.515 -            period = form_period_from_period(period)
   1.516 -
   1.517 -            # Cancel any removed periods.
   1.518 -
   1.519 -            if operation == "remove":
   1.520 -                for index, p in enumerate(current):
   1.521 -                    if p == period:
   1.522 -                        self.cancel_periods([index])
   1.523 -                        break
   1.524 -
   1.525 -            # Add or replace any other suggestions.
   1.526 -
   1.527 -            elif operation == "add":
   1.528 -
   1.529 -                # Make the status of the period compatible.
   1.530 -
   1.531 -                period.cancelled = False
   1.532 -                period.origin = "DTSTART-RECUR"
   1.533 -
   1.534 -                # Either replace or add the period.
   1.535 -
   1.536 -                recurrenceid = period.get_recurrenceid()
   1.537 -
   1.538 -                for i, p in enumerate(current):
   1.539 -                    if p.get_recurrenceid() == recurrenceid:
   1.540 -                        current[i] = period
   1.541 -                        break
   1.542 -
   1.543 -                # Add as a new period.
   1.544 -
   1.545 -                else:
   1.546 -                    period.recurrenceid = None
   1.547 -                    current.append(period)
   1.548 -
   1.549 -        except IndexError:
   1.550 -            pass
   1.551 -
   1.552 -    def cancel_periods(self, indexes, cancelled=True):
   1.553 -
   1.554 -        """
   1.555 -        Set cancellation state for periods with the given 'indexes', indicating
   1.556 -        'cancelled' as a true or false value. New periods will be removed if
   1.557 -        cancelled.
   1.558 -        """
   1.559 -
   1.560 -        periods = self.state.get("periods")
   1.561 -        to_remove = []
   1.562 -        removed = 0
   1.563 -
   1.564 -        for index in indexes:
   1.565 -            p = periods[index]
   1.566 -
   1.567 -            # Make replacements from existing periods and cancel them.
   1.568 -
   1.569 -            if p.recurrenceid:
   1.570 -                p.replacement = True
   1.571 -                p.cancelled = cancelled
   1.572 -
   1.573 -            # Remove new periods completely.
   1.574 -
   1.575 -            elif cancelled:
   1.576 -                to_remove.append(index - removed)
   1.577 -                removed += 1
   1.578 -
   1.579 -        for index in to_remove:
   1.580 -            del periods[index]
   1.581 -
   1.582 -    def can_edit_attendance(self):
   1.583 -
   1.584 -        "Return whether the organiser's attendance can be edited."
   1.585 -
   1.586 -        return self.state.get("attendees").has_key(self.user)
   1.587 -
   1.588 -    def edit_attendance(self, partstat):
   1.589 -
   1.590 -        "Set the 'partstat' of the current user, if attending."
   1.591 -
   1.592 -        attendees = self.state.get("attendees")
   1.593 -        attr = attendees.get(self.user)
   1.594 -
   1.595 -        # Set the attendance for the user, if attending.
   1.596 -
   1.597 -        if attr is not None:
   1.598 -            new_attr = {}
   1.599 -            new_attr.update(attr)
   1.600 -            new_attr["PARTSTAT"] = partstat
   1.601 -            attendees[self.user] = new_attr
   1.602 -
   1.603 -    def can_edit_attendee(self, index):
   1.604 -
   1.605 -        """
   1.606 -        Return whether the attendee at 'index' can be edited, requiring either
   1.607 -        the organiser and an unshared event, or a new attendee.
   1.608 -        """
   1.609 -
   1.610 -        attendees = self.state.get("attendees")
   1.611 -        attendee = attendees.keys()[index]
   1.612 -
   1.613 -        try:
   1.614 -            attr = attendees[attendee]
   1.615 -            if self.is_organiser() and not self.obj.is_shared() or not attr:
   1.616 -                return (attendee, attr)
   1.617 -        except IndexError:
   1.618 -            pass
   1.619 -
   1.620 -        return None
   1.621 -
   1.622 -    def can_remove_attendee(self, index):
   1.623 -
   1.624 -        """
   1.625 -        Return whether the attendee at 'index' can be removed, requiring either
   1.626 -        the organiser or a new attendee.
   1.627 -        """
   1.628 -
   1.629 -        attendees = self.state.get("attendees")
   1.630 -        attendee = attendees.keys()[index]
   1.631 -
   1.632 -        try:
   1.633 -            attr = attendees[attendee]
   1.634 -            if self.is_organiser() or not attr:
   1.635 -                return (attendee, attr)
   1.636 -        except IndexError:
   1.637 -            pass
   1.638 -
   1.639 -        return None
   1.640 -
   1.641 -    def remove_attendees(self, indexes):
   1.642 -
   1.643 -        "Remove attendee at 'index'."
   1.644 -
   1.645 -        attendees = self.state.get("attendees")
   1.646 -        to_remove = []
   1.647 -
   1.648 -        for index in indexes:
   1.649 -            attendee_item = self.can_remove_attendee(index)
   1.650 -            if attendee_item:
   1.651 -                attendee, attr = attendee_item
   1.652 -                to_remove.append(attendee)
   1.653 -
   1.654 -        for key in to_remove:
   1.655 -            del attendees[key]
   1.656 -
   1.657 -    def can_edit_period(self, index):
   1.658 -
   1.659 -        """
   1.660 -        Return the period at 'index' for editing or None if it cannot be edited.
   1.661 -        """
   1.662 -
   1.663 -        try:
   1.664 -            return self.state.get("periods")[index]
   1.665 -        except IndexError:
   1.666 -            return None
   1.667 -
   1.668 -    def can_edit_properties(self):
   1.669 -
   1.670 -        "Return whether general event properties can be edited."
   1.671 -
   1.672 -        return True
   1.673 -
   1.674 -
   1.675 -
   1.676 -# Period-related abstractions.
   1.677 -
   1.678 -class PeriodError(Exception):
   1.679 -    pass
   1.680 -
   1.681 -class EditablePeriod(RecurringPeriod):
   1.682 -
   1.683 -    "An editable period tracking the identity of any original period."
   1.684 -
   1.685 -    def _get_recurrenceid_item(self):
   1.686 -
   1.687 -        # Convert any stored identifier to the current time zone.
   1.688 -        # NOTE: This should not be necessary, but is done for consistency with
   1.689 -        # NOTE: the datetime properties.
   1.690 -
   1.691 -        dt = get_datetime(self.recurrenceid)
   1.692 -        dt = to_timezone(dt, self.tzid)
   1.693 -        return dt, get_datetime_attributes(dt)
   1.694 -
   1.695 -    def get_recurrenceid(self):
   1.696 -
   1.697 -        """
   1.698 -        Return a recurrence identity to be used to associate stored periods with
   1.699 -        edited periods.
   1.700 -        """
   1.701 -
   1.702 -        if not self.recurrenceid:
   1.703 -            return RecurringPeriod.get_recurrenceid(self)
   1.704 -        return self.recurrenceid
   1.705 -
   1.706 -    def get_recurrenceid_item(self):
   1.707 -
   1.708 -        """
   1.709 -        Return a recurrence identifier value and datetime properties for use in
   1.710 -        specifying the RECURRENCE-ID property.
   1.711 -        """
   1.712 -
   1.713 -        if not self.recurrenceid:
   1.714 -            return RecurringPeriod.get_recurrenceid_item(self)
   1.715 -        return self._get_recurrenceid_item()
   1.716 -
   1.717 -class EventPeriod(EditablePeriod):
   1.718 -
   1.719 -    """
   1.720 -    A simple period plus attribute details, compatible with RecurringPeriod, and
   1.721 -    intended to represent information obtained from an iCalendar resource.
   1.722 -    """
   1.723 -
   1.724 -    def __init__(self, start, end, tzid=None, origin=None, start_attr=None,
   1.725 -                 end_attr=None, form_start=None, form_end=None,
   1.726 -                 replacement=False, cancelled=False, recurrenceid=None):
   1.727 -
   1.728 -        """
   1.729 -        Initialise a period with the given 'start' and 'end' datetimes.
   1.730 -
   1.731 -        The optional 'tzid' provides time zone information, and the optional
   1.732 -        'origin' indicates the kind of period this object describes.
   1.733 -
   1.734 -        The optional 'start_attr' and 'end_attr' provide metadata for the start
   1.735 -        and end datetimes respectively, and 'form_start' and 'form_end' are
   1.736 -        values provided as textual input.
   1.737 -
   1.738 -        The 'replacement' flag indicates whether the period is provided by a
   1.739 -        separate recurrence instance.
   1.740 -
   1.741 -        The 'cancelled' flag indicates whether a separate recurrence is
   1.742 -        cancelled.
   1.743 -
   1.744 -        The 'recurrenceid' describes the original identity of the period,
   1.745 -        regardless of whether it is separate or not.
   1.746 -        """
   1.747 -
   1.748 -        EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr)
   1.749 -        self.form_start = form_start
   1.750 -        self.form_end = form_end
   1.751 -
   1.752 -        # Information about whether a separate recurrence provides this period
   1.753 -        # and the original period identity.
   1.754 -
   1.755 -        self.replacement = replacement
   1.756 -        self.cancelled = cancelled
   1.757 -        self.recurrenceid = recurrenceid
   1.758 -
   1.759 -        # Additional editing state.
   1.760 -
   1.761 -        self.new_replacement = False
   1.762 -
   1.763 -    def as_tuple(self):
   1.764 -        return self.start, self.end, self.tzid, self.origin, self.start_attr, \
   1.765 -               self.end_attr, self.form_start, self.form_end, self.replacement, \
   1.766 -               self.cancelled, self.recurrenceid
   1.767 -
   1.768 -    def __repr__(self):
   1.769 -        return "EventPeriod%r" % (self.as_tuple(),)
   1.770 -
   1.771 -    def copy(self):
   1.772 -        return EventPeriod(*self.as_tuple())
   1.773 -
   1.774 -    def as_event_period(self, index=None):
   1.775 -        return self
   1.776 -
   1.777 -    def get_start_item(self):
   1.778 -        return self.get_start(), self.get_start_attr()
   1.779 -
   1.780 -    def get_end_item(self):
   1.781 -        return self.get_end(), self.get_end_attr()
   1.782 -
   1.783 -    # Form data compatibility methods.
   1.784 -
   1.785 -    def get_form_start(self):
   1.786 -        if not self.form_start:
   1.787 -            self.form_start = self.get_form_date(self.get_start(), self.start_attr)
   1.788 -        return self.form_start
   1.789 -
   1.790 -    def get_form_end(self):
   1.791 -        if not self.form_end:
   1.792 -            self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr)
   1.793 -        return self.form_end
   1.794 -
   1.795 -    def as_form_period(self):
   1.796 -        return FormPeriod(
   1.797 -            self.get_form_start(),
   1.798 -            self.get_form_end(),
   1.799 -            isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1),
   1.800 -            isinstance(self.start, datetime) or isinstance(self.end, datetime),
   1.801 -            self.tzid,
   1.802 -            self.origin,
   1.803 -            self.replacement,
   1.804 -            self.cancelled,
   1.805 -            self.recurrenceid
   1.806 -            )
   1.807 -
   1.808 -    def get_form_date(self, dt, attr=None):
   1.809 -        return FormDate(
   1.810 -            format_datetime(to_date(dt)),
   1.811 -            isinstance(dt, datetime) and str(dt.hour) or None,
   1.812 -            isinstance(dt, datetime) and str(dt.minute) or None,
   1.813 -            isinstance(dt, datetime) and str(dt.second) or None,
   1.814 -            attr and attr.get("TZID") or None,
   1.815 -            dt, attr
   1.816 -            )
   1.817 -
   1.818 -class FormPeriod(EditablePeriod):
   1.819 -
   1.820 -    "A period whose information originates from a form."
   1.821 -
   1.822 -    def __init__(self, start, end, end_enabled=True, times_enabled=True,
   1.823 -                 tzid=None, origin=None, replacement=False, cancelled=False,
   1.824 -                 recurrenceid=None):
   1.825 -        self.start = start
   1.826 -        self.end = end
   1.827 -        self.end_enabled = end_enabled
   1.828 -        self.times_enabled = times_enabled
   1.829 -        self.tzid = tzid
   1.830 -        self.origin = origin
   1.831 -        self.replacement = replacement
   1.832 -        self.cancelled = cancelled
   1.833 -        self.recurrenceid = recurrenceid
   1.834 -        self.new_replacement = False
   1.835 -
   1.836 -    def as_tuple(self):
   1.837 -        return self.start, self.end, self.end_enabled, self.times_enabled, \
   1.838 -               self.tzid, self.origin, self.replacement, self.cancelled, \
   1.839 -               self.recurrenceid
   1.840 -
   1.841 -    def __repr__(self):
   1.842 -        return "FormPeriod%r" % (self.as_tuple(),)
   1.843 -
   1.844 -    def copy(self):
   1.845 -        args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:]
   1.846 -        return FormPeriod(*args)
   1.847 -
   1.848 -    def as_event_period(self, index=None):
   1.849 -
   1.850 -        """
   1.851 -        Return a converted version of this object as an event period suitable
   1.852 -        for iCalendar usage. If 'index' is indicated, include it in any error
   1.853 -        raised in the conversion process.
   1.854 -        """
   1.855 -
   1.856 -        dtstart, dtstart_attr = self.get_start_item()
   1.857 -        if not dtstart:
   1.858 -            if index is not None:
   1.859 -                raise PeriodError(("dtstart", index))
   1.860 -            else:
   1.861 -                raise PeriodError("dtstart")
   1.862 -
   1.863 -        dtend, dtend_attr = self.get_end_item()
   1.864 -        if not dtend:
   1.865 -            if index is not None:
   1.866 -                raise PeriodError(("dtend", index))
   1.867 -            else:
   1.868 -                raise PeriodError("dtend")
   1.869 -
   1.870 -        if dtstart > dtend:
   1.871 -            if index is not None:
   1.872 -                raise PeriodError(("dtstart", index), ("dtend", index))
   1.873 -            else:
   1.874 -                raise PeriodError("dtstart", "dtend")
   1.875 -
   1.876 -        return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid,
   1.877 -                           self.origin, dtstart_attr, dtend_attr,
   1.878 -                           self.start, self.end, self.replacement,
   1.879 -                           self.cancelled, self.recurrenceid)
   1.880 -
   1.881 -    # Period data methods.
   1.882 -
   1.883 -    def get_start(self):
   1.884 -        return self.start and self.start.as_datetime(self.times_enabled) or None
   1.885 -
   1.886 -    def get_end(self):
   1.887 -
   1.888 -        # Handle specified end datetimes.
   1.889 -
   1.890 -        if self.end_enabled:
   1.891 -            dtend = self.end.as_datetime(self.times_enabled)
   1.892 -            if not dtend:
   1.893 -                return None
   1.894 -
   1.895 -        # Handle same day times.
   1.896 -
   1.897 -        elif self.times_enabled:
   1.898 -            formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid)
   1.899 -            dtend = formdate.as_datetime(self.times_enabled)
   1.900 -            if not dtend:
   1.901 -                return None
   1.902 -
   1.903 -        # Otherwise, treat the end date as the start date. Datetimes are
   1.904 -        # handled by making the event occupy the rest of the day.
   1.905 -
   1.906 -        else:
   1.907 -            dtstart, dtstart_attr = self.get_start_item()
   1.908 -            if dtstart:
   1.909 -                if isinstance(dtstart, datetime):
   1.910 -                    dtend = get_end_of_day(dtstart, dtstart_attr["TZID"])
   1.911 -                else:
   1.912 -                    dtend = dtstart
   1.913 -            else:
   1.914 -                return None
   1.915 -
   1.916 -        return dtend
   1.917 -
   1.918 -    def get_start_attr(self):
   1.919 -        return self.start and self.start.get_attributes(self.times_enabled) or {}
   1.920 -
   1.921 -    def get_end_attr(self):
   1.922 -        return self.end and self.end.get_attributes(self.times_enabled) or {}
   1.923 -
   1.924 -    # Form data methods.
   1.925 -
   1.926 -    def get_form_start(self):
   1.927 -        return self.start
   1.928 -
   1.929 -    def get_form_end(self):
   1.930 -        return self.end
   1.931 -
   1.932 -    def as_form_period(self):
   1.933 -        return self
   1.934 -
   1.935 -class FormDate:
   1.936 -
   1.937 -    "Date information originating from form information."
   1.938 -
   1.939 -    def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None):
   1.940 -        self.date = date
   1.941 -        self.hour = hour
   1.942 -        self.minute = minute
   1.943 -        self.second = second
   1.944 -        self.tzid = tzid
   1.945 -        self.dt = dt
   1.946 -        self.attr = attr
   1.947 -
   1.948 -    def as_tuple(self):
   1.949 -        return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr
   1.950 -
   1.951 -    def copy(self):
   1.952 -        return FormDate(*self.as_tuple())
   1.953 -
   1.954 -    def reset(self):
   1.955 -        self.dt = None
   1.956 -
   1.957 -    def __repr__(self):
   1.958 -        return "FormDate%r" % (self.as_tuple(),)
   1.959 -
   1.960 -    def get_component(self, value):
   1.961 -        return (value or "").rjust(2, "0")[:2]
   1.962 -
   1.963 -    def get_hour(self):
   1.964 -        return self.get_component(self.hour)
   1.965 -
   1.966 -    def get_minute(self):
   1.967 -        return self.get_component(self.minute)
   1.968 -
   1.969 -    def get_second(self):
   1.970 -        return self.get_component(self.second)
   1.971 -
   1.972 -    def get_date_string(self):
   1.973 -        return self.date or ""
   1.974 -
   1.975 -    def get_datetime_string(self):
   1.976 -        if not self.date:
   1.977 -            return ""
   1.978 -
   1.979 -        hour = self.hour; minute = self.minute; second = self.second
   1.980 -
   1.981 -        if hour or minute or second:
   1.982 -            time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second)))
   1.983 -        else:
   1.984 -            time = ""
   1.985 -            
   1.986 -        return "%s%s" % (self.date, time)
   1.987 -
   1.988 -    def get_tzid(self):
   1.989 -        return self.tzid
   1.990 -
   1.991 -    def as_datetime(self, with_time=True):
   1.992 -
   1.993 -        """
   1.994 -        Return a datetime for this object if one is provided or can be produced.
   1.995 -        """
   1.996 -
   1.997 -        # Return any original datetime details.
   1.998 -
   1.999 -        if self.dt:
  1.1000 -            return self.dt
  1.1001 -
  1.1002 -        # Otherwise, construct a datetime.
  1.1003 -
  1.1004 -        s, attr = self.as_datetime_item(with_time)
  1.1005 -        if not s:
  1.1006 -            return None
  1.1007 -
  1.1008 -        # An erroneous datetime will yield None as result.
  1.1009 -
  1.1010 -        try:
  1.1011 -            return get_datetime(s, attr)
  1.1012 -        except ValueError:
  1.1013 -            return None
  1.1014 -
  1.1015 -    def as_datetime_item(self, with_time=True):
  1.1016 -
  1.1017 -        """
  1.1018 -        Return a (datetime string, attr) tuple for the datetime information
  1.1019 -        provided by this object, where both tuple elements will be None if no
  1.1020 -        suitable date or datetime information exists.
  1.1021 -        """
  1.1022 -
  1.1023 -        s = None
  1.1024 -        if with_time:
  1.1025 -            s = self.get_datetime_string()
  1.1026 -            attr = self.get_attributes(True)
  1.1027 -        if not s:
  1.1028 -            s = self.get_date_string()
  1.1029 -            attr = self.get_attributes(False)
  1.1030 -        if not s:
  1.1031 -            return None, None
  1.1032 -        return s, attr
  1.1033 -
  1.1034 -    def get_attributes(self, with_time=True):
  1.1035 -
  1.1036 -        "Return attributes for the date or datetime represented by this object."
  1.1037 -
  1.1038 -        if with_time:
  1.1039 -            return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"}
  1.1040 -        else:
  1.1041 -            return {"VALUE" : "DATE"}
  1.1042 -
  1.1043 -def event_period_from_period(period, index=None):
  1.1044 -
  1.1045 -    """
  1.1046 -    Convert a 'period' to one suitable for use in an iCalendar representation.
  1.1047 -    In an "event period" representation, the end day of any date-level event is
  1.1048 -    encoded as the "day after" the last day actually involved in the event.
  1.1049 -    """
  1.1050 -
  1.1051 -    if isinstance(period, EventPeriod):
  1.1052 -        return period
  1.1053 -    elif isinstance(period, FormPeriod):
  1.1054 -        return period.as_event_period(index)
  1.1055 -    else:
  1.1056 -        dtstart, dtstart_attr = period.get_start_item()
  1.1057 -        dtend, dtend_attr = period.get_end_item()
  1.1058 -
  1.1059 -        if not isinstance(period, RecurringPeriod):
  1.1060 -            dtend = end_date_to_calendar(dtend)
  1.1061 -
  1.1062 -        return EventPeriod(dtstart, dtend, period.tzid, period.origin,
  1.1063 -                           dtstart_attr, dtend_attr,
  1.1064 -                           recurrenceid=format_datetime(to_utc_datetime(dtstart)))
  1.1065 -
  1.1066 -def event_periods_from_periods(periods):
  1.1067 -    return map(event_period_from_period, periods, range(0, len(periods)))
  1.1068 -
  1.1069 -def form_period_from_period(period):
  1.1070 -
  1.1071 -    """
  1.1072 -    Convert a 'period' into a representation usable in a user-editable form.
  1.1073 -    In a "form period" representation, the end day of any date-level event is
  1.1074 -    presented in a "natural" form, not the iCalendar "day after" form.
  1.1075 -    """
  1.1076 -
  1.1077 -    if isinstance(period, EventPeriod):
  1.1078 -        return period.as_form_period()
  1.1079 -    elif isinstance(period, FormPeriod):
  1.1080 -        return period
  1.1081 -    else:
  1.1082 -        return event_period_from_period(period).as_form_period()
  1.1083 -
  1.1084 -def form_periods_from_periods(periods):
  1.1085 -    return map(form_period_from_period, periods)
  1.1086 -
  1.1087 -
  1.1088 -
  1.1089 -# Event period processing.
  1.1090 -
  1.1091 -def periods_from_updated_periods(updated_periods, fn):
  1.1092 -
  1.1093 -    """
  1.1094 -    Return periods from the given 'updated_periods' created using 'fn', setting
  1.1095 -    replacement, cancelled and recurrence identifier details.
  1.1096 -
  1.1097 -    This function should be used to produce editing-related periods from the
  1.1098 -    general updated periods provided by the client abstractions.
  1.1099 -    """
  1.1100 -
  1.1101 -    periods = []
  1.1102 -
  1.1103 -    for sp, p in updated_periods:
  1.1104 -
  1.1105 -        # Stored periods with corresponding current periods.
  1.1106 -
  1.1107 -        if p:
  1.1108 -            period = fn(p)
  1.1109 -
  1.1110 -            # Replacements are identified by comparing object identities, since
  1.1111 -            # a replacement will not be provided by the same object.
  1.1112 -
  1.1113 -            if sp is not p:
  1.1114 -                period.replacement = True
  1.1115 -
  1.1116 -        # Stored periods without corresponding current periods.
  1.1117 -
  1.1118 -        else:
  1.1119 -            period = fn(sp)
  1.1120 -            period.replacement = True
  1.1121 -            period.cancelled = True
  1.1122 -
  1.1123 -        # Replace the recurrence identifier with that of the original period.
  1.1124 -
  1.1125 -        period.recurrenceid = sp.get_recurrenceid()
  1.1126 -        periods.append(period)
  1.1127 -
  1.1128 -    return periods
  1.1129 -
  1.1130 -def event_periods_from_updated_periods(updated_periods):
  1.1131 -    return periods_from_updated_periods(updated_periods, event_period_from_period)
  1.1132 -
  1.1133 -def form_periods_from_updated_periods(updated_periods):
  1.1134 -    return periods_from_updated_periods(updated_periods, form_period_from_period)
  1.1135 -
  1.1136 -def periods_by_recurrence(periods):
  1.1137 -
  1.1138 -    """
  1.1139 -    Return a mapping from recurrence identifier to period for 'periods' along
  1.1140 -    with a collection of unmapped periods.
  1.1141 -    """
  1.1142 -
  1.1143 -    d = {}
  1.1144 -    new = []
  1.1145 -
  1.1146 -    for p in periods:
  1.1147 -        if not p.recurrenceid:
  1.1148 -            new.append(p)
  1.1149 -        else:
  1.1150 -            d[p.recurrenceid] = p
  1.1151 -
  1.1152 -    return d, new
  1.1153 -
  1.1154 -def combine_periods(old, new):
  1.1155 -
  1.1156 -    """
  1.1157 -    Combine 'old' and 'new' periods for comparison, making a list of (old, new)
  1.1158 -    updated period tuples.
  1.1159 -    """
  1.1160 -
  1.1161 -    old_by_recurrenceid, _new_periods = periods_by_recurrence(old)
  1.1162 -    new_by_recurrenceid, new_periods = periods_by_recurrence(new)
  1.1163 -
  1.1164 -    combined = []
  1.1165 -
  1.1166 -    for recurrenceid, op in old_by_recurrenceid.items():
  1.1167 -        np = new_by_recurrenceid.get(recurrenceid)
  1.1168 -
  1.1169 -        # Old period has corresponding new period that is not cancelled.
  1.1170 -
  1.1171 -        if np and not (np.cancelled and not op.cancelled):
  1.1172 -            combined.append((op, np))
  1.1173 -
  1.1174 -        # No corresponding new, uncancelled period.
  1.1175 -
  1.1176 -        else:
  1.1177 -            combined.append((op, None))
  1.1178 -
  1.1179 -    # New periods without corresponding old periods are genuinely new.
  1.1180 -
  1.1181 -    for np in new_periods:
  1.1182 -        combined.append((None, np))
  1.1183 -
  1.1184 -    # Note that new periods should not have recurrence identifiers, and if
  1.1185 -    # imported from other events, they should have such identifiers removed.
  1.1186 -
  1.1187 -    return combined
  1.1188 -
  1.1189 -def classify_periods(updated_periods):
  1.1190 -
  1.1191 -    """
  1.1192 -    Using the 'updated_periods', being a list of (stored, current) periods,
  1.1193 -    return a tuple containing collections of new, replaced, retained, cancelled
  1.1194 -    and obsolete periods.
  1.1195 -
  1.1196 -    Note that replaced and retained indicate the presence or absence of
  1.1197 -    differences between the original event periods and the current periods that
  1.1198 -    would need to be represented using separate recurrence instances, not
  1.1199 -    whether any editing operations have changed the periods.
  1.1200 -
  1.1201 -    Obsolete periods are those that have been replaced but not cancelled.
  1.1202 -    """
  1.1203 -
  1.1204 -    new = []
  1.1205 -    replaced = []
  1.1206 -    retained = []
  1.1207 -    cancelled = []
  1.1208 -    obsolete = []
  1.1209 -
  1.1210 -    for sp, p in updated_periods:
  1.1211 -
  1.1212 -        # Stored periods...
  1.1213 -
  1.1214 -        if sp:
  1.1215 -
  1.1216 -            # With cancelled or absent current periods.
  1.1217 -
  1.1218 -            if not p or p.cancelled:
  1.1219 -                cancelled.append(sp)
  1.1220 -
  1.1221 -            # With differing or replacement current periods.
  1.1222 -
  1.1223 -            elif p != sp or p.replacement:
  1.1224 -                replaced.append(p)
  1.1225 -                if not p.replacement:
  1.1226 -                    p.new_replacement = True
  1.1227 -                    obsolete.append(sp)
  1.1228 -
  1.1229 -            # With retained, not differing current periods.
  1.1230 -
  1.1231 -            else:
  1.1232 -                retained.append(p)
  1.1233 -                if p.new_replacement:
  1.1234 -                    p.new_replacement = False
  1.1235 -
  1.1236 -        # New periods without corresponding stored periods.
  1.1237 -
  1.1238 -        elif p:
  1.1239 -            new.append(p)
  1.1240 -
  1.1241 -    return new, replaced, retained, cancelled, obsolete
  1.1242 -
  1.1243 -def classify_period_changes(updated_periods):
  1.1244 -
  1.1245 -    """
  1.1246 -    Using the 'updated_periods', being a list of (original, current) periods,
  1.1247 -    return a tuple containing collections of modified, unmodified and removed
  1.1248 -    periods.
  1.1249 -    """
  1.1250 -
  1.1251 -    modified = []
  1.1252 -    unmodified = []
  1.1253 -    removed = []
  1.1254 -
  1.1255 -    for op, p in updated_periods:
  1.1256 -
  1.1257 -        # Test for periods cancelled, reinstated or changed, or left unmodified
  1.1258 -        # during editing.
  1.1259 -
  1.1260 -        if op:
  1.1261 -            if not op.cancelled and (not p or p.cancelled):
  1.1262 -                removed.append(op)
  1.1263 -            elif op.cancelled and not p.cancelled or p != op:
  1.1264 -                modified.append(p)
  1.1265 -            else:
  1.1266 -                unmodified.append(p)
  1.1267 -
  1.1268 -        # New periods are always modifications.
  1.1269 -
  1.1270 -        elif p:
  1.1271 -            modified.append(p)
  1.1272 -
  1.1273 -    return modified, unmodified, removed
  1.1274 -
  1.1275 -def classify_period_operations(new, replaced, retained, cancelled,
  1.1276 -                               obsolete, modified, removed,
  1.1277 -                               is_organiser, is_shared, is_changed):
  1.1278 -
  1.1279 -    """
  1.1280 -    Classify the operations for the update of an event. For updates modifying
  1.1281 -    shared events, return periods for descheduling and rescheduling (where these
  1.1282 -    operations can modify the event), and periods for exclusion and application
  1.1283 -    (where these operations redefine the event).
  1.1284 -
  1.1285 -    To define the new state of the event, details of the complete set of
  1.1286 -    unscheduled and rescheduled periods are also provided.
  1.1287 -    """
  1.1288 -
  1.1289 -    active_periods = new + replaced + retained
  1.1290 -
  1.1291 -    # Modified replaced and retained recurrences are used for incremental
  1.1292 -    # updates.
  1.1293 -
  1.1294 -    replaced_modified = select_recurrences(replaced, modified).values()
  1.1295 -    retained_modified = select_recurrences(retained, modified).values()
  1.1296 -
  1.1297 -    # Unmodified replaced and retained recurrences are used in the complete
  1.1298 -    # event summary.
  1.1299 -
  1.1300 -    replaced_unmodified = subtract_recurrences(replaced, modified).values()
  1.1301 -    retained_unmodified = subtract_recurrences(retained, modified).values()
  1.1302 -
  1.1303 -    # Obtain the removed periods in terms of existing periods. These are used in
  1.1304 -    # incremental updates.
  1.1305 -
  1.1306 -    cancelled_removed = select_recurrences(cancelled, removed).values()
  1.1307 -
  1.1308 -    # Reinstated periods are previously-cancelled periods that are now modified
  1.1309 -    # periods, and they appear in updates.
  1.1310 -
  1.1311 -    reinstated = select_recurrences(modified, cancelled).values()
  1.1312 -
  1.1313 -    # Get cancelled periods without reinstated periods. These appear in complete
  1.1314 -    # event summaries.
  1.1315 -
  1.1316 -    cancelled_unmodified = subtract_recurrences(cancelled, modified).values()
  1.1317 -
  1.1318 -    # Cancelled periods originating from rules must be excluded since there are
  1.1319 -    # no explicit instances to be deleted.
  1.1320 -
  1.1321 -    cancelled_rule = []
  1.1322 -    for p in cancelled_removed:
  1.1323 -        if p.origin == "RRULE":
  1.1324 -            cancelled_rule.append(p)
  1.1325 -
  1.1326 -    # Obsolete periods (replaced by other periods) originating from rules must
  1.1327 -    # be excluded if no explicit instance will be used to replace them.
  1.1328 -
  1.1329 -    obsolete_rule = []
  1.1330 -    for p in obsolete:
  1.1331 -        if p.origin == "RRULE":
  1.1332 -            obsolete_rule.append(p)
  1.1333 -
  1.1334 -    # As organiser...
  1.1335 -
  1.1336 -    if is_organiser:
  1.1337 -
  1.1338 -        # For unshared events...
  1.1339 -        # All modifications redefine the event.
  1.1340 -
  1.1341 -        # For shared events...
  1.1342 -        # New periods should cause the event to be redefined.
  1.1343 -        # Other changes should also cause event redefinition.
  1.1344 -        # Event redefinition should only occur if no replacement periods exist.
  1.1345 -        # Cancelled rule-originating periods must be excluded.
  1.1346 -
  1.1347 -        if not is_shared or new and not replaced:
  1.1348 -            to_set = active_periods
  1.1349 -            to_exclude = list(chain(cancelled_rule, obsolete_rule))
  1.1350 -            to_unschedule = []
  1.1351 -            to_reschedule = []
  1.1352 -            to_add = []
  1.1353 -            all_unscheduled = []
  1.1354 -            all_rescheduled = []
  1.1355 -
  1.1356 -        # Changed periods should be rescheduled separately.
  1.1357 -        # Removed periods should be cancelled separately.
  1.1358 -
  1.1359 -        else:
  1.1360 -            to_set = []
  1.1361 -            to_exclude = []
  1.1362 -            to_unschedule = cancelled_removed
  1.1363 -            to_reschedule = list(chain(replaced_modified, retained_modified, reinstated))
  1.1364 -            to_add = new
  1.1365 -            all_unscheduled = cancelled_unmodified
  1.1366 -            all_rescheduled = list(chain(replaced_unmodified, to_reschedule))
  1.1367 -
  1.1368 -    # As attendee...
  1.1369 -
  1.1370 -    else:
  1.1371 -        to_unschedule = []
  1.1372 -        to_add = []
  1.1373 -
  1.1374 -        # Changed periods without new or removed periods are proposed as
  1.1375 -        # separate changes. Parent event changes cause redefinition of the
  1.1376 -        # entire event.
  1.1377 -
  1.1378 -        if not new and not removed and not is_changed:
  1.1379 -            to_set = []
  1.1380 -            to_exclude = []
  1.1381 -            to_reschedule = list(chain(replaced_modified, retained_modified, reinstated))
  1.1382 -            all_unscheduled = list(cancelled_unmodified)
  1.1383 -            all_rescheduled = list(chain(replaced_unmodified, to_reschedule))
  1.1384 -
  1.1385 -        # Otherwise, the event is defined in terms of new periods and
  1.1386 -        # exceptions for removed periods or obsolete rule periods.
  1.1387 -
  1.1388 -        else:
  1.1389 -            to_set = active_periods
  1.1390 -            to_exclude = list(chain(cancelled, obsolete_rule))
  1.1391 -            to_reschedule = []
  1.1392 -            all_unscheduled = []
  1.1393 -            all_rescheduled = []
  1.1394 -
  1.1395 -    return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled
  1.1396 -
  1.1397 -def get_period_mapping(periods):
  1.1398 -
  1.1399 -    "Return a mapping of recurrence identifiers to the given 'periods."
  1.1400 -
  1.1401 -    d, new = periods_by_recurrence(periods)
  1.1402 -    return d
  1.1403 -
  1.1404 -def select_recurrences(source, selected):
  1.1405 -
  1.1406 -    "Restrict 'source' to the recurrences referenced by 'selected'."
  1.1407 -
  1.1408 -    mapping = get_period_mapping(source)
  1.1409 -
  1.1410 -    recurrenceids = get_recurrenceids(selected)
  1.1411 -    for recurrenceid in mapping.keys():
  1.1412 -        if not recurrenceid in recurrenceids:
  1.1413 -            del mapping[recurrenceid]
  1.1414 -    return mapping
  1.1415 -
  1.1416 -def subtract_recurrences(source, selected):
  1.1417 -
  1.1418 -    "Remove from 'source' the recurrences referenced by 'selected'."
  1.1419 -
  1.1420 -    mapping = get_period_mapping(source)
  1.1421 -
  1.1422 -    for recurrenceid in get_recurrenceids(selected):
  1.1423 -        if mapping.has_key(recurrenceid):
  1.1424 -            del mapping[recurrenceid]
  1.1425 -    return mapping
  1.1426 -
  1.1427 -def get_recurrenceids(periods):
  1.1428 -
  1.1429 -    "Return the recurrence identifiers employed by 'periods'."
  1.1430 -
  1.1431 -    return map(lambda p: p.get_recurrenceid(), periods)
  1.1432 -
  1.1433 -
  1.1434 +from imiptools.editing import FormPeriod
  1.1435  
  1.1436  # Form field extraction and serialisation.
  1.1437  
  1.1438 @@ -1673,51 +256,6 @@
  1.1439  
  1.1440  
  1.1441  
  1.1442 -# Attendee processing.
  1.1443 -
  1.1444 -def classify_attendee_changes(original, current):
  1.1445 -
  1.1446 -    """
  1.1447 -    Return categories of attendees given the 'original' and 'current'
  1.1448 -    collections of attendees.
  1.1449 -    """
  1.1450 -
  1.1451 -    new = {}
  1.1452 -    modified = {}
  1.1453 -    unmodified = {}
  1.1454 -
  1.1455 -    # Check current attendees against the original ones.
  1.1456 -
  1.1457 -    for attendee, attendee_attr in current.items():
  1.1458 -        original_attr = original.get(attendee)
  1.1459 -
  1.1460 -        # New attendee if missing original details.
  1.1461 -
  1.1462 -        if not original_attr:
  1.1463 -            new[attendee] = attendee_attr
  1.1464 -
  1.1465 -        # Details unchanged for existing attendee.
  1.1466 -
  1.1467 -        elif attendee_attr == original_attr:
  1.1468 -            unmodified[attendee] = attendee_attr
  1.1469 -
  1.1470 -        # Details changed for existing attendee.
  1.1471 -
  1.1472 -        else:
  1.1473 -            modified[attendee] = attendee_attr
  1.1474 -
  1.1475 -    removed = {}
  1.1476 -
  1.1477 -    # Check for removed attendees.
  1.1478 -
  1.1479 -    for attendee, attendee_attr in original.items():
  1.1480 -        if not current.has_key(attendee):
  1.1481 -            removed[attendee] = attendee_attr
  1.1482 -
  1.1483 -    return new, modified, unmodified, removed
  1.1484 -
  1.1485 -
  1.1486 -
  1.1487  # Utilities.
  1.1488  
  1.1489  def filter_duplicates(l):