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): 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(self.obj.get_value_map("ATTENDEE")) 342 return attendees.get(user or self.user) or {} 343 344 def is_participating(self, user, as_organiser=False): 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 attr = self.get_attendance(user) 353 return as_organiser or not attr or attr.get("PARTSTAT") != "DECLINED" 354 355 def get_overriding_transparency(self, user, as_organiser=False): 356 357 """ 358 Return the overriding transparency to be associated with the free/busy 359 records for an event, subject to the 'user' indicating an identity and 360 the 'as_organiser' status of that identity. 361 362 Where an identity is only an organiser and not attending, "ORG" is 363 returned. Otherwise, no overriding transparency is defined and None is 364 returned. 365 """ 366 367 attr = self.get_attendance(user) 368 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 369 370 def is_attendee(self, identity, obj=None): 371 372 """ 373 Return whether 'identity' is an attendee in the current object, or in 374 'obj' if specified. 375 """ 376 377 return identity in uri_values((obj or self.obj).get_values("ATTENDEE")) 378 379 def can_schedule(self, freebusy, periods): 380 381 """ 382 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 383 """ 384 385 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 386 387 def have_new_object(self, obj=None, strict=True): 388 389 """ 390 Return whether the current object is new to the current user (or if the 391 given 'obj' is new). If 'strict' is specified and is a false value, the 392 DTSTAMP test will be ignored. This is useful in handling responses from 393 attendees from clients (like Claws Mail) that erase time information 394 from DTSTAMP and make it invalid. 395 """ 396 397 obj = obj or self.get_stored_object_version() 398 399 # If found, compare SEQUENCE and potentially DTSTAMP. 400 401 if obj: 402 sequence = obj.get_value("SEQUENCE") 403 dtstamp = obj.get_value("DTSTAMP") 404 405 # If the request refers to an older version of the object, ignore 406 # it. 407 408 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 409 410 return True 411 412 def possibly_recurring_indefinitely(self): 413 414 "Return whether the object recurs indefinitely." 415 416 # Obtain the stored object to make sure that recurrence information 417 # is not being ignored. This might happen if a client sends a 418 # cancellation without the complete set of properties, for instance. 419 420 return self.obj.possibly_recurring_indefinitely() or \ 421 self.get_stored_object_version() and \ 422 self.get_stored_object_version().possibly_recurring_indefinitely() 423 424 # Constraint application on event periods. 425 426 def check_object(self): 427 428 "Check the object against any scheduling constraints." 429 430 permitted_values = self.get_permitted_values() 431 if not permitted_values: 432 return None 433 434 invalid = [] 435 436 for period in self.obj.get_periods(self.get_tzid()): 437 start = period.get_start() 438 end = period.get_end() 439 start_errors = check_permitted_values(start, permitted_values) 440 end_errors = check_permitted_values(end, permitted_values) 441 if start_errors or end_errors: 442 invalid.append((period.origin, start_errors, end_errors)) 443 444 return invalid 445 446 def correct_object(self): 447 448 "Correct the object according to any scheduling constraints." 449 450 permitted_values = self.get_permitted_values() 451 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 452 453 # Object retrieval. 454 455 def get_stored_object_version(self): 456 457 """ 458 Return the stored object to which the current object refers for the 459 current user. 460 """ 461 462 return self.get_stored_object(self.uid, self.recurrenceid) 463 464 def get_definitive_object(self, from_organiser): 465 466 """ 467 Return an object considered definitive for the current transaction, 468 using 'from_organiser' to select the current transaction's object if 469 true, or selecting a stored object if false. 470 """ 471 472 return from_organiser and self.obj or self.get_stored_object_version() 473 474 def get_parent_object(self): 475 476 """ 477 Return the parent object to which the current object refers for the 478 current user. 479 """ 480 481 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 482 483 # Convenience methods for modifying free/busy collections. 484 485 def get_recurrence_start_point(self, recurrenceid): 486 487 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 488 489 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 490 491 def remove_from_freebusy(self, freebusy): 492 493 "Remove this event from the given 'freebusy' collection." 494 495 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 496 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 497 498 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 499 500 """ 501 Remove from 'freebusy' any original recurrence from parent free/busy 502 details for the current object, if the current object is a specific 503 additional recurrence. Otherwise, remove all additional recurrence 504 information corresponding to 'recurrenceids', or if omitted, all 505 recurrences. 506 """ 507 508 if self.recurrenceid: 509 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 510 remove_affected_period(freebusy, self.uid, recurrenceid) 511 else: 512 # Remove obsolete recurrence periods. 513 514 remove_additional_periods(freebusy, self.uid, recurrenceids) 515 516 # Remove original periods affected by additional recurrences. 517 518 if recurrenceids: 519 for recurrenceid in recurrenceids: 520 recurrenceid = self.get_recurrence_start_point(recurrenceid) 521 remove_affected_period(freebusy, self.uid, recurrenceid) 522 523 def update_freebusy(self, freebusy, user, for_organiser): 524 525 """ 526 Update the 'freebusy' collection for this event with the periods and 527 transparency associated with the current object, subject to the 'user' 528 identity and the attendance details provided for them, indicating 529 whether the update is being done 'for_organiser' (for the organiser of 530 an event) or not. 531 """ 532 533 # Obtain the stored object if the current object is not issued by the 534 # organiser. Attendees do not have the opportunity to redefine the 535 # periods. 536 537 obj = self.get_definitive_object(for_organiser) 538 if not obj: 539 return 540 541 # Obtain the affected periods. 542 543 periods = self.get_periods(obj) 544 545 # Define an overriding transparency, the indicated event transparency, 546 # or the default transparency for the free/busy entry. 547 548 transp = self.get_overriding_transparency(user, for_organiser) or \ 549 obj.get_value("TRANSP") or \ 550 "OPAQUE" 551 552 # Perform the low-level update. 553 554 Client.update_freebusy(self, freebusy, periods, transp, 555 self.uid, self.recurrenceid, 556 obj.get_value("SUMMARY"), 557 obj.get_value("ORGANIZER")) 558 559 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 560 updating_other=False): 561 562 """ 563 Update the 'freebusy' collection using the given 'periods', involving 564 the given 'user', indicating whether the update is 'for_organiser' 565 (being done for the organiser of an event) or not, and whether it is 566 'updating_other' (meaning another user's details). 567 """ 568 569 # Record in the free/busy details unless a non-participating attendee. 570 # Use any attendee property information for an organiser, not the 571 # organiser property attributes. 572 573 if self.is_participating(user, for_organiser and not updating_other): 574 self.update_freebusy(freebusy, user, for_organiser) 575 else: 576 self.remove_from_freebusy(freebusy) 577 578 # Convenience methods for updating stored free/busy information received 579 # from other users. 580 581 def update_freebusy_from_participant(self, user, for_organiser): 582 583 """ 584 For the current user, record the free/busy information for another 585 'user', indicating whether the update is 'for_organiser' or not, thus 586 maintaining a separate record of their free/busy details. 587 """ 588 589 # A user does not store free/busy information for themself as another 590 # party. 591 592 if user == self.user: 593 return 594 595 freebusy = self.store.get_freebusy_for_other(self.user, user) 596 self.update_freebusy_for_participant(freebusy, user, for_organiser, True) 597 598 # Tidy up any obsolete recurrences. 599 600 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 601 self.store.set_freebusy_for_other(self.user, freebusy, user) 602 603 def update_freebusy_from_organiser(self, organiser): 604 605 "For the current user, record free/busy information from 'organiser'." 606 607 self.update_freebusy_from_participant(organiser, True) 608 609 def update_freebusy_from_attendees(self, attendees): 610 611 "For the current user, record free/busy information from 'attendees'." 612 613 for attendee in attendees.keys(): 614 self.update_freebusy_from_participant(attendee, False) 615 616 # vim: tabstop=4 expandtab shiftwidth=4