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, uri_dict, uri_item, uri_items, \ 28 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 attendee_map = 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 attendee_map.has_key(attendee): 558 attendee_map[attendee] = attendee_attr 559 560 # Check for delegated attendees. 561 562 for attendee, attendee_attr in attendees.items(): 563 564 # Identify delegates and check the delegation using the updated 565 # attendee information. 566 567 if not attendee_map.has_key(attendee) and \ 568 attendee_attr.has_key("DELEGATED-FROM") and \ 569 check_delegation(attendee_map, attendee, attendee_attr): 570 571 attendee_map[attendee] = attendee_attr 572 573 # Set the new details and store the object. 574 575 obj["ATTENDEE"] = attendee_map.items() 576 577 # Set a specific recurrence or the complete event if not an additional 578 # occurrence. 579 580 return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 581 582 def update_attendees(self, attendees, removed): 583 584 """ 585 Update the attendees in the current object with the given 'attendees' 586 and 'removed' attendee lists. 587 588 A tuple is returned containing two items: a list of the attendees whose 589 attendance is being proposed (in a counter-proposal), a list of the 590 attendees whose attendance should be cancelled. 591 """ 592 593 to_cancel = [] 594 595 existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) 596 existing_attendees_map = dict(existing_attendees) 597 598 # Added attendees are those from the supplied collection not already 599 # present in the object. 600 601 added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) 602 removed = uri_values(removed) 603 604 if added or removed: 605 606 # The organiser can remove existing attendees. 607 608 if removed and self.is_organiser(): 609 remaining = [] 610 611 for attendee, attendee_attr in existing_attendees: 612 if attendee in removed: 613 614 # Only when an event has not been published can 615 # attendees be silently removed. 616 617 if self.obj.is_shared(): 618 to_cancel.append((attendee, attendee_attr)) 619 else: 620 remaining.append((attendee, attendee_attr)) 621 622 existing_attendees = remaining 623 624 # Attendees (when countering) must only include the current user and 625 # any added attendees. 626 627 elif not self.is_organiser(): 628 existing_attendees = [] 629 630 # Both organisers and attendees (when countering) can add attendees. 631 632 if added: 633 634 # Obtain a mapping from URIs to name details. 635 636 attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) 637 638 for attendee in added: 639 attendee = attendee.strip() 640 if attendee: 641 cn = attendee_map.get(attendee) 642 attendee_attr = {"CN" : cn} or {} 643 644 # Only the organiser can reset the participation attributes. 645 646 if self.is_organiser(): 647 attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) 648 649 existing_attendees.append((attendee, attendee_attr)) 650 651 # Attendees (when countering) must only include the current user and 652 # any added attendees. 653 654 if not self.is_organiser() and self.user not in existing_attendees: 655 user_attr = self.get_user_attributes() 656 user_attr.update(existing_attendees_map.get(self.user) or {}) 657 existing_attendees.append((self.user, user_attr)) 658 659 self.obj["ATTENDEE"] = existing_attendees 660 661 return added, to_cancel 662 663 def update_participation(self, partstat=None): 664 665 """ 666 Update the participation in the current object of the user with the 667 given 'partstat'. 668 """ 669 670 attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user) 671 if not attendee_attr: 672 return None 673 if partstat: 674 attendee_attr["PARTSTAT"] = partstat 675 if attendee_attr.has_key("RSVP"): 676 del attendee_attr["RSVP"] 677 self.update_sender(attendee_attr) 678 return attendee_attr 679 680 # Communication methods. 681 682 def send_message(self, parts, sender, obj, from_organiser, bcc_sender): 683 684 """ 685 Send the given 'parts' to the appropriate recipients, also sending a 686 copy to the 'sender'. The 'obj' together with the 'from_organiser' value 687 (which indicates whether the organiser is sending this message) are used 688 to determine the recipients of the message. 689 """ 690 691 # As organiser, send an invitation to attendees, excluding oneself if 692 # also attending. The updated event will be saved by the outgoing 693 # handler. 694 695 organiser = get_uri(obj.get_value("ORGANIZER")) 696 attendees = uri_values(obj.get_values("ATTENDEE")) 697 698 if from_organiser: 699 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 700 else: 701 recipients = [get_address(organiser)] 702 703 # Since the outgoing handler updates this user's free/busy details, 704 # the stored details will probably not have the updated details at 705 # this point, so we update our copy for serialisation as the bundled 706 # free/busy object. 707 708 freebusy = self.store.get_freebusy(self.user).copy() 709 self.update_freebusy(freebusy, self.user, from_organiser) 710 711 # Bundle free/busy information if appropriate. 712 713 part = self.get_freebusy_part(freebusy) 714 if part: 715 parts.append(part) 716 717 if recipients or bcc_sender: 718 self._send_message(sender, recipients, parts, bcc_sender) 719 720 def _send_message(self, sender, recipients, parts, bcc_sender): 721 722 """ 723 Send a message, explicitly specifying the 'sender' as an outgoing BCC 724 recipient since the generic calendar user will be the actual sender. 725 """ 726 727 if not self.messenger: 728 return 729 730 if not bcc_sender: 731 message = self.messenger.make_outgoing_message(parts, recipients) 732 self.messenger.sendmail(recipients, message.as_string()) 733 else: 734 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 735 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 736 737 def send_message_to_self(self, parts): 738 739 "Send a message composed of the given 'parts' to the given user." 740 741 if not self.messenger: 742 return 743 744 sender = get_address(self.user) 745 message = self.messenger.make_outgoing_message(parts, [sender]) 746 self.messenger.sendmail([sender], message.as_string()) 747 748 # Action methods. 749 750 def process_declined_counter(self, attendee): 751 752 "Process a declined counter-proposal." 753 754 # Obtain the counter-proposal for the attendee. 755 756 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) 757 if not obj: 758 return False 759 760 method = "DECLINECOUNTER" 761 self.update_senders(obj=obj) 762 obj.update_dtstamp() 763 obj.update_sequence(False) 764 self._send_message(get_address(self.user), [get_address(attendee)], [self.object_to_part(method, obj)], True) 765 return True 766 767 def process_received_request(self, changed=False): 768 769 """ 770 Process the current request for the current user. Return whether any 771 action was taken. If 'changed' is set to a true value, or if 'attendees' 772 is specified and differs from the stored attendees, a counter-proposal 773 will be sent instead of a reply. 774 """ 775 776 # Reply only on behalf of this user. 777 778 attendee_attr = self.update_participation() 779 780 if not attendee_attr: 781 return False 782 783 if not changed: 784 self.obj["ATTENDEE"] = [(self.user, attendee_attr)] 785 else: 786 self.update_senders() 787 788 self.update_dtstamp() 789 self.update_sequence(False) 790 self.send_message([self.object_to_part(changed and "COUNTER" or "REPLY", self.obj)], 791 get_address(self.user), self.obj, False, True) 792 return True 793 794 def process_created_request(self, method, to_cancel=None, to_unschedule=None): 795 796 """ 797 Process the current request, sending a created request of the given 798 'method' to attendees. Return whether any action was taken. 799 800 If 'to_cancel' is specified, a list of participants to be sent cancel 801 messages is provided. 802 803 If 'to_unschedule' is specified, a list of periods to be unscheduled is 804 provided. 805 """ 806 807 # Here, the organiser should be the current user. 808 809 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 810 811 self.update_sender(organiser_attr) 812 self.update_senders() 813 self.update_dtstamp() 814 self.update_sequence(True) 815 816 if method == "REQUEST": 817 methods, parts = self.get_message_parts(self.obj, "REQUEST") 818 819 # Add message parts with cancelled occurrence information. 820 821 unscheduled_parts = self.get_unscheduled_parts(to_unschedule) 822 823 # Send the updated event, along with a cancellation for each of the 824 # unscheduled occurrences. 825 826 self.send_message(parts + unscheduled_parts, get_address(organiser), self.obj, True, False) 827 828 # Since the organiser can update the SEQUENCE but this can leave any 829 # mail/calendar client lagging, issue a PUBLISH message to the 830 # user's address. 831 832 methods, parts = self.get_message_parts(self.obj, "PUBLISH") 833 self.send_message_to_self(parts + unscheduled_parts) 834 835 # When cancelling, replace the attendees with those for whom the event 836 # is now cancelled. 837 838 if method == "CANCEL" or to_cancel: 839 if to_cancel: 840 obj = self.obj.copy() 841 obj["ATTENDEE"] = to_cancel 842 else: 843 obj = self.obj 844 845 # Send a cancellation to all uninvited attendees. 846 847 parts = [self.object_to_part("CANCEL", obj)] 848 self.send_message(parts, get_address(organiser), obj, True, False) 849 850 # Issue a CANCEL message to the user's address. 851 852 if method == "CANCEL": 853 self.send_message_to_self(parts) 854 855 return True 856 857 # Object-related tests. 858 859 def is_recognised_organiser(self, organiser): 860 861 """ 862 Return whether the given 'organiser' is recognised from 863 previously-received details. If no stored details exist, True is 864 returned. 865 """ 866 867 obj = self.get_stored_object_version() 868 if obj: 869 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 870 return stored_organiser == organiser 871 else: 872 return True 873 874 def is_recognised_attendee(self, attendee): 875 876 """ 877 Return whether the given 'attendee' is recognised from 878 previously-received details. If no stored details exist, True is 879 returned. 880 """ 881 882 obj = self.get_stored_object_version() 883 if obj: 884 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 885 return stored_attendees.has_key(attendee) 886 else: 887 return True 888 889 def get_attendance(self, user=None, obj=None): 890 891 """ 892 Return the attendance attributes for 'user', or the current user if 893 'user' is not specified. 894 """ 895 896 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 897 return attendees.get(user or self.user) 898 899 def is_participating(self, user, as_organiser=False, obj=None): 900 901 """ 902 Return whether, subject to the 'user' indicating an identity and the 903 'as_organiser' status of that identity, the user concerned is actually 904 participating in the current object event. 905 """ 906 907 # Use any attendee property information for an organiser, not the 908 # organiser property attributes. 909 910 attr = self.get_attendance(user, obj) 911 return as_organiser or attr is not None and not attr or \ 912 attr and attr.get("PARTSTAT") not in ("DECLINED", "DELEGATED", "NEEDS-ACTION") 913 914 def has_indicated_attendance(self, user=None, obj=None): 915 916 """ 917 Return whether the given 'user' (or the current user if not specified) 918 has indicated attendance in the given 'obj' (or the current object if 919 not specified). 920 """ 921 922 attr = self.get_attendance(user, obj) 923 return attr and attr.get("PARTSTAT") not in (None, "NEEDS-ACTION") 924 925 def get_overriding_transparency(self, user, as_organiser=False): 926 927 """ 928 Return the overriding transparency to be associated with the free/busy 929 records for an event, subject to the 'user' indicating an identity and 930 the 'as_organiser' status of that identity. 931 932 Where an identity is only an organiser and not attending, "ORG" is 933 returned. Otherwise, no overriding transparency is defined and None is 934 returned. 935 """ 936 937 attr = self.get_attendance(user) 938 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 939 940 def can_schedule(self, freebusy, periods): 941 942 """ 943 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 944 """ 945 946 return freebusy.can_schedule(periods, self.uid, self.recurrenceid) 947 948 def have_new_object(self, strict=True): 949 950 """ 951 Return whether the current object is new to the current user. 952 953 If 'strict' is specified and is a false value, the DTSTAMP test will be 954 ignored. This is useful in handling responses from attendees from 955 clients (like Claws Mail) that erase time information from DTSTAMP and 956 make it invalid. 957 """ 958 959 obj = self.get_stored_object_version() 960 961 # If found, compare SEQUENCE and potentially DTSTAMP. 962 963 if obj: 964 sequence = obj.get_value("SEQUENCE") 965 dtstamp = obj.get_value("DTSTAMP") 966 967 # If the request refers to an older version of the object, ignore 968 # it. 969 970 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 971 972 return True 973 974 def possibly_recurring_indefinitely(self): 975 976 "Return whether the object recurs indefinitely." 977 978 # Obtain the stored object to make sure that recurrence information 979 # is not being ignored. This might happen if a client sends a 980 # cancellation without the complete set of properties, for instance. 981 982 return self.obj.possibly_recurring_indefinitely() or \ 983 self.get_stored_object_version() and \ 984 self.get_stored_object_version().possibly_recurring_indefinitely() 985 986 # Constraint application on event periods. 987 988 def check_object(self): 989 990 "Check the object against any scheduling constraints." 991 992 permitted_values = self.get_permitted_values() 993 if not permitted_values: 994 return None 995 996 invalid = [] 997 998 for period in self.obj.get_periods(self.get_tzid()): 999 errors = period.check_permitted(permitted_values) 1000 if errors: 1001 start_errors, end_errors = errors 1002 invalid.append((period.origin, start_errors, end_errors)) 1003 1004 return invalid 1005 1006 def correct_object(self): 1007 1008 "Correct the object according to any scheduling constraints." 1009 1010 permitted_values = self.get_permitted_values() 1011 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 1012 1013 def correct_period(self, period): 1014 1015 "Correct 'period' according to any scheduling constraints." 1016 1017 permitted_values = self.get_permitted_values() 1018 if not permitted_values: 1019 return period 1020 else: 1021 return period.get_corrected(permitted_values) 1022 1023 # Object retrieval. 1024 1025 def get_stored_object_version(self): 1026 1027 """ 1028 Return the stored object to which the current object refers for the 1029 current user. 1030 """ 1031 1032 return self.get_stored_object(self.uid, self.recurrenceid) 1033 1034 def get_definitive_object(self, as_organiser): 1035 1036 """ 1037 Return an object considered definitive for the current transaction, 1038 using 'as_organiser' to select the current transaction's object if 1039 false, or selecting a stored object if true. 1040 """ 1041 1042 return not as_organiser and self.obj or self.get_stored_object_version() 1043 1044 def get_parent_object(self): 1045 1046 """ 1047 Return the parent object to which the current object refers for the 1048 current user. 1049 """ 1050 1051 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 1052 1053 def revert_cancellations(self, periods): 1054 1055 """ 1056 Restore cancelled recurrences corresponding to any of the given 1057 'periods'. 1058 """ 1059 1060 for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid): 1061 obj = self.get_stored_object(self.uid, recurrenceid, "cancellations") 1062 if set(self.get_periods(obj)).intersection(periods): 1063 self.store.remove_cancellation(self.user, self.uid, recurrenceid) 1064 1065 # Convenience methods for modifying free/busy collections. 1066 1067 def get_recurrence_start_point(self, recurrenceid): 1068 1069 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 1070 1071 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 1072 1073 def remove_from_freebusy(self, freebusy, participant=None): 1074 1075 """ 1076 Remove this event from the given 'freebusy' collection. If 'participant' 1077 is specified, only remove this event if the participant is attending. 1078 """ 1079 1080 removed = freebusy.remove_event_periods(self.uid, self.recurrenceid, participant) 1081 if not removed and self.recurrenceid: 1082 return freebusy.remove_affected_period(self.uid, self.get_recurrence_start_point(self.recurrenceid), participant) 1083 else: 1084 return removed 1085 1086 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 1087 1088 """ 1089 Remove from 'freebusy' any original recurrence from parent free/busy 1090 details for the current object, if the current object is a specific 1091 additional recurrence. Otherwise, remove all additional recurrence 1092 information corresponding to 'recurrenceids', or if omitted, all 1093 recurrences. 1094 """ 1095 1096 if self.recurrenceid: 1097 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 1098 freebusy.remove_affected_period(self.uid, recurrenceid) 1099 else: 1100 # Remove obsolete recurrence periods. 1101 1102 freebusy.remove_additional_periods(self.uid, recurrenceids) 1103 1104 # Remove original periods affected by additional recurrences. 1105 1106 if recurrenceids: 1107 for recurrenceid in recurrenceids: 1108 recurrenceid = self.get_recurrence_start_point(recurrenceid) 1109 freebusy.remove_affected_period(self.uid, recurrenceid) 1110 1111 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 1112 1113 """ 1114 Update the 'freebusy' collection for this event with the periods and 1115 transparency associated with the current object, subject to the 'user' 1116 identity and the attendance details provided for them, indicating 1117 whether the update is being done 'as_organiser' (for the organiser of 1118 an event) or not. 1119 1120 If 'offer' is set to a true value, any free/busy updates will be tagged 1121 with an expiry time. 1122 """ 1123 1124 # Obtain the stored object if the current object is not issued by the 1125 # organiser. Attendees do not have the opportunity to redefine the 1126 # periods. 1127 1128 obj = self.get_definitive_object(as_organiser) 1129 if not obj: 1130 return 1131 1132 # Obtain the affected periods. 1133 1134 periods = self.get_periods(obj, future_only=True) 1135 1136 # Define an overriding transparency, the indicated event transparency, 1137 # or the default transparency for the free/busy entry. 1138 1139 transp = self.get_overriding_transparency(user, as_organiser) or \ 1140 obj.get_value("TRANSP") or \ 1141 "OPAQUE" 1142 1143 # Calculate any expiry time. If no offer period is defined, do not 1144 # record the offer periods. 1145 1146 if offer: 1147 offer_period = self.get_offer_period() 1148 if offer_period: 1149 expires = get_timestamp(offer_period) 1150 else: 1151 return 1152 else: 1153 expires = None 1154 1155 # Perform the low-level update. 1156 1157 Client.update_freebusy(self, freebusy, periods, transp, 1158 self.uid, self.recurrenceid, 1159 obj.get_value("SUMMARY"), 1160 get_uri(obj.get_value("ORGANIZER")), 1161 expires) 1162 1163 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1164 updating_other=False, offer=False): 1165 1166 """ 1167 Update the 'freebusy' collection for the given 'user', indicating 1168 whether the update is 'for_organiser' (being done for the organiser of 1169 an event) or not, and whether it is 'updating_other' (meaning another 1170 user's details). 1171 1172 If 'offer' is set to a true value, any free/busy updates will be tagged 1173 with an expiry time. 1174 """ 1175 1176 # Record in the free/busy details unless a non-participating attendee. 1177 # Remove periods for non-participating attendees. 1178 1179 if offer or self.is_participating(user, for_organiser and not updating_other): 1180 self.update_freebusy(freebusy, user, 1181 for_organiser and not updating_other or 1182 not for_organiser and updating_other, 1183 offer 1184 ) 1185 else: 1186 self.remove_from_freebusy(freebusy) 1187 1188 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1189 updating_other=False): 1190 1191 """ 1192 Remove details from the 'freebusy' collection for the given 'user', 1193 indicating whether the modification is 'for_organiser' (being done for 1194 the organiser of an event) or not, and whether it is 'updating_other' 1195 (meaning another user's details). 1196 """ 1197 1198 # Remove from the free/busy details if a specified attendee. 1199 1200 if self.is_participating(user, for_organiser and not updating_other): 1201 self.remove_from_freebusy(freebusy) 1202 1203 # Convenience methods for updating stored free/busy information received 1204 # from other users. 1205 1206 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 1207 1208 """ 1209 For the current user, record the free/busy information for another 1210 'user', indicating whether the update is 'for_organiser' or not, thus 1211 maintaining a separate record of their free/busy details. 1212 """ 1213 1214 fn = fn or self.update_freebusy_for_participant 1215 1216 # A user does not store free/busy information for themself as another 1217 # party. 1218 1219 if user == self.user: 1220 return 1221 1222 self.acquire_lock() 1223 try: 1224 freebusy = self.store.get_freebusy_for_other_for_update(self.user, user) 1225 fn(freebusy, user, for_organiser, True) 1226 1227 # Tidy up any obsolete recurrences. 1228 1229 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1230 self.store.set_freebusy_for_other(self.user, freebusy, user) 1231 1232 finally: 1233 self.release_lock() 1234 1235 def update_freebusy_from_organiser(self, organiser): 1236 1237 "For the current user, record free/busy information from 'organiser'." 1238 1239 self.update_freebusy_from_participant(organiser, True) 1240 1241 def update_freebusy_from_attendees(self, attendees): 1242 1243 "For the current user, record free/busy information from 'attendees'." 1244 1245 obj = self.get_stored_object_version() 1246 1247 if not obj or not self.have_new_object(): 1248 return False 1249 1250 # Filter out unrecognised attendees. 1251 1252 attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE"))) 1253 1254 for attendee in attendees: 1255 self.update_freebusy_from_participant(attendee, False) 1256 1257 return True 1258 1259 def remove_freebusy_from_organiser(self, organiser): 1260 1261 "For the current user, remove free/busy information from 'organiser'." 1262 1263 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 1264 1265 def remove_freebusy_from_attendees(self, attendees): 1266 1267 "For the current user, remove free/busy information from 'attendees'." 1268 1269 for attendee in attendees.keys(): 1270 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 1271 1272 # Convenience methods for updating free/busy details at the event level. 1273 1274 def update_event_in_freebusy(self, for_organiser=True): 1275 1276 """ 1277 Update free/busy information when handling an object, doing so for the 1278 organiser of an event if 'for_organiser' is set to a true value. 1279 """ 1280 1281 freebusy = self.store.get_freebusy_for_update(self.user) 1282 1283 # Obtain the attendance attributes for this user, if available. 1284 1285 self.update_freebusy_for_participant(freebusy, self.user, for_organiser) 1286 1287 # Remove original recurrence details replaced by additional 1288 # recurrences, as well as obsolete additional recurrences. 1289 1290 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1291 self.store.set_freebusy(self.user, freebusy) 1292 1293 if self.publisher and self.is_sharing() and self.is_publishing(): 1294 self.publisher.set_freebusy(self.user, freebusy) 1295 1296 # Update free/busy provider information if the event may recur 1297 # indefinitely. 1298 1299 if self.possibly_recurring_indefinitely(): 1300 self.store.append_freebusy_provider(self.user, self.obj) 1301 1302 return True 1303 1304 def remove_event_from_freebusy(self): 1305 1306 "Remove free/busy information when handling an object." 1307 1308 freebusy = self.store.get_freebusy_for_update(self.user) 1309 1310 self.remove_from_freebusy(freebusy) 1311 self.remove_freebusy_for_recurrences(freebusy) 1312 self.store.set_freebusy(self.user, freebusy) 1313 1314 if self.publisher and self.is_sharing() and self.is_publishing(): 1315 self.publisher.set_freebusy(self.user, freebusy) 1316 1317 # Update free/busy provider information if the event may recur 1318 # indefinitely. 1319 1320 if self.possibly_recurring_indefinitely(): 1321 self.store.remove_freebusy_provider(self.user, self.obj) 1322 1323 def update_event_in_freebusy_offers(self): 1324 1325 "Update free/busy offers when handling an object." 1326 1327 freebusy = self.store.get_freebusy_offers_for_update(self.user) 1328 1329 # Obtain the attendance attributes for this user, if available. 1330 1331 self.update_freebusy_for_participant(freebusy, self.user, offer=True) 1332 1333 # Remove original recurrence details replaced by additional 1334 # recurrences, as well as obsolete additional recurrences. 1335 1336 self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid)) 1337 self.store.set_freebusy_offers(self.user, freebusy) 1338 1339 return True 1340 1341 def remove_event_from_freebusy_offers(self): 1342 1343 "Remove free/busy offers when handling an object." 1344 1345 freebusy = self.store.get_freebusy_offers_for_update(self.user) 1346 1347 self.remove_from_freebusy(freebusy) 1348 self.remove_freebusy_for_recurrences(freebusy) 1349 self.store.set_freebusy_offers(self.user, freebusy) 1350 1351 return True 1352 1353 # Convenience methods for removing counter-proposals and updating the 1354 # request queue. 1355 1356 def remove_request(self): 1357 return self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1358 1359 def remove_event(self): 1360 return self.store.remove_event(self.user, self.uid, self.recurrenceid) 1361 1362 def remove_counter(self, attendee): 1363 self.remove_counters([attendee]) 1364 1365 def remove_counters(self, attendees): 1366 for attendee in attendees: 1367 self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid) 1368 1369 if not self.store.get_counters(self.user, self.uid, self.recurrenceid): 1370 self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1371 1372 # vim: tabstop=4 expandtab shiftwidth=4