imip-agent

imiptools/data.py

535:7cd2088ea85d
2015-05-17 Paul Boddie Attempted to consolidate identifier handling, particularly the construction and comparison of recurrence identifiers. Modified the period start and end retrieval methods, removing timezone conversion parameters. Fixed links to replacement events, again. Simplified invocations by removing superfluous uid parameters.
     1 #!/usr/bin/env python     2      3 """     4 Interpretation of vCalendar content.     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    23 from datetime import datetime, timedelta    24 from email.mime.text import MIMEText    25 from imiptools.dates import format_datetime, get_datetime, get_duration, \    26                             get_freebusy_period, get_period, to_datetime, \    27                             to_timezone, to_utc_datetime    28 from imiptools.period import Period, period_overlaps    29 from pytz import timezone    30 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node    31 from vRecurrence import get_parameters, get_rule    32 import email.utils    33     34 try:    35     from cStringIO import StringIO    36 except ImportError:    37     from StringIO import StringIO    38     39 class Object:    40     41     "Access to calendar structures."    42     43     def __init__(self, fragment):    44         self.objtype, (self.details, self.attr) = fragment.items()[0]    45     46     def get_uid(self):    47         return self.get_value("UID")    48     49     def get_recurrenceid(self):    50         return format_datetime(self.get_utc_datetime("RECURRENCE-ID"))    51     52     # Structure access.    53     54     def copy(self):    55         return Object(to_dict(self.to_node()))    56     57     def get_items(self, name, all=True):    58         return get_items(self.details, name, all)    59     60     def get_item(self, name):    61         return get_item(self.details, name)    62     63     def get_value_map(self, name):    64         return get_value_map(self.details, name)    65     66     def get_values(self, name, all=True):    67         return get_values(self.details, name, all)    68     69     def get_value(self, name):    70         return get_value(self.details, name)    71     72     def get_utc_datetime(self, name, date_tzid=None):    73         return get_utc_datetime(self.details, name, date_tzid)    74     75     def get_date_values(self, name, tzid=None):    76         items = get_date_value_items(self.details, name, tzid)    77         return items and [value for value, attr in items]    78     79     def get_date_value_items(self, name, tzid=None):    80         return get_date_value_items(self.details, name, tzid)    81     82     def get_datetime(self, name):    83         dt, attr = get_datetime_item(self.details, name)    84         return dt    85     86     def get_datetime_item(self, name):    87         return get_datetime_item(self.details, name)    88     89     def get_duration(self, name):    90         return get_duration(self.get_value(name))    91     92     def to_node(self):    93         return to_node({self.objtype : [(self.details, self.attr)]})    94     95     def to_part(self, method):    96         return to_part(method, [self.to_node()])    97     98     # Direct access to the structure.    99    100     def has_key(self, name):   101         return self.details.has_key(name)   102    103     def get(self, name):   104         return self.details.get(name)   105    106     def __getitem__(self, name):   107         return self.details[name]   108    109     def __setitem__(self, name, value):   110         self.details[name] = value   111    112     def __delitem__(self, name):   113         del self.details[name]   114    115     def remove(self, name):   116         try:   117             del self[name]   118         except KeyError:   119             pass   120    121     def remove_all(self, names):   122         for name in names:   123             self.remove(name)   124    125     # Computed results.   126    127     def has_recurrence(self, tzid, recurrence):   128         recurrences = [p.get_start() for p in get_periods(self, tzid, recurrence, inclusive=True)]   129         return recurrence in recurrences   130    131     def get_periods(self, tzid, end):   132         return get_periods(self, tzid, end)   133    134     def get_periods_for_freebusy(self, tzid, end):   135         periods = self.get_periods(tzid, end)   136         return get_periods_for_freebusy(self, periods, tzid)   137    138     def get_tzid(self):   139         dtstart, dtstart_attr = self.get_datetime_item("DTSTART")   140         dtend, dtend_attr = self.get_datetime_item("DTEND")   141         return get_tzid(dtstart_attr, dtend_attr)   142    143 # Construction and serialisation.   144    145 def make_calendar(nodes, method=None):   146    147     """   148     Return a complete calendar node wrapping the given 'nodes' and employing the   149     given 'method', if indicated.   150     """   151    152     return ("VCALENDAR", {},   153             (method and [("METHOD", {}, method)] or []) +   154             [("VERSION", {}, "2.0")] +   155             nodes   156            )   157    158 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,   159     attendee_attr=None, dtstart=None, dtend=None):   160        161     """   162     Return a calendar node defining the free/busy details described in the given   163     'freebusy' list, employing the given 'uid', for the given 'organiser' and   164     optional 'organiser_attr', with the optional 'attendee' providing recipient   165     details together with the optional 'attendee_attr'.   166    167     The result will be constrained to the 'dtstart' and 'dtend' period if these   168     parameters are given.   169     """   170        171     record = []   172     rwrite = record.append   173        174     rwrite(("ORGANIZER", organiser_attr or {}, organiser))   175    176     if attendee:   177         rwrite(("ATTENDEE", attendee_attr or {}, attendee))    178    179     rwrite(("UID", {}, uid))   180    181     if freebusy:   182    183         # Get a constrained view if start and end limits are specified.   184    185         periods = dtstart and dtend and \   186             period_overlaps(freebusy, Period(dtstart, dtend), True) or \   187             freebusy   188    189         # Write the limits of the resource.   190    191         rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, format_datetime(periods[0].get_start())))   192         rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, format_datetime(periods[-1].get_end())))   193    194         for p in periods:   195             if p.transp == "OPAQUE":   196                 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join(   197                     map(format_datetime, [p.get_start(), p.get_end()])   198                     )))   199    200     return ("VFREEBUSY", {}, record)   201    202 def parse_object(f, encoding, objtype=None):   203    204     """   205     Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is   206     given, only objects of that type will be returned. Otherwise, the root of   207     the content will be returned as a dictionary with a single key indicating   208     the object type.   209    210     Return None if the content was not readable or suitable.   211     """   212    213     try:   214         try:   215             doctype, attrs, elements = obj = parse(f, encoding=encoding)   216             if objtype and doctype == objtype:   217                 return to_dict(obj)[objtype][0]   218             elif not objtype:   219                 return to_dict(obj)   220         finally:   221             f.close()   222    223     # NOTE: Handle parse errors properly.   224    225     except (ParseError, ValueError):   226         pass   227    228     return None   229    230 def to_part(method, calendar):   231    232     """   233     Write using the given 'method', the 'calendar' details to a MIME   234     text/calendar part.   235     """   236    237     encoding = "utf-8"   238     out = StringIO()   239     try:   240         to_stream(out, make_calendar(calendar, method), encoding)   241         part = MIMEText(out.getvalue(), "calendar", encoding)   242         part.set_param("method", method)   243         return part   244    245     finally:   246         out.close()   247    248 def to_stream(out, fragment, encoding="utf-8"):   249     iterwrite(out, encoding=encoding).append(fragment)   250    251 # Structure access functions.   252    253 def get_items(d, name, all=True):   254    255     """   256     Get all items from 'd' for the given 'name', returning single items if   257     'all' is specified and set to a false value and if only one value is   258     present for the name. Return None if no items are found for the name or if   259     many items are found but 'all' is set to a false value.   260     """   261    262     if d.has_key(name):   263         items = d[name]   264         if all:   265             return items   266         elif len(items) == 1:   267             return items[0]   268         else:   269             return None   270     else:   271         return None   272    273 def get_item(d, name):   274     return get_items(d, name, False)   275    276 def get_value_map(d, name):   277    278     """   279     Return a dictionary for all items in 'd' having the given 'name'. The   280     dictionary will map values for the name to any attributes or qualifiers   281     that may have been present.   282     """   283    284     items = get_items(d, name)   285     if items:   286         return dict(items)   287     else:   288         return {}   289    290 def values_from_items(items):   291     return map(lambda x: x[0], items)   292    293 def get_values(d, name, all=True):   294     if d.has_key(name):   295         items = d[name]   296         if not all and len(items) == 1:   297             return items[0][0]   298         else:   299             return values_from_items(items)   300     else:   301         return None   302    303 def get_value(d, name):   304     return get_values(d, name, False)   305    306 def get_date_value_items(d, name, tzid=None):   307    308     """   309     Obtain items from 'd' having the given 'name', where a single item yields   310     potentially many values. Return a list of tuples of the form (value,   311     attributes) where the attributes have been given for the property in 'd'.   312     """   313    314     items = get_items(d, name)   315     if items:   316         all_items = []   317         for item in items:   318             values, attr = item   319             if not attr.has_key("TZID") and tzid:   320                 attr["TZID"] = tzid   321             if not isinstance(values, list):   322                 values = [values]   323             for value in values:   324                 all_items.append((get_datetime(value, attr) or get_period(value, attr), attr))   325         return all_items   326     else:   327         return None   328    329 def get_utc_datetime(d, name, date_tzid=None):   330    331     """   332     Return the value provided by 'd' for 'name' as a datetime in the UTC zone   333     or as a date, converting any date to a datetime if 'date_tzid' is specified.   334     """   335    336     t = get_datetime_item(d, name)   337     if not t:   338         return None   339     else:   340         dt, attr = t   341         return to_utc_datetime(dt, date_tzid)   342    343 def get_datetime_item(d, name):   344     t = get_item(d, name)   345     if not t:   346         return None   347     else:   348         value, attr = t   349         return get_datetime(value, attr), attr   350    351 # Conversion functions.   352    353 def get_addresses(values):   354     return [address for name, address in email.utils.getaddresses(values)]   355    356 def get_address(value):   357     value = value.lower()   358     return value.startswith("mailto:") and value[7:] or value   359    360 def get_uri(value):   361     return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower()   362    363 uri_value = get_uri   364    365 def uri_values(values):   366     return map(get_uri, values)   367    368 def uri_dict(d):   369     return dict([(get_uri(key), value) for key, value in d.items()])   370    371 def uri_item(item):   372     return get_uri(item[0]), item[1]   373    374 def uri_items(items):   375     return [(get_uri(value), attr) for value, attr in items]   376    377 # Operations on structure data.   378    379 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set):   380    381     """   382     Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and   383     'new_dtstamp', and the 'partstat_set' indication, whether the object   384     providing the new information is really newer than the object providing the   385     old information.   386     """   387    388     have_sequence = old_sequence is not None and new_sequence is not None   389     is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)   390    391     have_dtstamp = old_dtstamp and new_dtstamp   392     is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp   393    394     is_old_sequence = have_sequence and (   395         int(new_sequence) < int(old_sequence) or   396         is_same_sequence and is_old_dtstamp   397         )   398    399     return is_same_sequence and partstat_set or not is_old_sequence   400    401 # NOTE: Need to expose the 100 day window for recurring events in the   402 # NOTE: configuration.   403    404 def get_window_end(tzid, window_size=100):   405     return to_timezone(datetime.now(), tzid) + timedelta(window_size)   406    407 def get_tzid(dtstart_attr, dtend_attr):   408     return dtstart_attr.get("TZID") or dtend_attr.get("TZID")   409    410 class RecurringPeriod(Period):   411    412     "A period with origin information from the object."   413    414     def __init__(self, start, end, origin, start_attr=None, end_attr=None):   415         Period.__init__(self, start, end, origin)   416         self.start_attr = start_attr   417         self.end_attr = end_attr   418    419     def get_start_item(self):   420         return self.start, self.start_attr   421    422     def get_end_item(self):   423         return self.end, self.end_attr   424    425     def get_tzid(self):   426         return get_tzid(self.start_attr, self.end_attr)   427    428     def as_tuple(self):   429         return self.start, self.end, self.origin, self.start_attr, self.end_attr   430    431     def __repr__(self):   432         return "RecurringPeriod(%r, %r, %r, %r, %r)" % (self.start, self.end, self.origin, self.start_attr, self.end_attr)   433    434 def get_periods(obj, tzid, window_end, inclusive=False):   435    436     """   437     Return periods for the given object 'obj', confining materialised periods   438     to before the given 'window_end' datetime. If 'inclusive' is set to a true   439     value, any period occurring at the 'window_end' will be included.   440     """   441    442     rrule = obj.get_value("RRULE")   443    444     # Use localised datetimes.   445    446     dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")   447    448     if obj.has_key("DTEND"):   449         dtend, dtend_attr = obj.get_datetime_item("DTEND")   450         duration = dtend - dtstart   451     elif obj.has_key("DURATION"):   452         duration = obj.get_duration("DURATION")   453         dtend = dtstart + duration   454         dtend_attr = dtstart_attr   455     else:   456         dtend, dtend_attr = dtstart, dtstart_attr   457    458     tzid = get_tzid(dtstart_attr, dtend_attr) or tzid   459    460     if not rrule:   461         periods = [RecurringPeriod(dtstart, dtend, "DTSTART", dtstart_attr, dtend_attr)]   462     else:   463         # Recurrence rules create multiple instances to be checked.   464         # Conflicts may only be assessed within a period defined by policy   465         # for the agent, with instances outside that period being considered   466         # unchecked.   467    468         selector = get_rule(dtstart, rrule)   469         parameters = get_parameters(rrule)   470         periods = []   471    472         until = parameters.get("UNTIL")   473         if until:   474             window_end = min(to_timezone(get_datetime(until, dtstart_attr), tzid), window_end)   475             inclusive = True   476    477         for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive):   478             start = to_timezone(datetime(*start), tzid)   479             end = start + duration   480             periods.append(RecurringPeriod(start, end, "RRULE"))   481    482     # Add recurrence dates.   483    484     rdates = obj.get_date_value_items("RDATE", tzid)   485    486     if rdates:   487         for rdate, rdate_attr in rdates:   488             if isinstance(rdate, tuple):   489                 periods.append(RecurringPeriod(rdate[0], rdate[1], "RDATE", rdate_attr))   490             else:   491                 periods.append(RecurringPeriod(rdate, rdate + duration, "RDATE", rdate_attr))   492    493     # Return a sorted list of the periods.   494    495     periods.sort(cmp=compare_periods(tzid))   496    497     # Exclude exception dates.   498    499     exdates = obj.get_date_values("EXDATE", tzid)   500    501     if exdates:   502         for exdate in exdates:   503             if isinstance(exdate, tuple):   504                 period = Period(exdate[0], exdate[1])   505             else:   506                 period = Period(exdate, exdate + duration)   507             i = bisect_left(periods, period)   508             while i < len(periods) and periods[i] == period:   509                 del periods[i]   510    511     return periods   512    513 class compare_periods:   514    515     "Compare periods for exception date purposes."   516    517     def __init__(self, tzid):   518         self.tzid = tzid   519    520     def __call__(self, first, second):   521         return cmp(   522             (to_datetime(first.get_start(), self.tzid), to_datetime(first.get_end(), self.tzid)),   523             (to_datetime(second.get_start(), self.tzid), to_datetime(second.get_end(), self.tzid))   524             )   525    526 def get_periods_for_freebusy(obj, periods, tzid):   527    528     """   529     Get free/busy-compliant periods employed by 'obj' from the given 'periods',   530     using the indicated 'tzid' to convert dates to datetimes.   531     """   532    533     tzid = obj.get_tzid() or tzid   534    535     l = []   536    537     for p in periods:   538         start, end = get_freebusy_period(p.get_start(), p.get_end(), tzid)   539         start, end = [to_timezone(x, "UTC") for x in start, end]   540    541         # Create a new period for free/busy purposes with the converted   542         # datetime information.   543    544         l.append(p.__class__(start, end, *p.as_tuple()[2:]))   545    546     return l   547    548 # vim: tabstop=4 expandtab shiftwidth=4