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 a specific recurrence or the complete event if not an additional 400 # occurrence. 401 402 self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 403 404 return True 405 406 # Object-related tests. 407 408 def is_recognised_organiser(self, organiser): 409 410 """ 411 Return whether the given 'organiser' is recognised from 412 previously-received details. If no stored details exist, True is 413 returned. 414 """ 415 416 obj = self.get_stored_object_version() 417 if obj: 418 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 419 return stored_organiser == organiser 420 else: 421 return True 422 423 def is_recognised_attendee(self, attendee): 424 425 """ 426 Return whether the given 'attendee' is recognised from 427 previously-received details. If no stored details exist, True is 428 returned. 429 """ 430 431 obj = self.get_stored_object_version() 432 if obj: 433 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 434 return stored_attendees.has_key(attendee) 435 else: 436 return True 437 438 def get_attendance(self, user=None, obj=None): 439 440 """ 441 Return the attendance attributes for 'user', or the current user if 442 'user' is not specified. 443 """ 444 445 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 446 return attendees.get(user or self.user) 447 448 def is_participating(self, user, as_organiser=False, obj=None): 449 450 """ 451 Return whether, subject to the 'user' indicating an identity and the 452 'as_organiser' status of that identity, the user concerned is actually 453 participating in the current object event. 454 """ 455 456 # Use any attendee property information for an organiser, not the 457 # organiser property attributes. 458 459 attr = self.get_attendance(user, obj=obj) 460 return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED" 461 462 def get_overriding_transparency(self, user, as_organiser=False): 463 464 """ 465 Return the overriding transparency to be associated with the free/busy 466 records for an event, subject to the 'user' indicating an identity and 467 the 'as_organiser' status of that identity. 468 469 Where an identity is only an organiser and not attending, "ORG" is 470 returned. Otherwise, no overriding transparency is defined and None is 471 returned. 472 """ 473 474 attr = self.get_attendance(user) 475 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 476 477 def can_schedule(self, freebusy, periods): 478 479 """ 480 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 481 """ 482 483 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 484 485 def have_new_object(self, strict=True): 486 487 """ 488 Return whether the current object is new to the current user. 489 490 If 'strict' is specified and is a false value, the DTSTAMP test will be 491 ignored. This is useful in handling responses from attendees from 492 clients (like Claws Mail) that erase time information from DTSTAMP and 493 make it invalid. 494 """ 495 496 obj = self.get_stored_object_version() 497 498 # If found, compare SEQUENCE and potentially DTSTAMP. 499 500 if obj: 501 sequence = obj.get_value("SEQUENCE") 502 dtstamp = obj.get_value("DTSTAMP") 503 504 # If the request refers to an older version of the object, ignore 505 # it. 506 507 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 508 509 return True 510 511 def possibly_recurring_indefinitely(self): 512 513 "Return whether the object recurs indefinitely." 514 515 # Obtain the stored object to make sure that recurrence information 516 # is not being ignored. This might happen if a client sends a 517 # cancellation without the complete set of properties, for instance. 518 519 return self.obj.possibly_recurring_indefinitely() or \ 520 self.get_stored_object_version() and \ 521 self.get_stored_object_version().possibly_recurring_indefinitely() 522 523 # Constraint application on event periods. 524 525 def check_object(self): 526 527 "Check the object against any scheduling constraints." 528 529 permitted_values = self.get_permitted_values() 530 if not permitted_values: 531 return None 532 533 invalid = [] 534 535 for period in self.obj.get_periods(self.get_tzid()): 536 start = period.get_start() 537 end = period.get_end() 538 start_errors = check_permitted_values(start, permitted_values) 539 end_errors = check_permitted_values(end, permitted_values) 540 if start_errors or end_errors: 541 invalid.append((period.origin, start_errors, end_errors)) 542 543 return invalid 544 545 def correct_object(self): 546 547 "Correct the object according to any scheduling constraints." 548 549 permitted_values = self.get_permitted_values() 550 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 551 552 # Object retrieval. 553 554 def get_stored_object_version(self): 555 556 """ 557 Return the stored object to which the current object refers for the 558 current user. 559 """ 560 561 return self.get_stored_object(self.uid, self.recurrenceid) 562 563 def get_definitive_object(self, as_organiser): 564 565 """ 566 Return an object considered definitive for the current transaction, 567 using 'as_organiser' to select the current transaction's object if 568 false, or selecting a stored object if true. 569 """ 570 571 return not as_organiser and self.obj or self.get_stored_object_version() 572 573 def get_parent_object(self): 574 575 """ 576 Return the parent object to which the current object refers for the 577 current user. 578 """ 579 580 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 581 582 # Convenience methods for modifying free/busy collections. 583 584 def get_recurrence_start_point(self, recurrenceid): 585 586 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 587 588 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 589 590 def remove_from_freebusy(self, freebusy): 591 592 "Remove this event from the given 'freebusy' collection." 593 594 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 595 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 596 597 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 598 599 """ 600 Remove from 'freebusy' any original recurrence from parent free/busy 601 details for the current object, if the current object is a specific 602 additional recurrence. Otherwise, remove all additional recurrence 603 information corresponding to 'recurrenceids', or if omitted, all 604 recurrences. 605 """ 606 607 if self.recurrenceid: 608 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 609 remove_affected_period(freebusy, self.uid, recurrenceid) 610 else: 611 # Remove obsolete recurrence periods. 612 613 remove_additional_periods(freebusy, self.uid, recurrenceids) 614 615 # Remove original periods affected by additional recurrences. 616 617 if recurrenceids: 618 for recurrenceid in recurrenceids: 619 recurrenceid = self.get_recurrence_start_point(recurrenceid) 620 remove_affected_period(freebusy, self.uid, recurrenceid) 621 622 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 623 624 """ 625 Update the 'freebusy' collection for this event with the periods and 626 transparency associated with the current object, subject to the 'user' 627 identity and the attendance details provided for them, indicating 628 whether the update is being done 'as_organiser' (for the organiser of 629 an event) or not. 630 631 If 'offer' is set to a true value, any free/busy updates will be tagged 632 with an expiry time. 633 """ 634 635 # Obtain the stored object if the current object is not issued by the 636 # organiser. Attendees do not have the opportunity to redefine the 637 # periods. 638 639 obj = self.get_definitive_object(as_organiser) 640 if not obj: 641 return 642 643 # Obtain the affected periods. 644 645 periods = self.get_periods(obj) 646 647 # Define an overriding transparency, the indicated event transparency, 648 # or the default transparency for the free/busy entry. 649 650 transp = self.get_overriding_transparency(user, as_organiser) or \ 651 obj.get_value("TRANSP") or \ 652 "OPAQUE" 653 654 # Calculate any expiry time. If no offer period is defined, do not 655 # record the offer periods. 656 657 if offer: 658 offer_period = self.get_offer_period() 659 if offer_period: 660 expires = format_datetime(to_timezone(datetime.utcnow(), "UTC") + offer_period) 661 else: 662 return 663 else: 664 expires = None 665 666 # Perform the low-level update. 667 668 Client.update_freebusy(self, freebusy, periods, transp, 669 self.uid, self.recurrenceid, 670 obj.get_value("SUMMARY"), 671 obj.get_value("ORGANIZER"), 672 expires) 673 674 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 675 updating_other=False, offer=False): 676 677 """ 678 Update the 'freebusy' collection for the given 'user', indicating 679 whether the update is 'for_organiser' (being done for the organiser of 680 an event) or not, and whether it is 'updating_other' (meaning another 681 user's details). 682 683 If 'offer' is set to a true value, any free/busy updates will be tagged 684 with an expiry time. 685 """ 686 687 # Record in the free/busy details unless a non-participating attendee. 688 # Remove periods for non-participating attendees. 689 690 if offer or self.is_participating(user, for_organiser and not updating_other): 691 self.update_freebusy(freebusy, user, 692 for_organiser and not updating_other or 693 not for_organiser and updating_other, 694 offer 695 ) 696 else: 697 self.remove_from_freebusy(freebusy) 698 699 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 700 updating_other=False): 701 702 """ 703 Remove details from the 'freebusy' collection for the given 'user', 704 indicating whether the modification is 'for_organiser' (being done for 705 the organiser of an event) or not, and whether it is 'updating_other' 706 (meaning another user's details). 707 """ 708 709 # Remove from the free/busy details if a specified attendee. 710 711 if self.is_participating(user, for_organiser and not updating_other): 712 self.remove_from_freebusy(freebusy) 713 714 # Convenience methods for updating stored free/busy information received 715 # from other users. 716 717 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 718 719 """ 720 For the current user, record the free/busy information for another 721 'user', indicating whether the update is 'for_organiser' or not, thus 722 maintaining a separate record of their free/busy details. 723 """ 724 725 fn = fn or self.update_freebusy_for_participant 726 727 # A user does not store free/busy information for themself as another 728 # party. 729 730 if user == self.user: 731 return 732 733 self.acquire_lock() 734 try: 735 freebusy = self.store.get_freebusy_for_other(self.user, user) 736 fn(freebusy, user, for_organiser, True) 737 738 # Tidy up any obsolete recurrences. 739 740 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 741 self.store.set_freebusy_for_other(self.user, freebusy, user) 742 743 finally: 744 self.release_lock() 745 746 def update_freebusy_from_organiser(self, organiser): 747 748 "For the current user, record free/busy information from 'organiser'." 749 750 self.update_freebusy_from_participant(organiser, True) 751 752 def update_freebusy_from_attendees(self, attendees): 753 754 "For the current user, record free/busy information from 'attendees'." 755 756 for attendee in attendees.keys(): 757 self.update_freebusy_from_participant(attendee, False) 758 759 def remove_freebusy_from_organiser(self, organiser): 760 761 "For the current user, remove free/busy information from 'organiser'." 762 763 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 764 765 def remove_freebusy_from_attendees(self, attendees): 766 767 "For the current user, remove free/busy information from 'attendees'." 768 769 for attendee in attendees.keys(): 770 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 771 772 # vim: tabstop=4 expandtab shiftwidth=4