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