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