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_participating(self): 73 prefs = self.get_preferences() 74 return prefs and prefs.get("participating", "participate") != "no" or False 75 76 def is_sharing(self): 77 prefs = self.get_preferences() 78 return prefs and prefs.get("freebusy_sharing") == "share" or False 79 80 def is_bundling(self): 81 prefs = self.get_preferences() 82 return prefs and prefs.get("freebusy_bundling") == "always" or False 83 84 def is_notifying(self): 85 prefs = self.get_preferences() 86 return prefs and prefs.get("freebusy_messages") == "notify" or False 87 88 def get_scheduling_resolution(self): 89 90 """ 91 Decode a specification of one of the following forms... 92 93 <minute values> 94 <hour values>:<minute values> 95 <hour values>:<minute values>:<second values> 96 97 ...with each list of values being comma-separated. 98 """ 99 100 prefs = self.get_preferences() 101 resolution = prefs and prefs.get("scheduling_resolution") 102 if resolution: 103 try: 104 l = [] 105 for component in resolution.split(":")[:3]: 106 if component: 107 l.append(map(int, component.split(","))) 108 else: 109 l.append(None) 110 111 # NOTE: Should probably report an error somehow. 112 113 except ValueError: 114 return None 115 else: 116 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or []) 117 return l 118 else: 119 return None 120 121 # Common operations on calendar data. 122 123 def update_attendees(self, obj, attendees, removed): 124 125 """ 126 Update the attendees in 'obj' with the given 'attendees' and 'removed' 127 attendee lists. A list is returned containing the attendees whose 128 attendance should be cancelled. 129 """ 130 131 to_cancel = [] 132 133 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 134 added = set(attendees).difference(existing_attendees) 135 136 if added or removed: 137 attendees = uri_items(obj.get_items("ATTENDEE") or []) 138 sequence = obj.get_value("SEQUENCE") 139 140 if removed: 141 remaining = [] 142 143 for attendee, attendee_attr in attendees: 144 if attendee in removed: 145 146 # Without a sequence number, assume that the event has not 147 # been published and that attendees can be silently removed. 148 149 if sequence is not None: 150 to_cancel.append((attendee, attendee_attr)) 151 else: 152 remaining.append((attendee, attendee_attr)) 153 154 attendees = remaining 155 156 if added: 157 for attendee in added: 158 attendee = attendee.strip() 159 if attendee: 160 attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) 161 162 obj["ATTENDEE"] = attendees 163 164 return to_cancel 165 166 def update_participation(self, obj, partstat=None): 167 168 """ 169 Update the participation in 'obj' of the user with the given 'partstat'. 170 """ 171 172 attendee_attr = uri_dict(obj.get_value_map("ATTENDEE")).get(self.user) 173 if not attendee_attr: 174 return None 175 if partstat: 176 attendee_attr["PARTSTAT"] = partstat 177 if attendee_attr.has_key("RSVP"): 178 del attendee_attr["RSVP"] 179 self.update_sender(attendee_attr) 180 return attendee_attr 181 182 def update_sender(self, attr): 183 184 "Update the SENT-BY attribute of the 'attr' sender metadata." 185 186 if self.messenger and self.messenger.sender != get_address(self.user): 187 attr["SENT-BY"] = get_uri(self.messenger.sender) 188 189 def get_periods(self, obj): 190 191 """ 192 Return periods for the given 'obj'. Interpretation of periods can depend 193 on the time zone, which is obtained for the current user. 194 """ 195 196 return obj.get_periods(self.get_tzid(), self.get_window_end()) 197 198 # Store operations. 199 200 def get_stored_object(self, uid, recurrenceid): 201 202 """ 203 Return the stored object for the current user, with the given 'uid' and 204 'recurrenceid'. 205 """ 206 207 fragment = self.store.get_event(self.user, uid, recurrenceid) 208 return fragment and Object(fragment) 209 210 # Free/busy operations. 211 212 def get_freebusy_part(self, freebusy=None): 213 214 """ 215 Return a message part containing free/busy information for the user, 216 either specified as 'freebusy' or obtained from the store directly. 217 """ 218 219 if self.is_sharing() and self.is_bundling(): 220 221 # Invent a unique identifier. 222 223 utcnow = get_timestamp() 224 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 225 226 freebusy = freebusy or self.store.get_freebusy(self.user) 227 228 user_attr = {} 229 self.update_sender(user_attr) 230 return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)]) 231 232 return None 233 234 def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser): 235 236 """ 237 Update the 'freebusy' collection with the given 'periods', indicating a 238 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a 239 recurrence or the parent event. The 'summary' and 'organiser' must also 240 be provided. 241 """ 242 243 update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser) 244 245 class ClientForObject(Client): 246 247 "A client maintaining a specific object." 248 249 def __init__(self, obj, user, messenger=None, store=None, publisher=None, preferences_dir=None): 250 Client.__init__(self, user, messenger, store, publisher, preferences_dir) 251 self.set_object(obj) 252 253 def set_object(self, obj): 254 255 "Set the current object to 'obj', obtaining metadata details." 256 257 self.obj = obj 258 self.uid = obj and self.obj.get_uid() 259 self.recurrenceid = obj and self.obj.get_recurrenceid() 260 self.sequence = obj and self.obj.get_value("SEQUENCE") 261 self.dtstamp = obj and self.obj.get_value("DTSTAMP") 262 263 # Object update methods. 264 265 def update_dtstamp(self): 266 267 "Update the DTSTAMP in the current object." 268 269 dtstamp = self.obj.get_utc_datetime("DTSTAMP") 270 utcnow = to_timezone(datetime.utcnow(), "UTC") 271 self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})] 272 273 def set_sequence(self, increment=False): 274 275 "Update the SEQUENCE in the current object." 276 277 sequence = self.obj.get_value("SEQUENCE") or "0" 278 self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})] 279 280 def merge_attendance(self, attendees): 281 282 """ 283 Merge attendance from the current object's 'attendees' into the version 284 stored for the current user. 285 """ 286 287 obj = self.get_stored_object_version() 288 289 if not obj or not self.have_new_object(obj): 290 return False 291 292 # Get attendee details in a usable form. 293 294 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 295 296 for attendee, attendee_attr in attendees.items(): 297 298 # Update attendance in the loaded object. 299 300 attendee_map[attendee] = attendee_attr 301 302 # Set the new details and store the object. 303 304 obj["ATTENDEE"] = attendee_map.items() 305 306 # Set the complete event if not an additional occurrence. 307 308 event = obj.to_node() 309 self.store.set_event(self.user, self.uid, self.recurrenceid, event) 310 311 return True 312 313 # Object-related tests. 314 315 def get_attendance(self, user=None): 316 317 """ 318 Return the attendance attributes for 'user', or the current user if 319 'user' is not specified. 320 """ 321 322 attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 323 return attendees.get(user or self.user) or {} 324 325 def is_participating(self, user, as_organiser=False): 326 327 """ 328 Return whether, subject to the 'user' indicating an identity and the 329 'as_organiser' status of that identity, the user concerned is actually 330 participating in the current object event. 331 """ 332 333 attr = self.get_attendance(user) 334 return as_organiser or not attr or attr.get("PARTSTAT") != "DECLINED" 335 336 def get_overriding_transparency(self, user, as_organiser=False): 337 338 """ 339 Return the overriding transparency to be associated with the free/busy 340 records for an event, subject to the 'user' indicating an identity and 341 the 'as_organiser' status of that identity. 342 343 Where an identity is only an organiser and not attending, "ORG" is 344 returned. Otherwise, no overriding transparency is defined and None is 345 returned. 346 """ 347 348 attr = self.get_attendance(user) 349 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 350 351 def is_attendee(self, identity, obj=None): 352 353 """ 354 Return whether 'identity' is an attendee in the current object, or in 355 'obj' if specified. 356 """ 357 358 return identity in uri_values((obj or self.obj).get_values("ATTENDEE")) 359 360 def can_schedule(self, freebusy, periods): 361 362 """ 363 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 364 """ 365 366 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 367 368 def have_new_object(self, obj=None): 369 370 """ 371 Return whether the current object is new to the current user (or if the 372 given 'obj' is new). 373 """ 374 375 obj = obj or self.get_stored_object_version() 376 377 # If found, compare SEQUENCE and potentially DTSTAMP. 378 379 if obj: 380 sequence = obj.get_value("SEQUENCE") 381 dtstamp = obj.get_value("DTSTAMP") 382 383 # If the request refers to an older version of the object, ignore 384 # it. 385 386 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, 387 self.is_partstat_updated(obj)) 388 389 return True 390 391 def is_partstat_updated(self, obj): 392 393 """ 394 Return whether the participant status has been updated in the current 395 object in comparison to the given 'obj'. 396 397 NOTE: Some clients like Claws Mail erase time information from DTSTAMP 398 NOTE: and make it invalid. Thus, such attendance information may also be 399 NOTE: incorporated into any new object assessment. 400 """ 401 402 old_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 403 new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE")) 404 405 for attendee, attr in old_attendees.items(): 406 old_partstat = attr.get("PARTSTAT") 407 new_attr = new_attendees.get(attendee) 408 new_partstat = new_attr and new_attr.get("PARTSTAT") 409 410 if old_partstat == "NEEDS-ACTION" and new_partstat and \ 411 new_partstat != old_partstat: 412 413 return True 414 415 return False 416 417 # Constraint application on event periods. 418 419 def check_object(self): 420 421 "Check the object against any scheduling constraints." 422 423 resolution = self.get_scheduling_resolution() 424 if not resolution: 425 return None 426 427 invalid = [] 428 429 for period in self.obj.get_periods(self.get_tzid()): 430 start = period.get_start() 431 end = period.get_end() 432 start_errors = check_resolution(start, resolution) 433 end_errors = check_resolution(end, resolution) 434 if start_errors or end_errors: 435 invalid.append((period.origin, start_errors, end_errors)) 436 437 return invalid 438 439 def correct_object(self): 440 441 "Correct the object according to any scheduling constraints." 442 443 resolution = self.get_scheduling_resolution() 444 return resolution and self.obj.correct_object(self.get_tzid(), resolution) 445 446 # Object retrieval. 447 448 def get_stored_object_version(self): 449 450 """ 451 Return the stored object to which the current object refers for the 452 current user. 453 """ 454 455 return self.get_stored_object(self.uid, self.recurrenceid) 456 457 def get_definitive_object(self, from_organiser): 458 459 """ 460 Return an object considered definitive for the current transaction, 461 using 'from_organiser' to select the current transaction's object if 462 true, or selecting a stored object if false. 463 """ 464 465 return from_organiser and self.obj or self.get_stored_object_version() 466 467 def get_parent_object(self): 468 469 """ 470 Return the parent object to which the current object refers for the 471 current user. 472 """ 473 474 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 475 476 # Convenience methods for modifying free/busy collections. 477 478 def get_recurrence_start_point(self, recurrenceid): 479 480 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 481 482 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 483 484 def remove_from_freebusy(self, freebusy): 485 486 "Remove this event from the given 'freebusy' collection." 487 488 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 489 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 490 491 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 492 493 """ 494 Remove from 'freebusy' any original recurrence from parent free/busy 495 details for the current object, if the current object is a specific 496 additional recurrence. Otherwise, remove all additional recurrence 497 information corresponding to 'recurrenceids', or if omitted, all 498 recurrences. 499 """ 500 501 if self.recurrenceid: 502 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 503 remove_affected_period(freebusy, self.uid, recurrenceid) 504 else: 505 # Remove obsolete recurrence periods. 506 507 remove_additional_periods(freebusy, self.uid, recurrenceids) 508 509 # Remove original periods affected by additional recurrences. 510 511 if recurrenceids: 512 for recurrenceid in recurrenceids: 513 recurrenceid = self.get_recurrence_start_point(recurrenceid) 514 remove_affected_period(freebusy, self.uid, recurrenceid) 515 516 def update_freebusy(self, freebusy, user, for_organiser): 517 518 """ 519 Update the 'freebusy' collection for this event with the periods and 520 transparency associated with the current object, subject to the 'user' 521 identity and the attendance details provided for them, indicating 522 whether the update is 'for_organiser' or not. 523 """ 524 525 # Obtain the stored object if the current object is not issued by the 526 # organiser. Attendees do not have the opportunity to redefine the 527 # periods. 528 529 obj = self.get_definitive_object(for_organiser) 530 if not obj: 531 return 532 533 # Obtain the affected periods. 534 535 periods = self.get_periods(obj) 536 537 # Define an overriding transparency, the indicated event transparency, 538 # or the default transparency for the free/busy entry. 539 540 transp = self.get_overriding_transparency(user, for_organiser) or \ 541 obj.get_value("TRANSP") or \ 542 "OPAQUE" 543 544 # Perform the low-level update. 545 546 Client.update_freebusy(self, freebusy, periods, transp, 547 self.uid, self.recurrenceid, 548 obj.get_value("SUMMARY"), 549 obj.get_value("ORGANIZER")) 550 551 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 552 updating_other=False): 553 554 """ 555 Update the 'freebusy' collection using the given 'periods', involving 556 the given 'user', indicating whether the update is 'for_organiser' or 557 not, and whether it is 'updating_other' (meaning another user's 558 details). 559 """ 560 561 # Record in the free/busy details unless a non-participating attendee. 562 # Use any attendee information for an organiser, not the organiser's own 563 # attributes. 564 565 if self.is_participating(user, for_organiser and not updating_other): 566 self.update_freebusy(freebusy, user, for_organiser) 567 else: 568 self.remove_from_freebusy(freebusy) 569 570 # Convenience methods for updating stored free/busy information received 571 # from other users. 572 573 def update_freebusy_from_participant(self, user, for_organiser): 574 575 """ 576 For the current user, record the free/busy information for another 577 'user', indicating whether the update is 'for_organiser' or not, thus 578 maintaining a separate record of their free/busy details. 579 """ 580 581 # A user does not store free/busy information for themself as another 582 # party. 583 584 if user == self.user: 585 return 586 587 freebusy = self.store.get_freebusy_for_other(self.user, user) 588 self.update_freebusy_for_participant(freebusy, user, for_organiser, True) 589 590 # Tidy up any obsolete recurrences. 591 592 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 593 self.store.set_freebusy_for_other(self.user, freebusy, user) 594 595 def update_freebusy_from_organiser(self, organiser): 596 597 "For the current user, record free/busy information from 'organiser'." 598 599 self.update_freebusy_from_participant(organiser, True) 600 601 def update_freebusy_from_attendees(self, attendees): 602 603 "For the current user, record free/busy information from 'attendees'." 604 605 for attendee in attendees.keys(): 606 self.update_freebusy_from_participant(attendee, False) 607 608 # vim: tabstop=4 expandtab shiftwidth=4