imip-agent

imiptools/period.py

560:ade19f50b58e
2015-05-18 Paul Boddie Produce recurring periods employing dates if they are involved. Handle missing DTSTART when encountering CANCEL messages.
     1 #!/usr/bin/env python     2      3 """     4 Managing and presenting periods of time.     5      6 Copyright (C) 2014, 2015 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 format_datetime, get_datetime, \    25                             get_datetime_attributes, get_start_of_day, \    26                             get_tzid, to_timezone, to_utc_datetime    27     28 class Period:    29     30     "A basic period abstraction."    31     32     def __init__(self, start, end, tzid=None, origin=None):    33     34         """    35         Initialise a period with the given 'start' and 'end', having a    36         contextual 'tzid', if specified, and an indicated 'origin'.    37         """    38     39         self.start = isinstance(start, date) and start or get_datetime(start)    40         self.end = isinstance(end, date) and end or get_datetime(end)    41         self.tzid = tzid    42         self.origin = origin    43     44     def as_tuple(self):    45         return self.start, self.end, self.tzid, self.origin    46     47     def __hash__(self):    48         return hash((self.get_start(), self.get_end()))    49     50     def __cmp__(self, other):    51     52         """    53         Return a comparison result against 'other' using points in time,    54         employing the time zone context to convert dates.    55         """    56     57         if isinstance(other, Period):    58             return cmp(    59                 (self.get_start_point(), self.get_end_point()),    60                 (other.get_start_point(), other.get_end_point())    61                 )    62         else:    63             return 1    64     65     def get_key(self):    66         return self.get_start(), self.get_end()    67     68     def __repr__(self):    69         return "Period(%r)" % (self.as_tuple(),)    70     71     # Datetime metadata methods.    72     73     def get_start(self):    74         return self.start    75     76     def get_end(self):    77         return self.end    78     79     def get_tzid(self):    80         return self.tzid    81     82     def get_start_item(self):    83         return self.start, get_datetime_attributes(self.start)    84     85     def get_end_item(self):    86         return self.end, get_datetime_attributes(self.end)    87     88     def get_start_point(self):    89         return to_utc_datetime(self.get_start(), self.get_tzid())    90     91     def get_end_point(self):    92         return to_utc_datetime(self.get_end(), self.get_tzid())    93     94 class FreeBusyPeriod(Period):    95     96     "A free/busy record abstraction."    97     98     def __init__(self, start, end, uid=None, transp=None, recurrenceid=None, summary=None, organiser=None, tzid=None):    99    100         """   101         Initialise a free/busy period with the given 'start' and 'end' limits,   102         with an optional 'tzid', plus any 'uid', 'transp', 'recurrenceid',   103         'summary' and 'organiser' details.   104         """   105    106         Period.__init__(self, start, end, tzid)   107         self.uid = uid   108         self.transp = transp   109         self.recurrenceid = recurrenceid   110         self.summary = summary   111         self.organiser = organiser   112    113     def as_tuple(self):   114         return format_datetime(self.get_start_point()), \   115                format_datetime(self.get_end_point()), \   116                self.uid, self.transp, self.recurrenceid, self.summary, self.organiser   117    118     def __cmp__(self, other):   119    120         """   121         Compare this object to 'other', employing the uid if the periods   122         involved are the same.   123         """   124    125         result = Period.__cmp__(self, other)   126         if result == 0 and isinstance(other, FreeBusyPeriod):   127             return cmp(self.uid, other.uid)   128         else:   129             return result   130    131     def get_key(self):   132         return self.uid, self.recurrenceid, self.get_start()   133    134     def __repr__(self):   135         return "FreeBusyPeriod(%r)" % (self.as_tuple(),)   136    137 class RecurringPeriod(Period):   138        139     "A period with iCalendar attribute and origin information from the object."   140        141     def __init__(self, start, end, tzid=None, origin=None, start_attr=None, end_attr=None):   142         Period.__init__(self, start, end, tzid, origin)   143         self.start_attr = start_attr   144         self.end_attr = end_attr   145    146     def get_start_item(self):   147         return self.get_start(), self.start_attr   148    149     def get_end_item(self):   150         return self.get_end(), self.end_attr   151    152     def get_tzid(self):   153         return get_tzid(self.start_attr, self.end_attr) or self.tzid   154    155     def as_tuple(self):   156         return self.start, self.end, self.tzid, self.origin, self.start_attr, self.end_attr   157        158     def __repr__(self):   159         return "RecurringPeriod(%r)" % (self.as_tuple(),)   160    161 # Time and period management.   162    163 def can_schedule(freebusy, periods, uid, recurrenceid):   164    165     """   166     Return whether the 'freebusy' list can accommodate the given 'periods'   167     employing the specified 'uid' and 'recurrenceid'.   168     """   169    170     for conflict in have_conflict(freebusy, periods, True):   171         if conflict.uid != uid or conflict.recurrenceid != recurrenceid:   172             return False   173    174     return True   175    176 def have_conflict(freebusy, periods, get_conflicts=False):   177    178     """   179     Return whether any period in 'freebusy' overlaps with the given 'periods',   180     returning a collection of such overlapping periods if 'get_conflicts' is   181     set to a true value.   182     """   183    184     conflicts = set()   185     for p in periods:   186         overlapping = period_overlaps(freebusy, p, get_conflicts)   187         if overlapping:   188             if get_conflicts:   189                 conflicts.update(overlapping)   190             else:   191                 return True   192    193     if get_conflicts:   194         return conflicts   195     else:   196         return False   197    198 def insert_period(freebusy, period):   199    200     "Insert into 'freebusy' the given 'period'."   201    202     insort_left(freebusy, period)   203    204 def remove_period(freebusy, uid, recurrenceid=None):   205    206     """   207     Remove from 'freebusy' all periods associated with 'uid' and 'recurrenceid'   208     (which if omitted causes the "parent" object's periods to be referenced).   209     """   210    211     removed = False   212     i = 0   213     while i < len(freebusy):   214         fb = freebusy[i]   215         if fb.uid == uid and fb.recurrenceid == recurrenceid:   216             del freebusy[i]   217             removed = True   218         else:   219             i += 1   220    221     return removed   222    223 def remove_additional_periods(freebusy, uid, recurrenceids=None):   224    225     """   226     Remove from 'freebusy' all periods associated with 'uid' having a   227     recurrence identifier indicating an additional or modified period.   228    229     If 'recurrenceids' is specified, remove all periods associated with 'uid'   230     that do not have a recurrence identifier in the given list.   231     """   232    233     i = 0   234     while i < len(freebusy):   235         fb = freebusy[i]   236         if fb.uid == uid and fb.recurrenceid and (   237             recurrenceids is None or   238             recurrenceids is not None and fb.recurrenceid not in recurrenceids   239             ):   240             del freebusy[i]   241         else:   242             i += 1   243    244 def remove_affected_period(freebusy, uid, start):   245    246     """   247     Remove from 'freebusy' a period associated with 'uid' that provides an   248     occurrence starting at the given 'start' (provided by a recurrence   249     identifier, converted to a datetime). A recurrence identifier is used to   250     provide an alternative time period whilst also acting as a reference to the   251     originally-defined occurrence.   252     """   253    254     search = Period(start, start)   255     found = bisect_left(freebusy, search)   256    257     while found < len(freebusy):   258         fb = freebusy[found]   259    260         # Stop looking if the start no longer matches the recurrence identifier.   261    262         if fb.get_start_point() != search.get_start_point():   263             return   264    265         # If the period belongs to the parent object, remove it and return.   266    267         if not fb.recurrenceid and uid == fb.uid:   268             del freebusy[found]   269             break   270    271         # Otherwise, keep looking for a matching period.   272    273         found += 1   274    275 def get_overlapping(freebusy, period):   276    277     """   278     Return the entries in 'freebusy' providing periods overlapping with   279     'period'.   280     """   281    282     # Find the range of periods potentially overlapping the period in the   283     # free/busy collection.   284    285     last = bisect_right(freebusy, Period(period.get_end(), period.get_end(), period.get_tzid()))   286     startpoints = freebusy[:last]   287    288     # Find the range of periods potentially overlapping the period in a version   289     # of the free/busy collection sorted according to end datetimes.   290    291     endpoints = [(fb.get_end_point(), fb.get_start_point(), fb) for fb in startpoints]   292     endpoints.sort()   293     first = bisect_left(endpoints, (period.get_start_point(),))   294     endpoints = endpoints[first:]   295    296     overlapping = set()   297    298     for end, start, fb in endpoints:   299         if end > period.get_start_point() and start < period.get_end_point():   300             overlapping.add(fb)   301    302     overlapping = list(overlapping)   303     overlapping.sort()   304     return overlapping   305    306 def period_overlaps(freebusy, period, get_periods=False):   307    308     """   309     Return whether any period in 'freebusy' overlaps with the given 'period',   310     returning a collection of overlapping periods if 'get_periods' is set to a   311     true value.   312     """   313    314     overlapping = get_overlapping(freebusy, period)   315    316     if get_periods:   317         return overlapping   318     else:   319         return len(overlapping) != 0   320    321 def remove_overlapping(freebusy, period):   322    323     "Remove from 'freebusy' all periods overlapping with 'period'."   324    325     overlapping = get_overlapping(freebusy, period)   326    327     if overlapping:   328         for fb in overlapping:   329             freebusy.remove(fb)   330    331 def replace_overlapping(freebusy, period, replacements):   332    333     """   334     Replace existing periods in 'freebusy' within the given 'period', using the   335     given 'replacements'.   336     """   337    338     remove_overlapping(freebusy, period)   339     for replacement in replacements:   340         insert_period(freebusy, replacement)   341    342 # Period layout.   343    344 def get_scale(periods, tzid):   345    346     """   347     Return an ordered time scale from the given list of 'periods'.   348    349     The given 'tzid' is used to make sure that the times are defined according   350     to the chosen time zone.   351    352     The returned scale is a mapping from time to (starting, ending) tuples,   353     where starting and ending are collections of periods.   354     """   355    356     scale = {}   357    358     for p in periods:   359    360         # Add a point and this event to the starting list.   361    362         start = to_timezone(p.get_start(), tzid)   363         if not scale.has_key(start):   364             scale[start] = [], []   365         scale[start][0].append(p)   366    367         # Add a point and this event to the ending list.   368    369         end = to_timezone(p.get_end(), tzid)   370         if not scale.has_key(end):   371             scale[end] = [], []   372         scale[end][1].append(p)   373    374     return scale   375    376 class Point:   377    378     "A qualified point in time."   379    380     PRINCIPAL, REPEATED = 0, 1   381    382     def __init__(self, point, indicator=None):   383         self.point = point   384         self.indicator = indicator or self.PRINCIPAL   385    386     def __hash__(self):   387         return hash((self.point, self.indicator))   388    389     def __cmp__(self, other):   390         if isinstance(other, Point):   391             return cmp((self.point, self.indicator), (other.point, other.indicator))   392         elif isinstance(other, datetime):   393             return cmp(self.point, other)   394         else:   395             return 1   396    397     def __eq__(self, other):   398         return self.__cmp__(other) == 0   399    400     def __ne__(self, other):   401         return not self == other   402    403     def __lt__(self, other):   404         return self.__cmp__(other) < 0   405    406     def __le__(self, other):   407         return self.__cmp__(other) <= 0   408    409     def __gt__(self, other):   410         return not self <= other   411    412     def __ge__(self, other):   413         return not self < other   414    415     def __repr__(self):   416         return "Point(%r, Point.%s)" % (self.point, self.indicator and "REPEATED" or "PRINCIPAL")   417    418 def get_slots(scale):   419    420     """   421     Return an ordered list of time slots from the given 'scale'.   422    423     Each slot is a tuple containing details of a point in time for the start of   424     the slot, together with a list of parallel event periods.   425    426     Each point in time is described as a Point representing the actual point in   427     time together with an indicator of the nature of the point in time (as a   428     principal point in a time scale or as a repeated point used to terminate   429     events occurring for an instant in time).   430     """   431    432     slots = []   433     active = []   434    435     points = scale.items()   436     points.sort()   437    438     for point, (starting, ending) in points:   439         ending = set(ending)   440         instants = ending.intersection(starting)   441    442         # Discard all active events ending at or before this start time.   443         # Free up the position in the active list.   444    445         for t in ending.difference(instants):   446             i = active.index(t)   447             active[i] = None   448    449         # For each event starting at the current point, fill any newly-vacated   450         # position or add to the end of the active list.   451    452         for t in starting:   453             try:   454                 i = active.index(None)   455                 active[i] = t   456             except ValueError:   457                 active.append(t)   458    459         # Discard vacant positions from the end of the active list.   460    461         while active and active[-1] is None:   462             active.pop()   463    464         # Add an entry for the time point before "instants".   465    466         slots.append((Point(point), active[:]))   467    468         # Discard events ending at the same time as they began.   469    470         if instants:   471             for t in instants:   472                 i = active.index(t)   473                 active[i] = None   474    475             # Discard vacant positions from the end of the active list.   476    477             while active and active[-1] is None:   478                 active.pop()   479    480             # Add another entry for the time point after "instants".   481    482             slots.append((Point(point, Point.REPEATED), active[:]))   483    484     return slots   485    486 def add_day_start_points(slots, tzid):   487    488     """   489     Introduce into the 'slots' any day start points required by multi-day   490     periods. The 'tzid' is required to make sure that appropriate time zones   491     are chosen and not necessarily those provided by the existing time points.   492     """   493    494     new_slots = []   495     current_date = None   496     previously_active = []   497    498     for point, active in slots:   499         start_of_day = get_start_of_day(point.point, tzid)   500         this_date = point.point.date()   501    502         # For each new day, add a slot for the start of the day where periods   503         # are active and where no such slot already exists.   504    505         if this_date != current_date:   506    507             # Fill in days where events remain active.   508    509             if current_date:   510                 current_date += timedelta(1)   511                 while current_date < this_date:   512                     new_slots.append((Point(get_start_of_day(current_date, tzid)), previously_active))   513                     current_date += timedelta(1)   514             else:   515                 current_date = this_date   516    517             # Add any continuing periods.   518    519             if point.point != start_of_day:   520                 new_slots.append((Point(start_of_day), previously_active))   521    522         # Add the currently active periods at this point in time.   523    524         previously_active = active   525    526     for t in new_slots:   527         insort_left(slots, t)   528    529 def add_slots(slots, points):   530    531     """   532     Introduce into the 'slots' entries for those in 'points' that are not   533     already present, propagating active periods from time points preceding   534     those added.   535     """   536    537     new_slots = []   538    539     for point in points:   540         i = bisect_left(slots, (point,)) # slots is [(point, active)...]   541         if i < len(slots) and slots[i][0] == point:   542             continue   543    544         new_slots.append((point, i > 0 and slots[i-1][1] or []))   545    546     for t in new_slots:   547         insort_left(slots, t)   548    549 def partition_by_day(slots):   550    551     """   552     Return a mapping from dates to time points provided by 'slots'.   553     """   554    555     d = {}   556    557     for point, value in slots:   558         day = point.point.date()   559         if not d.has_key(day):   560             d[day] = []   561         d[day].append((point, value))   562    563     return d   564    565 def add_empty_days(days, tzid):   566    567     "Add empty days to 'days' between busy days."   568    569     last_day = None   570     all_days = days.keys()   571     all_days.sort()   572    573     for day in all_days:   574         if last_day:   575             empty_day = last_day + timedelta(1)   576             while empty_day < day:   577                 days[empty_day] = [(Point(get_start_of_day(empty_day, tzid)), None)]   578                 empty_day += timedelta(1)   579         last_day = day    580    581 def get_spans(slots):   582    583     "Inspect the given 'slots', returning a mapping of period keys to spans."   584    585     points = [point for point, active in slots]   586     spans = {}   587    588     for _point, active in slots:   589         for p in active:   590             if p:   591                 key = p.get_key()   592                 start_slot = bisect_left(points, p.get_start())   593                 end_slot = bisect_left(points, p.get_end())   594                 spans[key] = end_slot - start_slot   595    596     return spans   597    598 def update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser):   599    600     """   601     Update the free/busy details with the given 'periods', 'transp' setting,   602     'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.   603     """   604    605     remove_period(freebusy, uid, recurrenceid)   606    607     for p in periods:   608         insert_period(freebusy, FreeBusyPeriod(p.get_start(), p.get_end(), uid, transp, recurrenceid, summary, organiser, p.get_tzid()))   609    610 # vim: tabstop=4 expandtab shiftwidth=4