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 can_schedule, remove_event_periods, \ 31 remove_additional_periods, remove_affected_period, \ 32 update_freebusy 33 from imiptools.profile import Preferences 34 import imip_store 35 36 class Client: 37 38 "Common handler and manager methods." 39 40 default_window_size = 100 41 organiser_methods = "ADD", "CANCEL", "DECLINECOUNTER", "PUBLISH", "REQUEST" 42 43 def __init__(self, user, messenger=None, store=None, publisher=None, journal=None, 44 preferences_dir=None): 45 46 """ 47 Initialise a calendar client with the current 'user', plus any 48 'messenger', 'store', 'publisher' and 'journal' objects, indicating any 49 specific 'preferences_dir'. 50 """ 51 52 self.user = user 53 self.messenger = messenger 54 self.store = store or imip_store.FileStore() 55 self.journal = journal or imip_store.FileJournal() 56 57 try: 58 self.publisher = publisher or imip_store.FilePublisher() 59 except OSError: 60 self.publisher = None 61 62 self.preferences_dir = preferences_dir 63 self.preferences = None 64 65 # Localise the messenger. 66 67 if self.messenger: 68 self.messenger.gettext = self.get_translator() 69 70 def get_store(self): 71 return self.store 72 73 def get_publisher(self): 74 return self.publisher 75 76 def get_journal(self): 77 return self.journal 78 79 # Store-related methods. 80 81 def acquire_lock(self): 82 self.store.acquire_lock(self.user) 83 84 def release_lock(self): 85 self.store.release_lock(self.user) 86 87 # Preferences-related methods. 88 89 def get_preferences(self): 90 if not self.preferences and self.user: 91 self.preferences = Preferences(self.user, self.preferences_dir) 92 return self.preferences 93 94 def get_locale(self): 95 prefs = self.get_preferences() 96 return prefs and prefs.get("LANG", "en", True) or "en" 97 98 def get_translator(self): 99 return get_translator([self.get_locale()]) 100 101 def get_user_attributes(self): 102 prefs = self.get_preferences() 103 return prefs and prefs.get_all(["CN"]) or {} 104 105 def get_tzid(self): 106 prefs = self.get_preferences() 107 return prefs and prefs.get("TZID") or get_default_timezone() 108 109 def get_window_size(self): 110 prefs = self.get_preferences() 111 try: 112 return prefs and int(prefs.get("window_size")) or self.default_window_size 113 except (TypeError, ValueError): 114 return self.default_window_size 115 116 def get_window_end(self): 117 return get_window_end(self.get_tzid(), self.get_window_size()) 118 119 def is_participating(self): 120 121 "Return participation in the calendar system." 122 123 prefs = self.get_preferences() 124 return prefs and prefs.get("participating", config.PARTICIPATING_DEFAULT) != "no" or False 125 126 def is_sharing(self): 127 128 "Return whether free/busy information is being generally shared." 129 130 prefs = self.get_preferences() 131 return prefs and prefs.get("freebusy_sharing", config.SHARING_DEFAULT) == "share" or False 132 133 def is_bundling(self): 134 135 "Return whether free/busy information is being bundled in messages." 136 137 prefs = self.get_preferences() 138 return prefs and prefs.get("freebusy_bundling", config.BUNDLING_DEFAULT) == "always" or False 139 140 def is_notifying(self): 141 142 "Return whether recipients are notified about free/busy payloads." 143 144 prefs = self.get_preferences() 145 return prefs and prefs.get("freebusy_messages", config.NOTIFYING_DEFAULT) == "notify" or False 146 147 def is_publishing(self): 148 149 "Return whether free/busy information is being published as Web resources." 150 151 prefs = self.get_preferences() 152 return prefs and prefs.get("freebusy_publishing", config.PUBLISHING_DEFAULT) == "publish" or False 153 154 def is_refreshing(self): 155 156 "Return whether a recipient supports requests to refresh event details." 157 158 prefs = self.get_preferences() 159 return prefs and prefs.get("event_refreshing", config.REFRESHING_DEFAULT) == "always" or False 160 161 def allow_add(self): 162 return self.get_add_method_response() in ("add", "refresh") 163 164 def get_add_method_response(self): 165 prefs = self.get_preferences() 166 return prefs and prefs.get("add_method_response", config.ADD_RESPONSE_DEFAULT) or "refresh" 167 168 def get_offer_period(self): 169 170 "Decode a specification in the iCalendar duration format." 171 172 prefs = self.get_preferences() 173 duration = prefs and prefs.get("freebusy_offers", config.FREEBUSY_OFFER_DEFAULT) 174 175 # NOTE: Should probably report an error somehow if None. 176 177 return duration and get_duration(duration) or None 178 179 def get_organiser_replacement(self): 180 prefs = self.get_preferences() 181 return prefs and prefs.get("organiser_replacement", config.ORGANISER_REPLACEMENT_DEFAULT) or "attendee" 182 183 def have_manager(self): 184 return config.MANAGER_INTERFACE 185 186 def get_permitted_values(self): 187 188 """ 189 Decode a specification of one of the following forms... 190 191 <minute values> 192 <hour values>:<minute values> 193 <hour values>:<minute values>:<second values> 194 195 ...with each list of values being comma-separated. 196 """ 197 198 prefs = self.get_preferences() 199 permitted_values = prefs and prefs.get("permitted_times") 200 if permitted_values: 201 try: 202 l = [] 203 for component in permitted_values.split(":")[:3]: 204 if component: 205 l.append(map(int, component.split(","))) 206 else: 207 l.append(None) 208 209 # NOTE: Should probably report an error somehow. 210 211 except ValueError: 212 return None 213 else: 214 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or []) 215 return l 216 else: 217 return None 218 219 # Common operations on calendar data. 220 221 def update_sender(self, attr): 222 223 "Update the SENT-BY attribute of the 'attr' sender metadata." 224 225 if self.messenger and self.messenger.sender != get_address(self.user): 226 attr["SENT-BY"] = get_uri(self.messenger.sender) 227 228 def get_periods(self, obj, explicit_only=False): 229 230 """ 231 Return periods for the given 'obj'. Interpretation of periods can depend 232 on the time zone, which is obtained for the current user. If 233 'explicit_only' is set to a true value, only explicit periods will be 234 returned, not rule-based periods. 235 """ 236 237 return obj.get_periods(self.get_tzid(), not explicit_only and self.get_window_end() or None) 238 239 # Store operations. 240 241 def get_stored_object(self, uid, recurrenceid, section=None, username=None): 242 243 """ 244 Return the stored object for the current user, with the given 'uid' and 245 'recurrenceid' from the given 'section' and for the given 'username' (if 246 specified), or from the standard object collection otherwise. 247 """ 248 249 if section == "counters": 250 fragment = self.store.get_counter(self.user, username, uid, recurrenceid) 251 else: 252 fragment = self.store.get_event(self.user, uid, recurrenceid, section) 253 return fragment and Object(fragment) 254 255 # Free/busy operations. 256 257 def get_freebusy_part(self, freebusy=None): 258 259 """ 260 Return a message part containing free/busy information for the user, 261 either specified as 'freebusy' or obtained from the store directly. 262 """ 263 264 if self.is_sharing() and self.is_bundling(): 265 266 # Invent a unique identifier. 267 268 utcnow = get_timestamp() 269 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 270 271 freebusy = freebusy or self.store.get_freebusy(self.user) 272 273 user_attr = {} 274 self.update_sender(user_attr) 275 return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)]) 276 277 return None 278 279 def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 280 281 """ 282 Update the 'freebusy' collection with the given 'periods', indicating a 283 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a 284 recurrence or the parent event. The 'summary' and 'organiser' must also 285 be provided. 286 287 An optional 'expires' datetime string can be provided to tag a free/busy 288 offer. 289 """ 290 291 update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires) 292 293 # Preparation of messages communicating the state of events. 294 295 def get_message_parts(self, obj, method, attendee=None): 296 297 """ 298 Return a tuple containing a list of methods and a list of message parts, 299 with the parts collectively describing the given object 'obj' and its 300 recurrences, using 'method' as the means of publishing details (with 301 CANCEL being used to retract or remove details). 302 303 If 'attendee' is indicated, the attendee's participation will be taken 304 into account when generating the description. 305 """ 306 307 # Assume that the outcome will be composed of requests and 308 # cancellations. It would not seem completely bizarre to produce 309 # publishing messages if a refresh message was unprovoked. 310 311 responses = [] 312 methods = set() 313 314 # Get the parent event, add SENT-BY details to the organiser. 315 316 if not attendee or self.is_participating(attendee, obj=obj): 317 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 318 self.update_sender(organiser_attr) 319 responses.append(obj.to_part(method)) 320 methods.add(method) 321 322 # Get recurrences for parent events. 323 324 if not self.recurrenceid: 325 326 # Collect active and cancelled recurrences. 327 328 for rl, section, rmethod in [ 329 (self.store.get_active_recurrences(self.user, self.uid), None, method), 330 (self.store.get_cancelled_recurrences(self.user, self.uid), "cancellations", "CANCEL"), 331 ]: 332 333 for recurrenceid in rl: 334 335 # Get the recurrence, add SENT-BY details to the organiser. 336 337 obj = self.get_stored_object(self.uid, recurrenceid, section) 338 339 if not attendee or self.is_participating(attendee, obj=obj): 340 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 341 self.update_sender(organiser_attr) 342 responses.append(obj.to_part(rmethod)) 343 methods.add(rmethod) 344 345 return methods, responses 346 347 class ClientForObject(Client): 348 349 "A client maintaining a specific object." 350 351 def __init__(self, obj, user, messenger=None, store=None, publisher=None, 352 journal=None, preferences_dir=None): 353 Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir) 354 self.set_object(obj) 355 356 def set_object(self, obj): 357 358 "Set the current object to 'obj', obtaining metadata details." 359 360 self.obj = obj 361 self.uid = obj and self.obj.get_uid() 362 self.recurrenceid = obj and self.obj.get_recurrenceid() 363 self.sequence = obj and self.obj.get_value("SEQUENCE") 364 self.dtstamp = obj and self.obj.get_value("DTSTAMP") 365 366 def set_identity(self, method): 367 368 """ 369 Set the current user for the current object in the context of the given 370 'method'. It is usually set when initialising the handler, using the 371 recipient details, but outgoing messages do not reference the recipient 372 in this way. 373 """ 374 375 pass 376 377 def is_usable(self, method=None): 378 379 "Return whether the current object is usable with the given 'method'." 380 381 return True 382 383 def is_organiser(self): 384 385 """ 386 Return whether the current user is the organiser in the current object. 387 """ 388 389 return get_uri(self.obj.get_value("ORGANIZER")) == self.user 390 391 # Common operations on calendar data. 392 393 def update_senders(self, obj=None): 394 395 """ 396 Update sender details in 'obj', or the current object if not indicated, 397 removing SENT-BY attributes for attendees other than the current user if 398 those attributes give the URI of the calendar system. 399 """ 400 401 obj = obj or self.obj 402 calendar_uri = self.messenger and get_uri(self.messenger.sender) 403 for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")): 404 if attendee != self.user: 405 if attendee_attr.get("SENT-BY") == calendar_uri: 406 del attendee_attr["SENT-BY"] 407 else: 408 attendee_attr["SENT-BY"] = calendar_uri 409 410 def get_sending_attendee(self): 411 412 "Return the attendee who sent the current object." 413 414 # Search for the sender of the message or the calendar system address. 415 416 senders = self.senders or self.messenger and [self.messenger.sender] or [] 417 418 for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): 419 if get_address(attendee) in senders or \ 420 get_address(attendee_attr.get("SENT-BY")) in senders: 421 return get_uri(attendee) 422 423 return None 424 425 def get_unscheduled_parts(self, periods): 426 427 "Return message parts describing unscheduled 'periods'." 428 429 unscheduled_parts = [] 430 431 if periods: 432 obj = self.obj.copy() 433 obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"]) 434 435 for p in periods: 436 if not p.origin: 437 continue 438 obj["RECURRENCE-ID"] = obj["DTSTART"] = [(format_datetime(p.get_start()), p.get_start_attr())] 439 obj["DTEND"] = [(format_datetime(p.get_end()), p.get_end_attr())] 440 unscheduled_parts.append(obj.to_part("CANCEL")) 441 442 return unscheduled_parts 443 444 # Object update methods. 445 446 def update_recurrenceid(self): 447 448 """ 449 Update the RECURRENCE-ID in the current object, initialising it from 450 DTSTART. 451 """ 452 453 self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")] 454 self.recurrenceid = self.obj.get_recurrenceid() 455 456 def update_dtstamp(self, obj=None): 457 458 "Update the DTSTAMP in the current object or any given object 'obj'." 459 460 obj = obj or self.obj 461 self.dtstamp = obj.update_dtstamp() 462 463 def update_sequence(self, increment=False, obj=None): 464 465 "Update the SEQUENCE in the current object or any given object 'obj'." 466 467 obj = obj or self.obj 468 obj.update_sequence(increment) 469 470 def merge_attendance(self, attendees): 471 472 """ 473 Merge attendance from the current object's 'attendees' into the version 474 stored for the current user. 475 """ 476 477 obj = self.get_stored_object_version() 478 479 if not obj or not self.have_new_object(): 480 return False 481 482 # Get attendee details in a usable form. 483 484 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 485 486 for attendee, attendee_attr in attendees.items(): 487 488 # Update attendance in the loaded object for any recognised 489 # attendees. 490 491 if attendee_map.has_key(attendee): 492 attendee_map[attendee] = attendee_attr 493 494 # Set the new details and store the object. 495 496 obj["ATTENDEE"] = attendee_map.items() 497 498 # Set a specific recurrence or the complete event if not an additional 499 # occurrence. 500 501 return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 502 503 def update_attendees(self, attendees, removed): 504 505 """ 506 Update the attendees in the current object with the given 'attendees' 507 and 'removed' attendee lists. 508 509 A tuple is returned containing two items: a list of the attendees whose 510 attendance is being proposed (in a counter-proposal), a list of the 511 attendees whose attendance should be cancelled. 512 """ 513 514 to_cancel = [] 515 516 existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) 517 existing_attendees_map = dict(existing_attendees) 518 519 # Added attendees are those from the supplied collection not already 520 # present in the object. 521 522 added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) 523 removed = uri_values(removed) 524 525 if added or removed: 526 527 # The organiser can remove existing attendees. 528 529 if removed and self.is_organiser(): 530 remaining = [] 531 532 for attendee, attendee_attr in existing_attendees: 533 if attendee in removed: 534 535 # Only when an event has not been published can 536 # attendees be silently removed. 537 538 if self.obj.is_shared(): 539 to_cancel.append((attendee, attendee_attr)) 540 else: 541 remaining.append((attendee, attendee_attr)) 542 543 existing_attendees = remaining 544 545 # Attendees (when countering) must only include the current user and 546 # any added attendees. 547 548 elif not self.is_organiser(): 549 existing_attendees = [] 550 551 # Both organisers and attendees (when countering) can add attendees. 552 553 if added: 554 555 # Obtain a mapping from URIs to name details. 556 557 attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) 558 559 for attendee in added: 560 attendee = attendee.strip() 561 if attendee: 562 cn = attendee_map.get(attendee) 563 attendee_attr = {"CN" : cn} or {} 564 565 # Only the organiser can reset the participation attributes. 566 567 if self.is_organiser(): 568 attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) 569 570 existing_attendees.append((attendee, attendee_attr)) 571 572 # Attendees (when countering) must only include the current user and 573 # any added attendees. 574 575 if not self.is_organiser() and self.user not in existing_attendees: 576 user_attr = self.get_user_attributes() 577 user_attr.update(existing_attendees_map.get(self.user) or {}) 578 existing_attendees.append((self.user, user_attr)) 579 580 self.obj["ATTENDEE"] = existing_attendees 581 582 return added, to_cancel 583 584 def update_participation(self, partstat=None): 585 586 """ 587 Update the participation in the current object of the user with the 588 given 'partstat'. 589 """ 590 591 attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user) 592 if not attendee_attr: 593 return None 594 if partstat: 595 attendee_attr["PARTSTAT"] = partstat 596 if attendee_attr.has_key("RSVP"): 597 del attendee_attr["RSVP"] 598 self.update_sender(attendee_attr) 599 return attendee_attr 600 601 # Communication methods. 602 603 def send_message(self, parts, sender, obj, from_organiser, bcc_sender): 604 605 """ 606 Send the given 'parts' to the appropriate recipients, also sending a 607 copy to the 'sender'. The 'obj' together with the 'from_organiser' value 608 (which indicates whether the organiser is sending this message) are used 609 to determine the recipients of the message. 610 """ 611 612 # As organiser, send an invitation to attendees, excluding oneself if 613 # also attending. The updated event will be saved by the outgoing 614 # handler. 615 616 organiser = get_uri(obj.get_value("ORGANIZER")) 617 attendees = uri_values(obj.get_values("ATTENDEE")) 618 619 if from_organiser: 620 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 621 else: 622 recipients = [get_address(organiser)] 623 624 # Since the outgoing handler updates this user's free/busy details, 625 # the stored details will probably not have the updated details at 626 # this point, so we update our copy for serialisation as the bundled 627 # free/busy object. 628 629 freebusy = self.store.get_freebusy(self.user) 630 self.update_freebusy(freebusy, self.user, from_organiser) 631 632 # Bundle free/busy information if appropriate. 633 634 part = self.get_freebusy_part(freebusy) 635 if part: 636 parts.append(part) 637 638 if recipients or bcc_sender: 639 self._send_message(sender, recipients, parts, bcc_sender) 640 641 def _send_message(self, sender, recipients, parts, bcc_sender): 642 643 """ 644 Send a message, explicitly specifying the 'sender' as an outgoing BCC 645 recipient since the generic calendar user will be the actual sender. 646 """ 647 648 if not self.messenger: 649 return 650 651 if not bcc_sender: 652 message = self.messenger.make_outgoing_message(parts, recipients) 653 self.messenger.sendmail(recipients, message.as_string()) 654 else: 655 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 656 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 657 658 def send_message_to_self(self, parts): 659 660 "Send a message composed of the given 'parts' to the given user." 661 662 if not self.messenger: 663 return 664 665 sender = get_address(self.user) 666 message = self.messenger.make_outgoing_message(parts, [sender]) 667 self.messenger.sendmail([sender], message.as_string()) 668 669 # Action methods. 670 671 def process_declined_counter(self, attendee): 672 673 "Process a declined counter-proposal." 674 675 # Obtain the counter-proposal for the attendee. 676 677 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) 678 if not obj: 679 return False 680 681 method = "DECLINECOUNTER" 682 self.update_senders(obj=obj) 683 obj.update_dtstamp() 684 obj.update_sequence(False) 685 self._send_message(get_address(self.user), [get_address(attendee)], [obj.to_part(method)], True) 686 return True 687 688 def process_received_request(self, changed=False): 689 690 """ 691 Process the current request for the current user. Return whether any 692 action was taken. If 'changed' is set to a true value, or if 'attendees' 693 is specified and differs from the stored attendees, a counter-proposal 694 will be sent instead of a reply. 695 """ 696 697 # Reply only on behalf of this user. 698 699 attendee_attr = self.update_participation() 700 701 if not attendee_attr: 702 return False 703 704 if not changed: 705 self.obj["ATTENDEE"] = [(self.user, attendee_attr)] 706 else: 707 self.update_senders() 708 709 self.update_dtstamp() 710 self.update_sequence(False) 711 self.send_message([self.obj.to_part(changed and "COUNTER" or "REPLY")], get_address(self.user), self.obj, False, True) 712 return True 713 714 def process_created_request(self, method, to_cancel=None, to_unschedule=None): 715 716 """ 717 Process the current request, sending a created request of the given 718 'method' to attendees. Return whether any action was taken. 719 720 If 'to_cancel' is specified, a list of participants to be sent cancel 721 messages is provided. 722 723 If 'to_unschedule' is specified, a list of periods to be unscheduled is 724 provided. 725 """ 726 727 # Here, the organiser should be the current user. 728 729 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 730 731 self.update_sender(organiser_attr) 732 self.update_senders() 733 self.update_dtstamp() 734 self.update_sequence(True) 735 736 if method == "REQUEST": 737 methods, parts = self.get_message_parts(self.obj, "REQUEST") 738 739 # Add message parts with cancelled occurrence information. 740 741 unscheduled_parts = self.get_unscheduled_parts(to_unschedule) 742 743 # Send the updated event, along with a cancellation for each of the 744 # unscheduled occurrences. 745 746 self.send_message(parts + unscheduled_parts, get_address(organiser), self.obj, True, False) 747 748 # Since the organiser can update the SEQUENCE but this can leave any 749 # mail/calendar client lagging, issue a PUBLISH message to the 750 # user's address. 751 752 methods, parts = self.get_message_parts(self.obj, "PUBLISH") 753 self.send_message_to_self(parts + unscheduled_parts) 754 755 # When cancelling, replace the attendees with those for whom the event 756 # is now cancelled. 757 758 if method == "CANCEL" or to_cancel: 759 if to_cancel: 760 obj = self.obj.copy() 761 obj["ATTENDEE"] = to_cancel 762 else: 763 obj = self.obj 764 765 # Send a cancellation to all uninvited attendees. 766 767 parts = [obj.to_part("CANCEL")] 768 self.send_message(parts, get_address(organiser), obj, True, False) 769 770 # Issue a CANCEL message to the user's address. 771 772 if method == "CANCEL": 773 self.send_message_to_self(parts) 774 775 return True 776 777 # Object-related tests. 778 779 def is_recognised_organiser(self, organiser): 780 781 """ 782 Return whether the given 'organiser' is recognised from 783 previously-received details. If no stored details exist, True is 784 returned. 785 """ 786 787 obj = self.get_stored_object_version() 788 if obj: 789 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 790 return stored_organiser == organiser 791 else: 792 return True 793 794 def is_recognised_attendee(self, attendee): 795 796 """ 797 Return whether the given 'attendee' is recognised from 798 previously-received details. If no stored details exist, True is 799 returned. 800 """ 801 802 obj = self.get_stored_object_version() 803 if obj: 804 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 805 return stored_attendees.has_key(attendee) 806 else: 807 return True 808 809 def get_attendance(self, user=None, obj=None): 810 811 """ 812 Return the attendance attributes for 'user', or the current user if 813 'user' is not specified. 814 """ 815 816 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 817 return attendees.get(user or self.user) 818 819 def is_participating(self, user, as_organiser=False, obj=None): 820 821 """ 822 Return whether, subject to the 'user' indicating an identity and the 823 'as_organiser' status of that identity, the user concerned is actually 824 participating in the current object event. 825 """ 826 827 # Use any attendee property information for an organiser, not the 828 # organiser property attributes. 829 830 attr = self.get_attendance(user, obj) 831 return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") not in ("DECLINED", "NEEDS-ACTION") 832 833 def has_indicated_attendance(self, user=None, obj=None): 834 835 """ 836 Return whether the given 'user' (or the current user if not specified) 837 has indicated attendance in the given 'obj' (or the current object if 838 not specified). 839 """ 840 841 attr = self.get_attendance(user, obj) 842 return attr and attr.get("PARTSTAT") not in (None, "NEEDS-ACTION") 843 844 def get_overriding_transparency(self, user, as_organiser=False): 845 846 """ 847 Return the overriding transparency to be associated with the free/busy 848 records for an event, subject to the 'user' indicating an identity and 849 the 'as_organiser' status of that identity. 850 851 Where an identity is only an organiser and not attending, "ORG" is 852 returned. Otherwise, no overriding transparency is defined and None is 853 returned. 854 """ 855 856 attr = self.get_attendance(user) 857 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 858 859 def can_schedule(self, freebusy, periods): 860 861 """ 862 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 863 """ 864 865 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 866 867 def have_new_object(self, strict=True): 868 869 """ 870 Return whether the current object is new to the current user. 871 872 If 'strict' is specified and is a false value, the DTSTAMP test will be 873 ignored. This is useful in handling responses from attendees from 874 clients (like Claws Mail) that erase time information from DTSTAMP and 875 make it invalid. 876 """ 877 878 obj = self.get_stored_object_version() 879 880 # If found, compare SEQUENCE and potentially DTSTAMP. 881 882 if obj: 883 sequence = obj.get_value("SEQUENCE") 884 dtstamp = obj.get_value("DTSTAMP") 885 886 # If the request refers to an older version of the object, ignore 887 # it. 888 889 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 890 891 return True 892 893 def possibly_recurring_indefinitely(self): 894 895 "Return whether the object recurs indefinitely." 896 897 # Obtain the stored object to make sure that recurrence information 898 # is not being ignored. This might happen if a client sends a 899 # cancellation without the complete set of properties, for instance. 900 901 return self.obj.possibly_recurring_indefinitely() or \ 902 self.get_stored_object_version() and \ 903 self.get_stored_object_version().possibly_recurring_indefinitely() 904 905 # Constraint application on event periods. 906 907 def check_object(self): 908 909 "Check the object against any scheduling constraints." 910 911 permitted_values = self.get_permitted_values() 912 if not permitted_values: 913 return None 914 915 invalid = [] 916 917 for period in self.obj.get_periods(self.get_tzid()): 918 errors = period.check_permitted(permitted_values) 919 if errors: 920 start_errors, end_errors = errors 921 invalid.append((period.origin, start_errors, end_errors)) 922 923 return invalid 924 925 def correct_object(self): 926 927 "Correct the object according to any scheduling constraints." 928 929 permitted_values = self.get_permitted_values() 930 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 931 932 def correct_period(self, period): 933 934 "Correct 'period' according to any scheduling constraints." 935 936 permitted_values = self.get_permitted_values() 937 if not permitted_values: 938 return period 939 else: 940 return period.get_corrected(permitted_values) 941 942 # Object retrieval. 943 944 def get_stored_object_version(self): 945 946 """ 947 Return the stored object to which the current object refers for the 948 current user. 949 """ 950 951 return self.get_stored_object(self.uid, self.recurrenceid) 952 953 def get_definitive_object(self, as_organiser): 954 955 """ 956 Return an object considered definitive for the current transaction, 957 using 'as_organiser' to select the current transaction's object if 958 false, or selecting a stored object if true. 959 """ 960 961 return not as_organiser and self.obj or self.get_stored_object_version() 962 963 def get_parent_object(self): 964 965 """ 966 Return the parent object to which the current object refers for the 967 current user. 968 """ 969 970 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 971 972 def revert_cancellations(self, periods): 973 974 """ 975 Restore cancelled recurrences corresponding to any of the given 976 'periods'. 977 """ 978 979 for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid): 980 obj = self.get_stored_object(self.uid, recurrenceid, "cancellations") 981 if set(self.get_periods(obj)).intersection(periods): 982 self.store.remove_cancellation(self.user, self.uid, recurrenceid) 983 984 # Convenience methods for modifying free/busy collections. 985 986 def get_recurrence_start_point(self, recurrenceid): 987 988 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 989 990 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 991 992 def remove_from_freebusy(self, freebusy): 993 994 "Remove this event from the given 'freebusy' collection." 995 996 if not remove_event_periods(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 997 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 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 remove_affected_period(freebusy, self.uid, recurrenceid) 1012 else: 1013 # Remove obsolete recurrence periods. 1014 1015 remove_additional_periods(freebusy, 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 remove_affected_period(freebusy, 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