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