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