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