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