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