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