imip-agent

imip_store.py

749:3b0ac9430955
2015-09-18 Paul Boddie Added support for configurable default preferences.
     1 #!/usr/bin/env python     2      3 """     4 A simple filesystem-based store of calendar data.     5      6 Copyright (C) 2014, 2015 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 bisect import bisect_left    23 from datetime import datetime    24 from imiptools.config import STORE_DIR, PUBLISH_DIR    25 from imiptools.data import make_calendar, parse_object, to_stream    26 from imiptools.dates import format_datetime, get_datetime, to_timezone    27 from imiptools.filesys import fix_permissions, FileBase    28 from imiptools.period import FreeBusyPeriod    29 from os.path import exists, isfile, join    30 from os import listdir, remove, rmdir    31 from time import sleep    32 import codecs    33     34 class FileStore(FileBase):    35     36     "A file store of tabular free/busy data and objects."    37     38     def __init__(self, store_dir=None):    39         FileBase.__init__(self, store_dir or STORE_DIR)    40     41     def acquire_lock(self, user, timeout=None):    42         FileBase.acquire_lock(self, timeout, user)    43     44     def release_lock(self, user):    45         FileBase.release_lock(self, user)    46     47     # Utility methods.    48     49     def _set_defaults(self, t, empty_defaults):    50         for i, default in empty_defaults:    51             if i >= len(t):    52                 t += [None] * (i - len(t) + 1)    53             if not t[i]:    54                 t[i] = default    55         return t    56     57     def _get_table(self, user, filename, empty_defaults=None):    58     59         """    60         From the file for the given 'user' having the given 'filename', return    61         a list of tuples representing the file's contents.    62     63         The 'empty_defaults' is a list of (index, value) tuples indicating the    64         default value where a column either does not exist or provides an empty    65         value.    66         """    67     68         f = codecs.open(filename, "rb", encoding="utf-8")    69         try:    70             l = []    71             for line in f.readlines():    72                 t = line.strip(" \r\n").split("\t")    73                 if empty_defaults:    74                     t = self._set_defaults(t, empty_defaults)    75                 l.append(tuple(t))    76             return l    77         finally:    78             f.close()    79     80     def _get_table_atomic(self, user, filename, empty_defaults=None):    81     82         """    83         From the file for the given 'user' having the given 'filename', return    84         a list of tuples representing the file's contents.    85     86         The 'empty_defaults' is a list of (index, value) tuples indicating the    87         default value where a column either does not exist or provides an empty    88         value.    89         """    90     91         self.acquire_lock(user)    92         try:    93             return self._get_table(user, filename, empty_defaults)    94         finally:    95             self.release_lock(user)    96     97     def _set_table(self, user, filename, items, empty_defaults=None):    98     99         """   100         For the given 'user', write to the file having the given 'filename' the   101         'items'.   102    103         The 'empty_defaults' is a list of (index, value) tuples indicating the   104         default value where a column either does not exist or provides an empty   105         value.   106         """   107    108         f = codecs.open(filename, "wb", encoding="utf-8")   109         try:   110             for item in items:   111                 self._set_table_item(f, item, empty_defaults)   112         finally:   113             f.close()   114             fix_permissions(filename)   115    116     def _set_table_item(self, f, item, empty_defaults=None):   117    118         "Set in table 'f' the given 'item', using any 'empty_defaults'."   119    120         if empty_defaults:   121             item = self._set_defaults(list(item), empty_defaults)   122         f.write("\t".join(item) + "\n")   123    124     def _set_table_atomic(self, user, filename, items, empty_defaults=None):   125    126         """   127         For the given 'user', write to the file having the given 'filename' the   128         'items'.   129    130         The 'empty_defaults' is a list of (index, value) tuples indicating the   131         default value where a column either does not exist or provides an empty   132         value.   133         """   134    135         self.acquire_lock(user)   136         try:   137             self._set_table(user, filename, items, empty_defaults)   138         finally:   139             self.release_lock(user)   140    141     # Store object access.   142    143     def _get_object(self, user, filename):   144    145         """   146         Return the parsed object for the given 'user' having the given   147         'filename'.   148         """   149    150         self.acquire_lock(user)   151         try:   152             f = open(filename, "rb")   153             try:   154                 return parse_object(f, "utf-8")   155             finally:   156                 f.close()   157         finally:   158             self.release_lock(user)   159    160     def _set_object(self, user, filename, node):   161    162         """   163         Set an object for the given 'user' having the given 'filename', using   164         'node' to define the object.   165         """   166    167         self.acquire_lock(user)   168         try:   169             f = open(filename, "wb")   170             try:   171                 to_stream(f, node)   172             finally:   173                 f.close()   174                 fix_permissions(filename)   175         finally:   176             self.release_lock(user)   177    178         return True   179    180     def _remove_object(self, filename):   181    182         "Remove the object with the given 'filename'."   183    184         try:   185             remove(filename)   186         except OSError:   187             return False   188    189         return True   190    191     def _remove_collection(self, filename):   192    193         "Remove the collection with the given 'filename'."   194    195         try:   196             rmdir(filename)   197         except OSError:   198             return False   199    200         return True   201    202     # User discovery.   203    204     def get_users(self):   205    206         "Return a list of users."   207    208         return listdir(self.store_dir)   209    210     # Event and event metadata access.   211    212     def get_events(self, user):   213    214         "Return a list of event identifiers."   215    216         filename = self.get_object_in_store(user, "objects")   217         if not filename or not exists(filename):   218             return None   219    220         return [name for name in listdir(filename) if isfile(join(filename, name))]   221    222     def get_all_events(self, user):   223    224         "Return a set of (uid, recurrenceid) tuples for all events."   225    226         uids = self.get_events(user)   227         if not uids:   228             return set()   229    230         all_events = set()   231         for uid in uids:   232             all_events.add((uid, None))   233             all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)])   234    235         return all_events   236    237     def get_event_filename(self, user, uid, recurrenceid=None, dirname=None):   238    239         """   240         Get the filename providing the event for the given 'user' with the given   241         'uid'. If the optional 'recurrenceid' is specified, a specific instance   242         or occurrence of an event is returned.   243    244         Where 'dirname' is specified, the given directory name is used as the   245         base of the location within which any filename will reside.   246         """   247    248         if recurrenceid:   249             return self.get_recurrence_filename(user, uid, recurrenceid, dirname)   250         else:   251             return self.get_complete_event_filename(user, uid, dirname)   252    253     def get_event(self, user, uid, recurrenceid=None):   254    255         """   256         Get the event for the given 'user' with the given 'uid'. If   257         the optional 'recurrenceid' is specified, a specific instance or   258         occurrence of an event is returned.   259         """   260    261         filename = self.get_event_filename(user, uid, recurrenceid)   262         if not filename or not exists(filename):   263             return None   264    265         return filename and self._get_object(user, filename)   266    267     def get_complete_event_filename(self, user, uid, dirname=None):   268    269         """   270         Get the filename providing the event for the given 'user' with the given   271         'uid'.    272    273         Where 'dirname' is specified, the given directory name is used as the   274         base of the location within which any filename will reside.   275         """   276    277         return self.get_object_in_store(user, dirname, "objects", uid)   278    279     def get_complete_event(self, user, uid):   280    281         "Get the event for the given 'user' with the given 'uid'."   282    283         filename = self.get_complete_event_filename(user, uid)   284         if not filename or not exists(filename):   285             return None   286    287         return filename and self._get_object(user, filename)   288    289     def set_event(self, user, uid, recurrenceid, node):   290    291         """   292         Set an event for 'user' having the given 'uid' and 'recurrenceid' (which   293         if the latter is specified, a specific instance or occurrence of an   294         event is referenced), using the given 'node' description.   295         """   296    297         if recurrenceid:   298             return self.set_recurrence(user, uid, recurrenceid, node)   299         else:   300             return self.set_complete_event(user, uid, node)   301    302     def set_complete_event(self, user, uid, node):   303    304         "Set an event for 'user' having the given 'uid' and 'node'."   305    306         filename = self.get_object_in_store(user, "objects", uid)   307         if not filename:   308             return False   309    310         return self._set_object(user, filename, node)   311    312     def remove_event(self, user, uid, recurrenceid=None):   313    314         """   315         Remove an event for 'user' having the given 'uid'. If the optional   316         'recurrenceid' is specified, a specific instance or occurrence of an   317         event is removed.   318         """   319    320         if recurrenceid:   321             return self.remove_recurrence(user, uid, recurrenceid)   322         else:   323             for recurrenceid in self.get_recurrences(user, uid) or []:   324                 self.remove_recurrence(user, uid, recurrenceid)   325             return self.remove_complete_event(user, uid)   326    327     def remove_complete_event(self, user, uid):   328    329         "Remove an event for 'user' having the given 'uid'."   330    331         self.remove_recurrences(user, uid)   332    333         filename = self.get_object_in_store(user, "objects", uid)   334         if not filename:   335             return False   336    337         return self._remove_object(filename)   338    339     def get_recurrences(self, user, uid):   340    341         """   342         Get additional event instances for an event of the given 'user' with the   343         indicated 'uid'. Both active and cancelled recurrences are returned.   344         """   345    346         return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid)   347    348     def get_active_recurrences(self, user, uid):   349    350         """   351         Get additional event instances for an event of the given 'user' with the   352         indicated 'uid'. Cancelled recurrences are not returned.   353         """   354    355         filename = self.get_object_in_store(user, "recurrences", uid)   356         if not filename or not exists(filename):   357             return []   358    359         return [name for name in listdir(filename) if isfile(join(filename, name))]   360    361     def get_cancelled_recurrences(self, user, uid):   362    363         """   364         Get additional event instances for an event of the given 'user' with the   365         indicated 'uid'. Only cancelled recurrences are returned.   366         """   367    368         filename = self.get_object_in_store(user, "cancelled", "recurrences", uid)   369         if not filename or not exists(filename):   370             return []   371    372         return [name for name in listdir(filename) if isfile(join(filename, name))]   373    374     def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None):   375    376         """   377         For the event of the given 'user' with the given 'uid', return the   378         filename providing the recurrence with the given 'recurrenceid'.   379    380         Where 'dirname' is specified, the given directory name is used as the   381         base of the location within which any filename will reside.   382         """   383    384         return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid)   385    386     def get_recurrence(self, user, uid, recurrenceid):   387    388         """   389         For the event of the given 'user' with the given 'uid', return the   390         specific recurrence indicated by the 'recurrenceid'.   391         """   392    393         filename = self.get_recurrence_filename(user, uid, recurrenceid)   394         if not filename or not exists(filename):   395             return None   396    397         return filename and self._get_object(user, filename)   398    399     def set_recurrence(self, user, uid, recurrenceid, node):   400    401         "Set an event for 'user' having the given 'uid' and 'node'."   402    403         filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)   404         if not filename:   405             return False   406    407         return self._set_object(user, filename, node)   408    409     def remove_recurrence(self, user, uid, recurrenceid):   410    411         """   412         Remove a special recurrence from an event stored by 'user' having the   413         given 'uid' and 'recurrenceid'.   414         """   415    416         filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid)   417         if not filename:   418             return False   419    420         return self._remove_object(filename)   421    422     def remove_recurrences(self, user, uid):   423    424         """   425         Remove all recurrences for an event stored by 'user' having the given   426         'uid'.   427         """   428    429         for recurrenceid in self.get_recurrences(user, uid):   430             self.remove_recurrence(user, uid, recurrenceid)   431    432         recurrences = self.get_object_in_store(user, "recurrences", uid)   433         if recurrences:   434             return self._remove_collection(recurrences)   435    436         return True   437    438     # Free/busy period providers, upon extension of the free/busy records.   439    440     def _get_freebusy_providers(self, user):   441    442         """   443         Return the free/busy providers for the given 'user'.   444    445         This function returns any stored datetime and a list of providers as a   446         2-tuple. Each provider is itself a (uid, recurrenceid) tuple.   447         """   448    449         filename = self.get_object_in_store(user, "freebusy-providers")   450         if not filename or not exists(filename):   451             return None   452    453         # Attempt to read providers, with a declaration of the datetime   454         # from which such providers are considered as still being active.   455    456         t = self._get_table_atomic(user, filename, [(1, None)])   457         try:   458             dt_string = t[0][0]   459         except IndexError:   460             return None   461    462         return dt_string, t[1:]   463    464     def get_freebusy_providers(self, user, dt=None):   465    466         """   467         Return a set of uncancelled events of the form (uid, recurrenceid)   468         providing free/busy details beyond the given datetime 'dt'.   469    470         If 'dt' is not specified, all events previously found to provide   471         details will be returned. Otherwise, if 'dt' is earlier than the   472         datetime recorded for the known providers, None is returned, indicating   473         that the list of providers must be recomputed.   474    475         This function returns a list of (uid, recurrenceid) tuples upon success.   476         """   477    478         t = self._get_freebusy_providers(user)   479         if not t:   480             return None   481    482         dt_string, t = t   483    484         # If the requested datetime is earlier than the stated datetime, the   485         # providers will need to be recomputed.   486    487         if dt:   488             providers_dt = get_datetime(dt_string)   489             if not providers_dt or providers_dt > dt:   490                 return None   491    492         # Otherwise, return the providers.   493    494         return t[1:]   495    496     def _set_freebusy_providers(self, user, dt_string, t):   497    498         "Set the given provider timestamp 'dt_string' and table 't'."   499    500         filename = self.get_object_in_store(user, "freebusy-providers")   501         if not filename:   502             return False   503    504         t.insert(0, (dt_string,))   505         self._set_table_atomic(user, filename, t, [(1, "")])   506         return True   507    508     def set_freebusy_providers(self, user, dt, providers):   509    510         """   511         Define the uncancelled events providing free/busy details beyond the   512         given datetime 'dt'.   513         """   514    515         t = []   516    517         for obj in providers:   518             t.append((obj.get_uid(), obj.get_recurrenceid()))   519    520         return self._set_freebusy_providers(user, format_datetime(dt), t)   521    522     def append_freebusy_provider(self, user, provider):   523    524         "For the given 'user', append the free/busy 'provider'."   525    526         t = self._get_freebusy_providers(user)   527         if not t:   528             return False   529    530         dt_string, t = t   531         t.append((provider.get_uid(), provider.get_recurrenceid()))   532    533         return self._set_freebusy_providers(user, dt_string, t)   534    535     def remove_freebusy_provider(self, user, provider):   536    537         "For the given 'user', remove the free/busy 'provider'."   538    539         t = self._get_freebusy_providers(user)   540         if not t:   541             return False   542    543         dt_string, t = t   544         try:   545             t.remove((provider.get_uid(), provider.get_recurrenceid()))   546         except ValueError:   547             return False   548    549         return self._set_freebusy_providers(user, dt_string, t)   550    551     # Free/busy period access.   552    553     def get_freebusy(self, user, name=None, get_table=None):   554    555         "Get free/busy details for the given 'user'."   556    557         filename = self.get_object_in_store(user, name or "freebusy")   558         if not filename or not exists(filename):   559             return []   560         else:   561             return map(lambda t: FreeBusyPeriod(*t),   562                 (get_table or self._get_table_atomic)(user, filename, [(4, None)]))   563    564     def get_freebusy_for_other(self, user, other, get_table=None):   565    566         "For the given 'user', get free/busy details for the 'other' user."   567    568         filename = self.get_object_in_store(user, "freebusy-other", other)   569         if not filename or not exists(filename):   570             return []   571         else:   572             return map(lambda t: FreeBusyPeriod(*t),   573                 (get_table or self._get_table_atomic)(user, filename, [(4, None)]))   574    575     def set_freebusy(self, user, freebusy, name=None, set_table=None):   576    577         "For the given 'user', set 'freebusy' details."   578    579         filename = self.get_object_in_store(user, name or "freebusy")   580         if not filename:   581             return False   582    583         (set_table or self._set_table_atomic)(user, filename,   584             map(lambda fb: fb.as_tuple(strings_only=True), freebusy))   585         return True   586    587     def set_freebusy_for_other(self, user, freebusy, other, set_table=None):   588    589         "For the given 'user', set 'freebusy' details for the 'other' user."   590    591         filename = self.get_object_in_store(user, "freebusy-other", other)   592         if not filename:   593             return False   594    595         (set_table or self._set_table_atomic)(user, filename,   596             map(lambda fb: fb.as_tuple(strings_only=True), freebusy))   597         return True   598    599     # Tentative free/busy periods related to countering.   600    601     def get_freebusy_offers(self, user):   602    603         "Get free/busy offers for the given 'user'."   604    605         offers = []   606         expired = []   607         now = to_timezone(datetime.utcnow(), "UTC")   608    609         # Expire old offers and save the collection if modified.   610    611         self.acquire_lock(user)   612         try:   613             l = self.get_freebusy(user, "freebusy-offers")   614             for fb in l:   615                 if fb.expires and get_datetime(fb.expires) <= now:   616                     expired.append(fb)   617                 else:   618                     offers.append(fb)   619    620             if expired:   621                 self.set_freebusy_offers(user, offers)   622         finally:   623             self.release_lock(user)   624    625         return offers   626    627     def set_freebusy_offers(self, user, freebusy):   628    629         "For the given 'user', set 'freebusy' offers."   630    631         return self.set_freebusy(user, freebusy, "freebusy-offers")   632    633     # Requests and counter-proposals.   634    635     def _get_requests(self, user, queue):   636    637         "Get requests for the given 'user' from the given 'queue'."   638    639         filename = self.get_object_in_store(user, queue)   640         if not filename or not exists(filename):   641             return None   642    643         return self._get_table_atomic(user, filename, [(1, None), (2, None)])   644    645     def get_requests(self, user):   646    647         "Get requests for the given 'user'."   648    649         return self._get_requests(user, "requests")   650    651     def _set_requests(self, user, requests, queue):   652    653         """   654         For the given 'user', set the list of queued 'requests' in the given   655         'queue'.   656         """   657    658         filename = self.get_object_in_store(user, queue)   659         if not filename:   660             return False   661    662         self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")])   663         return True   664    665     def set_requests(self, user, requests):   666    667         "For the given 'user', set the list of queued 'requests'."   668    669         return self._set_requests(user, requests, "requests")   670    671     def _set_request(self, user, request, queue):   672    673         """   674         For the given 'user', set the given 'request' in the given 'queue'.   675         """   676    677         filename = self.get_object_in_store(user, queue)   678         if not filename:   679             return False   680    681         self.acquire_lock(user)   682         try:   683             f = codecs.open(filename, "ab", encoding="utf-8")   684             try:   685                 self._set_table_item(f, request, [(1, ""), (2, "")])   686             finally:   687                 f.close()   688                 fix_permissions(filename)   689         finally:   690             self.release_lock(user)   691    692         return True   693    694     def set_request(self, user, uid, recurrenceid=None, type=None):   695    696         """   697         For the given 'user', set the queued 'uid' and 'recurrenceid',   698         indicating a request, along with any given 'type'.   699         """   700    701         return self._set_request(user, (uid, recurrenceid, type), "requests")   702    703     def queue_request(self, user, uid, recurrenceid=None, type=None):   704    705         """   706         Queue a request for 'user' having the given 'uid'. If the optional   707         'recurrenceid' is specified, the entry refers to a specific instance   708         or occurrence of an event. The 'type' parameter can be used to indicate   709         a specific type of request.   710         """   711    712         requests = self.get_requests(user) or []   713    714         if not self.have_request(requests, uid, recurrenceid):   715             return self.set_request(user, uid, recurrenceid, type)   716    717         return False   718    719     def dequeue_request(self, user, uid, recurrenceid=None, type=None):   720    721         """   722         Dequeue all requests for 'user' having the given 'uid'. If the optional   723         'recurrenceid' is specified, all requests for that specific instance or   724         occurrence of an event are dequeued.   725         """   726    727         requests = self.get_requests(user) or []   728    729         if not self.have_request(requests, uid, recurrenceid):   730             return False   731    732         i = bisect_left(requests, (uid, recurrenceid))   733         while i < len(requests) and requests[i][:2] == (uid, recurrenceid):   734    735             # Remove associated objects.   736    737             type = requests[i][2]   738             if type == "COUNTER":   739                 self.remove_counter(user, uid, recurrenceid)   740    741             # Remove the request.   742    743             del requests[i]   744             i += 1   745    746         self.set_requests(user, requests)   747         return True   748    749     def have_request(self, requests, uid, recurrenceid=None):   750         i = bisect_left(requests, (uid, recurrenceid))   751         return i < len(requests) and requests[i][:2] == (uid, recurrenceid)   752    753     def set_counter(self, user, node, uid, recurrenceid=None):   754    755         """   756         For the given 'user', store the given 'node' for the given 'uid' and   757         'recurrenceid' as a counter-proposal.   758         """   759    760         filename = self.get_event_filename(user, uid, recurrenceid, "counters")   761         if not filename:   762             return False   763    764         return self._set_object(user, filename, node)   765    766     def remove_counter(self, user, uid, recurrenceid=None):   767    768         """   769         For the given 'user', remove any counter-proposal associated with the   770         given 'uid' and 'recurrenceid'.   771         """   772    773         filename = self.get_event_filename(user, uid, recurrenceid, "counters")   774         if not filename:   775             return False   776    777         return self._remove_object(filename)   778    779     # Event cancellation.   780    781     def cancel_event(self, user, uid, recurrenceid=None):   782    783         """   784         Cancel an event for 'user' having the given 'uid'. If the optional   785         'recurrenceid' is specified, a specific instance or occurrence of an   786         event is cancelled.   787         """   788    789         filename = self.get_event_filename(user, uid, recurrenceid)   790         cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations")   791    792         if filename and cancelled_filename and exists(filename):   793             return self.move_object(filename, cancelled_filename)   794    795         return False   796    797 class FilePublisher(FileBase):   798    799     "A publisher of objects."   800    801     def __init__(self, store_dir=None):   802         FileBase.__init__(self, store_dir or PUBLISH_DIR)   803    804     def set_freebusy(self, user, freebusy):   805    806         "For the given 'user', set 'freebusy' details."   807    808         filename = self.get_object_in_store(user, "freebusy")   809         if not filename:   810             return False   811    812         record = []   813         rwrite = record.append   814    815         rwrite(("ORGANIZER", {}, user))   816         rwrite(("UID", {}, user))   817         rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")))   818    819         for fb in freebusy:   820             if not fb.transp or fb.transp == "OPAQUE":   821                 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(   822                     map(format_datetime, [fb.get_start_point(), fb.get_end_point()]))))   823    824         f = open(filename, "wb")   825         try:   826             to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH"))   827         finally:   828             f.close()   829             fix_permissions(filename)   830    831         return True   832    833 # vim: tabstop=4 expandtab shiftwidth=4