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