imip-agent

imiptools/data.py

360:2d0ab2a511b9
2015-03-01 Paul Boddie Changed period generation to use an explicit end point, supporting inclusive end points in order to be able to test for the presence of particular recurrence instances. Added initial support for detaching specific instances from recurring events. recurring-events
     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 datetime import datetime, timedelta    23 from email.mime.text import MIMEText    24 from imiptools.dates import format_datetime, get_datetime, get_freebusy_period, \    25                             to_timezone, to_utc_datetime    26 from imiptools.period import period_overlaps    27 from pytz import timezone    28 from vCalendar import iterwrite, parse, ParseError, to_dict, to_node    29 from vRecurrence import get_parameters, get_rule    30 import email.utils    31     32 try:    33     from cStringIO import StringIO    34 except ImportError:    35     from StringIO import StringIO    36     37 class Object:    38     39     "Access to calendar structures."    40     41     def __init__(self, fragment):    42         self.objtype, (self.details, self.attr) = fragment.items()[0]    43     44     def get_items(self, name, all=True):    45         return get_items(self.details, name, all)    46     47     def get_item(self, name):    48         return get_item(self.details, name)    49     50     def get_value_map(self, name):    51         return get_value_map(self.details, name)    52     53     def get_values(self, name, all=True):    54         return get_values(self.details, name, all)    55     56     def get_value(self, name):    57         return get_value(self.details, name)    58     59     def get_utc_datetime(self, name):    60         return get_utc_datetime(self.details, name)    61     62     def get_item_datetimes(self, name):    63         items = get_item_datetime_items(self.details, name)    64         return items and [dt for dt, attr in items]    65     66     def get_item_datetime_items(self, name):    67         return get_item_datetime_items(self.details, name)    68     69     def get_datetime(self, name):    70         dt, attr = get_datetime_item(self.details, name)    71         return dt    72     73     def get_datetime_item(self, name):    74         return get_datetime_item(self.details, name)    75     76     def to_node(self):    77         return to_node({self.objtype : [(self.details, self.attr)]})    78     79     def to_part(self, method):    80         return to_part(method, [self.to_node()])    81     82     # Direct access to the structure.    83     84     def __getitem__(self, name):    85         return self.details[name]    86     87     def __setitem__(self, name, value):    88         self.details[name] = value    89     90     def __delitem__(self, name):    91         del self.details[name]    92     93     # Computed results.    94     95     def has_recurrence(self, tzid, recurrence):    96         recurrences = [start for start, end in get_periods(self, tzid, recurrence, True)]    97         return recurrence in recurrences    98     99     def get_periods(self, tzid, end):   100         return get_periods(self, tzid, end)   101    102     def get_periods_for_freebusy(self, tzid, end):   103         periods = self.get_periods(tzid, end)   104         return get_periods_for_freebusy(self, periods, tzid)   105    106 # Construction and serialisation.   107    108 def make_calendar(nodes, method=None):   109    110     """   111     Return a complete calendar node wrapping the given 'nodes' and employing the   112     given 'method', if indicated.   113     """   114    115     return ("VCALENDAR", {},   116             (method and [("METHOD", {}, method)] or []) +   117             [("VERSION", {}, "2.0")] +   118             nodes   119            )   120    121 def make_freebusy(freebusy, uid, organiser, organiser_attr=None, attendee=None,   122     attendee_attr=None, dtstart=None, dtend=None):   123        124     """   125     Return a calendar node defining the free/busy details described in the given   126     'freebusy' list, employing the given 'uid', for the given 'organiser' and   127     optional 'organiser_attr', with the optional 'attendee' providing recipient   128     details together with the optional 'attendee_attr'.   129    130     The result will be constrained to the 'dtstart' and 'dtend' period if these   131     parameters are given.   132     """   133        134     record = []   135     rwrite = record.append   136        137     rwrite(("ORGANIZER", organiser_attr or {}, organiser))   138    139     if attendee:   140         rwrite(("ATTENDEE", attendee_attr or {}, attendee))    141    142     rwrite(("UID", {}, uid))   143    144     if freebusy:   145    146         # Get a constrained view if start and end limits are specified.   147    148         periods = dtstart and dtend and period_overlaps(freebusy, (dtstart, dtend), True) or freebusy   149    150         # Write the limits of the resource.   151    152         rwrite(("DTSTART", {"VALUE" : "DATE-TIME"}, periods[0][0]))   153         rwrite(("DTEND", {"VALUE" : "DATE-TIME"}, periods[-1][1]))   154    155         for start, end, uid, transp, recurrenceid in periods:   156             if transp == "OPAQUE":   157                 rwrite(("FREEBUSY", {"FBTYPE" : "BUSY"}, "/".join([start, end])))   158    159     return ("VFREEBUSY", {}, record)   160    161 def parse_object(f, encoding, objtype=None):   162    163     """   164     Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is   165     given, only objects of that type will be returned. Otherwise, the root of   166     the content will be returned as a dictionary with a single key indicating   167     the object type.   168    169     Return None if the content was not readable or suitable.   170     """   171    172     try:   173         try:   174             doctype, attrs, elements = obj = parse(f, encoding=encoding)   175             if objtype and doctype == objtype:   176                 return to_dict(obj)[objtype][0]   177             elif not objtype:   178                 return to_dict(obj)   179         finally:   180             f.close()   181    182     # NOTE: Handle parse errors properly.   183    184     except (ParseError, ValueError):   185         pass   186    187     return None   188    189 def to_part(method, calendar):   190    191     """   192     Write using the given 'method', the 'calendar' details to a MIME   193     text/calendar part.   194     """   195    196     encoding = "utf-8"   197     out = StringIO()   198     try:   199         to_stream(out, make_calendar(calendar, method), encoding)   200         part = MIMEText(out.getvalue(), "calendar", encoding)   201         part.set_param("method", method)   202         return part   203    204     finally:   205         out.close()   206    207 def to_stream(out, fragment, encoding="utf-8"):   208     iterwrite(out, encoding=encoding).append(fragment)   209    210 # Structure access functions.   211    212 def get_items(d, name, all=True):   213    214     """   215     Get all items from 'd' for the given 'name', returning single items if   216     'all' is specified and set to a false value and if only one value is   217     present for the name. Return None if no items are found for the name or if   218     many items are found but 'all' is set to a false value.   219     """   220    221     if d.has_key(name):   222         values = d[name]   223         if all:   224             return values   225         elif len(values) == 1:   226             return values[0]   227         else:   228             return None   229     else:   230         return None   231    232 def get_item(d, name):   233     return get_items(d, name, False)   234    235 def get_value_map(d, name):   236    237     """   238     Return a dictionary for all items in 'd' having the given 'name'. The   239     dictionary will map values for the name to any attributes or qualifiers   240     that may have been present.   241     """   242    243     items = get_items(d, name)   244     if items:   245         return dict(items)   246     else:   247         return {}   248    249 def get_values(d, name, all=True):   250     if d.has_key(name):   251         values = d[name]   252         if not all and len(values) == 1:   253             return values[0][0]   254         else:   255             return map(lambda x: x[0], values)   256     else:   257         return None   258    259 def get_value(d, name):   260     return get_values(d, name, False)   261    262 def get_item_datetime_items(d, name):   263    264     """   265     Return datetime items from 'd' having the given 'name', where a single item   266     yields potentially many datetime values, each employing the attributes given   267     for the principal item.   268     """   269    270     item = get_item(d, name)   271     if item:   272         values, attr = item   273         if not isinstance(values, list):   274             values = [values]   275         return [(get_datetime(value, attr), attr) for value in values]   276     else:   277         return None   278    279 def get_utc_datetime(d, name):   280     t = get_datetime_item(d, name)   281     if not t:   282         return None   283     else:   284         dt, attr = t   285         return to_utc_datetime(dt)   286    287 def get_datetime_item(d, name):   288     t = get_item(d, name)   289     if not t:   290         return None   291     else:   292         value, attr = t   293         return get_datetime(value, attr), attr   294    295 def get_addresses(values):   296     return [address for name, address in email.utils.getaddresses(values)]   297    298 def get_address(value):   299     value = value.lower()   300     return value.startswith("mailto:") and value[7:] or value   301    302 def get_uri(value):   303     return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower()   304    305 uri_value = get_uri   306    307 def uri_values(values):   308     return map(get_uri, values)   309    310 def uri_dict(d):   311     return dict([(get_uri(key), value) for key, value in d.items()])   312    313 def uri_item(item):   314     return get_uri(item[0]), item[1]   315    316 def uri_items(items):   317     return [(get_uri(value), attr) for value, attr in items]   318    319 # Operations on structure data.   320    321 def is_new_object(old_sequence, new_sequence, old_dtstamp, new_dtstamp, partstat_set):   322    323     """   324     Return for the given 'old_sequence' and 'new_sequence', 'old_dtstamp' and   325     'new_dtstamp', and the 'partstat_set' indication, whether the object   326     providing the new information is really newer than the object providing the   327     old information.   328     """   329    330     have_sequence = old_sequence is not None and new_sequence is not None   331     is_same_sequence = have_sequence and int(new_sequence) == int(old_sequence)   332    333     have_dtstamp = old_dtstamp and new_dtstamp   334     is_old_dtstamp = have_dtstamp and new_dtstamp < old_dtstamp or old_dtstamp and not new_dtstamp   335    336     is_old_sequence = have_sequence and (   337         int(new_sequence) < int(old_sequence) or   338         is_same_sequence and is_old_dtstamp   339         )   340    341     return is_same_sequence and partstat_set or not is_old_sequence   342    343 # NOTE: Need to expose the 100 day window for recurring events in the   344 # NOTE: configuration.   345    346 def get_window_end(tzid, window_size=100):   347     return to_timezone(datetime.now(), tzid) + timedelta(window_size)   348    349 def get_periods(obj, tzid, window_end, inclusive=False):   350    351     """   352     Return periods for the given object 'obj', confining materialised periods   353     to before the given 'window_end' datetime. If 'inclusive' is set to a true   354     value, any period occurring at the 'window_end' will be included.   355     """   356    357     rrule = obj.get_value("RRULE")   358    359     # Use localised datetimes.   360    361     dtstart, start_attr = obj.get_datetime_item("DTSTART")   362     dtend, end_attr = obj.get_datetime_item("DTEND")   363     tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid   364    365     # NOTE: Need also DURATION support.   366    367     duration = dtend - dtstart   368    369     if not rrule:   370         periods = [(dtstart, dtend)]   371     else:   372         # Recurrence rules create multiple instances to be checked.   373         # Conflicts may only be assessed within a period defined by policy   374         # for the agent, with instances outside that period being considered   375         # unchecked.   376    377         selector = get_rule(dtstart, rrule)   378         parameters = get_parameters(rrule)   379         periods = []   380    381         for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS"), inclusive):   382             start = to_timezone(datetime(*start), tzid)   383             end = start + duration   384             periods.append((start, end))   385    386     # Add recurrence dates.   387    388     periods = set(periods)   389     rdates = obj.get_item_datetimes("RDATE")   390    391     if rdates:   392         for rdate in rdates:   393             periods.add((rdate, rdate + duration))   394    395     # Exclude exception dates.   396    397     exdates = obj.get_item_datetimes("EXDATE")   398    399     if exdates:   400         for exdate in exdates:   401             period = (exdate, exdate + duration)   402             if period in periods:   403                 periods.remove(period)   404    405     # Return a sorted list of the periods.   406    407     periods = list(periods)   408     periods.sort()   409     return periods   410    411 def get_periods_for_freebusy(obj, periods, tzid):   412    413     """   414     Get free/busy-compliant periods employed by 'obj' from the given 'periods',   415     using the indicated 'tzid' to convert dates to datetimes.   416     """   417    418     start, start_attr = obj.get_datetime_item("DTSTART")   419     end, end_attr = obj.get_datetime_item("DTEND")   420    421     tzid = start_attr.get("TZID") or end_attr.get("TZID") or tzid   422    423     l = []   424    425     for start, end in periods:   426         start, end = get_freebusy_period(start, end, tzid)   427         start, end = [to_timezone(x, "UTC") for x in start, end]   428         l.append((format_datetime(start), format_datetime(end)))   429    430     return l   431    432 # vim: tabstop=4 expandtab shiftwidth=4