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