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