# HG changeset patch # User Paul Boddie # Date 1495815923 -7200 # Node ID b7c84c76b1e621a6a8bdae048e7718b2d7fd1776 # Parent 3c756db2eb7e32a6503bb959d8e8cb9671b6f98f Split the freebusy module into separate modules within a package. diff -r 3c756db2eb7e -r b7c84c76b1e6 imiptools/freebusy.py --- a/imiptools/freebusy.py Fri May 26 16:52:25 2017 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1175 +0,0 @@ -#!/usr/bin/env python - -""" -Managing free/busy periods. - -Copyright (C) 2014, 2015, 2016, 2017 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 bisect import bisect_left, bisect_right -from imiptools.dates import format_datetime -from imiptools.period import get_overlapping, Period, PeriodBase -from imiptools.sql import DatabaseOperations - -try: - from cStringIO import StringIO -except ImportError: - from StringIO import StringIO - -def from_string(s, encoding): - - "Interpret 's' using 'encoding', preserving None." - - if s: - return unicode(s, encoding) - else: - return s - -def to_string(s, encoding): - - "Encode 's' using 'encoding', preserving None." - - if s: - return s.encode(encoding) - else: - return s - -def to_copy_string(s, encoding): - - """ - Encode 's' using 'encoding' as a string suitable for use in tabular data - acceptable to the PostgreSQL COPY command with \N as null. - """ - - s = to_string(s, encoding) - return s is None and "\\N" or s - -def to_copy_file(records): - - """ - Encode the given 'records' and store them in a file-like object for use with - a tabular import mechanism. Return the file-like object. - """ - - io = StringIO() - for values in records: - l = [] - for v in values: - l.append(to_copy_string(v, "utf-8")) - io.write("\t".join(l)) - io.write("\n") - io.seek(0) - return io - -def quote_column(column): - - "Quote 'column' using the SQL keyword quoting notation." - - return '"%s"' % column - -class FreeBusyPeriod(PeriodBase): - - "A free/busy record abstraction." - - def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, - summary=None, organiser=None): - - """ - Initialise a free/busy period with the given 'start' and 'end' points, - plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' - details. - """ - - PeriodBase.__init__(self, start, end) - self.uid = uid - self.transp = transp or None - self.recurrenceid = recurrenceid or None - self.summary = summary or None - self.organiser = organiser or None - - def as_tuple(self, strings_only=False, string_datetimes=False): - - """ - Return the initialisation parameter tuple, converting datetimes and - false value parameters to strings if 'strings_only' is set to a true - value. Otherwise, if 'string_datetimes' is set to a true value, only the - datetime values are converted to strings. - """ - - null = lambda x: (strings_only and [""] or [x])[0] - return ( - (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, - (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, - self.uid or null(self.uid), - self.transp or strings_only and "OPAQUE" or None, - self.recurrenceid or null(self.recurrenceid), - self.summary or null(self.summary), - self.organiser or null(self.organiser) - ) - - def __cmp__(self, other): - - """ - Compare this object to 'other', employing the uid if the periods - involved are the same. - """ - - result = PeriodBase.__cmp__(self, other) - if result == 0 and isinstance(other, FreeBusyPeriod): - return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) - else: - return result - - def get_key(self): - return self.uid, self.recurrenceid, self.get_start() - - def __repr__(self): - return "FreeBusyPeriod%r" % (self.as_tuple(),) - - def get_tzid(self): - return "UTC" - - # Period and event recurrence logic. - - def is_replaced(self, recurrences): - - """ - Return whether this period refers to one of the 'recurrences'. - The 'recurrences' must be UTC datetimes corresponding to the start of - the period described by a recurrence. - """ - - for recurrence in recurrences: - if self.is_affected(recurrence): - return True - return False - - def is_affected(self, recurrence): - - """ - Return whether this period refers to 'recurrence'. The 'recurrence' must - be a UTC datetime corresponding to the start of the period described by - a recurrence. - """ - - return recurrence and self.get_start_point() == recurrence - - # Value correction methods. - - def make_corrected(self, start, end): - return self.__class__(start, end) - -class FreeBusyOfferPeriod(FreeBusyPeriod): - - "A free/busy record abstraction for an offer period." - - def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, - summary=None, organiser=None, expires=None): - - """ - Initialise a free/busy period with the given 'start' and 'end' points, - plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' - details. - - An additional 'expires' parameter can be used to indicate an expiry - datetime in conjunction with free/busy offers made when countering - event proposals. - """ - - FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, - summary, organiser) - self.expires = expires or None - - def as_tuple(self, strings_only=False, string_datetimes=False): - - """ - Return the initialisation parameter tuple, converting datetimes and - false value parameters to strings if 'strings_only' is set to a true - value. Otherwise, if 'string_datetimes' is set to a true value, only the - datetime values are converted to strings. - """ - - null = lambda x: (strings_only and [""] or [x])[0] - return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( - self.expires or null(self.expires),) - - def __repr__(self): - return "FreeBusyOfferPeriod%r" % (self.as_tuple(),) - -class FreeBusyGroupPeriod(FreeBusyPeriod): - - "A free/busy record abstraction for a quota group period." - - def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, - summary=None, organiser=None, attendee=None): - - """ - Initialise a free/busy period with the given 'start' and 'end' points, - plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' - details. - - An additional 'attendee' parameter can be used to indicate the identity - of the attendee recording the period. - """ - - FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, - summary, organiser) - self.attendee = attendee or None - - def as_tuple(self, strings_only=False, string_datetimes=False): - - """ - Return the initialisation parameter tuple, converting datetimes and - false value parameters to strings if 'strings_only' is set to a true - value. Otherwise, if 'string_datetimes' is set to a true value, only the - datetime values are converted to strings. - """ - - null = lambda x: (strings_only and [""] or [x])[0] - return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( - self.attendee or null(self.attendee),) - - def __cmp__(self, other): - - """ - Compare this object to 'other', employing the uid if the periods - involved are the same. - """ - - result = FreeBusyPeriod.__cmp__(self, other) - if isinstance(other, FreeBusyGroupPeriod) and result == 0: - return cmp(self.attendee, other.attendee) - else: - return result - - def __repr__(self): - return "FreeBusyGroupPeriod%r" % (self.as_tuple(),) - -class FreeBusyCollectionBase: - - "Common operations on free/busy period collections." - - period_columns = [ - "start", "end", "object_uid", "transp", "object_recurrenceid", - "summary", "organiser" - ] - - period_class = FreeBusyPeriod - - def __init__(self, mutable=True): - self.mutable = mutable - - def _check_mutable(self): - if not self.mutable: - raise TypeError, "Cannot mutate this collection." - - def copy(self): - - "Make an independent mutable copy of the collection." - - return FreeBusyCollection(list(self), True) - - def make_period(self, t): - - """ - Make a period using the given tuple of arguments and the collection's - column details. - """ - - args = [] - for arg, column in zip(t, self.period_columns): - args.append(from_string(arg, "utf-8")) - return self.period_class(*args) - - def make_tuple(self, t): - - """ - Return a tuple from the given tuple 't' conforming to the collection's - column details. - """ - - args = [] - for arg, column in zip(t, self.period_columns): - args.append(arg) - return tuple(args) - - # List emulation methods. - - def __iadd__(self, periods): - self.insert_periods(periods) - return self - - def append(self, period): - self.insert_period(period) - - # Operations. - - def insert_periods(self, periods): - - "Insert the given 'periods' into the collection." - - for p in periods: - self.insert_period(p) - - def can_schedule(self, periods, uid, recurrenceid): - - """ - Return whether the collection can accommodate the given 'periods' - employing the specified 'uid' and 'recurrenceid'. - """ - - for conflict in self.have_conflict(periods, True): - if conflict.uid != uid or conflict.recurrenceid != recurrenceid: - return False - - return True - - def have_conflict(self, periods, get_conflicts=False): - - """ - Return whether any period in the collection overlaps with the given - 'periods', returning a collection of such overlapping periods if - 'get_conflicts' is set to a true value. - """ - - conflicts = set() - for p in periods: - overlapping = self.period_overlaps(p, get_conflicts) - if overlapping: - if get_conflicts: - conflicts.update(overlapping) - else: - return True - - if get_conflicts: - return conflicts - else: - return False - - def period_overlaps(self, period, get_periods=False): - - """ - Return whether any period in the collection overlaps with the given - 'period', returning a collection of overlapping periods if 'get_periods' - is set to a true value. - """ - - overlapping = self.get_overlapping([period]) - - if get_periods: - return overlapping - else: - return len(overlapping) != 0 - - def replace_overlapping(self, period, replacements): - - """ - Replace existing periods in the collection within the given 'period', - using the given 'replacements'. - """ - - self._check_mutable() - - self.remove_overlapping(period) - for replacement in replacements: - self.insert_period(replacement) - - def coalesce_freebusy(self): - - "Coalesce the periods in the collection, returning a new collection." - - if not self: - return FreeBusyCollection() - - fb = [] - - it = iter(self) - period = it.next() - - start = period.get_start_point() - end = period.get_end_point() - - try: - while True: - period = it.next() - if period.get_start_point() > end: - fb.append(self.period_class(start, end)) - start = period.get_start_point() - end = period.get_end_point() - else: - end = max(end, period.get_end_point()) - except StopIteration: - pass - - fb.append(self.period_class(start, end)) - return FreeBusyCollection(fb) - - def invert_freebusy(self): - - "Return the free periods from the collection as a new collection." - - if not self: - return FreeBusyCollection([self.period_class(None, None)]) - - # Coalesce periods that overlap or are adjacent. - - fb = self.coalesce_freebusy() - free = [] - - # Add a start-of-time period if appropriate. - - first = fb[0].get_start_point() - if first: - free.append(self.period_class(None, first)) - - start = fb[0].get_end_point() - - for period in fb[1:]: - free.append(self.period_class(start, period.get_start_point())) - start = period.get_end_point() - - # Add an end-of-time period if appropriate. - - if start: - free.append(self.period_class(start, None)) - - return FreeBusyCollection(free) - - def _update_freebusy(self, periods, uid, recurrenceid): - - """ - Update the free/busy details with the given 'periods', using the given - 'uid' plus 'recurrenceid' to remove existing periods. - """ - - self._check_mutable() - - self.remove_specific_event_periods(uid, recurrenceid) - - self.insert_periods(periods) - - def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser): - - """ - Update the free/busy details with the given 'periods', 'transp' setting, - 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. - """ - - new_periods = [] - - for p in periods: - new_periods.append( - self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser) - ) - - self._update_freebusy(new_periods, uid, recurrenceid) - -class SupportAttendee: - - "A mix-in that supports the affected attendee in free/busy periods." - - period_columns = FreeBusyCollectionBase.period_columns + ["attendee"] - period_class = FreeBusyGroupPeriod - - def _update_freebusy(self, periods, uid, recurrenceid, attendee=None): - - """ - Update the free/busy details with the given 'periods', using the given - 'uid' plus 'recurrenceid' and 'attendee' to remove existing periods. - """ - - self._check_mutable() - - self.remove_specific_event_periods(uid, recurrenceid, attendee) - - self.insert_periods(periods) - - def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, attendee=None): - - """ - Update the free/busy details with the given 'periods', 'transp' setting, - 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. - - An optional 'attendee' indicates the attendee affected by the period. - """ - - new_periods = [] - - for p in periods: - new_periods.append( - self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, attendee) - ) - - self._update_freebusy(new_periods, uid, recurrenceid, attendee) - -class SupportExpires: - - "A mix-in that supports the expiry datetime in free/busy periods." - - period_columns = FreeBusyCollectionBase.period_columns + ["expires"] - period_class = FreeBusyOfferPeriod - - def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): - - """ - Update the free/busy details with the given 'periods', 'transp' setting, - 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. - - An optional 'expires' datetime string indicates the expiry time of any - free/busy offer. - """ - - new_periods = [] - - for p in periods: - new_periods.append( - self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires) - ) - - self._update_freebusy(new_periods, uid, recurrenceid) - -class FreeBusyCollection(FreeBusyCollectionBase): - - "An abstraction for a collection of free/busy periods." - - def __init__(self, periods=None, mutable=True): - - """ - Initialise the collection with the given list of 'periods', or start an - empty collection if no list is given. If 'mutable' is indicated, the - collection may be changed; otherwise, an exception will be raised. - """ - - FreeBusyCollectionBase.__init__(self, mutable) - self.periods = periods or [] - - # List emulation methods. - - def __nonzero__(self): - return bool(self.periods) - - def __iter__(self): - return iter(self.periods) - - def __len__(self): - return len(self.periods) - - def __getitem__(self, i): - return self.periods[i] - - # Operations. - - def insert_period(self, period): - - "Insert the given 'period' into the collection." - - self._check_mutable() - - i = bisect_left(self.periods, period) - if i == len(self.periods): - self.periods.append(period) - elif self.periods[i] != period: - self.periods.insert(i, period) - - def remove_periods(self, periods): - - "Remove the given 'periods' from the collection." - - self._check_mutable() - - for period in periods: - i = bisect_left(self.periods, period) - if i < len(self.periods) and self.periods[i] == period: - del self.periods[i] - - def remove_event_periods(self, uid, recurrenceid=None, participant=None): - - """ - Remove from the collection all periods associated with 'uid' and - 'recurrenceid' (which if omitted causes the "parent" object's periods to - be referenced). - - If 'participant' is specified, only remove periods for which the - participant is given as attending. - - Return the removed periods. - """ - - self._check_mutable() - - removed = [] - i = 0 - while i < len(self.periods): - fb = self.periods[i] - - if fb.uid == uid and fb.recurrenceid == recurrenceid and \ - (not participant or participant == fb.attendee): - - removed.append(self.periods[i]) - del self.periods[i] - else: - i += 1 - - return removed - - # Specific period removal when updating event details. - - remove_specific_event_periods = remove_event_periods - - def remove_additional_periods(self, uid, recurrenceids=None): - - """ - Remove from the collection all periods associated with 'uid' having a - recurrence identifier indicating an additional or modified period. - - If 'recurrenceids' is specified, remove all periods associated with - 'uid' that do not have a recurrence identifier in the given list. - - Return the removed periods. - """ - - self._check_mutable() - - removed = [] - i = 0 - while i < len(self.periods): - fb = self.periods[i] - if fb.uid == uid and fb.recurrenceid and ( - recurrenceids is None or - recurrenceids is not None and fb.recurrenceid not in recurrenceids - ): - removed.append(self.periods[i]) - del self.periods[i] - else: - i += 1 - - return removed - - def remove_affected_period(self, uid, start, participant=None): - - """ - Remove from the collection the period associated with 'uid' that - provides an occurrence starting at the given 'start' (provided by a - recurrence identifier, converted to a datetime). A recurrence identifier - is used to provide an alternative time period whilst also acting as a - reference to the originally-defined occurrence. - - If 'participant' is specified, only remove periods for which the - participant is given as attending. - - Return any removed period in a list. - """ - - self._check_mutable() - - removed = [] - - search = Period(start, start) - found = bisect_left(self.periods, search) - - while found < len(self.periods): - fb = self.periods[found] - - # Stop looking if the start no longer matches the recurrence identifier. - - if fb.get_start_point() != search.get_start_point(): - break - - # If the period belongs to the parent object, remove it and return. - - if not fb.recurrenceid and uid == fb.uid and \ - (not participant or participant == fb.attendee): - - removed.append(self.periods[found]) - del self.periods[found] - break - - # Otherwise, keep looking for a matching period. - - found += 1 - - return removed - - def periods_from(self, period): - - "Return the entries in the collection at or after 'period'." - - first = bisect_left(self.periods, period) - return self.periods[first:] - - def periods_until(self, period): - - "Return the entries in the collection before 'period'." - - last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) - return self.periods[:last] - - def get_overlapping(self, periods): - - """ - Return the entries in the collection providing periods overlapping with - the given sorted collection of 'periods'. - """ - - return get_overlapping(self.periods, periods) - - def remove_overlapping(self, period): - - "Remove all periods overlapping with 'period' from the collection." - - self._check_mutable() - - overlapping = self.get_overlapping([period]) - - if overlapping: - for fb in overlapping: - self.periods.remove(fb) - -class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection): - - "A collection of quota group free/busy objects." - - def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): - - """ - Remove from the collection all periods associated with 'uid' and - 'recurrenceid' (which if omitted causes the "parent" object's periods to - be referenced) and any 'attendee'. - - Return the removed periods. - """ - - self._check_mutable() - - removed = [] - i = 0 - while i < len(self.periods): - fb = self.periods[i] - if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee: - removed.append(self.periods[i]) - del self.periods[i] - else: - i += 1 - - return removed - -class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection): - - "A collection of offered free/busy objects." - - pass - -class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations): - - """ - An abstraction for a collection of free/busy periods stored in a database - system. - """ - - def __init__(self, cursor, table_name, column_names=None, filter_values=None, - mutable=True, paramstyle=None): - - """ - Initialise the collection with the given 'cursor' and with the - 'table_name', 'column_names' and 'filter_values' configuring the - selection of data. If 'mutable' is indicated, the collection may be - changed; otherwise, an exception will be raised. - """ - - FreeBusyCollectionBase.__init__(self, mutable) - DatabaseOperations.__init__(self, column_names, filter_values, paramstyle) - self.cursor = cursor - self.table_name = table_name - - # List emulation methods. - - def __nonzero__(self): - return len(self) and True or False - - def __iter__(self): - query, values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }) - self.cursor.execute(query, values) - return iter(map(lambda t: self.make_period(t), self.cursor.fetchall())) - - def __len__(self): - query, values = self.get_query( - "select count(*) from %(table)s :condition" % { - "table" : self.table_name - }) - self.cursor.execute(query, values) - result = self.cursor.fetchone() - return result and int(result[0]) or 0 - - def __getitem__(self, i): - return list(iter(self))[i] - - # Operations. - - def insert_period(self, period): - - "Insert the given 'period' into the collection." - - self._check_mutable() - - columns, values = self.period_columns, period.as_tuple(string_datetimes=True) - - query, values = self.get_query( - "insert into %(table)s (:columns) values (:values)" % { - "table" : self.table_name - }, - columns, [to_string(v, "utf-8") for v in values]) - - self.cursor.execute(query, values) - - def insert_periods(self, periods): - - "Insert the given 'periods' into the collection." - - if not hasattr(self.cursor, "copy_from"): - return FreeBusyCollectionBase.insert_periods(self, periods) - - self._check_mutable() - - columns = self.merge_default_columns(self.period_columns) - - all_values = [] - for period in periods: - all_values.append(self.merge_default_values(period.as_tuple(string_datetimes=True))) - - f = to_copy_file(all_values) - - # Copy from the file-like object to the table. - - self.cursor.copy_from(f, self.table_name, columns=map(quote_column, columns)) - - def remove_periods(self, periods): - - "Remove the given 'periods' from the collection." - - self._check_mutable() - - for period in periods: - values = period.as_tuple(string_datetimes=True) - - query, values = self.get_query( - "delete from %(table)s :condition" % { - "table" : self.table_name - }, - self.period_columns, [to_string(v, "utf-8") for v in values]) - - self.cursor.execute(query, values) - - def remove_event_periods(self, uid, recurrenceid=None, participant=None): - - """ - Remove from the collection all periods associated with 'uid' and - 'recurrenceid' (which if omitted causes the "parent" object's periods to - be referenced). - - If 'participant' is specified, only remove periods for which the - participant is given as attending. - - Return the removed periods. - """ - - self._check_mutable() - - columns, values = ["object_uid"], [uid] - - if recurrenceid: - columns.append("object_recurrenceid") - values.append(recurrenceid) - else: - columns.append("object_recurrenceid is null") - - if participant: - columns.append("attendee") - values.append(participant) - - query, _values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, _values) - removed = self.cursor.fetchall() - - query, values = self.get_query( - "delete from %(table)s :condition" % { - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - return map(lambda t: self.make_period(t), removed) - - # Specific period removal when updating event details. - - remove_specific_event_periods = remove_event_periods - - def remove_additional_periods(self, uid, recurrenceids=None): - - """ - Remove from the collection all periods associated with 'uid' having a - recurrence identifier indicating an additional or modified period. - - If 'recurrenceids' is specified, remove all periods associated with - 'uid' that do not have a recurrence identifier in the given list. - - Return the removed periods. - """ - - self._check_mutable() - - if not recurrenceids: - columns, values = ["object_uid", "object_recurrenceid is not null"], [uid] - else: - columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)] - - query, _values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, _values) - removed = self.cursor.fetchall() - - query, values = self.get_query( - "delete from %(table)s :condition" % { - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - return map(lambda t: self.make_period(t), removed) - - def remove_affected_period(self, uid, start, participant=None): - - """ - Remove from the collection the period associated with 'uid' that - provides an occurrence starting at the given 'start' (provided by a - recurrence identifier, converted to a datetime). A recurrence identifier - is used to provide an alternative time period whilst also acting as a - reference to the originally-defined occurrence. - - If 'participant' is specified, only remove periods for which the - participant is given as attending. - - Return any removed period in a list. - """ - - self._check_mutable() - - start = format_datetime(start) - - columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start] - - if participant: - columns.append("attendee") - values.append(participant) - - query, _values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, _values) - removed = self.cursor.fetchall() - - query, values = self.get_query( - "delete from %(table)s :condition" % { - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - return map(lambda t: self.make_period(t), removed) - - def periods_from(self, period): - - "Return the entries in the collection at or after 'period'." - - start = format_datetime(period.get_start_point()) - - columns, values = [], [] - - if start: - columns.append("start >= ?") - values.append(start) - - query, values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - return map(lambda t: self.make_period(t), self.cursor.fetchall()) - - def periods_until(self, period): - - "Return the entries in the collection before 'period'." - - end = format_datetime(period.get_end_point()) - - columns, values = [], [] - - if end: - columns.append("start < ?") - values.append(end) - - query, values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - return map(lambda t: self.make_period(t), self.cursor.fetchall()) - - def get_overlapping(self, periods): - - """ - Return the entries in the collection providing periods overlapping with - the given sorted collection of 'periods'. - """ - - overlapping = set() - - for period in periods: - columns, values = self._get_period_values(period) - - query, values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - overlapping.update(map(lambda t: self.make_period(t), self.cursor.fetchall())) - - overlapping = list(overlapping) - overlapping.sort() - return overlapping - - def remove_overlapping(self, period): - - "Remove all periods overlapping with 'period' from the collection." - - self._check_mutable() - - columns, values = self._get_period_values(period) - - query, values = self.get_query( - "delete from %(table)s :condition" % { - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - def _get_period_values(self, period): - - start = format_datetime(period.get_start_point()) - end = format_datetime(period.get_end_point()) - - columns, values = [], [] - - if end: - columns.append("start < ?") - values.append(end) - if start: - columns.append("end > ?") - values.append(start) - - return columns, values - -class FreeBusyGroupDatabaseCollection(SupportAttendee, FreeBusyDatabaseCollection): - - "A collection of quota group free/busy objects." - - def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): - - """ - Remove from the collection all periods associated with 'uid' and - 'recurrenceid' (which if omitted causes the "parent" object's periods to - be referenced) and any 'attendee'. - - Return the removed periods. - """ - - self._check_mutable() - - columns, values = ["object_uid"], [uid] - - if recurrenceid: - columns.append("object_recurrenceid") - values.append(recurrenceid) - else: - columns.append("object_recurrenceid is null") - - if attendee: - columns.append("attendee") - values.append(attendee) - else: - columns.append("attendee is null") - - query, _values = self.get_query( - "select %(columns)s from %(table)s :condition" % { - "columns" : self.columnlist(self.period_columns), - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, _values) - removed = self.cursor.fetchall() - - query, values = self.get_query( - "delete from %(table)s :condition" % { - "table" : self.table_name - }, - columns, values) - - self.cursor.execute(query, values) - - return map(lambda t: self.make_period(t), removed) - -class FreeBusyOffersDatabaseCollection(SupportExpires, FreeBusyDatabaseCollection): - - "A collection of offered free/busy objects." - - pass - -# vim: tabstop=4 expandtab shiftwidth=4 diff -r 3c756db2eb7e -r b7c84c76b1e6 imiptools/freebusy/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/freebusy/__init__.py Fri May 26 18:25:23 2017 +0200 @@ -0,0 +1,30 @@ +#!/usr/bin/env python + +""" +Managing free/busy periods. + +Copyright (C) 2017 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.freebusy.common import FreeBusyPeriod, \ + FreeBusyGroupPeriod, \ + FreeBusyOfferPeriod, \ + FreeBusyCollection, \ + FreeBusyGroupCollection, \ + FreeBusyOffersCollection, \ + SupportAttendee, SupportExpires + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 3c756db2eb7e -r b7c84c76b1e6 imiptools/freebusy/common.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/freebusy/common.py Fri May 26 18:25:23 2017 +0200 @@ -0,0 +1,747 @@ +#!/usr/bin/env python + +""" +Managing free/busy periods. + +Copyright (C) 2014, 2015, 2016, 2017 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 bisect import bisect_left, bisect_right +from imiptools.dates import format_datetime +from imiptools.period import get_overlapping, Period, PeriodBase + +# Conversion functions. + +def from_string(s, encoding): + + "Interpret 's' using 'encoding', preserving None." + + if s: + return unicode(s, encoding) + else: + return s + +def to_string(s, encoding): + + "Encode 's' using 'encoding', preserving None." + + if s: + return s.encode(encoding) + else: + return s + + + +# Period abstractions. + +class FreeBusyPeriod(PeriodBase): + + "A free/busy record abstraction." + + def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, + summary=None, organiser=None): + + """ + Initialise a free/busy period with the given 'start' and 'end' points, + plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' + details. + """ + + PeriodBase.__init__(self, start, end) + self.uid = uid + self.transp = transp or None + self.recurrenceid = recurrenceid or None + self.summary = summary or None + self.organiser = organiser or None + + def as_tuple(self, strings_only=False, string_datetimes=False): + + """ + Return the initialisation parameter tuple, converting datetimes and + false value parameters to strings if 'strings_only' is set to a true + value. Otherwise, if 'string_datetimes' is set to a true value, only the + datetime values are converted to strings. + """ + + null = lambda x: (strings_only and [""] or [x])[0] + return ( + (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start, + (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end, + self.uid or null(self.uid), + self.transp or strings_only and "OPAQUE" or None, + self.recurrenceid or null(self.recurrenceid), + self.summary or null(self.summary), + self.organiser or null(self.organiser) + ) + + def __cmp__(self, other): + + """ + Compare this object to 'other', employing the uid if the periods + involved are the same. + """ + + result = PeriodBase.__cmp__(self, other) + if result == 0 and isinstance(other, FreeBusyPeriod): + return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid)) + else: + return result + + def get_key(self): + return self.uid, self.recurrenceid, self.get_start() + + def __repr__(self): + return "FreeBusyPeriod%r" % (self.as_tuple(),) + + def get_tzid(self): + return "UTC" + + # Period and event recurrence logic. + + def is_replaced(self, recurrences): + + """ + Return whether this period refers to one of the 'recurrences'. + The 'recurrences' must be UTC datetimes corresponding to the start of + the period described by a recurrence. + """ + + for recurrence in recurrences: + if self.is_affected(recurrence): + return True + return False + + def is_affected(self, recurrence): + + """ + Return whether this period refers to 'recurrence'. The 'recurrence' must + be a UTC datetime corresponding to the start of the period described by + a recurrence. + """ + + return recurrence and self.get_start_point() == recurrence + + # Value correction methods. + + def make_corrected(self, start, end): + return self.__class__(start, end) + +class FreeBusyOfferPeriod(FreeBusyPeriod): + + "A free/busy record abstraction for an offer period." + + def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, + summary=None, organiser=None, expires=None): + + """ + Initialise a free/busy period with the given 'start' and 'end' points, + plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' + details. + + An additional 'expires' parameter can be used to indicate an expiry + datetime in conjunction with free/busy offers made when countering + event proposals. + """ + + FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, + summary, organiser) + self.expires = expires or None + + def as_tuple(self, strings_only=False, string_datetimes=False): + + """ + Return the initialisation parameter tuple, converting datetimes and + false value parameters to strings if 'strings_only' is set to a true + value. Otherwise, if 'string_datetimes' is set to a true value, only the + datetime values are converted to strings. + """ + + null = lambda x: (strings_only and [""] or [x])[0] + return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( + self.expires or null(self.expires),) + + def __repr__(self): + return "FreeBusyOfferPeriod%r" % (self.as_tuple(),) + +class FreeBusyGroupPeriod(FreeBusyPeriod): + + "A free/busy record abstraction for a quota group period." + + def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, + summary=None, organiser=None, attendee=None): + + """ + Initialise a free/busy period with the given 'start' and 'end' points, + plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser' + details. + + An additional 'attendee' parameter can be used to indicate the identity + of the attendee recording the period. + """ + + FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid, + summary, organiser) + self.attendee = attendee or None + + def as_tuple(self, strings_only=False, string_datetimes=False): + + """ + Return the initialisation parameter tuple, converting datetimes and + false value parameters to strings if 'strings_only' is set to a true + value. Otherwise, if 'string_datetimes' is set to a true value, only the + datetime values are converted to strings. + """ + + null = lambda x: (strings_only and [""] or [x])[0] + return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + ( + self.attendee or null(self.attendee),) + + def __cmp__(self, other): + + """ + Compare this object to 'other', employing the uid if the periods + involved are the same. + """ + + result = FreeBusyPeriod.__cmp__(self, other) + if isinstance(other, FreeBusyGroupPeriod) and result == 0: + return cmp(self.attendee, other.attendee) + else: + return result + + def __repr__(self): + return "FreeBusyGroupPeriod%r" % (self.as_tuple(),) + +class FreeBusyCollectionBase: + + "Common operations on free/busy period collections." + + period_columns = [ + "start", "end", "object_uid", "transp", "object_recurrenceid", + "summary", "organiser" + ] + + period_class = FreeBusyPeriod + + def __init__(self, mutable=True): + self.mutable = mutable + + def _check_mutable(self): + if not self.mutable: + raise TypeError, "Cannot mutate this collection." + + def copy(self): + + "Make an independent mutable copy of the collection." + + return FreeBusyCollection(list(self), True) + + def make_period(self, t): + + """ + Make a period using the given tuple of arguments and the collection's + column details. + """ + + args = [] + for arg, column in zip(t, self.period_columns): + args.append(from_string(arg, "utf-8")) + return self.period_class(*args) + + def make_tuple(self, t): + + """ + Return a tuple from the given tuple 't' conforming to the collection's + column details. + """ + + args = [] + for arg, column in zip(t, self.period_columns): + args.append(arg) + return tuple(args) + + # List emulation methods. + + def __iadd__(self, periods): + self.insert_periods(periods) + return self + + def append(self, period): + self.insert_period(period) + + # Operations. + + def insert_periods(self, periods): + + "Insert the given 'periods' into the collection." + + for p in periods: + self.insert_period(p) + + def can_schedule(self, periods, uid, recurrenceid): + + """ + Return whether the collection can accommodate the given 'periods' + employing the specified 'uid' and 'recurrenceid'. + """ + + for conflict in self.have_conflict(periods, True): + if conflict.uid != uid or conflict.recurrenceid != recurrenceid: + return False + + return True + + def have_conflict(self, periods, get_conflicts=False): + + """ + Return whether any period in the collection overlaps with the given + 'periods', returning a collection of such overlapping periods if + 'get_conflicts' is set to a true value. + """ + + conflicts = set() + for p in periods: + overlapping = self.period_overlaps(p, get_conflicts) + if overlapping: + if get_conflicts: + conflicts.update(overlapping) + else: + return True + + if get_conflicts: + return conflicts + else: + return False + + def period_overlaps(self, period, get_periods=False): + + """ + Return whether any period in the collection overlaps with the given + 'period', returning a collection of overlapping periods if 'get_periods' + is set to a true value. + """ + + overlapping = self.get_overlapping([period]) + + if get_periods: + return overlapping + else: + return len(overlapping) != 0 + + def replace_overlapping(self, period, replacements): + + """ + Replace existing periods in the collection within the given 'period', + using the given 'replacements'. + """ + + self._check_mutable() + + self.remove_overlapping(period) + for replacement in replacements: + self.insert_period(replacement) + + def coalesce_freebusy(self): + + "Coalesce the periods in the collection, returning a new collection." + + if not self: + return FreeBusyCollection() + + fb = [] + + it = iter(self) + period = it.next() + + start = period.get_start_point() + end = period.get_end_point() + + try: + while True: + period = it.next() + if period.get_start_point() > end: + fb.append(self.period_class(start, end)) + start = period.get_start_point() + end = period.get_end_point() + else: + end = max(end, period.get_end_point()) + except StopIteration: + pass + + fb.append(self.period_class(start, end)) + return FreeBusyCollection(fb) + + def invert_freebusy(self): + + "Return the free periods from the collection as a new collection." + + if not self: + return FreeBusyCollection([self.period_class(None, None)]) + + # Coalesce periods that overlap or are adjacent. + + fb = self.coalesce_freebusy() + free = [] + + # Add a start-of-time period if appropriate. + + first = fb[0].get_start_point() + if first: + free.append(self.period_class(None, first)) + + start = fb[0].get_end_point() + + for period in fb[1:]: + free.append(self.period_class(start, period.get_start_point())) + start = period.get_end_point() + + # Add an end-of-time period if appropriate. + + if start: + free.append(self.period_class(start, None)) + + return FreeBusyCollection(free) + + def _update_freebusy(self, periods, uid, recurrenceid): + + """ + Update the free/busy details with the given 'periods', using the given + 'uid' plus 'recurrenceid' to remove existing periods. + """ + + self._check_mutable() + + self.remove_specific_event_periods(uid, recurrenceid) + + self.insert_periods(periods) + + def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser): + + """ + Update the free/busy details with the given 'periods', 'transp' setting, + 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. + """ + + new_periods = [] + + for p in periods: + new_periods.append( + self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser) + ) + + self._update_freebusy(new_periods, uid, recurrenceid) + +class SupportAttendee: + + "A mix-in that supports the affected attendee in free/busy periods." + + period_columns = FreeBusyCollectionBase.period_columns + ["attendee"] + period_class = FreeBusyGroupPeriod + + def _update_freebusy(self, periods, uid, recurrenceid, attendee=None): + + """ + Update the free/busy details with the given 'periods', using the given + 'uid' plus 'recurrenceid' and 'attendee' to remove existing periods. + """ + + self._check_mutable() + + self.remove_specific_event_periods(uid, recurrenceid, attendee) + + self.insert_periods(periods) + + def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, attendee=None): + + """ + Update the free/busy details with the given 'periods', 'transp' setting, + 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. + + An optional 'attendee' indicates the attendee affected by the period. + """ + + new_periods = [] + + for p in periods: + new_periods.append( + self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, attendee) + ) + + self._update_freebusy(new_periods, uid, recurrenceid, attendee) + +class SupportExpires: + + "A mix-in that supports the expiry datetime in free/busy periods." + + period_columns = FreeBusyCollectionBase.period_columns + ["expires"] + period_class = FreeBusyOfferPeriod + + def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): + + """ + Update the free/busy details with the given 'periods', 'transp' setting, + 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. + + An optional 'expires' datetime string indicates the expiry time of any + free/busy offer. + """ + + new_periods = [] + + for p in periods: + new_periods.append( + self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires) + ) + + self._update_freebusy(new_periods, uid, recurrenceid) + + + +# Simple abstractions suitable for use with file-based representations and as +# general copies of collections. + +class FreeBusyCollection(FreeBusyCollectionBase): + + "An abstraction for a collection of free/busy periods." + + def __init__(self, periods=None, mutable=True): + + """ + Initialise the collection with the given list of 'periods', or start an + empty collection if no list is given. If 'mutable' is indicated, the + collection may be changed; otherwise, an exception will be raised. + """ + + FreeBusyCollectionBase.__init__(self, mutable) + self.periods = periods or [] + + # List emulation methods. + + def __nonzero__(self): + return bool(self.periods) + + def __iter__(self): + return iter(self.periods) + + def __len__(self): + return len(self.periods) + + def __getitem__(self, i): + return self.periods[i] + + # Operations. + + def insert_period(self, period): + + "Insert the given 'period' into the collection." + + self._check_mutable() + + i = bisect_left(self.periods, period) + if i == len(self.periods): + self.periods.append(period) + elif self.periods[i] != period: + self.periods.insert(i, period) + + def remove_periods(self, periods): + + "Remove the given 'periods' from the collection." + + self._check_mutable() + + for period in periods: + i = bisect_left(self.periods, period) + if i < len(self.periods) and self.periods[i] == period: + del self.periods[i] + + def remove_event_periods(self, uid, recurrenceid=None, participant=None): + + """ + Remove from the collection all periods associated with 'uid' and + 'recurrenceid' (which if omitted causes the "parent" object's periods to + be referenced). + + If 'participant' is specified, only remove periods for which the + participant is given as attending. + + Return the removed periods. + """ + + self._check_mutable() + + removed = [] + i = 0 + while i < len(self.periods): + fb = self.periods[i] + + if fb.uid == uid and fb.recurrenceid == recurrenceid and \ + (not participant or participant == fb.attendee): + + removed.append(self.periods[i]) + del self.periods[i] + else: + i += 1 + + return removed + + # Specific period removal when updating event details. + + remove_specific_event_periods = remove_event_periods + + def remove_additional_periods(self, uid, recurrenceids=None): + + """ + Remove from the collection all periods associated with 'uid' having a + recurrence identifier indicating an additional or modified period. + + If 'recurrenceids' is specified, remove all periods associated with + 'uid' that do not have a recurrence identifier in the given list. + + Return the removed periods. + """ + + self._check_mutable() + + removed = [] + i = 0 + while i < len(self.periods): + fb = self.periods[i] + if fb.uid == uid and fb.recurrenceid and ( + recurrenceids is None or + recurrenceids is not None and fb.recurrenceid not in recurrenceids + ): + removed.append(self.periods[i]) + del self.periods[i] + else: + i += 1 + + return removed + + def remove_affected_period(self, uid, start, participant=None): + + """ + Remove from the collection the period associated with 'uid' that + provides an occurrence starting at the given 'start' (provided by a + recurrence identifier, converted to a datetime). A recurrence identifier + is used to provide an alternative time period whilst also acting as a + reference to the originally-defined occurrence. + + If 'participant' is specified, only remove periods for which the + participant is given as attending. + + Return any removed period in a list. + """ + + self._check_mutable() + + removed = [] + + search = Period(start, start) + found = bisect_left(self.periods, search) + + while found < len(self.periods): + fb = self.periods[found] + + # Stop looking if the start no longer matches the recurrence identifier. + + if fb.get_start_point() != search.get_start_point(): + break + + # If the period belongs to the parent object, remove it and return. + + if not fb.recurrenceid and uid == fb.uid and \ + (not participant or participant == fb.attendee): + + removed.append(self.periods[found]) + del self.periods[found] + break + + # Otherwise, keep looking for a matching period. + + found += 1 + + return removed + + def periods_from(self, period): + + "Return the entries in the collection at or after 'period'." + + first = bisect_left(self.periods, period) + return self.periods[first:] + + def periods_until(self, period): + + "Return the entries in the collection before 'period'." + + last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid())) + return self.periods[:last] + + def get_overlapping(self, periods): + + """ + Return the entries in the collection providing periods overlapping with + the given sorted collection of 'periods'. + """ + + return get_overlapping(self.periods, periods) + + def remove_overlapping(self, period): + + "Remove all periods overlapping with 'period' from the collection." + + self._check_mutable() + + overlapping = self.get_overlapping([period]) + + if overlapping: + for fb in overlapping: + self.periods.remove(fb) + +class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection): + + "A collection of quota group free/busy objects." + + def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): + + """ + Remove from the collection all periods associated with 'uid' and + 'recurrenceid' (which if omitted causes the "parent" object's periods to + be referenced) and any 'attendee'. + + Return the removed periods. + """ + + self._check_mutable() + + removed = [] + i = 0 + while i < len(self.periods): + fb = self.periods[i] + if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee: + removed.append(self.periods[i]) + del self.periods[i] + else: + i += 1 + + return removed + +class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection): + + "A collection of offered free/busy objects." + + pass + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 3c756db2eb7e -r b7c84c76b1e6 imiptools/freebusy/database.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/imiptools/freebusy/database.py Fri May 26 18:25:23 2017 +0200 @@ -0,0 +1,471 @@ +#!/usr/bin/env python + +""" +Managing free/busy period collections using database representations. + +Copyright (C) 2014, 2015, 2016, 2017 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.dates import format_datetime +from imiptools.freebusy.common import FreeBusyCollectionBase, SupportAttendee, \ + SupportExpires, from_string, to_string +from imiptools.sql import DatabaseOperations + +try: + from cStringIO import StringIO +except ImportError: + from StringIO import StringIO + +# Conversion functions. + +def to_copy_string(s, encoding): + + """ + Encode 's' using 'encoding' as a string suitable for use in tabular data + acceptable to the PostgreSQL COPY command with \N as null. + """ + + s = to_string(s, encoding) + return s is None and "\\N" or s + +def to_copy_file(records): + + """ + Encode the given 'records' and store them in a file-like object for use with + a tabular import mechanism. Return the file-like object. + """ + + io = StringIO() + for values in records: + l = [] + for v in values: + l.append(to_copy_string(v, "utf-8")) + io.write("\t".join(l)) + io.write("\n") + io.seek(0) + return io + +def quote_column(column): + + "Quote 'column' using the SQL keyword quoting notation." + + return '"%s"' % column + + + +# Collection abstractions. + +class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations): + + """ + An abstraction for a collection of free/busy periods stored in a database + system. + """ + + def __init__(self, cursor, table_name, column_names=None, filter_values=None, + mutable=True, paramstyle=None): + + """ + Initialise the collection with the given 'cursor' and with the + 'table_name', 'column_names' and 'filter_values' configuring the + selection of data. If 'mutable' is indicated, the collection may be + changed; otherwise, an exception will be raised. + """ + + FreeBusyCollectionBase.__init__(self, mutable) + DatabaseOperations.__init__(self, column_names, filter_values, paramstyle) + self.cursor = cursor + self.table_name = table_name + + # List emulation methods. + + def __nonzero__(self): + return len(self) and True or False + + def __iter__(self): + query, values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }) + self.cursor.execute(query, values) + return iter(map(lambda t: self.make_period(t), self.cursor.fetchall())) + + def __len__(self): + query, values = self.get_query( + "select count(*) from %(table)s :condition" % { + "table" : self.table_name + }) + self.cursor.execute(query, values) + result = self.cursor.fetchone() + return result and int(result[0]) or 0 + + def __getitem__(self, i): + return list(iter(self))[i] + + # Operations. + + def insert_period(self, period): + + "Insert the given 'period' into the collection." + + self._check_mutable() + + columns, values = self.period_columns, period.as_tuple(string_datetimes=True) + + query, values = self.get_query( + "insert into %(table)s (:columns) values (:values)" % { + "table" : self.table_name + }, + columns, [to_string(v, "utf-8") for v in values]) + + self.cursor.execute(query, values) + + def insert_periods(self, periods): + + "Insert the given 'periods' into the collection." + + if not hasattr(self.cursor, "copy_from"): + return FreeBusyCollectionBase.insert_periods(self, periods) + + self._check_mutable() + + columns = self.merge_default_columns(self.period_columns) + + all_values = [] + for period in periods: + all_values.append(self.merge_default_values(period.as_tuple(string_datetimes=True))) + + f = to_copy_file(all_values) + + # Copy from the file-like object to the table. + + self.cursor.copy_from(f, self.table_name, columns=map(quote_column, columns)) + + def remove_periods(self, periods): + + "Remove the given 'periods' from the collection." + + self._check_mutable() + + for period in periods: + values = period.as_tuple(string_datetimes=True) + + query, values = self.get_query( + "delete from %(table)s :condition" % { + "table" : self.table_name + }, + self.period_columns, [to_string(v, "utf-8") for v in values]) + + self.cursor.execute(query, values) + + def remove_event_periods(self, uid, recurrenceid=None, participant=None): + + """ + Remove from the collection all periods associated with 'uid' and + 'recurrenceid' (which if omitted causes the "parent" object's periods to + be referenced). + + If 'participant' is specified, only remove periods for which the + participant is given as attending. + + Return the removed periods. + """ + + self._check_mutable() + + columns, values = ["object_uid"], [uid] + + if recurrenceid: + columns.append("object_recurrenceid") + values.append(recurrenceid) + else: + columns.append("object_recurrenceid is null") + + if participant: + columns.append("attendee") + values.append(participant) + + query, _values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, _values) + removed = self.cursor.fetchall() + + query, values = self.get_query( + "delete from %(table)s :condition" % { + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + return map(lambda t: self.make_period(t), removed) + + # Specific period removal when updating event details. + + remove_specific_event_periods = remove_event_periods + + def remove_additional_periods(self, uid, recurrenceids=None): + + """ + Remove from the collection all periods associated with 'uid' having a + recurrence identifier indicating an additional or modified period. + + If 'recurrenceids' is specified, remove all periods associated with + 'uid' that do not have a recurrence identifier in the given list. + + Return the removed periods. + """ + + self._check_mutable() + + if not recurrenceids: + columns, values = ["object_uid", "object_recurrenceid is not null"], [uid] + else: + columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)] + + query, _values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, _values) + removed = self.cursor.fetchall() + + query, values = self.get_query( + "delete from %(table)s :condition" % { + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + return map(lambda t: self.make_period(t), removed) + + def remove_affected_period(self, uid, start, participant=None): + + """ + Remove from the collection the period associated with 'uid' that + provides an occurrence starting at the given 'start' (provided by a + recurrence identifier, converted to a datetime). A recurrence identifier + is used to provide an alternative time period whilst also acting as a + reference to the originally-defined occurrence. + + If 'participant' is specified, only remove periods for which the + participant is given as attending. + + Return any removed period in a list. + """ + + self._check_mutable() + + start = format_datetime(start) + + columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start] + + if participant: + columns.append("attendee") + values.append(participant) + + query, _values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, _values) + removed = self.cursor.fetchall() + + query, values = self.get_query( + "delete from %(table)s :condition" % { + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + return map(lambda t: self.make_period(t), removed) + + def periods_from(self, period): + + "Return the entries in the collection at or after 'period'." + + start = format_datetime(period.get_start_point()) + + columns, values = [], [] + + if start: + columns.append("start >= ?") + values.append(start) + + query, values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + return map(lambda t: self.make_period(t), self.cursor.fetchall()) + + def periods_until(self, period): + + "Return the entries in the collection before 'period'." + + end = format_datetime(period.get_end_point()) + + columns, values = [], [] + + if end: + columns.append("start < ?") + values.append(end) + + query, values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + return map(lambda t: self.make_period(t), self.cursor.fetchall()) + + def get_overlapping(self, periods): + + """ + Return the entries in the collection providing periods overlapping with + the given sorted collection of 'periods'. + """ + + overlapping = set() + + for period in periods: + columns, values = self._get_period_values(period) + + query, values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + overlapping.update(map(lambda t: self.make_period(t), self.cursor.fetchall())) + + overlapping = list(overlapping) + overlapping.sort() + return overlapping + + def remove_overlapping(self, period): + + "Remove all periods overlapping with 'period' from the collection." + + self._check_mutable() + + columns, values = self._get_period_values(period) + + query, values = self.get_query( + "delete from %(table)s :condition" % { + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + def _get_period_values(self, period): + + start = format_datetime(period.get_start_point()) + end = format_datetime(period.get_end_point()) + + columns, values = [], [] + + if end: + columns.append("start < ?") + values.append(end) + if start: + columns.append("end > ?") + values.append(start) + + return columns, values + +class FreeBusyGroupDatabaseCollection(SupportAttendee, FreeBusyDatabaseCollection): + + "A collection of quota group free/busy objects." + + def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None): + + """ + Remove from the collection all periods associated with 'uid' and + 'recurrenceid' (which if omitted causes the "parent" object's periods to + be referenced) and any 'attendee'. + + Return the removed periods. + """ + + self._check_mutable() + + columns, values = ["object_uid"], [uid] + + if recurrenceid: + columns.append("object_recurrenceid") + values.append(recurrenceid) + else: + columns.append("object_recurrenceid is null") + + if attendee: + columns.append("attendee") + values.append(attendee) + else: + columns.append("attendee is null") + + query, _values = self.get_query( + "select %(columns)s from %(table)s :condition" % { + "columns" : self.columnlist(self.period_columns), + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, _values) + removed = self.cursor.fetchall() + + query, values = self.get_query( + "delete from %(table)s :condition" % { + "table" : self.table_name + }, + columns, values) + + self.cursor.execute(query, values) + + return map(lambda t: self.make_period(t), removed) + +class FreeBusyOffersDatabaseCollection(SupportExpires, FreeBusyDatabaseCollection): + + "A collection of offered free/busy objects." + + pass + +# vim: tabstop=4 expandtab shiftwidth=4 diff -r 3c756db2eb7e -r b7c84c76b1e6 imiptools/stores/database/common.py --- a/imiptools/stores/database/common.py Fri May 26 16:52:25 2017 +0200 +++ b/imiptools/stores/database/common.py Fri May 26 18:25:23 2017 +0200 @@ -24,9 +24,9 @@ from datetime import datetime from imiptools.data import Object, parse_string, to_string from imiptools.dates import format_datetime, get_datetime, to_timezone -from imiptools.freebusy import FreeBusyDatabaseCollection, \ - FreeBusyGroupDatabaseCollection, \ - FreeBusyOffersDatabaseCollection +from imiptools.freebusy.database import FreeBusyDatabaseCollection, \ + FreeBusyGroupDatabaseCollection, \ + FreeBusyOffersDatabaseCollection from imiptools.sql import DatabaseOperations def first(l): return l[0] diff -r 3c756db2eb7e -r b7c84c76b1e6 imiptools/stores/file.py --- a/imiptools/stores/file.py Fri May 26 16:52:25 2017 +0200 +++ b/imiptools/stores/file.py Fri May 26 18:25:23 2017 +0200 @@ -27,8 +27,10 @@ from imiptools.dates import format_datetime, get_datetime, to_timezone from imiptools.filesys import fix_permissions, FileBase from imiptools.freebusy import FreeBusyPeriod, FreeBusyGroupPeriod, \ - FreeBusyOfferPeriod, FreeBusyCollection, \ - FreeBusyGroupCollection, FreeBusyOffersCollection + FreeBusyOfferPeriod, \ + FreeBusyCollection, \ + FreeBusyGroupCollection, \ + FreeBusyOffersCollection from imiptools.text import get_table, set_defaults from os.path import isdir, isfile, join from os import listdir, remove, rmdir diff -r 3c756db2eb7e -r b7c84c76b1e6 tools/install.sh --- a/tools/install.sh Fri May 26 16:52:25 2017 +0200 +++ b/tools/install.sh Fri May 26 18:25:23 2017 +0200 @@ -47,6 +47,7 @@ # Package modules. for DIR in "$INSTALL_DIR/imiptools" \ + "$INSTALL_DIR/imiptools/freebusy" \ "$INSTALL_DIR/imiptools/stores" \ "$INSTALL_DIR/imiptools/stores/database" \ "$INSTALL_DIR/imiptools/handlers" \ @@ -66,6 +67,7 @@ # Copy modules into the installation directory. cp imiptools/*.py "$INSTALL_DIR/imiptools/" +cp imiptools/freebusy/*.py "$INSTALL_DIR/imiptools/freebusy/" cp imiptools/stores/*.py "$INSTALL_DIR/imiptools/stores/" cp imiptools/stores/database/*.py "$INSTALL_DIR/imiptools/stores/database/" cp imiptools/handlers/*.py "$INSTALL_DIR/imiptools/handlers/" @@ -77,6 +79,10 @@ rm "$INSTALL_DIR/imiptools/handlers/scheduling.py"* fi +if [ -e "$INSTALL_DIR/imiptools/freebusy.py" ]; then + rm "$INSTALL_DIR/imiptools/freebusy.py"* +fi + if [ -e "$INSTALL_DIR/imip_store.py" ]; then rm "$INSTALL_DIR/imip_store.py"* fi diff -r 3c756db2eb7e -r b7c84c76b1e6 tools/make_freebusy.py --- a/tools/make_freebusy.py Fri May 26 16:52:25 2017 +0200 +++ b/tools/make_freebusy.py Fri May 26 18:25:23 2017 +0200 @@ -36,7 +36,7 @@ from codecs import getwriter from imiptools.config import settings from imiptools.client import Client -from imiptools.data import get_window_end, Object +from imiptools.data import get_window_end from imiptools.dates import get_default_timezone, to_utc_datetime from imiptools.freebusy import FreeBusyCollection, FreeBusyGroupCollection, \ FreeBusyGroupPeriod