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_resolution, 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_scheduling_resolution(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 resolution = prefs and prefs.get("scheduling_resolution") 106 if resolution: 107 try: 108 l = [] 109 for component in resolution.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 # Constraint application on event periods. 422 423 def check_object(self): 424 425 "Check the object against any scheduling constraints." 426 427 resolution = self.get_scheduling_resolution() 428 if not resolution: 429 return None 430 431 invalid = [] 432 433 for period in self.obj.get_periods(self.get_tzid()): 434 start = period.get_start() 435 end = period.get_end() 436 start_errors = check_resolution(start, resolution) 437 end_errors = check_resolution(end, resolution) 438 if start_errors or end_errors: 439 invalid.append((period.origin, start_errors, end_errors)) 440 441 return invalid 442 443 def correct_object(self): 444 445 "Correct the object according to any scheduling constraints." 446 447 resolution = self.get_scheduling_resolution() 448 return resolution and self.obj.correct_object(self.get_tzid(), resolution) 449 450 # Object retrieval. 451 452 def get_stored_object_version(self): 453 454 """ 455 Return the stored object to which the current object refers for the 456 current user. 457 """ 458 459 return self.get_stored_object(self.uid, self.recurrenceid) 460 461 def get_definitive_object(self, from_organiser): 462 463 """ 464 Return an object considered definitive for the current transaction, 465 using 'from_organiser' to select the current transaction's object if 466 true, or selecting a stored object if false. 467 """ 468 469 return from_organiser and self.obj or self.get_stored_object_version() 470 471 def get_parent_object(self): 472 473 """ 474 Return the parent object to which the current object refers for the 475 current user. 476 """ 477 478 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 479 480 # Convenience methods for modifying free/busy collections. 481 482 def get_recurrence_start_point(self, recurrenceid): 483 484 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 485 486 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 487 488 def remove_from_freebusy(self, freebusy): 489 490 "Remove this event from the given 'freebusy' collection." 491 492 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 493 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 494 495 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 496 497 """ 498 Remove from 'freebusy' any original recurrence from parent free/busy 499 details for the current object, if the current object is a specific 500 additional recurrence. Otherwise, remove all additional recurrence 501 information corresponding to 'recurrenceids', or if omitted, all 502 recurrences. 503 """ 504 505 if self.recurrenceid: 506 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 507 remove_affected_period(freebusy, self.uid, recurrenceid) 508 else: 509 # Remove obsolete recurrence periods. 510 511 remove_additional_periods(freebusy, self.uid, recurrenceids) 512 513 # Remove original periods affected by additional recurrences. 514 515 if recurrenceids: 516 for recurrenceid in recurrenceids: 517 recurrenceid = self.get_recurrence_start_point(recurrenceid) 518 remove_affected_period(freebusy, self.uid, recurrenceid) 519 520 def update_freebusy(self, freebusy, user, for_organiser): 521 522 """ 523 Update the 'freebusy' collection for this event with the periods and 524 transparency associated with the current object, subject to the 'user' 525 identity and the attendance details provided for them, indicating 526 whether the update is 'for_organiser' 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' or 561 not, and whether it is 'updating_other' (meaning another user's 562 details). 563 """ 564 565 # Record in the free/busy details unless a non-participating attendee. 566 # Use any attendee information for an organiser, not the organiser's own 567 # 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