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