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