imip-agent

imiptools/period.py

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