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, timedelta 23 from imiptools import config 24 from imiptools.data import Object, get_address, get_uri, get_window_end, \ 25 is_new_object, make_freebusy, to_part, \ 26 uri_dict, uri_item, uri_items, uri_parts, uri_values 27 from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \ 28 get_duration, get_timestamp 29 from imiptools.i18n import get_translator 30 from imiptools.period import can_schedule, remove_event_periods, \ 31 remove_additional_periods, remove_affected_period, \ 32 update_freebusy 33 from imiptools.profile import Preferences 34 import imip_store 35 36 class Client: 37 38 "Common handler and manager methods." 39 40 default_window_size = 100 41 organiser_methods = "ADD", "CANCEL", "DECLINECOUNTER", "PUBLISH", "REQUEST" 42 43 def __init__(self, user, messenger=None, store=None, publisher=None, preferences_dir=None): 44 45 """ 46 Initialise a calendar client with the current 'user', plus any 47 'messenger', 'store' and 'publisher' objects, indicating any specific 48 'preferences_dir'. 49 """ 50 51 self.user = user 52 self.messenger = messenger 53 self.store = store or imip_store.FileStore() 54 55 try: 56 self.publisher = publisher or imip_store.FilePublisher() 57 except OSError: 58 self.publisher = None 59 60 self.preferences_dir = preferences_dir 61 self.preferences = None 62 63 # Localise the messenger. 64 65 if self.messenger: 66 self.messenger.gettext = self.get_translator() 67 68 # Store-related methods. 69 70 def acquire_lock(self): 71 self.store.acquire_lock(self.user) 72 73 def release_lock(self): 74 self.store.release_lock(self.user) 75 76 # Preferences-related methods. 77 78 def get_preferences(self): 79 if not self.preferences and self.user: 80 self.preferences = Preferences(self.user, self.preferences_dir) 81 return self.preferences 82 83 def get_locale(self): 84 return self.get_preferences().get("LANG", "en", True) 85 86 def get_translator(self): 87 return get_translator([self.get_locale()]) 88 89 def get_user_attributes(self): 90 prefs = self.get_preferences() 91 return prefs and prefs.get_all(["CN"]) or {} 92 93 def get_tzid(self): 94 prefs = self.get_preferences() 95 return prefs and prefs.get("TZID") or get_default_timezone() 96 97 def get_window_size(self): 98 prefs = self.get_preferences() 99 try: 100 return prefs and int(prefs.get("window_size")) or self.default_window_size 101 except (TypeError, ValueError): 102 return self.default_window_size 103 104 def get_window_end(self): 105 return get_window_end(self.get_tzid(), self.get_window_size()) 106 107 def is_participating(self): 108 109 "Return participation in the calendar system." 110 111 prefs = self.get_preferences() 112 return prefs and prefs.get("participating", config.PARTICIPATING_DEFAULT) != "no" or False 113 114 def is_sharing(self): 115 116 "Return whether free/busy information is being generally shared." 117 118 prefs = self.get_preferences() 119 return prefs and prefs.get("freebusy_sharing", config.SHARING_DEFAULT) == "share" or False 120 121 def is_bundling(self): 122 123 "Return whether free/busy information is being bundled in messages." 124 125 prefs = self.get_preferences() 126 return prefs and prefs.get("freebusy_bundling", config.BUNDLING_DEFAULT) == "always" or False 127 128 def is_notifying(self): 129 130 "Return whether recipients are notified about free/busy payloads." 131 132 prefs = self.get_preferences() 133 return prefs and prefs.get("freebusy_messages", config.NOTIFYING_DEFAULT) == "notify" or False 134 135 def is_publishing(self): 136 137 "Return whether free/busy information is being published as Web resources." 138 139 prefs = self.get_preferences() 140 return prefs and prefs.get("freebusy_publishing", config.PUBLISHING_DEFAULT) == "publish" or False 141 142 def is_refreshing(self): 143 144 "Return whether a recipient supports requests to refresh event details." 145 146 prefs = self.get_preferences() 147 return prefs and prefs.get("event_refreshing", config.REFRESHING_DEFAULT) == "always" or False 148 149 def allow_add(self): 150 return self.get_add_method_response() in ("add", "refresh") 151 152 def get_add_method_response(self): 153 prefs = self.get_preferences() 154 return prefs and prefs.get("add_method_response", config.ADD_RESPONSE_DEFAULT) or "refresh" 155 156 def get_offer_period(self): 157 158 "Decode a specification in the iCalendar duration format." 159 160 prefs = self.get_preferences() 161 duration = prefs and prefs.get("freebusy_offers", config.FREEBUSY_OFFER_DEFAULT) 162 163 # NOTE: Should probably report an error somehow if None. 164 165 return duration and get_duration(duration) or None 166 167 def get_organiser_replacement(self): 168 prefs = self.get_preferences() 169 return prefs and prefs.get("organiser_replacement", config.ORGANISER_REPLACEMENT_DEFAULT) or "attendee" 170 171 def have_manager(self): 172 return config.MANAGER_INTERFACE 173 174 def get_permitted_values(self): 175 176 """ 177 Decode a specification of one of the following forms... 178 179 <minute values> 180 <hour values>:<minute values> 181 <hour values>:<minute values>:<second values> 182 183 ...with each list of values being comma-separated. 184 """ 185 186 prefs = self.get_preferences() 187 permitted_values = prefs and prefs.get("permitted_times") 188 if permitted_values: 189 try: 190 l = [] 191 for component in permitted_values.split(":")[:3]: 192 if component: 193 l.append(map(int, component.split(","))) 194 else: 195 l.append(None) 196 197 # NOTE: Should probably report an error somehow. 198 199 except ValueError: 200 return None 201 else: 202 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or []) 203 return l 204 else: 205 return None 206 207 # Common operations on calendar data. 208 209 def update_senders(self, obj=None): 210 211 """ 212 Update sender details in 'obj', or the current object if not indicated, 213 removing SENT-BY attributes for attendees other than the current user if 214 those attributes give the URI of the calendar system. 215 """ 216 217 obj = obj or self.obj 218 calendar_uri = self.messenger and get_uri(self.messenger.sender) 219 for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")): 220 if attendee != self.user: 221 if attendee_attr.get("SENT-BY") == calendar_uri: 222 del attendee_attr["SENT-BY"] 223 else: 224 attendee_attr["SENT-BY"] = calendar_uri 225 226 def update_sender(self, attr): 227 228 "Update the SENT-BY attribute of the 'attr' sender metadata." 229 230 if self.messenger and self.messenger.sender != get_address(self.user): 231 attr["SENT-BY"] = get_uri(self.messenger.sender) 232 233 def get_sending_attendee(self): 234 235 "Return the attendee who sent the current object." 236 237 # Search for the sender of the message or the calendar system address. 238 239 senders = self.senders or self.messenger and [self.messenger.sender] or [] 240 241 for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): 242 if get_address(attendee) in senders or \ 243 get_address(attendee_attr.get("SENT-BY")) in senders: 244 return get_uri(attendee) 245 246 return None 247 248 def get_periods(self, obj, explicit_only=False): 249 250 """ 251 Return periods for the given 'obj'. Interpretation of periods can depend 252 on the time zone, which is obtained for the current user. If 253 'explicit_only' is set to a true value, only explicit periods will be 254 returned, not rule-based periods. 255 """ 256 257 return obj.get_periods(self.get_tzid(), not explicit_only and self.get_window_end() or None) 258 259 # Store operations. 260 261 def get_stored_object(self, uid, recurrenceid, section=None, username=None): 262 263 """ 264 Return the stored object for the current user, with the given 'uid' and 265 'recurrenceid' from the given 'section' and for the given 'username' (if 266 specified), or from the standard object collection otherwise. 267 """ 268 269 if section == "counters": 270 fragment = self.store.get_counter(self.user, username, uid, recurrenceid) 271 else: 272 fragment = self.store.get_event(self.user, uid, recurrenceid, section) 273 return fragment and Object(fragment) 274 275 # Free/busy operations. 276 277 def get_freebusy_part(self, freebusy=None): 278 279 """ 280 Return a message part containing free/busy information for the user, 281 either specified as 'freebusy' or obtained from the store directly. 282 """ 283 284 if self.is_sharing() and self.is_bundling(): 285 286 # Invent a unique identifier. 287 288 utcnow = get_timestamp() 289 uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user)) 290 291 freebusy = freebusy or self.store.get_freebusy(self.user) 292 293 user_attr = {} 294 self.update_sender(user_attr) 295 return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)]) 296 297 return None 298 299 def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 300 301 """ 302 Update the 'freebusy' collection with the given 'periods', indicating a 303 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a 304 recurrence or the parent event. The 'summary' and 'organiser' must also 305 be provided. 306 307 An optional 'expires' datetime string can be provided to tag a free/busy 308 offer. 309 """ 310 311 update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires) 312 313 # Preparation of messages communicating the state of events. 314 315 def get_message_parts(self, obj, method, attendee=None): 316 317 """ 318 Return a tuple containing a list of methods and a list of message parts, 319 with the parts collectively describing the given object 'obj' and its 320 recurrences, using 'method' as the means of publishing details (with 321 CANCEL being used to retract or remove details). 322 323 If 'attendee' is indicated, the attendee's participation will be taken 324 into account when generating the description. 325 """ 326 327 # Assume that the outcome will be composed of requests and 328 # cancellations. It would not seem completely bizarre to produce 329 # publishing messages if a refresh message was unprovoked. 330 331 responses = [] 332 methods = set() 333 334 # Get the parent event, add SENT-BY details to the organiser. 335 336 if not attendee or self.is_participating(attendee, obj=obj): 337 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 338 self.update_sender(organiser_attr) 339 responses.append(obj.to_part(method)) 340 methods.add(method) 341 342 # Get recurrences for parent events. 343 344 if not self.recurrenceid: 345 346 # Collect active and cancelled recurrences. 347 348 for rl, section, rmethod in [ 349 (self.store.get_active_recurrences(self.user, self.uid), None, method), 350 (self.store.get_cancelled_recurrences(self.user, self.uid), "cancellations", "CANCEL"), 351 ]: 352 353 for recurrenceid in rl: 354 355 # Get the recurrence, add SENT-BY details to the organiser. 356 357 obj = self.get_stored_object(self.uid, recurrenceid, section) 358 359 if not attendee or self.is_participating(attendee, obj=obj): 360 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 361 self.update_sender(organiser_attr) 362 responses.append(obj.to_part(rmethod)) 363 methods.add(rmethod) 364 365 return methods, responses 366 367 def get_unscheduled_parts(self, periods): 368 369 "Return message parts describing unscheduled 'periods'." 370 371 unscheduled_parts = [] 372 373 if periods: 374 obj = self.obj.copy() 375 obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"]) 376 377 for p in periods: 378 if not p.origin: 379 continue 380 obj["RECURRENCE-ID"] = obj["DTSTART"] = [(format_datetime(p.get_start()), p.get_start_attr())] 381 obj["DTEND"] = [(format_datetime(p.get_end()), p.get_end_attr())] 382 unscheduled_parts.append(obj.to_part("CANCEL")) 383 384 return unscheduled_parts 385 386 class ClientForObject(Client): 387 388 "A client maintaining a specific object." 389 390 def __init__(self, obj, user, messenger=None, store=None, publisher=None, preferences_dir=None): 391 Client.__init__(self, user, messenger, store, publisher, preferences_dir) 392 self.set_object(obj) 393 394 def set_object(self, obj): 395 396 "Set the current object to 'obj', obtaining metadata details." 397 398 self.obj = obj 399 self.uid = obj and self.obj.get_uid() 400 self.recurrenceid = obj and self.obj.get_recurrenceid() 401 self.sequence = obj and self.obj.get_value("SEQUENCE") 402 self.dtstamp = obj and self.obj.get_value("DTSTAMP") 403 404 def set_identity(self, method): 405 406 """ 407 Set the current user for the current object in the context of the given 408 'method'. It is usually set when initialising the handler, using the 409 recipient details, but outgoing messages do not reference the recipient 410 in this way. 411 """ 412 413 pass 414 415 def is_usable(self, method=None): 416 417 "Return whether the current object is usable with the given 'method'." 418 419 return True 420 421 def is_organiser(self): 422 423 """ 424 Return whether the current user is the organiser in the current object. 425 """ 426 427 return get_uri(self.obj.get_value("ORGANIZER")) == self.user 428 429 # Object update methods. 430 431 def update_recurrenceid(self): 432 433 """ 434 Update the RECURRENCE-ID in the current object, initialising it from 435 DTSTART. 436 """ 437 438 self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")] 439 self.recurrenceid = self.obj.get_recurrenceid() 440 441 def update_dtstamp(self, obj=None): 442 443 "Update the DTSTAMP in the current object or any given object 'obj'." 444 445 obj = obj or self.obj 446 self.dtstamp = obj.update_dtstamp() 447 448 def update_sequence(self, increment=False, obj=None): 449 450 "Update the SEQUENCE in the current object or any given object 'obj'." 451 452 obj = obj or self.obj 453 obj.update_sequence(increment) 454 455 def merge_attendance(self, attendees): 456 457 """ 458 Merge attendance from the current object's 'attendees' into the version 459 stored for the current user. 460 """ 461 462 obj = self.get_stored_object_version() 463 464 if not obj or not self.have_new_object(): 465 return False 466 467 # Get attendee details in a usable form. 468 469 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 470 471 for attendee, attendee_attr in attendees.items(): 472 473 # Update attendance in the loaded object for any recognised 474 # attendees. 475 476 if attendee_map.has_key(attendee): 477 attendee_map[attendee] = attendee_attr 478 479 # Set the new details and store the object. 480 481 obj["ATTENDEE"] = attendee_map.items() 482 483 # Set a specific recurrence or the complete event if not an additional 484 # occurrence. 485 486 return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 487 488 def update_attendees(self, attendees, removed): 489 490 """ 491 Update the attendees in the current object with the given 'attendees' 492 and 'removed' attendee lists. 493 494 A tuple is returned containing two items: a list of the attendees whose 495 attendance is being proposed (in a counter-proposal), a list of the 496 attendees whose attendance should be cancelled. 497 """ 498 499 to_cancel = [] 500 501 existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) 502 existing_attendees_map = dict(existing_attendees) 503 504 # Added attendees are those from the supplied collection not already 505 # present in the object. 506 507 added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) 508 removed = uri_values(removed) 509 510 if added or removed: 511 512 # The organiser can remove existing attendees. 513 514 if removed and self.is_organiser(): 515 remaining = [] 516 517 for attendee, attendee_attr in existing_attendees: 518 if attendee in removed: 519 520 # Only when an event has not been published can 521 # attendees be silently removed. 522 523 if self.obj.is_shared(): 524 to_cancel.append((attendee, attendee_attr)) 525 else: 526 remaining.append((attendee, attendee_attr)) 527 528 existing_attendees = remaining 529 530 # Attendees (when countering) must only include the current user and 531 # any added attendees. 532 533 elif not self.is_organiser(): 534 existing_attendees = [] 535 536 # Both organisers and attendees (when countering) can add attendees. 537 538 if added: 539 540 # Obtain a mapping from URIs to name details. 541 542 attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) 543 544 for attendee in added: 545 attendee = attendee.strip() 546 if attendee: 547 cn = attendee_map.get(attendee) 548 attendee_attr = {"CN" : cn} or {} 549 550 # Only the organiser can reset the participation attributes. 551 552 if self.is_organiser(): 553 attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) 554 555 existing_attendees.append((attendee, attendee_attr)) 556 557 # Attendees (when countering) must only include the current user and 558 # any added attendees. 559 560 if not self.is_organiser() and self.user not in existing_attendees: 561 user_attr = self.get_user_attributes() 562 user_attr.update(existing_attendees_map.get(self.user) or {}) 563 existing_attendees.append((self.user, user_attr)) 564 565 self.obj["ATTENDEE"] = existing_attendees 566 567 return added, to_cancel 568 569 def update_participation(self, partstat=None): 570 571 """ 572 Update the participation in the current object of the user with the 573 given 'partstat'. 574 """ 575 576 attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user) 577 if not attendee_attr: 578 return None 579 if partstat: 580 attendee_attr["PARTSTAT"] = partstat 581 if attendee_attr.has_key("RSVP"): 582 del attendee_attr["RSVP"] 583 self.update_sender(attendee_attr) 584 return attendee_attr 585 586 # Communication methods. 587 588 def send_message(self, parts, sender, obj, from_organiser, bcc_sender): 589 590 """ 591 Send the given 'parts' to the appropriate recipients, also sending a 592 copy to the 'sender'. The 'obj' together with the 'from_organiser' value 593 (which indicates whether the organiser is sending this message) are used 594 to determine the recipients of the message. 595 """ 596 597 # As organiser, send an invitation to attendees, excluding oneself if 598 # also attending. The updated event will be saved by the outgoing 599 # handler. 600 601 organiser = get_uri(obj.get_value("ORGANIZER")) 602 attendees = uri_values(obj.get_values("ATTENDEE")) 603 604 if from_organiser: 605 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 606 else: 607 recipients = [get_address(organiser)] 608 609 # Since the outgoing handler updates this user's free/busy details, 610 # the stored details will probably not have the updated details at 611 # this point, so we update our copy for serialisation as the bundled 612 # free/busy object. 613 614 freebusy = self.store.get_freebusy(self.user) 615 self.update_freebusy(freebusy, self.user, from_organiser) 616 617 # Bundle free/busy information if appropriate. 618 619 part = self.get_freebusy_part(freebusy) 620 if part: 621 parts.append(part) 622 623 if recipients or bcc_sender: 624 self._send_message(sender, recipients, parts, bcc_sender) 625 626 def _send_message(self, sender, recipients, parts, bcc_sender): 627 628 """ 629 Send a message, explicitly specifying the 'sender' as an outgoing BCC 630 recipient since the generic calendar user will be the actual sender. 631 """ 632 633 if not self.messenger: 634 return 635 636 if not bcc_sender: 637 message = self.messenger.make_outgoing_message(parts, recipients) 638 self.messenger.sendmail(recipients, message.as_string()) 639 else: 640 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 641 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 642 643 def send_message_to_self(self, parts): 644 645 "Send a message composed of the given 'parts' to the given user." 646 647 if not self.messenger: 648 return 649 650 sender = get_address(self.user) 651 message = self.messenger.make_outgoing_message(parts, [sender]) 652 self.messenger.sendmail([sender], message.as_string()) 653 654 # Action methods. 655 656 def process_declined_counter(self, attendee): 657 658 "Process a declined counter-proposal." 659 660 # Obtain the counter-proposal for the attendee. 661 662 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) 663 if not obj: 664 return False 665 666 method = "DECLINECOUNTER" 667 self.update_senders(obj=obj) 668 obj.update_dtstamp() 669 obj.update_sequence(False) 670 self._send_message(get_address(self.user), [get_address(attendee)], [obj.to_part(method)], True) 671 return True 672 673 def process_received_request(self, changed=False): 674 675 """ 676 Process the current request for the current user. Return whether any 677 action was taken. If 'changed' is set to a true value, or if 'attendees' 678 is specified and differs from the stored attendees, a counter-proposal 679 will be sent instead of a reply. 680 """ 681 682 # Reply only on behalf of this user. 683 684 attendee_attr = self.update_participation() 685 686 if not attendee_attr: 687 return False 688 689 if not changed: 690 self.obj["ATTENDEE"] = [(self.user, attendee_attr)] 691 else: 692 self.update_senders() 693 694 self.update_dtstamp() 695 self.update_sequence(False) 696 self.send_message([self.obj.to_part(changed and "COUNTER" or "REPLY")], get_address(self.user), self.obj, False, True) 697 return True 698 699 def process_created_request(self, method, to_cancel=None, to_unschedule=None): 700 701 """ 702 Process the current request, sending a created request of the given 703 'method' to attendees. Return whether any action was taken. 704 705 If 'to_cancel' is specified, a list of participants to be sent cancel 706 messages is provided. 707 708 If 'to_unschedule' is specified, a list of periods to be unscheduled is 709 provided. 710 """ 711 712 # Here, the organiser should be the current user. 713 714 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 715 716 self.update_sender(organiser_attr) 717 self.update_senders() 718 self.update_dtstamp() 719 self.update_sequence(True) 720 721 if method == "REQUEST": 722 methods, parts = self.get_message_parts(self.obj, "REQUEST") 723 724 # Add message parts with cancelled occurrence information. 725 726 unscheduled_parts = self.get_unscheduled_parts(to_unschedule) 727 728 # Send the updated event, along with a cancellation for each of the 729 # unscheduled occurrences. 730 731 self.send_message(parts + unscheduled_parts, get_address(organiser), self.obj, True, False) 732 733 # Since the organiser can update the SEQUENCE but this can leave any 734 # mail/calendar client lagging, issue a PUBLISH message to the 735 # user's address. 736 737 methods, parts = self.get_message_parts(self.obj, "PUBLISH") 738 self.send_message_to_self(parts + unscheduled_parts) 739 740 # When cancelling, replace the attendees with those for whom the event 741 # is now cancelled. 742 743 if method == "CANCEL" or to_cancel: 744 if to_cancel: 745 obj = self.obj.copy() 746 obj["ATTENDEE"] = to_cancel 747 else: 748 obj = self.obj 749 750 # Send a cancellation to all uninvited attendees. 751 752 parts = [obj.to_part("CANCEL")] 753 self.send_message(parts, get_address(organiser), obj, True, False) 754 755 # Issue a CANCEL message to the user's address. 756 757 if method == "CANCEL": 758 self.send_message_to_self(parts) 759 760 return True 761 762 # Object-related tests. 763 764 def is_recognised_organiser(self, organiser): 765 766 """ 767 Return whether the given 'organiser' is recognised from 768 previously-received details. If no stored details exist, True is 769 returned. 770 """ 771 772 obj = self.get_stored_object_version() 773 if obj: 774 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 775 return stored_organiser == organiser 776 else: 777 return True 778 779 def is_recognised_attendee(self, attendee): 780 781 """ 782 Return whether the given 'attendee' is recognised from 783 previously-received details. If no stored details exist, True is 784 returned. 785 """ 786 787 obj = self.get_stored_object_version() 788 if obj: 789 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 790 return stored_attendees.has_key(attendee) 791 else: 792 return True 793 794 def get_attendance(self, user=None, obj=None): 795 796 """ 797 Return the attendance attributes for 'user', or the current user if 798 'user' is not specified. 799 """ 800 801 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 802 return attendees.get(user or self.user) 803 804 def is_participating(self, user, as_organiser=False, obj=None): 805 806 """ 807 Return whether, subject to the 'user' indicating an identity and the 808 'as_organiser' status of that identity, the user concerned is actually 809 participating in the current object event. 810 """ 811 812 # Use any attendee property information for an organiser, not the 813 # organiser property attributes. 814 815 attr = self.get_attendance(user, obj) 816 return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") not in ("DECLINED", "NEEDS-ACTION") 817 818 def has_indicated_attendance(self, user=None, obj=None): 819 820 """ 821 Return whether the given 'user' (or the current user if not specified) 822 has indicated attendance in the given 'obj' (or the current object if 823 not specified). 824 """ 825 826 attr = self.get_attendance(user, obj) 827 return attr and attr.get("PARTSTAT") not in (None, "NEEDS-ACTION") 828 829 def get_overriding_transparency(self, user, as_organiser=False): 830 831 """ 832 Return the overriding transparency to be associated with the free/busy 833 records for an event, subject to the 'user' indicating an identity and 834 the 'as_organiser' status of that identity. 835 836 Where an identity is only an organiser and not attending, "ORG" is 837 returned. Otherwise, no overriding transparency is defined and None is 838 returned. 839 """ 840 841 attr = self.get_attendance(user) 842 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 843 844 def can_schedule(self, freebusy, periods): 845 846 """ 847 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 848 """ 849 850 return can_schedule(freebusy, periods, self.uid, self.recurrenceid) 851 852 def have_new_object(self, strict=True): 853 854 """ 855 Return whether the current object is new to the current user. 856 857 If 'strict' is specified and is a false value, the DTSTAMP test will be 858 ignored. This is useful in handling responses from attendees from 859 clients (like Claws Mail) that erase time information from DTSTAMP and 860 make it invalid. 861 """ 862 863 obj = self.get_stored_object_version() 864 865 # If found, compare SEQUENCE and potentially DTSTAMP. 866 867 if obj: 868 sequence = obj.get_value("SEQUENCE") 869 dtstamp = obj.get_value("DTSTAMP") 870 871 # If the request refers to an older version of the object, ignore 872 # it. 873 874 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 875 876 return True 877 878 def possibly_recurring_indefinitely(self): 879 880 "Return whether the object recurs indefinitely." 881 882 # Obtain the stored object to make sure that recurrence information 883 # is not being ignored. This might happen if a client sends a 884 # cancellation without the complete set of properties, for instance. 885 886 return self.obj.possibly_recurring_indefinitely() or \ 887 self.get_stored_object_version() and \ 888 self.get_stored_object_version().possibly_recurring_indefinitely() 889 890 # Constraint application on event periods. 891 892 def check_object(self): 893 894 "Check the object against any scheduling constraints." 895 896 permitted_values = self.get_permitted_values() 897 if not permitted_values: 898 return None 899 900 invalid = [] 901 902 for period in self.obj.get_periods(self.get_tzid()): 903 errors = period.check_permitted(permitted_values) 904 if errors: 905 start_errors, end_errors = errors 906 invalid.append((period.origin, start_errors, end_errors)) 907 908 return invalid 909 910 def correct_object(self): 911 912 "Correct the object according to any scheduling constraints." 913 914 permitted_values = self.get_permitted_values() 915 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 916 917 def correct_period(self, period): 918 919 "Correct 'period' according to any scheduling constraints." 920 921 permitted_values = self.get_permitted_values() 922 if not permitted_values: 923 return period 924 else: 925 return period.get_corrected(permitted_values) 926 927 # Object retrieval. 928 929 def get_stored_object_version(self): 930 931 """ 932 Return the stored object to which the current object refers for the 933 current user. 934 """ 935 936 return self.get_stored_object(self.uid, self.recurrenceid) 937 938 def get_definitive_object(self, as_organiser): 939 940 """ 941 Return an object considered definitive for the current transaction, 942 using 'as_organiser' to select the current transaction's object if 943 false, or selecting a stored object if true. 944 """ 945 946 return not as_organiser and self.obj or self.get_stored_object_version() 947 948 def get_parent_object(self): 949 950 """ 951 Return the parent object to which the current object refers for the 952 current user. 953 """ 954 955 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 956 957 def revert_cancellations(self, periods): 958 959 """ 960 Restore cancelled recurrences corresponding to any of the given 961 'periods'. 962 """ 963 964 for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid): 965 obj = self.get_stored_object(self.uid, recurrenceid, "cancellations") 966 if set(self.get_periods(obj)).intersection(periods): 967 self.store.remove_cancellation(self.user, self.uid, recurrenceid) 968 969 # Convenience methods for modifying free/busy collections. 970 971 def get_recurrence_start_point(self, recurrenceid): 972 973 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 974 975 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 976 977 def remove_from_freebusy(self, freebusy): 978 979 "Remove this event from the given 'freebusy' collection." 980 981 if not remove_event_periods(freebusy, self.uid, self.recurrenceid) and self.recurrenceid: 982 remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid)) 983 984 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 985 986 """ 987 Remove from 'freebusy' any original recurrence from parent free/busy 988 details for the current object, if the current object is a specific 989 additional recurrence. Otherwise, remove all additional recurrence 990 information corresponding to 'recurrenceids', or if omitted, all 991 recurrences. 992 """ 993 994 if self.recurrenceid: 995 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 996 remove_affected_period(freebusy, self.uid, recurrenceid) 997 else: 998 # Remove obsolete recurrence periods. 999 1000 remove_additional_periods(freebusy, self.uid, recurrenceids) 1001 1002 # Remove original periods affected by additional recurrences. 1003 1004 if recurrenceids: 1005 for recurrenceid in recurrenceids: 1006 recurrenceid = self.get_recurrence_start_point(recurrenceid) 1007 remove_affected_period(freebusy, self.uid, recurrenceid) 1008 1009 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 1010 1011 """ 1012 Update the 'freebusy' collection for this event with the periods and 1013 transparency associated with the current object, subject to the 'user' 1014 identity and the attendance details provided for them, indicating 1015 whether the update is being done 'as_organiser' (for the organiser of 1016 an event) or not. 1017 1018 If 'offer' is set to a true value, any free/busy updates will be tagged 1019 with an expiry time. 1020 """ 1021 1022 # Obtain the stored object if the current object is not issued by the 1023 # organiser. Attendees do not have the opportunity to redefine the 1024 # periods. 1025 1026 obj = self.get_definitive_object(as_organiser) 1027 if not obj: 1028 return 1029 1030 # Obtain the affected periods. 1031 1032 periods = self.get_periods(obj) 1033 1034 # Define an overriding transparency, the indicated event transparency, 1035 # or the default transparency for the free/busy entry. 1036 1037 transp = self.get_overriding_transparency(user, as_organiser) or \ 1038 obj.get_value("TRANSP") or \ 1039 "OPAQUE" 1040 1041 # Calculate any expiry time. If no offer period is defined, do not 1042 # record the offer periods. 1043 1044 if offer: 1045 offer_period = self.get_offer_period() 1046 if offer_period: 1047 expires = get_timestamp(offer_period) 1048 else: 1049 return 1050 else: 1051 expires = None 1052 1053 # Perform the low-level update. 1054 1055 Client.update_freebusy(self, freebusy, periods, transp, 1056 self.uid, self.recurrenceid, 1057 obj.get_value("SUMMARY"), 1058 get_uri(obj.get_value("ORGANIZER")), 1059 expires) 1060 1061 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1062 updating_other=False, offer=False): 1063 1064 """ 1065 Update the 'freebusy' collection for the given 'user', indicating 1066 whether the update is 'for_organiser' (being done for the organiser of 1067 an event) or not, and whether it is 'updating_other' (meaning another 1068 user's details). 1069 1070 If 'offer' is set to a true value, any free/busy updates will be tagged 1071 with an expiry time. 1072 """ 1073 1074 # Record in the free/busy details unless a non-participating attendee. 1075 # Remove periods for non-participating attendees. 1076 1077 if offer or self.is_participating(user, for_organiser and not updating_other): 1078 self.update_freebusy(freebusy, user, 1079 for_organiser and not updating_other or 1080 not for_organiser and updating_other, 1081 offer 1082 ) 1083 else: 1084 self.remove_from_freebusy(freebusy) 1085 1086 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1087 updating_other=False): 1088 1089 """ 1090 Remove details from the 'freebusy' collection for the given 'user', 1091 indicating whether the modification is 'for_organiser' (being done for 1092 the organiser of an event) or not, and whether it is 'updating_other' 1093 (meaning another user's details). 1094 """ 1095 1096 # Remove from the free/busy details if a specified attendee. 1097 1098 if self.is_participating(user, for_organiser and not updating_other): 1099 self.remove_from_freebusy(freebusy) 1100 1101 # Convenience methods for updating stored free/busy information received 1102 # from other users. 1103 1104 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 1105 1106 """ 1107 For the current user, record the free/busy information for another 1108 'user', indicating whether the update is 'for_organiser' or not, thus 1109 maintaining a separate record of their free/busy details. 1110 """ 1111 1112 fn = fn or self.update_freebusy_for_participant 1113 1114 # A user does not store free/busy information for themself as another 1115 # party. 1116 1117 if user == self.user: 1118 return 1119 1120 self.acquire_lock() 1121 try: 1122 freebusy = self.store.get_freebusy_for_other(self.user, user) 1123 fn(freebusy, user, for_organiser, True) 1124 1125 # Tidy up any obsolete recurrences. 1126 1127 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1128 self.store.set_freebusy_for_other(self.user, freebusy, user) 1129 1130 finally: 1131 self.release_lock() 1132 1133 def update_freebusy_from_organiser(self, organiser): 1134 1135 "For the current user, record free/busy information from 'organiser'." 1136 1137 self.update_freebusy_from_participant(organiser, True) 1138 1139 def update_freebusy_from_attendees(self, attendees): 1140 1141 "For the current user, record free/busy information from 'attendees'." 1142 1143 obj = self.get_stored_object_version() 1144 1145 if not obj or not self.have_new_object(): 1146 return 1147 1148 # Filter out unrecognised attendees. 1149 1150 attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE"))) 1151 1152 for attendee in attendees: 1153 self.update_freebusy_from_participant(attendee, False) 1154 1155 def remove_freebusy_from_organiser(self, organiser): 1156 1157 "For the current user, remove free/busy information from 'organiser'." 1158 1159 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 1160 1161 def remove_freebusy_from_attendees(self, attendees): 1162 1163 "For the current user, remove free/busy information from 'attendees'." 1164 1165 for attendee in attendees.keys(): 1166 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 1167 1168 # Convenience methods for updating free/busy details at the event level. 1169 1170 def update_event_in_freebusy(self, for_organiser=True): 1171 1172 """ 1173 Update free/busy information when handling an object, doing so for the 1174 organiser of an event if 'for_organiser' is set to a true value. 1175 """ 1176 1177 freebusy = self.store.get_freebusy(self.user) 1178 1179 # Obtain the attendance attributes for this user, if available. 1180 1181 self.update_freebusy_for_participant(freebusy, self.user, for_organiser) 1182 1183 # Remove original recurrence details replaced by additional 1184 # recurrences, as well as obsolete additional recurrences. 1185 1186 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1187 self.store.set_freebusy(self.user, freebusy) 1188 1189 if self.publisher and self.is_sharing() and self.is_publishing(): 1190 self.publisher.set_freebusy(self.user, freebusy) 1191 1192 # Update free/busy provider information if the event may recur 1193 # indefinitely. 1194 1195 if self.possibly_recurring_indefinitely(): 1196 self.store.append_freebusy_provider(self.user, self.obj) 1197 1198 return True 1199 1200 def remove_event_from_freebusy(self): 1201 1202 "Remove free/busy information when handling an object." 1203 1204 freebusy = self.store.get_freebusy(self.user) 1205 1206 self.remove_from_freebusy(freebusy) 1207 self.remove_freebusy_for_recurrences(freebusy) 1208 self.store.set_freebusy(self.user, freebusy) 1209 1210 if self.publisher and self.is_sharing() and self.is_publishing(): 1211 self.publisher.set_freebusy(self.user, freebusy) 1212 1213 # Update free/busy provider information if the event may recur 1214 # indefinitely. 1215 1216 if self.possibly_recurring_indefinitely(): 1217 self.store.remove_freebusy_provider(self.user, self.obj) 1218 1219 def update_event_in_freebusy_offers(self): 1220 1221 "Update free/busy offers when handling an object." 1222 1223 freebusy = self.store.get_freebusy_offers(self.user) 1224 1225 # Obtain the attendance attributes for this user, if available. 1226 1227 self.update_freebusy_for_participant(freebusy, self.user, offer=True) 1228 1229 # Remove original recurrence details replaced by additional 1230 # recurrences, as well as obsolete additional recurrences. 1231 1232 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1233 self.store.set_freebusy_offers(self.user, freebusy) 1234 1235 return True 1236 1237 def remove_event_from_freebusy_offers(self): 1238 1239 "Remove free/busy offers when handling an object." 1240 1241 freebusy = self.store.get_freebusy_offers(self.user) 1242 1243 self.remove_from_freebusy(freebusy) 1244 self.remove_freebusy_for_recurrences(freebusy) 1245 self.store.set_freebusy_offers(self.user, freebusy) 1246 1247 return True 1248 1249 # Convenience methods for removing counter-proposals and updating the 1250 # request queue. 1251 1252 def remove_request(self): 1253 return self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1254 1255 def remove_event(self): 1256 return self.store.remove_event(self.user, self.uid, self.recurrenceid) 1257 1258 def remove_counter(self, attendee): 1259 self.remove_counters([attendee]) 1260 1261 def remove_counters(self, attendees): 1262 for attendee in attendees: 1263 self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid) 1264 1265 if not self.store.get_counters(self.user, self.uid, self.recurrenceid): 1266 self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1267 1268 # vim: tabstop=4 expandtab shiftwidth=4