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