imip-agent

imiptools/period.py

1078:3a034fc1ca8d
2016-03-07 Paul Boddie Introduced a more extensive query building mechanism. freebusy-collections
     1 #!/usr/bin/env python     2      3 """     4 Managing and presenting periods of time.     5      6 Copyright (C) 2014, 2015, 2016 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 from bisect import bisect_left, bisect_right, insort_left    23 from datetime import date, datetime, timedelta    24 from imiptools.dates import check_permitted_values, correct_datetime, \    25                             format_datetime, get_datetime, \    26                             get_datetime_attributes, \    27                             get_recurrence_start, get_recurrence_start_point, \    28                             get_start_of_day, \    29                             get_tzid, \    30                             to_timezone, to_utc_datetime    31 from imiptools.sql import DatabaseOperations    32     33 def ifnone(x, y):    34     if x is None: return y    35     else: return x    36     37 class Comparable:    38     39     "A date/datetime wrapper that allows comparisons with other types."    40     41     def __init__(self, dt):    42         self.dt = dt    43     44     def __cmp__(self, other):    45         dt = None    46         odt = None    47     48         # Find any dates/datetimes.    49     50         if isinstance(self.dt, date):    51             dt = self.dt    52         if isinstance(other, date):    53             odt = other    54         elif isinstance(other, Comparable):    55             if isinstance(other.dt, date):    56                 odt = other.dt    57             else:    58                 other = other.dt    59     60         if dt and odt:    61             return cmp(dt, odt)    62         elif dt:    63             return other.__rcmp__(dt)    64         elif odt:    65             return self.dt.__cmp__(odt)    66         else:    67             return self.dt.__cmp__(other)    68     69 class PointInTime:    70     71     "A base class for special values."    72     73     pass    74     75 class StartOfTime(PointInTime):    76     77     "A special value that compares earlier than other values."    78     79     def __cmp__(self, other):    80         if isinstance(other, StartOfTime):    81             return 0    82         else:    83             return -1    84     85     def __rcmp__(self, other):    86         return -self.__cmp__(other)    87     88     def __nonzero__(self):    89         return False    90     91     def __hash__(self):    92         return 0    93     94 class EndOfTime(PointInTime):    95     96     "A special value that compares later than other values."    97     98     def __cmp__(self, other):    99         if isinstance(other, EndOfTime):   100             return 0   101         else:   102             return 1   103    104     def __rcmp__(self, other):   105         return -self.__cmp__(other)   106    107     def __nonzero__(self):   108         return False   109    110     def __hash__(self):   111         return 0   112    113 class Endless:   114    115     "A special value indicating an endless period."   116    117     def __cmp__(self, other):   118         if isinstance(other, Endless):   119             return 0   120         else:   121             return 1   122    123     def __rcmp__(self, other):   124         return -self.__cmp__(other)   125    126     def __nonzero__(self):   127         return True   128    129 class PeriodBase:   130    131     "A basic period abstraction."   132    133     def __init__(self, start, end):   134         if isinstance(start, (date, PointInTime)):   135             self.start = start   136         else:   137             self.start = get_datetime(start) or StartOfTime()   138         if isinstance(end, (date, PointInTime)):   139             self.end = end   140         else:   141             self.end = get_datetime(end) or EndOfTime()   142    143     def as_tuple(self):   144         return self.start, self.end   145    146     def __hash__(self):   147         return hash((self.get_start(), self.get_end()))   148    149     def __cmp__(self, other):   150    151         "Return a comparison result against 'other' using points in time."   152    153         if isinstance(other, PeriodBase):   154             return cmp(   155                 (Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(self.get_end_point(), EndOfTime()))),   156                 (Comparable(ifnone(other.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime())))   157                 )   158         else:   159             return 1   160    161     def overlaps(self, other):   162         return Comparable(ifnone(self.get_end_point(), EndOfTime())) > Comparable(ifnone(other.get_start_point(), StartOfTime())) and \   163                Comparable(ifnone(self.get_start_point(), StartOfTime())) < Comparable(ifnone(other.get_end_point(), EndOfTime()))   164    165     def within(self, other):   166         return Comparable(ifnone(self.get_start_point(), StartOfTime())) >= Comparable(ifnone(other.get_start_point(), StartOfTime())) and \   167                Comparable(ifnone(self.get_end_point(), EndOfTime())) <= Comparable(ifnone(other.get_end_point(), EndOfTime()))   168    169     def common(self, other):   170         start = max(Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_start_point(), StartOfTime())))   171         end = min(Comparable(ifnone(self.get_end_point(), EndOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime())))   172         if start <= end:   173             return self.make_corrected(start.dt, end.dt)   174         else:   175             return None   176    177     def get_key(self):   178         return self.get_start(), self.get_end()   179    180     # Datetime and metadata methods.   181    182     def get_start(self):   183         return self.start   184    185     def get_end(self):   186         return self.end   187    188     def get_start_attr(self):   189         return get_datetime_attributes(self.start, self.tzid)   190    191     def get_end_attr(self):   192         return get_datetime_attributes(self.end, self.tzid)   193    194     def get_start_item(self):   195         return self.get_start(), self.get_start_attr()   196    197     def get_end_item(self):   198         return self.get_end(), self.get_end_attr()   199    200     def get_start_point(self):   201         return self.start   202    203     def get_end_point(self):   204         return self.end   205    206     def get_duration(self):   207         start = self.get_start_point()   208         end = self.get_end_point()   209         if start and end:   210             return end - start   211         else:   212             return Endless()   213    214 class Period(PeriodBase):   215    216     "A simple period abstraction."   217    218     def __init__(self, start, end, tzid=None, origin=None):   219    220         """   221         Initialise a period with the given 'start' and 'end', having a   222         contextual 'tzid', if specified, and an indicated 'origin'.   223    224         All metadata from the start and end points are derived from the supplied   225         dates/datetimes.   226         """   227    228         PeriodBase.__init__(self, start, end)   229         self.tzid = tzid   230         self.origin = origin   231    232     def as_tuple(self):   233         return self.start, self.end, self.tzid, self.origin   234    235     def __repr__(self):   236         return "Period%r" % (self.as_tuple(),)   237    238     # Datetime and metadata methods.   239    240     def get_tzid(self):   241         return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid   242    243     def get_start_point(self):   244         start = self.get_start()   245         if isinstance(start, PointInTime): return start   246         else: return to_utc_datetime(start, self.get_tzid())   247    248     def get_end_point(self):   249         end = self.get_end()   250         if isinstance(end, PointInTime): return end   251         else: return to_utc_datetime(end, self.get_tzid())   252    253     # Period and event recurrence logic.   254    255     def is_replaced(self, recurrenceids):   256    257         """   258         Return whether this period refers to one of the 'recurrenceids'.   259         The 'recurrenceids' should be normalised to UTC datetimes according to   260         time zone information provided by their objects or be floating dates or   261         datetimes requiring conversion using contextual time zone information.   262         """   263    264         for recurrenceid in recurrenceids:   265             if self.is_affected(recurrenceid):   266                 return recurrenceid   267         return None   268    269     def is_affected(self, recurrenceid):   270    271         """   272         Return whether this period refers to 'recurrenceid'. The 'recurrenceid'   273         should be normalised to UTC datetimes according to time zone information   274         provided by their objects. Otherwise, this period's contextual time zone   275         information is used to convert any date or floating datetime   276         representation to a point in time.   277         """   278    279         if not recurrenceid:   280             return None   281         d = get_recurrence_start(recurrenceid)   282         dt = get_recurrence_start_point(recurrenceid, self.tzid)   283         if self.get_start() == d or self.get_start_point() == dt:   284             return recurrenceid   285         return None   286    287     # Value correction methods.   288    289     def with_duration(self, duration):   290    291         """   292         Return a version of this period with the same start point but with the   293         given 'duration'.   294         """   295    296         return self.make_corrected(self.get_start(), self.get_start() + duration)   297    298     def check_permitted(self, permitted_values):   299    300         "Check the period against the given 'permitted_values'."   301    302         start = self.get_start()   303         end = self.get_end()   304         start_errors = check_permitted_values(start, permitted_values)   305         end_errors = check_permitted_values(end, permitted_values)   306    307         if not (start_errors or end_errors):   308             return None   309    310         return start_errors, end_errors   311    312     def get_corrected(self, permitted_values):   313    314         "Return a corrected version of this period."   315    316         errors = self.check_permitted(permitted_values)   317    318         if not errors:   319             return self   320    321         start_errors, end_errors = errors   322    323         start = self.get_start()   324         end = self.get_end()   325    326         if start_errors:   327             start = correct_datetime(start, permitted_values)   328         if end_errors:   329             end = correct_datetime(end, permitted_values)   330    331         return self.make_corrected(start, end)   332    333     def make_corrected(self, start, end):   334         return self.__class__(start, end, self.tzid, self.origin)   335    336 class FreeBusyPeriod(PeriodBase):   337    338     "A free/busy record abstraction."   339    340     def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, expires=None):   341    342         """   343         Initialise a free/busy period with the given 'start' and 'end' points,   344         plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'   345         details.   346    347         An additional 'expires' parameter can be used to indicate an expiry   348         datetime in conjunction with free/busy offers made when countering   349         event proposals.   350         """   351    352         PeriodBase.__init__(self, start, end)   353         self.uid = uid   354         self.transp = transp or None   355         self.recurrenceid = recurrenceid or None   356         self.summary = summary or None   357         self.organiser = organiser or None   358         self.expires = expires or None   359    360     def as_tuple(self, strings_only=False, string_datetimes=False):   361    362         """   363         Return the initialisation parameter tuple, converting datetimes and   364         false value parameters to strings if 'strings_only' is set to a true   365         value. Otherwise, if 'string_datetimes' is set to a true value, only the   366         datetime values are converted to strings.   367         """   368    369         null = lambda x: (strings_only and [""] or [x])[0]   370         return (   371             (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start,   372             (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end,   373             self.uid or null(self.uid),   374             self.transp or strings_only and "OPAQUE" or None,   375             self.recurrenceid or null(self.recurrenceid),   376             self.summary or null(self.summary),   377             self.organiser or null(self.organiser),   378             self.expires or null(self.expires)   379             )   380    381     def __cmp__(self, other):   382    383         """   384         Compare this object to 'other', employing the uid if the periods   385         involved are the same.   386         """   387    388         result = PeriodBase.__cmp__(self, other)   389         if result == 0 and isinstance(other, FreeBusyPeriod):   390             return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid))   391         else:   392             return result   393    394     def get_key(self):   395         return self.uid, self.recurrenceid, self.get_start()   396    397     def __repr__(self):   398         return "FreeBusyPeriod%r" % (self.as_tuple(),)   399    400     def get_tzid(self):   401         return "UTC"   402    403     # Period and event recurrence logic.   404    405     def is_replaced(self, recurrences):   406    407         """   408         Return whether this period refers to one of the 'recurrences'.   409         The 'recurrences' must be UTC datetimes corresponding to the start of   410         the period described by a recurrence.   411         """   412    413         for recurrence in recurrences:   414             if self.is_affected(recurrence):   415                 return True   416         return False   417    418     def is_affected(self, recurrence):   419    420         """   421         Return whether this period refers to 'recurrence'. The 'recurrence' must   422         be a UTC datetime corresponding to the start of the period described by   423         a recurrence.   424         """   425    426         return recurrence and self.get_start_point() == recurrence   427    428     # Value correction methods.   429    430     def make_corrected(self, start, end):   431         return self.__class__(start, end)   432    433 class RecurringPeriod(Period):   434        435     """   436     A period with iCalendar metadata attributes and origin information from an   437     object.   438     """   439        440     def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None):   441         Period.__init__(self, start, end, tzid, origin)   442         self.start_attr = start_attr   443         self.end_attr = end_attr   444    445     def get_start_attr(self):   446         return self.start_attr   447    448     def get_end_attr(self):   449         return self.end_attr   450    451     def as_tuple(self):   452         return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr   453        454     def __repr__(self):   455         return "RecurringPeriod%r" % (self.as_tuple(),)   456    457     def make_corrected(self, start, end):   458         return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr())   459    460 class FreeBusyCollectionBase:   461    462     "Common operations on free/busy period collections."   463    464     def __init__(self, mutable=True):   465         self.mutable = mutable   466    467     def _check_mutable(self):   468         if not self.mutable:   469             raise TypeError, "Cannot mutate this collection."   470    471     def copy(self):   472    473         "Make an independent mutable copy of the collection."   474    475         return FreeBusyCollection(list(self), True)   476    477     # List emulation methods.   478    479     def __iadd__(self, other):   480         for period in other:   481             self.insert_period(period)   482         return self   483    484     # Operations.   485    486     def can_schedule(self, periods, uid, recurrenceid):   487    488         """   489         Return whether the collection can accommodate the given 'periods'   490         employing the specified 'uid' and 'recurrenceid'.   491         """   492    493         for conflict in self.have_conflict(periods, True):   494             if conflict.uid != uid or conflict.recurrenceid != recurrenceid:   495                 return False   496    497         return True   498    499     def have_conflict(self, periods, get_conflicts=False):   500    501         """   502         Return whether any period in the collection overlaps with the given   503         'periods', returning a collection of such overlapping periods if   504         'get_conflicts' is set to a true value.   505         """   506    507         conflicts = set()   508         for p in periods:   509             overlapping = self.period_overlaps(p, get_conflicts)   510             if overlapping:   511                 if get_conflicts:   512                     conflicts.update(overlapping)   513                 else:   514                     return True   515    516         if get_conflicts:   517             return conflicts   518         else:   519             return False   520    521     def period_overlaps(self, period, get_periods=False):   522    523         """   524         Return whether any period in the collection overlaps with the given   525         'period', returning a collection of overlapping periods if 'get_periods'   526         is set to a true value.   527         """   528    529         overlapping = self.get_overlapping(period)   530    531         if get_periods:   532             return overlapping   533         else:   534             return len(overlapping) != 0   535    536     def replace_overlapping(self, period, replacements):   537    538         """   539         Replace existing periods in the collection within the given 'period',   540         using the given 'replacements'.   541         """   542    543         self._check_mutable()   544    545         self.remove_overlapping(period)   546         for replacement in replacements:   547             self.insert_period(replacement)   548    549     def coalesce_freebusy(self):   550    551         "Coalesce the periods in the collection, returning a new collection."   552    553         if not self:   554             return FreeBusyCollection()   555    556         fb = []   557    558         it = iter(self)   559         period = it.next()   560    561         start = period.get_start_point()   562         end = period.get_end_point()   563    564         try:   565             while True:   566                 period = it.next()   567                 if period.get_start_point() > end:   568                     fb.append(FreeBusyPeriod(start, end))   569                     start = period.get_start_point()   570                     end = period.get_end_point()   571                 else:   572                     end = max(end, period.get_end_point())   573         except StopIteration:   574             pass   575    576         fb.append(FreeBusyPeriod(start, end))   577         return FreeBusyCollection(fb)   578    579     def invert_freebusy(self):   580    581         "Return the free periods from the collection as a new collection."   582    583         if not self:   584             return FreeBusyCollection([FreeBusyPeriod(None, None)])   585    586         # Coalesce periods that overlap or are adjacent.   587    588         fb = self.coalesce_freebusy()   589         free = []   590    591         # Add a start-of-time period if appropriate.   592    593         first = fb[0].get_start_point()   594         if first:   595             free.append(FreeBusyPeriod(None, first))   596    597         start = fb[0].get_end_point()   598    599         for period in fb[1:]:   600             free.append(FreeBusyPeriod(start, period.get_start_point()))   601             start = period.get_end_point()   602    603         # Add an end-of-time period if appropriate.   604    605         if start:   606             free.append(FreeBusyPeriod(start, None))   607    608         return FreeBusyCollection(free)   609    610     def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None):   611    612         """   613         Update the free/busy details with the given 'periods', 'transp' setting,   614         'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.   615    616         An optional 'expires' datetime string indicates the expiry time of any   617         free/busy offer.   618         """   619    620         self._check_mutable()   621    622         self.remove_event_periods(uid, recurrenceid)   623    624         for p in periods:   625             self.insert_period(FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires))   626    627 class FreeBusyCollection(FreeBusyCollectionBase):   628    629     "An abstraction for a collection of free/busy periods."   630    631     def __init__(self, periods=None, mutable=True):   632    633         """   634         Initialise the collection with the given list of 'periods', or start an   635         empty collection if no list is given.   636         """   637    638         FreeBusyCollectionBase.__init__(self, mutable)   639         self.periods = periods or []   640    641     # List emulation methods.   642    643     def __nonzero__(self):   644         return bool(self.periods)   645    646     def __iter__(self):   647         return iter(self.periods)   648    649     def __len__(self):   650         return len(self.periods)   651    652     def __getitem__(self, i):   653         return self.periods[i]   654    655     # Operations.   656    657     def insert_period(self, period):   658    659         "Insert the given 'period' into the collection."   660    661         self._check_mutable()   662    663         i = bisect_left(self.periods, period)   664         if i == len(self.periods):   665             self.periods.append(period)   666         elif self.periods[i] != period:   667             self.periods.insert(i, period)   668    669     def remove_periods(self, periods):   670    671         "Remove the given 'periods' from the collection."   672    673         self._check_mutable()   674    675         for period in periods:   676             i = bisect_left(self.periods, period)   677             if i < len(self.periods) and self.periods[i] == period:   678                 del self.periods[i]   679    680     def remove_event_periods(self, uid, recurrenceid=None):   681    682         """   683         Remove from the collection all periods associated with 'uid' and   684         'recurrenceid' (which if omitted causes the "parent" object's periods to   685         be referenced).   686    687         Return the removed periods.   688         """   689    690         self._check_mutable()   691    692         removed = []   693         i = 0   694         while i < len(self.periods):   695             fb = self.periods[i]   696             if fb.uid == uid and fb.recurrenceid == recurrenceid:   697                 removed.append(self.periods[i])   698                 del self.periods[i]   699             else:   700                 i += 1   701    702         return removed   703    704     def remove_additional_periods(self, uid, recurrenceids=None):   705    706         """   707         Remove from the collection all periods associated with 'uid' having a   708         recurrence identifier indicating an additional or modified period.   709    710         If 'recurrenceids' is specified, remove all periods associated with   711         'uid' that do not have a recurrence identifier in the given list.   712    713         Return the removed periods.   714         """   715    716         self._check_mutable()   717    718         removed = []   719         i = 0   720         while i < len(self.periods):   721             fb = self.periods[i]   722             if fb.uid == uid and fb.recurrenceid and (   723                 recurrenceids is None or   724                 recurrenceids is not None and fb.recurrenceid not in recurrenceids   725                 ):   726                 removed.append(self.periods[i])   727                 del self.periods[i]   728             else:   729                 i += 1   730    731         return removed   732    733     def remove_affected_period(self, uid, start):   734    735         """   736         Remove from the collection the period associated with 'uid' that   737         provides an occurrence starting at the given 'start' (provided by a   738         recurrence identifier, converted to a datetime). A recurrence identifier   739         is used to provide an alternative time period whilst also acting as a   740         reference to the originally-defined occurrence.   741    742         Return any removed period in a list.   743         """   744    745         self._check_mutable()   746    747         removed = []   748    749         search = Period(start, start)   750         found = bisect_left(self.periods, search)   751    752         while found < len(self.periods):   753             fb = self.periods[found]   754    755             # Stop looking if the start no longer matches the recurrence identifier.   756    757             if fb.get_start_point() != search.get_start_point():   758                 break   759    760             # If the period belongs to the parent object, remove it and return.   761    762             if not fb.recurrenceid and uid == fb.uid:   763                 removed.append(self.periods[found])   764                 del self.periods[found]   765                 break   766    767             # Otherwise, keep looking for a matching period.   768    769             found += 1   770    771         return removed   772    773     def periods_from(self, period):   774    775         "Return the entries in the collection at or after 'period'."   776    777         first = bisect_left(self.periods, period)   778         return self.periods[first:]   779    780     def periods_until(self, period):   781    782         "Return the entries in the collection before 'period'."   783    784         last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid()))   785         return self.periods[:last]   786    787     def get_overlapping(self, period):   788    789         """   790         Return the entries in the collection providing periods overlapping with   791         'period'.   792         """   793    794         # Find the range of periods potentially overlapping the period in the   795         # free/busy collection.   796    797         startpoints = self.periods_until(period)   798    799         # Find the range of periods potentially overlapping the period in a version   800         # of the free/busy collection sorted according to end datetimes.   801    802         endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints]   803         endpoints.sort()   804         first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),))   805         endpoints = endpoints[first:]   806    807         overlapping = set()   808    809         for p, fb in endpoints:   810             if fb.overlaps(period):   811                 overlapping.add(fb)   812    813         overlapping = list(overlapping)   814         overlapping.sort()   815         return overlapping   816    817     def remove_overlapping(self, period):   818    819         "Remove all periods overlapping with 'period' from the collection."   820    821         self._check_mutable()   822    823         overlapping = self.get_overlapping(period)   824    825         if overlapping:   826             for fb in overlapping:   827                 self.periods.remove(fb)   828    829 class FreeBusyDatabaseCollection(FreeBusyCollectionBase, DatabaseOperations):   830    831     """   832     An abstraction for a collection of free/busy periods stored in a database   833     system.   834     """   835    836     period_columns = ["start", "end", "object_uid", "transp", "object_recurrenceid", "summary", "organiser", "expires"]   837    838     def __init__(self, cursor, table_name, column_names=None, filter_values=None, mutable=True):   839    840         """   841         Initialise the collection with the given 'cursor' and with the   842         'table_name', 'column_names' and 'filter_values' configuring the   843         selection of data.   844         """   845    846         FreeBusyCollectionBase.__init__(self, mutable)   847         DatabaseOperations.__init__(self, column_names, filter_values)   848         self.cursor = cursor   849         self.table_name = table_name   850    851     # List emulation methods.   852    853     def __nonzero__(self):   854         return len(self) and True or False   855    856     def __iter__(self):   857         query, values = self.get_query(   858             "select %(columns)s from %(table)s :condition" % {   859                 "columns" : ", ".join(self.period_columns),   860                 "table" : self.table_name   861                 })   862         self.cursor.execute(query, values)   863         return iter(map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall()))   864    865     def __len__(self):   866         query, values = self.get_query(   867             "select count(*) from %(table)s :condition" % {   868                 "table" : self.table_name   869                 })   870         self.cursor.execute(query, values)   871         result = self.cursor.fetchone()   872         return result and result[0] or 0   873    874     def __getitem__(self, i):   875         return list(iter(self))[i]   876    877     # Operations.   878    879     def insert_period(self, period):   880    881         "Insert the given 'period' into the collection."   882    883         self._check_mutable()   884    885         columns, values = self.period_columns, period.as_tuple(string_datetimes=True)   886    887         query, values = self.get_query(   888             "insert into %(table)s (:columns) values (:values)" % {   889                 "table" : self.table_name   890                 },   891             columns, values)   892    893         self.cursor.execute(query, values)   894    895     def remove_periods(self, periods):   896    897         "Remove the given 'periods' from the collection."   898    899         self._check_mutable()   900    901         for period in periods:   902             query, values = self.get_query(   903                 "delete from %(table)s :condition" % {   904                     "table" : self.table_name   905                     },   906                 self.period_columns, period.as_tuple(string_datetimes=True))   907             self.cursor.execute(query, values)   908    909     def remove_event_periods(self, uid, recurrenceid=None):   910    911         """   912         Remove from the collection all periods associated with 'uid' and   913         'recurrenceid' (which if omitted causes the "parent" object's periods to   914         be referenced).   915    916         Return the removed periods.   917         """   918    919         self._check_mutable()   920    921         if recurrenceid:   922             columns, values = ["object_uid", "object_recurrenceid"], [uid, recurrenceid]   923         else:   924             columns, values = ["object_uid"], [uid]   925    926         query, values = self.get_query(   927             "select %(columns)s from %(table)s :condition" % {   928                 "columns" : ", ".join(self.period_columns),   929                 "table" : self.table_name   930                 },   931             columns, values)   932    933         self.cursor.execute(query, values)   934         removed = self.cursor.fetchall()   935    936         query = "delete from %(table)s %(condition)s" % {   937             "table" : self.table_name,   938             "condition" : condition   939             }   940         self.cursor.execute(query, values)   941    942         return map(lambda t: FreeBusyPeriod(*t), removed)   943    944     def remove_additional_periods(self, uid, recurrenceids=None):   945    946         """   947         Remove from the collection all periods associated with 'uid' having a   948         recurrence identifier indicating an additional or modified period.   949    950         If 'recurrenceids' is specified, remove all periods associated with   951         'uid' that do not have a recurrence identifier in the given list.   952    953         Return the removed periods.   954         """   955    956         self._check_mutable()   957    958         if recurrenceids is None:   959             columns, values = ["object_uid", "object_recurrenceid is not null"], [uid]   960         else:   961             columns, values = ["object_uid", "object_recurrenceid not in", "object_recurrenceid is not null"], [uid, recurrenceid]   962    963         query, values = self.get_query(   964             "select %(columns)s from %(table)s :condition" % {   965                 "columns" : ", ".join(self.period_columns),   966                 "table" : self.table_name   967                 },   968             columns, values)   969    970         self.cursor.execute(query, values)   971         removed = self.cursor.fetchall()   972    973         query = self.get_query(   974             "delete from %(table)s %(condition)s" % {   975                 "table" : self.table_name   976                 },   977             columns, values)   978    979         self.cursor.execute(query, values)   980    981         return map(lambda t: FreeBusyPeriod(*t), removed)   982    983     def remove_affected_period(self, uid, start):   984    985         """   986         Remove from the collection the period associated with 'uid' that   987         provides an occurrence starting at the given 'start' (provided by a   988         recurrence identifier, converted to a datetime). A recurrence identifier   989         is used to provide an alternative time period whilst also acting as a   990         reference to the originally-defined occurrence.   991    992         Return any removed period in a list.   993         """   994    995         self._check_mutable()   996    997         start = format_datetime(start)   998    999         columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start]  1000   1001         query, values = self.get_query(  1002             "select %(columns)s from %(table)s :condition" % {  1003                 "columns" : ", ".join(self.period_columns),  1004                 "table" : self.table_name  1005                 },  1006             columns, values)  1007   1008         self.cursor.execute(query, values)  1009         removed = self.cursor.fetchall()  1010   1011         query, values = self.get_query(  1012             "delete from %(table)s :condition" % {  1013                 "table" : self.table_name  1014                 },  1015             columns, values)  1016   1017         self.cursor.execute(query, values)  1018   1019         return map(lambda t: FreeBusyPeriod(*t), removed)  1020   1021     def periods_from(self, period):  1022   1023         "Return the entries in the collection at or after 'period'."  1024   1025         columns, values = ["start >= ?"], [format_datetime(period.get_start_point())]  1026   1027         query, values = self.get_query(  1028             "select %(columns)s from %(table)s :condition" % {  1029                 "columns" : ", ".join(self.period_columns),  1030                 "table" : self.table_name  1031                 },  1032             columns, values)  1033   1034         self.cursor.execute(query, values)  1035   1036         return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())  1037   1038     def periods_until(self, period):  1039   1040         "Return the entries in the collection before 'period'."  1041   1042         columns, values = ["start < ?"], [format_datetime(period.get_end_point())]  1043   1044         query, values = self.get_query(  1045             "select %(columns)s from %(table)s :condition" % {  1046                 "columns" : ", ".join(self.period_columns),  1047                 "table" : self.table_name  1048                 },  1049             columns, values)  1050   1051         self.cursor.execute(query, values)  1052   1053         return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())  1054   1055     def get_overlapping(self, period):  1056   1057         """  1058         Return the entries in the collection providing periods overlapping with  1059         'period'.  1060         """  1061   1062         columns, values = ["start < ?", "end > ?"], [format_datetime(period.get_end_point()), format_datetime(period.get_start_point())]  1063   1064         query, values = self.get_query(  1065             "select %(columns)s from %(table)s :condition" % {  1066                 "columns" : ", ".join(self.period_columns),  1067                 "table" : self.table_name  1068                 },  1069             columns, values)  1070   1071         self.cursor.execute(query, values)  1072   1073         return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())  1074   1075     def remove_overlapping(self, period):  1076   1077         "Remove all periods overlapping with 'period' from the collection."  1078   1079         self._check_mutable()  1080   1081         columns, values = ["start < ?", "end > ?"], [format_datetime(period.get_end_point()), format_datetime(period.get_start_point())]  1082   1083         query, values = self.get_query(  1084             "delete from %(table)s :condition" % {  1085                 "table" : self.table_name  1086                 },  1087             columns, values)  1088   1089         self.cursor.execute(query, values)  1090   1091 # Period layout.  1092   1093 def get_scale(periods, tzid, view_period=None):  1094   1095     """  1096     Return a time scale from the given list of 'periods'.  1097   1098     The given 'tzid' is used to make sure that the times are defined according  1099     to the chosen time zone.  1100   1101     An optional 'view_period' is used to constrain the scale to the given  1102     period.  1103   1104     The returned scale is a mapping from time to (starting, ending) tuples,  1105     where starting and ending are collections of periods.  1106     """  1107   1108     scale = {}  1109     view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None  1110     view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None  1111   1112     for p in periods:  1113   1114         # Add a point and this event to the starting list.  1115   1116         start = to_timezone(p.get_start(), tzid)  1117         start = view_start and max(start, view_start) or start  1118         if not scale.has_key(start):  1119             scale[start] = [], []  1120         scale[start][0].append(p)  1121   1122         # Add a point and this event to the ending list.  1123   1124         end = to_timezone(p.get_end(), tzid)  1125         end = view_end and min(end, view_end) or end  1126         if not scale.has_key(end):  1127             scale[end] = [], []  1128         scale[end][1].append(p)  1129   1130     return scale  1131   1132 class Point:  1133   1134     "A qualified point in time."  1135   1136     PRINCIPAL, REPEATED = 0, 1  1137   1138     def __init__(self, point, indicator=None):  1139         self.point = point  1140         self.indicator = indicator or self.PRINCIPAL  1141   1142     def __hash__(self):  1143         return hash((self.point, self.indicator))  1144   1145     def __cmp__(self, other):  1146         if isinstance(other, Point):  1147             return cmp((self.point, self.indicator), (other.point, other.indicator))  1148         elif isinstance(other, datetime):  1149             return cmp(self.point, other)  1150         else:  1151             return 1  1152   1153     def __eq__(self, other):  1154         return self.__cmp__(other) == 0  1155   1156     def __ne__(self, other):  1157         return not self == other  1158   1159     def __lt__(self, other):  1160         return self.__cmp__(other) < 0  1161   1162     def __le__(self, other):  1163         return self.__cmp__(other) <= 0  1164   1165     def __gt__(self, other):  1166         return not self <= other  1167   1168     def __ge__(self, other):  1169         return not self < other  1170   1171     def __repr__(self):  1172         return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")  1173   1174 def get_slots(scale):  1175   1176     """  1177     Return an ordered list of time slots from the given 'scale'.  1178   1179     Each slot is a tuple containing details of a point in time for the start of  1180     the slot, together with a list of parallel event periods.  1181   1182     Each point in time is described as a Point representing the actual point in  1183     time together with an indicator of the nature of the point in time (as a  1184     principal point in a time scale or as a repeated point used to terminate  1185     events occurring for an instant in time).  1186     """  1187   1188     slots = []  1189     active = []  1190   1191     points = scale.items()  1192     points.sort()  1193   1194     for point, (starting, ending) in points:  1195         ending = set(ending)  1196         instants = ending.intersection(starting)  1197   1198         # Discard all active events ending at or before this start time.  1199         # Free up the position in the active list.  1200   1201         for t in ending.difference(instants):  1202             i = active.index(t)  1203             active[i] = None  1204   1205         # For each event starting at the current point, fill any newly-vacated  1206         # position or add to the end of the active list.  1207   1208         for t in starting:  1209             try:  1210                 i = active.index(None)  1211                 active[i] = t  1212             except ValueError:  1213                 active.append(t)  1214   1215         # Discard vacant positions from the end of the active list.  1216   1217         while active and active[-1] is None:  1218             active.pop()  1219   1220         # Add an entry for the time point before "instants".  1221   1222         slots.append((Point(point), active[:]))  1223   1224         # Discard events ending at the same time as they began.  1225   1226         if instants:  1227             for t in instants:  1228                 i = active.index(t)  1229                 active[i] = None  1230   1231             # Discard vacant positions from the end of the active list.  1232   1233             while active and active[-1] is None:  1234                 active.pop()  1235   1236             # Add another entry for the time point after "instants".  1237   1238             slots.append((Point(point, Point.REPEATED), active[:]))  1239   1240     return slots  1241   1242 def add_day_start_points(slots, tzid):  1243   1244     """  1245     Introduce into the 'slots' any day start points required by multi-day  1246     periods. The 'tzid' is required to make sure that appropriate time zones  1247     are chosen and not necessarily those provided by the existing time points.  1248     """  1249   1250     new_slots = []  1251     current_date = None  1252     previously_active = []  1253   1254     for point, active in slots:  1255         start_of_day = get_start_of_day(point.point, tzid)  1256         this_date = point.point.date()  1257   1258         # For each new day, add a slot for the start of the day where periods  1259         # are active and where no such slot already exists.  1260   1261         if this_date != current_date:  1262   1263             # Fill in days where events remain active.  1264   1265             if current_date:  1266                 current_date += timedelta(1)  1267                 while current_date < this_date:  1268                     new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))  1269                     current_date += timedelta(1)  1270             else:  1271                 current_date = this_date  1272   1273             # Add any continuing periods.  1274   1275             if point.point != start_of_day:  1276                 new_slots.append((Point(start_of_day), previously_active))  1277   1278         # Add the currently active periods at this point in time.  1279   1280         previously_active = active  1281   1282     for t in new_slots:  1283         insort_left(slots, t)  1284   1285 def remove_end_slot(slots, view_period):  1286   1287     """  1288     Remove from 'slots' any slot situated at the end of the given 'view_period'.  1289     """  1290   1291     end = view_period.get_end_point()  1292     if not end or not slots:  1293         return  1294     i = bisect_left(slots, (Point(end), None))  1295     if i < len(slots):  1296         del slots[i:]  1297   1298 def add_slots(slots, points):  1299   1300     """  1301     Introduce into the 'slots' entries for those in 'points' that are not  1302     already present, propagating active periods from time points preceding  1303     those added.  1304     """  1305   1306     new_slots = []  1307   1308     for point in points:  1309         i = bisect_left(slots, (point,)) # slots is [(point, active)...]  1310         if i < len(slots) and slots[i][0] == point:  1311             continue  1312   1313         new_slots.append((point, i > 0 and slots[i-1][1] or []))  1314   1315     for t in new_slots:  1316         insort_left(slots, t)  1317   1318 def partition_by_day(slots):  1319   1320     """  1321     Return a mapping from dates to time points provided by 'slots'.  1322     """  1323   1324     d = {}  1325   1326     for point, value in slots:  1327         day = point.point.date()  1328         if not d.has_key(day):  1329             d[day] = []  1330         d[day].append((point, value))  1331   1332     return d  1333   1334 def add_empty_days(days, tzid, start=None, end=None):  1335   1336     """  1337     Add empty days to 'days' between busy days, and optionally from the given  1338     'start' day and until the given 'end' day.  1339     """  1340   1341     last_day = start - timedelta(1)  1342     all_days = days.keys()  1343     all_days.sort()  1344   1345     for day in all_days:  1346         if last_day:  1347             empty_day = last_day + timedelta(1)  1348             while empty_day < day:  1349                 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]  1350                 empty_day += timedelta(1)  1351         last_day = day  1352   1353     if end:  1354         empty_day = last_day + timedelta(1)  1355         while empty_day < end:  1356             days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]  1357             empty_day += timedelta(1)  1358   1359 def get_spans(slots):  1360   1361     "Inspect the given 'slots', returning a mapping of period keys to spans."  1362   1363     points = [point for point, active in slots]  1364     spans = {}  1365   1366     for _point, active in slots:  1367         for p in active:  1368             if p:  1369                 key = p.get_key()  1370                 start_slot = bisect_left(points, p.get_start())  1371                 end_slot = bisect_left(points, p.get_end())  1372                 spans[key] = end_slot - start_slot  1373   1374     return spans  1375   1376 # vim: tabstop=4 expandtab shiftwidth=4