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