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