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