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