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