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