imip-agent

imip_store.py

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