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