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