1 #!/usr/bin/env python 2 3 """ 4 User interface data abstractions. 5 6 Copyright (C) 2014, 2015, 2017 Paul Boddie <paul@boddie.org.uk> 7 8 This program is free software; you can redistribute it and/or modify it under 9 the terms of the GNU General Public License as published by the Free Software 10 Foundation; either version 3 of the License, or (at your option) any later 11 version. 12 13 This program is distributed in the hope that it will be useful, but WITHOUT 14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS 15 FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 16 details. 17 18 You should have received a copy of the GNU General Public License along with 19 this program. If not, see <http://www.gnu.org/licenses/>. 20 """ 21 22 from datetime import datetime, timedelta 23 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ 24 format_datetime, get_datetime, \ 25 get_datetime_attributes, get_end_of_day, \ 26 to_date, to_utc_datetime, to_timezone 27 from imiptools.period import RecurringPeriod 28 29 # General editing abstractions. 30 31 class State: 32 33 "Manage computed state." 34 35 def __init__(self, callables): 36 37 """ 38 Define state variable initialisation using the given 'callables', which 39 is a mapping that defines a callable for each variable name that is 40 invoked when the variable is first requested. 41 """ 42 43 self.state = {} 44 self.callables = callables 45 46 def get_callable(self, key): 47 return self.callables.get(key, lambda: None) 48 49 def get(self, key, reset=False): 50 51 """ 52 Return state for the given 'key', using the configured callable to 53 compute and set the state if no state is already defined. 54 55 If 'reset' is set to a true value, compute and return the state using 56 the configured callable regardless of any existing state. 57 """ 58 59 if reset or not self.state.has_key(key): 60 self.state[key] = self.get_callable(key)() 61 62 return self.state[key] 63 64 def set(self, key, value): 65 self.state[key] = value 66 67 def __getitem__(self, key): 68 return self.get(key) 69 70 def __setitem__(self, key, value): 71 self.set(key, value) 72 73 def has_changed(self, key): 74 return self.get_callable(key)() != self.get(key) 75 76 77 78 # Period-related abstractions. 79 80 class PeriodError(Exception): 81 pass 82 83 class EditablePeriod(RecurringPeriod): 84 85 "An editable period tracking the identity of any original period." 86 87 def _get_recurrenceid_item(self): 88 89 # Convert any stored identifier to the current time zone. 90 # NOTE: This should not be necessary, but is done for consistency with 91 # NOTE: the datetime properties. 92 93 dt = get_datetime(self.recurrenceid) 94 dt = to_timezone(dt, self.tzid) 95 return dt, get_datetime_attributes(dt) 96 97 def get_recurrenceid(self): 98 if not self.recurrenceid: 99 return RecurringPeriod.get_recurrenceid(self) 100 return self.recurrenceid 101 102 def get_recurrenceid_item(self): 103 if not self.recurrenceid: 104 return RecurringPeriod.get_recurrenceid_item(self) 105 return self._get_recurrenceid_item() 106 107 class EventPeriod(EditablePeriod): 108 109 """ 110 A simple period plus attribute details, compatible with RecurringPeriod, and 111 intended to represent information obtained from an iCalendar resource. 112 """ 113 114 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 115 end_attr=None, form_start=None, form_end=None, 116 replacement=False, cancelled=False, recurrenceid=None): 117 118 """ 119 Initialise a period with the given 'start' and 'end' datetimes. 120 121 The optional 'tzid' provides time zone information, and the optional 122 'origin' indicates the kind of period this object describes. 123 124 The optional 'start_attr' and 'end_attr' provide metadata for the start 125 and end datetimes respectively, and 'form_start' and 'form_end' are 126 values provided as textual input. 127 128 The 'replacement' flag indicates whether the period is provided by a 129 separate recurrence instance. 130 131 The 'cancelled' flag indicates whether a separate recurrence is 132 cancelled. 133 134 The 'recurrenceid' describes the original identity of the period, 135 regardless of whether it is separate or not. 136 """ 137 138 EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 139 self.form_start = form_start 140 self.form_end = form_end 141 142 # Information about whether a separate recurrence provides this period 143 # and the original period identity. 144 145 self.replacement = replacement 146 self.cancelled = cancelled 147 self.recurrenceid = recurrenceid 148 149 # Additional editing state. 150 151 self.new_replacement = False 152 153 def as_tuple(self): 154 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 155 self.end_attr, self.form_start, self.form_end, self.replacement, \ 156 self.cancelled, self.recurrenceid 157 158 def __repr__(self): 159 return "EventPeriod%r" % (self.as_tuple(),) 160 161 def copy(self): 162 return EventPeriod(*self.as_tuple()) 163 164 def as_event_period(self, index=None): 165 return self 166 167 def get_start_item(self): 168 return self.get_start(), self.get_start_attr() 169 170 def get_end_item(self): 171 return self.get_end(), self.get_end_attr() 172 173 # Form data compatibility methods. 174 175 def get_form_start(self): 176 if not self.form_start: 177 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 178 return self.form_start 179 180 def get_form_end(self): 181 if not self.form_end: 182 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 183 return self.form_end 184 185 def as_form_period(self): 186 return FormPeriod( 187 self.get_form_start(), 188 self.get_form_end(), 189 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 190 isinstance(self.start, datetime) or isinstance(self.end, datetime), 191 self.tzid, 192 self.origin, 193 self.replacement, 194 self.cancelled, 195 self.recurrenceid 196 ) 197 198 def get_form_date(self, dt, attr=None): 199 return FormDate( 200 format_datetime(to_date(dt)), 201 isinstance(dt, datetime) and str(dt.hour) or None, 202 isinstance(dt, datetime) and str(dt.minute) or None, 203 isinstance(dt, datetime) and str(dt.second) or None, 204 attr and attr.get("TZID") or None, 205 dt, attr 206 ) 207 208 class FormPeriod(EditablePeriod): 209 210 "A period whose information originates from a form." 211 212 def __init__(self, start, end, end_enabled=True, times_enabled=True, 213 tzid=None, origin=None, replacement=False, cancelled=False, 214 recurrenceid=None): 215 self.start = start 216 self.end = end 217 self.end_enabled = end_enabled 218 self.times_enabled = times_enabled 219 self.tzid = tzid 220 self.origin = origin 221 self.replacement = replacement 222 self.cancelled = cancelled 223 self.recurrenceid = recurrenceid 224 self.new_replacement = False 225 226 def as_tuple(self): 227 return self.start, self.end, self.end_enabled, self.times_enabled, \ 228 self.tzid, self.origin, self.replacement, self.cancelled, \ 229 self.recurrenceid 230 231 def __repr__(self): 232 return "FormPeriod%r" % (self.as_tuple(),) 233 234 def copy(self): 235 return FormPeriod(*self.as_tuple()) 236 237 def as_event_period(self, index=None): 238 239 """ 240 Return a converted version of this object as an event period suitable 241 for iCalendar usage. If 'index' is indicated, include it in any error 242 raised in the conversion process. 243 """ 244 245 dtstart, dtstart_attr = self.get_start_item() 246 if not dtstart: 247 if index is not None: 248 raise PeriodError(("dtstart", index)) 249 else: 250 raise PeriodError("dtstart") 251 252 dtend, dtend_attr = self.get_end_item() 253 if not dtend: 254 if index is not None: 255 raise PeriodError(("dtend", index)) 256 else: 257 raise PeriodError("dtend") 258 259 if dtstart > dtend: 260 if index is not None: 261 raise PeriodError(("dtstart", index), ("dtend", index)) 262 else: 263 raise PeriodError("dtstart", "dtend") 264 265 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 266 self.origin, dtstart_attr, dtend_attr, 267 self.start, self.end, self.replacement, 268 self.cancelled, self.recurrenceid) 269 270 # Period data methods. 271 272 def get_start(self): 273 return self.start and self.start.as_datetime(self.times_enabled) or None 274 275 def get_end(self): 276 277 # Handle specified end datetimes. 278 279 if self.end_enabled: 280 dtend = self.end.as_datetime(self.times_enabled) 281 if not dtend: 282 return None 283 284 # Handle same day times. 285 286 elif self.times_enabled: 287 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 288 dtend = formdate.as_datetime(self.times_enabled) 289 if not dtend: 290 return None 291 292 # Otherwise, treat the end date as the start date. Datetimes are 293 # handled by making the event occupy the rest of the day. 294 295 else: 296 dtstart, dtstart_attr = self.get_start_item() 297 if dtstart: 298 if isinstance(dtstart, datetime): 299 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 300 else: 301 dtend = dtstart 302 else: 303 return None 304 305 return dtend 306 307 def get_start_attr(self): 308 return self.start and self.start.get_attributes(self.times_enabled) or {} 309 310 def get_end_attr(self): 311 return self.end and self.end.get_attributes(self.times_enabled) or {} 312 313 # Form data methods. 314 315 def get_form_start(self): 316 return self.start 317 318 def get_form_end(self): 319 return self.end 320 321 def as_form_period(self): 322 return self 323 324 class FormDate: 325 326 "Date information originating from form information." 327 328 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 329 self.date = date 330 self.hour = hour 331 self.minute = minute 332 self.second = second 333 self.tzid = tzid 334 self.dt = dt 335 self.attr = attr 336 337 def as_tuple(self): 338 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 339 340 def reset(self): 341 self.dt = None 342 343 def __repr__(self): 344 return "FormDate%r" % (self.as_tuple(),) 345 346 def get_component(self, value): 347 return (value or "").rjust(2, "0")[:2] 348 349 def get_hour(self): 350 return self.get_component(self.hour) 351 352 def get_minute(self): 353 return self.get_component(self.minute) 354 355 def get_second(self): 356 return self.get_component(self.second) 357 358 def get_date_string(self): 359 return self.date or "" 360 361 def get_datetime_string(self): 362 if not self.date: 363 return "" 364 365 hour = self.hour; minute = self.minute; second = self.second 366 367 if hour or minute or second: 368 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 369 else: 370 time = "" 371 372 return "%s%s" % (self.date, time) 373 374 def get_tzid(self): 375 return self.tzid 376 377 def as_datetime(self, with_time=True): 378 379 "Return a datetime for this object." 380 381 # Return any original datetime details. 382 383 if self.dt: 384 return self.dt 385 386 # Otherwise, construct a datetime. 387 388 s, attr = self.as_datetime_item(with_time) 389 if s: 390 return get_datetime(s, attr) 391 else: 392 return None 393 394 def as_datetime_item(self, with_time=True): 395 396 """ 397 Return a (datetime string, attr) tuple for the datetime information 398 provided by this object, where both tuple elements will be None if no 399 suitable date or datetime information exists. 400 """ 401 402 s = None 403 if with_time: 404 s = self.get_datetime_string() 405 attr = self.get_attributes(True) 406 if not s: 407 s = self.get_date_string() 408 attr = self.get_attributes(False) 409 if not s: 410 return None, None 411 return s, attr 412 413 def get_attributes(self, with_time=True): 414 415 "Return attributes for the date or datetime represented by this object." 416 417 if with_time: 418 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 419 else: 420 return {"VALUE" : "DATE"} 421 422 def event_period_from_period(period, index=None): 423 424 """ 425 Convert a 'period' to one suitable for use in an iCalendar representation. 426 In an "event period" representation, the end day of any date-level event is 427 encoded as the "day after" the last day actually involved in the event. 428 """ 429 430 if isinstance(period, EventPeriod): 431 return period 432 elif isinstance(period, FormPeriod): 433 return period.as_event_period(index) 434 else: 435 dtstart, dtstart_attr = period.get_start_item() 436 dtend, dtend_attr = period.get_end_item() 437 438 if not isinstance(period, RecurringPeriod): 439 dtend = end_date_to_calendar(dtend) 440 441 return EventPeriod(dtstart, dtend, period.tzid, period.origin, 442 dtstart_attr, dtend_attr, 443 recurrenceid=format_datetime(to_utc_datetime(dtstart))) 444 445 def event_periods_from_periods(periods): 446 return map(event_period_from_period, periods, range(0, len(periods))) 447 448 def form_period_from_period(period): 449 450 """ 451 Convert a 'period' into a representation usable in a user-editable form. 452 In a "form period" representation, the end day of any date-level event is 453 presented in a "natural" form, not the iCalendar "day after" form. 454 """ 455 456 if isinstance(period, EventPeriod): 457 return period.as_form_period() 458 elif isinstance(period, FormPeriod): 459 return period 460 else: 461 return event_period_from_period(period).as_form_period() 462 463 def form_periods_from_periods(periods): 464 return map(form_period_from_period, periods) 465 466 467 468 # Event period processing. 469 470 def periods_from_updated_periods(updated_periods, fn): 471 472 """ 473 Return periods from the given 'updated_periods' created using 'fn, setting 474 replacement, cancelled and recurrence identifier details. 475 """ 476 477 periods = [] 478 479 for sp, p in updated_periods: 480 if p: 481 period = fn(p) 482 if sp != p: 483 period.replacement = True 484 else: 485 period = fn(sp) 486 period.replacement = True 487 period.cancelled = True 488 489 # Replace the recurrence identifier with that of the original period. 490 491 period.recurrenceid = sp.get_recurrenceid() 492 periods.append(period) 493 494 return periods 495 496 def event_periods_from_updated_periods(updated_periods): 497 return periods_from_updated_periods(updated_periods, event_period_from_period) 498 499 def form_periods_from_updated_periods(updated_periods): 500 return periods_from_updated_periods(updated_periods, form_period_from_period) 501 502 def periods_by_recurrence(periods): 503 504 """ 505 Return a mapping from recurrence identifier to period for 'periods' along 506 with a collection of unmapped periods. 507 """ 508 509 d = {} 510 new = [] 511 512 for p in periods: 513 if not p.recurrenceid: 514 new.append(p) 515 else: 516 d[p.recurrenceid] = p 517 518 return d, new 519 520 def combine_periods(old, new): 521 522 "Combine 'old' and 'new' periods for comparison." 523 524 old_by_recurrenceid, _new_periods = periods_by_recurrence(old) 525 new_by_recurrenceid, new_periods = periods_by_recurrence(new) 526 527 combined = [] 528 529 for recurrenceid, op in old_by_recurrenceid.items(): 530 np = new_by_recurrenceid.get(recurrenceid) 531 if np and not np.cancelled: 532 combined.append((op, np)) 533 else: 534 combined.append((op, None)) 535 536 for np in new_periods: 537 combined.append((None, np)) 538 539 return combined 540 541 def classify_periods(updated_periods): 542 543 """ 544 Using the 'updated_periods', being a list of (stored, current) periods, 545 return a tuple containing collections of new, changed, unchanged and removed 546 periods. 547 548 Note that changed and unchanged indicate the presence or absence of 549 differences between the original event periods and the current periods, not 550 whether any editing operations have changed the periods. 551 """ 552 553 new = [] 554 changed = [] 555 unchanged = [] 556 removed = [] 557 558 for sp, p in updated_periods: 559 if sp: 560 if not p or p.cancelled: 561 removed.append(sp) 562 elif p != sp or p.replacement: 563 changed.append(p) 564 if not p.replacement: 565 p.new_replacement = True 566 else: 567 unchanged.append(p) 568 if p.new_replacement: 569 p.new_replacement = False 570 elif p: 571 new.append(p) 572 573 return new, changed, unchanged, removed 574 575 def classify_operations(new, changed, unchanged, removed, is_organiser, is_shared): 576 577 """ 578 Classify the operations for the update of an event. Return the unscheduled 579 periods, rescheduled periods, excluded periods, and the periods to be set in 580 the object to replace the existing stored periods. 581 """ 582 583 active_periods = new + unchanged + changed 584 585 # As organiser... 586 587 if is_organiser: 588 to_exclude = [] 589 590 # For unshared events... 591 # All modifications redefine the event. 592 593 # For shared events... 594 # New periods should cause the event to be redefined. 595 596 if not is_shared or new: 597 to_unschedule = [] 598 to_reschedule = [] 599 to_set = active_periods 600 601 # Changed periods should be rescheduled separately. 602 # Removed periods should be cancelled separately. 603 604 else: 605 to_unschedule = removed 606 to_reschedule = changed 607 to_set = [] 608 609 # As attendee... 610 611 else: 612 to_unschedule = [] 613 614 # Changed periods without new or removed periods are proposed as 615 # separate changes. 616 617 if not new and not removed: 618 to_exclude = [] 619 to_reschedule = changed 620 to_set = [] 621 622 # Otherwise, the event is defined in terms of new periods and 623 # exceptions for removed periods. 624 625 else: 626 to_exclude = removed 627 to_reschedule = [] 628 to_set = active_periods 629 630 return to_unschedule, to_reschedule, to_exclude, to_set 631 632 633 634 # Form field extraction and serialisation. 635 636 def get_date_control_inputs(args, name, tzid_name=None): 637 638 """ 639 Return a tuple of date control inputs taken from 'args' for field names 640 starting with 'name'. 641 642 If 'tzid_name' is specified, the time zone information will be acquired 643 from fields starting with 'tzid_name' instead of 'name'. 644 """ 645 646 return args.get("%s-date" % name, []), \ 647 args.get("%s-hour" % name, []), \ 648 args.get("%s-minute" % name, []), \ 649 args.get("%s-second" % name, []), \ 650 args.get("%s-tzid" % (tzid_name or name), []) 651 652 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 653 654 """ 655 Return a form date object representing fields taken from 'args' starting 656 with 'name'. 657 658 If 'multiple' is set to a true value, many date objects will be returned 659 corresponding to a collection of datetimes. 660 661 If 'tzid_name' is specified, the time zone information will be acquired 662 from fields starting with 'tzid_name' instead of 'name'. 663 664 If 'tzid' is specified, it will provide the time zone where no explicit 665 time zone information is indicated in the field data. 666 """ 667 668 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 669 670 # Handle absent values by employing None values. 671 672 field_values = map(None, dates, hours, minutes, seconds, tzids) 673 674 if not field_values and not multiple: 675 all_values = FormDate() 676 else: 677 all_values = [] 678 for date, hour, minute, second, tzid_field in field_values: 679 value = FormDate(date, hour, minute, second, tzid_field or tzid) 680 681 # Return a single value or append to a collection of all values. 682 683 if not multiple: 684 return value 685 else: 686 all_values.append(value) 687 688 return all_values 689 690 def set_date_control_values(formdates, args, name, tzid_name=None): 691 692 """ 693 Using the values of the given 'formdates', replace form fields in 'args' 694 starting with 'name'. 695 696 If 'tzid_name' is specified, the time zone information will be stored in 697 fields starting with 'tzid_name' instead of 'name'. 698 """ 699 700 args["%s-date" % name] = [] 701 args["%s-hour" % name] = [] 702 args["%s-minute" % name] = [] 703 args["%s-second" % name] = [] 704 args["%s-tzid" % (tzid_name or name)] = [] 705 706 for d in formdates: 707 args["%s-date" % name].append(d and d.date or "") 708 args["%s-hour" % name].append(d and d.hour or "") 709 args["%s-minute" % name].append(d and d.minute or "") 710 args["%s-second" % name].append(d and d.second or "") 711 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 712 713 def get_period_control_values(args, start_name, end_name, 714 end_enabled_name, times_enabled_name, 715 origin=None, origin_name=None, 716 replacement_name=None, cancelled_name=None, 717 recurrenceid_name=None, tzid=None): 718 719 """ 720 Return period values from fields found in 'args' prefixed with the given 721 'start_name' (for start dates), 'end_name' (for end dates), 722 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 723 (to enable times for periods). 724 725 If 'origin' is specified, a single period with the given origin is 726 returned. If 'origin_name' is specified, fields containing the name will 727 provide origin information. 728 729 If specified, fields containing 'replacement_name' will indicate periods 730 provided by separate recurrences, fields containing 'cancelled_name' 731 will indicate periods that are replacements and cancelled, and fields 732 containing 'recurrenceid_name' will indicate periods that have existing 733 recurrence details from an event. 734 735 If 'tzid' is specified, it will provide the time zone where no explicit 736 time zone information is indicated in the field data. 737 """ 738 739 # Get the end datetime and time presence settings. 740 741 all_end_enabled = args.get(end_enabled_name, []) 742 all_times_enabled = args.get(times_enabled_name, []) 743 744 # Get the origins of period data and whether the periods are replacements. 745 746 if origin: 747 all_origins = [origin] 748 else: 749 all_origins = origin_name and args.get(origin_name, []) or [] 750 751 all_replacements = replacement_name and args.get(replacement_name, []) or [] 752 all_cancelled = cancelled_name and args.get(cancelled_name, []) or [] 753 all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or [] 754 755 # Get the start and end datetimes. 756 757 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 758 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 759 760 # Construct period objects for each start, end, origin combination. 761 762 periods = [] 763 764 for index, (start, end, found_origin, recurrenceid) in \ 765 enumerate(map(None, all_starts, all_ends, all_origins, all_recurrenceids)): 766 767 # Obtain period settings from separate controls. 768 769 end_enabled = str(index) in all_end_enabled 770 times_enabled = str(index) in all_times_enabled 771 replacement = str(index) in all_replacements 772 cancelled = str(index) in all_cancelled 773 774 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 775 found_origin or origin, replacement, cancelled, 776 recurrenceid) 777 periods.append(period) 778 779 # Return a single period if a single origin was specified. 780 781 if origin: 782 return periods[0] 783 else: 784 return periods 785 786 def set_period_control_values(periods, args, start_name, end_name, 787 end_enabled_name, times_enabled_name, 788 origin_name=None, replacement_name=None, 789 cancelled_name=None, recurrenceid_name=None): 790 791 """ 792 Using the given 'periods', replace form fields in 'args' prefixed with the 793 given 'start_name' (for start dates), 'end_name' (for end dates), 794 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 795 (to enable times for periods). 796 797 If 'origin_name' is specified, fields containing the name will provide 798 origin information, fields containing 'replacement_name' will indicate 799 periods provided by separate recurrences, fields containing 'cancelled_name' 800 will indicate periods that are replacements and cancelled, and fields 801 containing 'recurrenceid_name' will indicate periods that have existing 802 recurrence details from an event. 803 """ 804 805 # Record period settings separately. 806 807 args[end_enabled_name] = [] 808 args[times_enabled_name] = [] 809 810 # Record origin and replacement information if naming is defined. 811 812 if origin_name: 813 args[origin_name] = [] 814 815 if replacement_name: 816 args[replacement_name] = [] 817 818 if cancelled_name: 819 args[cancelled_name] = [] 820 821 if recurrenceid_name: 822 args[recurrenceid_name] = [] 823 824 all_starts = [] 825 all_ends = [] 826 827 for index, period in enumerate(periods): 828 829 # Encode period settings in controls. 830 831 if period.end_enabled: 832 args[end_enabled_name].append(str(index)) 833 if period.times_enabled: 834 args[times_enabled_name].append(str(index)) 835 836 # Add origin information where controls are present to record it. 837 838 if origin_name: 839 args[origin_name].append(period.origin or "") 840 841 # Add replacement information where controls are present to record it. 842 843 if replacement_name and period.replacement: 844 args[replacement_name].append(str(index)) 845 846 # Add cancelled recurrence information where controls are present to 847 # record it. 848 849 if cancelled_name and period.cancelled: 850 args[cancelled_name].append(str(index)) 851 852 # Add recurrence identifiers where controls are present to record it. 853 854 if recurrenceid_name: 855 args[recurrenceid_name].append(period.recurrenceid or "") 856 857 # Collect form date information for addition below. 858 859 all_starts.append(period.get_form_start()) 860 all_ends.append(period.get_form_end()) 861 862 # Set the controls for the dates. 863 864 set_date_control_values(all_starts, args, start_name) 865 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 866 867 868 869 # Utilities. 870 871 def filter_duplicates(l): 872 873 """ 874 Return collection 'l' filtered for duplicate values, retaining the given 875 element ordering. 876 """ 877 878 s = set() 879 f = [] 880 881 for value in l: 882 if value not in s: 883 s.add(value) 884 f.append(value) 885 886 return f 887 888 def remove_from_collection(l, indexes, fn): 889 890 """ 891 Remove from collection 'l' all values present at the given 'indexes' where 892 'fn' applied to each referenced value returns a true value. Values where 893 'fn' returns a false value are added to a list of deferred removals which is 894 returned. 895 """ 896 897 still_to_remove = [] 898 correction = 0 899 900 for i in indexes: 901 try: 902 i = int(i) - correction 903 value = l[i] 904 except (IndexError, ValueError): 905 continue 906 907 if fn(value): 908 del l[i] 909 correction += 1 910 else: 911 still_to_remove.append(value) 912 913 return still_to_remove 914 915 # vim: tabstop=4 expandtab shiftwidth=4