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