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 # Object update methods. 272 273 def update_recurrenceid(self): 274 275 """ 276 Update the RECURRENCE-ID in the current object, initialising it from 277 DTSTART. 278 """ 279 280 self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")] 281 self.recurrenceid = self.obj.get_recurrenceid() 282 283 def update_dtstamp(self): 284 285 "Update the DTSTAMP in the current object." 286 287 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 288 utcnow = to_timezone(datetime.utcnow(), "UTC") 289 self.dtstamp = format_datetime(dtstamp > utcnow and dtstamp or utcnow) 290 self.obj["DTSTAMP"] = [(self.dtstamp, {})] 291 292 def set_sequence(self, increment=False): 293 294 "Update the SEQUENCE in the current object." 295 296 sequence = self.obj.get_value("SEQUENCE") or "0" 297 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 298 299 def merge_attendance(self, attendees): 300 301 """ 302 Merge attendance from the current object's 'attendees' into the version 303 stored for the current user. 304 """ 305 306 obj = self.get_stored_object_version() 307 308 if not obj or not self.have_new_object(obj): 309 return False 310 311 # Get attendee details in a usable form. 312 313 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 314 315 for attendee, attendee_attr in attendees.items(): 316 317 # Update attendance in the loaded object. 318 319 attendee_map[attendee] = attendee_attr 320 321 # Set the new details and store the object. 322 323 obj["ATTENDEE"] = attendee_map.items() 324 325 # Set the complete event if not an additional occurrence. 326 327 event = obj.to_node() 328 self.store.set_event(self.user, self.uid, self.recurrenceid, event) 329 330 return True 331 332 # Object-related tests. 333 334 def get_attendance(self, user=None, obj=None): 335 336 """ 337 Return the attendance attributes for 'user', or the current user if 338 'user' is not specified. 339 """ 340 341 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 342 return attendees.get(user or self.user) 343 344 def is_participating(self, user, as_organiser=False, obj=None): 345 346 """ 347 Return whether, subject to the 'user' indicating an identity and the 348 'as_organiser' status of that identity, the user concerned is actually 349 participating in the current object event. 350 """ 351 352 # Use any attendee property information for an organiser, not the 353 # organiser property attributes. 354 355 attr = self.get_attendance(user, obj=obj) 356 return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED" 357 358 def get_overriding_transparency(self, user, as_organiser=False): 359 360 """ 361 Return the overriding transparency to be associated with the free/busy 362 records for an event, subject to the 'user' indicating an identity and 363 the 'as_organiser' status of that identity. 364 365 Where an identity is only an organiser and not attending, "ORG" is 366 returned. Otherwise, no overriding transparency is defined and None is 367 returned. 368 """ 369 370 attr = self.get_attendance(user) 371 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 372 373 def is_attendee(self, identity, obj=None): 374 375 """ 376 Return whether 'identity' is an attendee in the current object, or in 377 'obj' if specified. 378 """ 379 380 return identity in uri_values((obj or self.obj).get_values("ATTENDEE")) 381 382 def can_schedule(self, freebusy, periods): 383 384 """ 385 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 386 """ 387 388 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 389 390 def have_new_object(self, obj=None, strict=True): 391 392 """ 393 Return whether the current object is new to the current user (or if the 394 given 'obj' is new). If 'strict' is specified and is a false value, the 395 DTSTAMP test will be ignored. This is useful in handling responses from 396 attendees from clients (like Claws Mail) that erase time information 397 from DTSTAMP and make it invalid. 398 """ 399 400 obj = obj or self.get_stored_object_version() 401 402 # If found, compare SEQUENCE and potentially DTSTAMP. 403 404 if obj: 405 sequence = obj.get_value("SEQUENCE") 406 dtstamp = obj.get_value("DTSTAMP") 407 408 # If the request refers to an older version of the object, ignore 409 # it. 410 411 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 412 413 return True 414 415 def possibly_recurring_indefinitely(self): 416 417 "Return whether the object recurs indefinitely." 418 419 # Obtain the stored object to make sure that recurrence information 420 # is not being ignored. This might happen if a client sends a 421 # cancellation without the complete set of properties, for instance. 422 423 return self.obj.possibly_recurring_indefinitely() or \ 424 self.get_stored_object_version() and \ 425 self.get_stored_object_version().possibly_recurring_indefinitely() 426 427 # Constraint application on event periods. 428 429 def check_object(self): 430 431 "Check the object against any scheduling constraints." 432 433 permitted_values = self.get_permitted_values() 434 if not permitted_values: 435 return None 436 437 invalid = [] 438 439 for period in self.obj.get_periods(self.get_tzid()): 440 start = period.get_start() 441 end = period.get_end() 442 start_errors = check_permitted_values(start, permitted_values) 443 end_errors = check_permitted_values(end, permitted_values) 444 if start_errors or end_errors: 445 invalid.append((period.origin, start_errors, end_errors)) 446 447 return invalid 448 449 def correct_object(self): 450 451 "Correct the object according to any scheduling constraints." 452 453 permitted_values = self.get_permitted_values() 454 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 455 456 # Object retrieval. 457 458 def get_stored_object_version(self): 459 460 """ 461 Return the stored object to which the current object refers for the 462 current user. 463 """ 464 465 return self.get_stored_object(self.uid, self.recurrenceid) 466 467 def get_definitive_object(self, from_organiser): 468 469 """ 470 Return an object considered definitive for the current transaction, 471 using 'from_organiser' to select the current transaction's object if 472 true, or selecting a stored object if false. 473 """ 474 475 return from_organiser and self.obj or self.get_stored_object_version() 476 477 def get_parent_object(self): 478 479 """ 480 Return the parent object to which the current object refers for the 481 current user. 482 """ 483 484 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 485 486 # Convenience methods for modifying free/busy collections. 487 488 def get_recurrence_start_point(self, recurrenceid): 489 490 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 491 492 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 493 494 def remove_from_freebusy(self, freebusy): 495 496 "Remove this event from the given 'freebusy' collection." 497 498 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 499 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 500 501 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 502 503 """ 504 Remove from 'freebusy' any original recurrence from parent free/busy 505 details for the current object, if the current object is a specific 506 additional recurrence. Otherwise, remove all additional recurrence 507 information corresponding to 'recurrenceids', or if omitted, all 508 recurrences. 509 """ 510 511 if self.recurrenceid: 512 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 513 remove_affected_period(freebusy, self.uid, recurrenceid) 514 else: 515 # Remove obsolete recurrence periods. 516 517 remove_additional_periods(freebusy, self.uid, recurrenceids) 518 519 # Remove original periods affected by additional recurrences. 520 521 if recurrenceids: 522 for recurrenceid in recurrenceids: 523 recurrenceid = self.get_recurrence_start_point(recurrenceid) 524 remove_affected_period(freebusy, self.uid, recurrenceid) 525 526 def update_freebusy(self, freebusy, user, for_organiser): 527 528 """ 529 Update the 'freebusy' collection for this event with the periods and 530 transparency associated with the current object, subject to the 'user' 531 identity and the attendance details provided for them, indicating 532 whether the update is being done 'for_organiser' (for the organiser of 533 an event) or not. 534 """ 535 536 # Obtain the stored object if the current object is not issued by the 537 # organiser. Attendees do not have the opportunity to redefine the 538 # periods. 539 540 obj = self.get_definitive_object(for_organiser) 541 if not obj: 542 return 543 544 # Obtain the affected periods. 545 546 periods = self.get_periods(obj) 547 548 # Define an overriding transparency, the indicated event transparency, 549 # or the default transparency for the free/busy entry. 550 551 transp = self.get_overriding_transparency(user, for_organiser) or \ 552 obj.get_value("TRANSP") or \ 553 "OPAQUE" 554 555 # Perform the low-level update. 556 557 Client.update_freebusy(self, freebusy, periods, transp, 558 self.uid, self.recurrenceid, 559 obj.get_value("SUMMARY"), 560 obj.get_value("ORGANIZER")) 561 562 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 563 updating_other=False): 564 565 """ 566 Update the 'freebusy' collection for the given 'user', indicating 567 whether the update is 'for_organiser' (being done for the organiser of 568 an event) or not, and whether it is 'updating_other' (meaning another 569 user's details). 570 """ 571 572 # Record in the free/busy details unless a non-participating attendee. 573 # Remove periods for non-participating attendees. 574 575 if self.is_participating(user, for_organiser and not updating_other): 576 self.update_freebusy(freebusy, user, for_organiser) 577 else: 578 self.remove_from_freebusy(freebusy) 579 580 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 581 updating_other=False): 582 583 """ 584 Remove details from the 'freebusy' collection for the given 'user', 585 indicating whether the modification is 'for_organiser' (being done for 586 the organiser of an event) or not, and whether it is 'updating_other' 587 (meaning another user's details). 588 """ 589 590 # Remove from the free/busy details if a specified attendee. 591 592 if self.is_participating(user, for_organiser and not updating_other): 593 self.remove_from_freebusy(freebusy) 594 595 # Convenience methods for updating stored free/busy information received 596 # from other users. 597 598 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 599 600 """ 601 For the current user, record the free/busy information for another 602 'user', indicating whether the update is 'for_organiser' or not, thus 603 maintaining a separate record of their free/busy details. 604 """ 605 606 fn = fn or self.update_freebusy_for_participant 607 608 # A user does not store free/busy information for themself as another 609 # party. 610 611 if user == self.user: 612 return 613 614 freebusy = self.store.get_freebusy_for_other(self.user, user) 615 fn(freebusy, user, for_organiser, True) 616 617 # Tidy up any obsolete recurrences. 618 619 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 620 self.store.set_freebusy_for_other(self.user, freebusy, user) 621 622 def update_freebusy_from_organiser(self, organiser): 623 624 "For the current user, record free/busy information from 'organiser'." 625 626 self.update_freebusy_from_participant(organiser, True) 627 628 def update_freebusy_from_attendees(self, attendees): 629 630 "For the current user, record free/busy information from 'attendees'." 631 632 for attendee in attendees.keys(): 633 self.update_freebusy_from_participant(attendee, False) 634 635 def remove_freebusy_from_organiser(self, organiser): 636 637 "For the current user, remove free/busy information from 'organiser'." 638 639 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 640 641 def remove_freebusy_from_attendees(self, attendees): 642 643 "For the current user, remove free/busy information from 'attendees'." 644 645 for attendee in attendees.keys(): 646 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 647 648 # vim: tabstop=4 expandtab shiftwidth=4