1.1 --- a/imipweb/data.py Fri Sep 15 00:03:38 2017 +0200
1.2 +++ b/imipweb/data.py Mon Sep 18 20:34:43 2017 +0200
1.3 @@ -21,8 +21,9 @@
1.4
1.5 from datetime import datetime, timedelta
1.6 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \
1.7 - format_datetime, get_datetime, get_end_of_day, \
1.8 - to_date
1.9 + format_datetime, get_datetime, \
1.10 + get_datetime_attributes, get_end_of_day, \
1.11 + to_date, to_utc_datetime, to_timezone
1.12 from imiptools.period import RecurringPeriod
1.13
1.14 class PeriodError(Exception):
1.15 @@ -36,27 +37,71 @@
1.16 """
1.17
1.18 def __init__(self, start, end, tzid=None, origin=None, start_attr=None,
1.19 - end_attr=None, form_start=None, form_end=None, replaced=False):
1.20 + end_attr=None, form_start=None, form_end=None,
1.21 + replacement=False, cancelled=False, recurrenceid=None):
1.22
1.23 """
1.24 - Initialise a period with the given 'start' and 'end' datetimes, together
1.25 - with optional 'start_attr' and 'end_attr' metadata, 'form_start' and
1.26 - 'form_end' values provided as textual input, and with an optional
1.27 - 'origin' indicating the kind of period this object describes.
1.28 + Initialise a period with the given 'start' and 'end' datetimes.
1.29 +
1.30 + The optional 'tzid' provides time zone information, and the optional
1.31 + 'origin' indicates the kind of period this object describes.
1.32 +
1.33 + The optional 'start_attr' and 'end_attr' provide metadata for the start
1.34 + and end datetimes respectively, and 'form_start' and 'form_end' are
1.35 + values provided as textual input.
1.36 +
1.37 + The 'replacement' flag indicates whether the period is provided by a
1.38 + separate recurrence instance.
1.39 +
1.40 + The 'cancelled' flag indicates whether a separate recurrence is
1.41 + cancelled.
1.42 +
1.43 + The 'recurrenceid' describes the original identity of the period,
1.44 + regardless of whether it is separate or not.
1.45 """
1.46
1.47 RecurringPeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr)
1.48 self.form_start = form_start
1.49 self.form_end = form_end
1.50 - self.replaced = replaced
1.51 +
1.52 + # Information about whether a separate recurrence provides this period
1.53 + # and the original period identity.
1.54 +
1.55 + self.replacement = replacement
1.56 + self.cancelled = cancelled
1.57 + self.recurrenceid = recurrenceid
1.58
1.59 def as_tuple(self):
1.60 return self.start, self.end, self.tzid, self.origin, self.start_attr, \
1.61 - self.end_attr, self.form_start, self.form_end, self.replaced
1.62 + self.end_attr, self.form_start, self.form_end, self.replacement, \
1.63 + self.cancelled, self.recurrenceid
1.64
1.65 def __repr__(self):
1.66 return "EventPeriod%r" % (self.as_tuple(),)
1.67
1.68 + def copy(self):
1.69 + return EventPeriod(*self.as_tuple())
1.70 +
1.71 + def _get_recurrenceid_item(self):
1.72 +
1.73 + # Convert any stored identifier to the current time zone.
1.74 + # NOTE: This should not be necessary, but is done for consistency with
1.75 + # NOTE: the datetime properties.
1.76 +
1.77 + dt = get_datetime(self.recurrenceid)
1.78 + dt = to_timezone(dt, self.tzid)
1.79 + return dt, get_datetime_attributes(dt)
1.80 +
1.81 + def get_recurrenceid(self):
1.82 + if not self.recurrenceid:
1.83 + return RecurringPeriod.get_recurrenceid(self)
1.84 + return self.recurrenceid
1.85 +
1.86 + def get_recurrenceid_item(self):
1.87 + if not self.recurrenceid:
1.88 + return RecurringPeriod.get_recurrenceid_item(self)
1.89 + return self._get_recurrenceid_item()
1.90 +
1.91 def as_event_period(self):
1.92 return self
1.93
1.94 @@ -86,8 +131,9 @@
1.95 isinstance(self.start, datetime) or isinstance(self.end, datetime),
1.96 self.tzid,
1.97 self.origin,
1.98 - self.replaced and True or False,
1.99 - format_datetime(self.get_start_point())
1.100 + self.replacement,
1.101 + self.cancelled,
1.102 + self.recurrenceid
1.103 )
1.104
1.105 def get_form_date(self, dt, attr=None):
1.106 @@ -105,24 +151,28 @@
1.107 "A period whose information originates from a form."
1.108
1.109 def __init__(self, start, end, end_enabled=True, times_enabled=True,
1.110 - tzid=None, origin=None, replaced=False, recurrenceid=None):
1.111 + tzid=None, origin=None, replacement=False, cancelled=False,
1.112 + recurrenceid=None):
1.113 self.start = start
1.114 self.end = end
1.115 self.end_enabled = end_enabled
1.116 self.times_enabled = times_enabled
1.117 self.tzid = tzid
1.118 self.origin = origin
1.119 - self.replaced = replaced
1.120 + self.replacement = replacement
1.121 + self.cancelled = cancelled
1.122 self.recurrenceid = recurrenceid
1.123
1.124 def as_tuple(self):
1.125 - return self.start, self.end, self.end_enabled, self.times_enabled, self.tzid, self.origin, self.replaced, self.recurrenceid
1.126 + return self.start, self.end, self.end_enabled, self.times_enabled, \
1.127 + self.tzid, self.origin, self.replacement, self.cancelled, \
1.128 + self.recurrenceid
1.129
1.130 def __repr__(self):
1.131 return "FormPeriod%r" % (self.as_tuple(),)
1.132
1.133 - def is_changed(self):
1.134 - return not self.recurrenceid or format_datetime(self.get_start_point()) != self.recurrenceid
1.135 + def copy(self):
1.136 + return FormPeriod(*self.as_tuple())
1.137
1.138 def as_event_period(self, index=None):
1.139
1.140 @@ -154,7 +204,8 @@
1.141
1.142 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid,
1.143 self.origin, dtstart_attr, dtend_attr,
1.144 - self.start, self.end, self.replaced)
1.145 + self.start, self.end, self.replacement,
1.146 + self.cancelled, self.recurrenceid)
1.147
1.148 # Period data methods.
1.149
1.150 @@ -226,6 +277,9 @@
1.151 def as_tuple(self):
1.152 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr
1.153
1.154 + def reset(self):
1.155 + self.dt = None
1.156 +
1.157 def __repr__(self):
1.158 return "FormDate%r" % (self.as_tuple(),)
1.159
1.160 @@ -320,9 +374,16 @@
1.161 else:
1.162 dtstart, dtstart_attr = period.get_start_item()
1.163 dtend, dtend_attr = period.get_end_item()
1.164 +
1.165 if not isinstance(period, RecurringPeriod):
1.166 dtend = end_date_to_calendar(dtend)
1.167 - return EventPeriod(dtstart, dtend, period.tzid, period.origin, dtstart_attr, dtend_attr)
1.168 +
1.169 + return EventPeriod(dtstart, dtend, period.tzid, period.origin,
1.170 + dtstart_attr, dtend_attr,
1.171 + recurrenceid=format_datetime(to_utc_datetime(dtstart)))
1.172 +
1.173 +def event_periods_from_periods(periods):
1.174 + return map(event_period_from_period, periods)
1.175
1.176 def form_period_from_period(period):
1.177
1.178 @@ -339,84 +400,198 @@
1.179 else:
1.180 return event_period_from_period(period).as_form_period()
1.181
1.182 +def form_periods_from_periods(periods):
1.183 + return map(form_period_from_period, periods)
1.184 +
1.185
1.186
1.187 -# Form period processing.
1.188 +# Event period processing.
1.189
1.190 -def get_existing_periods(periods, still_to_remove):
1.191 +def periods_from_updated_periods(updated_periods, fn):
1.192
1.193 """
1.194 - Find all periods that existed before editing, given 'periods', applying
1.195 - the periods in 'still_to_remove' and producing retained, replaced and
1.196 - to-remove collections containing these existing periods.
1.197 + Return periods from the given 'updated_periods' created using 'fn, setting
1.198 + replacement, cancelled and recurrence identifier details.
1.199 + """
1.200 +
1.201 + periods = []
1.202 +
1.203 + for sp, p in updated_periods:
1.204 + if p:
1.205 + period = fn(p)
1.206 + if sp != p:
1.207 + period.replacement = True
1.208 + else:
1.209 + period = fn(sp)
1.210 + period.replacement = True
1.211 + period.cancelled = True
1.212 +
1.213 + # Replace the recurrence identifier with that of the original period.
1.214 +
1.215 + period.recurrenceid = sp.get_recurrenceid()
1.216 + periods.append(period)
1.217 +
1.218 + return periods
1.219 +
1.220 +def event_periods_from_updated_periods(updated_periods):
1.221 + return periods_from_updated_periods(updated_periods, event_period_from_period)
1.222 +
1.223 +def form_periods_from_updated_periods(updated_periods):
1.224 + return periods_from_updated_periods(updated_periods, form_period_from_period)
1.225 +
1.226 +def get_main_period(periods):
1.227 + for p in periods:
1.228 + if p.origin == "DTSTART":
1.229 + return p
1.230 + return None
1.231 +
1.232 +def get_recurrence_periods(periods):
1.233 + l = []
1.234 + for p in periods:
1.235 + if p.origin != "DTSTART":
1.236 + l.append(p)
1.237 + return l
1.238 +
1.239 +def periods_by_recurrence(periods):
1.240 +
1.241 + """
1.242 + Return a mapping from recurrence identifier to period for 'periods' along
1.243 + with a collection of unmapped periods.
1.244 """
1.245
1.246 - retained = []
1.247 - replaced = []
1.248 - to_remove = []
1.249 + d = {}
1.250 + new = []
1.251
1.252 for p in periods:
1.253 - p = form_period_from_period(p)
1.254 - if p.recurrenceid:
1.255 - if p.replaced:
1.256 - replaced.append(p)
1.257 - elif p in still_to_remove:
1.258 - to_remove.append(p)
1.259 - else:
1.260 - retained.append(p)
1.261 + if not p.recurrenceid:
1.262 + new.append(p)
1.263 + else:
1.264 + d[p.recurrenceid] = p
1.265 +
1.266 + return d, new
1.267 +
1.268 +def combine_periods(old, new):
1.269 +
1.270 + "Combine 'old' and 'new' periods for comparison."
1.271 +
1.272 + old_by_recurrenceid, _new_periods = periods_by_recurrence(old)
1.273 + new_by_recurrenceid, new_periods = periods_by_recurrence(new)
1.274 +
1.275 + combined = []
1.276 +
1.277 + for recurrenceid, op in old_by_recurrenceid.items():
1.278 + np = new_by_recurrenceid.get(recurrenceid)
1.279 + if np and not np.cancelled:
1.280 + combined.append((op, np))
1.281 + else:
1.282 + combined.append((op, None))
1.283 +
1.284 + for np in new_periods:
1.285 + combined.append((None, np))
1.286
1.287 - return retained, replaced, to_remove
1.288 + return combined
1.289 +
1.290 +def classify_periods(updated_periods):
1.291 +
1.292 + """
1.293 + Using the 'updated_periods', being a list of (stored, current) periods,
1.294 + return a tuple containing collections of new, changed, unchanged, removed
1.295 + periods.
1.296 + """
1.297 +
1.298 + new, changed, unchanged, removed = get_changed_periods(updated_periods)
1.299
1.300 -def get_new_periods(periods):
1.301 + changed = set(changed).difference(removed)
1.302 + unchanged = set(unchanged).difference(removed)
1.303 +
1.304 + return new, list(changed), list(unchanged), removed
1.305 +
1.306 +def get_changed_periods(updated_periods):
1.307
1.308 - "Return all periods introduced during editing, given 'periods'."
1.309 + """
1.310 + Using the 'updated_periods', being a list of (stored, current) periods,
1.311 + return a tuple containing collections of new, changed, unchanged and removed
1.312 + periods.
1.313 +
1.314 + Note that changed and unchanged indicate the presence or absence of
1.315 + differences between the original event periods and the current periods, not
1.316 + whether any editing operations have changed the periods.
1.317 + """
1.318
1.319 new = []
1.320 - for p in periods:
1.321 - fp = form_period_from_period(p)
1.322 - if not fp.recurrenceid:
1.323 - new.append(p)
1.324 - return new
1.325 -
1.326 -def get_changed_periods(periods):
1.327 -
1.328 - "Return changed and unchanged periods, given 'periods'."
1.329 -
1.330 changed = []
1.331 unchanged = []
1.332 + removed = []
1.333
1.334 - for p in periods:
1.335 - fp = form_period_from_period(p)
1.336 - if fp.is_changed():
1.337 - changed.append(p)
1.338 - else:
1.339 - unchanged.append(p)
1.340 + for sp, p in updated_periods:
1.341 + if sp:
1.342 + if not p or p.cancelled:
1.343 + removed.append(sp)
1.344 + elif p != sp or p.replacement:
1.345 + changed.append(p)
1.346 + else:
1.347 + unchanged.append(p)
1.348 + elif p:
1.349 + new.append(p)
1.350
1.351 - return changed, unchanged
1.352 + return new, changed, unchanged, removed
1.353
1.354 -def classify_periods(periods, still_to_remove):
1.355 +def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared):
1.356
1.357 """
1.358 - From the recurrence 'periods', given details of those 'still_to_remove',
1.359 - return a tuple containing collections of new, changed, unchanged, replaced
1.360 - and to-be-removed periods.
1.361 + Classify the operations for the update of an event. Return the unscheduled
1.362 + periods, rescheduled periods, excluded periods, and the periods to be set in
1.363 + the object to replace the existing stored periods.
1.364 """
1.365
1.366 - retained, replaced, to_remove = get_existing_periods(periods, still_to_remove)
1.367 + active_periods = new + unchanged + changed
1.368 +
1.369 + # As organiser...
1.370 +
1.371 + if is_organiser:
1.372 + to_exclude = []
1.373 +
1.374 + # For unshared events...
1.375 + # All modifications redefine the event.
1.376
1.377 - # Filter new periods with the existing period information.
1.378 + # For shared events...
1.379 + # New periods should cause the event to be redefined.
1.380
1.381 - new = set(get_new_periods(periods))
1.382 + if not is_shared or new:
1.383 + to_unschedule = []
1.384 + to_reschedule = []
1.385 + to_set = active_periods
1.386 +
1.387 + # Changed periods should be rescheduled separately.
1.388 + # Removed periods should be cancelled separately.
1.389
1.390 - new.difference_update(retained)
1.391 - new.difference_update(replaced)
1.392 - new.difference_update(to_remove)
1.393 + else:
1.394 + to_unschedule = removed
1.395 + to_reschedule = changed
1.396 + to_set = []
1.397 +
1.398 + # As attendee...
1.399 +
1.400 + else:
1.401 + to_unschedule = []
1.402 +
1.403 + # Changed periods without new or removed periods are proposed as
1.404 + # separate changes.
1.405
1.406 - # Divide retained periods into changed and unchanged collections.
1.407 + if not new and not removed:
1.408 + to_exclude = []
1.409 + to_reschedule = changed
1.410 + to_set = []
1.411
1.412 - changed, unchanged = get_changed_periods(retained)
1.413 + # Otherwise, the event is defined in terms of new periods and
1.414 + # exceptions for removed periods.
1.415
1.416 - return list(new), changed, unchanged, replaced, to_remove
1.417 + else:
1.418 + to_exclude = removed
1.419 + to_reschedule = []
1.420 + to_set = active_periods
1.421 +
1.422 + return to_unschedule, to_reschedule, to_exclude, to_set
1.423
1.424
1.425
1.426 @@ -502,8 +677,8 @@
1.427 def get_period_control_values(args, start_name, end_name,
1.428 end_enabled_name, times_enabled_name,
1.429 origin=None, origin_name=None,
1.430 - replaced_name=None, recurrenceid_name=None,
1.431 - tzid=None):
1.432 + replacement_name=None, cancelled_name=None,
1.433 + recurrenceid_name=None, tzid=None):
1.434
1.435 """
1.436 Return period values from fields found in 'args' prefixed with the given
1.437 @@ -513,9 +688,13 @@
1.438
1.439 If 'origin' is specified, a single period with the given origin is
1.440 returned. If 'origin_name' is specified, fields containing the name will
1.441 - provide origin information, fields containing 'replaced_name' will indicate
1.442 - periods that are replaced, and fields containing 'recurrenceid_name' will
1.443 - indicate periods that have existing recurrence details from an event.
1.444 + provide origin information.
1.445 +
1.446 + If specified, fields containing 'replacement_name' will indicate periods
1.447 + provided by separate recurrences, fields containing 'cancelled_name'
1.448 + will indicate periods that are replacements and cancelled, and fields
1.449 + containing 'recurrenceid_name' will indicate periods that have existing
1.450 + recurrence details from an event.
1.451
1.452 If 'tzid' is specified, it will provide the time zone where no explicit
1.453 time zone information is indicated in the field data.
1.454 @@ -526,14 +705,15 @@
1.455 all_end_enabled = args.get(end_enabled_name, [])
1.456 all_times_enabled = args.get(times_enabled_name, [])
1.457
1.458 - # Get the origins of period data and whether the periods are replaced.
1.459 + # Get the origins of period data and whether the periods are replacements.
1.460
1.461 if origin:
1.462 all_origins = [origin]
1.463 else:
1.464 all_origins = origin_name and args.get(origin_name, []) or []
1.465
1.466 - all_replaced = replaced_name and args.get(replaced_name, []) or []
1.467 + all_replacements = replacement_name and args.get(replacement_name, []) or []
1.468 + all_cancelled = cancelled_name and args.get(cancelled_name, []) or []
1.469 all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or []
1.470
1.471 # Get the start and end datetimes.
1.472 @@ -552,10 +732,12 @@
1.473
1.474 end_enabled = str(index) in all_end_enabled
1.475 times_enabled = str(index) in all_times_enabled
1.476 - replaced = str(index) in all_replaced
1.477 + replacement = str(index) in all_replacements
1.478 + cancelled = str(index) in all_cancelled
1.479
1.480 period = FormPeriod(start, end, end_enabled, times_enabled, tzid,
1.481 - found_origin or origin, replaced, recurrenceid)
1.482 + found_origin or origin, replacement, cancelled,
1.483 + recurrenceid)
1.484 periods.append(period)
1.485
1.486 # Return a single period if a single origin was specified.
1.487 @@ -567,8 +749,8 @@
1.488
1.489 def set_period_control_values(periods, args, start_name, end_name,
1.490 end_enabled_name, times_enabled_name,
1.491 - origin_name=None, replaced_name=None,
1.492 - recurrenceid_name=None):
1.493 + origin_name=None, replacement_name=None,
1.494 + cancelled_name=None, recurrenceid_name=None):
1.495
1.496 """
1.497 Using the given 'periods', replace form fields in 'args' prefixed with the
1.498 @@ -577,9 +759,11 @@
1.499 (to enable times for periods).
1.500
1.501 If 'origin_name' is specified, fields containing the name will provide
1.502 - origin information, fields containing 'replaced_name' will indicate periods
1.503 - that are replaced, and fields containing 'recurrenceid_name' will indicate
1.504 - periods that have existing recurrence details from an event.
1.505 + origin information, fields containing 'replacement_name' will indicate
1.506 + periods provided by separate recurrences, fields containing 'cancelled_name'
1.507 + will indicate periods that are replacements and cancelled, and fields
1.508 + containing 'recurrenceid_name' will indicate periods that have existing
1.509 + recurrence details from an event.
1.510 """
1.511
1.512 # Record period settings separately.
1.513 @@ -592,8 +776,11 @@
1.514 if origin_name:
1.515 args[origin_name] = []
1.516
1.517 - if replaced_name:
1.518 - args[replaced_name] = []
1.519 + if replacement_name:
1.520 + args[replacement_name] = []
1.521 +
1.522 + if cancelled_name:
1.523 + args[cancelled_name] = []
1.524
1.525 if recurrenceid_name:
1.526 args[recurrenceid_name] = []
1.527 @@ -617,8 +804,14 @@
1.528
1.529 # Add replacement information where controls are present to record it.
1.530
1.531 - if replaced_name and period.replaced:
1.532 - args[replaced_name].append(str(index))
1.533 + if replacement_name and period.replacement:
1.534 + args[replacement_name].append(str(index))
1.535 +
1.536 + # Add cancelled recurrence information where controls are present to
1.537 + # record it.
1.538 +
1.539 + if cancelled_name and period.cancelled:
1.540 + args[cancelled_name].append(str(index))
1.541
1.542 # Add recurrence identifiers where controls are present to record it.
1.543