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