imip-agent

imiptools/freebusy/common.py

1462:87ec4de5c43e
2019-05-05 Paul Boddie Added page links to various diagrams.
     1 #!/usr/bin/env python     2      3 """     4 Managing free/busy periods.     5      6 Copyright (C) 2014, 2015, 2016, 2017 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    23 from imiptools.dates import format_datetime    24 from imiptools.period import get_overlapping, Period, PeriodBase    25     26 # Conversion functions.    27     28 def from_string(s, encoding):    29     30     "Interpret 's' using 'encoding', preserving None."    31     32     if s:    33         if isinstance(s, unicode):    34             return s    35         else:    36             return unicode(s, encoding)    37     else:    38         return s    39     40 def to_string(s, encoding):    41     42     "Encode 's' using 'encoding', preserving None."    43     44     if s:    45         return s.encode(encoding)    46     else:    47         return s    48     49 class period_from_tuple:    50     51     "Convert a tuple to an instance of the given 'period_class'."    52     53     def __init__(self, period_class):    54         self.period_class = period_class    55     def __call__(self, t):    56         return make_period(t, self.period_class)    57     58 def period_to_tuple(p):    59     60     "Convert period 'p' to a tuple for serialisation."    61     62     return p.as_tuple(strings_only=True)    63     64 def make_period(t, period_class):    65     66     "Convert tuple 't' to an instance of the given 'period_class'."    67     68     args = []    69     for arg, column in zip(t, period_class.period_columns):    70         args.append(from_string(arg, "utf-8"))    71     return period_class(*args)    72     73 def make_tuple(t, period_class):    74     75     "Restrict tuple 't' to the columns appropriate for 'period_class'."    76     77     args = []    78     for arg, column in zip(t, period_class.period_columns):    79         args.append(arg)    80     return tuple(args)    81     82     83     84 # Period abstractions.    85     86 class FreeBusyPeriod(PeriodBase):    87     88     "A free/busy record abstraction."    89     90     period_columns = [    91         "start", "end", "object_uid", "transp", "object_recurrenceid",    92         "summary", "organiser"    93         ]    94     95     def __init__(self, start, end, uid=None, transp=None, recurrenceid=None,    96         summary=None, organiser=None):    97     98         """    99         Initialise a free/busy period with the given 'start' and 'end' points,   100         plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'   101         details.   102         """   103    104         PeriodBase.__init__(self, start, end)   105         self.uid = uid or None   106         self.transp = transp or None   107         self.recurrenceid = recurrenceid or None   108         self.summary = summary or None   109         self.organiser = organiser or None   110    111     def as_tuple(self, strings_only=False, string_datetimes=False):   112    113         """   114         Return the initialisation parameter tuple, converting datetimes and   115         false value parameters to strings if 'strings_only' is set to a true   116         value. Otherwise, if 'string_datetimes' is set to a true value, only the   117         datetime values are converted to strings.   118         """   119    120         null = lambda x: (strings_only and [""] or [x])[0]   121         return (   122             (strings_only or string_datetimes) and format_datetime(self.get_start_point()) or self.start,   123             (strings_only or string_datetimes) and format_datetime(self.get_end_point()) or self.end,   124             self.uid or null(self.uid),   125             self.transp or strings_only and "OPAQUE" or None,   126             self.recurrenceid or null(self.recurrenceid),   127             self.summary or null(self.summary),   128             self.organiser or null(self.organiser)   129             )   130    131     def __cmp__(self, other):   132    133         """   134         Compare this object to 'other', employing the uid if the periods   135         involved are the same.   136         """   137    138         result = PeriodBase.__cmp__(self, other)   139         if result == 0 and isinstance(other, FreeBusyPeriod):   140             return cmp((self.uid, self.recurrenceid), (other.uid, other.recurrenceid))   141         else:   142             return result   143    144     def get_key(self):   145         return self.uid, self.recurrenceid, self.get_start()   146    147     def __repr__(self):   148         return "FreeBusyPeriod%r" % (self.as_tuple(),)   149    150     def get_tzid(self):   151         return "UTC"   152    153     # Period and event recurrence logic.   154    155     def is_replaced(self, recurrences):   156    157         """   158         Return whether this period refers to one of the 'recurrences'.   159         The 'recurrences' must be UTC datetimes corresponding to the start of   160         the period described by a recurrence.   161         """   162    163         for recurrence in recurrences:   164             if self.is_affected(recurrence):   165                 return True   166         return False   167    168     def is_affected(self, recurrence):   169    170         """   171         Return whether this period refers to 'recurrence'. The 'recurrence' must   172         be a UTC datetime corresponding to the start of the period described by   173         a recurrence.   174         """   175    176         return recurrence and self.get_start_point() == recurrence   177    178     # Value correction methods.   179    180     def make_corrected(self, start, end):   181         return self.__class__(start, end)   182    183 class FreeBusyOfferPeriod(FreeBusyPeriod):   184    185     "A free/busy record abstraction for an offer period."   186    187     period_columns = FreeBusyPeriod.period_columns + ["expires"]   188    189     def __init__(self, start, end, uid=None, transp=None, recurrenceid=None,   190         summary=None, organiser=None, expires=None):   191    192         """   193         Initialise a free/busy period with the given 'start' and 'end' points,   194         plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'   195         details.   196    197         An additional 'expires' parameter can be used to indicate an expiry   198         datetime in conjunction with free/busy offers made when countering   199         event proposals.   200         """   201    202         FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid,   203             summary, organiser)   204         self.expires = expires or None   205    206     def as_tuple(self, strings_only=False, string_datetimes=False):   207    208         """   209         Return the initialisation parameter tuple, converting datetimes and   210         false value parameters to strings if 'strings_only' is set to a true   211         value. Otherwise, if 'string_datetimes' is set to a true value, only the   212         datetime values are converted to strings.   213         """   214    215         null = lambda x: (strings_only and [""] or [x])[0]   216         return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + (   217             self.expires or null(self.expires),)   218    219     def __repr__(self):   220         return "FreeBusyOfferPeriod%r" % (self.as_tuple(),)   221    222 class FreeBusyGroupPeriod(FreeBusyPeriod):   223    224     "A free/busy record abstraction for a quota group period."   225    226     period_columns = FreeBusyPeriod.period_columns + ["attendee"]   227    228     def __init__(self, start, end, uid=None, transp=None, recurrenceid=None,   229         summary=None, organiser=None, attendee=None):   230    231         """   232         Initialise a free/busy period with the given 'start' and 'end' points,   233         plus any 'uid', 'transp', 'recurrenceid', 'summary' and 'organiser'   234         details.   235    236         An additional 'attendee' parameter can be used to indicate the identity   237         of the attendee recording the period.   238         """   239    240         FreeBusyPeriod.__init__(self, start, end, uid, transp, recurrenceid,   241             summary, organiser)   242         self.attendee = attendee or None   243    244     def as_tuple(self, strings_only=False, string_datetimes=False):   245    246         """   247         Return the initialisation parameter tuple, converting datetimes and   248         false value parameters to strings if 'strings_only' is set to a true   249         value. Otherwise, if 'string_datetimes' is set to a true value, only the   250         datetime values are converted to strings.   251         """   252    253         null = lambda x: (strings_only and [""] or [x])[0]   254         return FreeBusyPeriod.as_tuple(self, strings_only, string_datetimes) + (   255             self.attendee or null(self.attendee),)   256    257     def __cmp__(self, other):   258    259         """   260         Compare this object to 'other', employing the uid if the periods   261         involved are the same.   262         """   263    264         result = FreeBusyPeriod.__cmp__(self, other)   265         if isinstance(other, FreeBusyGroupPeriod) and result == 0:   266             return cmp(self.attendee, other.attendee)   267         else:   268             return result   269    270     def __repr__(self):   271         return "FreeBusyGroupPeriod%r" % (self.as_tuple(),)   272    273    274    275 # Collection abstractions.   276    277 class FreeBusyCollectionBase:   278    279     "Common operations on free/busy period collections."   280    281     period_class = FreeBusyPeriod   282    283     def __init__(self, mutable=True):   284         self.mutable = mutable   285    286     def _check_mutable(self):   287         if not self.mutable:   288             raise TypeError, "Cannot mutate this collection."   289    290     def close(self):   291    292         "Close the collection."   293    294         pass   295    296     def copy(self):   297    298         "Make an independent mutable copy of the collection."   299    300         return FreeBusyCollection(list(self), True)   301    302     def make_period(self, t):   303    304         """   305         Make a period using the given tuple of arguments and the collection's   306         column details.   307         """   308    309         return make_period(t, self.period_class)   310    311     def make_tuple(self, t):   312    313         """   314         Return a tuple from the given tuple 't' conforming to the collection's   315         column details.   316         """   317    318         return make_tuple(t, self.period_class)   319    320     # List emulation methods.   321    322     def __iadd__(self, periods):   323         self.insert_periods(periods)   324         return self   325    326     def append(self, period):   327         self.insert_period(period)   328    329     # Operations.   330    331     def insert_period(self, period):   332    333         """   334         Insert the given 'period' into the collection.   335    336         This should be implemented in subclasses.   337         """   338    339         pass   340    341     def insert_periods(self, periods):   342    343         "Insert the given 'periods' into the collection."   344    345         for p in periods:   346             self.insert_period(p)   347    348     def can_schedule(self, periods, uid, recurrenceid):   349    350         """   351         Return whether the collection can accommodate the given 'periods'   352         employing the specified 'uid' and 'recurrenceid'.   353         """   354    355         for conflict in self.have_conflict(periods, True):   356             if conflict.uid != uid or conflict.recurrenceid != recurrenceid:   357                 return False   358    359         return True   360    361     def have_conflict(self, periods, get_conflicts=False):   362    363         """   364         Return whether any period in the collection overlaps with the given   365         'periods', returning a collection of such overlapping periods if   366         'get_conflicts' is set to a true value.   367         """   368    369         conflicts = set()   370         for p in periods:   371             overlapping = self.period_overlaps(p, get_conflicts)   372             if overlapping:   373                 if get_conflicts:   374                     conflicts.update(overlapping)   375                 else:   376                     return True   377    378         if get_conflicts:   379             return conflicts   380         else:   381             return False   382    383     def period_overlaps(self, period, get_periods=False):   384    385         """   386         Return whether any period in the collection overlaps with the given   387         'period', returning a collection of overlapping periods if 'get_periods'   388         is set to a true value.   389         """   390    391         overlapping = self.get_overlapping([period])   392    393         if get_periods:   394             return overlapping   395         else:   396             return len(overlapping) != 0   397    398     def replace_overlapping(self, period, replacements):   399    400         """   401         Replace existing periods in the collection within the given 'period',   402         using the given 'replacements'.   403         """   404    405         self._check_mutable()   406    407         self.remove_overlapping(period)   408         for replacement in replacements:   409             self.insert_period(replacement)   410    411     def coalesce_freebusy(self):   412    413         "Coalesce the periods in the collection, returning a new collection."   414    415         if not self:   416             return FreeBusyCollection()   417    418         fb = []   419    420         it = iter(self)   421         period = it.next()   422    423         start = period.get_start_point()   424         end = period.get_end_point()   425    426         try:   427             while True:   428                 period = it.next()   429                 if period.get_start_point() > end:   430                     fb.append(self.period_class(start, end))   431                     start = period.get_start_point()   432                     end = period.get_end_point()   433                 else:   434                     end = max(end, period.get_end_point())   435         except StopIteration:   436             pass   437    438         fb.append(self.period_class(start, end))   439         return FreeBusyCollection(fb)   440    441     def invert_freebusy(self):   442    443         "Return the free periods from the collection as a new collection."   444    445         if not self:   446             return FreeBusyCollection([self.period_class(None, None)])   447    448         # Coalesce periods that overlap or are adjacent.   449    450         fb = self.coalesce_freebusy()   451         free = []   452    453         # Add a start-of-time period if appropriate.   454    455         first = fb[0].get_start_point()   456         if first:   457             free.append(self.period_class(None, first))   458    459         start = fb[0].get_end_point()   460    461         for period in fb[1:]:   462             free.append(self.period_class(start, period.get_start_point()))   463             start = period.get_end_point()   464    465         # Add an end-of-time period if appropriate.   466    467         if start:   468             free.append(self.period_class(start, None))   469    470         return FreeBusyCollection(free)   471    472     def _update_freebusy(self, periods, uid, recurrenceid):   473    474         """   475         Update the free/busy details with the given 'periods', using the given   476         'uid' plus 'recurrenceid' to remove existing periods.   477         """   478    479         self._check_mutable()   480    481         self.remove_specific_event_periods(uid, recurrenceid)   482    483         self.insert_periods(periods)   484    485     def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser):   486    487         """   488         Update the free/busy details with the given 'periods', 'transp' setting,   489         'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.   490         """   491    492         new_periods = []   493    494         for p in periods:   495             new_periods.append(   496                 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser)   497                 )   498    499         self._update_freebusy(new_periods, uid, recurrenceid)   500    501 class SupportAttendee:   502    503     "A mix-in that supports the affected attendee in free/busy periods."   504    505     period_class = FreeBusyGroupPeriod   506    507     def _update_freebusy(self, periods, uid, recurrenceid, attendee=None):   508    509         """   510         Update the free/busy details with the given 'periods', using the given   511         'uid' plus 'recurrenceid' and 'attendee' to remove existing periods.   512         """   513    514         self._check_mutable()   515    516         self.remove_specific_event_periods(uid, recurrenceid, attendee)   517    518         self.insert_periods(periods)   519    520     def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, attendee=None):   521    522         """   523         Update the free/busy details with the given 'periods', 'transp' setting,   524         'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.   525    526         An optional 'attendee' indicates the attendee affected by the period.   527         """   528    529         new_periods = []   530    531         for p in periods:   532             new_periods.append(   533                 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, attendee)   534                 )   535    536         self._update_freebusy(new_periods, uid, recurrenceid, attendee)   537    538 class SupportExpires:   539    540     "A mix-in that supports the expiry datetime in free/busy periods."   541    542     period_class = FreeBusyOfferPeriod   543    544     def update_freebusy(self, periods, transp, uid, recurrenceid, summary, organiser, expires=None):   545    546         """   547         Update the free/busy details with the given 'periods', 'transp' setting,   548         'uid' plus 'recurrenceid' and 'summary' and 'organiser' details.   549    550         An optional 'expires' datetime string indicates the expiry time of any   551         free/busy offer.   552         """   553    554         new_periods = []   555    556         for p in periods:   557             new_periods.append(   558                 self.period_class(p.get_start_point(), p.get_end_point(), uid, transp, recurrenceid, summary, organiser, expires)   559                 )   560    561         self._update_freebusy(new_periods, uid, recurrenceid)   562    563    564    565 # Simple abstractions suitable for use with file-based representations and as   566 # general copies of collections.   567    568 class FreeBusyCollection(FreeBusyCollectionBase):   569    570     "An abstraction for a collection of free/busy periods."   571    572     def __init__(self, periods=None, mutable=True):   573    574         """   575         Initialise the collection with the given list of 'periods', or start an   576         empty collection if no list is given. If 'mutable' is indicated, the   577         collection may be changed; otherwise, an exception will be raised.   578         """   579    580         FreeBusyCollectionBase.__init__(self, mutable)   581    582         if periods is not None:   583             self.periods = periods   584         else:   585             self.periods = []   586    587     def get_filename(self):   588    589         "Return any filename for the periods collection."   590    591         if hasattr(self.periods, "filename"):   592             return self.periods.filename   593         else:   594             return None   595    596     def close(self):   597    598         "Close the collection."   599    600         if hasattr(self.periods, "close"):   601             self.periods.close()   602    603     # List emulation methods.   604    605     def __nonzero__(self):   606         return bool(self.periods)   607    608     def __iter__(self):   609         return iter(self.periods)   610    611     def __len__(self):   612         return len(self.periods)   613    614     def __getitem__(self, i):   615         return self.periods[i]   616    617     # Dictionary emulation methods (even though this is not a mapping).   618    619     def clear(self):   620         del self.periods[:]   621    622     # Operations.   623    624     def insert_period(self, period):   625    626         "Insert the given 'period' into the collection."   627    628         self._check_mutable()   629    630         i = bisect_left(self.periods, period)   631         if i == len(self.periods):   632             self.periods.append(period)   633         elif self.periods[i] != period:   634             self.periods.insert(i, period)   635    636     def remove_periods(self, periods):   637    638         "Remove the given 'periods' from the collection."   639    640         self._check_mutable()   641    642         for period in periods:   643             i = bisect_left(self.periods, period)   644             if i < len(self.periods) and self.periods[i] == period:   645                 del self.periods[i]   646    647     def remove_periods_before(self, period):   648    649         "Remove the entries in the collection before 'period'."   650    651         last = bisect_right(self.periods, period)   652         self.remove_periods(self.periods[:last])   653    654     def remove_event_periods(self, uid, recurrenceid=None, participant=None):   655    656         """   657         Remove from the collection all periods associated with 'uid' and   658         'recurrenceid' (which if omitted causes the "parent" object's periods to   659         be referenced).   660    661         If 'participant' is specified, only remove periods for which the   662         participant is given as attending.   663    664         Return the removed periods.   665         """   666    667         self._check_mutable()   668    669         removed = []   670         i = 0   671         while i < len(self.periods):   672             fb = self.periods[i]   673    674             if fb.uid == uid and fb.recurrenceid == recurrenceid and \   675                (not participant or participant == fb.attendee):   676    677                 removed.append(self.periods[i])   678                 del self.periods[i]   679             else:   680                 i += 1   681    682         return removed   683    684     # Specific period removal when updating event details.   685    686     remove_specific_event_periods = remove_event_periods   687    688     def remove_additional_periods(self, uid, recurrenceids=None):   689    690         """   691         Remove from the collection all periods associated with 'uid' having a   692         recurrence identifier indicating an additional or modified period.   693    694         If 'recurrenceids' is specified, remove all periods associated with   695         'uid' that do not have a recurrence identifier in the given list.   696    697         Return the removed periods.   698         """   699    700         self._check_mutable()   701    702         removed = []   703         i = 0   704         while i < len(self.periods):   705             fb = self.periods[i]   706             if fb.uid == uid and fb.recurrenceid and (   707                 recurrenceids is None or   708                 recurrenceids is not None and fb.recurrenceid not in recurrenceids   709                 ):   710                 removed.append(self.periods[i])   711                 del self.periods[i]   712             else:   713                 i += 1   714    715         return removed   716    717     def remove_affected_period(self, uid, start, participant=None):   718    719         """   720         Remove from the collection the period associated with 'uid' that   721         provides an occurrence starting at the given 'start' (provided by a   722         recurrence identifier, converted to a datetime). A recurrence identifier   723         is used to provide an alternative time period whilst also acting as a   724         reference to the originally-defined occurrence.   725    726         If 'participant' is specified, only remove periods for which the   727         participant is given as attending.   728    729         Return any removed period in a list.   730         """   731    732         self._check_mutable()   733    734         removed = []   735    736         search = Period(start, start)   737         found = bisect_left(self.periods, search)   738    739         while found < len(self.periods):   740             fb = self.periods[found]   741    742             # Stop looking if the start no longer matches the recurrence identifier.   743    744             if fb.get_start_point() != search.get_start_point():   745                 break   746    747             # If the period belongs to the parent object, remove it and return.   748    749             if not fb.recurrenceid and uid == fb.uid and \   750                (not participant or participant == fb.attendee):   751    752                 removed.append(self.periods[found])   753                 del self.periods[found]   754                 break   755    756             # Otherwise, keep looking for a matching period.   757    758             found += 1   759    760         return removed   761    762     def periods_from(self, period):   763    764         "Return the entries in the collection at or after 'period'."   765    766         first = bisect_left(self.periods, period)   767         return self.periods[first:]   768    769     def periods_until(self, period):   770    771         "Return the entries in the collection before 'period'."   772    773         last = bisect_right(self.periods, Period(period.get_end(), period.get_end(), period.get_tzid()))   774         return self.periods[:last]   775    776     def get_overlapping(self, periods):   777    778         """   779         Return the entries in the collection providing periods overlapping with   780         the given sorted collection of 'periods'.   781         """   782    783         return get_overlapping(self.periods, periods)   784    785     def remove_overlapping(self, period):   786    787         "Remove all periods overlapping with 'period' from the collection."   788    789         self._check_mutable()   790    791         overlapping = self.get_overlapping([period])   792    793         if overlapping:   794             for fb in overlapping:   795                 self.periods.remove(fb)   796    797 class FreeBusyGroupCollection(SupportAttendee, FreeBusyCollection):   798    799     "A collection of quota group free/busy objects."   800    801     def remove_specific_event_periods(self, uid, recurrenceid=None, attendee=None):   802    803         """   804         Remove from the collection all periods associated with 'uid' and   805         'recurrenceid' (which if omitted causes the "parent" object's periods to   806         be referenced) and any 'attendee'.   807    808         Return the removed periods.   809         """   810    811         self._check_mutable()   812    813         removed = []   814         i = 0   815         while i < len(self.periods):   816             fb = self.periods[i]   817             if fb.uid == uid and fb.recurrenceid == recurrenceid and fb.attendee == attendee:   818                 removed.append(self.periods[i])   819                 del self.periods[i]   820             else:   821                 i += 1   822    823         return removed   824    825 class FreeBusyOffersCollection(SupportExpires, FreeBusyCollection):   826    827     "A collection of offered free/busy objects."   828    829     pass   830    831 # vim: tabstop=4 expandtab shiftwidth=4