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