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