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