imip-agent

imiptools/period.py

1086:c0c1af0b3ddb
2016-03-09 Paul Boddie Fixed various result and query column/value errors. 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, paramstyle=None):   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, paramstyle)   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" : self.columnlist(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 int(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" : self.columnlist(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, values = self.get_query(   937             "delete from %(table)s :condition" % {   938                 "table" : self.table_name   939                 },   940             columns, values)   941    942         self.cursor.execute(query, values)   943    944         return map(lambda t: FreeBusyPeriod(*t), removed)   945    946     def remove_additional_periods(self, uid, recurrenceids=None):   947    948         """   949         Remove from the collection all periods associated with 'uid' having a   950         recurrence identifier indicating an additional or modified period.   951    952         If 'recurrenceids' is specified, remove all periods associated with   953         'uid' that do not have a recurrence identifier in the given list.   954    955         Return the removed periods.   956         """   957    958         self._check_mutable()   959    960         if recurrenceids is None:   961             columns, values = ["object_uid", "object_recurrenceid is not null"], [uid]   962         else:   963             columns, values = ["object_uid", "object_recurrenceid not in ?", "object_recurrenceid is not null"], [uid, tuple(recurrenceids)]   964    965         query, _values = self.get_query(   966             "select %(columns)s from %(table)s :condition" % {   967                 "columns" : self.columnlist(self.period_columns),   968                 "table" : self.table_name   969                 },   970             columns, values)   971    972         self.cursor.execute(query, _values)   973         removed = self.cursor.fetchall()   974    975         query, values = self.get_query(   976             "delete from %(table)s :condition" % {   977                 "table" : self.table_name   978                 },   979             columns, values)   980    981         self.cursor.execute(query, values)   982    983         return map(lambda t: FreeBusyPeriod(*t), removed)   984    985     def remove_affected_period(self, uid, start):   986    987         """   988         Remove from the collection the period associated with 'uid' that   989         provides an occurrence starting at the given 'start' (provided by a   990         recurrence identifier, converted to a datetime). A recurrence identifier   991         is used to provide an alternative time period whilst also acting as a   992         reference to the originally-defined occurrence.   993    994         Return any removed period in a list.   995         """   996    997         self._check_mutable()   998    999         start = format_datetime(start)  1000   1001         columns, values = ["object_uid", "start", "object_recurrenceid is null"], [uid, start]  1002   1003         query, _values = self.get_query(  1004             "select %(columns)s from %(table)s :condition" % {  1005                 "columns" : self.columnlist(self.period_columns),  1006                 "table" : self.table_name  1007                 },  1008             columns, values)  1009   1010         self.cursor.execute(query, _values)  1011         removed = self.cursor.fetchall()  1012   1013         query, values = self.get_query(  1014             "delete from %(table)s :condition" % {  1015                 "table" : self.table_name  1016                 },  1017             columns, values)  1018   1019         self.cursor.execute(query, values)  1020   1021         return map(lambda t: FreeBusyPeriod(*t), removed)  1022   1023     def periods_from(self, period):  1024   1025         "Return the entries in the collection at or after 'period'."  1026   1027         columns, values = ["start >= ?"], [format_datetime(period.get_start_point())]  1028   1029         query, values = self.get_query(  1030             "select %(columns)s from %(table)s :condition" % {  1031                 "columns" : self.columnlist(self.period_columns),  1032                 "table" : self.table_name  1033                 },  1034             columns, values)  1035   1036         self.cursor.execute(query, values)  1037   1038         return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())  1039   1040     def periods_until(self, period):  1041   1042         "Return the entries in the collection before 'period'."  1043   1044         columns, values = ["start < ?"], [format_datetime(period.get_end_point())]  1045   1046         query, values = self.get_query(  1047             "select %(columns)s from %(table)s :condition" % {  1048                 "columns" : self.columnlist(self.period_columns),  1049                 "table" : self.table_name  1050                 },  1051             columns, values)  1052   1053         self.cursor.execute(query, values)  1054   1055         return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())  1056   1057     def get_overlapping(self, period):  1058   1059         """  1060         Return the entries in the collection providing periods overlapping with  1061         'period'.  1062         """  1063   1064         columns, values = ["start < ?", "end > ?"], [format_datetime(period.get_end_point()), format_datetime(period.get_start_point())]  1065   1066         query, values = self.get_query(  1067             "select %(columns)s from %(table)s :condition" % {  1068                 "columns" : self.columnlist(self.period_columns),  1069                 "table" : self.table_name  1070                 },  1071             columns, values)  1072   1073         self.cursor.execute(query, values)  1074   1075         return map(lambda t: FreeBusyPeriod(*t), self.cursor.fetchall())  1076   1077     def remove_overlapping(self, period):  1078   1079         "Remove all periods overlapping with 'period' from the collection."  1080   1081         self._check_mutable()  1082   1083         columns, values = ["start < ?", "end > ?"], [format_datetime(period.get_end_point()), format_datetime(period.get_start_point())]  1084   1085         query, values = self.get_query(  1086             "delete from %(table)s :condition" % {  1087                 "table" : self.table_name  1088                 },  1089             columns, values)  1090   1091         self.cursor.execute(query, values)  1092   1093 # Period layout.  1094   1095 def get_scale(periods, tzid, view_period=None):  1096   1097     """  1098     Return a time scale from the given list of 'periods'.  1099   1100     The given 'tzid' is used to make sure that the times are defined according  1101     to the chosen time zone.  1102   1103     An optional 'view_period' is used to constrain the scale to the given  1104     period.  1105   1106     The returned scale is a mapping from time to (starting, ending) tuples,  1107     where starting and ending are collections of periods.  1108     """  1109   1110     scale = {}  1111     view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None  1112     view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None  1113   1114     for p in periods:  1115   1116         # Add a point and this event to the starting list.  1117   1118         start = to_timezone(p.get_start(), tzid)  1119         start = view_start and max(start, view_start) or start  1120         if not scale.has_key(start):  1121             scale[start] = [], []  1122         scale[start][0].append(p)  1123   1124         # Add a point and this event to the ending list.  1125   1126         end = to_timezone(p.get_end(), tzid)  1127         end = view_end and min(end, view_end) or end  1128         if not scale.has_key(end):  1129             scale[end] = [], []  1130         scale[end][1].append(p)  1131   1132     return scale  1133   1134 class Point:  1135   1136     "A qualified point in time."  1137   1138     PRINCIPAL, REPEATED = 0, 1  1139   1140     def __init__(self, point, indicator=None):  1141         self.point = point  1142         self.indicator = indicator or self.PRINCIPAL  1143   1144     def __hash__(self):  1145         return hash((self.point, self.indicator))  1146   1147     def __cmp__(self, other):  1148         if isinstance(other, Point):  1149             return cmp((self.point, self.indicator), (other.point, other.indicator))  1150         elif isinstance(other, datetime):  1151             return cmp(self.point, other)  1152         else:  1153             return 1  1154   1155     def __eq__(self, other):  1156         return self.__cmp__(other) == 0  1157   1158     def __ne__(self, other):  1159         return not self == other  1160   1161     def __lt__(self, other):  1162         return self.__cmp__(other) < 0  1163   1164     def __le__(self, other):  1165         return self.__cmp__(other) <= 0  1166   1167     def __gt__(self, other):  1168         return not self <= other  1169   1170     def __ge__(self, other):  1171         return not self < other  1172   1173     def __repr__(self):  1174         return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")  1175   1176 def get_slots(scale):  1177   1178     """  1179     Return an ordered list of time slots from the given 'scale'.  1180   1181     Each slot is a tuple containing details of a point in time for the start of  1182     the slot, together with a list of parallel event periods.  1183   1184     Each point in time is described as a Point representing the actual point in  1185     time together with an indicator of the nature of the point in time (as a  1186     principal point in a time scale or as a repeated point used to terminate  1187     events occurring for an instant in time).  1188     """  1189   1190     slots = []  1191     active = []  1192   1193     points = scale.items()  1194     points.sort()  1195   1196     for point, (starting, ending) in points:  1197         ending = set(ending)  1198         instants = ending.intersection(starting)  1199   1200         # Discard all active events ending at or before this start time.  1201         # Free up the position in the active list.  1202   1203         for t in ending.difference(instants):  1204             i = active.index(t)  1205             active[i] = None  1206   1207         # For each event starting at the current point, fill any newly-vacated  1208         # position or add to the end of the active list.  1209   1210         for t in starting:  1211             try:  1212                 i = active.index(None)  1213                 active[i] = t  1214             except ValueError:  1215                 active.append(t)  1216   1217         # Discard vacant positions from the end of the active list.  1218   1219         while active and active[-1] is None:  1220             active.pop()  1221   1222         # Add an entry for the time point before "instants".  1223   1224         slots.append((Point(point), active[:]))  1225   1226         # Discard events ending at the same time as they began.  1227   1228         if instants:  1229             for t in instants:  1230                 i = active.index(t)  1231                 active[i] = None  1232   1233             # Discard vacant positions from the end of the active list.  1234   1235             while active and active[-1] is None:  1236                 active.pop()  1237   1238             # Add another entry for the time point after "instants".  1239   1240             slots.append((Point(point, Point.REPEATED), active[:]))  1241   1242     return slots  1243   1244 def add_day_start_points(slots, tzid):  1245   1246     """  1247     Introduce into the 'slots' any day start points required by multi-day  1248     periods. The 'tzid' is required to make sure that appropriate time zones  1249     are chosen and not necessarily those provided by the existing time points.  1250     """  1251   1252     new_slots = []  1253     current_date = None  1254     previously_active = []  1255   1256     for point, active in slots:  1257         start_of_day = get_start_of_day(point.point, tzid)  1258         this_date = point.point.date()  1259   1260         # For each new day, add a slot for the start of the day where periods  1261         # are active and where no such slot already exists.  1262   1263         if this_date != current_date:  1264   1265             # Fill in days where events remain active.  1266   1267             if current_date:  1268                 current_date += timedelta(1)  1269                 while current_date < this_date:  1270                     new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))  1271                     current_date += timedelta(1)  1272             else:  1273                 current_date = this_date  1274   1275             # Add any continuing periods.  1276   1277             if point.point != start_of_day:  1278                 new_slots.append((Point(start_of_day), previously_active))  1279   1280         # Add the currently active periods at this point in time.  1281   1282         previously_active = active  1283   1284     for t in new_slots:  1285         insort_left(slots, t)  1286   1287 def remove_end_slot(slots, view_period):  1288   1289     """  1290     Remove from 'slots' any slot situated at the end of the given 'view_period'.  1291     """  1292   1293     end = view_period.get_end_point()  1294     if not end or not slots:  1295         return  1296     i = bisect_left(slots, (Point(end), None))  1297     if i < len(slots):  1298         del slots[i:]  1299   1300 def add_slots(slots, points):  1301   1302     """  1303     Introduce into the 'slots' entries for those in 'points' that are not  1304     already present, propagating active periods from time points preceding  1305     those added.  1306     """  1307   1308     new_slots = []  1309   1310     for point in points:  1311         i = bisect_left(slots, (point,)) # slots is [(point, active)...]  1312         if i < len(slots) and slots[i][0] == point:  1313             continue  1314   1315         new_slots.append((point, i > 0 and slots[i-1][1] or []))  1316   1317     for t in new_slots:  1318         insort_left(slots, t)  1319   1320 def partition_by_day(slots):  1321   1322     """  1323     Return a mapping from dates to time points provided by 'slots'.  1324     """  1325   1326     d = {}  1327   1328     for point, value in slots:  1329         day = point.point.date()  1330         if not d.has_key(day):  1331             d[day] = []  1332         d[day].append((point, value))  1333   1334     return d  1335   1336 def add_empty_days(days, tzid, start=None, end=None):  1337   1338     """  1339     Add empty days to 'days' between busy days, and optionally from the given  1340     'start' day and until the given 'end' day.  1341     """  1342   1343     last_day = start - timedelta(1)  1344     all_days = days.keys()  1345     all_days.sort()  1346   1347     for day in all_days:  1348         if last_day:  1349             empty_day = last_day + timedelta(1)  1350             while empty_day < day:  1351                 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]  1352                 empty_day += timedelta(1)  1353         last_day = day  1354   1355     if end:  1356         empty_day = last_day + timedelta(1)  1357         while empty_day < end:  1358             days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]  1359             empty_day += timedelta(1)  1360   1361 def get_spans(slots):  1362   1363     "Inspect the given 'slots', returning a mapping of period keys to spans."  1364   1365     points = [point for point, active in slots]  1366     spans = {}  1367   1368     for _point, active in slots:  1369         for p in active:  1370             if p:  1371                 key = p.get_key()  1372                 start_slot = bisect_left(points, p.get_start())  1373                 end_slot = bisect_left(points, p.get_end())  1374                 spans[key] = end_slot - start_slot  1375   1376     return spans  1377   1378 # vim: tabstop=4 expandtab shiftwidth=4