1 #!/usr/bin/env python 2 3 """ 4 Common calendar client utilities. 5 6 Copyright (C) 2014, 2015, 2016, 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 datetime import datetime, timedelta 23 from imiptools.config import settings 24 from imiptools.data import check_delegation, get_address, get_uri, \ 25 get_recurrence_periods, \ 26 get_window_end, is_new_object, make_freebusy, \ 27 make_uid, to_part, uri_dict, uri_item, uri_items, \ 28 uri_parts, uri_values 29 from imiptools.dates import check_permitted_values, format_datetime, \ 30 get_datetime, get_default_timezone, \ 31 get_duration, get_time, get_timestamp, \ 32 to_datetime 33 from imiptools.i18n import get_translator 34 from imiptools.freebusy import SupportAttendee, SupportExpires 35 from imiptools.profile import Preferences 36 from imiptools.stores import get_store, get_publisher, get_journal 37 38 class Client: 39 40 "Common handler and manager methods." 41 42 default_window_size = 100 43 organiser_methods = "ADD", "CANCEL", "DECLINECOUNTER", "PUBLISH", "REQUEST" 44 45 def __init__(self, user, messenger=None, store=None, publisher=None, journal=None, 46 preferences_dir=None): 47 48 """ 49 Initialise a calendar client with the current 'user', plus any 50 'messenger', 'store', 'publisher' and 'journal' objects, indicating any 51 specific 'preferences_dir'. 52 """ 53 54 self.user = user 55 self.messenger = messenger 56 self.store = store or get_store(settings["STORE_TYPE"], settings["STORE_DIR"]) 57 self.journal = journal or get_journal(settings["STORE_TYPE"], settings["JOURNAL_DIR"]) 58 59 try: 60 self.publisher = publisher or get_publisher(settings["PUBLISH_DIR"]) 61 except OSError: 62 self.publisher = None 63 64 self.preferences_dir = preferences_dir 65 self.preferences = None 66 67 # Localise the messenger. 68 69 if self.messenger: 70 self.messenger.gettext = self.get_translator() 71 72 def get_store(self): 73 return self.store 74 75 def get_publisher(self): 76 return self.publisher 77 78 def get_journal(self): 79 return self.journal 80 81 # Store-related methods. 82 83 def acquire_lock(self): 84 self.store.acquire_lock(self.user) 85 86 def release_lock(self): 87 self.store.release_lock(self.user) 88 89 # Preferences-related methods. 90 91 def get_preferences(self): 92 if not self.preferences and self.user: 93 self.preferences = Preferences(self.user, self.preferences_dir) 94 return self.preferences 95 96 def get_locale(self): 97 prefs = self.get_preferences() 98 return prefs and prefs.get("LANG", "en", True) or "en" 99 100 def get_translator(self): 101 return get_translator([self.get_locale()]) 102 103 def get_user_attributes(self): 104 prefs = self.get_preferences() 105 return prefs and prefs.get_all(["CN"]) or {} 106 107 def get_tzid(self): 108 prefs = self.get_preferences() 109 return prefs and prefs.get("TZID") or get_default_timezone() 110 111 def get_window_size(self): 112 113 "Return the period window size as an integer." 114 115 prefs = self.get_preferences() 116 try: 117 return prefs and int(prefs.get("window_size")) or self.default_window_size 118 except (TypeError, ValueError): 119 return self.default_window_size 120 121 def get_window_start(self): 122 123 "Return the period window start as a datetime." 124 125 prefs = self.get_preferences() 126 tzid = self.get_tzid() 127 start = prefs and get_datetime(prefs.get("window_start"), {"TZID" : tzid}) 128 return isinstance(start, datetime) and start or start and to_datetime(start, tzid) 129 130 def get_window_end(self, size=None, start=None): 131 132 "Return the period window end as a datetime." 133 134 tzid = self.get_tzid() 135 return get_window_end(tzid, size or self.get_window_size(), 136 start or self.get_window_start()) 137 138 def is_participating(self): 139 140 "Return participation in the calendar system." 141 142 prefs = self.get_preferences() 143 return prefs and prefs.get("participating", settings["PARTICIPATING_DEFAULT"]) != "no" or False 144 145 def is_sharing(self): 146 147 "Return whether free/busy information is being generally shared." 148 149 prefs = self.get_preferences() 150 return prefs and prefs.get("freebusy_sharing", settings["SHARING_DEFAULT"]) == "share" or False 151 152 def is_bundling(self): 153 154 "Return whether free/busy information is being bundled in messages." 155 156 prefs = self.get_preferences() 157 return prefs and prefs.get("freebusy_bundling", settings["BUNDLING_DEFAULT"]) == "always" or False 158 159 def is_notifying(self): 160 161 "Return whether recipients are notified about free/busy payloads." 162 163 prefs = self.get_preferences() 164 return prefs and prefs.get("freebusy_messages", settings["NOTIFYING_DEFAULT"]) == "notify" or False 165 166 def is_publishing(self): 167 168 "Return whether free/busy information is being published as Web resources." 169 170 prefs = self.get_preferences() 171 return prefs and prefs.get("freebusy_publishing", settings["PUBLISHING_DEFAULT"]) == "publish" or False 172 173 def is_refreshing(self): 174 175 "Return whether a recipient supports requests to refresh event details." 176 177 prefs = self.get_preferences() 178 return prefs and prefs.get("event_refreshing", settings["REFRESHING_DEFAULT"]) == "always" or False 179 180 def allow_add(self): 181 return self.get_add_method_response() in ("add", "refresh") 182 183 def get_add_method_response(self): 184 prefs = self.get_preferences() 185 return prefs and prefs.get("add_method_response", settings["ADD_RESPONSE_DEFAULT"]) or "refresh" 186 187 def get_offer_period(self): 188 189 "Decode a specification in the iCalendar duration format." 190 191 prefs = self.get_preferences() 192 duration = prefs and prefs.get("freebusy_offers", settings["FREEBUSY_OFFER_DEFAULT"]) 193 194 # NOTE: Should probably report an error somehow if None. 195 196 return duration and get_duration(duration) or None 197 198 def get_organiser_replacement(self): 199 prefs = self.get_preferences() 200 return prefs and prefs.get("organiser_replacement", settings["ORGANISER_REPLACEMENT_DEFAULT"]) or "attendee" 201 202 def have_manager(self): 203 return settings["MANAGER_INTERFACE"] 204 205 def get_permitted_values(self): 206 207 """ 208 Decode a specification of one of the following forms... 209 210 <minute values> 211 <hour values>:<minute values> 212 <hour values>:<minute values>:<second values> 213 214 ...with each list of values being comma-separated. 215 """ 216 217 prefs = self.get_preferences() 218 permitted_values = prefs and prefs.get("permitted_times") 219 if permitted_values: 220 try: 221 l = [] 222 for component in permitted_values.split(":")[:3]: 223 if component: 224 l.append(map(int, component.split(","))) 225 else: 226 l.append(None) 227 228 # NOTE: Should probably report an error somehow. 229 230 except ValueError: 231 return None 232 else: 233 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or []) 234 return l 235 else: 236 return None 237 238 # Common operations on calendar data. 239 240 def update_sender(self, attr): 241 242 "Update the SENT-BY attribute of the 'attr' sender metadata." 243 244 if self.messenger and self.messenger.sender != get_address(self.user): 245 attr["SENT-BY"] = get_uri(self.messenger.sender) 246 247 def get_periods(self, obj, explicit_only=False, future_only=False): 248 249 """ 250 Return periods for the given 'obj'. 251 252 If 'explicit_only' is set to a true value, only explicit periods will be 253 returned, not rule-based periods. 254 255 If 'future_only' is set to a true value, only future periods will be 256 returned, not all periods defined by an event starting in the past. 257 """ 258 259 return obj.get_periods( 260 start=(future_only and self.get_window_start() or None), 261 end=(not explicit_only and self.get_window_end() or None)) 262 263 def get_main_period(self, obj): 264 265 "Return the main period defined by 'obj'." 266 267 return obj.get_main_period() 268 269 def get_recurrence_periods(self, obj): 270 271 "Return recurrence periods defined by 'obj'." 272 273 return get_recurrence_periods(Client.get_periods(self, obj)) 274 275 # Store operations. 276 277 def get_stored_object(self, uid, recurrenceid, section=None, username=None): 278 279 """ 280 Return the stored object for the current user, with the given 'uid' and 281 'recurrenceid' from the given 'section' and for the given 'username' (if 282 specified), or from the standard object collection otherwise. 283 """ 284 285 if section == "counters": 286 obj = self.store.get_counter(self.user, username, uid, recurrenceid) 287 else: 288 obj = self.store.get_event(self.user, uid, recurrenceid, section) 289 290 # Set the fallback time zone. 291 292 if obj: 293 obj.set_tzid(self.get_tzid()) 294 295 return obj 296 297 # Free/busy operations. 298 299 def get_freebusy_part(self, freebusy=None): 300 301 """ 302 Return a message part containing free/busy information for the user, 303 either specified as 'freebusy' or obtained from the store directly. 304 """ 305 306 if self.is_sharing() and self.is_bundling(): 307 308 # Invent a unique identifier. 309 310 uid = make_uid(self.user) 311 312 freebusy = freebusy or self.store.get_freebusy(self.user) 313 314 user_attr = {} 315 self.update_sender(user_attr) 316 return self.to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)]) 317 318 return None 319 320 def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 321 322 """ 323 Update the 'freebusy' collection with the given 'periods', indicating a 324 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a 325 recurrence or the parent event. The 'summary' and 'organiser' must also 326 be provided. 327 328 An optional 'expires' datetime string can be provided to tag a free/busy 329 offer. 330 """ 331 332 # Add specific attendee information for certain collections. 333 334 if isinstance(freebusy, SupportAttendee): 335 freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser, self.user) 336 337 # Add expiry datetime for certain collections. 338 339 elif isinstance(freebusy, SupportExpires): 340 freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser, expires) 341 342 # Provide only the essential attributes for other collections. 343 344 else: 345 freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser) 346 347 # Preparation of content. 348 349 def to_part(self, method, fragments): 350 351 "Return an encoded MIME part for the given 'method' and 'fragments'." 352 353 return to_part(method, fragments, line_length=settings["CALENDAR_LINE_LENGTH"]) 354 355 def object_to_part(self, method, obj): 356 357 "Return an encoded MIME part for the given 'method' and 'obj'." 358 359 return obj.to_part(method, line_length=settings["CALENDAR_LINE_LENGTH"]) 360 361 # Preparation of messages communicating the state of events. 362 363 def get_message_parts(self, obj, method, attendee=None): 364 365 """ 366 Return a tuple containing a list of methods and a list of message parts, 367 with the parts collectively describing the given object 'obj' and its 368 recurrences, using 'method' as the means of publishing details (with 369 CANCEL being used to retract or remove details). 370 371 If 'attendee' is indicated, the attendee's participation will be taken 372 into account when generating the description. 373 """ 374 375 # Assume that the outcome will be composed of requests and 376 # cancellations. It would not seem completely bizarre to produce 377 # publishing messages if a refresh message was unprovoked. 378 379 responses = [] 380 methods = set() 381 382 # Get the parent event, add SENT-BY details to the organiser. 383 384 if not attendee or self.is_participating(attendee, obj=obj): 385 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 386 self.update_sender(organiser_attr) 387 responses.append(self.object_to_part(method, obj)) 388 methods.add(method) 389 390 # Get recurrences for parent events. 391 392 if not self.recurrenceid: 393 394 # Collect active and cancelled recurrences. 395 396 for rl, section, rmethod in [ 397 (self.store.get_active_recurrences(self.user, self.uid), None, method), 398 (self.store.get_cancelled_recurrences(self.user, self.uid), "cancellations", "CANCEL"), 399 ]: 400 401 for recurrenceid in rl: 402 403 # Get the recurrence, add SENT-BY details to the organiser. 404 405 obj = self.get_stored_object(self.uid, recurrenceid, section) 406 407 if not attendee or self.is_participating(attendee, obj=obj): 408 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 409 self.update_sender(organiser_attr) 410 responses.append(self.object_to_part(rmethod, obj)) 411 methods.add(rmethod) 412 413 return methods, responses 414 415 class ClientForObject(Client): 416 417 "A client maintaining a specific object." 418 419 def __init__(self, obj, user, messenger=None, store=None, publisher=None, 420 journal=None, preferences_dir=None): 421 Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir) 422 self.set_object(obj) 423 424 def set_object(self, obj): 425 426 "Set the current object to 'obj', obtaining metadata details." 427 428 self.obj = obj 429 self.uid = obj and self.obj.get_uid() 430 self.recurrenceid = obj and self.obj.get_recurrenceid() 431 self.sequence = obj and self.obj.get_value("SEQUENCE") 432 self.dtstamp = obj and self.obj.get_value("DTSTAMP") 433 434 # Set the fallback time zone. 435 436 if obj: 437 self.obj.set_tzid(self.get_tzid()) 438 439 def load_object(self, uid, recurrenceid): 440 441 "Load the object with the given 'uid' and 'recurrenceid'." 442 443 self.set_object(self.get_stored_object(uid, recurrenceid)) 444 445 def set_identity(self, method): 446 447 """ 448 Set the current user for the current object in the context of the given 449 'method'. It is usually set when initialising the handler, using the 450 recipient details, but outgoing messages do not reference the recipient 451 in this way. 452 """ 453 454 pass 455 456 def is_usable(self, method=None): 457 458 "Return whether the current object is usable with the given 'method'." 459 460 return True 461 462 def is_organiser(self): 463 464 """ 465 Return whether the current user is the organiser in the current object. 466 """ 467 468 return get_uri(self.obj.get_value("ORGANIZER")) == self.user 469 470 def is_recurrence(self): 471 472 "Return whether the current object is a recurrence of its parent." 473 474 parent = self.get_parent_object() 475 return parent and parent.has_recurrence(self.obj.get_recurrenceid()) 476 477 def get_recurrences(self): 478 479 "Return the current object's recurrence identifiers." 480 481 return self.store.get_recurrences(self.user, self.uid) 482 483 def get_periods(self, obj=None, explicit_only=False, future_only=False): 484 485 "Return the periods provided by the current object." 486 487 return Client.get_periods(self, obj or self.obj, explicit_only, future_only) 488 489 def get_updated_periods(self): 490 491 """ 492 Return the periods provided by the current object and associated 493 recurrence instances. Each original period is returned in a tuple with 494 a corresponding updated period which may be the same or which may be 495 None if the period is cancelled. A list of these tuples is returned. 496 """ 497 498 updated = [] 499 recurrenceids = self.get_recurrences() 500 501 for period in self.get_periods(): 502 recurrenceid = period.is_replaced(recurrenceids) 503 504 # Obtain any replacement instead of the replaced period. 505 506 if recurrenceid: 507 obj = self.get_stored_object(self.uid, recurrenceid) 508 periods = obj and Client.get_periods(self, obj) 509 510 # Active periods are obtained. Cancelled periods yield None. 511 512 if periods: 513 p = periods[0] 514 if p.origin == "DTSTART" and period.origin != "DTSTART": 515 p.origin = "DTSTART-RECUR" 516 updated.append((period, p)) 517 else: 518 updated.append((period, None)) 519 520 # Otherwise, retain the known period. 521 522 else: 523 updated.append((period, period)) 524 525 return updated 526 527 def get_main_period(self, obj=None): 528 529 "Return the main period defined by the current object." 530 531 return Client.get_main_period(self, obj or self.obj) 532 533 def get_recurrence_periods(self, obj=None): 534 535 "Return the recurrence periods defined by the current object." 536 537 return Client.get_recurrence_periods(self, obj or self.obj) 538 539 # Common operations on calendar data. 540 541 def update_senders(self, obj=None): 542 543 """ 544 Update sender details in 'obj', or the current object if not indicated, 545 removing SENT-BY attributes for attendees other than the current user if 546 those attributes give the URI of the calendar system. 547 """ 548 549 obj = obj or self.obj 550 calendar_uri = self.messenger and get_uri(self.messenger.sender) 551 for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")): 552 if attendee != self.user: 553 if attendee_attr.get("SENT-BY") == calendar_uri: 554 del attendee_attr["SENT-BY"] 555 else: 556 attendee_attr["SENT-BY"] = calendar_uri 557 558 def get_sending_attendee(self): 559 560 "Return the attendee who sent the current object." 561 562 # Search for the sender of the message or the calendar system address. 563 564 senders = self.senders or self.messenger and [self.messenger.sender] or [] 565 566 for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): 567 if get_address(attendee) in senders or \ 568 get_address(attendee_attr.get("SENT-BY")) in senders: 569 return get_uri(attendee) 570 571 return None 572 573 def get_rescheduled_parts(self, periods, method): 574 575 """ 576 Return message parts describing rescheduled 'periods' affected by 'method'. 577 """ 578 579 rescheduled_parts = [] 580 581 if periods: 582 583 # Duplicate the core of the object without any period information. 584 585 obj = self.obj.copy() 586 obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"]) 587 588 for p in periods: 589 if not p.origin: 590 continue 591 592 # Set specific recurrence information. 593 594 obj.set_datetime("DTSTART", p.get_start()) 595 obj.set_datetime("DTEND", p.get_end()) 596 597 # Acquire the original recurrence identifier associated with 598 # this period. This may differ where the start of the period has 599 # changed. 600 601 dt, attr = p.get_recurrenceid_item() 602 obj["RECURRENCE-ID"] = [(format_datetime(dt), attr)] 603 604 rescheduled_parts.append(self.object_to_part(method, obj)) 605 606 return rescheduled_parts 607 608 # Object update methods. 609 610 def update_recurrenceid(self): 611 612 """ 613 Update the RECURRENCE-ID in the current object, initialising it from 614 DTSTART. 615 """ 616 617 self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")] 618 self.recurrenceid = self.obj.get_recurrenceid() 619 620 def update_dtstamp(self, obj=None): 621 622 "Update the DTSTAMP in the current object or any given object 'obj'." 623 624 obj = obj or self.obj 625 self.dtstamp = obj.update_dtstamp() 626 627 def update_sequence(self, increment=False, obj=None): 628 629 "Update the SEQUENCE in the current object or any given object 'obj'." 630 631 obj = obj or self.obj 632 obj.update_sequence(increment) 633 634 def merge_attendance(self, attendees): 635 636 """ 637 Merge attendance from the current object's 'attendees' into the version 638 stored for the current user. 639 """ 640 641 obj = self.get_stored_object_version() 642 643 if not obj or not self.have_new_object(): 644 return False 645 646 # Get attendee details in a usable form. 647 648 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 649 650 for attendee, attendee_attr in attendees.items(): 651 652 # Update attendance in the loaded object for any recognised 653 # attendees. 654 655 if attendee_map.has_key(attendee): 656 attendee_map[attendee] = attendee_attr 657 658 # Check for delegated attendees. 659 660 for attendee, attendee_attr in attendees.items(): 661 662 # Identify delegates and check the delegation using the updated 663 # attendee information. 664 665 if not attendee_map.has_key(attendee) and \ 666 attendee_attr.has_key("DELEGATED-FROM") and \ 667 check_delegation(attendee_map, attendee, attendee_attr): 668 669 attendee_map[attendee] = attendee_attr 670 671 # Set the new details and store the object. 672 673 obj["ATTENDEE"] = attendee_map.items() 674 675 # Set a specific recurrence or the complete event if not an additional 676 # occurrence. 677 678 return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 679 680 def update_attendees(self, attendees, removed): 681 682 """ 683 Update the attendees in the current object with the given 'attendees' 684 and 'removed' attendee lists. 685 686 A tuple is returned containing two items: a list of the attendees whose 687 attendance is being proposed (in a counter-proposal), a list of the 688 attendees whose attendance should be cancelled. 689 """ 690 691 to_cancel = [] 692 693 existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) 694 existing_attendees_map = dict(existing_attendees) 695 696 # Added attendees are those from the supplied collection not already 697 # present in the object. 698 699 added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) 700 removed = uri_values(removed) 701 702 if added or removed: 703 704 # The organiser can remove existing attendees. 705 706 if removed and self.is_organiser(): 707 remaining = [] 708 709 for attendee, attendee_attr in existing_attendees: 710 if attendee in removed: 711 712 # Only when an event has not been published can 713 # attendees be silently removed. 714 715 if self.obj.is_shared(): 716 to_cancel.append((attendee, attendee_attr)) 717 else: 718 remaining.append((attendee, attendee_attr)) 719 720 existing_attendees = remaining 721 722 # Attendees (when countering) must only include the current user and 723 # any added attendees. 724 725 elif not self.is_organiser(): 726 existing_attendees = [] 727 728 # Both organisers and attendees (when countering) can add attendees. 729 730 if added: 731 732 # Obtain a mapping from URIs to name details. 733 734 attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) 735 736 for attendee in added: 737 attendee = attendee.strip() 738 if attendee: 739 cn = attendee_map.get(attendee) 740 attendee_attr = {"CN" : cn} or {} 741 742 # Only the organiser can reset the participation attributes. 743 744 if self.is_organiser(): 745 attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) 746 747 existing_attendees.append((attendee, attendee_attr)) 748 749 # Attendees (when countering) must only include the current user and 750 # any added attendees. 751 752 if not self.is_organiser() and self.user not in existing_attendees: 753 user_attr = self.get_user_attributes() 754 user_attr.update(existing_attendees_map.get(self.user) or {}) 755 existing_attendees.append((self.user, user_attr)) 756 757 self.obj["ATTENDEE"] = existing_attendees 758 759 return added, to_cancel 760 761 def update_participation(self, partstat=None): 762 763 """ 764 Update the participation in the current object of the user with the 765 given 'partstat'. 766 """ 767 768 attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user) 769 if not attendee_attr: 770 return None 771 if partstat: 772 attendee_attr["PARTSTAT"] = partstat 773 if attendee_attr.has_key("RSVP"): 774 del attendee_attr["RSVP"] 775 self.update_sender(attendee_attr) 776 return attendee_attr 777 778 # Communication methods. 779 780 def send_message(self, parts, sender, obj, from_organiser, bcc_sender): 781 782 """ 783 Send the given 'parts' to the appropriate recipients, also sending a 784 copy to the 'sender'. The 'obj' together with the 'from_organiser' value 785 (which indicates whether the organiser is sending this message) are used 786 to determine the recipients of the message. 787 """ 788 789 # As organiser, send an invitation to attendees, excluding oneself if 790 # also attending. The updated event will be saved by the outgoing 791 # handler. 792 793 organiser = get_uri(obj.get_value("ORGANIZER")) 794 attendees = uri_values(obj.get_values("ATTENDEE")) 795 796 if from_organiser: 797 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 798 else: 799 recipients = [get_address(organiser)] 800 801 # Since the outgoing handler updates this user's free/busy details, 802 # the stored details will probably not have the updated details at 803 # this point, so we update our copy for serialisation as the bundled 804 # free/busy object. 805 806 freebusy = self.store.get_freebusy(self.user).copy() 807 self.update_freebusy(freebusy, self.user, from_organiser) 808 809 # Bundle free/busy information if appropriate. 810 811 part = self.get_freebusy_part(freebusy) 812 if part: 813 parts.append(part) 814 815 if recipients or bcc_sender: 816 self._send_message(sender, recipients, parts, bcc_sender) 817 818 def _send_message(self, sender, recipients, parts, bcc_sender): 819 820 """ 821 Send a message, explicitly specifying the 'sender' as an outgoing BCC 822 recipient since the generic calendar user will be the actual sender. 823 """ 824 825 if not self.messenger: 826 return 827 828 if not bcc_sender: 829 message = self.messenger.make_outgoing_message(parts, recipients) 830 self.messenger.sendmail(recipients, message.as_string()) 831 else: 832 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 833 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 834 835 def send_message_to_self(self, parts): 836 837 "Send a message composed of the given 'parts' to the given user." 838 839 if not self.messenger: 840 return 841 842 sender = get_address(self.user) 843 message = self.messenger.make_outgoing_message(parts, [sender]) 844 self.messenger.sendmail([sender], message.as_string()) 845 846 # Action methods. 847 848 def process_declined_counter(self, attendee): 849 850 "Process a declined counter-proposal." 851 852 # Obtain the counter-proposal for the attendee. 853 854 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) 855 if not obj: 856 return False 857 858 method = "DECLINECOUNTER" 859 self.update_senders(obj=obj) 860 obj.update_dtstamp() 861 obj.update_sequence(False) 862 self._send_message(get_address(self.user), [get_address(attendee)], [self.object_to_part(method, obj)], True) 863 return True 864 865 def process_received_request(self, changed=False): 866 867 """ 868 Process the current request for the current user. Return whether any 869 action was taken. If 'changed' is set to a true value, or if 'attendees' 870 is specified and differs from the stored attendees, a counter-proposal 871 will be sent instead of a reply. 872 """ 873 874 # Reply only on behalf of this user. 875 876 attendee_attr = self.update_participation() 877 878 if not attendee_attr: 879 return False 880 881 if not changed: 882 self.obj["ATTENDEE"] = [(self.user, attendee_attr)] 883 else: 884 self.update_senders() 885 886 self.update_dtstamp() 887 self.update_sequence(False) 888 self.send_message([self.object_to_part(changed and "COUNTER" or "REPLY", self.obj)], 889 get_address(self.user), self.obj, False, True) 890 return True 891 892 def process_created_request(self, method, to_cancel=None, 893 to_unschedule=None, to_reschedule=None): 894 895 """ 896 Process the current request, sending a created request of the given 897 'method' to attendees. Return whether any action was taken. 898 899 If 'to_cancel' is specified, a list of participants to be sent cancel 900 messages is provided. 901 902 If 'to_unschedule' is specified, a list of periods to be unscheduled is 903 provided. 904 905 If 'to_reschedule' is specified, a list of periods to be rescheduled is 906 provided. 907 908 Note that this method, although similar to get_message_parts, processes 909 the core object and the explicitly-specified objects, not the separate 910 recurrence instances that are already stored. 911 """ 912 913 # Here, the organiser should be the current user. 914 915 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 916 917 self.update_sender(organiser_attr) 918 self.update_senders() 919 self.update_dtstamp() 920 self.update_sequence(True) 921 922 if method == "REQUEST": 923 924 # Start with the parent object and augment it with the given 925 # amendments. 926 927 parts = [self.object_to_part(method, self.obj)] 928 929 # Add message parts with cancelled and modified occurrence 930 # information. 931 932 unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL") 933 rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "REQUEST") 934 935 # Send the updated event, along with a cancellation for each of the 936 # unscheduled occurrences. 937 938 self.send_message(parts + unscheduled_parts + rescheduled_parts, 939 get_address(organiser), self.obj, True, False) 940 941 # Since the organiser can update the SEQUENCE but this can leave any 942 # mail/calendar client lagging, issue a PUBLISH message to the 943 # user's address. 944 945 parts = [self.object_to_part("PUBLISH", self.obj)] 946 rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "PUBLISH") 947 948 self.send_message_to_self(parts + unscheduled_parts + rescheduled_parts) 949 950 # When cancelling, replace the attendees with those for whom the event 951 # is now cancelled. 952 953 if method == "CANCEL" or to_cancel: 954 if to_cancel: 955 obj = self.obj.copy() 956 obj["ATTENDEE"] = to_cancel 957 else: 958 obj = self.obj 959 960 # Send a cancellation to all uninvited attendees. 961 962 parts = [self.object_to_part("CANCEL", obj)] 963 self.send_message(parts, get_address(organiser), obj, True, False) 964 965 # Issue a CANCEL message to the user's address. 966 967 if method == "CANCEL": 968 self.send_message_to_self(parts) 969 970 return True 971 972 # Object-related tests. 973 974 def is_recognised_organiser(self, organiser): 975 976 """ 977 Return whether the given 'organiser' is recognised from 978 previously-received details. If no stored details exist, True is 979 returned. 980 """ 981 982 obj = self.get_stored_object_version() 983 if obj: 984 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 985 return stored_organiser == organiser 986 else: 987 return True 988 989 def is_recognised_attendee(self, attendee): 990 991 """ 992 Return whether the given 'attendee' is recognised from 993 previously-received details. If no stored details exist, True is 994 returned. 995 """ 996 997 obj = self.get_stored_object_version() 998 if obj: 999 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 1000 return stored_attendees.has_key(attendee) 1001 else: 1002 return True 1003 1004 def get_attendance(self, user=None, obj=None): 1005 1006 """ 1007 Return the attendance attributes for 'user', or the current user if 1008 'user' is not specified. 1009 """ 1010 1011 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 1012 return attendees.get(user or self.user) 1013 1014 def is_participating(self, user, as_organiser=False, obj=None): 1015 1016 """ 1017 Return whether, subject to the 'user' indicating an identity and the 1018 'as_organiser' status of that identity, the user concerned is actually 1019 participating in the current object event. 1020 """ 1021 1022 # Use any attendee property information for an organiser, not the 1023 # organiser property attributes. 1024 1025 attr = self.get_attendance(user, obj) 1026 return as_organiser or attr is not None and not attr or \ 1027 attr and attr.get("PARTSTAT") not in ("DECLINED", "DELEGATED", "NEEDS-ACTION") 1028 1029 def has_indicated_attendance(self, user=None, obj=None): 1030 1031 """ 1032 Return whether the given 'user' (or the current user if not specified) 1033 has indicated attendance in the given 'obj' (or the current object if 1034 not specified). 1035 """ 1036 1037 attr = self.get_attendance(user, obj) 1038 return attr and attr.get("PARTSTAT") not in (None, "NEEDS-ACTION") 1039 1040 def get_overriding_transparency(self, user, as_organiser=False): 1041 1042 """ 1043 Return the overriding transparency to be associated with the free/busy 1044 records for an event, subject to the 'user' indicating an identity and 1045 the 'as_organiser' status of that identity. 1046 1047 Where an identity is only an organiser and not attending, "ORG" is 1048 returned. Otherwise, no overriding transparency is defined and None is 1049 returned. 1050 """ 1051 1052 attr = self.get_attendance(user) 1053 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 1054 1055 def can_schedule(self, freebusy, periods): 1056 1057 """ 1058 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 1059 """ 1060 1061 return freebusy.can_schedule(periods, self.uid, self.recurrenceid) 1062 1063 def have_new_object(self, strict=True): 1064 1065 """ 1066 Return whether the current object is new to the current user. 1067 1068 If 'strict' is specified and is a false value, the DTSTAMP test will be 1069 ignored. This is useful in handling responses from attendees from 1070 clients (like Claws Mail) that erase time information from DTSTAMP and 1071 make it invalid. 1072 """ 1073 1074 obj = self.get_stored_object_version() 1075 1076 # If found, compare SEQUENCE and potentially DTSTAMP. 1077 1078 if obj: 1079 sequence = obj.get_value("SEQUENCE") 1080 dtstamp = obj.get_value("DTSTAMP") 1081 1082 # If the request refers to an older version of the object, ignore 1083 # it. 1084 1085 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 1086 1087 return True 1088 1089 def possibly_recurring_indefinitely(self): 1090 1091 "Return whether the object recurs indefinitely." 1092 1093 # Obtain the stored object to make sure that recurrence information 1094 # is not being ignored. This might happen if a client sends a 1095 # cancellation without the complete set of properties, for instance. 1096 1097 return self.obj.possibly_recurring_indefinitely() or \ 1098 self.get_stored_object_version() and \ 1099 self.get_stored_object_version().possibly_recurring_indefinitely() 1100 1101 # Constraint application on event periods. 1102 1103 def check_object(self): 1104 1105 "Check the object against any scheduling constraints." 1106 1107 permitted_values = self.get_permitted_values() 1108 if not permitted_values: 1109 return None 1110 1111 invalid = [] 1112 1113 for period in self.obj.get_periods(): 1114 errors = period.check_permitted(permitted_values) 1115 if errors: 1116 start_errors, end_errors = errors 1117 invalid.append((period.origin, start_errors, end_errors)) 1118 1119 return invalid 1120 1121 def correct_object(self): 1122 1123 "Correct the object according to any scheduling constraints." 1124 1125 permitted_values = self.get_permitted_values() 1126 return permitted_values and self.obj.correct_object(permitted_values) 1127 1128 def correct_period(self, period): 1129 1130 "Correct 'period' according to any scheduling constraints." 1131 1132 permitted_values = self.get_permitted_values() 1133 if not permitted_values: 1134 return period 1135 else: 1136 return period.get_corrected(permitted_values) 1137 1138 # Object retrieval. 1139 1140 def get_stored_object_version(self): 1141 1142 """ 1143 Return the stored object to which the current object refers for the 1144 current user. 1145 """ 1146 1147 return self.get_stored_object(self.uid, self.recurrenceid) 1148 1149 def get_definitive_object(self, as_organiser): 1150 1151 """ 1152 Return an object considered definitive for the current transaction, 1153 using 'as_organiser' to select the current transaction's object if 1154 false, or selecting a stored object if true. 1155 """ 1156 1157 return not as_organiser and self.obj or self.get_stored_object_version() 1158 1159 def get_parent_object(self): 1160 1161 """ 1162 Return the parent object to which the current object refers for the 1163 current user. 1164 """ 1165 1166 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 1167 1168 # Convenience methods for modifying free/busy collections. 1169 1170 def get_recurrence_start_point(self, recurrenceid): 1171 1172 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 1173 1174 return self.obj.get_recurrence_start_point(recurrenceid) 1175 1176 def remove_from_freebusy(self, freebusy, participant=None): 1177 1178 """ 1179 Remove this event from the given 'freebusy' collection. If 'participant' 1180 is specified, only remove this event if the participant is attending. 1181 """ 1182 1183 removed = freebusy.remove_event_periods(self.uid, self.recurrenceid, participant) 1184 if not removed and self.recurrenceid: 1185 return freebusy.remove_affected_period(self.uid, self.get_recurrence_start_point(self.recurrenceid), participant) 1186 else: 1187 return removed 1188 1189 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 1190 1191 """ 1192 Remove from 'freebusy' any original recurrence from parent free/busy 1193 details for the current object, if the current object is a specific 1194 additional recurrence. Otherwise, remove all additional recurrence 1195 information corresponding to 'recurrenceids', or if omitted, all 1196 recurrences. 1197 """ 1198 1199 if self.recurrenceid: 1200 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 1201 freebusy.remove_affected_period(self.uid, recurrenceid) 1202 else: 1203 # Remove obsolete recurrence periods. 1204 1205 freebusy.remove_additional_periods(self.uid, recurrenceids) 1206 1207 # Remove original periods affected by additional recurrences. 1208 1209 if recurrenceids: 1210 for recurrenceid in recurrenceids: 1211 recurrenceid = self.get_recurrence_start_point(recurrenceid) 1212 freebusy.remove_affected_period(self.uid, recurrenceid) 1213 1214 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 1215 1216 """ 1217 Update the 'freebusy' collection for this event with the periods and 1218 transparency associated with the current object, subject to the 'user' 1219 identity and the attendance details provided for them, indicating 1220 whether the update is being done 'as_organiser' (for the organiser of 1221 an event) or not. 1222 1223 If 'offer' is set to a true value, any free/busy updates will be tagged 1224 with an expiry time. 1225 """ 1226 1227 # Obtain the stored object if the current object is not issued by the 1228 # organiser. Attendees do not have the opportunity to redefine the 1229 # periods. 1230 1231 obj = self.get_definitive_object(as_organiser) 1232 if not obj: 1233 return 1234 1235 # Obtain the affected periods. 1236 1237 periods = self.get_periods(obj, future_only=True) 1238 1239 # Define an overriding transparency, the indicated event transparency, 1240 # or the default transparency for the free/busy entry. 1241 1242 transp = self.get_overriding_transparency(user, as_organiser) or \ 1243 obj.get_value("TRANSP") or \ 1244 "OPAQUE" 1245 1246 # Calculate any expiry time. If no offer period is defined, do not 1247 # record the offer periods. 1248 1249 if offer: 1250 offer_period = self.get_offer_period() 1251 if offer_period: 1252 expires = get_timestamp(offer_period) 1253 else: 1254 return 1255 else: 1256 expires = None 1257 1258 # Perform the low-level update. 1259 1260 Client.update_freebusy(self, freebusy, periods, transp, 1261 self.uid, self.recurrenceid, 1262 obj.get_value("SUMMARY"), 1263 get_uri(obj.get_value("ORGANIZER")), 1264 expires) 1265 1266 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1267 updating_other=False, offer=False): 1268 1269 """ 1270 Update the 'freebusy' collection for the given 'user', indicating 1271 whether the update is 'for_organiser' (being done for the organiser of 1272 an event) or not, and whether it is 'updating_other' (meaning another 1273 user's details). 1274 1275 If 'offer' is set to a true value, any free/busy updates will be tagged 1276 with an expiry time. 1277 """ 1278 1279 # Record in the free/busy details unless a non-participating attendee. 1280 # Remove periods for non-participating attendees. 1281 1282 if offer or self.is_participating(user, for_organiser and not updating_other): 1283 self.update_freebusy(freebusy, user, 1284 for_organiser and not updating_other or 1285 not for_organiser and updating_other, 1286 offer 1287 ) 1288 else: 1289 self.remove_from_freebusy(freebusy) 1290 1291 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1292 updating_other=False): 1293 1294 """ 1295 Remove details from the 'freebusy' collection for the given 'user', 1296 indicating whether the modification is 'for_organiser' (being done for 1297 the organiser of an event) or not, and whether it is 'updating_other' 1298 (meaning another user's details). 1299 """ 1300 1301 # Remove from the free/busy details if a specified attendee. 1302 1303 if self.is_participating(user, for_organiser and not updating_other): 1304 self.remove_from_freebusy(freebusy) 1305 1306 # Convenience methods for updating stored free/busy information received 1307 # from other users. 1308 1309 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 1310 1311 """ 1312 For the current user, record the free/busy information for another 1313 'user', indicating whether the update is 'for_organiser' or not, thus 1314 maintaining a separate record of their free/busy details. 1315 """ 1316 1317 fn = fn or self.update_freebusy_for_participant 1318 1319 # A user does not store free/busy information for themself as another 1320 # party. 1321 1322 if user == self.user: 1323 return 1324 1325 self.acquire_lock() 1326 try: 1327 freebusy = self.store.get_freebusy_for_other_for_update(self.user, user) 1328 fn(freebusy, user, for_organiser, True) 1329 1330 # Tidy up any obsolete recurrences. 1331 1332 self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) 1333 self.store.set_freebusy_for_other(self.user, freebusy, user) 1334 1335 finally: 1336 self.release_lock() 1337 1338 def update_freebusy_from_organiser(self, organiser): 1339 1340 "For the current user, record free/busy information from 'organiser'." 1341 1342 self.update_freebusy_from_participant(organiser, True) 1343 1344 def update_freebusy_from_attendees(self, attendees): 1345 1346 "For the current user, record free/busy information from 'attendees'." 1347 1348 obj = self.get_stored_object_version() 1349 1350 if not obj or not self.have_new_object(): 1351 return False 1352 1353 # Filter out unrecognised attendees. 1354 1355 attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE"))) 1356 1357 for attendee in attendees: 1358 self.update_freebusy_from_participant(attendee, False) 1359 1360 return True 1361 1362 def remove_freebusy_from_organiser(self, organiser): 1363 1364 "For the current user, remove free/busy information from 'organiser'." 1365 1366 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 1367 1368 def remove_freebusy_from_attendees(self, attendees): 1369 1370 "For the current user, remove free/busy information from 'attendees'." 1371 1372 for attendee in attendees.keys(): 1373 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 1374 1375 # Convenience methods for updating free/busy details at the event level. 1376 1377 def update_event_in_freebusy(self, for_organiser=True): 1378 1379 """ 1380 Update free/busy information when handling an object, doing so for the 1381 organiser of an event if 'for_organiser' is set to a true value. 1382 """ 1383 1384 freebusy = self.store.get_freebusy_for_update(self.user) 1385 1386 # Obtain the attendance attributes for this user, if available. 1387 1388 self.update_freebusy_for_participant(freebusy, self.user, for_organiser) 1389 1390 # Remove original recurrence details replaced by additional 1391 # recurrences, as well as obsolete additional recurrences. 1392 1393 self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) 1394 self.store.set_freebusy(self.user, freebusy) 1395 1396 if self.publisher and self.is_sharing() and self.is_publishing(): 1397 self.publisher.set_freebusy(self.user, freebusy) 1398 1399 # Update free/busy provider information if the event may recur 1400 # indefinitely. 1401 1402 if self.possibly_recurring_indefinitely(): 1403 self.store.append_freebusy_provider(self.user, self.obj) 1404 1405 return True 1406 1407 def remove_event_from_freebusy(self): 1408 1409 "Remove free/busy information when handling an object." 1410 1411 freebusy = self.store.get_freebusy_for_update(self.user) 1412 1413 self.remove_from_freebusy(freebusy) 1414 self.remove_freebusy_for_recurrences(freebusy) 1415 self.store.set_freebusy(self.user, freebusy) 1416 1417 if self.publisher and self.is_sharing() and self.is_publishing(): 1418 self.publisher.set_freebusy(self.user, freebusy) 1419 1420 # Update free/busy provider information if the event may recur 1421 # indefinitely. 1422 1423 if self.possibly_recurring_indefinitely(): 1424 self.store.remove_freebusy_provider(self.user, self.obj) 1425 1426 def update_event_in_freebusy_offers(self): 1427 1428 "Update free/busy offers when handling an object." 1429 1430 freebusy = self.store.get_freebusy_offers_for_update(self.user) 1431 1432 # Obtain the attendance attributes for this user, if available. 1433 1434 self.update_freebusy_for_participant(freebusy, self.user, offer=True) 1435 1436 # Remove original recurrence details replaced by additional 1437 # recurrences, as well as obsolete additional recurrences. 1438 1439 self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) 1440 self.store.set_freebusy_offers(self.user, freebusy) 1441 1442 return True 1443 1444 def remove_event_from_freebusy_offers(self): 1445 1446 "Remove free/busy offers when handling an object." 1447 1448 freebusy = self.store.get_freebusy_offers_for_update(self.user) 1449 1450 self.remove_from_freebusy(freebusy) 1451 self.remove_freebusy_for_recurrences(freebusy) 1452 self.store.set_freebusy_offers(self.user, freebusy) 1453 1454 return True 1455 1456 # Convenience methods for removing counter-proposals and updating the 1457 # request queue. 1458 1459 def remove_request(self): 1460 return self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1461 1462 def remove_event(self): 1463 return self.store.remove_event(self.user, self.uid, self.recurrenceid) 1464 1465 def remove_counter(self, attendee): 1466 self.remove_counters([attendee]) 1467 1468 def remove_counters(self, attendees): 1469 for attendee in attendees: 1470 self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid) 1471 1472 if not self.store.get_counters(self.user, self.uid, self.recurrenceid): 1473 self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1474 1475 # vim: tabstop=4 expandtab shiftwidth=4