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