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