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