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