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