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