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_recurrence_start_point, get_timestamp, \ 28 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 def update_attendees(obj, attendees, removed): 36 37 """ 38 Update the attendees in 'obj' with the given 'attendees' and 'removed' 39 attendee lists. A list is returned containing the attendees whose 40 attendance should be cancelled. 41 """ 42 43 to_cancel = [] 44 45 existing_attendees = uri_values(obj.get_values("ATTENDEE") or []) 46 added = set(attendees).difference(existing_attendees) 47 48 if added or removed: 49 attendees = uri_items(obj.get_items("ATTENDEE") or []) 50 sequence = obj.get_value("SEQUENCE") 51 52 if removed: 53 remaining = [] 54 55 for attendee, attendee_attr in attendees: 56 if attendee in removed: 57 58 # Without a sequence number, assume that the event has not 59 # been published and that attendees can be silently removed. 60 61 if sequence is not None: 62 to_cancel.append((attendee, attendee_attr)) 63 else: 64 remaining.append((attendee, attendee_attr)) 65 66 attendees = remaining 67 68 if added: 69 for attendee in added: 70 attendee = attendee.strip() 71 if attendee: 72 attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})) 73 74 obj["ATTENDEE"] = attendees 75 76 return to_cancel 77 78 class Client: 79 80 "Common handler and manager methods." 81 82 default_window_size = 100 83 84 def __init__(self, user, messenger=None, store=None, publisher=None): 85 self.user = user 86 self.messenger = messenger 87 self.store = store or imip_store.FileStore() 88 89 try: 90 self.publisher = publisher or imip_store.FilePublisher() 91 except OSError: 92 self.publisher = None 93 94 self.preferences = None 95 96 def get_preferences(self): 97 if not self.preferences and self.user: 98 self.preferences = Preferences(self.user) 99 return self.preferences 100 101 def get_tzid(self): 102 prefs = self.get_preferences() 103 return prefs and prefs.get("TZID") or get_default_timezone() 104 105 def get_window_size(self): 106 prefs = self.get_preferences() 107 try: 108 return prefs and int(prefs.get("window_size")) or self.default_window_size 109 except (TypeError, ValueError): 110 return self.default_window_size 111 112 def get_window_end(self): 113 return get_window_end(self.get_tzid(), self.get_window_size()) 114 115 def is_sharing(self): 116 prefs = self.get_preferences() 117 return prefs and prefs.get("freebusy_sharing") == "share" or False 118 119 def is_bundling(self): 120 prefs = self.get_preferences() 121 return prefs and prefs.get("freebusy_bundling") == "always" or False 122 123 def is_notifying(self): 124 prefs = self.get_preferences() 125 return prefs and prefs.get("freebusy_messages") == "notify" or False 126 127 # Common operations on calendar data. 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, self.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): 213 Client.__init__(self, user, messenger, store, publisher) 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 tzid = self.obj.get_tzid() or self.get_tzid() 417 return get_recurrence_start_point(recurrenceid, tzid) 418 419 def remove_from_freebusy(self, freebusy): 420 421 "Remove this event from the given 'freebusy' collection." 422 423 if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 424 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 425 426 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 427 428 """ 429 Remove from 'freebusy' any original recurrence from parent free/busy 430 details for the current object, if the current object is a specific 431 additional recurrence. Otherwise, remove all additional recurrence 432 information corresponding to 'recurrenceids', or if omitted, all 433 recurrences. 434 """ 435 436 if self.recurrenceid: 437 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 438 remove_affected_period(freebusy, self.uid, recurrenceid) 439 else: 440 # Remove obsolete recurrence periods. 441 442 remove_additional_periods(freebusy, self.uid, recurrenceids) 443 444 # Remove original periods affected by additional recurrences. 445 446 if recurrenceids: 447 for recurrenceid in recurrenceids: 448 recurrenceid = self.get_recurrence_start_point(recurrenceid) 449 remove_affected_period(freebusy, self.uid, recurrenceid) 450 451 def update_freebusy(self, freebusy, user, for_organiser): 452 453 """ 454 Update the 'freebusy' collection for this event with the periods and 455 transparency associated with the current object, subject to the 'user' 456 identity and the attendance details provided for them, indicating 457 whether the update is 'for_organiser' or not. 458 """ 459 460 # Obtain the stored object if the current object is not issued by the 461 # organiser. Attendees do not have the opportunity to redefine the 462 # periods. 463 464 obj = self.get_definitive_object(for_organiser) 465 if not obj: 466 return 467 468 # Obtain the affected periods. 469 470 periods = self.get_periods(obj) 471 472 # Define an overriding transparency, the indicated event transparency, 473 # or the default transparency for the free/busy entry. 474 475 transp = self.get_overriding_transparency(user, for_organiser) or \ 476 obj.get_value("TRANSP") or \ 477 "OPAQUE" 478 479 # Perform the low-level update. 480 481 Client.update_freebusy(self, freebusy, periods, transp, 482 self.uid, self.recurrenceid, 483 obj.get_value("SUMMARY"), 484 obj.get_value("ORGANIZER")) 485 486 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 487 updating_other=False): 488 489 """ 490 Update the 'freebusy' collection using the given 'periods', involving 491 the given 'user', indicating whether the update is 'for_organiser' or 492 not, and whether it is 'updating_other' (meaning another user's 493 details). 494 """ 495 496 # Record in the free/busy details unless a non-participating attendee. 497 # Use any attendee information for an organiser, not the organiser's own 498 # attributes. 499 500 if self.is_participating(user, for_organiser and not updating_other): 501 self.update_freebusy(freebusy, user, for_organiser) 502 else: 503 self.remove_from_freebusy(freebusy) 504 505 # Convenience methods for updating stored free/busy information received 506 # from other users. 507 508 def update_freebusy_from_participant(self, user, for_organiser): 509 510 """ 511 For the current user, record the free/busy information for another 512 'user', indicating whether the update is 'for_organiser' or not, thus 513 maintaining a separate record of their free/busy details. 514 """ 515 516 # A user does not store free/busy information for themself as another 517 # party. 518 519 if user == self.user: 520 return 521 522 freebusy = self.store.get_freebusy_for_other(self.user, user) 523 self.update_freebusy_for_participant(freebusy, user, for_organiser, True) 524 525 # Tidy up any obsolete recurrences. 526 527 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 528 self.store.set_freebusy_for_other(self.user, freebusy, user) 529 530 def update_freebusy_from_organiser(self, organiser): 531 532 "For the current user, record free/busy information from 'organiser'." 533 534 self.update_freebusy_from_participant(organiser, True) 535 536 def update_freebusy_from_attendees(self, attendees): 537 538 "For the current user, record free/busy information from 'attendees'." 539 540 for attendee in attendees.keys(): 541 self.update_freebusy_from_participant(attendee, False) 542 543 # vim: tabstop=4 expandtab shiftwidth=4