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