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