imip-agent

imiptools/period.py

1055:9d7567e5675c
2016-02-08 Paul Boddie Added a test of simultaneous resource booking with recurring events and differing organiser quota limits.
     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     32 def ifnone(x, y):    33     if x is None: return y    34     else: return x    35     36 class Comparable:    37     38     "A date/datetime wrapper that allows comparisons with other types."    39     40     def __init__(self, dt):    41         self.dt = dt    42     43     def __cmp__(self, other):    44         dt = None    45         odt = None    46     47         # Find any dates/datetimes.    48     49         if isinstance(self.dt, date):    50             dt = self.dt    51         if isinstance(other, date):    52             odt = other    53         elif isinstance(other, Comparable):    54             if isinstance(other.dt, date):    55                 odt = other.dt    56             else:    57                 other = other.dt    58     59         if dt and odt:    60             return cmp(dt, odt)    61         elif dt:    62             return other.__rcmp__(dt)    63         elif odt:    64             return self.dt.__cmp__(odt)    65         else:    66             return self.dt.__cmp__(other)    67     68 class PointInTime:    69     70     "A base class for special values."    71     72     pass    73     74 class StartOfTime(PointInTime):    75     76     "A special value that compares earlier than other values."    77     78     def __cmp__(self, other):    79         if isinstance(other, StartOfTime):    80             return 0    81         else:    82             return -1    83     84     def __rcmp__(self, other):    85         return -self.__cmp__(other)    86     87     def __nonzero__(self):    88         return False    89     90     def __hash__(self):    91         return 0    92     93 class EndOfTime(PointInTime):    94     95     "A special value that compares later than other values."    96     97     def __cmp__(self, other):    98         if isinstance(other, EndOfTime):    99             return 0   100         else:   101             return 1   102    103     def __rcmp__(self, other):   104         return -self.__cmp__(other)   105    106     def __nonzero__(self):   107         return False   108    109     def __hash__(self):   110         return 0   111    112 class Endless:   113    114     "A special value indicating an endless period."   115    116     def __cmp__(self, other):   117         if isinstance(other, Endless):   118             return 0   119         else:   120             return 1   121    122     def __rcmp__(self, other):   123         return -self.__cmp__(other)   124    125     def __nonzero__(self):   126         return True   127    128 class PeriodBase:   129    130     "A basic period abstraction."   131    132     def __init__(self, start, end):   133         if isinstance(start, (date, PointInTime)):   134             self.start = start   135         else:   136             self.start = get_datetime(start) or StartOfTime()   137         if isinstance(end, (date, PointInTime)):   138             self.end = end   139         else:   140             self.end = get_datetime(end) or EndOfTime()   141    142     def as_tuple(self):   143         return self.start, self.end   144    145     def __hash__(self):   146         return hash((self.get_start(), self.get_end()))   147    148     def __cmp__(self, other):   149    150         "Return a comparison result against 'other' using points in time."   151    152         if isinstance(other, PeriodBase):   153             return cmp(   154                 (Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(self.get_end_point(), EndOfTime()))),   155                 (Comparable(ifnone(other.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime())))   156                 )   157         else:   158             return 1   159    160     def overlaps(self, other):   161         return Comparable(ifnone(self.get_end_point(), EndOfTime())) > Comparable(ifnone(other.get_start_point(), StartOfTime())) and \   162                Comparable(ifnone(self.get_start_point(), StartOfTime())) < Comparable(ifnone(other.get_end_point(), EndOfTime()))   163    164     def within(self, other):   165         return Comparable(ifnone(self.get_start_point(), StartOfTime())) >= Comparable(ifnone(other.get_start_point(), StartOfTime())) and \   166                Comparable(ifnone(self.get_end_point(), EndOfTime())) <= Comparable(ifnone(other.get_end_point(), EndOfTime()))   167    168     def common(self, other):   169         start = max(Comparable(ifnone(self.get_start_point(), StartOfTime())), Comparable(ifnone(other.get_start_point(), StartOfTime())))   170         end = min(Comparable(ifnone(self.get_end_point(), EndOfTime())), Comparable(ifnone(other.get_end_point(), EndOfTime())))   171         if start <= end:   172             return self.make_corrected(start.dt, end.dt)   173         else:   174             return None   175    176     def get_key(self):   177         return self.get_start(), self.get_end()   178    179     # Datetime and metadata methods.   180    181     def get_start(self):   182         return self.start   183    184     def get_end(self):   185         return self.end   186    187     def get_start_attr(self):   188         return get_datetime_attributes(self.start, self.tzid)   189    190     def get_end_attr(self):   191         return get_datetime_attributes(self.end, self.tzid)   192    193     def get_start_item(self):   194         return self.get_start(), self.get_start_attr()   195    196     def get_end_item(self):   197         return self.get_end(), self.get_end_attr()   198    199     def get_start_point(self):   200         return self.start   201    202     def get_end_point(self):   203         return self.end   204    205     def get_duration(self):   206         start = self.get_start_point()   207         end = self.get_end_point()   208         if start and end:   209             return end - start   210         else:   211             return Endless()   212    213 class Period(PeriodBase):   214    215     "A simple period abstraction."   216    217     def __init__(self, start, end, tzid=None, origin=None):   218    219         """   220         Initialise a period with the given 'start' and 'end', having a   221         contextual 'tzid', if specified, and an indicated 'origin'.   222    223         All metadata from the start and end points are derived from the supplied   224         dates/datetimes.   225         """   226    227         PeriodBase.__init__(self, start, end)   228         self.tzid = tzid   229         self.origin = origin   230    231     def as_tuple(self):   232         return self.start, self.end, self.tzid, self.origin   233    234     def __repr__(self):   235         return "Period%r" % (self.as_tuple(),)   236    237     # Datetime and metadata methods.   238    239     def get_tzid(self):   240         return get_tzid(self.get_start_attr(), self.get_end_attr()) or self.tzid   241    242     def get_start_point(self):   243         start = self.get_start()   244         if isinstance(start, PointInTime): return start   245         else: return to_utc_datetime(start, self.get_tzid())   246    247     def get_end_point(self):   248         end = self.get_end()   249         if isinstance(end, PointInTime): return end   250         else: return to_utc_datetime(end, self.get_tzid())   251    252     # Period and event recurrence logic.   253    254     def is_replaced(self, recurrenceids):   255    256         """   257         Return whether this period refers to one of the 'recurrenceids'.   258         The 'recurrenceids' should be normalised to UTC datetimes according to   259         time zone information provided by their objects or be floating dates or   260         datetimes requiring conversion using contextual time zone information.   261         """   262    263         for recurrenceid in recurrenceids:   264             if self.is_affected(recurrenceid):   265                 return recurrenceid   266         return None   267    268     def is_affected(self, recurrenceid):   269    270         """   271         Return whether this period refers to 'recurrenceid'. The 'recurrenceid'   272         should be normalised to UTC datetimes according to time zone information   273         provided by their objects. Otherwise, this period's contextual time zone   274         information is used to convert any date or floating datetime   275         representation to a point in time.   276         """   277    278         if not recurrenceid:   279             return None   280         d = get_recurrence_start(recurrenceid)   281         dt = get_recurrence_start_point(recurrenceid, self.tzid)   282         if self.get_start() == d or self.get_start_point() == dt:   283             return recurrenceid   284         return None   285    286     # Value correction methods.   287    288     def with_duration(self, duration):   289    290         """   291         Return a version of this period with the same start point but with the   292         given 'duration'.   293         """   294    295         return self.make_corrected(self.get_start(), self.get_start() + duration)   296    297     def check_permitted(self, permitted_values):   298    299         "Check the period against the given 'permitted_values'."   300    301         start = self.get_start()   302         end = self.get_end()   303         start_errors = check_permitted_values(start, permitted_values)   304         end_errors = check_permitted_values(end, permitted_values)   305    306         if not (start_errors or end_errors):   307             return None   308    309         return start_errors, end_errors   310    311     def get_corrected(self, permitted_values):   312    313         "Return a corrected version of this period."   314    315         errors = self.check_permitted(permitted_values)   316    317         if not errors:   318             return self   319    320         start_errors, end_errors = errors   321    322         start = self.get_start()   323         end = self.get_end()   324    325         if start_errors:   326             start = correct_datetime(start, permitted_values)   327         if end_errors:   328             end = correct_datetime(end, permitted_values)   329    330         return self.make_corrected(start, end)   331    332     def make_corrected(self, start, end):   333         return self.__class__(start, end, self.tzid, self.origin)   334    335 class FreeBusyPeriod(PeriodBase):   336    337     "A free/busy record abstraction."   338    339     def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, expires=None):   340    341         """   342         Initialise a free/busy period with the given 'start' and 'end' points,   343         plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'   344         details.   345    346         An additional 'expires' parameter can be used to indicate an expiry   347         datetime in conjunction with free/busy offers made when countering   348         event proposals.   349         """   350    351         PeriodBase.__init__(self, start, end)   352         self.uid = uid   353         self.transp = transp   354         self.recurrenceid = recurrenceid   355         self.summary = summary   356         self.organiser = organiser   357         self.expires = expires   358    359     def as_tuple(self, strings_only=False):   360    361         """   362         Return the initialisation parameter tuple, converting false value   363         parameters to strings if 'strings_only' is set to a true value.   364         """   365    366         null = lambda x: (strings_only and [""] or [x])[0]   367         return (   368             strings_only and format_datetime(self.get_start_point()) or self.start,   369             strings_only and format_datetime(self.get_end_point()) or self.end,   370             self.uid or null(self.uid),   371             self.transp or strings_only and "OPAQUE" or None,   372             self.recurrenceid or null(self.recurrenceid),   373             self.summary or null(self.summary),   374             self.organiser or null(self.organiser),   375             self.expires or null(self.expires)   376             )   377    378     def __cmp__(self, other):   379    380         """   381         Compare this object to 'other', employing the uid if the periods   382         involved are the same.   383         """   384    385         result = PeriodBase.__cmp__(self, other)   386         if result == 0 and isinstance(other, FreeBusyPeriod):   387             return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid))   388         else:   389             return result   390    391     def get_key(self):   392         return self.uid, self.recurrenceid, self.get_start()   393    394     def __repr__(self):   395         return "FreeBusyPeriod%r" % (self.as_tuple(),)   396    397     def get_tzid(self):   398         return "UTC"   399    400     # Period and event recurrence logic.   401    402     def is_replaced(self, recurrences):   403    404         """   405         Return whether this period refers to one of the 'recurrences'.   406         The 'recurrences' must be UTC datetimes corresponding to the start of   407         the period described by a recurrence.   408         """   409    410         for recurrence in recurrences:   411             if self.is_affected(recurrence):   412                 return True   413         return False   414    415     def is_affected(self, recurrence):   416    417         """   418         Return whether this period refers to 'recurrence'. The 'recurrence' must   419         be a UTC datetime corresponding to the start of the period described by   420         a recurrence.   421         """   422    423         return recurrence and self.get_start_point() == recurrence   424    425     # Value correction methods.   426    427     def make_corrected(self, start, end):   428         return self.__class__(start, end)   429    430 class RecurringPeriod(Period):   431        432     """   433     A period with iCalendar metadata attributes and origin information from an   434     object.   435     """   436        437     def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None):   438         Period.__init__(self, start, end, tzid, origin)   439         self.start_attr = start_attr   440         self.end_attr = end_attr   441    442     def get_start_attr(self):   443         return self.start_attr   444    445     def get_end_attr(self):   446         return self.end_attr   447    448     def as_tuple(self):   449         return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr   450        451     def __repr__(self):   452         return "RecurringPeriod%r" % (self.as_tuple(),)   453    454     def make_corrected(self, start, end):   455         return self.__class__(start, end, self.tzid, self.origin, self.get_start_attr(), self.get_end_attr())   456    457 # Time and period management.   458    459 def can_schedule(freebusy, periods, uid, recurrenceid):   460    461     """   462     Return whether the 'freebusy' list can accommodate the given 'periods'   463     employing the specified 'uid' and 'recurrenceid'.   464     """   465    466     for conflict in have_conflict(freebusy, periods, True):   467         if conflict.uid != uid or conflict.recurrenceid != recurrenceid:   468             return False   469    470     return True   471    472 def have_conflict(freebusy, periods, get_conflicts=False):   473    474     """   475     Return whether any period in 'freebusy' overlaps with the given 'periods',   476     returning a collection of such overlapping periods if 'get_conflicts' is   477     set to a true value.   478     """   479    480     conflicts = set()   481     for p in periods:   482         overlapping = period_overlaps(freebusy, p, get_conflicts)   483         if overlapping:   484             if get_conflicts:   485                 conflicts.update(overlapping)   486             else:   487                 return True   488    489     if get_conflicts:   490         return conflicts   491     else:   492         return False   493    494 def insert_period(freebusy, period):   495    496     "Insert into 'freebusy' the given 'period'."   497    498     i = bisect_left(freebusy, period)   499     if i == len(freebusy):   500         freebusy.append(period)   501     elif freebusy[i] != period:   502         freebusy.insert(i, period)   503    504 def remove_periods(freebusy, periods):   505    506     "Remove from 'freebusy' the given 'periods'."   507    508     for period in periods:   509         i = bisect_left(freebusy, period)   510         if i < len(freebusy) and freebusy[i] == period:   511             del freebusy[i]   512    513 def remove_event_periods(freebusy, uid, recurrenceid=None):   514    515     """   516     Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'   517     (which if omitted causes the "parent" object's periods to be referenced).   518    519     Return the removed periods.   520     """   521    522     removed = []   523     i = 0   524     while i < len(freebusy):   525         fb = freebusy[i]   526         if fb.uid == uid and fb.recurrenceid == recurrenceid:   527             removed.append(freebusy[i])   528             del freebusy[i]   529         else:   530             i += 1   531    532     return removed   533    534 def remove_additional_periods(freebusy, uid, recurrenceids=None):   535    536     """   537     Remove from 'freebusy' all periods associated with 'uid' having a   538     recurrence identifier indicating an additional or modified period.   539    540     If 'recurrenceids' is specified, remove all periods associated with 'uid'   541     that do not have a recurrence identifier in the given list.   542    543     Return the removed periods.   544     """   545    546     removed = []   547     i = 0   548     while i < len(freebusy):   549         fb = freebusy[i]   550         if fb.uid == uid and fb.recurrenceid and (   551             recurrenceids is None or   552             recurrenceids is not None and fb.recurrenceid not in recurrenceids   553             ):   554             removed.append(freebusy[i])   555             del freebusy[i]   556         else:   557             i += 1   558    559     return removed   560    561 def remove_affected_period(freebusy, uid, start):   562    563     """   564     Remove from 'freebusy' a period associated with 'uid' that provides an   565     occurrence starting at the given 'start' (provided by a recurrence   566     identifier, converted to a datetime). A recurrence identifier is used to   567     provide an alternative time period whilst also acting as a reference to the   568     originally-defined occurrence.   569    570     Return any removed period in a list.   571     """   572    573     removed = []   574    575     search = Period(start, start)   576     found = bisect_left(freebusy, search)   577    578     while found < len(freebusy):   579         fb = freebusy[found]   580    581         # Stop looking if the start no longer matches the recurrence identifier.   582    583         if fb.get_start_point() != search.get_start_point():   584             break   585    586         # If the period belongs to the parent object, remove it and return.   587    588         if not fb.recurrenceid and uid == fb.uid:   589             removed.append(freebusy[found])   590             del freebusy[found]   591             break   592    593         # Otherwise, keep looking for a matching period.   594    595         found += 1   596    597     return removed   598    599 def periods_from(freebusy, period):   600    601     "Return the entries in 'freebusy' at or after 'period'."   602    603     first = bisect_left(freebusy, period)   604     return freebusy[first:]   605    606 def periods_until(freebusy, period):   607    608     "Return the entries in 'freebusy' before 'period'."   609    610     last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid()))   611     return freebusy[:last]   612    613 def get_overlapping(freebusy, period):   614    615     """   616     Return the entries in 'freebusy' providing periods overlapping with   617     'period'.   618     """   619    620     # Find the range of periods potentially overlapping the period in the   621     # free/busy collection.   622    623     startpoints = periods_until(freebusy, period)   624    625     # Find the range of periods potentially overlapping the period in a version   626     # of the free/busy collection sorted according to end datetimes.   627    628     endpoints = [(Period(fb.get_end_point(), fb.get_end_point()), fb) for fb in startpoints]   629     endpoints.sort()   630     first = bisect_left(endpoints, (Period(period.get_start_point(), period.get_start_point()),))   631     endpoints = endpoints[first:]   632    633     overlapping = set()   634    635     for p, fb in endpoints:   636         if fb.overlaps(period):   637             overlapping.add(fb)   638    639     overlapping = list(overlapping)   640     overlapping.sort()   641     return overlapping   642    643 def period_overlaps(freebusy, period, get_periods=False):   644    645     """   646     Return whether any period in 'freebusy' overlaps with the given 'period',   647     returning a collection of overlapping periods if 'get_periods' is set to a   648     true value.   649     """   650    651     overlapping = get_overlapping(freebusy, period)   652    653     if get_periods:   654         return overlapping   655     else:   656         return len(overlapping) != 0   657    658 def remove_overlapping(freebusy, period):   659    660     "Remove from 'freebusy' all periods overlapping with 'period'."   661    662     overlapping = get_overlapping(freebusy, period)   663    664     if overlapping:   665         for fb in overlapping:   666             freebusy.remove(fb)   667    668 def replace_overlapping(freebusy, period, replacements):   669    670     """   671     Replace existing periods in 'freebusy' within the given 'period', using the   672     given 'replacements'.   673     """   674    675     remove_overlapping(freebusy, period)   676     for replacement in replacements:   677         insert_period(freebusy, replacement)   678    679 def coalesce_freebusy(freebusy):   680    681     "Coalesce the periods in 'freebusy'."   682    683     if not freebusy:   684         return freebusy   685    686     fb = []   687     start = freebusy[0].get_start_point()   688     end = freebusy[0].get_end_point()   689    690     for period in freebusy[1:]:   691         if period.get_start_point() > end:   692             fb.append(FreeBusyPeriod(start, end))   693             start = period.get_start_point()   694             end = period.get_end_point()   695         else:   696             end = max(end, period.get_end_point())   697    698     fb.append(FreeBusyPeriod(start, end))   699     return fb   700    701 def invert_freebusy(freebusy):   702    703     "Return the free periods from 'freebusy'."   704    705     if not freebusy:   706         return [FreeBusyPeriod(None, None)]   707    708     # Coalesce periods that overlap or are adjacent.   709    710     fb = coalesce_freebusy(freebusy)   711     free = []   712    713     # Add a start-of-time period if appropriate.   714    715     first = fb[0].get_start_point()   716     if first:   717         free.append(FreeBusyPeriod(None, first))   718    719     start = fb[0].get_end_point()   720    721     for period in fb[1:]:   722         free.append(FreeBusyPeriod(start, period.get_start_point()))   723         start = period.get_end_point()   724    725     # Add an end-of-time period if appropriate.   726    727     if start:   728         free.append(FreeBusyPeriod(start, None))   729    730     return free   731    732 # Period layout.   733    734 def get_scale(periods, tzid, view_period=None):   735    736     """   737     Return a time scale from the given list of 'periods'.   738    739     The given 'tzid' is used to make sure that the times are defined according   740     to the chosen time zone.   741    742     An optional 'view_period' is used to constrain the scale to the given   743     period.   744    745     The returned scale is a mapping from time to (starting, ending) tuples,   746     where starting and ending are collections of periods.   747     """   748    749     scale = {}   750     view_start = view_period and to_timezone(view_period.get_start_point(), tzid) or None   751     view_end = view_period and to_timezone(view_period.get_end_point(), tzid) or None   752    753     for p in periods:   754    755         # Add a point and this event to the starting list.   756    757         start = to_timezone(p.get_start(), tzid)   758         start = view_start and max(start, view_start) or start   759         if not scale.has_key(start):   760             scale[start] = [], []   761         scale[start][0].append(p)   762    763         # Add a point and this event to the ending list.   764    765         end = to_timezone(p.get_end(), tzid)   766         end = view_end and min(end, view_end) or end   767         if not scale.has_key(end):   768             scale[end] = [], []   769         scale[end][1].append(p)   770    771     return scale   772    773 class Point:   774    775     "A qualified point in time."   776    777     PRINCIPAL, REPEATED = 0, 1   778    779     def __init__(self, point, indicator=None):   780         self.point = point   781         self.indicator = indicator or self.PRINCIPAL   782    783     def __hash__(self):   784         return hash((self.point, self.indicator))   785    786     def __cmp__(self, other):   787         if isinstance(other, Point):   788             return cmp((self.point, self.indicator), (other.point, other.indicator))   789         elif isinstance(other, datetime):   790             return cmp(self.point, other)   791         else:   792             return 1   793    794     def __eq__(self, other):   795         return self.__cmp__(other) == 0   796    797     def __ne__(self, other):   798         return not self == other   799    800     def __lt__(self, other):   801         return self.__cmp__(other) < 0   802    803     def __le__(self, other):   804         return self.__cmp__(other) <= 0   805    806     def __gt__(self, other):   807         return not self <= other   808    809     def __ge__(self, other):   810         return not self < other   811    812     def __repr__(self):   813         return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")   814    815 def get_slots(scale):   816    817     """   818     Return an ordered list of time slots from the given 'scale'.   819    820     Each slot is a tuple containing details of a point in time for the start of   821     the slot, together with a list of parallel event periods.   822    823     Each point in time is described as a Point representing the actual point in   824     time together with an indicator of the nature of the point in time (as a   825     principal point in a time scale or as a repeated point used to terminate   826     events occurring for an instant in time).   827     """   828    829     slots = []   830     active = []   831    832     points = scale.items()   833     points.sort()   834    835     for point, (starting, ending) in points:   836         ending = set(ending)   837         instants = ending.intersection(starting)   838    839         # Discard all active events ending at or before this start time.   840         # Free up the position in the active list.   841    842         for t in ending.difference(instants):   843             i = active.index(t)   844             active[i] = None   845    846         # For each event starting at the current point, fill any newly-vacated   847         # position or add to the end of the active list.   848    849         for t in starting:   850             try:   851                 i = active.index(None)   852                 active[i] = t   853             except ValueError:   854                 active.append(t)   855    856         # Discard vacant positions from the end of the active list.   857    858         while active and active[-1] is None:   859             active.pop()   860    861         # Add an entry for the time point before "instants".   862    863         slots.append((Point(point), active[:]))   864    865         # Discard events ending at the same time as they began.   866    867         if instants:   868             for t in instants:   869                 i = active.index(t)   870                 active[i] = None   871    872             # Discard vacant positions from the end of the active list.   873    874             while active and active[-1] is None:   875                 active.pop()   876    877             # Add another entry for the time point after "instants".   878    879             slots.append((Point(point, Point.REPEATED), active[:]))   880    881     return slots   882    883 def add_day_start_points(slots, tzid):   884    885     """   886     Introduce into the 'slots' any day start points required by multi-day   887     periods. The 'tzid' is required to make sure that appropriate time zones   888     are chosen and not necessarily those provided by the existing time points.   889     """   890    891     new_slots = []   892     current_date = None   893     previously_active = []   894    895     for point, active in slots:   896         start_of_day = get_start_of_day(point.point, tzid)   897         this_date = point.point.date()   898    899         # For each new day, add a slot for the start of the day where periods   900         # are active and where no such slot already exists.   901    902         if this_date != current_date:   903    904             # Fill in days where events remain active.   905    906             if current_date:   907                 current_date += timedelta(1)   908                 while current_date < this_date:   909                     new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))   910                     current_date += timedelta(1)   911             else:   912                 current_date = this_date   913    914             # Add any continuing periods.   915    916             if point.point != start_of_day:   917                 new_slots.append((Point(start_of_day), previously_active))   918    919         # Add the currently active periods at this point in time.   920    921         previously_active = active   922    923     for t in new_slots:   924         insort_left(slots, t)   925    926 def remove_end_slot(slots, view_period):   927    928     """   929     Remove from 'slots' any slot situated at the end of the given 'view_period'.   930     """   931    932     end = view_period.get_end_point()   933     if not end or not slots:   934         return   935     i = bisect_left(slots, (Point(end), None))   936     if i < len(slots):   937         del slots[i:]   938    939 def add_slots(slots, points):   940    941     """   942     Introduce into the 'slots' entries for those in 'points' that are not   943     already present, propagating active periods from time points preceding   944     those added.   945     """   946    947     new_slots = []   948    949     for point in points:   950         i = bisect_left(slots, (point,)) # slots is [(point, active)...]   951         if i < len(slots) and slots[i][0] == point:   952             continue   953    954         new_slots.append((point, i > 0 and slots[i-1][1] or []))   955    956     for t in new_slots:   957         insort_left(slots, t)   958    959 def partition_by_day(slots):   960    961     """   962     Return a mapping from dates to time points provided by 'slots'.   963     """   964    965     d = {}   966    967     for point, value in slots:   968         day = point.point.date()   969         if not d.has_key(day):   970             d[day] = []   971         d[day].append((point, value))   972    973     return d   974    975 def add_empty_days(days, tzid, start=None, end=None):   976    977     """   978     Add empty days to 'days' between busy days, and optionally from the given   979     'start' day and until the given 'end' day.   980     """   981    982     last_day = start - timedelta(1)   983     all_days = days.keys()   984     all_days.sort()   985    986     for day in all_days:   987         if last_day:   988             empty_day = last_day + timedelta(1)   989             while empty_day < day:   990                 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]   991                 empty_day += timedelta(1)   992         last_day = day   993    994     if end:   995         empty_day = last_day + timedelta(1)   996         while empty_day < end:   997             days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]   998             empty_day += timedelta(1)   999   1000 def get_spans(slots):  1001   1002     "Inspect the given 'slots', returning a mapping of period keys to spans."  1003   1004     points = [point for point, active in slots]  1005     spans = {}  1006   1007     for _point, active in slots:  1008         for p in active:  1009             if p:  1010                 key = p.get_key()  1011                 start_slot = bisect_left(points, p.get_start())  1012                 end_slot = bisect_left(points, p.get_end())  1013                 spans[key] = end_slot - start_slot  1014   1015     return spans  1016   1017 def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None):  1018   1019     """  1020     Update the free/busy details with the given 'periods', 'transp' setting,  1021     'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.  1022   1023     An optional 'expires' datetime string indicates the expiry time of any  1024     free/busy offer.  1025     """  1026   1027     remove_event_periods(freebusy, uid, recurrenceid)  1028   1029     for p in periods:  1030         insert_period(freebusy, FreeBusyPeriod(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires))  1031   1032 # vim: tabstop=4 expandtab shiftwidth=4