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