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 return classify_period_changes(self.combine_periods()) 405 406 def classify_periods(self): 407 408 "Classify the updated periods for the edited event." 409 410 return classify_periods(self.combine_periods()) 411 412 def combine_periods(self): 413 414 "Combine unedited and checked edited periods to make updated periods." 415 416 original = self.state.get_original("periods") 417 current = self.get_checked_periods() 418 return combine_periods(original, current) 419 420 def classify_period_operations(self, is_changed=False): 421 422 "Classify period update operations." 423 424 new, replaced, retained, cancelled, obsolete = self.classify_periods() 425 426 modified, unmodified, removed = self.classify_period_changes() 427 428 is_organiser = self.is_organiser() 429 is_shared = self.obj.is_shared() 430 431 return classify_period_operations(new, replaced, retained, cancelled, 432 obsolete, modified, removed, 433 is_organiser, is_shared, is_changed) 434 435 def properties_changed(self): 436 437 "Test for changes in event details." 438 439 is_changed = [] 440 441 for name in ["summary"]: 442 if self.state.has_changed(name): 443 is_changed.append(name) 444 445 return is_changed 446 447 def finish(self): 448 449 "Finish editing, writing edited details to the object." 450 451 if self.state.get("finished"): 452 return 453 454 is_changed = self.properties_changed() 455 456 # Determine attendee modifications. 457 458 self.state["attendee_operations"] = \ 459 to_invite, to_cancel, to_modify = \ 460 self.classify_attendee_operations() 461 462 self.state["attendees_to_cancel"] = to_cancel 463 464 # Determine period modification operations. 465 # Use property changes and attendee suggestions to affect the result for 466 # attendee responses. 467 468 is_changed = is_changed or to_invite 469 470 self.state["period_operations"] = \ 471 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 472 all_unscheduled, all_rescheduled = \ 473 self.classify_period_operations(is_changed) 474 475 # Determine whole event update status. 476 477 is_changed = is_changed or to_set 478 479 # Update event details. 480 481 if self.can_edit_properties(): 482 self.obj.set_value("SUMMARY", self.state.get("summary")) 483 484 self.update_attendees(to_invite, to_cancel, to_modify) 485 self.update_event_from_periods(to_set, to_exclude) 486 487 # Classify the nature of any update. 488 489 if is_changed: 490 self.state["changed"] = "complete" 491 elif to_reschedule or to_unschedule or to_add: 492 self.state["changed"] = "incremental" 493 494 self.state["finished"] = self.update_event_version(is_changed) 495 496 # Update preparation. 497 498 def have_update(self): 499 500 "Return whether an update can be prepared and sent." 501 502 return not self.is_organiser() or \ 503 not self.obj.is_shared() or \ 504 self.obj.is_shared() and self.state.get("changed") and \ 505 self.have_other_attendees() 506 507 def have_other_attendees(self): 508 509 "Return whether any attendees other than the user are present." 510 511 attendees = self.state.get("attendees") 512 return attendees and (not attendees.has_key(self.user) or len(attendees.keys()) > 1) 513 514 def prepare_cancel_message(self): 515 516 "Prepare the cancel message for uninvited attendees." 517 518 to_cancel = self.state.get("attendees_to_cancel") 519 return self.make_cancel_message(to_cancel) 520 521 def prepare_publish_message(self): 522 523 "Prepare the publishing message for the updated event." 524 525 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 526 all_unscheduled, all_rescheduled = self.state.get("period_operations") 527 528 return self.make_self_update_message(all_unscheduled, all_rescheduled, to_add) 529 530 def prepare_update_message(self): 531 532 "Prepare the update message for the updated event." 533 534 if not self.have_update(): 535 return None 536 537 # Obtain operation details. 538 539 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 540 all_unscheduled, all_rescheduled = self.state.get("period_operations") 541 542 # Prepare the message. 543 544 recipients = self.get_recipients() 545 update_parent = self.state["changed"] == "complete" 546 547 if self.is_organiser(): 548 return self.make_update_message(recipients, update_parent, 549 to_unschedule, to_reschedule, 550 all_unscheduled, all_rescheduled, 551 to_add) 552 else: 553 return self.make_response_message(recipients, update_parent, 554 all_rescheduled, to_reschedule) 555 556 def get_publish_objects(self): 557 558 "Return details of unscheduled, rescheduled and added objects." 559 560 to_unschedule, to_reschedule, to_add, to_exclude, to_set, \ 561 all_unscheduled, all_rescheduled = self.state.get("period_operations") 562 563 return self.get_rescheduled_objects(all_unscheduled), \ 564 self.get_rescheduled_objects(all_rescheduled), \ 565 self.get_rescheduled_objects(to_add) 566 567 # Access methods. 568 569 def find_attendee(self, attendee): 570 571 "Return the index of 'attendee' or None if not present." 572 573 attendees = self.state.get("attendees") 574 try: 575 return attendees.keys().index(attendee) 576 except ValueError: 577 return None 578 579 # Modification methods. 580 581 def add_attendee(self, uri=None): 582 583 "Add a blank attendee." 584 585 attendees = self.state.get("attendees") 586 attendees[uri or ""] = {"PARTSTAT" : "NEEDS-ACTION"} 587 588 def add_suggested_attendee(self, index): 589 590 "Add the suggested attendee at 'index' to the event." 591 592 attendees = self.state.get("attendees") 593 suggested_attendees = self.state.get("suggested_attendees") 594 try: 595 attendee, (suggested, attr) = suggested_attendees[index] 596 self.add_attendee(suggested) 597 except IndexError: 598 pass 599 600 def add_period(self): 601 602 "Add a copy of the main period as a new recurrence." 603 604 current = self.state.get("periods") 605 new = get_main_period(current).copy() 606 new.origin = "RDATE" 607 new.replacement = False 608 new.recurrenceid = False 609 new.cancelled = False 610 current.append(new) 611 612 def apply_suggested_period(self, index): 613 614 "Apply the suggested period at 'index' to the event." 615 616 current = self.state.get("periods") 617 suggested = self.state.get("suggested_periods") 618 619 try: 620 attendee, period, operation = suggested[index] 621 period = form_period_from_period(period) 622 623 # Cancel any removed periods. 624 625 if operation == "remove": 626 for index, p in enumerate(current): 627 if p == period: 628 self.cancel_periods([index]) 629 break 630 631 # Add or replace any other suggestions. 632 633 elif operation == "add": 634 635 # Make the status of the period compatible. 636 637 period.cancelled = False 638 period.origin = "DTSTART-RECUR" 639 640 # Either replace or add the period. 641 642 recurrenceid = period.get_recurrenceid() 643 644 for i, p in enumerate(current): 645 if p.get_recurrenceid() == recurrenceid: 646 current[i] = period 647 break 648 649 # Add as a new period. 650 651 else: 652 period.recurrenceid = None 653 current.append(period) 654 655 except IndexError: 656 pass 657 658 def cancel_periods(self, indexes, cancelled=True): 659 660 """ 661 Set cancellation state for periods with the given 'indexes', indicating 662 'cancelled' as a true or false value. New periods will be removed if 663 cancelled. 664 """ 665 666 periods = self.state.get("periods") 667 to_remove = [] 668 removed = 0 669 670 for index in indexes: 671 p = periods[index] 672 673 # Make replacements from existing periods and cancel them. 674 675 if p.recurrenceid: 676 p.replacement = True 677 p.cancelled = cancelled 678 679 # Remove new periods completely. 680 681 elif cancelled: 682 to_remove.append(index - removed) 683 removed += 1 684 685 for index in to_remove: 686 del periods[index] 687 688 def can_edit_attendance(self): 689 690 "Return whether the organiser's attendance can be edited." 691 692 return self.state.get("attendees").has_key(self.user) 693 694 def edit_attendance(self, partstat): 695 696 "Set the 'partstat' of the current user, if attending." 697 698 attendees = self.state.get("attendees") 699 attr = attendees.get(self.user) 700 701 # Set the attendance for the user, if attending. 702 703 if attr is not None: 704 new_attr = {} 705 new_attr.update(attr) 706 new_attr["PARTSTAT"] = partstat 707 attendees[self.user] = new_attr 708 709 def can_edit_attendee(self, index): 710 711 """ 712 Return whether the attendee at 'index' can be edited, requiring either 713 the organiser and an unshared event, or a new attendee. 714 """ 715 716 attendees = self.state.get("attendees") 717 attendee = attendees.keys()[index] 718 719 try: 720 attr = attendees[attendee] 721 if self.is_organiser() or not attr: 722 return (attendee, attr) 723 except IndexError: 724 pass 725 726 return None 727 728 def can_remove_attendee(self, index): 729 730 """ 731 Return whether the attendee at 'index' can be removed, requiring either 732 the organiser or a new attendee. 733 """ 734 735 attendees = self.state.get("attendees") 736 attendee = attendees.keys()[index] 737 738 try: 739 attr = attendees[attendee] 740 if self.is_organiser() or not attr: 741 return (attendee, attr) 742 except IndexError: 743 pass 744 745 return None 746 747 def remove_attendees(self, indexes): 748 749 "Remove attendee at 'index'." 750 751 attendees = self.state.get("attendees") 752 to_remove = [] 753 754 for index in indexes: 755 attendee_item = self.can_remove_attendee(index) 756 if attendee_item: 757 attendee, attr = attendee_item 758 to_remove.append(attendee) 759 760 for key in to_remove: 761 del attendees[key] 762 763 def can_edit_period(self, index): 764 765 """ 766 Return the period at 'index' for editing or None if it cannot be edited. 767 """ 768 769 try: 770 return self.state.get("periods")[index] 771 except IndexError: 772 return None 773 774 def can_edit_properties(self): 775 776 "Return whether general event properties can be edited." 777 778 return True 779 780 781 782 # Period-related abstractions. 783 784 class PeriodError(Exception): 785 pass 786 787 class EditablePeriod(RecurringPeriod): 788 789 "An editable period tracking the identity of any original period." 790 791 def _get_recurrenceid_item(self): 792 793 # Convert any stored identifier to the current time zone. 794 # NOTE: This should not be necessary, but is done for consistency with 795 # NOTE: the datetime properties. 796 797 dt = get_datetime(self.recurrenceid) 798 dt = to_timezone(dt, self.tzid) 799 return dt, get_datetime_attributes(dt) 800 801 def get_recurrenceid(self): 802 803 """ 804 Return a recurrence identity to be used to associate stored periods with 805 edited periods. 806 """ 807 808 if not self.recurrenceid: 809 return RecurringPeriod.get_recurrenceid(self) 810 return self.recurrenceid 811 812 def get_recurrenceid_item(self): 813 814 """ 815 Return a recurrence identifier value and datetime properties for use in 816 specifying the RECURRENCE-ID property. 817 """ 818 819 if not self.recurrenceid: 820 return RecurringPeriod.get_recurrenceid_item(self) 821 return self._get_recurrenceid_item() 822 823 class EventPeriod(EditablePeriod): 824 825 """ 826 A simple period plus attribute details, compatible with RecurringPeriod, and 827 intended to represent information obtained from an iCalendar resource. 828 """ 829 830 def __init__(self, start, end, tzid=None, origin=None, start_attr=None, 831 end_attr=None, form_start=None, form_end=None, 832 replacement=False, cancelled=False, recurrenceid=None): 833 834 """ 835 Initialise a period with the given 'start' and 'end' datetimes. 836 837 The optional 'tzid' provides time zone information, and the optional 838 'origin' indicates the kind of period this object describes. 839 840 The optional 'start_attr' and 'end_attr' provide metadata for the start 841 and end datetimes respectively, and 'form_start' and 'form_end' are 842 values provided as textual input. 843 844 The 'replacement' flag indicates whether the period is provided by a 845 separate recurrence instance. 846 847 The 'cancelled' flag indicates whether a separate recurrence is 848 cancelled. 849 850 The 'recurrenceid' describes the original identity of the period, 851 regardless of whether it is separate or not. 852 """ 853 854 EditablePeriod.__init__(self, start, end, tzid, origin, start_attr, end_attr) 855 self.form_start = form_start 856 self.form_end = form_end 857 858 # Information about whether a separate recurrence provides this period 859 # and the original period identity. 860 861 self.replacement = replacement 862 self.cancelled = cancelled 863 self.recurrenceid = recurrenceid 864 865 # Additional editing state. 866 867 self.new_replacement = False 868 869 def as_tuple(self): 870 return self.start, self.end, self.tzid, self.origin, self.start_attr, \ 871 self.end_attr, self.form_start, self.form_end, self.replacement, \ 872 self.cancelled, self.recurrenceid 873 874 def __repr__(self): 875 return "EventPeriod%r" % (self.as_tuple(),) 876 877 def copy(self): 878 return EventPeriod(*self.as_tuple()) 879 880 def as_event_period(self, index=None): 881 return self 882 883 def get_start_item(self): 884 return self.get_start(), self.get_start_attr() 885 886 def get_end_item(self): 887 return self.get_end(), self.get_end_attr() 888 889 # Form data compatibility methods. 890 891 def get_form_start(self): 892 if not self.form_start: 893 self.form_start = self.get_form_date(self.get_start(), self.start_attr) 894 return self.form_start 895 896 def get_form_end(self): 897 if not self.form_end: 898 self.form_end = self.get_form_date(end_date_from_calendar(self.get_end()), self.end_attr) 899 return self.form_end 900 901 def as_form_period(self): 902 return FormPeriod( 903 self.get_form_start(), 904 self.get_form_end(), 905 isinstance(self.end, datetime) or self.get_start() != self.get_end() - timedelta(1), 906 isinstance(self.start, datetime) or isinstance(self.end, datetime), 907 self.tzid, 908 self.origin, 909 self.replacement, 910 self.cancelled, 911 self.recurrenceid 912 ) 913 914 def get_form_date(self, dt, attr=None): 915 return FormDate( 916 format_datetime(to_date(dt)), 917 isinstance(dt, datetime) and str(dt.hour) or None, 918 isinstance(dt, datetime) and str(dt.minute) or None, 919 isinstance(dt, datetime) and str(dt.second) or None, 920 attr and attr.get("TZID") or None, 921 dt, attr 922 ) 923 924 class FormPeriod(EditablePeriod): 925 926 "A period whose information originates from a form." 927 928 def __init__(self, start, end, end_enabled=True, times_enabled=True, 929 tzid=None, origin=None, replacement=False, cancelled=False, 930 recurrenceid=None): 931 self.start = start 932 self.end = end 933 self.end_enabled = end_enabled 934 self.times_enabled = times_enabled 935 self.tzid = tzid 936 self.origin = origin 937 self.replacement = replacement 938 self.cancelled = cancelled 939 self.recurrenceid = recurrenceid 940 self.new_replacement = False 941 942 def as_tuple(self): 943 return self.start, self.end, self.end_enabled, self.times_enabled, \ 944 self.tzid, self.origin, self.replacement, self.cancelled, \ 945 self.recurrenceid 946 947 def __repr__(self): 948 return "FormPeriod%r" % (self.as_tuple(),) 949 950 def copy(self): 951 args = (self.start.copy(), self.end.copy()) + self.as_tuple()[2:] 952 return FormPeriod(*args) 953 954 def as_event_period(self, index=None): 955 956 """ 957 Return a converted version of this object as an event period suitable 958 for iCalendar usage. If 'index' is indicated, include it in any error 959 raised in the conversion process. 960 """ 961 962 dtstart, dtstart_attr = self.get_start_item() 963 if not dtstart: 964 if index is not None: 965 raise PeriodError(("dtstart", index)) 966 else: 967 raise PeriodError("dtstart") 968 969 dtend, dtend_attr = self.get_end_item() 970 if not dtend: 971 if index is not None: 972 raise PeriodError(("dtend", index)) 973 else: 974 raise PeriodError("dtend") 975 976 if dtstart > dtend: 977 if index is not None: 978 raise PeriodError(("dtstart", index), ("dtend", index)) 979 else: 980 raise PeriodError("dtstart", "dtend") 981 982 return EventPeriod(dtstart, end_date_to_calendar(dtend), self.tzid, 983 self.origin, dtstart_attr, dtend_attr, 984 self.start, self.end, self.replacement, 985 self.cancelled, self.recurrenceid) 986 987 # Period data methods. 988 989 def get_start(self): 990 return self.start and self.start.as_datetime(self.times_enabled) or None 991 992 def get_end(self): 993 994 # Handle specified end datetimes. 995 996 if self.end_enabled: 997 dtend = self.end.as_datetime(self.times_enabled) 998 if not dtend: 999 return None 1000 1001 # Handle same day times. 1002 1003 elif self.times_enabled: 1004 formdate = FormDate(self.start.date, self.end.hour, self.end.minute, self.end.second, self.end.tzid) 1005 dtend = formdate.as_datetime(self.times_enabled) 1006 if not dtend: 1007 return None 1008 1009 # Otherwise, treat the end date as the start date. Datetimes are 1010 # handled by making the event occupy the rest of the day. 1011 1012 else: 1013 dtstart, dtstart_attr = self.get_start_item() 1014 if dtstart: 1015 if isinstance(dtstart, datetime): 1016 dtend = get_end_of_day(dtstart, dtstart_attr["TZID"]) 1017 else: 1018 dtend = dtstart 1019 else: 1020 return None 1021 1022 return dtend 1023 1024 def get_start_attr(self): 1025 return self.start and self.start.get_attributes(self.times_enabled) or {} 1026 1027 def get_end_attr(self): 1028 return self.end and self.end.get_attributes(self.times_enabled) or {} 1029 1030 # Form data methods. 1031 1032 def get_form_start(self): 1033 return self.start 1034 1035 def get_form_end(self): 1036 return self.end 1037 1038 def as_form_period(self): 1039 return self 1040 1041 class FormDate: 1042 1043 "Date information originating from form information." 1044 1045 def __init__(self, date=None, hour=None, minute=None, second=None, tzid=None, dt=None, attr=None): 1046 self.date = date 1047 self.hour = hour 1048 self.minute = minute 1049 self.second = second 1050 self.tzid = tzid 1051 self.dt = dt 1052 self.attr = attr 1053 1054 def as_tuple(self): 1055 return self.date, self.hour, self.minute, self.second, self.tzid, self.dt, self.attr 1056 1057 def copy(self): 1058 return FormDate(*self.as_tuple()) 1059 1060 def reset(self): 1061 self.dt = None 1062 1063 def __repr__(self): 1064 return "FormDate%r" % (self.as_tuple(),) 1065 1066 def get_component(self, value): 1067 return (value or "").rjust(2, "0")[:2] 1068 1069 def get_hour(self): 1070 return self.get_component(self.hour) 1071 1072 def get_minute(self): 1073 return self.get_component(self.minute) 1074 1075 def get_second(self): 1076 return self.get_component(self.second) 1077 1078 def get_date_string(self): 1079 return self.date or "" 1080 1081 def get_datetime_string(self): 1082 if not self.date: 1083 return "" 1084 1085 hour = self.hour; minute = self.minute; second = self.second 1086 1087 if hour or minute or second: 1088 time = "T%s%s%s" % tuple(map(self.get_component, (hour, minute, second))) 1089 else: 1090 time = "" 1091 1092 return "%s%s" % (self.date, time) 1093 1094 def get_tzid(self): 1095 return self.tzid 1096 1097 def as_datetime(self, with_time=True): 1098 1099 """ 1100 Return a datetime for this object if one is provided or can be produced. 1101 """ 1102 1103 # Return any original datetime details. 1104 1105 if self.dt: 1106 return self.dt 1107 1108 # Otherwise, construct a datetime. 1109 1110 s, attr = self.as_datetime_item(with_time) 1111 if not s: 1112 return None 1113 1114 # An erroneous datetime will yield None as result. 1115 1116 try: 1117 return get_datetime(s, attr) 1118 except ValueError: 1119 return None 1120 1121 def as_datetime_item(self, with_time=True): 1122 1123 """ 1124 Return a (datetime string, attr) tuple for the datetime information 1125 provided by this object, where both tuple elements will be None if no 1126 suitable date or datetime information exists. 1127 """ 1128 1129 s = None 1130 if with_time: 1131 s = self.get_datetime_string() 1132 attr = self.get_attributes(True) 1133 if not s: 1134 s = self.get_date_string() 1135 attr = self.get_attributes(False) 1136 if not s: 1137 return None, None 1138 return s, attr 1139 1140 def get_attributes(self, with_time=True): 1141 1142 "Return attributes for the date or datetime represented by this object." 1143 1144 if with_time: 1145 return {"TZID" : self.get_tzid(), "VALUE" : "DATE-TIME"} 1146 else: 1147 return {"VALUE" : "DATE"} 1148 1149 def event_period_from_period(period, index=None): 1150 1151 """ 1152 Convert a 'period' to one suitable for use in an iCalendar representation. 1153 In an "event period" representation, the end day of any date-level event is 1154 encoded as the "day after" the last day actually involved in the event. 1155 """ 1156 1157 if isinstance(period, EventPeriod): 1158 return period 1159 elif isinstance(period, FormPeriod): 1160 return period.as_event_period(index) 1161 else: 1162 dtstart, dtstart_attr = period.get_start_item() 1163 dtend, dtend_attr = period.get_end_item() 1164 1165 if not isinstance(period, RecurringPeriod): 1166 dtend = end_date_to_calendar(dtend) 1167 1168 return EventPeriod(dtstart, dtend, period.tzid, period.origin, 1169 dtstart_attr, dtend_attr, 1170 recurrenceid=format_datetime(to_utc_datetime(dtstart))) 1171 1172 def event_periods_from_periods(periods): 1173 return map(event_period_from_period, periods, range(0, len(periods))) 1174 1175 def form_period_from_period(period): 1176 1177 """ 1178 Convert a 'period' into a representation usable in a user-editable form. 1179 In a "form period" representation, the end day of any date-level event is 1180 presented in a "natural" form, not the iCalendar "day after" form. 1181 """ 1182 1183 if isinstance(period, EventPeriod): 1184 return period.as_form_period() 1185 elif isinstance(period, FormPeriod): 1186 return period 1187 else: 1188 return event_period_from_period(period).as_form_period() 1189 1190 def form_periods_from_periods(periods): 1191 return map(form_period_from_period, periods) 1192 1193 1194 1195 # Event period processing. 1196 1197 def periods_from_updated_periods(updated_periods, fn): 1198 1199 """ 1200 Return periods from the given 'updated_periods' having the form (stored, 1201 unedited), creating them using 'fn', and setting replacement, cancelled and 1202 recurrence identifier details. 1203 1204 This function should be used to produce editing-related periods from the 1205 general updated periods provided by the client abstractions. 1206 """ 1207 1208 periods = [] 1209 1210 for sp, p in updated_periods: 1211 1212 # Stored periods with corresponding current periods. 1213 1214 if p: 1215 period = fn(p) 1216 1217 # Replacements are identified by comparing object identities, since 1218 # a replacement will not be provided by the same object. 1219 1220 if sp is not p: 1221 period.replacement = True 1222 1223 # Stored periods without corresponding current periods. 1224 1225 else: 1226 period = fn(sp) 1227 period.replacement = True 1228 period.cancelled = True 1229 1230 # Replace the recurrence identifier with that of the original period. 1231 1232 period.recurrenceid = sp.get_recurrenceid() 1233 periods.append(period) 1234 1235 return periods 1236 1237 def event_periods_from_updated_periods(updated_periods): 1238 1239 """ 1240 Return event periods from the 'updated_periods' having the form (stored, 1241 unedited). 1242 """ 1243 1244 return periods_from_updated_periods(updated_periods, event_period_from_period) 1245 1246 def form_periods_from_updated_periods(updated_periods): 1247 1248 """ 1249 Return form periods from the 'updated_periods' having the form (stored, 1250 unedited). 1251 """ 1252 1253 return periods_from_updated_periods(updated_periods, form_period_from_period) 1254 1255 def periods_by_recurrence(periods): 1256 1257 """ 1258 Return a mapping from recurrence identifier to period for 'periods' along 1259 with a collection of unmapped periods. 1260 """ 1261 1262 d = {} 1263 new = [] 1264 1265 for p in periods: 1266 if not p.recurrenceid: 1267 new.append(p) 1268 else: 1269 d[p.recurrenceid] = p 1270 1271 return d, new 1272 1273 def combine_periods(old, new): 1274 1275 """ 1276 Combine 'old' and 'new' periods for comparison, making a list of (old, new) 1277 updated period tuples. Such tuples encode correspondences between periods 1278 representing the same, potentially-edited data. 1279 """ 1280 1281 old_by_recurrenceid, _new_periods = periods_by_recurrence(old) 1282 new_by_recurrenceid, new_periods = periods_by_recurrence(new) 1283 1284 combined = [] 1285 1286 for recurrenceid, op in old_by_recurrenceid.items(): 1287 np = new_by_recurrenceid.get(recurrenceid) 1288 1289 # Old period has corresponding new period that is not cancelled. 1290 1291 if np and not (np.cancelled and not op.cancelled): 1292 combined.append((op, np)) 1293 1294 # No corresponding new, uncancelled period. 1295 1296 else: 1297 combined.append((op, None)) 1298 1299 # New periods without corresponding old periods are genuinely new. 1300 1301 for np in new_periods: 1302 combined.append((None, np)) 1303 1304 # Note that new periods should not have recurrence identifiers, and if 1305 # imported from other events, they should have such identifiers removed. 1306 1307 return combined 1308 1309 def classify_periods(edited_periods): 1310 1311 """ 1312 Using the 'edited_periods', being a list of (unedited, edited) periods, 1313 return a tuple containing collections of new, replaced, retained, cancelled 1314 and obsolete periods. 1315 1316 Note that replaced and retained indicate the presence or absence of 1317 differences between the original event periods and the current periods that 1318 would need to be represented using separate recurrence instances, not 1319 whether any editing operations have changed the periods. 1320 1321 Obsolete periods are those that have been replaced but not cancelled. 1322 """ 1323 1324 new = [] 1325 replaced = [] 1326 retained = [] 1327 cancelled = [] 1328 obsolete = [] 1329 1330 for op, p in edited_periods: 1331 1332 # Unedited periods that are not cancelled. 1333 1334 if op and not op.cancelled: 1335 1336 # With cancelled or absent current periods. 1337 1338 if not p or p.cancelled: 1339 cancelled.append(op) 1340 1341 # With differing or replacement current periods. 1342 1343 elif p != op or p.replacement: 1344 replaced.append(p) 1345 if not p.replacement: 1346 p.new_replacement = True 1347 obsolete.append(op) 1348 1349 # With retained, not differing current periods. 1350 1351 else: 1352 retained.append(p) 1353 if p.new_replacement: 1354 p.new_replacement = False 1355 1356 # New periods without corresponding unedited periods. 1357 1358 elif p: 1359 new.append(p) 1360 1361 return new, replaced, retained, cancelled, obsolete 1362 1363 def classify_period_changes(edited_periods): 1364 1365 """ 1366 Using the 'edited_periods', being a list of (unedited, edited) periods, 1367 return a tuple containing collections of modified, unmodified and removed 1368 periods. 1369 """ 1370 1371 modified = [] 1372 unmodified = [] 1373 removed = [] 1374 1375 for op, p in edited_periods: 1376 1377 # Test for periods cancelled, reinstated or changed, or left unmodified 1378 # during editing. 1379 1380 if op: 1381 if not op.cancelled and (not p or p.cancelled): 1382 removed.append(op) 1383 elif op.cancelled and not p.cancelled or p != op: 1384 modified.append(p) 1385 else: 1386 unmodified.append(p) 1387 1388 # New periods are always modifications. 1389 1390 elif p: 1391 modified.append(p) 1392 1393 return modified, unmodified, removed 1394 1395 def classify_period_operations(new, replaced, retained, cancelled, 1396 obsolete, modified, removed, 1397 is_organiser, is_shared, is_changed): 1398 1399 """ 1400 Classify the operations for the update of an event. For updates modifying 1401 shared events, return periods for descheduling and rescheduling (where these 1402 operations can modify the event), and periods for exclusion and application 1403 (where these operations redefine the event). 1404 1405 To define the new state of the event, details of the complete set of 1406 unscheduled and rescheduled periods are also provided. 1407 """ 1408 1409 active_periods = new + replaced + retained 1410 active_non_rule = filter(lambda p: p.origin != "RRULE", active_periods) 1411 1412 # Modified replaced and retained recurrences are used for incremental 1413 # updates. 1414 1415 replaced_modified = select_recurrences(replaced, modified).values() 1416 retained_modified = select_recurrences(retained, modified).values() 1417 1418 # Unmodified replaced and retained recurrences are used in the complete 1419 # event summary. 1420 1421 replaced_unmodified = subtract_recurrences(replaced, modified).values() 1422 retained_unmodified = subtract_recurrences(retained, modified).values() 1423 1424 # Obtain the removed periods in terms of existing periods. These are used in 1425 # incremental updates. 1426 1427 cancelled_removed = select_recurrences(cancelled, removed).values() 1428 cancelled_main = get_main_period(cancelled_removed) 1429 cancelled_main = cancelled_main and [cancelled_main] or [] 1430 1431 # Reinstated periods are previously-cancelled periods that are now modified 1432 # periods, and they appear in updates. 1433 1434 reinstated = select_recurrences(modified, cancelled).values() 1435 1436 # Get cancelled periods without reinstated periods. These appear in complete 1437 # event summaries. 1438 1439 cancelled_unmodified = subtract_recurrences(cancelled, modified).values() 1440 1441 # Cancelled periods originating from rules must be excluded since there are 1442 # no explicit instances to be deleted. 1443 1444 cancelled_rule = [] 1445 for p in cancelled_removed: 1446 if p.origin == "RRULE": 1447 cancelled_rule.append(p) 1448 1449 # Obsolete periods (replaced by other periods) originating from rules must 1450 # be excluded if no explicit instance will be used to replace them. 1451 1452 obsolete_rule = [] 1453 for p in obsolete: 1454 if p.origin == "RRULE": 1455 obsolete_rule.append(p) 1456 1457 # As organiser... 1458 1459 if is_organiser: 1460 1461 # For unshared events... 1462 # All modifications redefine the event. 1463 1464 # For shared events... 1465 # New periods should cause the event to be redefined. 1466 # Other changes should also cause event redefinition. 1467 # Event redefinition should only occur if no replacement periods exist. 1468 # Cancelled rule-originating periods must be excluded. 1469 1470 if not is_shared or new and not replaced: 1471 to_set = active_non_rule 1472 to_exclude = list(chain(cancelled_rule, obsolete_rule, cancelled_main)) 1473 to_unschedule = [] 1474 to_reschedule = [] 1475 to_add = [] 1476 all_unscheduled = [] 1477 all_rescheduled = [] 1478 1479 # Changed periods should be rescheduled separately. 1480 # Removed periods should be cancelled separately. 1481 1482 else: 1483 to_set = [] 1484 to_exclude = [] 1485 to_unschedule = cancelled_removed 1486 to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) 1487 to_add = new 1488 all_unscheduled = cancelled_unmodified 1489 all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) 1490 1491 # As attendee... 1492 1493 else: 1494 to_unschedule = [] 1495 to_add = [] 1496 1497 # Changed periods without new or removed periods are proposed as 1498 # separate changes. Parent event changes cause redefinition of the 1499 # entire event. 1500 1501 if not new and not removed and not is_changed: 1502 to_set = [] 1503 to_exclude = [] 1504 to_reschedule = list(chain(replaced_modified, retained_modified, reinstated)) 1505 all_unscheduled = list(cancelled_unmodified) 1506 all_rescheduled = list(chain(replaced_unmodified, to_reschedule)) 1507 1508 # Otherwise, the event is defined in terms of new periods and 1509 # exceptions for removed periods or obsolete rule periods. 1510 1511 else: 1512 to_set = active_non_rule 1513 to_exclude = list(chain(cancelled, obsolete_rule, cancelled_main)) 1514 to_reschedule = [] 1515 all_unscheduled = [] 1516 all_rescheduled = [] 1517 1518 return to_unschedule, to_reschedule, to_add, to_exclude, to_set, all_unscheduled, all_rescheduled 1519 1520 def get_period_mapping(periods): 1521 1522 "Return a mapping of recurrence identifiers to the given 'periods." 1523 1524 d, new = periods_by_recurrence(periods) 1525 return d 1526 1527 def select_recurrences(source, selected): 1528 1529 "Restrict 'source' to the recurrences referenced by 'selected'." 1530 1531 mapping = get_period_mapping(source) 1532 1533 recurrenceids = get_recurrenceids(selected) 1534 for recurrenceid in mapping.keys(): 1535 if not recurrenceid in recurrenceids: 1536 del mapping[recurrenceid] 1537 return mapping 1538 1539 def subtract_recurrences(source, selected): 1540 1541 "Remove from 'source' the recurrences referenced by 'selected'." 1542 1543 mapping = get_period_mapping(source) 1544 1545 for recurrenceid in get_recurrenceids(selected): 1546 if mapping.has_key(recurrenceid): 1547 del mapping[recurrenceid] 1548 return mapping 1549 1550 def get_recurrenceids(periods): 1551 1552 "Return the recurrence identifiers employed by 'periods'." 1553 1554 return map(lambda p: p.get_recurrenceid(), periods) 1555 1556 1557 1558 # Attendee processing. 1559 1560 def classify_attendee_changes(original, current): 1561 1562 """ 1563 Return categories of attendees given the 'original' and 'current' 1564 collections of attendees. 1565 """ 1566 1567 new = {} 1568 modified = {} 1569 unmodified = {} 1570 1571 # Check current attendees against the original ones. 1572 1573 for attendee, attendee_attr in current.items(): 1574 original_attr = original.get(attendee) 1575 1576 # New attendee if missing original details. 1577 1578 if not original_attr: 1579 new[attendee] = attendee_attr 1580 1581 # Details unchanged for existing attendee. 1582 1583 elif attendee_attr == original_attr: 1584 unmodified[attendee] = attendee_attr 1585 1586 # Details changed for existing attendee. 1587 1588 else: 1589 modified[attendee] = attendee_attr 1590 1591 removed = {} 1592 1593 # Check for removed attendees. 1594 1595 for attendee, attendee_attr in original.items(): 1596 if not current.has_key(attendee): 1597 removed[attendee] = attendee_attr 1598 1599 return new, modified, unmodified, removed 1600 1601 # vim: tabstop=4 expandtab shiftwidth=4