# HG changeset patch # User Paul Boddie # Date 1457221482 -3600 # Node ID 37921ab84c01296d3b04e4e3e4eafd5728ed6023 # Parent f4bf00639b35eb7ffd9c4f7ba30b40d1bdce4dc9 Moved imip_store into a new imiptools.stores package as the file module. diff -r f4bf00639b35 -r 37921ab84c01 docs/wiki/Developing --- a/docs/wiki/Developing Sun Mar 06 00:11:29 2016 +0100 +++ b/docs/wiki/Developing Sun Mar 06 00:44:42 2016 +0100 @@ -61,10 +61,6 @@ `imip_manager.py` || The [[../CalendarManager|management interface]] main program file == -`imip_store.py` -|| The Python module providing an abstraction over the -.. [[../FilesystemUsage|data storage structures]] -== `markup.py` || A Python library providing HTML generation support == @@ -112,6 +108,22 @@ system client, typically handling a single calendar object at any given time. +The `imiptools.stores` package provides the basis for calendar data +persistence. From the very start, the nature of data organisation for +calendar users was centred on the storage of each user's free/busy records, +since the priority was to generate such records from messages exchanged +over e-mail, and the use of plain text files was chosen as the simplest +and most transparent approach. Beyond this, the need to retain calendar +objects arose, and thus a [[../FilesystemUsage|filesystem-based approach]] +was cultivated to manage such data. + +In the future, other persistence mechanisms could be supported. However, +aside from performance concerns around access to free/busy schedules, +there may be no urgent need to adopt relational database technologies, +particularly as each user's data should remain isolated from that of +other users, and thus the volumes of data should remain relatively small +if managed well enough. + === imipweb === Most of the `imipweb` package is concerned with the display of calendar @@ -138,24 +150,6 @@ Web page, interpreted by the program, written back to the form, all without losing information. -=== imip_store === - -The `imip_store` module provides the basis for calendar data persistence. -From the very start, the nature of data organisation for calendar users -was centred on the storage of each user's free/busy records, since the -priority was to generate such records from messages exchanged over e-mail, -and the use of plain text files was chosen as the simplest and most -transparent approach. Beyond this, the need to retain calendar objects -arose, and thus a [[../FilesystemUsage|filesystem-based approach]] was -cultivated to manage such data. - -In the future, other persistence mechanisms could be supported. However, -aside from performance concerns around access to free/busy schedules, -there may be no urgent need to adopt relational database technologies, -particularly as each user's data should remain isolated from that of -other users, and thus the volumes of data should remain relatively small -if managed well enough. - == Localisation == The traditional [[https://www.gnu.org/s/gettext|gettext]] mechanisms for diff -r f4bf00639b35 -r 37921ab84c01 imip_store.py --- a/imip_store.py Sun Mar 06 00:11:29 2016 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1076 +0,0 @@ -#!/usr/bin/env python - -""" -A simple filesystem-based store of calendar data. - -Copyright (C) 2014, 2015, 2016 Paul Boddie - -This program is free software; you can redistribute it and/or modify it under -the terms of the GNU General Public License as published by the Free Software -Foundation; either version 3 of the License, or (at your option) any later -version. - -This program is distributed in the hope that it will be useful, but WITHOUT -ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS -FOR A PARTICULAR PURPOSE. See the GNU General Public License for more -details. - -You should have received a copy of the GNU General Public License along with -this program. If not, see . -""" - -from datetime import datetime -from imiptools.config import STORE_DIR, PUBLISH_DIR, JOURNAL_DIR -from imiptools.data import make_calendar, parse_object, to_stream -from imiptools.dates import format_datetime, get_datetime, to_timezone -from imiptools.filesys import fix_permissions, FileBase -from imiptools.period import FreeBusyPeriod -from imiptools.text import parse_line -from os.path import isdir, isfile, join -from os import listdir, remove, rmdir -import codecs - -class FileStoreBase(FileBase): - - "A file store supporting user-specific locking and tabular data." - - def acquire_lock(self, user, timeout=None): - FileBase.acquire_lock(self, timeout, user) - - def release_lock(self, user): - FileBase.release_lock(self, user) - - # Utility methods. - - def _set_defaults(self, t, empty_defaults): - for i, default in empty_defaults: - if i >= len(t): - t += [None] * (i - len(t) + 1) - if not t[i]: - t[i] = default - return t - - def _get_table(self, user, filename, empty_defaults=None, tab_separated=True): - - """ - From the file for the given 'user' having the given 'filename', return - a list of tuples representing the file's contents. - - The 'empty_defaults' is a list of (index, value) tuples indicating the - default value where a column either does not exist or provides an empty - value. - - If 'tab_separated' is specified and is a false value, line parsing using - the imiptools.text.parse_line function will be performed instead of - splitting each line of the file using tab characters as separators. - """ - - f = codecs.open(filename, "rb", encoding="utf-8") - try: - l = [] - for line in f.readlines(): - line = line.strip(" \r\n") - if tab_separated: - t = line.split("\t") - else: - t = parse_line(line) - if empty_defaults: - t = self._set_defaults(t, empty_defaults) - l.append(tuple(t)) - return l - finally: - f.close() - - def _get_table_atomic(self, user, filename, empty_defaults=None, tab_separated=True): - - """ - From the file for the given 'user' having the given 'filename', return - a list of tuples representing the file's contents. - - The 'empty_defaults' is a list of (index, value) tuples indicating the - default value where a column either does not exist or provides an empty - value. - - If 'tab_separated' is specified and is a false value, line parsing using - the imiptools.text.parse_line function will be performed instead of - splitting each line of the file using tab characters as separators. - """ - - self.acquire_lock(user) - try: - return self._get_table(user, filename, empty_defaults, tab_separated) - finally: - self.release_lock(user) - - def _set_table(self, user, filename, items, empty_defaults=None): - - """ - For the given 'user', write to the file having the given 'filename' the - 'items'. - - The 'empty_defaults' is a list of (index, value) tuples indicating the - default value where a column either does not exist or provides an empty - value. - """ - - f = codecs.open(filename, "wb", encoding="utf-8") - try: - for item in items: - self._set_table_item(f, item, empty_defaults) - finally: - f.close() - fix_permissions(filename) - - def _set_table_item(self, f, item, empty_defaults=None): - - "Set in table 'f' the given 'item', using any 'empty_defaults'." - - if empty_defaults: - item = self._set_defaults(list(item), empty_defaults) - f.write("\t".join(item) + "\n") - - def _set_table_atomic(self, user, filename, items, empty_defaults=None): - - """ - For the given 'user', write to the file having the given 'filename' the - 'items'. - - The 'empty_defaults' is a list of (index, value) tuples indicating the - default value where a column either does not exist or provides an empty - value. - """ - - self.acquire_lock(user) - try: - self._set_table(user, filename, items, empty_defaults) - finally: - self.release_lock(user) - -class FileStore(FileStoreBase): - - "A file store of tabular free/busy data and objects." - - def __init__(self, store_dir=None): - FileBase.__init__(self, store_dir or STORE_DIR) - - # Store object access. - - def _get_object(self, user, filename): - - """ - Return the parsed object for the given 'user' having the given - 'filename'. - """ - - self.acquire_lock(user) - try: - f = open(filename, "rb") - try: - return parse_object(f, "utf-8") - finally: - f.close() - finally: - self.release_lock(user) - - def _set_object(self, user, filename, node): - - """ - Set an object for the given 'user' having the given 'filename', using - 'node' to define the object. - """ - - self.acquire_lock(user) - try: - f = open(filename, "wb") - try: - to_stream(f, node) - finally: - f.close() - fix_permissions(filename) - finally: - self.release_lock(user) - - return True - - def _remove_object(self, filename): - - "Remove the object with the given 'filename'." - - try: - remove(filename) - except OSError: - return False - - return True - - def _remove_collection(self, filename): - - "Remove the collection with the given 'filename'." - - try: - rmdir(filename) - except OSError: - return False - - return True - - # User discovery. - - def get_users(self): - - "Return a list of users." - - return listdir(self.store_dir) - - # Event and event metadata access. - - def get_events(self, user): - - "Return a list of event identifiers." - - filename = self.get_object_in_store(user, "objects") - if not filename or not isdir(filename): - return None - - return [name for name in listdir(filename) if isfile(join(filename, name))] - - def get_all_events(self, user): - - "Return a set of (uid, recurrenceid) tuples for all events." - - uids = self.get_events(user) - if not uids: - return set() - - all_events = set() - for uid in uids: - all_events.add((uid, None)) - all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)]) - - return all_events - - def get_event_filename(self, user, uid, recurrenceid=None, dirname=None, username=None): - - """ - Get the filename providing the event for the given 'user' with the given - 'uid'. If the optional 'recurrenceid' is specified, a specific instance - or occurrence of an event is returned. - - Where 'dirname' is specified, the given directory name is used as the - base of the location within which any filename will reside. - """ - - if recurrenceid: - return self.get_recurrence_filename(user, uid, recurrenceid, dirname, username) - else: - return self.get_complete_event_filename(user, uid, dirname, username) - - def get_event(self, user, uid, recurrenceid=None, dirname=None): - - """ - Get the event for the given 'user' with the given 'uid'. If - the optional 'recurrenceid' is specified, a specific instance or - occurrence of an event is returned. - """ - - filename = self.get_event_filename(user, uid, recurrenceid, dirname) - if not filename or not isfile(filename): - return None - - return filename and self._get_object(user, filename) - - def get_complete_event_filename(self, user, uid, dirname=None, username=None): - - """ - Get the filename providing the event for the given 'user' with the given - 'uid'. - - Where 'dirname' is specified, the given directory name is used as the - base of the location within which any filename will reside. - - Where 'username' is specified, the event details will reside in a file - bearing that name within a directory having 'uid' as its name. - """ - - return self.get_object_in_store(user, dirname, "objects", uid, username) - - def get_complete_event(self, user, uid): - - "Get the event for the given 'user' with the given 'uid'." - - filename = self.get_complete_event_filename(user, uid) - if not filename or not isfile(filename): - return None - - return filename and self._get_object(user, filename) - - def set_event(self, user, uid, recurrenceid, node): - - """ - Set an event for 'user' having the given 'uid' and 'recurrenceid' (which - if the latter is specified, a specific instance or occurrence of an - event is referenced), using the given 'node' description. - """ - - if recurrenceid: - return self.set_recurrence(user, uid, recurrenceid, node) - else: - return self.set_complete_event(user, uid, node) - - def set_complete_event(self, user, uid, node): - - "Set an event for 'user' having the given 'uid' and 'node'." - - filename = self.get_object_in_store(user, "objects", uid) - if not filename: - return False - - return self._set_object(user, filename, node) - - def remove_event(self, user, uid, recurrenceid=None): - - """ - Remove an event for 'user' having the given 'uid'. If the optional - 'recurrenceid' is specified, a specific instance or occurrence of an - event is removed. - """ - - if recurrenceid: - return self.remove_recurrence(user, uid, recurrenceid) - else: - for recurrenceid in self.get_recurrences(user, uid) or []: - self.remove_recurrence(user, uid, recurrenceid) - return self.remove_complete_event(user, uid) - - def remove_complete_event(self, user, uid): - - "Remove an event for 'user' having the given 'uid'." - - self.remove_recurrences(user, uid) - return self.remove_parent_event(user, uid) - - def remove_parent_event(self, user, uid): - - "Remove the parent event for 'user' having the given 'uid'." - - filename = self.get_object_in_store(user, "objects", uid) - if not filename: - return False - - return self._remove_object(filename) - - def get_recurrences(self, user, uid): - - """ - Get additional event instances for an event of the given 'user' with the - indicated 'uid'. Both active and cancelled recurrences are returned. - """ - - return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) - - def get_active_recurrences(self, user, uid): - - """ - Get additional event instances for an event of the given 'user' with the - indicated 'uid'. Cancelled recurrences are not returned. - """ - - filename = self.get_object_in_store(user, "recurrences", uid) - if not filename or not isdir(filename): - return [] - - return [name for name in listdir(filename) if isfile(join(filename, name))] - - def get_cancelled_recurrences(self, user, uid): - - """ - Get additional event instances for an event of the given 'user' with the - indicated 'uid'. Only cancelled recurrences are returned. - """ - - filename = self.get_object_in_store(user, "cancellations", "recurrences", uid) - if not filename or not isdir(filename): - return [] - - return [name for name in listdir(filename) if isfile(join(filename, name))] - - def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None, username=None): - - """ - For the event of the given 'user' with the given 'uid', return the - filename providing the recurrence with the given 'recurrenceid'. - - Where 'dirname' is specified, the given directory name is used as the - base of the location within which any filename will reside. - - Where 'username' is specified, the event details will reside in a file - bearing that name within a directory having 'uid' as its name. - """ - - return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid, username) - - def get_recurrence(self, user, uid, recurrenceid): - - """ - For the event of the given 'user' with the given 'uid', return the - specific recurrence indicated by the 'recurrenceid'. - """ - - filename = self.get_recurrence_filename(user, uid, recurrenceid) - if not filename or not isfile(filename): - return None - - return filename and self._get_object(user, filename) - - def set_recurrence(self, user, uid, recurrenceid, node): - - "Set an event for 'user' having the given 'uid' and 'node'." - - filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) - if not filename: - return False - - return self._set_object(user, filename, node) - - def remove_recurrence(self, user, uid, recurrenceid): - - """ - Remove a special recurrence from an event stored by 'user' having the - given 'uid' and 'recurrenceid'. - """ - - filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) - if not filename: - return False - - return self._remove_object(filename) - - def remove_recurrences(self, user, uid): - - """ - Remove all recurrences for an event stored by 'user' having the given - 'uid'. - """ - - for recurrenceid in self.get_recurrences(user, uid): - self.remove_recurrence(user, uid, recurrenceid) - - return self.remove_recurrence_collection(user, uid) - - def remove_recurrence_collection(self, user, uid): - - """ - Remove the collection of recurrences stored by 'user' having the given - 'uid'. - """ - - recurrences = self.get_object_in_store(user, "recurrences", uid) - if recurrences: - return self._remove_collection(recurrences) - - return True - - # Free/busy period providers, upon extension of the free/busy records. - - def _get_freebusy_providers(self, user): - - """ - Return the free/busy providers for the given 'user'. - - This function returns any stored datetime and a list of providers as a - 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. - """ - - filename = self.get_object_in_store(user, "freebusy-providers") - if not filename or not isfile(filename): - return None - - # Attempt to read providers, with a declaration of the datetime - # from which such providers are considered as still being active. - - t = self._get_table_atomic(user, filename, [(1, None)]) - try: - dt_string = t[0][0] - except IndexError: - return None - - return dt_string, t[1:] - - def get_freebusy_providers(self, user, dt=None): - - """ - Return a set of uncancelled events of the form (uid, recurrenceid) - providing free/busy details beyond the given datetime 'dt'. - - If 'dt' is not specified, all events previously found to provide - details will be returned. Otherwise, if 'dt' is earlier than the - datetime recorded for the known providers, None is returned, indicating - that the list of providers must be recomputed. - - This function returns a list of (uid, recurrenceid) tuples upon success. - """ - - t = self._get_freebusy_providers(user) - if not t: - return None - - dt_string, t = t - - # If the requested datetime is earlier than the stated datetime, the - # providers will need to be recomputed. - - if dt: - providers_dt = get_datetime(dt_string) - if not providers_dt or providers_dt > dt: - return None - - # Otherwise, return the providers. - - return t[1:] - - def _set_freebusy_providers(self, user, dt_string, t): - - "Set the given provider timestamp 'dt_string' and table 't'." - - filename = self.get_object_in_store(user, "freebusy-providers") - if not filename: - return False - - t.insert(0, (dt_string,)) - self._set_table_atomic(user, filename, t, [(1, "")]) - return True - - def set_freebusy_providers(self, user, dt, providers): - - """ - Define the uncancelled events providing free/busy details beyond the - given datetime 'dt'. - """ - - t = [] - - for obj in providers: - t.append((obj.get_uid(), obj.get_recurrenceid())) - - return self._set_freebusy_providers(user, format_datetime(dt), t) - - def append_freebusy_provider(self, user, provider): - - "For the given 'user', append the free/busy 'provider'." - - t = self._get_freebusy_providers(user) - if not t: - return False - - dt_string, t = t - t.append((provider.get_uid(), provider.get_recurrenceid())) - - return self._set_freebusy_providers(user, dt_string, t) - - def remove_freebusy_provider(self, user, provider): - - "For the given 'user', remove the free/busy 'provider'." - - t = self._get_freebusy_providers(user) - if not t: - return False - - dt_string, t = t - try: - t.remove((provider.get_uid(), provider.get_recurrenceid())) - except ValueError: - return False - - return self._set_freebusy_providers(user, dt_string, t) - - # Free/busy period access. - - def get_freebusy(self, user, name=None): - - "Get free/busy details for the given 'user'." - - filename = self.get_object_in_store(user, name or "freebusy") - if not filename or not isfile(filename): - return [] - else: - return map(lambda t: FreeBusyPeriod(*t), - self._get_table_atomic(user, filename)) - - def get_freebusy_for_other(self, user, other): - - "For the given 'user', get free/busy details for the 'other' user." - - filename = self.get_object_in_store(user, "freebusy-other", other) - if not filename or not isfile(filename): - return [] - else: - return map(lambda t: FreeBusyPeriod(*t), - self._get_table_atomic(user, filename)) - - def set_freebusy(self, user, freebusy, name=None): - - "For the given 'user', set 'freebusy' details." - - filename = self.get_object_in_store(user, name or "freebusy") - if not filename: - return False - - self._set_table_atomic(user, filename, - map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) - return True - - def set_freebusy_for_other(self, user, freebusy, other): - - "For the given 'user', set 'freebusy' details for the 'other' user." - - filename = self.get_object_in_store(user, "freebusy-other", other) - if not filename: - return False - - self._set_table_atomic(user, filename, - map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) - return True - - # Tentative free/busy periods related to countering. - - def get_freebusy_offers(self, user): - - "Get free/busy offers for the given 'user'." - - offers = [] - expired = [] - now = to_timezone(datetime.utcnow(), "UTC") - - # Expire old offers and save the collection if modified. - - self.acquire_lock(user) - try: - l = self.get_freebusy(user, "freebusy-offers") - for fb in l: - if fb.expires and get_datetime(fb.expires) <= now: - expired.append(fb) - else: - offers.append(fb) - - if expired: - self.set_freebusy_offers(user, offers) - finally: - self.release_lock(user) - - return offers - - def set_freebusy_offers(self, user, freebusy): - - "For the given 'user', set 'freebusy' offers." - - return self.set_freebusy(user, freebusy, "freebusy-offers") - - # Requests and counter-proposals. - - def _get_requests(self, user, queue): - - "Get requests for the given 'user' from the given 'queue'." - - filename = self.get_object_in_store(user, queue) - if not filename or not isfile(filename): - return None - - return self._get_table_atomic(user, filename, [(1, None), (2, None)]) - - def get_requests(self, user): - - "Get requests for the given 'user'." - - return self._get_requests(user, "requests") - - def _set_requests(self, user, requests, queue): - - """ - For the given 'user', set the list of queued 'requests' in the given - 'queue'. - """ - - filename = self.get_object_in_store(user, queue) - if not filename: - return False - - self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")]) - return True - - def set_requests(self, user, requests): - - "For the given 'user', set the list of queued 'requests'." - - return self._set_requests(user, requests, "requests") - - def _set_request(self, user, request, queue): - - """ - For the given 'user', set the given 'request' in the given 'queue'. - """ - - filename = self.get_object_in_store(user, queue) - if not filename: - return False - - self.acquire_lock(user) - try: - f = codecs.open(filename, "ab", encoding="utf-8") - try: - self._set_table_item(f, request, [(1, ""), (2, "")]) - finally: - f.close() - fix_permissions(filename) - finally: - self.release_lock(user) - - return True - - def set_request(self, user, uid, recurrenceid=None, type=None): - - """ - For the given 'user', set the queued 'uid' and 'recurrenceid', - indicating a request, along with any given 'type'. - """ - - return self._set_request(user, (uid, recurrenceid, type), "requests") - - def queue_request(self, user, uid, recurrenceid=None, type=None): - - """ - Queue a request for 'user' having the given 'uid'. If the optional - 'recurrenceid' is specified, the entry refers to a specific instance - or occurrence of an event. The 'type' parameter can be used to indicate - a specific type of request. - """ - - requests = self.get_requests(user) or [] - - if not self.have_request(requests, uid, recurrenceid): - return self.set_request(user, uid, recurrenceid, type) - - return False - - def dequeue_request(self, user, uid, recurrenceid=None): - - """ - Dequeue all requests for 'user' having the given 'uid'. If the optional - 'recurrenceid' is specified, all requests for that specific instance or - occurrence of an event are dequeued. - """ - - requests = self.get_requests(user) or [] - result = [] - - for request in requests: - if request[:2] != (uid, recurrenceid): - result.append(request) - - self.set_requests(user, result) - return True - - def has_request(self, user, uid, recurrenceid=None, type=None, strict=False): - return self.have_request(self.get_requests(user) or [], uid, recurrenceid, type, strict) - - def have_request(self, requests, uid, recurrenceid=None, type=None, strict=False): - - """ - Return whether 'requests' contains a request with the given 'uid' and - any specified 'recurrenceid' and 'type'. If 'strict' is set to a true - value, the precise type of the request must match; otherwise, any type - of request for the identified object may be matched. - """ - - for request in requests: - if request[:2] == (uid, recurrenceid) and ( - not strict or - not request[2:] and not type or - request[2:] and request[2] == type): - - return True - - return False - - def get_counters(self, user, uid, recurrenceid=None): - - """ - For the given 'user', return a list of users from whom counter-proposals - have been received for the given 'uid' and optional 'recurrenceid'. - """ - - filename = self.get_event_filename(user, uid, recurrenceid, "counters") - if not filename or not isdir(filename): - return False - - return [name for name in listdir(filename) if isfile(join(filename, name))] - - def get_counter(self, user, other, uid, recurrenceid=None): - - """ - For the given 'user', return the counter-proposal from 'other' for the - given 'uid' and optional 'recurrenceid'. - """ - - filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) - if not filename: - return False - - return self._get_object(user, filename) - - def set_counter(self, user, other, node, uid, recurrenceid=None): - - """ - For the given 'user', store a counter-proposal received from 'other' the - given 'node' representing that proposal for the given 'uid' and - 'recurrenceid'. - """ - - filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) - if not filename: - return False - - return self._set_object(user, filename, node) - - def remove_counters(self, user, uid, recurrenceid=None): - - """ - For the given 'user', remove all counter-proposals associated with the - given 'uid' and 'recurrenceid'. - """ - - filename = self.get_event_filename(user, uid, recurrenceid, "counters") - if not filename or not isdir(filename): - return False - - removed = False - - for other in listdir(filename): - counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) - removed = removed or self._remove_object(counter_filename) - - return removed - - def remove_counter(self, user, other, uid, recurrenceid=None): - - """ - For the given 'user', remove any counter-proposal from 'other' - associated with the given 'uid' and 'recurrenceid'. - """ - - filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) - if not filename or not isfile(filename): - return False - - return self._remove_object(filename) - - # Event cancellation. - - def cancel_event(self, user, uid, recurrenceid=None): - - """ - Cancel an event for 'user' having the given 'uid'. If the optional - 'recurrenceid' is specified, a specific instance or occurrence of an - event is cancelled. - """ - - filename = self.get_event_filename(user, uid, recurrenceid) - cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") - - if filename and cancelled_filename and isfile(filename): - return self.move_object(filename, cancelled_filename) - - return False - - def uncancel_event(self, user, uid, recurrenceid=None): - - """ - Uncancel an event for 'user' having the given 'uid'. If the optional - 'recurrenceid' is specified, a specific instance or occurrence of an - event is uncancelled. - """ - - filename = self.get_event_filename(user, uid, recurrenceid) - cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") - - if filename and cancelled_filename and isfile(cancelled_filename): - return self.move_object(cancelled_filename, filename) - - return False - - def remove_cancellations(self, user, uid, recurrenceid=None): - - """ - Remove cancellations for 'user' for any event having the given 'uid'. If - the optional 'recurrenceid' is specified, a specific instance or - occurrence of an event is affected. - """ - - # Remove all recurrence cancellations if a general event is indicated. - - if not recurrenceid: - for _recurrenceid in self.get_cancelled_recurrences(user, uid): - self.remove_cancellation(user, uid, _recurrenceid) - - return self.remove_cancellation(user, uid, recurrenceid) - - def remove_cancellation(self, user, uid, recurrenceid=None): - - """ - Remove a cancellation for 'user' for the event having the given 'uid'. - If the optional 'recurrenceid' is specified, a specific instance or - occurrence of an event is affected. - """ - - # Remove any parent event cancellation or a specific recurrence - # cancellation if indicated. - - filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") - - if filename and isfile(filename): - return self._remove_object(filename) - - return False - -class FilePublisher(FileBase): - - "A publisher of objects." - - def __init__(self, store_dir=None): - FileBase.__init__(self, store_dir or PUBLISH_DIR) - - def set_freebusy(self, user, freebusy): - - "For the given 'user', set 'freebusy' details." - - filename = self.get_object_in_store(user, "freebusy") - if not filename: - return False - - record = [] - rwrite = record.append - - rwrite(("ORGANIZER", {}, user)) - rwrite(("UID", {}, user)) - rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) - - for fb in freebusy: - if not fb.transp or fb.transp == "OPAQUE": - rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( - map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) - - f = open(filename, "wb") - try: - to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) - finally: - f.close() - fix_permissions(filename) - - return True - -class FileJournal(FileStoreBase): - - "A journal system to support quotas." - - def __init__(self, store_dir=None): - FileBase.__init__(self, store_dir or JOURNAL_DIR) - - # Quota and user identity/group discovery. - - def get_quotas(self): - - "Return a list of quotas." - - return listdir(self.store_dir) - - def get_quota_users(self, quota): - - "Return a list of quota users." - - filename = self.get_object_in_store(quota, "journal") - if not filename or not isdir(filename): - return [] - - return listdir(filename) - - # Groups of users sharing quotas. - - def get_groups(self, quota): - - "Return the identity mappings for the given 'quota' as a dictionary." - - filename = self.get_object_in_store(quota, "groups") - if not filename or not isfile(filename): - return {} - - return dict(self._get_table_atomic(quota, filename, tab_separated=False)) - - def get_limits(self, quota): - - """ - Return the limits for the 'quota' as a dictionary mapping identities or - groups to durations. - """ - - filename = self.get_object_in_store(quota, "limits") - if not filename or not isfile(filename): - return None - - return dict(self._get_table_atomic(quota, filename, tab_separated=False)) - - # Free/busy period access for users within quota groups. - - def get_freebusy(self, quota, user): - - "Get free/busy details for the given 'quota' and 'user'." - - filename = self.get_object_in_store(quota, "freebusy", user) - if not filename or not isfile(filename): - return [] - - return map(lambda t: FreeBusyPeriod(*t), - self._get_table_atomic(quota, filename)) - - def set_freebusy(self, quota, user, freebusy): - - "For the given 'quota' and 'user', set 'freebusy' details." - - filename = self.get_object_in_store(quota, "freebusy", user) - if not filename: - return False - - self._set_table_atomic(quota, filename, - map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) - return True - - # Journal entry methods. - - def get_entries(self, quota, group): - - """ - Return a list of journal entries for the given 'quota' for the indicated - 'group'. - """ - - filename = self.get_object_in_store(quota, "journal", group) - if not filename or not isfile(filename): - return [] - - return map(lambda t: FreeBusyPeriod(*t), - self._get_table_atomic(quota, filename)) - - def set_entries(self, quota, group, entries): - - """ - For the given 'quota' and indicated 'group', set the list of journal - 'entries'. - """ - - filename = self.get_object_in_store(quota, "journal", group) - if not filename: - return False - - self._set_table_atomic(quota, filename, - map(lambda fb: fb.as_tuple(strings_only=True), entries)) - return True - -# vim: tabstop=4 expandtab shiftwidth=4 diff -r f4bf00639b35 -r 37921ab84c01 imiptools/__init__.py --- a/imiptools/__init__.py Sun Mar 06 00:11:29 2016 +0100 +++ b/imiptools/__init__.py Sun Mar 06 00:44:42 2016 +0100 @@ -25,7 +25,7 @@ from imiptools.content import handle_itip_part from imiptools.data import get_address, get_addresses, get_uri from imiptools.mail import Messenger -import imip_store +import imiptools.stores.file import sys, os # Postfix exit codes. @@ -63,13 +63,13 @@ self.debug = False def get_store(self): - return imip_store.FileStore(self.store_dir) + return imiptools.stores.file.FileStore(self.store_dir) def get_publisher(self): - return self.publishing_dir and imip_store.FilePublisher(self.publishing_dir) or None + return self.publishing_dir and imiptools.stores.file.FilePublisher(self.publishing_dir) or None def get_journal(self): - return imip_store.FileJournal(self.journal_dir) + return imiptools.stores.file.FileJournal(self.journal_dir) def process(self, f, original_recipients): diff -r f4bf00639b35 -r 37921ab84c01 imiptools/client.py --- a/imiptools/client.py Sun Mar 06 00:11:29 2016 +0100 +++ b/imiptools/client.py Sun Mar 06 00:44:42 2016 +0100 @@ -31,7 +31,7 @@ remove_additional_periods, remove_affected_period, \ update_freebusy from imiptools.profile import Preferences -import imip_store +import imiptools.stores.file class Client: @@ -51,11 +51,11 @@ self.user = user self.messenger = messenger - self.store = store or imip_store.FileStore() - self.journal = journal or imip_store.FileJournal() + self.store = store or imiptools.stores.file.FileStore() + self.journal = journal or imiptools.stores.file.FileJournal() try: - self.publisher = publisher or imip_store.FilePublisher() + self.publisher = publisher or imiptools.stores.file.FilePublisher() except OSError: self.publisher = None diff -r f4bf00639b35 -r 37921ab84c01 imiptools/stores/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/stores/__init__.py Sun Mar 06 00:44:42 2016 +0100 @@ -0,0 +1,529 @@ +#!/usr/bin/env python + +""" +General support for calendar data storage. + +Copyright (C) 2014, 2015, 2016 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +class StoreBase: + + "The core operations of a data store." + + def acquire_lock(self, user, timeout=None): + pass + + def release_lock(self, user): + pass + + # User discovery. + + def get_users(self): + + "Return a list of users." + + pass + + # Event and event metadata access. + + def get_events(self, user): + + "Return a list of event identifiers." + + pass + + def get_all_events(self, user): + + "Return a set of (uid, recurrenceid) tuples for all events." + + uids = self.get_events(user) + if not uids: + return set() + + all_events = set() + for uid in uids: + all_events.add((uid, None)) + all_events.update([(uid, recurrenceid) for recurrenceid in self.get_recurrences(user, uid)]) + + return all_events + + def get_event(self, user, uid, recurrenceid=None, dirname=None): + + """ + Get the event for the given 'user' with the given 'uid'. If + the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is returned. + """ + + pass + + def get_complete_event(self, user, uid): + + "Get the event for the given 'user' with the given 'uid'." + + pass + + def set_event(self, user, uid, recurrenceid, node): + + """ + Set an event for 'user' having the given 'uid' and 'recurrenceid' (which + if the latter is specified, a specific instance or occurrence of an + event is referenced), using the given 'node' description. + """ + + if recurrenceid: + return self.set_recurrence(user, uid, recurrenceid, node) + else: + return self.set_complete_event(user, uid, node) + + def set_complete_event(self, user, uid, node): + + "Set an event for 'user' having the given 'uid' and 'node'." + + pass + + def remove_event(self, user, uid, recurrenceid=None): + + """ + Remove an event for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, a specific instance or occurrence of an + event is removed. + """ + + if recurrenceid: + return self.remove_recurrence(user, uid, recurrenceid) + else: + for recurrenceid in self.get_recurrences(user, uid) or []: + self.remove_recurrence(user, uid, recurrenceid) + return self.remove_complete_event(user, uid) + + def remove_complete_event(self, user, uid): + + "Remove an event for 'user' having the given 'uid'." + + self.remove_recurrences(user, uid) + return self.remove_parent_event(user, uid) + + def remove_parent_event(self, user, uid): + + "Remove the parent event for 'user' having the given 'uid'." + + pass + + def get_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. Both active and cancelled recurrences are returned. + """ + + return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) + + def get_active_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. Cancelled recurrences are not returned. + """ + + pass + + def get_cancelled_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. Only cancelled recurrences are returned. + """ + + pass + + def get_recurrence(self, user, uid, recurrenceid): + + """ + For the event of the given 'user' with the given 'uid', return the + specific recurrence indicated by the 'recurrenceid'. + """ + + pass + + def set_recurrence(self, user, uid, recurrenceid, node): + + "Set an event for 'user' having the given 'uid' and 'node'." + + pass + + def remove_recurrence(self, user, uid, recurrenceid): + + """ + Remove a special recurrence from an event stored by 'user' having the + given 'uid' and 'recurrenceid'. + """ + + pass + + def remove_recurrences(self, user, uid): + + """ + Remove all recurrences for an event stored by 'user' having the given + 'uid'. + """ + + for recurrenceid in self.get_recurrences(user, uid): + self.remove_recurrence(user, uid, recurrenceid) + + return self.remove_recurrence_collection(user, uid) + + def remove_recurrence_collection(self, user, uid): + + """ + Remove the collection of recurrences stored by 'user' having the given + 'uid'. + """ + + pass + + # Free/busy period providers, upon extension of the free/busy records. + + def get_freebusy_providers(self, user, dt=None): + + """ + Return a set of uncancelled events of the form (uid, recurrenceid) + providing free/busy details beyond the given datetime 'dt'. + + If 'dt' is not specified, all events previously found to provide + details will be returned. Otherwise, if 'dt' is earlier than the + datetime recorded for the known providers, None is returned, indicating + that the list of providers must be recomputed. + + This function returns a list of (uid, recurrenceid) tuples upon success. + """ + + pass + + def set_freebusy_providers(self, user, dt, providers): + + """ + Define the uncancelled events providing free/busy details beyond the + given datetime 'dt'. + """ + + pass + + def append_freebusy_provider(self, user, provider): + + "For the given 'user', append the free/busy 'provider'." + + pass + + def remove_freebusy_provider(self, user, provider): + + "For the given 'user', remove the free/busy 'provider'." + + pass + + # Free/busy period access. + + def get_freebusy(self, user, name=None): + + "Get free/busy details for the given 'user'." + + pass + + def get_freebusy_for_other(self, user, other): + + "For the given 'user', get free/busy details for the 'other' user." + + pass + + def set_freebusy(self, user, freebusy, name=None): + + "For the given 'user', set 'freebusy' details." + + pass + + def set_freebusy_for_other(self, user, freebusy, other): + + "For the given 'user', set 'freebusy' details for the 'other' user." + + pass + + # Tentative free/busy periods related to countering. + + def get_freebusy_offers(self, user): + + "Get free/busy offers for the given 'user'." + + pass + + def set_freebusy_offers(self, user, freebusy): + + "For the given 'user', set 'freebusy' offers." + + return self.set_freebusy(user, freebusy, "freebusy-offers") + + # Requests and counter-proposals. + + def get_requests(self, user): + + "Get requests for the given 'user'." + + pass + + def set_requests(self, user, requests): + + "For the given 'user', set the list of queued 'requests'." + + pass + + def set_request(self, user, uid, recurrenceid=None, type=None): + + """ + For the given 'user', set the queued 'uid' and 'recurrenceid', + indicating a request, along with any given 'type'. + """ + + pass + + def queue_request(self, user, uid, recurrenceid=None, type=None): + + """ + Queue a request for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, the entry refers to a specific instance + or occurrence of an event. The 'type' parameter can be used to indicate + a specific type of request. + """ + + requests = self.get_requests(user) or [] + + if not self.have_request(requests, uid, recurrenceid): + return self.set_request(user, uid, recurrenceid, type) + + return False + + def dequeue_request(self, user, uid, recurrenceid=None): + + """ + Dequeue all requests for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, all requests for that specific instance or + occurrence of an event are dequeued. + """ + + requests = self.get_requests(user) or [] + result = [] + + for request in requests: + if request[:2] != (uid, recurrenceid): + result.append(request) + + self.set_requests(user, result) + return True + + def has_request(self, user, uid, recurrenceid=None, type=None, strict=False): + return self.have_request(self.get_requests(user) or [], uid, recurrenceid, type, strict) + + def have_request(self, requests, uid, recurrenceid=None, type=None, strict=False): + + """ + Return whether 'requests' contains a request with the given 'uid' and + any specified 'recurrenceid' and 'type'. If 'strict' is set to a true + value, the precise type of the request must match; otherwise, any type + of request for the identified object may be matched. + """ + + for request in requests: + if request[:2] == (uid, recurrenceid) and ( + not strict or + not request[2:] and not type or + request[2:] and request[2] == type): + + return True + + return False + + def get_counters(self, user, uid, recurrenceid=None): + + """ + For the given 'user', return a list of users from whom counter-proposals + have been received for the given 'uid' and optional 'recurrenceid'. + """ + + pass + + def get_counter(self, user, other, uid, recurrenceid=None): + + """ + For the given 'user', return the counter-proposal from 'other' for the + given 'uid' and optional 'recurrenceid'. + """ + + pass + + def set_counter(self, user, other, node, uid, recurrenceid=None): + + """ + For the given 'user', store a counter-proposal received from 'other' the + given 'node' representing that proposal for the given 'uid' and + 'recurrenceid'. + """ + + pass + + def remove_counters(self, user, uid, recurrenceid=None): + + """ + For the given 'user', remove all counter-proposals associated with the + given 'uid' and 'recurrenceid'. + """ + + pass + + def remove_counter(self, user, other, uid, recurrenceid=None): + + """ + For the given 'user', remove any counter-proposal from 'other' + associated with the given 'uid' and 'recurrenceid'. + """ + + pass + + # Event cancellation. + + def cancel_event(self, user, uid, recurrenceid=None): + + """ + Cancel an event for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, a specific instance or occurrence of an + event is cancelled. + """ + + pass + + def uncancel_event(self, user, uid, recurrenceid=None): + + """ + Uncancel an event for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, a specific instance or occurrence of an + event is uncancelled. + """ + + pass + + def remove_cancellations(self, user, uid, recurrenceid=None): + + """ + Remove cancellations for 'user' for any event having the given 'uid'. If + the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is affected. + """ + + # Remove all recurrence cancellations if a general event is indicated. + + if not recurrenceid: + for _recurrenceid in self.get_cancelled_recurrences(user, uid): + self.remove_cancellation(user, uid, _recurrenceid) + + return self.remove_cancellation(user, uid, recurrenceid) + + def remove_cancellation(self, user, uid, recurrenceid=None): + + """ + Remove a cancellation for 'user' for the event having the given 'uid'. + If the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is affected. + """ + + pass + +class PublisherBase: + + "The core operations of a data publisher." + + def set_freebusy(self, user, freebusy): + + "For the given 'user', set 'freebusy' details." + + pass + +class JournalBase: + + "The core operations of a journal system supporting quotas." + + # Quota and user identity/group discovery. + + def get_quotas(self): + + "Return a list of quotas." + + pass + + def get_quota_users(self, quota): + + "Return a list of quota users." + + pass + + # Groups of users sharing quotas. + + def get_groups(self, quota): + + "Return the identity mappings for the given 'quota' as a dictionary." + + pass + + def get_limits(self, quota): + + """ + Return the limits for the 'quota' as a dictionary mapping identities or + groups to durations. + """ + + pass + + # Free/busy period access for users within quota groups. + + def get_freebusy(self, quota, user): + + "Get free/busy details for the given 'quota' and 'user'." + + pass + + def set_freebusy(self, quota, user, freebusy): + + "For the given 'quota' and 'user', set 'freebusy' details." + + pass + + # Journal entry methods. + + def get_entries(self, quota, group): + + """ + Return a list of journal entries for the given 'quota' for the indicated + 'group'. + """ + + pass + + def set_entries(self, quota, group, entries): + + """ + For the given 'quota' and indicated 'group', set the list of journal + 'entries'. + """ + + pass + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r f4bf00639b35 -r 37921ab84c01 imiptools/stores/file.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/stores/file.py Sun Mar 06 00:44:42 2016 +0100 @@ -0,0 +1,944 @@ +#!/usr/bin/env python + +""" +A simple filesystem-based store of calendar data. + +Copyright (C) 2014, 2015, 2016 Paul Boddie + +This program is free software; you can redistribute it and/or modify it under +the terms of the GNU General Public License as published by the Free Software +Foundation; either version 3 of the License, or (at your option) any later +version. + +This program is distributed in the hope that it will be useful, but WITHOUT +ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +FOR A PARTICULAR PURPOSE. See the GNU General Public License for more +details. + +You should have received a copy of the GNU General Public License along with +this program. If not, see . +""" + +from imiptools.stores import StoreBase, PublisherBase, JournalBase + +from datetime import datetime +from imiptools.config import STORE_DIR, PUBLISH_DIR, JOURNAL_DIR +from imiptools.data import make_calendar, parse_object, to_stream +from imiptools.dates import format_datetime, get_datetime, to_timezone +from imiptools.filesys import fix_permissions, FileBase +from imiptools.period import FreeBusyPeriod +from imiptools.text import parse_line +from os.path import isdir, isfile, join +from os import listdir, remove, rmdir +import codecs + +class FileStoreBase(FileBase): + + "A file store supporting user-specific locking and tabular data." + + def acquire_lock(self, user, timeout=None): + FileBase.acquire_lock(self, timeout, user) + + def release_lock(self, user): + FileBase.release_lock(self, user) + + # Utility methods. + + def _set_defaults(self, t, empty_defaults): + for i, default in empty_defaults: + if i >= len(t): + t += [None] * (i - len(t) + 1) + if not t[i]: + t[i] = default + return t + + def _get_table(self, user, filename, empty_defaults=None, tab_separated=True): + + """ + From the file for the given 'user' having the given 'filename', return + a list of tuples representing the file's contents. + + The 'empty_defaults' is a list of (index, value) tuples indicating the + default value where a column either does not exist or provides an empty + value. + + If 'tab_separated' is specified and is a false value, line parsing using + the imiptools.text.parse_line function will be performed instead of + splitting each line of the file using tab characters as separators. + """ + + f = codecs.open(filename, "rb", encoding="utf-8") + try: + l = [] + for line in f.readlines(): + line = line.strip(" \r\n") + if tab_separated: + t = line.split("\t") + else: + t = parse_line(line) + if empty_defaults: + t = self._set_defaults(t, empty_defaults) + l.append(tuple(t)) + return l + finally: + f.close() + + def _get_table_atomic(self, user, filename, empty_defaults=None, tab_separated=True): + + """ + From the file for the given 'user' having the given 'filename', return + a list of tuples representing the file's contents. + + The 'empty_defaults' is a list of (index, value) tuples indicating the + default value where a column either does not exist or provides an empty + value. + + If 'tab_separated' is specified and is a false value, line parsing using + the imiptools.text.parse_line function will be performed instead of + splitting each line of the file using tab characters as separators. + """ + + self.acquire_lock(user) + try: + return self._get_table(user, filename, empty_defaults, tab_separated) + finally: + self.release_lock(user) + + def _set_table(self, user, filename, items, empty_defaults=None): + + """ + For the given 'user', write to the file having the given 'filename' the + 'items'. + + The 'empty_defaults' is a list of (index, value) tuples indicating the + default value where a column either does not exist or provides an empty + value. + """ + + f = codecs.open(filename, "wb", encoding="utf-8") + try: + for item in items: + self._set_table_item(f, item, empty_defaults) + finally: + f.close() + fix_permissions(filename) + + def _set_table_item(self, f, item, empty_defaults=None): + + "Set in table 'f' the given 'item', using any 'empty_defaults'." + + if empty_defaults: + item = self._set_defaults(list(item), empty_defaults) + f.write("\t".join(item) + "\n") + + def _set_table_atomic(self, user, filename, items, empty_defaults=None): + + """ + For the given 'user', write to the file having the given 'filename' the + 'items'. + + The 'empty_defaults' is a list of (index, value) tuples indicating the + default value where a column either does not exist or provides an empty + value. + """ + + self.acquire_lock(user) + try: + self._set_table(user, filename, items, empty_defaults) + finally: + self.release_lock(user) + +class FileStore(FileStoreBase, StoreBase): + + "A file store of tabular free/busy data and objects." + + def __init__(self, store_dir=None): + FileBase.__init__(self, store_dir or STORE_DIR) + + # Store object access. + + def _get_object(self, user, filename): + + """ + Return the parsed object for the given 'user' having the given + 'filename'. + """ + + self.acquire_lock(user) + try: + f = open(filename, "rb") + try: + return parse_object(f, "utf-8") + finally: + f.close() + finally: + self.release_lock(user) + + def _set_object(self, user, filename, node): + + """ + Set an object for the given 'user' having the given 'filename', using + 'node' to define the object. + """ + + self.acquire_lock(user) + try: + f = open(filename, "wb") + try: + to_stream(f, node) + finally: + f.close() + fix_permissions(filename) + finally: + self.release_lock(user) + + return True + + def _remove_object(self, filename): + + "Remove the object with the given 'filename'." + + try: + remove(filename) + except OSError: + return False + + return True + + def _remove_collection(self, filename): + + "Remove the collection with the given 'filename'." + + try: + rmdir(filename) + except OSError: + return False + + return True + + # User discovery. + + def get_users(self): + + "Return a list of users." + + return listdir(self.store_dir) + + # Event and event metadata access. + + def get_events(self, user): + + "Return a list of event identifiers." + + filename = self.get_object_in_store(user, "objects") + if not filename or not isdir(filename): + return None + + return [name for name in listdir(filename) if isfile(join(filename, name))] + + def get_event_filename(self, user, uid, recurrenceid=None, dirname=None, username=None): + + """ + Get the filename providing the event for the given 'user' with the given + 'uid'. If the optional 'recurrenceid' is specified, a specific instance + or occurrence of an event is returned. + + Where 'dirname' is specified, the given directory name is used as the + base of the location within which any filename will reside. + """ + + if recurrenceid: + return self.get_recurrence_filename(user, uid, recurrenceid, dirname, username) + else: + return self.get_complete_event_filename(user, uid, dirname, username) + + def get_event(self, user, uid, recurrenceid=None, dirname=None): + + """ + Get the event for the given 'user' with the given 'uid'. If + the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is returned. + """ + + filename = self.get_event_filename(user, uid, recurrenceid, dirname) + if not filename or not isfile(filename): + return None + + return filename and self._get_object(user, filename) + + def get_complete_event_filename(self, user, uid, dirname=None, username=None): + + """ + Get the filename providing the event for the given 'user' with the given + 'uid'. + + Where 'dirname' is specified, the given directory name is used as the + base of the location within which any filename will reside. + + Where 'username' is specified, the event details will reside in a file + bearing that name within a directory having 'uid' as its name. + """ + + return self.get_object_in_store(user, dirname, "objects", uid, username) + + def get_complete_event(self, user, uid): + + "Get the event for the given 'user' with the given 'uid'." + + filename = self.get_complete_event_filename(user, uid) + if not filename or not isfile(filename): + return None + + return filename and self._get_object(user, filename) + + def set_complete_event(self, user, uid, node): + + "Set an event for 'user' having the given 'uid' and 'node'." + + filename = self.get_object_in_store(user, "objects", uid) + if not filename: + return False + + return self._set_object(user, filename, node) + + def remove_parent_event(self, user, uid): + + "Remove the parent event for 'user' having the given 'uid'." + + filename = self.get_object_in_store(user, "objects", uid) + if not filename: + return False + + return self._remove_object(filename) + + def get_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. Both active and cancelled recurrences are returned. + """ + + return self.get_active_recurrences(user, uid) + self.get_cancelled_recurrences(user, uid) + + def get_active_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. Cancelled recurrences are not returned. + """ + + filename = self.get_object_in_store(user, "recurrences", uid) + if not filename or not isdir(filename): + return [] + + return [name for name in listdir(filename) if isfile(join(filename, name))] + + def get_cancelled_recurrences(self, user, uid): + + """ + Get additional event instances for an event of the given 'user' with the + indicated 'uid'. Only cancelled recurrences are returned. + """ + + filename = self.get_object_in_store(user, "cancellations", "recurrences", uid) + if not filename or not isdir(filename): + return [] + + return [name for name in listdir(filename) if isfile(join(filename, name))] + + def get_recurrence_filename(self, user, uid, recurrenceid, dirname=None, username=None): + + """ + For the event of the given 'user' with the given 'uid', return the + filename providing the recurrence with the given 'recurrenceid'. + + Where 'dirname' is specified, the given directory name is used as the + base of the location within which any filename will reside. + + Where 'username' is specified, the event details will reside in a file + bearing that name within a directory having 'uid' as its name. + """ + + return self.get_object_in_store(user, dirname, "recurrences", uid, recurrenceid, username) + + def get_recurrence(self, user, uid, recurrenceid): + + """ + For the event of the given 'user' with the given 'uid', return the + specific recurrence indicated by the 'recurrenceid'. + """ + + filename = self.get_recurrence_filename(user, uid, recurrenceid) + if not filename or not isfile(filename): + return None + + return filename and self._get_object(user, filename) + + def set_recurrence(self, user, uid, recurrenceid, node): + + "Set an event for 'user' having the given 'uid' and 'node'." + + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) + if not filename: + return False + + return self._set_object(user, filename, node) + + def remove_recurrence(self, user, uid, recurrenceid): + + """ + Remove a special recurrence from an event stored by 'user' having the + given 'uid' and 'recurrenceid'. + """ + + filename = self.get_object_in_store(user, "recurrences", uid, recurrenceid) + if not filename: + return False + + return self._remove_object(filename) + + def remove_recurrence_collection(self, user, uid): + + """ + Remove the collection of recurrences stored by 'user' having the given + 'uid'. + """ + + recurrences = self.get_object_in_store(user, "recurrences", uid) + if recurrences: + return self._remove_collection(recurrences) + + return True + + # Free/busy period providers, upon extension of the free/busy records. + + def _get_freebusy_providers(self, user): + + """ + Return the free/busy providers for the given 'user'. + + This function returns any stored datetime and a list of providers as a + 2-tuple. Each provider is itself a (uid, recurrenceid) tuple. + """ + + filename = self.get_object_in_store(user, "freebusy-providers") + if not filename or not isfile(filename): + return None + + # Attempt to read providers, with a declaration of the datetime + # from which such providers are considered as still being active. + + t = self._get_table_atomic(user, filename, [(1, None)]) + try: + dt_string = t[0][0] + except IndexError: + return None + + return dt_string, t[1:] + + def get_freebusy_providers(self, user, dt=None): + + """ + Return a set of uncancelled events of the form (uid, recurrenceid) + providing free/busy details beyond the given datetime 'dt'. + + If 'dt' is not specified, all events previously found to provide + details will be returned. Otherwise, if 'dt' is earlier than the + datetime recorded for the known providers, None is returned, indicating + that the list of providers must be recomputed. + + This function returns a list of (uid, recurrenceid) tuples upon success. + """ + + t = self._get_freebusy_providers(user) + if not t: + return None + + dt_string, t = t + + # If the requested datetime is earlier than the stated datetime, the + # providers will need to be recomputed. + + if dt: + providers_dt = get_datetime(dt_string) + if not providers_dt or providers_dt > dt: + return None + + # Otherwise, return the providers. + + return t[1:] + + def _set_freebusy_providers(self, user, dt_string, t): + + "Set the given provider timestamp 'dt_string' and table 't'." + + filename = self.get_object_in_store(user, "freebusy-providers") + if not filename: + return False + + t.insert(0, (dt_string,)) + self._set_table_atomic(user, filename, t, [(1, "")]) + return True + + def set_freebusy_providers(self, user, dt, providers): + + """ + Define the uncancelled events providing free/busy details beyond the + given datetime 'dt'. + """ + + t = [] + + for obj in providers: + t.append((obj.get_uid(), obj.get_recurrenceid())) + + return self._set_freebusy_providers(user, format_datetime(dt), t) + + def append_freebusy_provider(self, user, provider): + + "For the given 'user', append the free/busy 'provider'." + + t = self._get_freebusy_providers(user) + if not t: + return False + + dt_string, t = t + t.append((provider.get_uid(), provider.get_recurrenceid())) + + return self._set_freebusy_providers(user, dt_string, t) + + def remove_freebusy_provider(self, user, provider): + + "For the given 'user', remove the free/busy 'provider'." + + t = self._get_freebusy_providers(user) + if not t: + return False + + dt_string, t = t + try: + t.remove((provider.get_uid(), provider.get_recurrenceid())) + except ValueError: + return False + + return self._set_freebusy_providers(user, dt_string, t) + + # Free/busy period access. + + def get_freebusy(self, user, name=None): + + "Get free/busy details for the given 'user'." + + filename = self.get_object_in_store(user, name or "freebusy") + if not filename or not isfile(filename): + return [] + else: + return map(lambda t: FreeBusyPeriod(*t), + self._get_table_atomic(user, filename)) + + def get_freebusy_for_other(self, user, other): + + "For the given 'user', get free/busy details for the 'other' user." + + filename = self.get_object_in_store(user, "freebusy-other", other) + if not filename or not isfile(filename): + return [] + else: + return map(lambda t: FreeBusyPeriod(*t), + self._get_table_atomic(user, filename)) + + def set_freebusy(self, user, freebusy, name=None): + + "For the given 'user', set 'freebusy' details." + + filename = self.get_object_in_store(user, name or "freebusy") + if not filename: + return False + + self._set_table_atomic(user, filename, + map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) + return True + + def set_freebusy_for_other(self, user, freebusy, other): + + "For the given 'user', set 'freebusy' details for the 'other' user." + + filename = self.get_object_in_store(user, "freebusy-other", other) + if not filename: + return False + + self._set_table_atomic(user, filename, + map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) + return True + + # Tentative free/busy periods related to countering. + + def get_freebusy_offers(self, user): + + "Get free/busy offers for the given 'user'." + + offers = [] + expired = [] + now = to_timezone(datetime.utcnow(), "UTC") + + # Expire old offers and save the collection if modified. + + self.acquire_lock(user) + try: + l = self.get_freebusy(user, "freebusy-offers") + for fb in l: + if fb.expires and get_datetime(fb.expires) <= now: + expired.append(fb) + else: + offers.append(fb) + + if expired: + self.set_freebusy_offers(user, offers) + finally: + self.release_lock(user) + + return offers + + def set_freebusy_offers(self, user, freebusy): + + "For the given 'user', set 'freebusy' offers." + + return self.set_freebusy(user, freebusy, "freebusy-offers") + + # Requests and counter-proposals. + + def _get_requests(self, user, queue): + + "Get requests for the given 'user' from the given 'queue'." + + filename = self.get_object_in_store(user, queue) + if not filename or not isfile(filename): + return None + + return self._get_table_atomic(user, filename, [(1, None), (2, None)]) + + def get_requests(self, user): + + "Get requests for the given 'user'." + + return self._get_requests(user, "requests") + + def _set_requests(self, user, requests, queue): + + """ + For the given 'user', set the list of queued 'requests' in the given + 'queue'. + """ + + filename = self.get_object_in_store(user, queue) + if not filename: + return False + + self._set_table_atomic(user, filename, requests, [(1, ""), (2, "")]) + return True + + def set_requests(self, user, requests): + + "For the given 'user', set the list of queued 'requests'." + + return self._set_requests(user, requests, "requests") + + def _set_request(self, user, request, queue): + + """ + For the given 'user', set the given 'request' in the given 'queue'. + """ + + filename = self.get_object_in_store(user, queue) + if not filename: + return False + + self.acquire_lock(user) + try: + f = codecs.open(filename, "ab", encoding="utf-8") + try: + self._set_table_item(f, request, [(1, ""), (2, "")]) + finally: + f.close() + fix_permissions(filename) + finally: + self.release_lock(user) + + return True + + def set_request(self, user, uid, recurrenceid=None, type=None): + + """ + For the given 'user', set the queued 'uid' and 'recurrenceid', + indicating a request, along with any given 'type'. + """ + + return self._set_request(user, (uid, recurrenceid, type), "requests") + + def get_counters(self, user, uid, recurrenceid=None): + + """ + For the given 'user', return a list of users from whom counter-proposals + have been received for the given 'uid' and optional 'recurrenceid'. + """ + + filename = self.get_event_filename(user, uid, recurrenceid, "counters") + if not filename or not isdir(filename): + return False + + return [name for name in listdir(filename) if isfile(join(filename, name))] + + def get_counter(self, user, other, uid, recurrenceid=None): + + """ + For the given 'user', return the counter-proposal from 'other' for the + given 'uid' and optional 'recurrenceid'. + """ + + filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) + if not filename: + return False + + return self._get_object(user, filename) + + def set_counter(self, user, other, node, uid, recurrenceid=None): + + """ + For the given 'user', store a counter-proposal received from 'other' the + given 'node' representing that proposal for the given 'uid' and + 'recurrenceid'. + """ + + filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) + if not filename: + return False + + return self._set_object(user, filename, node) + + def remove_counters(self, user, uid, recurrenceid=None): + + """ + For the given 'user', remove all counter-proposals associated with the + given 'uid' and 'recurrenceid'. + """ + + filename = self.get_event_filename(user, uid, recurrenceid, "counters") + if not filename or not isdir(filename): + return False + + removed = False + + for other in listdir(filename): + counter_filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) + removed = removed or self._remove_object(counter_filename) + + return removed + + def remove_counter(self, user, other, uid, recurrenceid=None): + + """ + For the given 'user', remove any counter-proposal from 'other' + associated with the given 'uid' and 'recurrenceid'. + """ + + filename = self.get_event_filename(user, uid, recurrenceid, "counters", other) + if not filename or not isfile(filename): + return False + + return self._remove_object(filename) + + # Event cancellation. + + def cancel_event(self, user, uid, recurrenceid=None): + + """ + Cancel an event for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, a specific instance or occurrence of an + event is cancelled. + """ + + filename = self.get_event_filename(user, uid, recurrenceid) + cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") + + if filename and cancelled_filename and isfile(filename): + return self.move_object(filename, cancelled_filename) + + return False + + def uncancel_event(self, user, uid, recurrenceid=None): + + """ + Uncancel an event for 'user' having the given 'uid'. If the optional + 'recurrenceid' is specified, a specific instance or occurrence of an + event is uncancelled. + """ + + filename = self.get_event_filename(user, uid, recurrenceid) + cancelled_filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") + + if filename and cancelled_filename and isfile(cancelled_filename): + return self.move_object(cancelled_filename, filename) + + return False + + def remove_cancellation(self, user, uid, recurrenceid=None): + + """ + Remove a cancellation for 'user' for the event having the given 'uid'. + If the optional 'recurrenceid' is specified, a specific instance or + occurrence of an event is affected. + """ + + # Remove any parent event cancellation or a specific recurrence + # cancellation if indicated. + + filename = self.get_event_filename(user, uid, recurrenceid, "cancellations") + + if filename and isfile(filename): + return self._remove_object(filename) + + return False + +class FilePublisher(FileBase, PublisherBase): + + "A publisher of objects." + + def __init__(self, store_dir=None): + FileBase.__init__(self, store_dir or PUBLISH_DIR) + + def set_freebusy(self, user, freebusy): + + "For the given 'user', set 'freebusy' details." + + filename = self.get_object_in_store(user, "freebusy") + if not filename: + return False + + record = [] + rwrite = record.append + + rwrite(("ORGANIZER", {}, user)) + rwrite(("UID", {}, user)) + rwrite(("DTSTAMP", {}, datetime.utcnow().strftime("%Y%m%dT%H%M%SZ"))) + + for fb in freebusy: + if not fb.transp or fb.transp == "OPAQUE": + rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join( + map(format_datetime, [fb.get_start_point(), fb.get_end_point()])))) + + f = open(filename, "wb") + try: + to_stream(f, make_calendar([("VFREEBUSY", {}, record)], "PUBLISH")) + finally: + f.close() + fix_permissions(filename) + + return True + +class FileJournal(FileStoreBase, JournalBase): + + "A journal system to support quotas." + + def __init__(self, store_dir=None): + FileBase.__init__(self, store_dir or JOURNAL_DIR) + + # Quota and user identity/group discovery. + + def get_quotas(self): + + "Return a list of quotas." + + return listdir(self.store_dir) + + def get_quota_users(self, quota): + + "Return a list of quota users." + + filename = self.get_object_in_store(quota, "journal") + if not filename or not isdir(filename): + return [] + + return listdir(filename) + + # Groups of users sharing quotas. + + def get_groups(self, quota): + + "Return the identity mappings for the given 'quota' as a dictionary." + + filename = self.get_object_in_store(quota, "groups") + if not filename or not isfile(filename): + return {} + + return dict(self._get_table_atomic(quota, filename, tab_separated=False)) + + def get_limits(self, quota): + + """ + Return the limits for the 'quota' as a dictionary mapping identities or + groups to durations. + """ + + filename = self.get_object_in_store(quota, "limits") + if not filename or not isfile(filename): + return None + + return dict(self._get_table_atomic(quota, filename, tab_separated=False)) + + # Free/busy period access for users within quota groups. + + def get_freebusy(self, quota, user): + + "Get free/busy details for the given 'quota' and 'user'." + + filename = self.get_object_in_store(quota, "freebusy", user) + if not filename or not isfile(filename): + return [] + + return map(lambda t: FreeBusyPeriod(*t), + self._get_table_atomic(quota, filename)) + + def set_freebusy(self, quota, user, freebusy): + + "For the given 'quota' and 'user', set 'freebusy' details." + + filename = self.get_object_in_store(quota, "freebusy", user) + if not filename: + return False + + self._set_table_atomic(quota, filename, + map(lambda fb: fb.as_tuple(strings_only=True), freebusy)) + return True + + # Journal entry methods. + + def get_entries(self, quota, group): + + """ + Return a list of journal entries for the given 'quota' for the indicated + 'group'. + """ + + filename = self.get_object_in_store(quota, "journal", group) + if not filename or not isfile(filename): + return [] + + return map(lambda t: FreeBusyPeriod(*t), + self._get_table_atomic(quota, filename)) + + def set_entries(self, quota, group, entries): + + """ + For the given 'quota' and indicated 'group', set the list of journal + 'entries'. + """ + + filename = self.get_object_in_store(quota, "journal", group) + if not filename: + return False + + self._set_table_atomic(quota, filename, + map(lambda fb: fb.as_tuple(strings_only=True), entries)) + return True + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r f4bf00639b35 -r 37921ab84c01 imipweb/resource.py --- a/imipweb/resource.py Sun Mar 06 00:11:29 2016 +0100 +++ b/imipweb/resource.py Sun Mar 06 00:44:42 2016 +0100 @@ -28,7 +28,6 @@ from imipweb.env import CGIEnvironment from urllib import urlencode import babel.dates -import imip_store import markup import pytz diff -r f4bf00639b35 -r 37921ab84c01 tests/test_handle.py --- a/tests/test_handle.py Sun Mar 06 00:11:29 2016 +0100 +++ b/tests/test_handle.py Sun Mar 06 00:44:42 2016 +0100 @@ -24,7 +24,7 @@ from imiptools.dates import get_datetime, to_timezone from imiptools.mail import Messenger from imiptools.period import RecurringPeriod -import imip_store +import imiptools.stores.file import sys class TestClient(ClientForObject): @@ -120,8 +120,8 @@ """ sys.exit(1) - store = imip_store.FileStore(store_dir) - journal = imip_store.FileJournal(journal_dir) + store = imiptools.stores.file.FileStore(store_dir) + journal = imiptools.stores.file.FileJournal(journal_dir) if uid is not None: fragment = store.get_event(user, uid, recurrenceid) diff -r f4bf00639b35 -r 37921ab84c01 tools/install.sh --- a/tools/install.sh Sun Mar 06 00:11:29 2016 +0100 +++ b/tools/install.sh Sun Mar 06 00:44:42 2016 +0100 @@ -17,7 +17,7 @@ # Agents and modules. AGENTS="imip_person.py imip_person_outgoing.py imip_resource.py" -MODULES="markup.py imip_store.py vCalendar.py vContent.py vRecurrence.py" +MODULES="markup.py vCalendar.py vContent.py vRecurrence.py" if [ ! -e "$INSTALL_DIR" ]; then mkdir -p "$INSTALL_DIR" @@ -27,6 +27,7 @@ cp $MODULES "$INSTALL_DIR" for DIR in "$INSTALL_DIR/imiptools" \ + "$INSTALL_DIR/imiptools/stores" \ "$INSTALL_DIR/imiptools/handlers" \ "$INSTALL_DIR/imiptools/handlers/scheduling" ; do if [ ! -e "$DIR" ]; then @@ -43,6 +44,7 @@ # Copy modules into the installation directory. cp imiptools/*.py "$INSTALL_DIR/imiptools/" +cp imiptools/stores/*.py "$INSTALL_DIR/imiptools/stores/" cp imiptools/handlers/*.py "$INSTALL_DIR/imiptools/handlers/" cp imiptools/handlers/scheduling/*.py "$INSTALL_DIR/imiptools/handlers/scheduling/" @@ -52,6 +54,10 @@ rm "$INSTALL_DIR/imiptools/handlers/scheduling.py"* fi +if [ -e "$INSTALL_DIR/imip_store.py" ]; then + rm "$INSTALL_DIR/imip_store.py"* +fi + # Install the config module in a more appropriate location. if [ ! -e "$CONFIG_DIR" ]; then diff -r f4bf00639b35 -r 37921ab84c01 tools/make_freebusy.py --- a/tools/make_freebusy.py Sun Mar 06 00:11:29 2016 +0100 +++ b/tools/make_freebusy.py Sun Mar 06 00:44:42 2016 +0100 @@ -38,7 +38,7 @@ from imiptools.data import get_window_end, Object from imiptools.dates import get_default_timezone, to_utc_datetime from imiptools.period import insert_period -from imip_store import FileStore, FilePublisher, FileJournal +from imiptools.stores.file import FileStore, FilePublisher, FileJournal def make_freebusy(client, participant, store_and_publish, include_needs_action, reset_updated_list, verbose): diff -r f4bf00639b35 -r 37921ab84c01 tools/update_quotas.py --- a/tools/update_quotas.py Sun Mar 06 00:11:29 2016 +0100 +++ b/tools/update_quotas.py Sun Mar 06 00:44:42 2016 +0100 @@ -34,7 +34,7 @@ from codecs import getwriter from imiptools.dates import get_datetime, get_default_timezone, get_time, \ to_utc_datetime -from imip_store import FileJournal +from imiptools.stores.file import FileJournal def remove_expired_entries(entries, expiry):