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