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