1 #!/usr/bin/env python 2 3 """ 4 A simple filesystem-based store of calendar data. 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 imiptools.stores.common import StoreBase, PublisherBase, JournalBase 23 24 from datetime import datetime 25 from imiptools.config import settings 26 from imiptools.data import Object, make_calendar, parse_object, to_stream 27 from imiptools.dates import format_datetime, get_datetime, to_timezone 28 from imiptools.filesys import fix_permissions, FileBase 29 30 from imiptools.freebusy import FreeBusyCollection, \ 31 FreeBusyGroupCollection, \ 32 FreeBusyOffersCollection, \ 33 period_from_tuple, \ 34 period_to_tuple 35 36 from imiptools.text import FileTable, FileTableDict, FileTableSingle, \ 37 have_table 38 39 from os.path import isdir, isfile, join 40 from os import listdir, remove, rmdir 41 42 # Obtain defaults from the settings. 43 44 STORE_DIR = settings["STORE_DIR"] 45 PUBLISH_DIR = settings["PUBLISH_DIR"] 46 JOURNAL_DIR = settings["JOURNAL_DIR"] 47 48 # Store classes. 49 50 class FileStoreBase(FileBase): 51 52 "A file store supporting user-specific locking." 53 54 def acquire_lock(self, user, timeout=None): 55 FileBase.acquire_lock(self, timeout, user) 56 57 def release_lock(self, user): 58 FileBase.release_lock(self, user) 59 60 class Store(FileStoreBase, StoreBase): 61 62 "A file store of tabular free/busy data and objects." 63 64 def __init__(self, store_dir=None): 65 FileBase.__init__(self, store_dir or STORE_DIR) 66 67 # Store object access. 68 69 def _get_object(self, user, filename): 70 71 """ 72 Return the parsed object for the given 'user' having the given 73 'filename'. 74 """ 75 76 self.acquire_lock(user) 77 try: 78 f = open(filename, "rb") 79 try: 80 return Object(parse_object(f, "utf-8")) 81 finally: 82 f.close() 83 finally: 84 self.release_lock(user) 85 86 def _set_object(self, user, filename, node): 87 88 """ 89 Set an object for the given 'user' having the given 'filename', using 90 'node' to define the object. 91 """ 92 93 self.acquire_lock(user) 94 try: 95 f = open(filename, "wb") 96 try: 97 to_stream(f, node) 98 finally: 99 f.close() 100 fix_permissions(filename) 101 finally: 102 self.release_lock(user) 103 104 return True 105 106 def _remove_object(self, filename): 107 108 "Remove the object with the given 'filename'." 109 110 try: 111 remove(filename) 112 except OSError: 113 return False 114 115 return True 116 117 def _remove_collection(self, filename): 118 119 "Remove the collection with the given 'filename'." 120 121 try: 122 rmdir(filename) 123 except OSError: 124 return False 125 126 return True 127 128 # User discovery. 129 130 def get_users(self): 131 132 "Return a list of users." 133 134 return listdir(self.store_dir) 135 136 # Event and event metadata access. 137 138 def get_events(self, user): 139 140 "Return a list of event identifiers." 141 142 filename = self.get_object_in_store(user, "objects") 143 if not filename or not isdir(filename): 144 return [] 145 146 return [name for name in listdir(filename) if isfile(join(filename, name))] 147 148 def get_cancelled_events(self, user): 149 150 "Return a list of event identifiers for cancelled events." 151 152 filename = self.get_object_in_store(user, "cancellations", "objects") 153 if not filename or not isdir(filename): 154 return [] 155 156 return [name for name in listdir(filename) if isfile(join(filename, name))] 157 158 def get_event(self, user, uid, recurrenceid=None, dirname=None): 159 160 """ 161 Get the event for the given 'user' with the given 'uid'. If 162 the optional 'recurrenceid' is specified, a specific instance or 163 occurrence of an event is returned. 164 """ 165 166 filename = self.get_event_filename(user, uid, recurrenceid, dirname) 167 if not filename or not isfile(filename): 168 return None 169 170 return filename and self._get_object(user, filename) 171 172 def set_parent_event(self, user, uid, node): 173 174 "Set an event for 'user' having the given 'uid' and 'node'." 175 176 filename = self.get_object_in_store(user, "objects", uid) 177 if not filename: 178 return False 179 180 return self._set_object(user, filename, node) 181 182 def remove_parent_event(self, user, uid): 183 184 "Remove the parent event for 'user' having the given 'uid'." 185 186 filename = self.get_object_in_store(user, "objects", uid) 187 if not filename: 188 return False 189 190 return self._remove_object(filename) 191 192 def get_recurrences(self, user, uid): 193 194 """ 195 Get additional event instances for an event of the given 'user' with the 196 indicated 'uid'. Both active and cancelled recurrences are returned. 197 """ 198 199 return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) 200 201 def get_active_recurrences(self, user, uid): 202 203 """ 204 Get additional event instances for an event of the given 'user' with the 205 indicated 'uid'. Cancelled recurrences are not returned. 206 """ 207 208 filename = self.get_object_in_store(user, "recurrences", uid) 209 if not filename or not isdir(filename): 210 return [] 211 212 return [name for name in listdir(filename) if isfile(join(filename, name))] 213 214 def get_cancelled_recurrences(self, user, uid): 215 216 """ 217 Get additional event instances for an event of the given 'user' with the 218 indicated 'uid'. Only cancelled recurrences are returned. 219 """ 220 221 filename = self.get_object_in_store(user, "cancellations", "recurrences", uid) 222 if not filename or not isdir(filename): 223 return [] 224 225 return [name for name in listdir(filename) if isfile(join(filename, name))] 226 227 def get_recurrence(self, user, uid, recurrenceid): 228 229 """ 230 For the event of the given 'user' with the given 'uid', return the 231 specific recurrence indicated by the 'recurrenceid'. 232 """ 233 234 filename = self.get_recurrence_filename(user, uid, recurrenceid) 235 if not filename or not isfile(filename): 236 return None 237 238 return filename and self._get_object(user, filename) 239 240 def set_recurrence(self, user, uid, recurrenceid, node): 241 242 "Set an event for 'user' having the given 'uid' and 'node'." 243 244 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 245 if not filename: 246 return False 247 248 return self._set_object(user, filename, node) 249 250 def remove_recurrence(self, user, uid, recurrenceid): 251 252 """ 253 Remove a special recurrence from an event stored by 'user' having the 254 given 'uid' and 'recurrenceid'. 255 """ 256 257 filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) 258 if not filename: 259 return False 260 261 return self._remove_object(filename) 262 263 def remove_recurrence_collection(self, user, uid): 264 265 """ 266 Remove the collection of recurrences stored by 'user' having the given 267 'uid'. 268 """ 269 270 recurrences = self.get_object_in_store(user, "recurrences", uid) 271 if recurrences: 272 return self._remove_collection(recurrences) 273 274 return True 275 276 # Event filename computation. 277 278 def get_event_filename(self, user, uid, recurrenceid=None, dirname=None, username=None): 279 280 """ 281 Get the filename providing the event for the given 'user' with the given 282 'uid'. If the optional 'recurrenceid' is specified, a specific instance 283 or occurrence of an event is returned. 284 285 Where 'dirname' is specified, the given directory name is used as the 286 base of the location within which any filename will reside. 287 """ 288 289 if recurrenceid: 290 return self.get_recurrence_filename(user, uid, recurrenceid, dirname, username) 291 else: 292 return self.get_parent_event_filename(user, uid, dirname, username) 293 294 def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None, username=None): 295 296 """ 297 For the event of the given 'user' with the given 'uid', return the 298 filename providing the recurrence with the given 'recurrenceid'. 299 300 Where 'dirname' is specified, the given directory name is used as the 301 base of the location within which any filename will reside. 302 303 Where 'username' is specified, the event details will reside in a file 304 bearing that name within a directory having 'uid' as its name. 305 """ 306 307 return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid, username) 308 309 def get_parent_event_filename(self, user, uid, dirname=None, username=None): 310 311 """ 312 Get the filename providing the event for the given 'user' with the given 313 'uid'. 314 315 Where 'dirname' is specified, the given directory name is used as the 316 base of the location within which any filename will reside. 317 318 Where 'username' is specified, the event details will reside in a file 319 bearing that name within a directory having 'uid' as its name. 320 """ 321 322 return self.get_object_in_store(user, dirname, "objects", uid, username) 323 324 # Free/busy period providers, upon extension of the free/busy records. 325 326 def _get_freebusy_providers(self, user): 327 328 """ 329 Return the free/busy providers for the given 'user'. 330 331 This function returns any stored datetime and a list of providers as a 332 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. 333 """ 334 335 filename = self.get_object_in_store(user, "freebusy-providers") 336 if not filename: 337 return None 338 339 # Attempt to read providers, with a declaration of the datetime 340 # from which such providers are considered as still being active. 341 342 t = self._get_freebusy_providers_table(filename) 343 header = t.get_header_values() 344 if not header: 345 return None 346 347 return header[0], t 348 349 def _get_freebusy_providers_table(self, filename): 350 351 "Return a file-based table for storing providers in 'filename'." 352 353 return FileTable(filename, 354 in_defaults=[(1, None)], 355 out_defaults=[(1, "")], 356 headers=1) 357 358 def _set_freebusy_providers(self, user, dt_string, providers): 359 360 "Set the given provider timestamp 'dt_string' and 'providers'." 361 362 filename = self.get_object_in_store(user, "freebusy-providers") 363 if not filename: 364 return False 365 366 self.acquire_lock(user) 367 try: 368 if not have_table(providers, filename): 369 pr = self._get_freebusy_providers_table(filename) 370 pr.replaceall(providers) 371 providers = pr 372 providers.set_header_values([dt_string]) 373 providers.close() 374 finally: 375 self.release_lock(user) 376 return True 377 378 # Free/busy period access. 379 380 def get_freebusy(self, user, name=None, mutable=False): 381 382 "Get free/busy details for the given 'user'." 383 384 filename = self.get_object_in_store(user, name or "freebusy") 385 386 if not filename: 387 return [] 388 389 return self._get_freebusy(filename, mutable, FreeBusyCollection) 390 391 def get_freebusy_for_other(self, user, other, mutable=False, collection=None): 392 393 "For the given 'user', get free/busy details for the 'other' user." 394 395 filename = self.get_object_in_store(user, "freebusy-other", other) 396 397 if not filename: 398 return [] 399 400 return self._get_freebusy(filename, mutable, collection or FreeBusyCollection) 401 402 def _get_freebusy(self, filename, mutable=False, collection=None): 403 404 """ 405 Return a free/busy collection for 'filename' with the given 'mutable' 406 condition, employing the specified 'collection' class. 407 """ 408 409 collection = collection or FreeBusyCollection 410 411 periods = FileTable(filename, mutable=mutable, 412 in_converter=period_from_tuple(collection.period_class), 413 out_converter=period_to_tuple) 414 415 return collection(periods, mutable=mutable) 416 417 def set_freebusy(self, user, freebusy, name=None): 418 419 "For the given 'user', set 'freebusy' details." 420 421 filename = self.get_object_in_store(user, name or "freebusy") 422 if not filename: 423 return False 424 425 return self._set_freebusy(user, freebusy, filename) 426 427 def set_freebusy_for_other(self, user, freebusy, other, collection=None): 428 429 "For the given 'user', set 'freebusy' details for the 'other' user." 430 431 filename = self.get_object_in_store(user, "freebusy-other", other) 432 if not filename: 433 return False 434 435 return self._set_freebusy(user, freebusy, filename, collection) 436 437 def _set_freebusy(self, user, freebusy, filename, collection=None): 438 439 "For the given 'user', set 'freebusy' details for the given 'filename'." 440 441 # Copy to the specified table if different from that given. 442 443 self.acquire_lock(user) 444 try: 445 if not have_table(freebusy, filename): 446 fbc = self._get_freebusy(filename, True, collection) 447 fbc += freebusy 448 freebusy = fbc 449 freebusy.close() 450 finally: 451 self.release_lock(user) 452 453 return True 454 455 def get_freebusy_others(self, user): 456 457 """ 458 For the given 'user', return a list of other users for whom free/busy 459 information is retained. 460 """ 461 462 filename = self.get_object_in_store(user, "freebusy-other") 463 464 if not filename or not isdir(filename): 465 return [] 466 467 return listdir(filename) 468 469 # Tentative free/busy periods related to countering. 470 471 def get_freebusy_offers(self, user, mutable=False): 472 473 "Get free/busy offers for the given 'user'." 474 475 filename = self.get_object_in_store(user, "freebusy-offers") 476 477 if not filename: 478 return [] 479 480 expired = [] 481 now = to_timezone(datetime.utcnow(), "UTC") 482 483 # Expire old offers and save the collection if modified. 484 485 self.acquire_lock(user) 486 try: 487 offers = self._get_freebusy(filename, True, FreeBusyOffersCollection) 488 for fb in offers: 489 if fb.expires and get_datetime(fb.expires) <= now: 490 offers.remove(fb) 491 if expired: 492 offers.close() 493 finally: 494 self.release_lock(user) 495 496 offers.mutable = mutable 497 return offers 498 499 # Requests and counter-proposals. 500 501 def get_requests(self, user, queue="requests"): 502 503 "Get requests for the given 'user' from the given 'queue'." 504 505 filename = self.get_object_in_store(user, queue) 506 if not filename: 507 return [] 508 509 return FileTable(filename, 510 in_defaults=[(1, None), (2, None)], 511 out_defaults=[(1, ""), (2, "")]) 512 513 def set_request(self, user, uid, recurrenceid=None, type=None): 514 515 """ 516 For the given 'user', set the queued 'uid' and 'recurrenceid', 517 indicating a request, along with any given 'type'. 518 """ 519 520 requests = self.get_requests(user) 521 return self.set_requests(user, [(uid, recurrenceid, type)]) 522 523 def set_requests(self, user, requests, queue="requests"): 524 525 """ 526 For the given 'user', set the list of queued 'requests' in the given 527 'queue'. 528 """ 529 530 filename = self.get_object_in_store(user, queue) 531 if not filename: 532 return False 533 534 # Copy to the specified table if different from that given. 535 536 self.acquire_lock(user) 537 try: 538 if not have_table(requests, filename): 539 req = self.get_requests(user, queue) 540 req.replaceall(requests) 541 requests = req 542 requests.close() 543 finally: 544 self.release_lock(user) 545 546 return True 547 548 def get_counters(self, user, uid, recurrenceid=None): 549 550 """ 551 For the given 'user', return a list of users from whom counter-proposals 552 have been received for the given 'uid' and optional 'recurrenceid'. 553 """ 554 555 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 556 if not filename or not isdir(filename): 557 return [] 558 559 return [name for name in listdir(filename) if isfile(join(filename, name))] 560 561 def get_counter_recurrences(self, user, uid): 562 563 """ 564 For the given 'user', return a list of recurrence identifiers describing 565 counter-proposals for the parent event with the given 'uid'. 566 """ 567 568 filename = self.get_object_in_store(user, "counters", "recurrences", uid) 569 if not filename or not isdir(filename): 570 return [] 571 572 return listdir(filename) 573 574 def get_counter(self, user, other, uid, recurrenceid=None): 575 576 """ 577 For the given 'user', return the counter-proposal from 'other' for the 578 given 'uid' and optional 'recurrenceid'. 579 """ 580 581 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 582 if not filename or not isfile(filename): 583 return None 584 585 return self._get_object(user, filename) 586 587 def set_counter(self, user, other, node, uid, recurrenceid=None): 588 589 """ 590 For the given 'user', store a counter-proposal received from 'other' the 591 given 'node' representing that proposal for the given 'uid' and 592 'recurrenceid'. 593 """ 594 595 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 596 if not filename: 597 return False 598 599 return self._set_object(user, filename, node) 600 601 def remove_counters(self, user, uid, recurrenceid=None, attendee=None): 602 603 """ 604 For the given 'user', remove all counter-proposals associated with the 605 given 'uid' and 'recurrenceid'. If a parent event is specified, all 606 recurrence counter-proposals will be removed. If 'attendee' is 607 specified, only objects provided by this attendee will be removed. 608 """ 609 610 self._remove_counters(user, uid, recurrenceid, attendee) 611 612 if not recurrenceid: 613 for recurrenceid in self.get_counter_recurrences(user, uid): 614 self._remove_counters(user, uid, recurrenceid, attendee) 615 616 def _remove_counters(self, user, uid, recurrenceid=None, attendee=None): 617 618 """ 619 For the given 'user', remove all counter-proposals associated with the 620 given 'uid' and 'recurrenceid'. If 'attendee' is specified, only objects 621 provided by this attendee will be removed. 622 """ 623 624 filename = self.get_event_filename(user, uid, recurrenceid, "counters") 625 if not filename or not isdir(filename): 626 return False 627 628 removed = False 629 630 for other in listdir(filename): 631 if not attendee or other == attendee: 632 counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 633 removed = removed or self._remove_object(counter_filename) 634 635 if not listdir(filename): 636 self._remove_collection(filename) 637 638 return removed 639 640 def remove_counter(self, user, other, uid, recurrenceid=None): 641 642 """ 643 For the given 'user', remove any counter-proposal from 'other' 644 associated with the given 'uid' and 'recurrenceid'. 645 """ 646 647 filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) 648 if not filename or not isfile(filename): 649 return False 650 651 return self._remove_object(filename) 652 653 # Event cancellation. 654 655 def cancel_event(self, user, uid, recurrenceid=None): 656 657 """ 658 Cancel an event for 'user' having the given 'uid'. If the optional 659 'recurrenceid' is specified, a specific instance or occurrence of an 660 event is cancelled. 661 """ 662 663 filename = self.get_event_filename(user, uid, recurrenceid) 664 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 665 666 if filename and cancelled_filename and isfile(filename): 667 return self.move_object(filename, cancelled_filename) 668 669 return False 670 671 def uncancel_event(self, user, uid, recurrenceid=None): 672 673 """ 674 Uncancel an event for 'user' having the given 'uid'. If the optional 675 'recurrenceid' is specified, a specific instance or occurrence of an 676 event is uncancelled. 677 """ 678 679 filename = self.get_event_filename(user, uid, recurrenceid) 680 cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 681 682 if filename and cancelled_filename and isfile(cancelled_filename): 683 return self.move_object(cancelled_filename, filename) 684 685 return False 686 687 def remove_cancellation(self, user, uid, recurrenceid=None): 688 689 """ 690 Remove a cancellation for 'user' for the event having the given 'uid'. 691 If the optional 'recurrenceid' is specified, a specific instance or 692 occurrence of an event is affected. 693 """ 694 695 # Remove any parent event cancellation or a specific recurrence 696 # cancellation if indicated. 697 698 filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") 699 700 if filename and isfile(filename): 701 return self._remove_object(filename) 702 703 return False 704 705 class Publisher(FileBase, PublisherBase): 706 707 "A publisher of objects." 708 709 def __init__(self, store_dir=None): 710 FileBase.__init__(self, store_dir or PUBLISH_DIR) 711 712 def set_freebusy(self, user, freebusy): 713 714 "For the given 'user', set 'freebusy' details." 715 716 filename = self.get_object_in_store(user, "freebusy") 717 if not filename: 718 return False 719 720 record = [] 721 rwrite = record.append 722 723 rwrite(("ORGANIZER", {}, user)) 724 rwrite(("UID", {}, user)) 725 rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) 726 727 for fb in freebusy: 728 if not fb.transp or fb.transp == "OPAQUE": 729 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( 730 map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) 731 732 f = open(filename, "wb") 733 try: 734 to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) 735 finally: 736 f.close() 737 fix_permissions(filename) 738 739 return True 740 741 class Journal(Store, JournalBase): 742 743 "A journal system to support quotas." 744 745 # Quota and user identity/group discovery. 746 747 get_quotas = Store.get_users 748 get_quota_users = Store.get_freebusy_others 749 750 # Delegate information for the quota. 751 752 def get_delegates(self, quota): 753 754 "Return a list of delegates for 'quota'." 755 756 filename = self.get_object_in_store(quota, "delegates") 757 if not filename: 758 return [] 759 760 return FileTableSingle(filename) 761 762 def set_delegates(self, quota, delegates): 763 764 "For the given 'quota', set the list of 'delegates'." 765 766 filename = self.get_object_in_store(quota, "delegates") 767 if not filename: 768 return False 769 770 self.acquire_lock(quota) 771 try: 772 if not have_table(delegates, filename): 773 de = self.get_delegates(quota) 774 de.replaceall(delegates) 775 delegates = de 776 delegates.close() 777 finally: 778 self.release_lock(quota) 779 780 return True 781 782 # Groups of users sharing quotas. 783 784 def get_groups(self, quota): 785 786 "Return the identity mappings for the given 'quota' as a dictionary." 787 788 filename = self.get_object_in_store(quota, "groups") 789 if not filename: 790 return {} 791 792 return FileTableDict(filename, tab_separated=False) 793 794 def set_groups(self, quota, groups): 795 796 "For the given 'quota', set 'groups' mapping users to groups." 797 798 filename = self.get_object_in_store(quota, "groups") 799 if not filename: 800 return False 801 802 self.acquire_lock(quota) 803 try: 804 if not have_table(groups, filename): 805 gr = self.get_groups(quota) 806 gr.updateall(groups) 807 groups = gr 808 groups.close() 809 finally: 810 self.release_lock(quota) 811 812 return True 813 814 def get_limits(self, quota): 815 816 """ 817 Return the limits for the 'quota' as a dictionary mapping identities or 818 groups to durations. 819 """ 820 821 filename = self.get_object_in_store(quota, "limits") 822 if not filename: 823 return {} 824 825 return FileTableDict(filename, tab_separated=False) 826 827 def set_limits(self, quota, limits): 828 829 """ 830 For the given 'quota', set the given 'limits' on resource usage mapping 831 groups to limits. 832 """ 833 834 filename = self.get_object_in_store(quota, "limits") 835 if not filename: 836 return False 837 838 self.acquire_lock(quota) 839 try: 840 if not have_table(limits, filename): 841 li = self.get_limits(quota) 842 li.updateall(limits) 843 limits = li 844 limits.close() 845 finally: 846 self.release_lock(quota) 847 848 return True 849 850 # Journal entry methods. 851 852 def get_entries(self, quota, group, mutable=False): 853 854 """ 855 Return a list of journal entries for the given 'quota' for the indicated 856 'group'. 857 """ 858 859 return self.get_freebusy_for_other(quota, group, mutable) 860 861 def set_entries(self, quota, group, entries): 862 863 """ 864 For the given 'quota' and indicated 'group', set the list of journal 865 'entries'. 866 """ 867 868 return self.set_freebusy_for_other(quota, entries, group) 869 870 # Compatibility methods. 871 872 def get_freebusy_for_other(self, user, other, mutable=False): 873 return Store.get_freebusy_for_other(self, user, other, mutable, collection=FreeBusyGroupCollection) 874 875 def set_freebusy_for_other(self, user, entries, other): 876 Store.set_freebusy_for_other(self, user, entries, other, collection=FreeBusyGroupCollection) 877 878 # vim: tabstop=4 expandtab shiftwidth=4