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