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