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