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