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 collections import OrderedDict 23 from copy import copy 24 from datetime import datetime, timedelta 25 from imiptools.client import ClientForObject 26 from imiptools.data import get_main_period 27 from imiptools.dates import end_date_from_calendar, end_date_to_calendar, \ 28 format_datetime, get_datetime, \ 29 get_datetime_attributes, get_end_of_day, \ 30 to_date, to_utc_datetime, to_timezone 31 from imiptools.period import get_overlapping_members, RecurringPeriod 32 from itertools import chain 33 34 # General editing abstractions. 35 36 class State: 37 38 "Manage editing state." 39 40 def __init__(self, callables): 41 42 """ 43 Define state variable initialisation using the given 'callables', which 44 is a mapping that defines a callable for each variable name that is 45 invoked when the variable is first requested. 46 """ 47 48 self.state = {} 49 self.original = {} 50 self.callables = callables 51 52 def get_callable(self, key): 53 return self.callables.get(key, lambda: None) 54 55 def ensure_original(self, key): 56 57 "Ensure the original state for the given 'key'." 58 59 if not self.original.has_key(key): 60 self.original[key] = self.get_callable(key)() 61 62 def get_original(self, key): 63 64 "Return the original state for the given 'key'." 65 66 self.ensure_original(key) 67 return copy(self.original[key]) 68 69 def get(self, key, reset=False): 70 71 """ 72 Return state for the given 'key', using the configured callable to 73 compute and set the state if no state is already defined. 74 75 If 'reset' is set to a true value, compute and return the state using 76 the configured callable regardless of any existing state. 77 """ 78 79 if reset or not self.state.has_key(key): 80 self.state[key] = self.get_original(key) 81 82 return self.state[key] 83 84 def set(self, key, value): 85 86 "Set the state of 'key' to 'value'." 87 88 self.ensure_original(key) 89 self.state[key] = value 90 91 def has_changed(self, key): 92 93 "Return whether 'key' has changed during editing." 94 95 return self.get_original(key) != self.get(key) 96 97 # Dictionary emulation methods. 98 99 def __getitem__(self, key): 100 return self.get(key) 101 102 def __setitem__(self, key, value): 103 self.set(key, value) 104 105 106 107 # Object editing abstractions. 108 109 class EditingClient(ClientForObject): 110 111 "A simple calendar client." 112 113 def __init__(self, user, messenger, store, preferences_dir): 114 ClientForObject.__init__(self, None, user, messenger, store, 115 preferences_dir=preferences_dir) 116 self.reset() 117 118 # Editing state. 119 120 def reset(self): 121 122 "Reset the editing state." 123 124 self.state = State({ 125 "attendees" : lambda: OrderedDict(self.obj.get_items("ATTENDEE") or []), 126 "organiser" : lambda: self.obj.get_value("ORGANIZER"), 127 "periods" : lambda: form_periods_from_periods(self.get_unedited_periods()), 128 "suggested_attendees" : self.get_suggested_attendees, 129 "suggested_periods" : self.get_suggested_periods, 130 "summary" : lambda: self.obj.get_value("SUMMARY"), 131 }) 132 133 # Access to stored and current information. 134 135 def get_stored_periods(self): 136 137 """ 138 Return the stored, unrevised, integral periods for the event, excluding 139 revisions from separate recurrence instances. 140 """ 141 142 return event_periods_from_periods(self.get_periods()) 143 144 def get_unedited_periods(self): 145 146 """ 147 Return the original, unedited periods including revisions from separate 148 recurrence instances. 149 """ 150 151 return event_periods_from_updated_periods(self.get_updated_periods()) 152 153 def get_counters(self): 154 155 "Return a counter-proposal mapping from attendees to objects." 156 157 d = {} 158 159 # Get counter-proposals for the specific object. 160 161 recurrenceids = [self.recurrenceid] 162 163 # And for all recurrences associated with a parent object. 164 165 if not self.recurrenceid: 166 recurrenceids += self.store.get_counter_recurrences(self.user, self.uid) 167 168 # Map attendees to objects. 169 170 for recurrenceid in recurrenceids: 171 attendees = self.store.get_counters(self.user, self.uid, recurrenceid) 172 for attendee in attendees: 173 if not d.has_key(attendee): 174 d[attendee] = [] 175 d[attendee].append(self.get_stored_object(self.uid, recurrenceid, "counters", attendee)) 176 177 return d 178 179 def get_suggested_attendees(self): 180 181 "For all counter-proposals, return suggested attendee items." 182 183 existing = self.state.get("attendees") 184 l = [] 185 for attendee, objects in self.get_counters().items(): 186 for obj in objects: 187 for suggested, attr in obj.get_items("ATTENDEE"): 188 if suggested not in existing: 189 l.append((attendee, (suggested, attr))) 190 return l 191 192 def get_suggested_periods(self): 193 194 "For all counter-proposals, return suggested event periods." 195 196 existing = self.state.get("periods") 197 198 # Get active periods for filtering of suggested periods. 199 200 active = [] 201 for p in existing: 202 if not p.cancelled: 203 active.append(p) 204 205 suggested = [] 206 207 for attendee, objects in self.get_counters().items(): 208 209 # For each object, obtain suggested periods. 210 211 for obj in objects: 212 213 # Obtain the current periods for the object providing the 214 # suggested periods. 215 216 updated = self.get_updated_periods(obj) 217 suggestions = event_periods_from_updated_periods(updated) 218 219 # Compare current periods with suggested periods. 220 221 new = set(suggestions).difference(active) 222 223 # Treat each specific recurrence as affecting only the original 224 # period. 225 226 if obj.get_recurrenceid(): 227 removed = [] 228 else: 229 removed = set(active).difference(suggestions) 230 231 # Associate new and removed periods with the attendee. 232 233 for period in new: 234 suggested.append((attendee, period, "add")) 235 236 for period in removed: 237 suggested.append((attendee, period, "remove")) 238 239 return suggested 240 241 # Validation methods. 242 243 def get_checked_periods(self): 244 245 """ 246 Check the edited periods and return objects representing them, setting 247 the "periods" state. If errors occur, raise an exception and set the 248 "errors" state. 249 """ 250 251 self.state["period_errors"] = errors = {} 252 253 # Basic validation. 254 255 try: 256 periods = event_periods_from_periods(self.state.get("periods")) 257 258 except PeriodError, exc: 259 260 # Obtain error and period index details from the exception, 261 # collecting errors for each index position. 262 263 for err, index in exc.args: 264 l = errors.get(index) 265 if not l: 266 l = errors[index] = [] 267 l.append(err) 268 raise 269 270 # Check for overlapping periods. 271 272 overlapping = get_overlapping_members(periods) 273 274 for period in overlapping: 275 for index, p in enumerate(periods): 276 if period is p: 277 errors[index] = ["overlap"] 278 279 if overlapping: 280 raise PeriodError 281 282 self.state["periods"] = form_periods_from_periods(periods) 283 return periods 284 285 # Update result computation. 286 287 def classify_attendee_changes(self): 288 289 "Classify the attendees in the event." 290 291 original = self.state.get_original("attendees") 292 current = self.state.get("attendees") 293 return classify_attendee_changes(original, current) 294 295 def classify_attendee_operations(self): 296 297 "Classify attendee update operations." 298 299 new, modified, unmodified, removed = self.classify_attendee_changes() 300 301 if self.is_organiser(): 302 to_invite = new 303 to_cancel = removed 304 to_modify = modified 305 else: 306 to_invite = new 307 to_cancel = {} 308 to_modify = modified 309 310 return to_invite, to_cancel, to_modify 311 312 def classify_period_changes(self): 313 314 "Classify changes in the updated periods for the edited event." 315 316 updated = self.combine_periods_for_comparison() 317 return classify_period_changes(updated) 318 319 def classify_periods(self): 320 321 "Classify the updated periods for the edited event." 322 323 updated = self.combine_periods() 324 return classify_periods(updated) 325 326 def combine_periods(self): 327 328 "Combine stored and checked edited periods to make updated periods." 329 330 stored = self.get_stored_periods() 331 current = self.get_checked_periods() 332 return combine_periods(stored, current) 333 334 def combine_periods_for_comparison(self): 335 336 "Combine unedited and checked edited periods to make updated periods." 337 338 original = self.get_unedited_periods() 339 current = self.get_checked_periods() 340 return combine_periods(original, current) 341 342 def classify_period_operations(self, is_changed=False): 343 344 "Classify period update operations." 345 346 new, replaced, retained, cancelled, obsolete = self.classify_periods() 347 348 modified, unmodified, removed = self.classify_period_changes() 349 350 is_organiser = self.is_organiser() 351 is_shared = self.obj.is_shared() 352 353 return classify_period_operations(new, replaced, retained, cancelled, 354 obsolete, modified, removed, 355 is_organiser, is_shared, is_changed) 356 357 def properties_changed(self): 358 359 "Test for changes in event details." 360 361 is_changed = [] 362 363 for name in ["summary"]: 364 if self.state.has_changed(name): 365 is_changed.append(name) 366 367 return is_changed 368 369 def finish(self): 370 371 "Finish editing, writing edited details to the object." 372 373 if self.state.get("finished"): 374 return 375 376 is_changed = self.properties_changed() 377 378 # Determine attendee modifications. 379 380 self.state["attendee_operations"] = \ 381 to_invite, to_cancel, to_modify = \ 382 self.classify_attendee_operations() 383 384 self.state["attendees_to_cancel"] = to_cancel 385 386 # Determine period modification operations. 387 # Use property changes and attendee suggestions to affect the result for 388 # attendee responses. 389 390 is_changed = is_changed or to_invite 391 392 self.state["period_operations"] = \ 393 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 394 all_unscheduled, all_rescheduled = \ 395 self.classify_period_operations(is_changed) 396 397 # Determine whole event update status. 398 399 is_changed = is_changed or to_set 400 401 # Update event details. 402 403 if self.can_edit_properties(): 404 self.obj.set_value("SUMMARY", self.state.get("summary")) 405 406 self.update_attendees(to_invite, to_cancel, to_modify) 407 self.update_event_from_periods(to_set, to_exclude) 408 409 # Classify the nature of any update. 410 411 if is_changed: 412 self.state["changed"] = "complete" 413 elif to_reschedule or to_unschedule or to_add: 414 self.state["changed"] = "incremental" 415 416 self.state["finished"] = self.update_event_version(is_changed) 417 418 # Update preparation. 419 420 def have_update(self): 421 422 "Return whether an update can be prepared and sent." 423 424 return not self.is_organiser() or \ 425 not self.obj.is_shared() or \ 426 self.obj.is_shared() and self.state.get("changed") and \ 427 self.have_other_attendees() 428 429 def have_other_attendees(self): 430 431 "Return whether any attendees other than the user are present." 432 433 attendees = self.state.get("attendees") 434 return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1) 435 436 def prepare_cancel_message(self): 437 438 "Prepare the cancel message for uninvited attendees." 439 440 to_cancel = self.state.get("attendees_to_cancel") 441 return self.make_cancel_message(to_cancel) 442 443 def prepare_publish_message(self): 444 445 "Prepare the publishing message for the updated event." 446 447 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 448 all_unscheduled, all_rescheduled = self.state.get("period_operations") 449 450 return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add) 451 452 def prepare_update_message(self): 453 454 "Prepare the update message for the updated event." 455 456 if not self.have_update(): 457 return None 458 459 # Obtain operation details. 460 461 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 462 all_unscheduled, all_rescheduled = self.state.get("period_operations") 463 464 # Prepare the message. 465 466 recipients = self.get_recipients() 467 update_parent = self.state["changed"] == "complete" 468 469 if self.is_organiser(): 470 return self.make_update_message(recipients, update_parent, 471 to_unschedule, to_reschedule, 472 all_unscheduled, all_rescheduled, 473 to_add) 474 else: 475 return self.make_response_message(recipients, update_parent, 476 all_rescheduled, to_reschedule) 477 478 # Modification methods. 479 480 def add_attendee(self, uri=None): 481 482 "Add a blank attendee." 483 484 attendees = self.state.get("attendees") 485 attendees[uri or ""] = {"PARTSTAT" : "NEEDS-ACTION"} 486 487 def add_suggested_attendee(self, index): 488 489 "Add the suggested attendee at 'index' to the event." 490 491 attendees = self.state.get("attendees") 492 suggested_attendees = self.state.get("suggested_attendees") 493 try: 494 attendee, (suggested, attr) = suggested_attendees[index] 495 self.add_attendee(suggested) 496 except IndexError: 497 pass 498 499 def add_period(self): 500 501 "Add a copy of the main period as a new recurrence." 502 503 current = self.state.get("periods") 504 new = get_main_period(current).copy() 505 new.origin = "RDATE" 506 new.replacement = False 507 new.recurrenceid = False 508 new.cancelled = False 509 current.append(new) 510 511 def apply_suggested_period(self, index): 512 513 "Apply the suggested period at 'index' to the event." 514 515 current = self.state.get("periods") 516 suggested = self.state.get("suggested_periods") 517 518 try: 519 attendee, period, operation = suggested[index] 520 period = form_period_from_period(period) 521 522 # Cancel any removed periods. 523 524 if operation == "remove": 525 for index, p in enumerate(current): 526 if p == period: 527 self.cancel_periods([index]) 528 break 529 530 # Add or replace any other suggestions. 531 532 elif operation == "add": 533 534 # Make the status of the period compatible. 535 536 period.cancelled = False 537 period.origin = "DTSTART-RECUR" 538 539 # Either replace or add the period. 540 541 recurrenceid = period.get_recurrenceid() 542 543 for i, p in enumerate(current): 544 if p.get_recurrenceid() == recurrenceid: 545 current[i] = period 546 break 547 548 # Add as a new period. 549 550 else: 551 period.recurrenceid = None 552 current.append(period) 553 554 except IndexError: 555 pass 556 557 def cancel_periods(self, indexes, cancelled=True): 558 559 """ 560 Set cancellation state for periods with the given 'indexes', indicating 561 'cancelled' as a true or false value. New periods will be removed if 562 cancelled. 563 """ 564 565 periods = self.state.get("periods") 566 to_remove = [] 567 removed = 0 568 569 for index in indexes: 570 p = periods[index] 571 572 # Make replacements from existing periods and cancel them. 573 574 if p.recurrenceid: 575 p.replacement = True 576 p.cancelled = cancelled 577 578 # Remove new periods completely. 579 580 elif cancelled: 581 to_remove.append(index - removed) 582 removed += 1 583 584 for index in to_remove: 585 del periods[index] 586 587 def can_edit_attendance(self): 588 589 "Return whether the organiser's attendance can be edited." 590 591 return self.state.get("attendees").has_key(self.user) 592 593 def edit_attendance(self, partstat): 594 595 "Set the 'partstat' of the current user, if attending." 596 597 attendees = self.state.get("attendees") 598 attr = attendees.get(self.user) 599 600 # Set the attendance for the user, if attending. 601 602 if attr is not None: 603 new_attr = {} 604 new_attr.update(attr) 605 new_attr["PARTSTAT"] = partstat 606 attendees[self.user] = new_attr 607 608 def can_edit_attendee(self, index): 609 610 """ 611 Return whether the attendee at 'index' can be edited, requiring either 612 the organiser and an unshared event, or a new attendee. 613 """ 614 615 attendees = self.state.get("attendees") 616 attendee = attendees.keys()[index] 617 618 try: 619 attr = attendees[attendee] 620 if self.is_organiser() and not self.obj.is_shared() or not attr: 621 return (attendee, attr) 622 except IndexError: 623 pass 624 625 return None 626 627 def can_remove_attendee(self, index): 628 629 """ 630 Return whether the attendee at 'index' can be removed, requiring either 631 the organiser or a new attendee. 632 """ 633 634 attendees = self.state.get("attendees") 635 attendee = attendees.keys()[index] 636 637 try: 638 attr = attendees[attendee] 639 if self.is_organiser() or not attr: 640 return (attendee, attr) 641 except IndexError: 642 pass 643 644 return None 645 646 def remove_attendees(self, indexes): 647 648 "Remove attendee at 'index'." 649 650 attendees = self.state.get("attendees") 651 to_remove = [] 652 653 for index in indexes: 654 attendee_item = self.can_remove_attendee(index) 655 if attendee_item: 656 attendee, attr = attendee_item 657 to_remove.append(attendee) 658 659 for key in to_remove: 660 del attendees[key] 661 662 def can_edit_period(self, index): 663 664 """ 665 Return the period at 'index' for editing or None if it cannot be edited. 666 """ 667 668 try: 669 return self.state.get("periods")[index] 670 except IndexError: 671 return None 672 673 def can_edit_properties(self): 674 675 "Return whether general event properties can be edited." 676 677 return True 678 679 680 681 # Period-related abstractions. 682 683 class PeriodError(Exception): 684 pass 685 686 class EditablePeriod(RecurringPeriod): 687 688 "An editable period tracking the identity of any original period." 689 690 def _get_recurrenceid_item(self): 691 692 # Convert any stored identifier to the current time zone. 693 # NOTE: This should not be necessary, but is done for consistency with 694 # NOTE: the datetime properties. 695 696 dt = get_datetime(self.recurrenceid) 697 dt = to_timezone(dt, self.tzid) 698 return dt, get_datetime_attributes(dt) 699 700 def get_recurrenceid(self): 701 702 """ 703 Return a recurrence identity to be used to associate stored periods with 704 edited periods. 705 """ 706 707 if not self.recurrenceid: 708 return RecurringPeriod.get_recurrenceid(self) 709 return self.recurrenceid 710 711 def get_recurrenceid_item(self): 712 713 """ 714 Return a recurrence identifier value and datetime properties for use in 715 specifying the RECURRENCE-ID property. 716 """ 717 718 if not self.recurrenceid: 719 return RecurringPeriod.get_recurrenceid_item(self) 720 return self._get_recurrenceid_item() 721 722 class EventPeriod(EditablePeriod): 723 724 """ 725 A simple period plus attribute details, compatible with RecurringPeriod, and 726 intended to represent information obtained from an iCalendar resource. 727 """ 728 729 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 730 end_attr=None, form_start=None, form_end=None, 731 replacement=False, cancelled=False, recurrenceid=None): 732 733 """ 734 Initialise a period with the given 'start' and 'end' datetimes. 735 736 The optional 'tzid' provides time zone information, and the optional 737 'origin' indicates the kind of period this object describes. 738 739 The optional 'start_attr' and 'end_attr' provide metadata for the start 740 and end datetimes respectively, and 'form_start' and 'form_end' are 741 values provided as textual input. 742 743 The 'replacement' flag indicates whether the period is provided by a 744 separate recurrence instance. 745 746 The 'cancelled' flag indicates whether a separate recurrence is 747 cancelled. 748 749 The 'recurrenceid' describes the original identity of the period, 750 regardless of whether it is separate or not. 751 """ 752 753 EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 754 self.form_start = form_start 755 self.form_end = form_end 756 757 # Information about whether a separate recurrence provides this period 758 # and the original period identity. 759 760 self.replacement = replacement 761 self.cancelled = cancelled 762 self.recurrenceid = recurrenceid 763 764 # Additional editing state. 765 766 self.new_replacement = False 767 768 def as_tuple(self): 769 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 770 self.end_attr, self.form_start, self.form_end, self.replacement, \ 771 self.cancelled, self.recurrenceid 772 773 def __repr__(self): 774 return "EventPeriod%r" % (self.as_tuple(),) 775 776 def copy(self): 777 return EventPeriod(*self.as_tuple()) 778 779 def as_event_period(self, index=None): 780 return self 781 782 def get_start_item(self): 783 return self.get_start(), self.get_start_attr() 784 785 def get_end_item(self): 786 return self.get_end(), self.get_end_attr() 787 788 # Form data compatibility methods. 789 790 def get_form_start(self): 791 if not self.form_start: 792 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 793 return self.form_start 794 795 def get_form_end(self): 796 if not self.form_end: 797 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 798 return self.form_end 799 800 def as_form_period(self): 801 return FormPeriod( 802 self.get_form_start(), 803 self.get_form_end(), 804 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 805 isinstance(self.start, datetime) or isinstance(self.end, datetime), 806 self.tzid, 807 self.origin, 808 self.replacement, 809 self.cancelled, 810 self.recurrenceid 811 ) 812 813 def get_form_date(self, dt, attr=None): 814 return FormDate( 815 format_datetime(to_date(dt)), 816 isinstance(dt, datetime) and str(dt.hour) or None, 817 isinstance(dt, datetime) and str(dt.minute) or None, 818 isinstance(dt, datetime) and str(dt.second) or None, 819 attr and attr.get("TZID") or None, 820 dt, attr 821 ) 822 823 class FormPeriod(EditablePeriod): 824 825 "A period whose information originates from a form." 826 827 def __init__(self, start, end, end_enabled=True, times_enabled=True, 828 tzid=None, origin=None, replacement=False, cancelled=False, 829 recurrenceid=None): 830 self.start = start 831 self.end = end 832 self.end_enabled = end_enabled 833 self.times_enabled = times_enabled 834 self.tzid = tzid 835 self.origin = origin 836 self.replacement = replacement 837 self.cancelled = cancelled 838 self.recurrenceid = recurrenceid 839 self.new_replacement = False 840 841 def as_tuple(self): 842 return self.start, self.end, self.end_enabled, self.times_enabled, \ 843 self.tzid, self.origin, self.replacement, self.cancelled, \ 844 self.recurrenceid 845 846 def __repr__(self): 847 return "FormPeriod%r" % (self.as_tuple(),) 848 849 def copy(self): 850 args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:] 851 return FormPeriod(*args) 852 853 def as_event_period(self, index=None): 854 855 """ 856 Return a converted version of this object as an event period suitable 857 for iCalendar usage. If 'index' is indicated, include it in any error 858 raised in the conversion process. 859 """ 860 861 dtstart, dtstart_attr = self.get_start_item() 862 if not dtstart: 863 if index is not None: 864 raise PeriodError(("dtstart", index)) 865 else: 866 raise PeriodError("dtstart") 867 868 dtend, dtend_attr = self.get_end_item() 869 if not dtend: 870 if index is not None: 871 raise PeriodError(("dtend", index)) 872 else: 873 raise PeriodError("dtend") 874 875 if dtstart > dtend: 876 if index is not None: 877 raise PeriodError(("dtstart", index), ("dtend", index)) 878 else: 879 raise PeriodError("dtstart", "dtend") 880 881 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 882 self.origin, dtstart_attr, dtend_attr, 883 self.start, self.end, self.replacement, 884 self.cancelled, self.recurrenceid) 885 886 # Period data methods. 887 888 def get_start(self): 889 return self.start and self.start.as_datetime(self.times_enabled) or None 890 891 def get_end(self): 892 893 # Handle specified end datetimes. 894 895 if self.end_enabled: 896 dtend = self.end.as_datetime(self.times_enabled) 897 if not dtend: 898 return None 899 900 # Handle same day times. 901 902 elif self.times_enabled: 903 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 904 dtend = formdate.as_datetime(self.times_enabled) 905 if not dtend: 906 return None 907 908 # Otherwise, treat the end date as the start date. Datetimes are 909 # handled by making the event occupy the rest of the day. 910 911 else: 912 dtstart, dtstart_attr = self.get_start_item() 913 if dtstart: 914 if isinstance(dtstart, datetime): 915 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 916 else: 917 dtend = dtstart 918 else: 919 return None 920 921 return dtend 922 923 def get_start_attr(self): 924 return self.start and self.start.get_attributes(self.times_enabled) or {} 925 926 def get_end_attr(self): 927 return self.end and self.end.get_attributes(self.times_enabled) or {} 928 929 # Form data methods. 930 931 def get_form_start(self): 932 return self.start 933 934 def get_form_end(self): 935 return self.end 936 937 def as_form_period(self): 938 return self 939 940 class FormDate: 941 942 "Date information originating from form information." 943 944 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 945 self.date = date 946 self.hour = hour 947 self.minute = minute 948 self.second = second 949 self.tzid = tzid 950 self.dt = dt 951 self.attr = attr 952 953 def as_tuple(self): 954 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 955 956 def copy(self): 957 return FormDate(*self.as_tuple()) 958 959 def reset(self): 960 self.dt = None 961 962 def __repr__(self): 963 return "FormDate%r" % (self.as_tuple(),) 964 965 def get_component(self, value): 966 return (value or "").rjust(2, "0")[:2] 967 968 def get_hour(self): 969 return self.get_component(self.hour) 970 971 def get_minute(self): 972 return self.get_component(self.minute) 973 974 def get_second(self): 975 return self.get_component(self.second) 976 977 def get_date_string(self): 978 return self.date or "" 979 980 def get_datetime_string(self): 981 if not self.date: 982 return "" 983 984 hour = self.hour; minute = self.minute; second = self.second 985 986 if hour or minute or second: 987 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 988 else: 989 time = "" 990 991 return "%s%s" % (self.date, time) 992 993 def get_tzid(self): 994 return self.tzid 995 996 def as_datetime(self, with_time=True): 997 998 """ 999 Return a datetime for this object if one is provided or can be produced. 1000 """ 1001 1002 # Return any original datetime details. 1003 1004 if self.dt: 1005 return self.dt 1006 1007 # Otherwise, construct a datetime. 1008 1009 s, attr = self.as_datetime_item(with_time) 1010 if not s: 1011 return None 1012 1013 # An erroneous datetime will yield None as result. 1014 1015 try: 1016 return get_datetime(s, attr) 1017 except ValueError: 1018 return None 1019 1020 def as_datetime_item(self, with_time=True): 1021 1022 """ 1023 Return a (datetime string, attr) tuple for the datetime information 1024 provided by this object, where both tuple elements will be None if no 1025 suitable date or datetime information exists. 1026 """ 1027 1028 s = None 1029 if with_time: 1030 s = self.get_datetime_string() 1031 attr = self.get_attributes(True) 1032 if not s: 1033 s = self.get_date_string() 1034 attr = self.get_attributes(False) 1035 if not s: 1036 return None, None 1037 return s, attr 1038 1039 def get_attributes(self, with_time=True): 1040 1041 "Return attributes for the date or datetime represented by this object." 1042 1043 if with_time: 1044 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 1045 else: 1046 return {"VALUE" : "DATE"} 1047 1048 def event_period_from_period(period, index=None): 1049 1050 """ 1051 Convert a 'period' to one suitable for use in an iCalendar representation. 1052 In an "event period" representation, the end day of any date-level event is 1053 encoded as the "day after" the last day actually involved in the event. 1054 """ 1055 1056 if isinstance(period, EventPeriod): 1057 return period 1058 elif isinstance(period, FormPeriod): 1059 return period.as_event_period(index) 1060 else: 1061 dtstart, dtstart_attr = period.get_start_item() 1062 dtend, dtend_attr = period.get_end_item() 1063 1064 if not isinstance(period, RecurringPeriod): 1065 dtend = end_date_to_calendar(dtend) 1066 1067 return EventPeriod(dtstart, dtend, period.tzid, period.origin, 1068 dtstart_attr, dtend_attr, 1069 recurrenceid=format_datetime(to_utc_datetime(dtstart))) 1070 1071 def event_periods_from_periods(periods): 1072 return map(event_period_from_period, periods, range(0, len(periods))) 1073 1074 def form_period_from_period(period): 1075 1076 """ 1077 Convert a 'period' into a representation usable in a user-editable form. 1078 In a "form period" representation, the end day of any date-level event is 1079 presented in a "natural" form, not the iCalendar "day after" form. 1080 """ 1081 1082 if isinstance(period, EventPeriod): 1083 return period.as_form_period() 1084 elif isinstance(period, FormPeriod): 1085 return period 1086 else: 1087 return event_period_from_period(period).as_form_period() 1088 1089 def form_periods_from_periods(periods): 1090 return map(form_period_from_period, periods) 1091 1092 1093 1094 # Event period processing. 1095 1096 def periods_from_updated_periods(updated_periods, fn): 1097 1098 """ 1099 Return periods from the given 'updated_periods' created using 'fn', setting 1100 replacement, cancelled and recurrence identifier details. 1101 1102 This function should be used to produce editing-related periods from the 1103 general updated periods provided by the client abstractions. 1104 """ 1105 1106 periods = [] 1107 1108 for sp, p in updated_periods: 1109 1110 # Stored periods with corresponding current periods. 1111 1112 if p: 1113 period = fn(p) 1114 1115 # Replacements are identified by comparing object identities, since 1116 # a replacement will not be provided by the same object. 1117 1118 if sp is not p: 1119 period.replacement = True 1120 1121 # Stored periods without corresponding current periods. 1122 1123 else: 1124 period = fn(sp) 1125 period.replacement = True 1126 period.cancelled = True 1127 1128 # Replace the recurrence identifier with that of the original period. 1129 1130 period.recurrenceid = sp.get_recurrenceid() 1131 periods.append(period) 1132 1133 return periods 1134 1135 def event_periods_from_updated_periods(updated_periods): 1136 return periods_from_updated_periods(updated_periods, event_period_from_period) 1137 1138 def form_periods_from_updated_periods(updated_periods): 1139 return periods_from_updated_periods(updated_periods, form_period_from_period) 1140 1141 def periods_by_recurrence(periods): 1142 1143 """ 1144 Return a mapping from recurrence identifier to period for 'periods' along 1145 with a collection of unmapped periods. 1146 """ 1147 1148 d = {} 1149 new = [] 1150 1151 for p in periods: 1152 if not p.recurrenceid: 1153 new.append(p) 1154 else: 1155 d[p.recurrenceid] = p 1156 1157 return d, new 1158 1159 def combine_periods(old, new): 1160 1161 """ 1162 Combine 'old' and 'new' periods for comparison, making a list of (old, new) 1163 updated period tuples. 1164 """ 1165 1166 old_by_recurrenceid, _new_periods = periods_by_recurrence(old) 1167 new_by_recurrenceid, new_periods = periods_by_recurrence(new) 1168 1169 combined = [] 1170 1171 for recurrenceid, op in old_by_recurrenceid.items(): 1172 np = new_by_recurrenceid.get(recurrenceid) 1173 1174 # Old period has corresponding new period that is not cancelled. 1175 1176 if np and not (np.cancelled and not op.cancelled): 1177 combined.append((op, np)) 1178 1179 # No corresponding new, uncancelled period. 1180 1181 else: 1182 combined.append((op, None)) 1183 1184 # New periods without corresponding old periods are genuinely new. 1185 1186 for np in new_periods: 1187 combined.append((None, np)) 1188 1189 # Note that new periods should not have recurrence identifiers, and if 1190 # imported from other events, they should have such identifiers removed. 1191 1192 return combined 1193 1194 def classify_periods(updated_periods): 1195 1196 """ 1197 Using the 'updated_periods', being a list of (stored, current) periods, 1198 return a tuple containing collections of new, replaced, retained, cancelled 1199 and obsolete periods. 1200 1201 Note that replaced and retained indicate the presence or absence of 1202 differences between the original event periods and the current periods that 1203 would need to be represented using separate recurrence instances, not 1204 whether any editing operations have changed the periods. 1205 1206 Obsolete periods are those that have been replaced but not cancelled. 1207 """ 1208 1209 new = [] 1210 replaced = [] 1211 retained = [] 1212 cancelled = [] 1213 obsolete = [] 1214 1215 for sp, p in updated_periods: 1216 1217 # Stored periods... 1218 1219 if sp: 1220 1221 # With cancelled or absent current periods. 1222 1223 if not p or p.cancelled: 1224 cancelled.append(sp) 1225 1226 # With differing or replacement current periods. 1227 1228 elif p != sp or p.replacement: 1229 replaced.append(p) 1230 if not p.replacement: 1231 p.new_replacement = True 1232 obsolete.append(sp) 1233 1234 # With retained, not differing current periods. 1235 1236 else: 1237 retained.append(p) 1238 if p.new_replacement: 1239 p.new_replacement = False 1240 1241 # New periods without corresponding stored periods. 1242 1243 elif p: 1244 new.append(p) 1245 1246 return new, replaced, retained, cancelled, obsolete 1247 1248 def classify_period_changes(updated_periods): 1249 1250 """ 1251 Using the 'updated_periods', being a list of (original, current) periods, 1252 return a tuple containing collections of modified, unmodified and removed 1253 periods. 1254 """ 1255 1256 modified = [] 1257 unmodified = [] 1258 removed = [] 1259 1260 for op, p in updated_periods: 1261 1262 # Test for periods cancelled, reinstated or changed, or left unmodified 1263 # during editing. 1264 1265 if op: 1266 if not op.cancelled and (not p or p.cancelled): 1267 removed.append(op) 1268 elif op.cancelled and not p.cancelled or p != op: 1269 modified.append(p) 1270 else: 1271 unmodified.append(p) 1272 1273 # New periods are always modifications. 1274 1275 elif p: 1276 modified.append(p) 1277 1278 return modified, unmodified, removed 1279 1280 def classify_period_operations(new, replaced, retained, cancelled, 1281 obsolete, modified, removed, 1282 is_organiser, is_shared, is_changed): 1283 1284 """ 1285 Classify the operations for the update of an event. For updates modifying 1286 shared events, return periods for descheduling and rescheduling (where these 1287 operations can modify the event), and periods for exclusion and application 1288 (where these operations redefine the event). 1289 1290 To define the new state of the event, details of the complete set of 1291 unscheduled and rescheduled periods are also provided. 1292 """ 1293 1294 active_periods = new + replaced + retained 1295 1296 # Modified replaced and retained recurrences are used for incremental 1297 # updates. 1298 1299 replaced_modified = select_recurrences(replaced, modified).values() 1300 retained_modified = select_recurrences(retained, modified).values() 1301 1302 # Unmodified replaced and retained recurrences are used in the complete 1303 # event summary. 1304 1305 replaced_unmodified = subtract_recurrences(replaced, modified).values() 1306 retained_unmodified = subtract_recurrences(retained, modified).values() 1307 1308 # Obtain the removed periods in terms of existing periods. These are used in 1309 # incremental updates. 1310 1311 cancelled_removed = select_recurrences(cancelled, removed).values() 1312 1313 # Reinstated periods are previously-cancelled periods that are now modified 1314 # periods, and they appear in updates. 1315 1316 reinstated = select_recurrences(modified, cancelled).values() 1317 1318 # Get cancelled periods without reinstated periods. These appear in complete 1319 # event summaries. 1320 1321 cancelled_unmodified = subtract_recurrences(cancelled, modified).values() 1322 1323 # Cancelled periods originating from rules must be excluded since there are 1324 # no explicit instances to be deleted. 1325 1326 cancelled_rule = [] 1327 for p in cancelled_removed: 1328 if p.origin == "RRULE": 1329 cancelled_rule.append(p) 1330 1331 # Obsolete periods (replaced by other periods) originating from rules must 1332 # be excluded if no explicit instance will be used to replace them. 1333 1334 obsolete_rule = [] 1335 for p in obsolete: 1336 if p.origin == "RRULE": 1337 obsolete_rule.append(p) 1338 1339 # As organiser... 1340 1341 if is_organiser: 1342 1343 # For unshared events... 1344 # All modifications redefine the event. 1345 1346 # For shared events... 1347 # New periods should cause the event to be redefined. 1348 # Other changes should also cause event redefinition. 1349 # Event redefinition should only occur if no replacement periods exist. 1350 # Cancelled rule-originating periods must be excluded. 1351 1352 if not is_shared or new and not replaced: 1353 to_set = active_periods 1354 to_exclude = list(chain(cancelled_rule, obsolete_rule)) 1355 to_unschedule = [] 1356 to_reschedule = [] 1357 to_add = [] 1358 all_unscheduled = [] 1359 all_rescheduled = [] 1360 1361 # Changed periods should be rescheduled separately. 1362 # Removed periods should be cancelled separately. 1363 1364 else: 1365 to_set = [] 1366 to_exclude = [] 1367 to_unschedule = cancelled_removed 1368 to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) 1369 to_add = new 1370 all_unscheduled = cancelled_unmodified 1371 all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) 1372 1373 # As attendee... 1374 1375 else: 1376 to_unschedule = [] 1377 to_add = [] 1378 1379 # Changed periods without new or removed periods are proposed as 1380 # separate changes. Parent event changes cause redefinition of the 1381 # entire event. 1382 1383 if not new and not removed and not is_changed: 1384 to_set = [] 1385 to_exclude = [] 1386 to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) 1387 all_unscheduled = list(cancelled_unmodified) 1388 all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) 1389 1390 # Otherwise, the event is defined in terms of new periods and 1391 # exceptions for removed periods or obsolete rule periods. 1392 1393 else: 1394 to_set = active_periods 1395 to_exclude = list(chain(cancelled, obsolete_rule)) 1396 to_reschedule = [] 1397 all_unscheduled = [] 1398 all_rescheduled = [] 1399 1400 return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled 1401 1402 def get_period_mapping(periods): 1403 1404 "Return a mapping of recurrence identifiers to the given 'periods." 1405 1406 d, new = periods_by_recurrence(periods) 1407 return d 1408 1409 def select_recurrences(source, selected): 1410 1411 "Restrict 'source' to the recurrences referenced by 'selected'." 1412 1413 mapping = get_period_mapping(source) 1414 1415 recurrenceids = get_recurrenceids(selected) 1416 for recurrenceid in mapping.keys(): 1417 if not recurrenceid in recurrenceids: 1418 del mapping[recurrenceid] 1419 return mapping 1420 1421 def subtract_recurrences(source, selected): 1422 1423 "Remove from 'source' the recurrences referenced by 'selected'." 1424 1425 mapping = get_period_mapping(source) 1426 1427 for recurrenceid in get_recurrenceids(selected): 1428 if mapping.has_key(recurrenceid): 1429 del mapping[recurrenceid] 1430 return mapping 1431 1432 def get_recurrenceids(periods): 1433 1434 "Return the recurrence identifiers employed by 'periods'." 1435 1436 return map(lambda p: p.get_recurrenceid(), periods) 1437 1438 1439 1440 # Form field extraction and serialisation. 1441 1442 def get_date_control_inputs(args, name, tzid_name=None): 1443 1444 """ 1445 Return a tuple of date control inputs taken from 'args' for field names 1446 starting with 'name'. 1447 1448 If 'tzid_name' is specified, the time zone information will be acquired 1449 from fields starting with 'tzid_name' instead of 'name'. 1450 """ 1451 1452 return args.get("%s-date" % name, []), \ 1453 args.get("%s-hour" % name, []), \ 1454 args.get("%s-minute" % name, []), \ 1455 args.get("%s-second" % name, []), \ 1456 args.get("%s-tzid" % (tzid_name or name), []) 1457 1458 def get_date_control_values(args, name, multiple=False, tzid_name=None, tzid=None): 1459 1460 """ 1461 Return a form date object representing fields taken from 'args' starting 1462 with 'name'. 1463 1464 If 'multiple' is set to a true value, many date objects will be returned 1465 corresponding to a collection of datetimes. 1466 1467 If 'tzid_name' is specified, the time zone information will be acquired 1468 from fields starting with 'tzid_name' instead of 'name'. 1469 1470 If 'tzid' is specified, it will provide the time zone where no explicit 1471 time zone information is indicated in the field data. 1472 """ 1473 1474 dates, hours, minutes, seconds, tzids = get_date_control_inputs(args, name, tzid_name) 1475 1476 # Handle absent values by employing None values. 1477 1478 field_values = map(None, dates, hours, minutes, seconds, tzids) 1479 1480 if not field_values and not multiple: 1481 all_values = FormDate() 1482 else: 1483 all_values = [] 1484 for date, hour, minute, second, tzid_field in field_values: 1485 value = FormDate(date, hour, minute, second, tzid_field or tzid) 1486 1487 # Return a single value or append to a collection of all values. 1488 1489 if not multiple: 1490 return value 1491 else: 1492 all_values.append(value) 1493 1494 return all_values 1495 1496 def set_date_control_values(formdates, args, name, tzid_name=None): 1497 1498 """ 1499 Using the values of the given 'formdates', replace form fields in 'args' 1500 starting with 'name'. 1501 1502 If 'tzid_name' is specified, the time zone information will be stored in 1503 fields starting with 'tzid_name' instead of 'name'. 1504 """ 1505 1506 args["%s-date" % name] = [] 1507 args["%s-hour" % name] = [] 1508 args["%s-minute" % name] = [] 1509 args["%s-second" % name] = [] 1510 args["%s-tzid" % (tzid_name or name)] = [] 1511 1512 for d in formdates: 1513 args["%s-date" % name].append(d and d.date or "") 1514 args["%s-hour" % name].append(d and d.hour or "") 1515 args["%s-minute" % name].append(d and d.minute or "") 1516 args["%s-second" % name].append(d and d.second or "") 1517 args["%s-tzid" % (tzid_name or name)].append(d and d.tzid or "") 1518 1519 def get_period_control_values(args, start_name, end_name, 1520 end_enabled_name, times_enabled_name, 1521 origin=None, origin_name=None, 1522 replacement_name=None, cancelled_name=None, 1523 recurrenceid_name=None, tzid=None): 1524 1525 """ 1526 Return period values from fields found in 'args' prefixed with the given 1527 'start_name' (for start dates), 'end_name' (for end dates), 1528 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 1529 (to enable times for periods). 1530 1531 If 'origin' is specified, a single period with the given origin is 1532 returned. If 'origin_name' is specified, fields containing the name will 1533 provide origin information. 1534 1535 If specified, fields containing 'replacement_name' will indicate periods 1536 provided by separate recurrences, fields containing 'cancelled_name' 1537 will indicate periods that are replacements and cancelled, and fields 1538 containing 'recurrenceid_name' will indicate periods that have existing 1539 recurrence details from an event. 1540 1541 If 'tzid' is specified, it will provide the time zone where no explicit 1542 time zone information is indicated in the field data. 1543 """ 1544 1545 # Get the end datetime and time presence settings. 1546 1547 all_end_enabled = args.get(end_enabled_name, []) 1548 all_times_enabled = args.get(times_enabled_name, []) 1549 1550 # Get the origins of period data and whether the periods are replacements. 1551 1552 if origin: 1553 all_origins = [origin] 1554 else: 1555 all_origins = origin_name and args.get(origin_name, []) or [] 1556 1557 all_replacements = replacement_name and args.get(replacement_name, []) or [] 1558 all_cancelled = cancelled_name and args.get(cancelled_name, []) or [] 1559 all_recurrenceids = recurrenceid_name and args.get(recurrenceid_name, []) or [] 1560 1561 # Get the start and end datetimes. 1562 1563 all_starts = get_date_control_values(args, start_name, True, tzid=tzid) 1564 all_ends = get_date_control_values(args, end_name, True, start_name, tzid=tzid) 1565 1566 # Construct period objects for each start, end, origin combination. 1567 1568 periods = [] 1569 1570 for index, (start, end, found_origin, recurrenceid) in \ 1571 enumerate(map(None, all_starts, all_ends, all_origins, all_recurrenceids)): 1572 1573 # Obtain period settings from separate controls. 1574 1575 end_enabled = str(index) in all_end_enabled 1576 times_enabled = str(index) in all_times_enabled 1577 replacement = str(index) in all_replacements 1578 cancelled = str(index) in all_cancelled 1579 1580 period = FormPeriod(start, end, end_enabled, times_enabled, tzid, 1581 found_origin or origin, replacement, cancelled, 1582 recurrenceid) 1583 periods.append(period) 1584 1585 # Return a single period if a single origin was specified. 1586 1587 if origin: 1588 return periods[0] 1589 else: 1590 return periods 1591 1592 def set_period_control_values(periods, args, start_name, end_name, 1593 end_enabled_name, times_enabled_name, 1594 origin_name=None, replacement_name=None, 1595 cancelled_name=None, recurrenceid_name=None): 1596 1597 """ 1598 Using the given 'periods', replace form fields in 'args' prefixed with the 1599 given 'start_name' (for start dates), 'end_name' (for end dates), 1600 'end_enabled_name' (to enable end dates for periods), 'times_enabled_name' 1601 (to enable times for periods). 1602 1603 If 'origin_name' is specified, fields containing the name will provide 1604 origin information, fields containing 'replacement_name' will indicate 1605 periods provided by separate recurrences, fields containing 'cancelled_name' 1606 will indicate periods that are replacements and cancelled, and fields 1607 containing 'recurrenceid_name' will indicate periods that have existing 1608 recurrence details from an event. 1609 """ 1610 1611 # Record period settings separately. 1612 1613 args[end_enabled_name] = [] 1614 args[times_enabled_name] = [] 1615 1616 # Record origin and replacement information if naming is defined. 1617 1618 if origin_name: 1619 args[origin_name] = [] 1620 1621 if replacement_name: 1622 args[replacement_name] = [] 1623 1624 if cancelled_name: 1625 args[cancelled_name] = [] 1626 1627 if recurrenceid_name: 1628 args[recurrenceid_name] = [] 1629 1630 all_starts = [] 1631 all_ends = [] 1632 1633 for index, period in enumerate(periods): 1634 1635 # Encode period settings in controls. 1636 1637 if period.end_enabled: 1638 args[end_enabled_name].append(str(index)) 1639 if period.times_enabled: 1640 args[times_enabled_name].append(str(index)) 1641 1642 # Add origin information where controls are present to record it. 1643 1644 if origin_name: 1645 args[origin_name].append(period.origin or "") 1646 1647 # Add replacement information where controls are present to record it. 1648 1649 if replacement_name and period.replacement: 1650 args[replacement_name].append(str(index)) 1651 1652 # Add cancelled recurrence information where controls are present to 1653 # record it. 1654 1655 if cancelled_name and period.cancelled: 1656 args[cancelled_name].append(str(index)) 1657 1658 # Add recurrence identifiers where controls are present to record it. 1659 1660 if recurrenceid_name: 1661 args[recurrenceid_name].append(period.recurrenceid or "") 1662 1663 # Collect form date information for addition below. 1664 1665 all_starts.append(period.get_form_start()) 1666 all_ends.append(period.get_form_end()) 1667 1668 # Set the controls for the dates. 1669 1670 set_date_control_values(all_starts, args, start_name) 1671 set_date_control_values(all_ends, args, end_name, tzid_name=start_name) 1672 1673 1674 1675 # Attendee processing. 1676 1677 def classify_attendee_changes(original, current): 1678 1679 """ 1680 Return categories of attendees given the 'original' and 'current' 1681 collections of attendees. 1682 """ 1683 1684 new = {} 1685 modified = {} 1686 unmodified = {} 1687 1688 # Check current attendees against the original ones. 1689 1690 for attendee, attendee_attr in current.items(): 1691 original_attr = original.get(attendee) 1692 1693 # New attendee if missing original details. 1694 1695 if not original_attr: 1696 new[attendee] = attendee_attr 1697 1698 # Details unchanged for existing attendee. 1699 1700 elif attendee_attr == original_attr: 1701 unmodified[attendee] = attendee_attr 1702 1703 # Details changed for existing attendee. 1704 1705 else: 1706 modified[attendee] = attendee_attr 1707 1708 removed = {} 1709 1710 # Check for removed attendees. 1711 1712 for attendee, attendee_attr in original.items(): 1713 if not current.has_key(attendee): 1714 removed[attendee] = attendee_attr 1715 1716 return new, modified, unmodified, removed 1717 1718 1719 1720 # Utilities. 1721 1722 def filter_duplicates(l): 1723 1724 """ 1725 Return collection 'l' filtered for duplicate values, retaining the given 1726 element ordering. 1727 """ 1728 1729 s = set() 1730 f = [] 1731 1732 for value in l: 1733 if value not in s: 1734 s.add(value) 1735 f.append(value) 1736 1737 return f 1738 1739 def remove_from_collection(l, indexes, fn): 1740 1741 """ 1742 Remove from collection 'l' all values present at the given 'indexes' where 1743 'fn' applied to each referenced value returns a true value. Values where 1744 'fn' returns a false value are added to a list of deferred removals which is 1745 returned. 1746 """ 1747 1748 still_to_remove = [] 1749 correction = 0 1750 1751 for i in indexes: 1752 try: 1753 i = int(i) - correction 1754 value = l[i] 1755 except (IndexError, ValueError): 1756 continue 1757 1758 if fn(value): 1759 del l[i] 1760 correction += 1 1761 else: 1762 still_to_remove.append(value) 1763 1764 return still_to_remove 1765 1766 # vim: tabstop=4 expandtab shiftwidth=4