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