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.config import MANAGER_INTERFACE 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_items, uri_values 27 from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \ 28 get_timestamp, to_timezone 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_tzid(self): 78 prefs = self.get_preferences() 79 return prefs and prefs.get("TZID") or get_default_timezone() 80 81 def get_window_size(self): 82 prefs = self.get_preferences() 83 try: 84 return prefs and int(prefs.get("window_size")) or self.default_window_size 85 except (TypeError, ValueError): 86 return self.default_window_size 87 88 def get_window_end(self): 89 return get_window_end(self.get_tzid(), self.get_window_size()) 90 91 def is_participating(self): 92 93 "Return participation in the calendar system." 94 95 prefs = self.get_preferences() 96 return prefs and prefs.get("participating", "participate") != "no" or False 97 98 def is_sharing(self): 99 100 "Return whether free/busy information is being generally shared." 101 102 prefs = self.get_preferences() 103 return prefs and prefs.get("freebusy_sharing") == "share" or False 104 105 def is_bundling(self): 106 107 "Return whether free/busy information is being bundled in messages." 108 109 prefs = self.get_preferences() 110 return prefs and prefs.get("freebusy_bundling") == "always" or False 111 112 def is_notifying(self): 113 114 "Return whether recipients are notified about free/busy payloads." 115 116 prefs = self.get_preferences() 117 return prefs and prefs.get("freebusy_messages") == "notify" or False 118 119 def is_publishing(self): 120 121 "Return whether free/busy information is being published as Web resources." 122 123 prefs = self.get_preferences() 124 return prefs and prefs.get("freebusy_publishing") == "publish" or False 125 126 def is_refreshing(self): 127 128 "Return whether a recipient supports requests to refresh event details." 129 130 prefs = self.get_preferences() 131 return prefs and prefs.get("event_refreshing") == "always" or False 132 133 def allow_add(self): 134 return self.get_add_method_response() in ("add", "refresh") 135 136 def get_add_method_response(self): 137 prefs = self.get_preferences() 138 return prefs and prefs.get("add_method_response", "refresh") or "refresh" 139 140 def get_offer_period(self): 141 142 """ 143 Decode a specification of one of the following forms... 144 145 <number of seconds> 146 <number of days>d 147 """ 148 149 prefs = self.get_preferences() 150 duration = prefs and prefs.get("freebusy_offers") 151 if duration: 152 try: 153 if duration.endswith("d"): 154 return timedelta(days=int(duration[:-1])) 155 else: 156 return timedelta(seconds=int(duration)) 157 158 # NOTE: Should probably report an error somehow. 159 160 except ValueError: 161 return None 162 else: 163 return None 164 165 def get_organiser_replacement(self): 166 prefs = self.get_preferences() 167 return prefs and prefs.get("organiser_replacement", "attendee") or "attendee" 168 169 def have_manager(self): 170 return MANAGER_INTERFACE 171 172 def get_permitted_values(self): 173 174 """ 175 Decode a specification of one of the following forms... 176 177 <minute values> 178 <hour values>:<minute values> 179 <hour values>:<minute values>:<second values> 180 181 ...with each list of values being comma-separated. 182 """ 183 184 prefs = self.get_preferences() 185 permitted_values = prefs and prefs.get("permitted_times") 186 if permitted_values: 187 try: 188 l = [] 189 for component in permitted_values.split(":")[:3]: 190 if component: 191 l.append(map(int, component.split(","))) 192 else: 193 l.append(None) 194 195 # NOTE: Should probably report an error somehow. 196 197 except ValueError: 198 return None 199 else: 200 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or []) 201 return l 202 else: 203 return None 204 205 # Common operations on calendar data. 206 207 def update_attendees(self, obj, attendees, removed): 208 209 """ 210 Update the attendees in 'obj' with the given 'attendees' and 'removed' 211 attendee lists. A list is returned containing the attendees whose 212 attendance should be cancelled. 213 """ 214 215 to_cancel = [] 216 217 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 218 added = set(attendees).difference(existing_attendees) 219 220 if added or removed: 221 attendees = uri_items(obj.get_items("ATTENDEE") or []) 222 sequence = obj.get_value("SEQUENCE") 223 224 if removed: 225 remaining = [] 226 227 for attendee, attendee_attr in attendees: 228 if attendee in removed: 229 230 # Without a sequence number, assume that the event has not 231 # been published and that attendees can be silently removed. 232 233 if sequence is not None: 234 to_cancel.append((attendee, attendee_attr)) 235 else: 236 remaining.append((attendee, attendee_attr)) 237 238 attendees = remaining 239 240 if added: 241 for attendee in added: 242 attendee = attendee.strip() 243 if attendee: 244 attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) 245 246 obj["ATTENDEE"] = attendees 247 248 return to_cancel 249 250 def update_participation(self, obj, partstat=None): 251 252 """ 253 Update the participation in 'obj' of the user with the given 'partstat'. 254 """ 255 256 attendee_attr = uri_dict(obj.get_value_map("ATTENDEE")).get(self.user) 257 if not attendee_attr: 258 return None 259 if partstat: 260 attendee_attr["PARTSTAT"] = partstat 261 if attendee_attr.has_key("RSVP"): 262 del attendee_attr["RSVP"] 263 self.update_sender(attendee_attr) 264 return attendee_attr 265 266 def update_sender(self, attr): 267 268 "Update the SENT-BY attribute of the 'attr' sender metadata." 269 270 if self.messenger and self.messenger.sender != get_address(self.user): 271 attr["SENT-BY"] = get_uri(self.messenger.sender) 272 273 def get_periods(self, obj): 274 275 """ 276 Return periods for the given 'obj'. Interpretation of periods can depend 277 on the time zone, which is obtained for the current user. 278 """ 279 280 return obj.get_periods(self.get_tzid(), self.get_window_end()) 281 282 # Store operations. 283 284 def get_stored_object(self, uid, recurrenceid): 285 286 """ 287 Return the stored object for the current user, with the given 'uid' and 288 'recurrenceid'. 289 """ 290 291 fragment = self.store.get_event(self.user, uid, recurrenceid) 292 return fragment and Object(fragment) 293 294 # Free/busy operations. 295 296 def get_freebusy_part(self, freebusy=None): 297 298 """ 299 Return a message part containing free/busy information for the user, 300 either specified as 'freebusy' or obtained from the store directly. 301 """ 302 303 if self.is_sharing() and self.is_bundling(): 304 305 # Invent a unique identifier. 306 307 utcnow = get_timestamp() 308 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 309 310 freebusy = freebusy or self.store.get_freebusy(self.user) 311 312 user_attr = {} 313 self.update_sender(user_attr) 314 return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)]) 315 316 return None 317 318 def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 319 320 """ 321 Update the 'freebusy' collection with the given 'periods', indicating a 322 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a 323 recurrence or the parent event. The 'summary' and 'organiser' must also 324 be provided. 325 326 An optional 'expires' datetime string can be provided to tag a free/busy 327 offer. 328 """ 329 330 update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires) 331 332 class ClientForObject(Client): 333 334 "A client maintaining a specific object." 335 336 def __init__(self, obj, user, messenger=None, store=None, publisher=None, preferences_dir=None): 337 Client.__init__(self, user, messenger, store, publisher, preferences_dir) 338 self.set_object(obj) 339 340 def set_object(self, obj): 341 342 "Set the current object to 'obj', obtaining metadata details." 343 344 self.obj = obj 345 self.uid = obj and self.obj.get_uid() 346 self.recurrenceid = obj and self.obj.get_recurrenceid() 347 self.sequence = obj and self.obj.get_value("SEQUENCE") 348 self.dtstamp = obj and self.obj.get_value("DTSTAMP") 349 350 def set_identity(self, method): 351 352 """ 353 Set the current user for the current object in the context of the given 354 'method'. It is usually set when initialising the handler, using the 355 recipient details, but outgoing messages do not reference the recipient 356 in this way. 357 """ 358 359 pass 360 361 def is_usable(self, method=None): 362 363 "Return whether the current object is usable with the given 'method'." 364 365 return True 366 367 # Object update methods. 368 369 def update_recurrenceid(self): 370 371 """ 372 Update the RECURRENCE-ID in the current object, initialising it from 373 DTSTART. 374 """ 375 376 self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")] 377 self.recurrenceid = self.obj.get_recurrenceid() 378 379 def update_dtstamp(self): 380 381 "Update the DTSTAMP in the current object." 382 383 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 384 utcnow = to_timezone(datetime.utcnow(), "UTC") 385 self.dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow) 386 self.obj["DTSTAMP"] = [(self.dtstamp, {})] 387 388 def set_sequence(self, increment=False): 389 390 "Update the SEQUENCE in the current object." 391 392 sequence = self.obj.get_value("SEQUENCE") or "0" 393 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 394 395 def merge_attendance(self, attendees): 396 397 """ 398 Merge attendance from the current object's 'attendees' into the version 399 stored for the current user. 400 """ 401 402 obj = self.get_stored_object_version() 403 404 if not obj or not self.have_new_object(): 405 return False 406 407 # Get attendee details in a usable form. 408 409 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 410 411 for attendee, attendee_attr in attendees.items(): 412 413 # Update attendance in the loaded object. 414 415 attendee_map[attendee] = attendee_attr 416 417 # Set the new details and store the object. 418 419 obj["ATTENDEE"] = attendee_map.items() 420 421 # Set a specific recurrence or the complete event if not an additional 422 # occurrence. 423 424 self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 425 426 return True 427 428 # Object-related tests. 429 430 def is_recognised_organiser(self, organiser): 431 432 """ 433 Return whether the given 'organiser' is recognised from 434 previously-received details. If no stored details exist, True is 435 returned. 436 """ 437 438 obj = self.get_stored_object_version() 439 if obj: 440 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 441 return stored_organiser == organiser 442 else: 443 return True 444 445 def is_recognised_attendee(self, attendee): 446 447 """ 448 Return whether the given 'attendee' is recognised from 449 previously-received details. If no stored details exist, True is 450 returned. 451 """ 452 453 obj = self.get_stored_object_version() 454 if obj: 455 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 456 return stored_attendees.has_key(attendee) 457 else: 458 return True 459 460 def get_attendance(self, user=None, obj=None): 461 462 """ 463 Return the attendance attributes for 'user', or the current user if 464 'user' is not specified. 465 """ 466 467 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 468 return attendees.get(user or self.user) 469 470 def is_participating(self, user, as_organiser=False, obj=None): 471 472 """ 473 Return whether, subject to the 'user' indicating an identity and the 474 'as_organiser' status of that identity, the user concerned is actually 475 participating in the current object event. 476 """ 477 478 # Use any attendee property information for an organiser, not the 479 # organiser property attributes. 480 481 attr = self.get_attendance(user, obj=obj) 482 return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED" 483 484 def get_overriding_transparency(self, user, as_organiser=False): 485 486 """ 487 Return the overriding transparency to be associated with the free/busy 488 records for an event, subject to the 'user' indicating an identity and 489 the 'as_organiser' status of that identity. 490 491 Where an identity is only an organiser and not attending, "ORG" is 492 returned. Otherwise, no overriding transparency is defined and None is 493 returned. 494 """ 495 496 attr = self.get_attendance(user) 497 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 498 499 def can_schedule(self, freebusy, periods): 500 501 """ 502 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 503 """ 504 505 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 506 507 def have_new_object(self, strict=True): 508 509 """ 510 Return whether the current object is new to the current user. 511 512 If 'strict' is specified and is a false value, the DTSTAMP test will be 513 ignored. This is useful in handling responses from attendees from 514 clients (like Claws Mail) that erase time information from DTSTAMP and 515 make it invalid. 516 """ 517 518 obj = self.get_stored_object_version() 519 520 # If found, compare SEQUENCE and potentially DTSTAMP. 521 522 if obj: 523 sequence = obj.get_value("SEQUENCE") 524 dtstamp = obj.get_value("DTSTAMP") 525 526 # If the request refers to an older version of the object, ignore 527 # it. 528 529 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 530 531 return True 532 533 def possibly_recurring_indefinitely(self): 534 535 "Return whether the object recurs indefinitely." 536 537 # Obtain the stored object to make sure that recurrence information 538 # is not being ignored. This might happen if a client sends a 539 # cancellation without the complete set of properties, for instance. 540 541 return self.obj.possibly_recurring_indefinitely() or \ 542 self.get_stored_object_version() and \ 543 self.get_stored_object_version().possibly_recurring_indefinitely() 544 545 # Constraint application on event periods. 546 547 def check_object(self): 548 549 "Check the object against any scheduling constraints." 550 551 permitted_values = self.get_permitted_values() 552 if not permitted_values: 553 return None 554 555 invalid = [] 556 557 for period in self.obj.get_periods(self.get_tzid()): 558 start = period.get_start() 559 end = period.get_end() 560 start_errors = check_permitted_values(start, permitted_values) 561 end_errors = check_permitted_values(end, permitted_values) 562 if start_errors or end_errors: 563 invalid.append((period.origin, start_errors, end_errors)) 564 565 return invalid 566 567 def correct_object(self): 568 569 "Correct the object according to any scheduling constraints." 570 571 permitted_values = self.get_permitted_values() 572 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 573 574 # Object retrieval. 575 576 def get_stored_object_version(self): 577 578 """ 579 Return the stored object to which the current object refers for the 580 current user. 581 """ 582 583 return self.get_stored_object(self.uid, self.recurrenceid) 584 585 def get_definitive_object(self, as_organiser): 586 587 """ 588 Return an object considered definitive for the current transaction, 589 using 'as_organiser' to select the current transaction's object if 590 false, or selecting a stored object if true. 591 """ 592 593 return not as_organiser and self.obj or self.get_stored_object_version() 594 595 def get_parent_object(self): 596 597 """ 598 Return the parent object to which the current object refers for the 599 current user. 600 """ 601 602 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 603 604 # Convenience methods for modifying free/busy collections. 605 606 def get_recurrence_start_point(self, recurrenceid): 607 608 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 609 610 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 611 612 def remove_from_freebusy(self, freebusy): 613 614 "Remove this event from the given 'freebusy' collection." 615 616 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 617 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 618 619 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 620 621 """ 622 Remove from 'freebusy' any original recurrence from parent free/busy 623 details for the current object, if the current object is a specific 624 additional recurrence. Otherwise, remove all additional recurrence 625 information corresponding to 'recurrenceids', or if omitted, all 626 recurrences. 627 """ 628 629 if self.recurrenceid: 630 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 631 remove_affected_period(freebusy, self.uid, recurrenceid) 632 else: 633 # Remove obsolete recurrence periods. 634 635 remove_additional_periods(freebusy, self.uid, recurrenceids) 636 637 # Remove original periods affected by additional recurrences. 638 639 if recurrenceids: 640 for recurrenceid in recurrenceids: 641 recurrenceid = self.get_recurrence_start_point(recurrenceid) 642 remove_affected_period(freebusy, self.uid, recurrenceid) 643 644 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 645 646 """ 647 Update the 'freebusy' collection for this event with the periods and 648 transparency associated with the current object, subject to the 'user' 649 identity and the attendance details provided for them, indicating 650 whether the update is being done 'as_organiser' (for the organiser of 651 an event) or not. 652 653 If 'offer' is set to a true value, any free/busy updates will be tagged 654 with an expiry time. 655 """ 656 657 # Obtain the stored object if the current object is not issued by the 658 # organiser. Attendees do not have the opportunity to redefine the 659 # periods. 660 661 obj = self.get_definitive_object(as_organiser) 662 if not obj: 663 return 664 665 # Obtain the affected periods. 666 667 periods = self.get_periods(obj) 668 669 # Define an overriding transparency, the indicated event transparency, 670 # or the default transparency for the free/busy entry. 671 672 transp = self.get_overriding_transparency(user, as_organiser) or \ 673 obj.get_value("TRANSP") or \ 674 "OPAQUE" 675 676 # Calculate any expiry time. If no offer period is defined, do not 677 # record the offer periods. 678 679 if offer: 680 offer_period = self.get_offer_period() 681 if offer_period: 682 expires = format_datetime(to_timezone(datetime.utcnow(), "UTC") + offer_period) 683 else: 684 return 685 else: 686 expires = None 687 688 # Perform the low-level update. 689 690 Client.update_freebusy(self, freebusy, periods, transp, 691 self.uid, self.recurrenceid, 692 obj.get_value("SUMMARY"), 693 obj.get_value("ORGANIZER"), 694 expires) 695 696 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 697 updating_other=False, offer=False): 698 699 """ 700 Update the 'freebusy' collection for the given 'user', indicating 701 whether the update is 'for_organiser' (being done for the organiser of 702 an event) or not, and whether it is 'updating_other' (meaning another 703 user's details). 704 705 If 'offer' is set to a true value, any free/busy updates will be tagged 706 with an expiry time. 707 """ 708 709 # Record in the free/busy details unless a non-participating attendee. 710 # Remove periods for non-participating attendees. 711 712 if offer or self.is_participating(user, for_organiser and not updating_other): 713 self.update_freebusy(freebusy, user, 714 for_organiser and not updating_other or 715 not for_organiser and updating_other, 716 offer 717 ) 718 else: 719 self.remove_from_freebusy(freebusy) 720 721 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 722 updating_other=False): 723 724 """ 725 Remove details from the 'freebusy' collection for the given 'user', 726 indicating whether the modification is 'for_organiser' (being done for 727 the organiser of an event) or not, and whether it is 'updating_other' 728 (meaning another user's details). 729 """ 730 731 # Remove from the free/busy details if a specified attendee. 732 733 if self.is_participating(user, for_organiser and not updating_other): 734 self.remove_from_freebusy(freebusy) 735 736 # Convenience methods for updating stored free/busy information received 737 # from other users. 738 739 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 740 741 """ 742 For the current user, record the free/busy information for another 743 'user', indicating whether the update is 'for_organiser' or not, thus 744 maintaining a separate record of their free/busy details. 745 """ 746 747 fn = fn or self.update_freebusy_for_participant 748 749 # A user does not store free/busy information for themself as another 750 # party. 751 752 if user == self.user: 753 return 754 755 self.acquire_lock() 756 try: 757 freebusy = self.store.get_freebusy_for_other(self.user, user) 758 fn(freebusy, user, for_organiser, True) 759 760 # Tidy up any obsolete recurrences. 761 762 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 763 self.store.set_freebusy_for_other(self.user, freebusy, user) 764 765 finally: 766 self.release_lock() 767 768 def update_freebusy_from_organiser(self, organiser): 769 770 "For the current user, record free/busy information from 'organiser'." 771 772 self.update_freebusy_from_participant(organiser, True) 773 774 def update_freebusy_from_attendees(self, attendees): 775 776 "For the current user, record free/busy information from 'attendees'." 777 778 for attendee in attendees.keys(): 779 self.update_freebusy_from_participant(attendee, False) 780 781 def remove_freebusy_from_organiser(self, organiser): 782 783 "For the current user, remove free/busy information from 'organiser'." 784 785 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 786 787 def remove_freebusy_from_attendees(self, attendees): 788 789 "For the current user, remove free/busy information from 'attendees'." 790 791 for attendee in attendees.keys(): 792 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 793 794 # vim: tabstop=4 expandtab shiftwidth=4