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