imip-agent

imip_store.py

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