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