imip-agent

imiptools/stores/file.py

1370:b4544a1a80c1
2017-10-25 Paul Boddie Moved period collection abstractions into the period module.
     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