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