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