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=obj) 791 return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED" 792 793 def get_overriding_transparency(self, user, as_organiser=False): 794 795 """ 796 Return the overriding transparency to be associated with the free/busy 797 records for an event, subject to the 'user' indicating an identity and 798 the 'as_organiser' status of that identity. 799 800 Where an identity is only an organiser and not attending, "ORG" is 801 returned. Otherwise, no overriding transparency is defined and None is 802 returned. 803 """ 804 805 attr = self.get_attendance(user) 806 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 807 808 def can_schedule(self, freebusy, periods): 809 810 """ 811 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 812 """ 813 814 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 815 816 def have_new_object(self, strict=True): 817 818 """ 819 Return whether the current object is new to the current user. 820 821 If 'strict' is specified and is a false value, the DTSTAMP test will be 822 ignored. This is useful in handling responses from attendees from 823 clients (like Claws Mail) that erase time information from DTSTAMP and 824 make it invalid. 825 """ 826 827 obj = self.get_stored_object_version() 828 829 # If found, compare SEQUENCE and potentially DTSTAMP. 830 831 if obj: 832 sequence = obj.get_value("SEQUENCE") 833 dtstamp = obj.get_value("DTSTAMP") 834 835 # If the request refers to an older version of the object, ignore 836 # it. 837 838 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 839 840 return True 841 842 def possibly_recurring_indefinitely(self): 843 844 "Return whether the object recurs indefinitely." 845 846 # Obtain the stored object to make sure that recurrence information 847 # is not being ignored. This might happen if a client sends a 848 # cancellation without the complete set of properties, for instance. 849 850 return self.obj.possibly_recurring_indefinitely() or \ 851 self.get_stored_object_version() and \ 852 self.get_stored_object_version().possibly_recurring_indefinitely() 853 854 # Constraint application on event periods. 855 856 def check_object(self): 857 858 "Check the object against any scheduling constraints." 859 860 permitted_values = self.get_permitted_values() 861 if not permitted_values: 862 return None 863 864 invalid = [] 865 866 for period in self.obj.get_periods(self.get_tzid()): 867 start = period.get_start() 868 end = period.get_end() 869 start_errors = check_permitted_values(start, permitted_values) 870 end_errors = check_permitted_values(end, permitted_values) 871 if start_errors or end_errors: 872 invalid.append((period.origin, start_errors, end_errors)) 873 874 return invalid 875 876 def correct_object(self): 877 878 "Correct the object according to any scheduling constraints." 879 880 permitted_values = self.get_permitted_values() 881 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 882 883 # Object retrieval. 884 885 def get_stored_object_version(self): 886 887 """ 888 Return the stored object to which the current object refers for the 889 current user. 890 """ 891 892 return self.get_stored_object(self.uid, self.recurrenceid) 893 894 def get_definitive_object(self, as_organiser): 895 896 """ 897 Return an object considered definitive for the current transaction, 898 using 'as_organiser' to select the current transaction's object if 899 false, or selecting a stored object if true. 900 """ 901 902 return not as_organiser and self.obj or self.get_stored_object_version() 903 904 def get_parent_object(self): 905 906 """ 907 Return the parent object to which the current object refers for the 908 current user. 909 """ 910 911 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 912 913 def revert_cancellations(self, periods): 914 915 """ 916 Restore cancelled recurrences corresponding to any of the given 917 'periods'. 918 """ 919 920 for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid): 921 obj = self.get_stored_object(self.uid, recurrenceid, "cancellations") 922 if set(self.get_periods(obj)).intersection(periods): 923 self.store.remove_cancellation(self.user, self.uid, recurrenceid) 924 925 # Convenience methods for modifying free/busy collections. 926 927 def get_recurrence_start_point(self, recurrenceid): 928 929 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 930 931 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 932 933 def remove_from_freebusy(self, freebusy): 934 935 "Remove this event from the given 'freebusy' collection." 936 937 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 938 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 939 940 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 941 942 """ 943 Remove from 'freebusy' any original recurrence from parent free/busy 944 details for the current object, if the current object is a specific 945 additional recurrence. Otherwise, remove all additional recurrence 946 information corresponding to 'recurrenceids', or if omitted, all 947 recurrences. 948 """ 949 950 if self.recurrenceid: 951 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 952 remove_affected_period(freebusy, self.uid, recurrenceid) 953 else: 954 # Remove obsolete recurrence periods. 955 956 remove_additional_periods(freebusy, self.uid, recurrenceids) 957 958 # Remove original periods affected by additional recurrences. 959 960 if recurrenceids: 961 for recurrenceid in recurrenceids: 962 recurrenceid = self.get_recurrence_start_point(recurrenceid) 963 remove_affected_period(freebusy, self.uid, recurrenceid) 964 965 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 966 967 """ 968 Update the 'freebusy' collection for this event with the periods and 969 transparency associated with the current object, subject to the 'user' 970 identity and the attendance details provided for them, indicating 971 whether the update is being done 'as_organiser' (for the organiser of 972 an event) or not. 973 974 If 'offer' is set to a true value, any free/busy updates will be tagged 975 with an expiry time. 976 """ 977 978 # Obtain the stored object if the current object is not issued by the 979 # organiser. Attendees do not have the opportunity to redefine the 980 # periods. 981 982 obj = self.get_definitive_object(as_organiser) 983 if not obj: 984 return 985 986 # Obtain the affected periods. 987 988 periods = self.get_periods(obj) 989 990 # Define an overriding transparency, the indicated event transparency, 991 # or the default transparency for the free/busy entry. 992 993 transp = self.get_overriding_transparency(user, as_organiser) or \ 994 obj.get_value("TRANSP") or \ 995 "OPAQUE" 996 997 # Calculate any expiry time. If no offer period is defined, do not 998 # record the offer periods. 999 1000 if offer: 1001 offer_period = self.get_offer_period() 1002 if offer_period: 1003 expires = get_timestamp(offer_period) 1004 else: 1005 return 1006 else: 1007 expires = None 1008 1009 # Perform the low-level update. 1010 1011 Client.update_freebusy(self, freebusy, periods, transp, 1012 self.uid, self.recurrenceid, 1013 obj.get_value("SUMMARY"), 1014 get_uri(obj.get_value("ORGANIZER")), 1015 expires) 1016 1017 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1018 updating_other=False, offer=False): 1019 1020 """ 1021 Update the 'freebusy' collection for the given 'user', indicating 1022 whether the update is 'for_organiser' (being done for the organiser of 1023 an event) or not, and whether it is 'updating_other' (meaning another 1024 user's details). 1025 1026 If 'offer' is set to a true value, any free/busy updates will be tagged 1027 with an expiry time. 1028 """ 1029 1030 # Record in the free/busy details unless a non-participating attendee. 1031 # Remove periods for non-participating attendees. 1032 1033 if offer or self.is_participating(user, for_organiser and not updating_other): 1034 self.update_freebusy(freebusy, user, 1035 for_organiser and not updating_other or 1036 not for_organiser and updating_other, 1037 offer 1038 ) 1039 else: 1040 self.remove_from_freebusy(freebusy) 1041 1042 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1043 updating_other=False): 1044 1045 """ 1046 Remove details from the 'freebusy' collection for the given 'user', 1047 indicating whether the modification is 'for_organiser' (being done for 1048 the organiser of an event) or not, and whether it is 'updating_other' 1049 (meaning another user's details). 1050 """ 1051 1052 # Remove from the free/busy details if a specified attendee. 1053 1054 if self.is_participating(user, for_organiser and not updating_other): 1055 self.remove_from_freebusy(freebusy) 1056 1057 # Convenience methods for updating stored free/busy information received 1058 # from other users. 1059 1060 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 1061 1062 """ 1063 For the current user, record the free/busy information for another 1064 'user', indicating whether the update is 'for_organiser' or not, thus 1065 maintaining a separate record of their free/busy details. 1066 """ 1067 1068 fn = fn or self.update_freebusy_for_participant 1069 1070 # A user does not store free/busy information for themself as another 1071 # party. 1072 1073 if user == self.user: 1074 return 1075 1076 self.acquire_lock() 1077 try: 1078 freebusy = self.store.get_freebusy_for_other(self.user, user) 1079 fn(freebusy, user, for_organiser, True) 1080 1081 # Tidy up any obsolete recurrences. 1082 1083 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1084 self.store.set_freebusy_for_other(self.user, freebusy, user) 1085 1086 finally: 1087 self.release_lock() 1088 1089 def update_freebusy_from_organiser(self, organiser): 1090 1091 "For the current user, record free/busy information from 'organiser'." 1092 1093 self.update_freebusy_from_participant(organiser, True) 1094 1095 def update_freebusy_from_attendees(self, attendees): 1096 1097 "For the current user, record free/busy information from 'attendees'." 1098 1099 obj = self.get_stored_object_version() 1100 1101 if not obj or not self.have_new_object(): 1102 return 1103 1104 # Filter out unrecognised attendees. 1105 1106 attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE"))) 1107 1108 for attendee in attendees: 1109 self.update_freebusy_from_participant(attendee, False) 1110 1111 def remove_freebusy_from_organiser(self, organiser): 1112 1113 "For the current user, remove free/busy information from 'organiser'." 1114 1115 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 1116 1117 def remove_freebusy_from_attendees(self, attendees): 1118 1119 "For the current user, remove free/busy information from 'attendees'." 1120 1121 for attendee in attendees.keys(): 1122 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 1123 1124 # Convenience methods for updating free/busy details at the event level. 1125 1126 def update_event_in_freebusy(self, for_organiser=True): 1127 1128 """ 1129 Update free/busy information when handling an object, doing so for the 1130 organiser of an event if 'for_organiser' is set to a true value. 1131 """ 1132 1133 freebusy = self.store.get_freebusy(self.user) 1134 1135 # Obtain the attendance attributes for this user, if available. 1136 1137 self.update_freebusy_for_participant(freebusy, self.user, for_organiser) 1138 1139 # Remove original recurrence details replaced by additional 1140 # recurrences, as well as obsolete additional recurrences. 1141 1142 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1143 self.store.set_freebusy(self.user, freebusy) 1144 1145 if self.publisher and self.is_sharing() and self.is_publishing(): 1146 self.publisher.set_freebusy(self.user, freebusy) 1147 1148 # Update free/busy provider information if the event may recur 1149 # indefinitely. 1150 1151 if self.possibly_recurring_indefinitely(): 1152 self.store.append_freebusy_provider(self.user, self.obj) 1153 1154 return True 1155 1156 def remove_event_from_freebusy(self): 1157 1158 "Remove free/busy information when handling an object." 1159 1160 freebusy = self.store.get_freebusy(self.user) 1161 1162 self.remove_from_freebusy(freebusy) 1163 self.remove_freebusy_for_recurrences(freebusy) 1164 self.store.set_freebusy(self.user, freebusy) 1165 1166 if self.publisher and self.is_sharing() and self.is_publishing(): 1167 self.publisher.set_freebusy(self.user, freebusy) 1168 1169 # Update free/busy provider information if the event may recur 1170 # indefinitely. 1171 1172 if self.possibly_recurring_indefinitely(): 1173 self.store.remove_freebusy_provider(self.user, self.obj) 1174 1175 def update_event_in_freebusy_offers(self): 1176 1177 "Update free/busy offers when handling an object." 1178 1179 freebusy = self.store.get_freebusy_offers(self.user) 1180 1181 # Obtain the attendance attributes for this user, if available. 1182 1183 self.update_freebusy_for_participant(freebusy, self.user, offer=True) 1184 1185 # Remove original recurrence details replaced by additional 1186 # recurrences, as well as obsolete additional recurrences. 1187 1188 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1189 self.store.set_freebusy_offers(self.user, freebusy) 1190 1191 return True 1192 1193 def remove_event_from_freebusy_offers(self): 1194 1195 "Remove free/busy offers when handling an object." 1196 1197 freebusy = self.store.get_freebusy_offers(self.user) 1198 1199 self.remove_from_freebusy(freebusy) 1200 self.remove_freebusy_for_recurrences(freebusy) 1201 self.store.set_freebusy_offers(self.user, freebusy) 1202 1203 return True 1204 1205 # Convenience methods for removing counter-proposals and updating the 1206 # request queue. 1207 1208 def remove_request(self): 1209 return self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1210 1211 def remove_event(self): 1212 return self.store.remove_event(self.user, self.uid, self.recurrenceid) 1213 1214 def remove_counter(self, attendee): 1215 self.remove_counters([attendee]) 1216 1217 def remove_counters(self, attendees): 1218 for attendee in attendees: 1219 self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid) 1220 1221 if not self.store.get_counters(self.user, self.uid, self.recurrenceid): 1222 self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1223 1224 # vim: tabstop=4 expandtab shiftwidth=4