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