# HG changeset patch # User Paul Boddie # Date 1457043647 -3600 # Node ID 67369fd525dbb1ff765818054e374e688278c29d # Parent 2264ab469f6dc753bbe477dda3c743d532fbf838 Introduced a common free/busy collection abstraction and a specific database collection class. diff -r 2264ab469f6d -r 67369fd525db imiptools/period.py --- a/imiptools/period.py Wed Mar 02 21:17:11 2016 +0100 +++ b/imiptools/period.py Thu Mar 03 23:20:47 2016 +0100 @@ -454,33 +454,12 @@ def make_corrected(self, start, end): return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr()) -class FreeBusyCollection: - - "An abstraction for a collection of free/busy periods." - - def __init__(self, periods=None): +class FreeBusyCollectionBase: - """ - Initialise the collection with the given list of 'periods', or start an - empty collection if no list is given. - """ - - self.periods = periods or [] + "Common operations on free/busy period collections." # List emulation methods. - def __list__(self): - return self.periods - - def __iter__(self): - return iter(self.periods) - - def __len__(self): - return len(self.periods) - - def __getitem__(self, i): - return self.periods[i] - def __iadd__(self, other): for period in other: self.insert_period(period) @@ -523,6 +502,137 @@ 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.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(FreeBusyPeriod(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(FreeBusyPeriod(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([FreeBusyPeriod(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(FreeBusyPeriod(None, first)) + + start = fb[0].get_end_point() + + for period in fb[1:]: + free.append(FreeBusyPeriod(start, period.get_start_point())) + start = period.get_end_point() + + # Add an end-of-time period if appropriate. + + if start: + free.append(FreeBusyPeriod(start, None)) + + return FreeBusyCollection(free) + + 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. + """ + + self.remove_event_periods(uid, recurrenceid) + + for p in periods: + self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) + +class FreeBusyCollection(FreeBusyCollectionBase): + + "An abstraction for a collection of free/busy periods." + + def __init__(self, periods=None): + + """ + Initialise the collection with the given list of 'periods', or start an + empty collection if no list is given. + """ + + 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." @@ -673,21 +783,6 @@ overlapping.sort() return overlapping - 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 remove_overlapping(self, period): "Remove all periods overlapping with 'period' from the collection." @@ -698,84 +793,245 @@ for fb in overlapping: self.periods.remove(fb) - def replace_overlapping(self, period, replacements): +class FreeBusyDatabaseCollection(FreeBusyCollectionBase): + + """ + An abstraction for a collection of free/busy periods stored in a database + system. + """ + + def __init__(self, cursor, table_name): """ - Replace existing periods in the collection within the given 'period', - using the given 'replacements'. + Initialise the collection with the given 'cursor' and 'table_name'. + """ + + self.cursor = cursor + self.table_name = table_name + + # Special database-related operations. + + def placeholders(self, values): + return ", ".join(["?"] * len(values)) + + def initialise(self): + + "Create the database table required to hold the collection." + + query = """\ +create table %(table)s ( + start varchar not null, + end varchar not null, + uid varchar, + transp varchar, + recurrenceid varchar, + summary varchar, + organiser varchar, + expires varchar + )""" % {"table" : self.table_name} + + self.cursor.execute(query) + + # List emulation methods. + + def __nonzero__(self): + query = "select count(*) from %(table)s" % {"table" : self.table_name} + self.cursor.execute(query) + result = self.cursor.fetchone() + return result[0] + + def __iter__(self): + query = "select * from %(table)s" % {"table" : self.table_name} + self.cursor.execute(query) + return iter(map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())) + + def __len__(self): + return len(list(iter(self))) + + def __getitem__(self, i): + return list(iter(self))[i] + + # Operations. + + def insert_period(self, period): + + "Insert the given 'period' into the collection." + + values = period.as_tuple(strings_only=True) + query = "insert into %(table)s values (%(columns)s)" % { + "table" : self.table_name, + "columns" : self.placeholders(values) + } + self.cursor.execute(query, values) + + def remove_periods(self, periods): + + "Remove the given 'periods' from the collection." + + for period in periods: + values = period.as_tuple(strings_only=True) + query = """\ +delete from %(table)s +where start = ? and end = ? and uid = ? and transp = ? and recurrenceid = ? and summary = ? and organiser = ? and expires = ? +""" % {"table" : self.table_name} + self.cursor.execute(query, values) + + def remove_event_periods(self, uid, recurrenceid=None): + + """ + Remove from the collection all periods associated with 'uid' and + 'recurrenceid' (which if omitted causes the "parent" object's periods to + be referenced). + + Return the removed periods. """ - self.remove_overlapping(period) - for replacement in replacements: - self.insert_period(replacement) + if recurrenceid: + condition = "where uid = ? and recurrenceid = ?" + values = (uid, recurrenceid) + else: + condition = "where uid = ?" + values = (uid,) - def coalesce_freebusy(self): - - "Coalesce the periods in the collection, returning a new collection." - - if not self.periods: - return FreeBusyCollection(self.periods) + query = "select * from %(table)s for update %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + removed = self.cursor.fetchall() - fb = [] - start = self.periods[0].get_start_point() - end = self.periods[0].get_end_point() + query = "delete from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) - for period in self.periods[1:]: - if period.get_start_point() > end: - fb.append(FreeBusyPeriod(start, end)) - start = period.get_start_point() - end = period.get_end_point() - else: - end = max(end, period.get_end_point()) + return map(lambda t: FreeBusyPeriod(*t), removed) + + def remove_additional_periods(self, uid, recurrenceids=None): - fb.append(FreeBusyPeriod(start, end)) - return FreeBusyCollection(fb) - - def invert_freebusy(self): + """ + Remove from the collection all periods associated with 'uid' having a + recurrence identifier indicating an additional or modified period. - "Return the free periods from the collection as a new collection." + If 'recurrenceids' is specified, remove all periods associated with + 'uid' that do not have a recurrence identifier in the given list. - if not self.periods: - return FreeBusyCollection([FreeBusyPeriod(None, None)]) + Return the removed periods. + """ - # Coalesce periods that overlap or are adjacent. - - fb = self.coalesce_freebusy() - free = [] - - # Add a start-of-time period if appropriate. + if recurrenceids is None: + condition = "where uid = ? and recurrenceid is not null" + values = (uid,) + else: + condition = "where uid = ? and recurrenceid is not null and recurrenceid not in ?" + values = (uid, recurrenceid) - first = fb[0].get_start_point() - if first: - free.append(FreeBusyPeriod(None, first)) - - start = fb[0].get_end_point() + query = "select * from %(table)s for update %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + removed = self.cursor.fetchall() - for period in fb[1:]: - free.append(FreeBusyPeriod(start, period.get_start_point())) - start = period.get_end_point() - - # Add an end-of-time period if appropriate. + query = "delete from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) - if start: - free.append(FreeBusyPeriod(start, None)) + return map(lambda t: FreeBusyPeriod(*t), removed) - return FreeBusyCollection(free) - - def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None): + def remove_affected_period(self, uid, start): """ - Update the free/busy details with the given 'periods', 'transp' setting, - 'uid' plus 'recurrenceid' and 'summary' and 'organiser' details. + 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. - An optional 'expires' datetime string indicates the expiry time of any - free/busy offer. + Return any removed period in a list. """ - self.remove_event_periods(uid, recurrenceid) + condition = "where uid = ? and start = ? and recurrenceid is null" + values = (uid, start) + + query = "select * from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + removed = self.cursor.fetchall() + + query = "delete from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + + return map(lambda t: FreeBusyPeriod(*t), removed) + + def periods_from(self, period): + + "Return the entries in the collection at or after 'period'." + + condition = "where start >= ?" + values = (format_datetime(period.get_start_point()),) + + query = "select * from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + + return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()) + + def periods_until(self, period): + + "Return the entries in the collection before 'period'." - for p in periods: - self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)) + condition = "where start < ?" + values = (format_datetime(period.get_end_point()),) + + query = "select * from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + + return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()) + + def get_overlapping(self, period): + + """ + Return the entries in the collection providing periods overlapping with + 'period'. + """ + + condition = "where start < ? and end > ?" + values = (format_datetime(period.get_end_point()), format_datetime(period.get_start_point())) + + query = "select * from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) + + return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()) + + def remove_overlapping(self, period): + + "Remove all periods overlapping with 'period' from the collection." + + condition = "where start < ? and end > ?" + values = (format_datetime(period.get_end_point()), format_datetime(period.get_start_point())) + + query = "delete from %(table)s %(condition)s" % { + "table" : self.table_name, + "condition" : condition + } + self.cursor.execute(query, values) # Period layout.