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