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