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