# HG changeset patch # User Paul Boddie # Date 1508325899 -7200 # Node ID 822261876a739c35bf9f5adf822e1b47f1f0fba3 # Parent 6250dc100911e7ebf2e888cfb7dee599f257eb17 Moved generic editing functionality into a separate module. diff -r 6250dc100911 -r 822261876a73 imiptools/editing.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/editing.py Wed Oct 18 13:24:59 2017 +0200 @@ -0,0 +1,1484 @@ +#!/usr/bin/env python + +""" +User interface data abstractions. + +Copyright (C) 2014, 2015, 2017 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from collections import OrderedDict +from copy import copy +from datetime import datetime, timedelta +from imiptools.client import ClientForObject +from imiptools.data import get_main_period +from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ + format_datetime, get_datetime, \ + get_datetime_attributes, get_end_of_day, \ + to_date, to_utc_datetime, to_timezone +from imiptools.period import get_overlapping_members, RecurringPeriod +from itertools import chain + +# General editing abstractions. + +class State: + + "Manage editing state." + + def __init__(self, callables): + + """ + Define state variable initialisation using the given 'callables', which + is a mapping that defines a callable for each variable name that is + invoked when the variable is first requested. + """ + + self.state = {} + self.original = {} + self.callables = callables + + def get_callable(self, key): + return self.callables.get(key, lambda: None) + + def ensure_original(self, key): + + "Ensure the original state for the given 'key'." + + if not self.original.has_key(key): + self.original[key] = self.get_callable(key)() + + def get_original(self, key): + + "Return the original state for the given 'key'." + + self.ensure_original(key) + return copy(self.original[key]) + + def get(self, key, reset=False): + + """ + Return state for the given 'key', using the configured callable to + compute and set the state if no state is already defined. + + If 'reset' is set to a true value, compute and return the state using + the configured callable regardless of any existing state. + """ + + if reset or not self.state.has_key(key): + self.state[key] = self.get_original(key) + + return self.state[key] + + def set(self, key, value): + + "Set the state of 'key' to 'value'." + + self.ensure_original(key) + self.state[key] = value + + def has_changed(self, key): + + "Return whether 'key' has changed during editing." + + return self.get_original(key) != self.get(key) + + # Dictionary emulation methods. + + def __getitem__(self, key): + return self.get(key) + + def __setitem__(self, key, value): + self.set(key, value) + + + +# Object editing abstractions. + +class EditingClient(ClientForObject): + + "A simple calendar client." + + def __init__(self, user, messenger, store, journal, preferences_dir): + ClientForObject.__init__(self, None, user, messenger, store, + journal=journal, + preferences_dir=preferences_dir) + self.reset() + + # Editing state. + + def reset(self): + + "Reset the editing state." + + self.state = State({ + "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []), + "organiser" : lambda: self.obj.get_value("ORGANIZER"), + "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()), + "suggested_attendees" : self.get_suggested_attendees, + "suggested_periods" : self.get_suggested_periods, + "summary" : lambda: self.obj.get_value("SUMMARY"), + }) + + # Access to stored and current information. + + def get_stored_periods(self): + + """ + Return the stored, unrevised, integral periods for the event, excluding + revisions from separate recurrence instances. + """ + + return event_periods_from_periods(self.get_periods()) + + def get_unedited_periods(self): + + """ + Return the original, unedited periods including revisions from separate + recurrence instances. + """ + + return event_periods_from_updated_periods(self.get_updated_periods()) + + def get_counters(self): + + "Return a counter-proposal mapping from attendees to objects." + + d = {} + + # Get counter-proposals for the specific object. + + recurrenceids = [self.recurrenceid] + + # And for all recurrences associated with a parent object. + + if not self.recurrenceid: + recurrenceids += self.store.get_counter_recurrences(self.user, self.uid) + + # Map attendees to objects. + + for recurrenceid in recurrenceids: + attendees = self.store.get_counters(self.user, self.uid, recurrenceid) + for attendee in attendees: + if not d.has_key(attendee): + d[attendee] = [] + d[attendee].append(self.get_stored_object(self.uid, recurrenceid, "counters", attendee)) + + return d + + def get_suggested_attendees(self): + + "For all counter-proposals, return suggested attendee items." + + existing = self.state.get("attendees") + l = [] + for attendee, objects in self.get_counters().items(): + for obj in objects: + for suggested, attr in obj.get_items("ATTENDEE"): + if suggested not in existing: + l.append((attendee, (suggested, attr))) + return l + + def get_suggested_periods(self): + + "For all counter-proposals, return suggested event periods." + + existing = self.state.get("periods") + + # Get active periods for filtering of suggested periods. + + active = [] + for p in existing: + if not p.cancelled: + active.append(p) + + suggested = [] + + for attendee, objects in self.get_counters().items(): + + # For each object, obtain suggested periods. + + for obj in objects: + + # Obtain the current periods for the object providing the + # suggested periods. + + updated = self.get_updated_periods(obj) + suggestions = event_periods_from_updated_periods(updated) + + # Compare current periods with suggested periods. + + new = set(suggestions).difference(active) + + # Treat each specific recurrence as affecting only the original + # period. + + if obj.get_recurrenceid(): + removed = [] + else: + removed = set(active).difference(suggestions) + + # Associate new and removed periods with the attendee. + + for period in new: + suggested.append((attendee, period, "add")) + + for period in removed: + suggested.append((attendee, period, "remove")) + + return suggested + + # Validation methods. + + def get_checked_periods(self): + + """ + Check the edited periods and return objects representing them, setting + the "periods" state. If errors occur, raise an exception and set the + "errors" state. + """ + + self.state["period_errors"] = errors = {} + + # Basic validation. + + try: + periods = event_periods_from_periods(self.state.get("periods")) + + except PeriodError, exc: + + # Obtain error and period index details from the exception, + # collecting errors for each index position. + + for err, index in exc.args: + l = errors.get(index) + if not l: + l = errors[index] = [] + l.append(err) + raise + + # Check for overlapping periods. + + overlapping = get_overlapping_members(periods) + + for period in overlapping: + for index, p in enumerate(periods): + if period is p: + errors[index] = ["overlap"] + + if overlapping: + raise PeriodError + + self.state["periods"] = form_periods_from_periods(periods) + return periods + + # Update result computation. + + def classify_attendee_changes(self): + + "Classify the attendees in the event." + + original = self.state.get_original("attendees") + current = self.state.get("attendees") + return classify_attendee_changes(original, current) + + def classify_attendee_operations(self): + + "Classify attendee update operations." + + new, modified, unmodified, removed = self.classify_attendee_changes() + + if self.is_organiser(): + to_invite = new + to_cancel = removed + to_modify = modified + else: + to_invite = new + to_cancel = {} + to_modify = modified + + return to_invite, to_cancel, to_modify + + def classify_period_changes(self): + + "Classify changes in the updated periods for the edited event." + + updated = self.combine_periods_for_comparison() + return classify_period_changes(updated) + + def classify_periods(self): + + "Classify the updated periods for the edited event." + + updated = self.combine_periods() + return classify_periods(updated) + + def combine_periods(self): + + "Combine stored and checked edited periods to make updated periods." + + stored = self.get_stored_periods() + current = self.get_checked_periods() + return combine_periods(stored, current) + + def combine_periods_for_comparison(self): + + "Combine unedited and checked edited periods to make updated periods." + + original = self.get_unedited_periods() + current = self.get_checked_periods() + return combine_periods(original, current) + + def classify_period_operations(self, is_changed=False): + + "Classify period update operations." + + new, replaced, retained, cancelled, obsolete = self.classify_periods() + + modified, unmodified, removed = self.classify_period_changes() + + is_organiser = self.is_organiser() + is_shared = self.obj.is_shared() + + return classify_period_operations(new, replaced, retained, cancelled, + obsolete, modified, removed, + is_organiser, is_shared, is_changed) + + def properties_changed(self): + + "Test for changes in event details." + + is_changed = [] + + for name in ["summary"]: + if self.state.has_changed(name): + is_changed.append(name) + + return is_changed + + def finish(self): + + "Finish editing, writing edited details to the object." + + if self.state.get("finished"): + return + + is_changed = self.properties_changed() + + # Determine attendee modifications. + + self.state["attendee_operations"] = \ + to_invite, to_cancel, to_modify = \ + self.classify_attendee_operations() + + self.state["attendees_to_cancel"] = to_cancel + + # Determine period modification operations. + # Use property changes and attendee suggestions to affect the result for + # attendee responses. + + is_changed = is_changed or to_invite + + self.state["period_operations"] = \ + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = \ + self.classify_period_operations(is_changed) + + # Determine whole event update status. + + is_changed = is_changed or to_set + + # Update event details. + + if self.can_edit_properties(): + self.obj.set_value("SUMMARY", self.state.get("summary")) + + self.update_attendees(to_invite, to_cancel, to_modify) + self.update_event_from_periods(to_set, to_exclude) + + # Classify the nature of any update. + + if is_changed: + self.state["changed"] = "complete" + elif to_reschedule or to_unschedule or to_add: + self.state["changed"] = "incremental" + + self.state["finished"] = self.update_event_version(is_changed) + + # Update preparation. + + def have_update(self): + + "Return whether an update can be prepared and sent." + + return not self.is_organiser() or \ + not self.obj.is_shared() or \ + self.obj.is_shared() and self.state.get("changed") and \ + self.have_other_attendees() + + def have_other_attendees(self): + + "Return whether any attendees other than the user are present." + + attendees = self.state.get("attendees") + return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1) + + def prepare_cancel_message(self): + + "Prepare the cancel message for uninvited attendees." + + to_cancel = self.state.get("attendees_to_cancel") + return self.make_cancel_message(to_cancel) + + def prepare_publish_message(self): + + "Prepare the publishing message for the updated event." + + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = self.state.get("period_operations") + + return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add) + + def prepare_update_message(self): + + "Prepare the update message for the updated event." + + if not self.have_update(): + return None + + # Obtain operation details. + + to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ + all_unscheduled, all_rescheduled = self.state.get("period_operations") + + # Prepare the message. + + recipients = self.get_recipients() + update_parent = self.state["changed"] == "complete" + + if self.is_organiser(): + return self.make_update_message(recipients, update_parent, + to_unschedule, to_reschedule, + all_unscheduled, all_rescheduled, + to_add) + else: + return self.make_response_message(recipients, update_parent, + all_rescheduled, to_reschedule) + + # Modification methods. + + def add_attendee(self, uri=None): + + "Add a blank attendee." + + attendees = self.state.get("attendees") + attendees[uri or ""] = {"PARTSTAT" : "NEEDS-ACTION"} + + def add_suggested_attendee(self, index): + + "Add the suggested attendee at 'index' to the event." + + attendees = self.state.get("attendees") + suggested_attendees = self.state.get("suggested_attendees") + try: + attendee, (suggested, attr) = suggested_attendees[index] + self.add_attendee(suggested) + except IndexError: + pass + + def add_period(self): + + "Add a copy of the main period as a new recurrence." + + current = self.state.get("periods") + new = get_main_period(current).copy() + new.origin = "RDATE" + new.replacement = False + new.recurrenceid = False + new.cancelled = False + current.append(new) + + def apply_suggested_period(self, index): + + "Apply the suggested period at 'index' to the event." + + current = self.state.get("periods") + suggested = self.state.get("suggested_periods") + + try: + attendee, period, operation = suggested[index] + period = form_period_from_period(period) + + # Cancel any removed periods. + + if operation == "remove": + for index, p in enumerate(current): + if p == period: + self.cancel_periods([index]) + break + + # Add or replace any other suggestions. + + elif operation == "add": + + # Make the status of the period compatible. + + period.cancelled = False + period.origin = "DTSTART-RECUR" + + # Either replace or add the period. + + recurrenceid = period.get_recurrenceid() + + for i, p in enumerate(current): + if p.get_recurrenceid() == recurrenceid: + current[i] = period + break + + # Add as a new period. + + else: + period.recurrenceid = None + current.append(period) + + except IndexError: + pass + + def cancel_periods(self, indexes, cancelled=True): + + """ + Set cancellation state for periods with the given 'indexes', indicating + 'cancelled' as a true or false value. New periods will be removed if + cancelled. + """ + + periods = self.state.get("periods") + to_remove = [] + removed = 0 + + for index in indexes: + p = periods[index] + + # Make replacements from existing periods and cancel them. + + if p.recurrenceid: + p.replacement = True + p.cancelled = cancelled + + # Remove new periods completely. + + elif cancelled: + to_remove.append(index - removed) + removed += 1 + + for index in to_remove: + del periods[index] + + def can_edit_attendance(self): + + "Return whether the organiser's attendance can be edited." + + return self.state.get("attendees").has_key(self.user) + + def edit_attendance(self, partstat): + + "Set the 'partstat' of the current user, if attending." + + attendees = self.state.get("attendees") + attr = attendees.get(self.user) + + # Set the attendance for the user, if attending. + + if attr is not None: + new_attr = {} + new_attr.update(attr) + new_attr["PARTSTAT"] = partstat + attendees[self.user] = new_attr + + def can_edit_attendee(self, index): + + """ + Return whether the attendee at 'index' can be edited, requiring either + the organiser and an unshared event, or a new attendee. + """ + + attendees = self.state.get("attendees") + attendee = attendees.keys()[index] + + try: + attr = attendees[attendee] + if self.is_organiser() and not self.obj.is_shared() or not attr: + return (attendee, attr) + except IndexError: + pass + + return None + + def can_remove_attendee(self, index): + + """ + Return whether the attendee at 'index' can be removed, requiring either + the organiser or a new attendee. + """ + + attendees = self.state.get("attendees") + attendee = attendees.keys()[index] + + try: + attr = attendees[attendee] + if self.is_organiser() or not attr: + return (attendee, attr) + except IndexError: + pass + + return None + + def remove_attendees(self, indexes): + + "Remove attendee at 'index'." + + attendees = self.state.get("attendees") + to_remove = [] + + for index in indexes: + attendee_item = self.can_remove_attendee(index) + if attendee_item: + attendee, attr = attendee_item + to_remove.append(attendee) + + for key in to_remove: + del attendees[key] + + def can_edit_period(self, index): + + """ + Return the period at 'index' for editing or None if it cannot be edited. + """ + + try: + return self.state.get("periods")[index] + except IndexError: + return None + + def can_edit_properties(self): + + "Return whether general event properties can be edited." + + return True + + + +# Period-related abstractions. + +class PeriodError(Exception): + pass + +class EditablePeriod(RecurringPeriod): + + "An editable period tracking the identity of any original period." + + def _get_recurrenceid_item(self): + + # Convert any stored identifier to the current time zone. + # NOTE: This should not be necessary, but is done for consistency with + # NOTE: the datetime properties. + + dt = get_datetime(self.recurrenceid) + dt = to_timezone(dt, self.tzid) + return dt, get_datetime_attributes(dt) + + def get_recurrenceid(self): + + """ + Return a recurrence identity to be used to associate stored periods with + edited periods. + """ + + if not self.recurrenceid: + return RecurringPeriod.get_recurrenceid(self) + return self.recurrenceid + + def get_recurrenceid_item(self): + + """ + Return a recurrence identifier value and datetime properties for use in + specifying the RECURRENCE-ID property. + """ + + if not self.recurrenceid: + return RecurringPeriod.get_recurrenceid_item(self) + return self._get_recurrenceid_item() + +class EventPeriod(EditablePeriod): + + """ + A simple period plus attribute details, compatible with RecurringPeriod, and + intended to represent information obtained from an iCalendar resource. + """ + + def __init__(self, start, end, tzid=None, origin=None, start_attr=None, + end_attr=None, form_start=None, form_end=None, + replacement=False, cancelled=False, recurrenceid=None): + + """ + Initialise a period with the given 'start' and 'end' datetimes. + + The optional 'tzid' provides time zone information, and the optional + 'origin' indicates the kind of period this object describes. + + The optional 'start_attr' and 'end_attr' provide metadata for the start + and end datetimes respectively, and 'form_start' and 'form_end' are + values provided as textual input. + + The 'replacement' flag indicates whether the period is provided by a + separate recurrence instance. + + The 'cancelled' flag indicates whether a separate recurrence is + cancelled. + + The 'recurrenceid' describes the original identity of the period, + regardless of whether it is separate or not. + """ + + EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) + self.form_start = form_start + self.form_end = form_end + + # Information about whether a separate recurrence provides this period + # and the original period identity. + + self.replacement = replacement + self.cancelled = cancelled + self.recurrenceid = recurrenceid + + # Additional editing state. + + self.new_replacement = False + + def as_tuple(self): + return self.start, self.end, self.tzid, self.origin, self.start_attr, \ + self.end_attr, self.form_start, self.form_end, self.replacement, \ + self.cancelled, self.recurrenceid + + def __repr__(self): + return "EventPeriod%r" % (self.as_tuple(),) + + def copy(self): + return EventPeriod(*self.as_tuple()) + + def as_event_period(self, index=None): + return self + + def get_start_item(self): + return self.get_start(), self.get_start_attr() + + def get_end_item(self): + return self.get_end(), self.get_end_attr() + + # Form data compatibility methods. + + def get_form_start(self): + if not self.form_start: + self.form_start = self.get_form_date(self.get_start(), self.start_attr) + return self.form_start + + def get_form_end(self): + if not self.form_end: + self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) + return self.form_end + + def as_form_period(self): + return FormPeriod( + self.get_form_start(), + self.get_form_end(), + isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), + isinstance(self.start, datetime) or isinstance(self.end, datetime), + self.tzid, + self.origin, + self.replacement, + self.cancelled, + self.recurrenceid + ) + + def get_form_date(self, dt, attr=None): + return FormDate( + format_datetime(to_date(dt)), + isinstance(dt, datetime) and str(dt.hour) or None, + isinstance(dt, datetime) and str(dt.minute) or None, + isinstance(dt, datetime) and str(dt.second) or None, + attr and attr.get("TZID") or None, + dt, attr + ) + +class FormPeriod(EditablePeriod): + + "A period whose information originates from a form." + + def __init__(self, start, end, end_enabled=True, times_enabled=True, + tzid=None, origin=None, replacement=False, cancelled=False, + recurrenceid=None): + self.start = start + self.end = end + self.end_enabled = end_enabled + self.times_enabled = times_enabled + self.tzid = tzid + self.origin = origin + self.replacement = replacement + self.cancelled = cancelled + self.recurrenceid = recurrenceid + self.new_replacement = False + + def as_tuple(self): + return self.start, self.end, self.end_enabled, self.times_enabled, \ + self.tzid, self.origin, self.replacement, self.cancelled, \ + self.recurrenceid + + def __repr__(self): + return "FormPeriod%r" % (self.as_tuple(),) + + def copy(self): + args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:] + return FormPeriod(*args) + + def as_event_period(self, index=None): + + """ + Return a converted version of this object as an event period suitable + for iCalendar usage. If 'index' is indicated, include it in any error + raised in the conversion process. + """ + + dtstart, dtstart_attr = self.get_start_item() + if not dtstart: + if index is not None: + raise PeriodError(("dtstart", index)) + else: + raise PeriodError("dtstart") + + dtend, dtend_attr = self.get_end_item() + if not dtend: + if index is not None: + raise PeriodError(("dtend", index)) + else: + raise PeriodError("dtend") + + if dtstart > dtend: + if index is not None: + raise PeriodError(("dtstart", index), ("dtend", index)) + else: + raise PeriodError("dtstart", "dtend") + + return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, + self.origin, dtstart_attr, dtend_attr, + self.start, self.end, self.replacement, + self.cancelled, self.recurrenceid) + + # Period data methods. + + def get_start(self): + return self.start and self.start.as_datetime(self.times_enabled) or None + + def get_end(self): + + # Handle specified end datetimes. + + if self.end_enabled: + dtend = self.end.as_datetime(self.times_enabled) + if not dtend: + return None + + # Handle same day times. + + elif self.times_enabled: + formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) + dtend = formdate.as_datetime(self.times_enabled) + if not dtend: + return None + + # Otherwise, treat the end date as the start date. Datetimes are + # handled by making the event occupy the rest of the day. + + else: + dtstart, dtstart_attr = self.get_start_item() + if dtstart: + if isinstance(dtstart, datetime): + dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) + else: + dtend = dtstart + else: + return None + + return dtend + + def get_start_attr(self): + return self.start and self.start.get_attributes(self.times_enabled) or {} + + def get_end_attr(self): + return self.end and self.end.get_attributes(self.times_enabled) or {} + + # Form data methods. + + def get_form_start(self): + return self.start + + def get_form_end(self): + return self.end + + def as_form_period(self): + return self + +class FormDate: + + "Date information originating from form information." + + def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): + self.date = date + self.hour = hour + self.minute = minute + self.second = second + self.tzid = tzid + self.dt = dt + self.attr = attr + + def as_tuple(self): + return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr + + def copy(self): + return FormDate(*self.as_tuple()) + + def reset(self): + self.dt = None + + def __repr__(self): + return "FormDate%r" % (self.as_tuple(),) + + def get_component(self, value): + return (value or "").rjust(2, "0")[:2] + + def get_hour(self): + return self.get_component(self.hour) + + def get_minute(self): + return self.get_component(self.minute) + + def get_second(self): + return self.get_component(self.second) + + def get_date_string(self): + return self.date or "" + + def get_datetime_string(self): + if not self.date: + return "" + + hour = self.hour; minute = self.minute; second = self.second + + if hour or minute or second: + time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) + else: + time = "" + + return "%s%s" % (self.date, time) + + def get_tzid(self): + return self.tzid + + def as_datetime(self, with_time=True): + + """ + Return a datetime for this object if one is provided or can be produced. + """ + + # Return any original datetime details. + + if self.dt: + return self.dt + + # Otherwise, construct a datetime. + + s, attr = self.as_datetime_item(with_time) + if not s: + return None + + # An erroneous datetime will yield None as result. + + try: + return get_datetime(s, attr) + except ValueError: + return None + + def as_datetime_item(self, with_time=True): + + """ + Return a (datetime string, attr) tuple for the datetime information + provided by this object, where both tuple elements will be None if no + suitable date or datetime information exists. + """ + + s = None + if with_time: + s = self.get_datetime_string() + attr = self.get_attributes(True) + if not s: + s = self.get_date_string() + attr = self.get_attributes(False) + if not s: + return None, None + return s, attr + + def get_attributes(self, with_time=True): + + "Return attributes for the date or datetime represented by this object." + + if with_time: + return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} + else: + return {"VALUE" : "DATE"} + +def event_period_from_period(period, index=None): + + """ + Convert a 'period' to one suitable for use in an iCalendar representation. + In an "event period" representation, the end day of any date-level event is + encoded as the "day after" the last day actually involved in the event. + """ + + if isinstance(period, EventPeriod): + return period + elif isinstance(period, FormPeriod): + return period.as_event_period(index) + else: + dtstart, dtstart_attr = period.get_start_item() + dtend, dtend_attr = period.get_end_item() + + if not isinstance(period, RecurringPeriod): + dtend = end_date_to_calendar(dtend) + + return EventPeriod(dtstart, dtend, period.tzid, period.origin, + dtstart_attr, dtend_attr, + recurrenceid=format_datetime(to_utc_datetime(dtstart))) + +def event_periods_from_periods(periods): + return map(event_period_from_period, periods, range(0, len(periods))) + +def form_period_from_period(period): + + """ + Convert a 'period' into a representation usable in a user-editable form. + In a "form period" representation, the end day of any date-level event is + presented in a "natural" form, not the iCalendar "day after" form. + """ + + if isinstance(period, EventPeriod): + return period.as_form_period() + elif isinstance(period, FormPeriod): + return period + else: + return event_period_from_period(period).as_form_period() + +def form_periods_from_periods(periods): + return map(form_period_from_period, periods) + + + +# Event period processing. + +def periods_from_updated_periods(updated_periods, fn): + + """ + Return periods from the given 'updated_periods' created using 'fn', setting + replacement, cancelled and recurrence identifier details. + + This function should be used to produce editing-related periods from the + general updated periods provided by the client abstractions. + """ + + periods = [] + + for sp, p in updated_periods: + + # Stored periods with corresponding current periods. + + if p: + period = fn(p) + + # Replacements are identified by comparing object identities, since + # a replacement will not be provided by the same object. + + if sp is not p: + period.replacement = True + + # Stored periods without corresponding current periods. + + else: + period = fn(sp) + period.replacement = True + period.cancelled = True + + # Replace the recurrence identifier with that of the original period. + + period.recurrenceid = sp.get_recurrenceid() + periods.append(period) + + return periods + +def event_periods_from_updated_periods(updated_periods): + return periods_from_updated_periods(updated_periods, event_period_from_period) + +def form_periods_from_updated_periods(updated_periods): + return periods_from_updated_periods(updated_periods, form_period_from_period) + +def periods_by_recurrence(periods): + + """ + Return a mapping from recurrence identifier to period for 'periods' along + with a collection of unmapped periods. + """ + + d = {} + new = [] + + for p in periods: + if not p.recurrenceid: + new.append(p) + else: + d[p.recurrenceid] = p + + return d, new + +def combine_periods(old, new): + + """ + Combine 'old' and 'new' periods for comparison, making a list of (old, new) + updated period tuples. + """ + + old_by_recurrenceid, _new_periods = periods_by_recurrence(old) + new_by_recurrenceid, new_periods = periods_by_recurrence(new) + + combined = [] + + for recurrenceid, op in old_by_recurrenceid.items(): + np = new_by_recurrenceid.get(recurrenceid) + + # Old period has corresponding new period that is not cancelled. + + if np and not (np.cancelled and not op.cancelled): + combined.append((op, np)) + + # No corresponding new, uncancelled period. + + else: + combined.append((op, None)) + + # New periods without corresponding old periods are genuinely new. + + for np in new_periods: + combined.append((None, np)) + + # Note that new periods should not have recurrence identifiers, and if + # imported from other events, they should have such identifiers removed. + + return combined + +def classify_periods(updated_periods): + + """ + Using the 'updated_periods', being a list of (stored, current) periods, + return a tuple containing collections of new, replaced, retained, cancelled + and obsolete periods. + + Note that replaced and retained indicate the presence or absence of + differences between the original event periods and the current periods that + would need to be represented using separate recurrence instances, not + whether any editing operations have changed the periods. + + Obsolete periods are those that have been replaced but not cancelled. + """ + + new = [] + replaced = [] + retained = [] + cancelled = [] + obsolete = [] + + for sp, p in updated_periods: + + # Stored periods... + + if sp: + + # With cancelled or absent current periods. + + if not p or p.cancelled: + cancelled.append(sp) + + # With differing or replacement current periods. + + elif p != sp or p.replacement: + replaced.append(p) + if not p.replacement: + p.new_replacement = True + obsolete.append(sp) + + # With retained, not differing current periods. + + else: + retained.append(p) + if p.new_replacement: + p.new_replacement = False + + # New periods without corresponding stored periods. + + elif p: + new.append(p) + + return new, replaced, retained, cancelled, obsolete + +def classify_period_changes(updated_periods): + + """ + Using the 'updated_periods', being a list of (original, current) periods, + return a tuple containing collections of modified, unmodified and removed + periods. + """ + + modified = [] + unmodified = [] + removed = [] + + for op, p in updated_periods: + + # Test for periods cancelled, reinstated or changed, or left unmodified + # during editing. + + if op: + if not op.cancelled and (not p or p.cancelled): + removed.append(op) + elif op.cancelled and not p.cancelled or p != op: + modified.append(p) + else: + unmodified.append(p) + + # New periods are always modifications. + + elif p: + modified.append(p) + + return modified, unmodified, removed + +def classify_period_operations(new, replaced, retained, cancelled, + obsolete, modified, removed, + is_organiser, is_shared, is_changed): + + """ + Classify the operations for the update of an event. For updates modifying + shared events, return periods for descheduling and rescheduling (where these + operations can modify the event), and periods for exclusion and application + (where these operations redefine the event). + + To define the new state of the event, details of the complete set of + unscheduled and rescheduled periods are also provided. + """ + + active_periods = new + replaced + retained + + # Modified replaced and retained recurrences are used for incremental + # updates. + + replaced_modified = select_recurrences(replaced, modified).values() + retained_modified = select_recurrences(retained, modified).values() + + # Unmodified replaced and retained recurrences are used in the complete + # event summary. + + replaced_unmodified = subtract_recurrences(replaced, modified).values() + retained_unmodified = subtract_recurrences(retained, modified).values() + + # Obtain the removed periods in terms of existing periods. These are used in + # incremental updates. + + cancelled_removed = select_recurrences(cancelled, removed).values() + + # Reinstated periods are previously-cancelled periods that are now modified + # periods, and they appear in updates. + + reinstated = select_recurrences(modified, cancelled).values() + + # Get cancelled periods without reinstated periods. These appear in complete + # event summaries. + + cancelled_unmodified = subtract_recurrences(cancelled, modified).values() + + # Cancelled periods originating from rules must be excluded since there are + # no explicit instances to be deleted. + + cancelled_rule = [] + for p in cancelled_removed: + if p.origin == "RRULE": + cancelled_rule.append(p) + + # Obsolete periods (replaced by other periods) originating from rules must + # be excluded if no explicit instance will be used to replace them. + + obsolete_rule = [] + for p in obsolete: + if p.origin == "RRULE": + obsolete_rule.append(p) + + # As organiser... + + if is_organiser: + + # For unshared events... + # All modifications redefine the event. + + # For shared events... + # New periods should cause the event to be redefined. + # Other changes should also cause event redefinition. + # Event redefinition should only occur if no replacement periods exist. + # Cancelled rule-originating periods must be excluded. + + if not is_shared or new and not replaced: + to_set = active_periods + to_exclude = list(chain(cancelled_rule, obsolete_rule)) + to_unschedule = [] + to_reschedule = [] + to_add = [] + all_unscheduled = [] + all_rescheduled = [] + + # Changed periods should be rescheduled separately. + # Removed periods should be cancelled separately. + + else: + to_set = [] + to_exclude = [] + to_unschedule = cancelled_removed + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) + to_add = new + all_unscheduled = cancelled_unmodified + all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) + + # As attendee... + + else: + to_unschedule = [] + to_add = [] + + # Changed periods without new or removed periods are proposed as + # separate changes. Parent event changes cause redefinition of the + # entire event. + + if not new and not removed and not is_changed: + to_set = [] + to_exclude = [] + to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) + all_unscheduled = list(cancelled_unmodified) + all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) + + # Otherwise, the event is defined in terms of new periods and + # exceptions for removed periods or obsolete rule periods. + + else: + to_set = active_periods + to_exclude = list(chain(cancelled, obsolete_rule)) + to_reschedule = [] + all_unscheduled = [] + all_rescheduled = [] + + return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled + +def get_period_mapping(periods): + + "Return a mapping of recurrence identifiers to the given 'periods." + + d, new = periods_by_recurrence(periods) + return d + +def select_recurrences(source, selected): + + "Restrict 'source' to the recurrences referenced by 'selected'." + + mapping = get_period_mapping(source) + + recurrenceids = get_recurrenceids(selected) + for recurrenceid in mapping.keys(): + if not recurrenceid in recurrenceids: + del mapping[recurrenceid] + return mapping + +def subtract_recurrences(source, selected): + + "Remove from 'source' the recurrences referenced by 'selected'." + + mapping = get_period_mapping(source) + + for recurrenceid in get_recurrenceids(selected): + if mapping.has_key(recurrenceid): + del mapping[recurrenceid] + return mapping + +def get_recurrenceids(periods): + + "Return the recurrence identifiers employed by 'periods'." + + return map(lambda p: p.get_recurrenceid(), periods) + + + +# Attendee processing. + +def classify_attendee_changes(original, current): + + """ + Return categories of attendees given the 'original' and 'current' + collections of attendees. + """ + + new = {} + modified = {} + unmodified = {} + + # Check current attendees against the original ones. + + for attendee, attendee_attr in current.items(): + original_attr = original.get(attendee) + + # New attendee if missing original details. + + if not original_attr: + new[attendee] = attendee_attr + + # Details unchanged for existing attendee. + + elif attendee_attr == original_attr: + unmodified[attendee] = attendee_attr + + # Details changed for existing attendee. + + else: + modified[attendee] = attendee_attr + + removed = {} + + # Check for removed attendees. + + for attendee, attendee_attr in original.items(): + if not current.has_key(attendee): + removed[attendee] = attendee_attr + + return new, modified, unmodified, removed + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 6250dc100911 -r 822261876a73 imipweb/data.py --- a/imipweb/data.py Wed Oct 18 01:03:42 2017 +0200 +++ b/imipweb/data.py Wed Oct 18 13:24:59 2017 +0200 @@ -1,7 +1,7 @@ #!/usr/bin/env python """ -User interface data abstractions. +Web user interface operations. Copyright (C) 2014, 2015, 2017 Paul Boddie @@ -19,1424 +19,7 @@ this program. If not, see . """ -from collections import OrderedDict -from copy import copy -from datetime import datetime, timedelta -from imiptools.client import ClientForObject -from imiptools.data import get_main_period -from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ - format_datetime, get_datetime, \ - get_datetime_attributes, get_end_of_day, \ - to_date, to_utc_datetime, to_timezone -from imiptools.period import get_overlapping_members, RecurringPeriod -from itertools import chain - -# General editing abstractions. - -class State: - - "Manage editing state." - - def __init__(self, callables): - - """ - Define state variable initialisation using the given 'callables', which - is a mapping that defines a callable for each variable name that is - invoked when the variable is first requested. - """ - - self.state = {} - self.original = {} - self.callables = callables - - def get_callable(self, key): - return self.callables.get(key, lambda: None) - - def ensure_original(self, key): - - "Ensure the original state for the given 'key'." - - if not self.original.has_key(key): - self.original[key] = self.get_callable(key)() - - def get_original(self, key): - - "Return the original state for the given 'key'." - - self.ensure_original(key) - return copy(self.original[key]) - - def get(self, key, reset=False): - - """ - Return state for the given 'key', using the configured callable to - compute and set the state if no state is already defined. - - If 'reset' is set to a true value, compute and return the state using - the configured callable regardless of any existing state. - """ - - if reset or not self.state.has_key(key): - self.state[key] = self.get_original(key) - - return self.state[key] - - def set(self, key, value): - - "Set the state of 'key' to 'value'." - - self.ensure_original(key) - self.state[key] = value - - def has_changed(self, key): - - "Return whether 'key' has changed during editing." - - return self.get_original(key) != self.get(key) - - # Dictionary emulation methods. - - def __getitem__(self, key): - return self.get(key) - - def __setitem__(self, key, value): - self.set(key, value) - - - -# Object editing abstractions. - -class EditingClient(ClientForObject): - - "A simple calendar client." - - def __init__(self, user, messenger, store, journal, preferences_dir): - ClientForObject.__init__(self, None, user, messenger, store, - journal=journal, - preferences_dir=preferences_dir) - self.reset() - - # Editing state. - - def reset(self): - - "Reset the editing state." - - self.state = State({ - "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []), - "organiser" : lambda: self.obj.get_value("ORGANIZER"), - "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()), - "suggested_attendees" : self.get_suggested_attendees, - "suggested_periods" : self.get_suggested_periods, - "summary" : lambda: self.obj.get_value("SUMMARY"), - }) - - # Access to stored and current information. - - def get_stored_periods(self): - - """ - Return the stored, unrevised, integral periods for the event, excluding - revisions from separate recurrence instances. - """ - - return event_periods_from_periods(self.get_periods()) - - def get_unedited_periods(self): - - """ - Return the original, unedited periods including revisions from separate - recurrence instances. - """ - - return event_periods_from_updated_periods(self.get_updated_periods()) - - def get_counters(self): - - "Return a counter-proposal mapping from attendees to objects." - - d = {} - - # Get counter-proposals for the specific object. - - recurrenceids = [self.recurrenceid] - - # And for all recurrences associated with a parent object. - - if not self.recurrenceid: - recurrenceids += self.store.get_counter_recurrences(self.user, self.uid) - - # Map attendees to objects. - - for recurrenceid in recurrenceids: - attendees = self.store.get_counters(self.user, self.uid, recurrenceid) - for attendee in attendees: - if not d.has_key(attendee): - d[attendee] = [] - d[attendee].append(self.get_stored_object(self.uid, recurrenceid, "counters", attendee)) - - return d - - def get_suggested_attendees(self): - - "For all counter-proposals, return suggested attendee items." - - existing = self.state.get("attendees") - l = [] - for attendee, objects in self.get_counters().items(): - for obj in objects: - for suggested, attr in obj.get_items("ATTENDEE"): - if suggested not in existing: - l.append((attendee, (suggested, attr))) - return l - - def get_suggested_periods(self): - - "For all counter-proposals, return suggested event periods." - - existing = self.state.get("periods") - - # Get active periods for filtering of suggested periods. - - active = [] - for p in existing: - if not p.cancelled: - active.append(p) - - suggested = [] - - for attendee, objects in self.get_counters().items(): - - # For each object, obtain suggested periods. - - for obj in objects: - - # Obtain the current periods for the object providing the - # suggested periods. - - updated = self.get_updated_periods(obj) - suggestions = event_periods_from_updated_periods(updated) - - # Compare current periods with suggested periods. - - new = set(suggestions).difference(active) - - # Treat each specific recurrence as affecting only the original - # period. - - if obj.get_recurrenceid(): - removed = [] - else: - removed = set(active).difference(suggestions) - - # Associate new and removed periods with the attendee. - - for period in new: - suggested.append((attendee, period, "add")) - - for period in removed: - suggested.append((attendee, period, "remove")) - - return suggested - - # Validation methods. - - def get_checked_periods(self): - - """ - Check the edited periods and return objects representing them, setting - the "periods" state. If errors occur, raise an exception and set the - "errors" state. - """ - - self.state["period_errors"] = errors = {} - - # Basic validation. - - try: - periods = event_periods_from_periods(self.state.get("periods")) - - except PeriodError, exc: - - # Obtain error and period index details from the exception, - # collecting errors for each index position. - - for err, index in exc.args: - l = errors.get(index) - if not l: - l = errors[index] = [] - l.append(err) - raise - - # Check for overlapping periods. - - overlapping = get_overlapping_members(periods) - - for period in overlapping: - for index, p in enumerate(periods): - if period is p: - errors[index] = ["overlap"] - - if overlapping: - raise PeriodError - - self.state["periods"] = form_periods_from_periods(periods) - return periods - - # Update result computation. - - def classify_attendee_changes(self): - - "Classify the attendees in the event." - - original = self.state.get_original("attendees") - current = self.state.get("attendees") - return classify_attendee_changes(original, current) - - def classify_attendee_operations(self): - - "Classify attendee update operations." - - new, modified, unmodified, removed = self.classify_attendee_changes() - - if self.is_organiser(): - to_invite = new - to_cancel = removed - to_modify = modified - else: - to_invite = new - to_cancel = {} - to_modify = modified - - return to_invite, to_cancel, to_modify - - def classify_period_changes(self): - - "Classify changes in the updated periods for the edited event." - - updated = self.combine_periods_for_comparison() - return classify_period_changes(updated) - - def classify_periods(self): - - "Classify the updated periods for the edited event." - - updated = self.combine_periods() - return classify_periods(updated) - - def combine_periods(self): - - "Combine stored and checked edited periods to make updated periods." - - stored = self.get_stored_periods() - current = self.get_checked_periods() - return combine_periods(stored, current) - - def combine_periods_for_comparison(self): - - "Combine unedited and checked edited periods to make updated periods." - - original = self.get_unedited_periods() - current = self.get_checked_periods() - return combine_periods(original, current) - - def classify_period_operations(self, is_changed=False): - - "Classify period update operations." - - new, replaced, retained, cancelled, obsolete = self.classify_periods() - - modified, unmodified, removed = self.classify_period_changes() - - is_organiser = self.is_organiser() - is_shared = self.obj.is_shared() - - return classify_period_operations(new, replaced, retained, cancelled, - obsolete, modified, removed, - is_organiser, is_shared, is_changed) - - def properties_changed(self): - - "Test for changes in event details." - - is_changed = [] - - for name in ["summary"]: - if self.state.has_changed(name): - is_changed.append(name) - - return is_changed - - def finish(self): - - "Finish editing, writing edited details to the object." - - if self.state.get("finished"): - return - - is_changed = self.properties_changed() - - # Determine attendee modifications. - - self.state["attendee_operations"] = \ - to_invite, to_cancel, to_modify = \ - self.classify_attendee_operations() - - self.state["attendees_to_cancel"] = to_cancel - - # Determine period modification operations. - # Use property changes and attendee suggestions to affect the result for - # attendee responses. - - is_changed = is_changed or to_invite - - self.state["period_operations"] = \ - to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ - all_unscheduled, all_rescheduled = \ - self.classify_period_operations(is_changed) - - # Determine whole event update status. - - is_changed = is_changed or to_set - - # Update event details. - - if self.can_edit_properties(): - self.obj.set_value("SUMMARY", self.state.get("summary")) - - self.update_attendees(to_invite, to_cancel, to_modify) - self.update_event_from_periods(to_set, to_exclude) - - # Classify the nature of any update. - - if is_changed: - self.state["changed"] = "complete" - elif to_reschedule or to_unschedule or to_add: - self.state["changed"] = "incremental" - - self.state["finished"] = self.update_event_version(is_changed) - - # Update preparation. - - def have_update(self): - - "Return whether an update can be prepared and sent." - - return not self.is_organiser() or \ - not self.obj.is_shared() or \ - self.obj.is_shared() and self.state.get("changed") and \ - self.have_other_attendees() - - def have_other_attendees(self): - - "Return whether any attendees other than the user are present." - - attendees = self.state.get("attendees") - return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1) - - def prepare_cancel_message(self): - - "Prepare the cancel message for uninvited attendees." - - to_cancel = self.state.get("attendees_to_cancel") - return self.make_cancel_message(to_cancel) - - def prepare_publish_message(self): - - "Prepare the publishing message for the updated event." - - to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ - all_unscheduled, all_rescheduled = self.state.get("period_operations") - - return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add) - - def prepare_update_message(self): - - "Prepare the update message for the updated event." - - if not self.have_update(): - return None - - # Obtain operation details. - - to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ - all_unscheduled, all_rescheduled = self.state.get("period_operations") - - # Prepare the message. - - recipients = self.get_recipients() - update_parent = self.state["changed"] == "complete" - - if self.is_organiser(): - return self.make_update_message(recipients, update_parent, - to_unschedule, to_reschedule, - all_unscheduled, all_rescheduled, - to_add) - else: - return self.make_response_message(recipients, update_parent, - all_rescheduled, to_reschedule) - - # Modification methods. - - def add_attendee(self, uri=None): - - "Add a blank attendee." - - attendees = self.state.get("attendees") - attendees[uri or ""] = {"PARTSTAT" : "NEEDS-ACTION"} - - def add_suggested_attendee(self, index): - - "Add the suggested attendee at 'index' to the event." - - attendees = self.state.get("attendees") - suggested_attendees = self.state.get("suggested_attendees") - try: - attendee, (suggested, attr) = suggested_attendees[index] - self.add_attendee(suggested) - except IndexError: - pass - - def add_period(self): - - "Add a copy of the main period as a new recurrence." - - current = self.state.get("periods") - new = get_main_period(current).copy() - new.origin = "RDATE" - new.replacement = False - new.recurrenceid = False - new.cancelled = False - current.append(new) - - def apply_suggested_period(self, index): - - "Apply the suggested period at 'index' to the event." - - current = self.state.get("periods") - suggested = self.state.get("suggested_periods") - - try: - attendee, period, operation = suggested[index] - period = form_period_from_period(period) - - # Cancel any removed periods. - - if operation == "remove": - for index, p in enumerate(current): - if p == period: - self.cancel_periods([index]) - break - - # Add or replace any other suggestions. - - elif operation == "add": - - # Make the status of the period compatible. - - period.cancelled = False - period.origin = "DTSTART-RECUR" - - # Either replace or add the period. - - recurrenceid = period.get_recurrenceid() - - for i, p in enumerate(current): - if p.get_recurrenceid() == recurrenceid: - current[i] = period - break - - # Add as a new period. - - else: - period.recurrenceid = None - current.append(period) - - except IndexError: - pass - - def cancel_periods(self, indexes, cancelled=True): - - """ - Set cancellation state for periods with the given 'indexes', indicating - 'cancelled' as a true or false value. New periods will be removed if - cancelled. - """ - - periods = self.state.get("periods") - to_remove = [] - removed = 0 - - for index in indexes: - p = periods[index] - - # Make replacements from existing periods and cancel them. - - if p.recurrenceid: - p.replacement = True - p.cancelled = cancelled - - # Remove new periods completely. - - elif cancelled: - to_remove.append(index - removed) - removed += 1 - - for index in to_remove: - del periods[index] - - def can_edit_attendance(self): - - "Return whether the organiser's attendance can be edited." - - return self.state.get("attendees").has_key(self.user) - - def edit_attendance(self, partstat): - - "Set the 'partstat' of the current user, if attending." - - attendees = self.state.get("attendees") - attr = attendees.get(self.user) - - # Set the attendance for the user, if attending. - - if attr is not None: - new_attr = {} - new_attr.update(attr) - new_attr["PARTSTAT"] = partstat - attendees[self.user] = new_attr - - def can_edit_attendee(self, index): - - """ - Return whether the attendee at 'index' can be edited, requiring either - the organiser and an unshared event, or a new attendee. - """ - - attendees = self.state.get("attendees") - attendee = attendees.keys()[index] - - try: - attr = attendees[attendee] - if self.is_organiser() and not self.obj.is_shared() or not attr: - return (attendee, attr) - except IndexError: - pass - - return None - - def can_remove_attendee(self, index): - - """ - Return whether the attendee at 'index' can be removed, requiring either - the organiser or a new attendee. - """ - - attendees = self.state.get("attendees") - attendee = attendees.keys()[index] - - try: - attr = attendees[attendee] - if self.is_organiser() or not attr: - return (attendee, attr) - except IndexError: - pass - - return None - - def remove_attendees(self, indexes): - - "Remove attendee at 'index'." - - attendees = self.state.get("attendees") - to_remove = [] - - for index in indexes: - attendee_item = self.can_remove_attendee(index) - if attendee_item: - attendee, attr = attendee_item - to_remove.append(attendee) - - for key in to_remove: - del attendees[key] - - def can_edit_period(self, index): - - """ - Return the period at 'index' for editing or None if it cannot be edited. - """ - - try: - return self.state.get("periods")[index] - except IndexError: - return None - - def can_edit_properties(self): - - "Return whether general event properties can be edited." - - return True - - - -# Period-related abstractions. - -class PeriodError(Exception): - pass - -class EditablePeriod(RecurringPeriod): - - "An editable period tracking the identity of any original period." - - def _get_recurrenceid_item(self): - - # Convert any stored identifier to the current time zone. - # NOTE: This should not be necessary, but is done for consistency with - # NOTE: the datetime properties. - - dt = get_datetime(self.recurrenceid) - dt = to_timezone(dt, self.tzid) - return dt, get_datetime_attributes(dt) - - def get_recurrenceid(self): - - """ - Return a recurrence identity to be used to associate stored periods with - edited periods. - """ - - if not self.recurrenceid: - return RecurringPeriod.get_recurrenceid(self) - return self.recurrenceid - - def get_recurrenceid_item(self): - - """ - Return a recurrence identifier value and datetime properties for use in - specifying the RECURRENCE-ID property. - """ - - if not self.recurrenceid: - return RecurringPeriod.get_recurrenceid_item(self) - return self._get_recurrenceid_item() - -class EventPeriod(EditablePeriod): - - """ - A simple period plus attribute details, compatible with RecurringPeriod, and - intended to represent information obtained from an iCalendar resource. - """ - - def __init__(self, start, end, tzid=None, origin=None, start_attr=None, - end_attr=None, form_start=None, form_end=None, - replacement=False, cancelled=False, recurrenceid=None): - - """ - Initialise a period with the given 'start' and 'end' datetimes. - - The optional 'tzid' provides time zone information, and the optional - 'origin' indicates the kind of period this object describes. - - The optional 'start_attr' and 'end_attr' provide metadata for the start - and end datetimes respectively, and 'form_start' and 'form_end' are - values provided as textual input. - - The 'replacement' flag indicates whether the period is provided by a - separate recurrence instance. - - The 'cancelled' flag indicates whether a separate recurrence is - cancelled. - - The 'recurrenceid' describes the original identity of the period, - regardless of whether it is separate or not. - """ - - EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) - self.form_start = form_start - self.form_end = form_end - - # Information about whether a separate recurrence provides this period - # and the original period identity. - - self.replacement = replacement - self.cancelled = cancelled - self.recurrenceid = recurrenceid - - # Additional editing state. - - self.new_replacement = False - - def as_tuple(self): - return self.start, self.end, self.tzid, self.origin, self.start_attr, \ - self.end_attr, self.form_start, self.form_end, self.replacement, \ - self.cancelled, self.recurrenceid - - def __repr__(self): - return "EventPeriod%r" % (self.as_tuple(),) - - def copy(self): - return EventPeriod(*self.as_tuple()) - - def as_event_period(self, index=None): - return self - - def get_start_item(self): - return self.get_start(), self.get_start_attr() - - def get_end_item(self): - return self.get_end(), self.get_end_attr() - - # Form data compatibility methods. - - def get_form_start(self): - if not self.form_start: - self.form_start = self.get_form_date(self.get_start(), self.start_attr) - return self.form_start - - def get_form_end(self): - if not self.form_end: - self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) - return self.form_end - - def as_form_period(self): - return FormPeriod( - self.get_form_start(), - self.get_form_end(), - isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), - isinstance(self.start, datetime) or isinstance(self.end, datetime), - self.tzid, - self.origin, - self.replacement, - self.cancelled, - self.recurrenceid - ) - - def get_form_date(self, dt, attr=None): - return FormDate( - format_datetime(to_date(dt)), - isinstance(dt, datetime) and str(dt.hour) or None, - isinstance(dt, datetime) and str(dt.minute) or None, - isinstance(dt, datetime) and str(dt.second) or None, - attr and attr.get("TZID") or None, - dt, attr - ) - -class FormPeriod(EditablePeriod): - - "A period whose information originates from a form." - - def __init__(self, start, end, end_enabled=True, times_enabled=True, - tzid=None, origin=None, replacement=False, cancelled=False, - recurrenceid=None): - self.start = start - self.end = end - self.end_enabled = end_enabled - self.times_enabled = times_enabled - self.tzid = tzid - self.origin = origin - self.replacement = replacement - self.cancelled = cancelled - self.recurrenceid = recurrenceid - self.new_replacement = False - - def as_tuple(self): - return self.start, self.end, self.end_enabled, self.times_enabled, \ - self.tzid, self.origin, self.replacement, self.cancelled, \ - self.recurrenceid - - def __repr__(self): - return "FormPeriod%r" % (self.as_tuple(),) - - def copy(self): - args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:] - return FormPeriod(*args) - - def as_event_period(self, index=None): - - """ - Return a converted version of this object as an event period suitable - for iCalendar usage. If 'index' is indicated, include it in any error - raised in the conversion process. - """ - - dtstart, dtstart_attr = self.get_start_item() - if not dtstart: - if index is not None: - raise PeriodError(("dtstart", index)) - else: - raise PeriodError("dtstart") - - dtend, dtend_attr = self.get_end_item() - if not dtend: - if index is not None: - raise PeriodError(("dtend", index)) - else: - raise PeriodError("dtend") - - if dtstart > dtend: - if index is not None: - raise PeriodError(("dtstart", index), ("dtend", index)) - else: - raise PeriodError("dtstart", "dtend") - - return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, - self.origin, dtstart_attr, dtend_attr, - self.start, self.end, self.replacement, - self.cancelled, self.recurrenceid) - - # Period data methods. - - def get_start(self): - return self.start and self.start.as_datetime(self.times_enabled) or None - - def get_end(self): - - # Handle specified end datetimes. - - if self.end_enabled: - dtend = self.end.as_datetime(self.times_enabled) - if not dtend: - return None - - # Handle same day times. - - elif self.times_enabled: - formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) - dtend = formdate.as_datetime(self.times_enabled) - if not dtend: - return None - - # Otherwise, treat the end date as the start date. Datetimes are - # handled by making the event occupy the rest of the day. - - else: - dtstart, dtstart_attr = self.get_start_item() - if dtstart: - if isinstance(dtstart, datetime): - dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) - else: - dtend = dtstart - else: - return None - - return dtend - - def get_start_attr(self): - return self.start and self.start.get_attributes(self.times_enabled) or {} - - def get_end_attr(self): - return self.end and self.end.get_attributes(self.times_enabled) or {} - - # Form data methods. - - def get_form_start(self): - return self.start - - def get_form_end(self): - return self.end - - def as_form_period(self): - return self - -class FormDate: - - "Date information originating from form information." - - def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): - self.date = date - self.hour = hour - self.minute = minute - self.second = second - self.tzid = tzid - self.dt = dt - self.attr = attr - - def as_tuple(self): - return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr - - def copy(self): - return FormDate(*self.as_tuple()) - - def reset(self): - self.dt = None - - def __repr__(self): - return "FormDate%r" % (self.as_tuple(),) - - def get_component(self, value): - return (value or "").rjust(2, "0")[:2] - - def get_hour(self): - return self.get_component(self.hour) - - def get_minute(self): - return self.get_component(self.minute) - - def get_second(self): - return self.get_component(self.second) - - def get_date_string(self): - return self.date or "" - - def get_datetime_string(self): - if not self.date: - return "" - - hour = self.hour; minute = self.minute; second = self.second - - if hour or minute or second: - time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) - else: - time = "" - - return "%s%s" % (self.date, time) - - def get_tzid(self): - return self.tzid - - def as_datetime(self, with_time=True): - - """ - Return a datetime for this object if one is provided or can be produced. - """ - - # Return any original datetime details. - - if self.dt: - return self.dt - - # Otherwise, construct a datetime. - - s, attr = self.as_datetime_item(with_time) - if not s: - return None - - # An erroneous datetime will yield None as result. - - try: - return get_datetime(s, attr) - except ValueError: - return None - - def as_datetime_item(self, with_time=True): - - """ - Return a (datetime string, attr) tuple for the datetime information - provided by this object, where both tuple elements will be None if no - suitable date or datetime information exists. - """ - - s = None - if with_time: - s = self.get_datetime_string() - attr = self.get_attributes(True) - if not s: - s = self.get_date_string() - attr = self.get_attributes(False) - if not s: - return None, None - return s, attr - - def get_attributes(self, with_time=True): - - "Return attributes for the date or datetime represented by this object." - - if with_time: - return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} - else: - return {"VALUE" : "DATE"} - -def event_period_from_period(period, index=None): - - """ - Convert a 'period' to one suitable for use in an iCalendar representation. - In an "event period" representation, the end day of any date-level event is - encoded as the "day after" the last day actually involved in the event. - """ - - if isinstance(period, EventPeriod): - return period - elif isinstance(period, FormPeriod): - return period.as_event_period(index) - else: - dtstart, dtstart_attr = period.get_start_item() - dtend, dtend_attr = period.get_end_item() - - if not isinstance(period, RecurringPeriod): - dtend = end_date_to_calendar(dtend) - - return EventPeriod(dtstart, dtend, period.tzid, period.origin, - dtstart_attr, dtend_attr, - recurrenceid=format_datetime(to_utc_datetime(dtstart))) - -def event_periods_from_periods(periods): - return map(event_period_from_period, periods, range(0, len(periods))) - -def form_period_from_period(period): - - """ - Convert a 'period' into a representation usable in a user-editable form. - In a "form period" representation, the end day of any date-level event is - presented in a "natural" form, not the iCalendar "day after" form. - """ - - if isinstance(period, EventPeriod): - return period.as_form_period() - elif isinstance(period, FormPeriod): - return period - else: - return event_period_from_period(period).as_form_period() - -def form_periods_from_periods(periods): - return map(form_period_from_period, periods) - - - -# Event period processing. - -def periods_from_updated_periods(updated_periods, fn): - - """ - Return periods from the given 'updated_periods' created using 'fn', setting - replacement, cancelled and recurrence identifier details. - - This function should be used to produce editing-related periods from the - general updated periods provided by the client abstractions. - """ - - periods = [] - - for sp, p in updated_periods: - - # Stored periods with corresponding current periods. - - if p: - period = fn(p) - - # Replacements are identified by comparing object identities, since - # a replacement will not be provided by the same object. - - if sp is not p: - period.replacement = True - - # Stored periods without corresponding current periods. - - else: - period = fn(sp) - period.replacement = True - period.cancelled = True - - # Replace the recurrence identifier with that of the original period. - - period.recurrenceid = sp.get_recurrenceid() - periods.append(period) - - return periods - -def event_periods_from_updated_periods(updated_periods): - return periods_from_updated_periods(updated_periods, event_period_from_period) - -def form_periods_from_updated_periods(updated_periods): - return periods_from_updated_periods(updated_periods, form_period_from_period) - -def periods_by_recurrence(periods): - - """ - Return a mapping from recurrence identifier to period for 'periods' along - with a collection of unmapped periods. - """ - - d = {} - new = [] - - for p in periods: - if not p.recurrenceid: - new.append(p) - else: - d[p.recurrenceid] = p - - return d, new - -def combine_periods(old, new): - - """ - Combine 'old' and 'new' periods for comparison, making a list of (old, new) - updated period tuples. - """ - - old_by_recurrenceid, _new_periods = periods_by_recurrence(old) - new_by_recurrenceid, new_periods = periods_by_recurrence(new) - - combined = [] - - for recurrenceid, op in old_by_recurrenceid.items(): - np = new_by_recurrenceid.get(recurrenceid) - - # Old period has corresponding new period that is not cancelled. - - if np and not (np.cancelled and not op.cancelled): - combined.append((op, np)) - - # No corresponding new, uncancelled period. - - else: - combined.append((op, None)) - - # New periods without corresponding old periods are genuinely new. - - for np in new_periods: - combined.append((None, np)) - - # Note that new periods should not have recurrence identifiers, and if - # imported from other events, they should have such identifiers removed. - - return combined - -def classify_periods(updated_periods): - - """ - Using the 'updated_periods', being a list of (stored, current) periods, - return a tuple containing collections of new, replaced, retained, cancelled - and obsolete periods. - - Note that replaced and retained indicate the presence or absence of - differences between the original event periods and the current periods that - would need to be represented using separate recurrence instances, not - whether any editing operations have changed the periods. - - Obsolete periods are those that have been replaced but not cancelled. - """ - - new = [] - replaced = [] - retained = [] - cancelled = [] - obsolete = [] - - for sp, p in updated_periods: - - # Stored periods... - - if sp: - - # With cancelled or absent current periods. - - if not p or p.cancelled: - cancelled.append(sp) - - # With differing or replacement current periods. - - elif p != sp or p.replacement: - replaced.append(p) - if not p.replacement: - p.new_replacement = True - obsolete.append(sp) - - # With retained, not differing current periods. - - else: - retained.append(p) - if p.new_replacement: - p.new_replacement = False - - # New periods without corresponding stored periods. - - elif p: - new.append(p) - - return new, replaced, retained, cancelled, obsolete - -def classify_period_changes(updated_periods): - - """ - Using the 'updated_periods', being a list of (original, current) periods, - return a tuple containing collections of modified, unmodified and removed - periods. - """ - - modified = [] - unmodified = [] - removed = [] - - for op, p in updated_periods: - - # Test for periods cancelled, reinstated or changed, or left unmodified - # during editing. - - if op: - if not op.cancelled and (not p or p.cancelled): - removed.append(op) - elif op.cancelled and not p.cancelled or p != op: - modified.append(p) - else: - unmodified.append(p) - - # New periods are always modifications. - - elif p: - modified.append(p) - - return modified, unmodified, removed - -def classify_period_operations(new, replaced, retained, cancelled, - obsolete, modified, removed, - is_organiser, is_shared, is_changed): - - """ - Classify the operations for the update of an event. For updates modifying - shared events, return periods for descheduling and rescheduling (where these - operations can modify the event), and periods for exclusion and application - (where these operations redefine the event). - - To define the new state of the event, details of the complete set of - unscheduled and rescheduled periods are also provided. - """ - - active_periods = new + replaced + retained - - # Modified replaced and retained recurrences are used for incremental - # updates. - - replaced_modified = select_recurrences(replaced, modified).values() - retained_modified = select_recurrences(retained, modified).values() - - # Unmodified replaced and retained recurrences are used in the complete - # event summary. - - replaced_unmodified = subtract_recurrences(replaced, modified).values() - retained_unmodified = subtract_recurrences(retained, modified).values() - - # Obtain the removed periods in terms of existing periods. These are used in - # incremental updates. - - cancelled_removed = select_recurrences(cancelled, removed).values() - - # Reinstated periods are previously-cancelled periods that are now modified - # periods, and they appear in updates. - - reinstated = select_recurrences(modified, cancelled).values() - - # Get cancelled periods without reinstated periods. These appear in complete - # event summaries. - - cancelled_unmodified = subtract_recurrences(cancelled, modified).values() - - # Cancelled periods originating from rules must be excluded since there are - # no explicit instances to be deleted. - - cancelled_rule = [] - for p in cancelled_removed: - if p.origin == "RRULE": - cancelled_rule.append(p) - - # Obsolete periods (replaced by other periods) originating from rules must - # be excluded if no explicit instance will be used to replace them. - - obsolete_rule = [] - for p in obsolete: - if p.origin == "RRULE": - obsolete_rule.append(p) - - # As organiser... - - if is_organiser: - - # For unshared events... - # All modifications redefine the event. - - # For shared events... - # New periods should cause the event to be redefined. - # Other changes should also cause event redefinition. - # Event redefinition should only occur if no replacement periods exist. - # Cancelled rule-originating periods must be excluded. - - if not is_shared or new and not replaced: - to_set = active_periods - to_exclude = list(chain(cancelled_rule, obsolete_rule)) - to_unschedule = [] - to_reschedule = [] - to_add = [] - all_unscheduled = [] - all_rescheduled = [] - - # Changed periods should be rescheduled separately. - # Removed periods should be cancelled separately. - - else: - to_set = [] - to_exclude = [] - to_unschedule = cancelled_removed - to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) - to_add = new - all_unscheduled = cancelled_unmodified - all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) - - # As attendee... - - else: - to_unschedule = [] - to_add = [] - - # Changed periods without new or removed periods are proposed as - # separate changes. Parent event changes cause redefinition of the - # entire event. - - if not new and not removed and not is_changed: - to_set = [] - to_exclude = [] - to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) - all_unscheduled = list(cancelled_unmodified) - all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) - - # Otherwise, the event is defined in terms of new periods and - # exceptions for removed periods or obsolete rule periods. - - else: - to_set = active_periods - to_exclude = list(chain(cancelled, obsolete_rule)) - to_reschedule = [] - all_unscheduled = [] - all_rescheduled = [] - - return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled - -def get_period_mapping(periods): - - "Return a mapping of recurrence identifiers to the given 'periods." - - d, new = periods_by_recurrence(periods) - return d - -def select_recurrences(source, selected): - - "Restrict 'source' to the recurrences referenced by 'selected'." - - mapping = get_period_mapping(source) - - recurrenceids = get_recurrenceids(selected) - for recurrenceid in mapping.keys(): - if not recurrenceid in recurrenceids: - del mapping[recurrenceid] - return mapping - -def subtract_recurrences(source, selected): - - "Remove from 'source' the recurrences referenced by 'selected'." - - mapping = get_period_mapping(source) - - for recurrenceid in get_recurrenceids(selected): - if mapping.has_key(recurrenceid): - del mapping[recurrenceid] - return mapping - -def get_recurrenceids(periods): - - "Return the recurrence identifiers employed by 'periods'." - - return map(lambda p: p.get_recurrenceid(), periods) - - +from imiptools.editing import FormPeriod # Form field extraction and serialisation. @@ -1673,51 +256,6 @@ -# Attendee processing. - -def classify_attendee_changes(original, current): - - """ - Return categories of attendees given the 'original' and 'current' - collections of attendees. - """ - - new = {} - modified = {} - unmodified = {} - - # Check current attendees against the original ones. - - for attendee, attendee_attr in current.items(): - original_attr = original.get(attendee) - - # New attendee if missing original details. - - if not original_attr: - new[attendee] = attendee_attr - - # Details unchanged for existing attendee. - - elif attendee_attr == original_attr: - unmodified[attendee] = attendee_attr - - # Details changed for existing attendee. - - else: - modified[attendee] = attendee_attr - - removed = {} - - # Check for removed attendees. - - for attendee, attendee_attr in original.items(): - if not current.has_key(attendee): - removed[attendee] = attendee_attr - - return new, modified, unmodified, removed - - - # Utilities. def filter_duplicates(l):