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, get_uri, \ 25 get_recurrence_periods, \ 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 and self.messenger.sender != get_address(self.user): 242 attr["SENT-BY"] = get_uri(self.messenger.sender) 243 244 def get_periods(self, obj, explicit_only=False, future_only=False): 245 246 """ 247 Return periods for the given 'obj'. Interpretation of periods can depend 248 on the time zone, which is obtained for the current user. 249 250 If 'explicit_only' is set to a true value, only explicit periods will be 251 returned, not rule-based periods. 252 253 If 'future_only' is set to a true value, only future periods will be 254 returned, not all periods defined by an event starting in the past. 255 """ 256 257 return obj.get_periods(self.get_tzid(), 258 start=(future_only and self.get_window_start() or None), 259 end=(not explicit_only and self.get_window_end() or None)) 260 261 def get_main_period(self, obj): 262 263 "Return the main period defined by 'obj'." 264 265 return obj.get_main_period(self.get_tzid()) 266 267 def get_recurrence_periods(self, obj): 268 269 "Return recurrence periods defined by 'obj'." 270 271 return get_recurrence_periods(Client.get_periods(self, obj)) 272 273 # Store operations. 274 275 def get_stored_object(self, uid, recurrenceid, section=None, username=None): 276 277 """ 278 Return the stored object for the current user, with the given 'uid' and 279 'recurrenceid' from the given 'section' and for the given 'username' (if 280 specified), or from the standard object collection otherwise. 281 """ 282 283 if section == "counters": 284 return self.store.get_counter(self.user, username, uid, recurrenceid) 285 else: 286 return self.store.get_event(self.user, uid, recurrenceid, section) 287 288 # Free/busy operations. 289 290 def get_freebusy_part(self, freebusy=None): 291 292 """ 293 Return a message part containing free/busy information for the user, 294 either specified as 'freebusy' or obtained from the store directly. 295 """ 296 297 if self.is_sharing() and self.is_bundling(): 298 299 # Invent a unique identifier. 300 301 uid = make_uid(self.user) 302 303 freebusy = freebusy or self.store.get_freebusy(self.user) 304 305 user_attr = {} 306 self.update_sender(user_attr) 307 return self.to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)]) 308 309 return None 310 311 def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None): 312 313 """ 314 Update the 'freebusy' collection with the given 'periods', indicating a 315 'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a 316 recurrence or the parent event. The 'summary' and 'organiser' must also 317 be provided. 318 319 An optional 'expires' datetime string can be provided to tag a free/busy 320 offer. 321 """ 322 323 # Add specific attendee information for certain collections. 324 325 if isinstance(freebusy, SupportAttendee): 326 freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser, self.user) 327 328 # Add expiry datetime for certain collections. 329 330 elif isinstance(freebusy, SupportExpires): 331 freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser, expires) 332 333 # Provide only the essential attributes for other collections. 334 335 else: 336 freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser) 337 338 # Preparation of content. 339 340 def to_part(self, method, fragments): 341 342 "Return an encoded MIME part for the given 'method' and 'fragments'." 343 344 return to_part(method, fragments, line_length=settings["CALENDAR_LINE_LENGTH"]) 345 346 def object_to_part(self, method, obj): 347 348 "Return an encoded MIME part for the given 'method' and 'obj'." 349 350 return obj.to_part(method, line_length=settings["CALENDAR_LINE_LENGTH"]) 351 352 # Preparation of messages communicating the state of events. 353 354 def get_message_parts(self, obj, method, attendee=None): 355 356 """ 357 Return a tuple containing a list of methods and a list of message parts, 358 with the parts collectively describing the given object 'obj' and its 359 recurrences, using 'method' as the means of publishing details (with 360 CANCEL being used to retract or remove details). 361 362 If 'attendee' is indicated, the attendee's participation will be taken 363 into account when generating the description. 364 """ 365 366 # Assume that the outcome will be composed of requests and 367 # cancellations. It would not seem completely bizarre to produce 368 # publishing messages if a refresh message was unprovoked. 369 370 responses = [] 371 methods = set() 372 373 # Get the parent event, add SENT-BY details to the organiser. 374 375 if not attendee or self.is_participating(attendee, obj=obj): 376 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 377 self.update_sender(organiser_attr) 378 responses.append(self.object_to_part(method, obj)) 379 methods.add(method) 380 381 # Get recurrences for parent events. 382 383 if not self.recurrenceid: 384 385 # Collect active and cancelled recurrences. 386 387 for rl, section, rmethod in [ 388 (self.store.get_active_recurrences(self.user, self.uid), None, method), 389 (self.store.get_cancelled_recurrences(self.user, self.uid), "cancellations", "CANCEL"), 390 ]: 391 392 for recurrenceid in rl: 393 394 # Get the recurrence, add SENT-BY details to the organiser. 395 396 obj = self.get_stored_object(self.uid, recurrenceid, section) 397 398 if not attendee or self.is_participating(attendee, obj=obj): 399 organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER")) 400 self.update_sender(organiser_attr) 401 responses.append(self.object_to_part(rmethod, obj)) 402 methods.add(rmethod) 403 404 return methods, responses 405 406 class ClientForObject(Client): 407 408 "A client maintaining a specific object." 409 410 def __init__(self, obj, user, messenger=None, store=None, publisher=None, 411 journal=None, preferences_dir=None): 412 Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir) 413 self.set_object(obj) 414 415 def set_object(self, obj): 416 417 "Set the current object to 'obj', obtaining metadata details." 418 419 self.obj = obj 420 self.uid = obj and self.obj.get_uid() 421 self.recurrenceid = obj and self.obj.get_recurrenceid() 422 self.sequence = obj and self.obj.get_value("SEQUENCE") 423 self.dtstamp = obj and self.obj.get_value("DTSTAMP") 424 425 def load_object(self, uid, recurrenceid): 426 427 "Load the object with the given 'uid' and 'recurrenceid'." 428 429 self.set_object(self.get_stored_object(uid, recurrenceid)) 430 431 def set_identity(self, method): 432 433 """ 434 Set the current user for the current object in the context of the given 435 'method'. It is usually set when initialising the handler, using the 436 recipient details, but outgoing messages do not reference the recipient 437 in this way. 438 """ 439 440 pass 441 442 def is_usable(self, method=None): 443 444 "Return whether the current object is usable with the given 'method'." 445 446 return True 447 448 def is_organiser(self): 449 450 """ 451 Return whether the current user is the organiser in the current object. 452 """ 453 454 return get_uri(self.obj.get_value("ORGANIZER")) == self.user 455 456 def is_recurrence(self): 457 458 "Return whether the current object is a recurrence of its parent." 459 460 parent = self.get_parent_object() 461 return parent and parent.has_recurrence(self.get_tzid(), self.obj.get_recurrenceid()) 462 463 def get_recurrences(self): 464 465 "Return the current object's recurrence identifiers." 466 467 return self.store.get_recurrences(self.user, self.uid) 468 469 def get_periods(self, obj=None, explicit_only=False, future_only=False): 470 471 "Return the periods provided by the current object." 472 473 return Client.get_periods(self, obj or self.obj, explicit_only, future_only) 474 475 def get_updated_periods(self): 476 477 """ 478 Return the periods provided by the current object and associated 479 recurrence instances. Each original period is returned in a tuple with 480 a corresponding updated period which may be the same or which may be 481 None if the period is cancelled. A list of these tuples is returned. 482 """ 483 484 updated = [] 485 recurrenceids = self.get_recurrences() 486 487 for period in self.get_periods(): 488 recurrenceid = period.is_replaced(recurrenceids) 489 490 # Obtain any replacement instead of the replaced period. 491 492 if recurrenceid: 493 obj = self.get_stored_object(self.uid, recurrenceid) 494 periods = obj and Client.get_periods(self, obj) 495 496 # Active periods are obtained. Cancelled periods yield None. 497 498 if periods: 499 p = periods[0] 500 if p.origin == "DTSTART" and period.origin != "DTSTART": 501 p.origin = "DTSTART-RECUR" 502 updated.append((period, p)) 503 else: 504 updated.append((period, None)) 505 506 # Otherwise, retain the known period. 507 508 else: 509 updated.append((period, period)) 510 511 return updated 512 513 def get_main_period(self, obj=None): 514 515 "Return the main period defined by the current object." 516 517 return Client.get_main_period(self, obj or self.obj) 518 519 def get_recurrence_periods(self, obj=None): 520 521 "Return the recurrence periods defined by the current object." 522 523 return Client.get_recurrence_periods(self, obj or self.obj) 524 525 # Common operations on calendar data. 526 527 def update_senders(self, obj=None): 528 529 """ 530 Update sender details in 'obj', or the current object if not indicated, 531 removing SENT-BY attributes for attendees other than the current user if 532 those attributes give the URI of the calendar system. 533 """ 534 535 obj = obj or self.obj 536 calendar_uri = self.messenger and get_uri(self.messenger.sender) 537 for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")): 538 if attendee != self.user: 539 if attendee_attr.get("SENT-BY") == calendar_uri: 540 del attendee_attr["SENT-BY"] 541 else: 542 attendee_attr["SENT-BY"] = calendar_uri 543 544 def get_sending_attendee(self): 545 546 "Return the attendee who sent the current object." 547 548 # Search for the sender of the message or the calendar system address. 549 550 senders = self.senders or self.messenger and [self.messenger.sender] or [] 551 552 for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")): 553 if get_address(attendee) in senders or \ 554 get_address(attendee_attr.get("SENT-BY")) in senders: 555 return get_uri(attendee) 556 557 return None 558 559 def get_rescheduled_parts(self, periods, method): 560 561 """ 562 Return message parts describing rescheduled 'periods' affected by 'method'. 563 """ 564 565 rescheduled_parts = [] 566 567 if periods: 568 569 # Duplicate the core of the object without any period information. 570 571 obj = self.obj.copy() 572 obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"]) 573 574 for p in periods: 575 if not p.origin: 576 continue 577 578 # Set specific recurrence information. 579 580 obj.set_datetime("DTSTART", p.get_start()) 581 obj.set_datetime("DTEND", p.get_end()) 582 583 # Acquire the original recurrence identifier associated with 584 # this period. This may differ where the start of the period has 585 # changed. 586 587 dt, attr = p.get_recurrenceid_item() 588 obj["RECURRENCE-ID"] = [(format_datetime(dt), attr)] 589 590 rescheduled_parts.append(self.object_to_part(method, obj)) 591 592 return rescheduled_parts 593 594 # Object update methods. 595 596 def update_recurrenceid(self): 597 598 """ 599 Update the RECURRENCE-ID in the current object, initialising it from 600 DTSTART. 601 """ 602 603 self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")] 604 self.recurrenceid = self.obj.get_recurrenceid() 605 606 def update_dtstamp(self, obj=None): 607 608 "Update the DTSTAMP in the current object or any given object 'obj'." 609 610 obj = obj or self.obj 611 self.dtstamp = obj.update_dtstamp() 612 613 def update_sequence(self, increment=False, obj=None): 614 615 "Update the SEQUENCE in the current object or any given object 'obj'." 616 617 obj = obj or self.obj 618 obj.update_sequence(increment) 619 620 def merge_attendance(self, attendees): 621 622 """ 623 Merge attendance from the current object's 'attendees' into the version 624 stored for the current user. 625 """ 626 627 obj = self.get_stored_object_version() 628 629 if not obj or not self.have_new_object(): 630 return False 631 632 # Get attendee details in a usable form. 633 634 attendee_map = uri_dict(obj.get_value_map("ATTENDEE")) 635 636 for attendee, attendee_attr in attendees.items(): 637 638 # Update attendance in the loaded object for any recognised 639 # attendees. 640 641 if attendee_map.has_key(attendee): 642 attendee_map[attendee] = attendee_attr 643 644 # Check for delegated attendees. 645 646 for attendee, attendee_attr in attendees.items(): 647 648 # Identify delegates and check the delegation using the updated 649 # attendee information. 650 651 if not attendee_map.has_key(attendee) and \ 652 attendee_attr.has_key("DELEGATED-FROM") and \ 653 check_delegation(attendee_map, attendee, attendee_attr): 654 655 attendee_map[attendee] = attendee_attr 656 657 # Set the new details and store the object. 658 659 obj["ATTENDEE"] = attendee_map.items() 660 661 # Set a specific recurrence or the complete event if not an additional 662 # occurrence. 663 664 return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node()) 665 666 def update_attendees(self, attendees, removed): 667 668 """ 669 Update the attendees in the current object with the given 'attendees' 670 and 'removed' attendee lists. 671 672 A tuple is returned containing two items: a list of the attendees whose 673 attendance is being proposed (in a counter-proposal), a list of the 674 attendees whose attendance should be cancelled. 675 """ 676 677 to_cancel = [] 678 679 existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or []) 680 existing_attendees_map = dict(existing_attendees) 681 682 # Added attendees are those from the supplied collection not already 683 # present in the object. 684 685 added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees]) 686 removed = uri_values(removed) 687 688 if added or removed: 689 690 # The organiser can remove existing attendees. 691 692 if removed and self.is_organiser(): 693 remaining = [] 694 695 for attendee, attendee_attr in existing_attendees: 696 if attendee in removed: 697 698 # Only when an event has not been published can 699 # attendees be silently removed. 700 701 if self.obj.is_shared(): 702 to_cancel.append((attendee, attendee_attr)) 703 else: 704 remaining.append((attendee, attendee_attr)) 705 706 existing_attendees = remaining 707 708 # Attendees (when countering) must only include the current user and 709 # any added attendees. 710 711 elif not self.is_organiser(): 712 existing_attendees = [] 713 714 # Both organisers and attendees (when countering) can add attendees. 715 716 if added: 717 718 # Obtain a mapping from URIs to name details. 719 720 attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)]) 721 722 for attendee in added: 723 attendee = attendee.strip() 724 if attendee: 725 cn = attendee_map.get(attendee) 726 attendee_attr = {"CN" : cn} or {} 727 728 # Only the organiser can reset the participation attributes. 729 730 if self.is_organiser(): 731 attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}) 732 733 existing_attendees.append((attendee, attendee_attr)) 734 735 # Attendees (when countering) must only include the current user and 736 # any added attendees. 737 738 if not self.is_organiser() and self.user not in existing_attendees: 739 user_attr = self.get_user_attributes() 740 user_attr.update(existing_attendees_map.get(self.user) or {}) 741 existing_attendees.append((self.user, user_attr)) 742 743 self.obj["ATTENDEE"] = existing_attendees 744 745 return added, to_cancel 746 747 def update_participation(self, partstat=None): 748 749 """ 750 Update the participation in the current object of the user with the 751 given 'partstat'. 752 """ 753 754 attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user) 755 if not attendee_attr: 756 return None 757 if partstat: 758 attendee_attr["PARTSTAT"] = partstat 759 if attendee_attr.has_key("RSVP"): 760 del attendee_attr["RSVP"] 761 self.update_sender(attendee_attr) 762 return attendee_attr 763 764 # Communication methods. 765 766 def send_message(self, parts, sender, obj, from_organiser, bcc_sender): 767 768 """ 769 Send the given 'parts' to the appropriate recipients, also sending a 770 copy to the 'sender'. The 'obj' together with the 'from_organiser' value 771 (which indicates whether the organiser is sending this message) are used 772 to determine the recipients of the message. 773 """ 774 775 # As organiser, send an invitation to attendees, excluding oneself if 776 # also attending. The updated event will be saved by the outgoing 777 # handler. 778 779 organiser = get_uri(obj.get_value("ORGANIZER")) 780 attendees = uri_values(obj.get_values("ATTENDEE")) 781 782 if from_organiser: 783 recipients = [get_address(attendee) for attendee in attendees if attendee != self.user] 784 else: 785 recipients = [get_address(organiser)] 786 787 # Since the outgoing handler updates this user's free/busy details, 788 # the stored details will probably not have the updated details at 789 # this point, so we update our copy for serialisation as the bundled 790 # free/busy object. 791 792 freebusy = self.store.get_freebusy(self.user).copy() 793 self.update_freebusy(freebusy, self.user, from_organiser) 794 795 # Bundle free/busy information if appropriate. 796 797 part = self.get_freebusy_part(freebusy) 798 if part: 799 parts.append(part) 800 801 if recipients or bcc_sender: 802 self._send_message(sender, recipients, parts, bcc_sender) 803 804 def _send_message(self, sender, recipients, parts, bcc_sender): 805 806 """ 807 Send a message, explicitly specifying the 'sender' as an outgoing BCC 808 recipient since the generic calendar user will be the actual sender. 809 """ 810 811 if not self.messenger: 812 return 813 814 if not bcc_sender: 815 message = self.messenger.make_outgoing_message(parts, recipients) 816 self.messenger.sendmail(recipients, message.as_string()) 817 else: 818 message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender) 819 self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender) 820 821 def send_message_to_self(self, parts): 822 823 "Send a message composed of the given 'parts' to the given user." 824 825 if not self.messenger: 826 return 827 828 sender = get_address(self.user) 829 message = self.messenger.make_outgoing_message(parts, [sender]) 830 self.messenger.sendmail([sender], message.as_string()) 831 832 # Action methods. 833 834 def process_declined_counter(self, attendee): 835 836 "Process a declined counter-proposal." 837 838 # Obtain the counter-proposal for the attendee. 839 840 obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee) 841 if not obj: 842 return False 843 844 method = "DECLINECOUNTER" 845 self.update_senders(obj=obj) 846 obj.update_dtstamp() 847 obj.update_sequence(False) 848 self._send_message(get_address(self.user), [get_address(attendee)], [self.object_to_part(method, obj)], True) 849 return True 850 851 def process_received_request(self, changed=False): 852 853 """ 854 Process the current request for the current user. Return whether any 855 action was taken. If 'changed' is set to a true value, or if 'attendees' 856 is specified and differs from the stored attendees, a counter-proposal 857 will be sent instead of a reply. 858 """ 859 860 # Reply only on behalf of this user. 861 862 attendee_attr = self.update_participation() 863 864 if not attendee_attr: 865 return False 866 867 if not changed: 868 self.obj["ATTENDEE"] = [(self.user, attendee_attr)] 869 else: 870 self.update_senders() 871 872 self.update_dtstamp() 873 self.update_sequence(False) 874 self.send_message([self.object_to_part(changed and "COUNTER" or "REPLY", self.obj)], 875 get_address(self.user), self.obj, False, True) 876 return True 877 878 def process_created_request(self, method, to_cancel=None, 879 to_unschedule=None, to_reschedule=None): 880 881 """ 882 Process the current request, sending a created request of the given 883 'method' to attendees. Return whether any action was taken. 884 885 If 'to_cancel' is specified, a list of participants to be sent cancel 886 messages is provided. 887 888 If 'to_unschedule' is specified, a list of periods to be unscheduled is 889 provided. 890 891 If 'to_reschedule' is specified, a list of periods to be rescheduled is 892 provided. 893 894 Note that this method, although similar to get_message_parts, processes 895 the core object and the explicitly-specified objects, not the separate 896 recurrence instances that are already stored. 897 """ 898 899 # Here, the organiser should be the current user. 900 901 organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER")) 902 903 self.update_sender(organiser_attr) 904 self.update_senders() 905 self.update_dtstamp() 906 self.update_sequence(True) 907 908 if method == "REQUEST": 909 910 # Start with the parent object and augment it with the given 911 # amendments. 912 913 parts = [self.object_to_part(method, self.obj)] 914 915 # Add message parts with cancelled and modified occurrence 916 # information. 917 918 unscheduled_parts = self.get_rescheduled_parts(to_unschedule, "CANCEL") 919 rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "REQUEST") 920 921 # Send the updated event, along with a cancellation for each of the 922 # unscheduled occurrences. 923 924 self.send_message(parts + unscheduled_parts + rescheduled_parts, 925 get_address(organiser), self.obj, True, False) 926 927 # Since the organiser can update the SEQUENCE but this can leave any 928 # mail/calendar client lagging, issue a PUBLISH message to the 929 # user's address. 930 931 parts = [self.object_to_part("PUBLISH", self.obj)] 932 rescheduled_parts = self.get_rescheduled_parts(to_reschedule, "PUBLISH") 933 934 self.send_message_to_self(parts + unscheduled_parts + rescheduled_parts) 935 936 # When cancelling, replace the attendees with those for whom the event 937 # is now cancelled. 938 939 if method == "CANCEL" or to_cancel: 940 if to_cancel: 941 obj = self.obj.copy() 942 obj["ATTENDEE"] = to_cancel 943 else: 944 obj = self.obj 945 946 # Send a cancellation to all uninvited attendees. 947 948 parts = [self.object_to_part("CANCEL", obj)] 949 self.send_message(parts, get_address(organiser), obj, True, False) 950 951 # Issue a CANCEL message to the user's address. 952 953 if method == "CANCEL": 954 self.send_message_to_self(parts) 955 956 return True 957 958 # Object-related tests. 959 960 def is_recognised_organiser(self, organiser): 961 962 """ 963 Return whether the given 'organiser' is recognised from 964 previously-received details. If no stored details exist, True is 965 returned. 966 """ 967 968 obj = self.get_stored_object_version() 969 if obj: 970 stored_organiser = get_uri(obj.get_value("ORGANIZER")) 971 return stored_organiser == organiser 972 else: 973 return True 974 975 def is_recognised_attendee(self, attendee): 976 977 """ 978 Return whether the given 'attendee' is recognised from 979 previously-received details. If no stored details exist, True is 980 returned. 981 """ 982 983 obj = self.get_stored_object_version() 984 if obj: 985 stored_attendees = uri_dict(obj.get_value_map("ATTENDEE")) 986 return stored_attendees.has_key(attendee) 987 else: 988 return True 989 990 def get_attendance(self, user=None, obj=None): 991 992 """ 993 Return the attendance attributes for 'user', or the current user if 994 'user' is not specified. 995 """ 996 997 attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE")) 998 return attendees.get(user or self.user) 999 1000 def is_participating(self, user, as_organiser=False, obj=None): 1001 1002 """ 1003 Return whether, subject to the 'user' indicating an identity and the 1004 'as_organiser' status of that identity, the user concerned is actually 1005 participating in the current object event. 1006 """ 1007 1008 # Use any attendee property information for an organiser, not the 1009 # organiser property attributes. 1010 1011 attr = self.get_attendance(user, obj) 1012 return as_organiser or attr is not None and not attr or \ 1013 attr and attr.get("PARTSTAT") not in ("DECLINED", "DELEGATED", "NEEDS-ACTION") 1014 1015 def has_indicated_attendance(self, user=None, obj=None): 1016 1017 """ 1018 Return whether the given 'user' (or the current user if not specified) 1019 has indicated attendance in the given 'obj' (or the current object if 1020 not specified). 1021 """ 1022 1023 attr = self.get_attendance(user, obj) 1024 return attr and attr.get("PARTSTAT") not in (None, "NEEDS-ACTION") 1025 1026 def get_overriding_transparency(self, user, as_organiser=False): 1027 1028 """ 1029 Return the overriding transparency to be associated with the free/busy 1030 records for an event, subject to the 'user' indicating an identity and 1031 the 'as_organiser' status of that identity. 1032 1033 Where an identity is only an organiser and not attending, "ORG" is 1034 returned. Otherwise, no overriding transparency is defined and None is 1035 returned. 1036 """ 1037 1038 attr = self.get_attendance(user) 1039 return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None 1040 1041 def can_schedule(self, freebusy, periods): 1042 1043 """ 1044 Indicate whether within 'freebusy' the given 'periods' can be scheduled. 1045 """ 1046 1047 return freebusy.can_schedule(periods, self.uid, self.recurrenceid) 1048 1049 def have_new_object(self, strict=True): 1050 1051 """ 1052 Return whether the current object is new to the current user. 1053 1054 If 'strict' is specified and is a false value, the DTSTAMP test will be 1055 ignored. This is useful in handling responses from attendees from 1056 clients (like Claws Mail) that erase time information from DTSTAMP and 1057 make it invalid. 1058 """ 1059 1060 obj = self.get_stored_object_version() 1061 1062 # If found, compare SEQUENCE and potentially DTSTAMP. 1063 1064 if obj: 1065 sequence = obj.get_value("SEQUENCE") 1066 dtstamp = obj.get_value("DTSTAMP") 1067 1068 # If the request refers to an older version of the object, ignore 1069 # it. 1070 1071 return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict) 1072 1073 return True 1074 1075 def possibly_recurring_indefinitely(self): 1076 1077 "Return whether the object recurs indefinitely." 1078 1079 # Obtain the stored object to make sure that recurrence information 1080 # is not being ignored. This might happen if a client sends a 1081 # cancellation without the complete set of properties, for instance. 1082 1083 return self.obj.possibly_recurring_indefinitely() or \ 1084 self.get_stored_object_version() and \ 1085 self.get_stored_object_version().possibly_recurring_indefinitely() 1086 1087 # Constraint application on event periods. 1088 1089 def check_object(self): 1090 1091 "Check the object against any scheduling constraints." 1092 1093 permitted_values = self.get_permitted_values() 1094 if not permitted_values: 1095 return None 1096 1097 invalid = [] 1098 1099 for period in self.obj.get_periods(self.get_tzid()): 1100 errors = period.check_permitted(permitted_values) 1101 if errors: 1102 start_errors, end_errors = errors 1103 invalid.append((period.origin, start_errors, end_errors)) 1104 1105 return invalid 1106 1107 def correct_object(self): 1108 1109 "Correct the object according to any scheduling constraints." 1110 1111 permitted_values = self.get_permitted_values() 1112 return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values) 1113 1114 def correct_period(self, period): 1115 1116 "Correct 'period' according to any scheduling constraints." 1117 1118 permitted_values = self.get_permitted_values() 1119 if not permitted_values: 1120 return period 1121 else: 1122 return period.get_corrected(permitted_values) 1123 1124 # Object retrieval. 1125 1126 def get_stored_object_version(self): 1127 1128 """ 1129 Return the stored object to which the current object refers for the 1130 current user. 1131 """ 1132 1133 return self.get_stored_object(self.uid, self.recurrenceid) 1134 1135 def get_definitive_object(self, as_organiser): 1136 1137 """ 1138 Return an object considered definitive for the current transaction, 1139 using 'as_organiser' to select the current transaction's object if 1140 false, or selecting a stored object if true. 1141 """ 1142 1143 return not as_organiser and self.obj or self.get_stored_object_version() 1144 1145 def get_parent_object(self): 1146 1147 """ 1148 Return the parent object to which the current object refers for the 1149 current user. 1150 """ 1151 1152 return self.recurrenceid and self.get_stored_object(self.uid, None) or None 1153 1154 # Convenience methods for modifying free/busy collections. 1155 1156 def get_recurrence_start_point(self, recurrenceid): 1157 1158 "Get 'recurrenceid' in a form suitable for matching free/busy entries." 1159 1160 return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid()) 1161 1162 def remove_from_freebusy(self, freebusy, participant=None): 1163 1164 """ 1165 Remove this event from the given 'freebusy' collection. If 'participant' 1166 is specified, only remove this event if the participant is attending. 1167 """ 1168 1169 removed = freebusy.remove_event_periods(self.uid, self.recurrenceid, participant) 1170 if not removed and self.recurrenceid: 1171 return freebusy.remove_affected_period(self.uid, self.get_recurrence_start_point(self.recurrenceid), participant) 1172 else: 1173 return removed 1174 1175 def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None): 1176 1177 """ 1178 Remove from 'freebusy' any original recurrence from parent free/busy 1179 details for the current object, if the current object is a specific 1180 additional recurrence. Otherwise, remove all additional recurrence 1181 information corresponding to 'recurrenceids', or if omitted, all 1182 recurrences. 1183 """ 1184 1185 if self.recurrenceid: 1186 recurrenceid = self.get_recurrence_start_point(self.recurrenceid) 1187 freebusy.remove_affected_period(self.uid, recurrenceid) 1188 else: 1189 # Remove obsolete recurrence periods. 1190 1191 freebusy.remove_additional_periods(self.uid, recurrenceids) 1192 1193 # Remove original periods affected by additional recurrences. 1194 1195 if recurrenceids: 1196 for recurrenceid in recurrenceids: 1197 recurrenceid = self.get_recurrence_start_point(recurrenceid) 1198 freebusy.remove_affected_period(self.uid, recurrenceid) 1199 1200 def update_freebusy(self, freebusy, user, as_organiser, offer=False): 1201 1202 """ 1203 Update the 'freebusy' collection for this event with the periods and 1204 transparency associated with the current object, subject to the 'user' 1205 identity and the attendance details provided for them, indicating 1206 whether the update is being done 'as_organiser' (for the organiser of 1207 an event) or not. 1208 1209 If 'offer' is set to a true value, any free/busy updates will be tagged 1210 with an expiry time. 1211 """ 1212 1213 # Obtain the stored object if the current object is not issued by the 1214 # organiser. Attendees do not have the opportunity to redefine the 1215 # periods. 1216 1217 obj = self.get_definitive_object(as_organiser) 1218 if not obj: 1219 return 1220 1221 # Obtain the affected periods. 1222 1223 periods = self.get_periods(obj, future_only=True) 1224 1225 # Define an overriding transparency, the indicated event transparency, 1226 # or the default transparency for the free/busy entry. 1227 1228 transp = self.get_overriding_transparency(user, as_organiser) or \ 1229 obj.get_value("TRANSP") or \ 1230 "OPAQUE" 1231 1232 # Calculate any expiry time. If no offer period is defined, do not 1233 # record the offer periods. 1234 1235 if offer: 1236 offer_period = self.get_offer_period() 1237 if offer_period: 1238 expires = get_timestamp(offer_period) 1239 else: 1240 return 1241 else: 1242 expires = None 1243 1244 # Perform the low-level update. 1245 1246 Client.update_freebusy(self, freebusy, periods, transp, 1247 self.uid, self.recurrenceid, 1248 obj.get_value("SUMMARY"), 1249 get_uri(obj.get_value("ORGANIZER")), 1250 expires) 1251 1252 def update_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1253 updating_other=False, offer=False): 1254 1255 """ 1256 Update the 'freebusy' collection for the given 'user', indicating 1257 whether the update is 'for_organiser' (being done for the organiser of 1258 an event) or not, and whether it is 'updating_other' (meaning another 1259 user's details). 1260 1261 If 'offer' is set to a true value, any free/busy updates will be tagged 1262 with an expiry time. 1263 """ 1264 1265 # Record in the free/busy details unless a non-participating attendee. 1266 # Remove periods for non-participating attendees. 1267 1268 if offer or self.is_participating(user, for_organiser and not updating_other): 1269 self.update_freebusy(freebusy, user, 1270 for_organiser and not updating_other or 1271 not for_organiser and updating_other, 1272 offer 1273 ) 1274 else: 1275 self.remove_from_freebusy(freebusy) 1276 1277 def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False, 1278 updating_other=False): 1279 1280 """ 1281 Remove details from the 'freebusy' collection for the given 'user', 1282 indicating whether the modification is 'for_organiser' (being done for 1283 the organiser of an event) or not, and whether it is 'updating_other' 1284 (meaning another user's details). 1285 """ 1286 1287 # Remove from the free/busy details if a specified attendee. 1288 1289 if self.is_participating(user, for_organiser and not updating_other): 1290 self.remove_from_freebusy(freebusy) 1291 1292 # Convenience methods for updating stored free/busy information received 1293 # from other users. 1294 1295 def update_freebusy_from_participant(self, user, for_organiser, fn=None): 1296 1297 """ 1298 For the current user, record the free/busy information for another 1299 'user', indicating whether the update is 'for_organiser' or not, thus 1300 maintaining a separate record of their free/busy details. 1301 """ 1302 1303 fn = fn or self.update_freebusy_for_participant 1304 1305 # A user does not store free/busy information for themself as another 1306 # party. 1307 1308 if user == self.user: 1309 return 1310 1311 self.acquire_lock() 1312 try: 1313 freebusy = self.store.get_freebusy_for_other_for_update(self.user, user) 1314 fn(freebusy, user, for_organiser, True) 1315 1316 # Tidy up any obsolete recurrences. 1317 1318 self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) 1319 self.store.set_freebusy_for_other(self.user, freebusy, user) 1320 1321 finally: 1322 self.release_lock() 1323 1324 def update_freebusy_from_organiser(self, organiser): 1325 1326 "For the current user, record free/busy information from 'organiser'." 1327 1328 self.update_freebusy_from_participant(organiser, True) 1329 1330 def update_freebusy_from_attendees(self, attendees): 1331 1332 "For the current user, record free/busy information from 'attendees'." 1333 1334 obj = self.get_stored_object_version() 1335 1336 if not obj or not self.have_new_object(): 1337 return False 1338 1339 # Filter out unrecognised attendees. 1340 1341 attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE"))) 1342 1343 for attendee in attendees: 1344 self.update_freebusy_from_participant(attendee, False) 1345 1346 return True 1347 1348 def remove_freebusy_from_organiser(self, organiser): 1349 1350 "For the current user, remove free/busy information from 'organiser'." 1351 1352 self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant) 1353 1354 def remove_freebusy_from_attendees(self, attendees): 1355 1356 "For the current user, remove free/busy information from 'attendees'." 1357 1358 for attendee in attendees.keys(): 1359 self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant) 1360 1361 # Convenience methods for updating free/busy details at the event level. 1362 1363 def update_event_in_freebusy(self, for_organiser=True): 1364 1365 """ 1366 Update free/busy information when handling an object, doing so for the 1367 organiser of an event if 'for_organiser' is set to a true value. 1368 """ 1369 1370 freebusy = self.store.get_freebusy_for_update(self.user) 1371 1372 # Obtain the attendance attributes for this user, if available. 1373 1374 self.update_freebusy_for_participant(freebusy, self.user, for_organiser) 1375 1376 # Remove original recurrence details replaced by additional 1377 # recurrences, as well as obsolete additional recurrences. 1378 1379 self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) 1380 self.store.set_freebusy(self.user, freebusy) 1381 1382 if self.publisher and self.is_sharing() and self.is_publishing(): 1383 self.publisher.set_freebusy(self.user, freebusy) 1384 1385 # Update free/busy provider information if the event may recur 1386 # indefinitely. 1387 1388 if self.possibly_recurring_indefinitely(): 1389 self.store.append_freebusy_provider(self.user, self.obj) 1390 1391 return True 1392 1393 def remove_event_from_freebusy(self): 1394 1395 "Remove free/busy information when handling an object." 1396 1397 freebusy = self.store.get_freebusy_for_update(self.user) 1398 1399 self.remove_from_freebusy(freebusy) 1400 self.remove_freebusy_for_recurrences(freebusy) 1401 self.store.set_freebusy(self.user, freebusy) 1402 1403 if self.publisher and self.is_sharing() and self.is_publishing(): 1404 self.publisher.set_freebusy(self.user, freebusy) 1405 1406 # Update free/busy provider information if the event may recur 1407 # indefinitely. 1408 1409 if self.possibly_recurring_indefinitely(): 1410 self.store.remove_freebusy_provider(self.user, self.obj) 1411 1412 def update_event_in_freebusy_offers(self): 1413 1414 "Update free/busy offers when handling an object." 1415 1416 freebusy = self.store.get_freebusy_offers_for_update(self.user) 1417 1418 # Obtain the attendance attributes for this user, if available. 1419 1420 self.update_freebusy_for_participant(freebusy, self.user, offer=True) 1421 1422 # Remove original recurrence details replaced by additional 1423 # recurrences, as well as obsolete additional recurrences. 1424 1425 self.remove_freebusy_for_recurrences(freebusy, self.get_recurrences()) 1426 self.store.set_freebusy_offers(self.user, freebusy) 1427 1428 return True 1429 1430 def remove_event_from_freebusy_offers(self): 1431 1432 "Remove free/busy offers when handling an object." 1433 1434 freebusy = self.store.get_freebusy_offers_for_update(self.user) 1435 1436 self.remove_from_freebusy(freebusy) 1437 self.remove_freebusy_for_recurrences(freebusy) 1438 self.store.set_freebusy_offers(self.user, freebusy) 1439 1440 return True 1441 1442 # Convenience methods for removing counter-proposals and updating the 1443 # request queue. 1444 1445 def remove_request(self): 1446 return self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1447 1448 def remove_event(self): 1449 return self.store.remove_event(self.user, self.uid, self.recurrenceid) 1450 1451 def remove_counter(self, attendee): 1452 self.remove_counters([attendee]) 1453 1454 def remove_counters(self, attendees): 1455 for attendee in attendees: 1456 self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid) 1457 1458 if not self.store.get_counters(self.user, self.uid, self.recurrenceid): 1459 self.store.dequeue_request(self.user, self.uid, self.recurrenceid) 1460 1461 # vim: tabstop=4 expandtab shiftwidth=4