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