imip-agent

imiptools/content.py

158:bb9494547de1
2015-01-22 Paul Boddie Introduced a common DTSTAMP update method.
     1 #!/usr/bin/env python     2      3 """     4 Interpretation and preparation of iMIP content, together with a content handling     5 mechanism employed by specific recipients.     6      7 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>     8      9 This program is free software; you can redistribute it and/or modify it under    10 the terms of the GNU General Public License as published by the Free Software    11 Foundation; either version 3 of the License, or (at your option) any later    12 version.    13     14 This program is distributed in the hope that it will be useful, but WITHOUT    15 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    16 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    17 details.    18     19 You should have received a copy of the GNU General Public License along with    20 this program.  If not, see <http://www.gnu.org/licenses/>.    21 """    22     23 from datetime import datetime, timedelta    24 from email.mime.text import MIMEText    25 from imiptools.dates import *    26 from imiptools.period import have_conflict, insert_period, remove_period    27 from pytz import timezone    28 from vCalendar import parse, ParseError, to_dict    29 from vRecurrence import get_parameters, get_rule    30 import email.utils    31 import imip_store    32     33 try:    34     from cStringIO import StringIO    35 except ImportError:    36     from StringIO import StringIO    37     38 # Content interpretation.    39     40 def get_items(d, name, all=True):    41     42     """    43     Get all items from 'd' with the given 'name', returning single items if    44     'all' is specified and set to a false value and if only one value is    45     present for the name. Return None if no items are found for the name.    46     """    47     48     if d.has_key(name):    49         values = d[name]    50         if not all and len(values) == 1:    51             return values[0]    52         else:    53             return values    54     else:    55         return None    56     57 def get_item(d, name):    58     return get_items(d, name, False)    59     60 def get_value_map(d, name):    61     62     """    63     Return a dictionary for all items in 'd' having the given 'name'. The    64     dictionary will map values for the name to any attributes or qualifiers    65     that may have been present.    66     """    67     68     items = get_items(d, name)    69     if items:    70         return dict(items)    71     else:    72         return {}    73     74 def get_values(d, name, all=True):    75     if d.has_key(name):    76         values = d[name]    77         if not all and len(values) == 1:    78             return values[0][0]    79         else:    80             return map(lambda x: x[0], values)    81     else:    82         return None    83     84 def get_value(d, name):    85     return get_values(d, name, False)    86     87 def get_utc_datetime(d, name):    88     value, attr = get_item(d, name)    89     dt = get_datetime(value, attr)    90     return to_utc_datetime(dt)    91     92 def get_addresses(values):    93     return [address for name, address in email.utils.getaddresses(values)]    94     95 def get_address(value):    96     return value.lower().startswith("mailto:") and value.lower()[7:] or value    97     98 def get_uri(value):    99     return value.lower().startswith("mailto:") and value.lower() or ":" in value and value or "mailto:%s" % value.lower()   100    101 def uri_dict(d):   102     return dict([(get_uri(key), value) for key, value in d.items()])   103    104 def uri_item(item):   105     return get_uri(item[0]), item[1]   106    107 def uri_items(items):   108     return [(get_uri(value), attr) for value, attr in items]   109    110 # NOTE: Need to expose the 100 day window for recurring events in the   111 # NOTE: configuration.   112    113 def get_periods(obj, window_size=100):   114    115     """   116     Return periods for the given object 'obj', confining materialised periods   117     to the given 'window_size' in days starting from the present moment.   118     """   119    120     dtstart = get_utc_datetime(obj, "DTSTART")   121     dtend = get_utc_datetime(obj, "DTEND")   122    123     # NOTE: Need also DURATION support.   124    125     duration = dtend - dtstart   126    127     # Recurrence rules create multiple instances to be checked.   128     # Conflicts may only be assessed within a period defined by policy   129     # for the agent, with instances outside that period being considered   130     # unchecked.   131    132     window_end = datetime.now() + timedelta(window_size)   133    134     # NOTE: Need also RDATE and EXDATE support.   135    136     rrule = get_value(obj, "RRULE")   137    138     if rrule:   139         selector = get_rule(dtstart, rrule)   140         parameters = get_parameters(rrule)   141         periods = []   142         for start in selector.materialise(dtstart, window_end, parameters.get("COUNT"), parameters.get("BYSETPOS")):   143             start = datetime(*start, tzinfo=timezone("UTC"))   144             end = start + duration   145             periods.append((format_datetime(start), format_datetime(end)))   146     else:   147         periods = [(format_datetime(dtstart), format_datetime(dtend))]   148    149     return periods   150    151 def remove_from_freebusy(freebusy, attendee, uid, store):   152    153     """   154     For the given 'attendee', remove periods from 'freebusy' that are associated   155     with 'uid' in the 'store'.   156     """   157    158     remove_period(freebusy, uid)   159     store.set_freebusy(attendee, freebusy)   160    161 def update_freebusy(freebusy, attendee, periods, transp, uid, store):   162    163     """   164     For the given 'attendee', update the free/busy details with the given   165     'periods', 'transp' setting and 'uid' in the 'store'.   166     """   167    168     remove_period(freebusy, uid)   169    170     for start, end in periods:   171         insert_period(freebusy, (start, end, uid, transp))   172    173     store.set_freebusy(attendee, freebusy)   174    175 def can_schedule(freebusy, periods, uid):   176    177     """   178     Return whether the 'freebusy' list can accommodate the given 'periods'   179     employing the specified 'uid'.   180     """   181    182     for conflict in have_conflict(freebusy, periods, True):   183         start, end, found_uid, found_transp = conflict   184         if found_uid != uid:   185             return False   186    187     return True   188    189 # Handler mechanism objects.   190    191 def handle_itip_part(part, senders, recipients, handlers, messenger):   192    193     """   194     Handle the given iTIP 'part' from the given 'senders' for the given   195     'recipients' using the given 'handlers' and information provided by the   196     given 'messenger'. Return a list of responses, each response being a tuple   197     of the form (is-outgoing, message-part).   198     """   199    200     method = part.get_param("method")   201    202     # Decode the data and parse it.   203    204     f = StringIO(part.get_payload(decode=True))   205    206     itip = parse_object(f, part.get_content_charset(), "VCALENDAR")   207    208     # Ignore the part if not a calendar object.   209    210     if not itip:   211         return []   212    213     # Require consistency between declared and employed methods.   214    215     if get_value(itip, "METHOD") == method:   216    217         # Look for different kinds of sections.   218    219         all_results = []   220    221         for name, cls in handlers:   222             for details in get_values(itip, name) or []:   223    224                 # Dispatch to a handler and obtain any response.   225    226                 handler = cls(details, senders, recipients, messenger)   227                 result = methods[method](handler)()   228    229                 # Aggregate responses for a single message.   230    231                 if result:   232                     response_method, part = result   233                     outgoing = method != response_method   234                     all_results.append((outgoing, part))   235    236         return all_results   237    238     return []   239    240 def parse_object(f, encoding, objtype=None):   241    242     """   243     Parse the iTIP content from 'f' having the given 'encoding'. If 'objtype' is   244     given, only objects of that type will be returned. Otherwise, the root of   245     the content will be returned as a dictionary with a single key indicating   246     the object type.   247    248     Return None if the content was not readable or suitable.   249     """   250    251     try:   252         try:   253             doctype, attrs, elements = obj = parse(f, encoding=encoding)   254             if objtype and doctype == objtype:   255                 return to_dict(obj)[objtype][0]   256             elif not objtype:   257                 return to_dict(obj)   258         finally:   259             f.close()   260    261     # NOTE: Handle parse errors properly.   262    263     except (ParseError, ValueError):   264         pass   265    266     return None   267    268 def to_part(method, calendar):   269    270     """   271     Write using the given 'method', the 'calendar' details to a MIME   272     text/calendar part.   273     """   274    275     encoding = "utf-8"   276     out = StringIO()   277     try:   278         imip_store.to_stream(out, imip_store.make_calendar(calendar, method), encoding)   279         part = MIMEText(out.getvalue(), "calendar", encoding)   280         part.set_param("method", method)   281         return part   282    283     finally:   284         out.close()   285    286 class Handler:   287    288     "General handler support."   289    290     def __init__(self, details, senders=None, recipients=None, messenger=None):   291    292         """   293         Initialise the handler with the 'details' of a calendar object and the   294         'senders' and 'recipients' of the object (if specifically indicated).   295         """   296    297         self.details = details   298         self.senders = senders and set(map(get_address, senders))   299         self.recipients = recipients and set(map(get_address, recipients))   300         self.messenger = messenger   301    302         self.uid = get_value(details, "UID")   303         self.sequence = get_value(details, "SEQUENCE")   304         self.dtstamp = get_value(details, "DTSTAMP")   305    306         self.store = imip_store.FileStore()   307    308         try:   309             self.publisher = imip_store.FilePublisher()   310         except OSError:   311             self.publisher = None   312    313     # Access to calendar structures and other data.   314    315     def get_items(self, name, all=True):   316         return get_items(self.details, name, all)   317    318     def get_item(self, name):   319         return get_item(self.details, name)   320    321     def get_value_map(self, name):   322         return get_value_map(self.details, name)   323    324     def get_values(self, name, all=True):   325         return get_values(self.details, name, all)   326    327     def get_value(self, name):   328         return get_value(self.details, name)   329    330     def get_utc_datetime(self, name):   331         return get_utc_datetime(self.details, name)   332    333     def get_periods(self):   334         return get_periods(self.details)   335    336     def remove_from_freebusy(self, freebusy, attendee):   337         remove_from_freebusy(freebusy, attendee, self.uid, self.store)   338    339     def update_freebusy(self, freebusy, attendee, periods):   340         return update_freebusy(freebusy, attendee, periods, self.get_value("TRANSP"), self.uid, self.store)   341    342     def can_schedule(self, freebusy, periods):   343         return can_schedule(freebusy, periods, self.uid)   344    345     def filter_by_senders(self, values):   346         addresses = map(get_address, values)   347         if self.senders:   348             return self.senders.intersection(addresses)   349         else:   350             return addresses   351    352     def filter_by_recipients(self, values):   353         addresses = map(get_address, values)   354         if self.recipients:   355             return self.recipients.intersection(addresses)   356         else:   357             return addresses   358    359     def require_organiser_and_attendees(self, from_organiser=True):   360    361         """   362         Return the organiser and attendees for the current object, filtered by   363         the recipients of interest. Return None if no identities are eligible.   364    365         Organiser and attendee identities are provided as lower case values.   366         """   367    368         attendee_map = uri_dict(self.get_value_map("ATTENDEE"))   369         organiser = uri_item(self.get_item("ORGANIZER"))   370    371         # Only provide details for recipients who are also attendees.   372    373         filter_fn = from_organiser and self.filter_by_recipients or self.filter_by_senders   374    375         attendees = {}   376         for attendee in map(get_uri, filter_fn(attendee_map)):   377             attendees[attendee] = attendee_map[attendee]   378    379         if not attendees or not organiser:   380             return None   381    382         return organiser, attendees   383    384     def validate_identities(self, items):   385    386         """   387         Validate the 'items' against the known senders, obtaining sent-by   388         addresses from attributes provided by the items.   389         """   390    391         # Reject organisers that do not match any senders.   392    393         identities = []   394    395         for value, attr in items:   396             identities.append(value)   397             sent_by = attr.get("SENT-BY")   398             if sent_by:   399                 identities.append(get_uri(sent_by))   400    401         return self.filter_by_senders(identities)   402    403     def get_object(self, user, objtype):   404    405         """   406         Return the stored object to which the current object refers for the   407         given 'user' and for the given 'objtype'.   408         """   409    410         f = self.store.get_event(user, self.uid)   411         obj = f and parse_object(f, "utf-8", objtype)   412         return obj   413    414     def have_new_object(self, attendee, objtype, obj=None):   415    416         """   417         Return whether the current object is new to the 'attendee' for the   418         given 'objtype'.   419         """   420    421         obj = obj or self.get_object(attendee, objtype)   422    423         # If found, compare SEQUENCE and potentially DTSTAMP.   424    425         if obj:   426             sequence = get_value(obj, "SEQUENCE")   427             dtstamp = get_value(obj, "DTSTAMP")   428    429             # If the request refers to an older version of the object, ignore   430             # it.   431    432             old_dtstamp = self.dtstamp < dtstamp   433    434             have_sequence = sequence is not None and self.sequence is not None   435    436             if have_sequence and (   437                 int(self.sequence) < int(sequence) or   438                 int(self.sequence) == int(sequence) and old_dtstamp   439                 ) or not have_sequence and old_dtstamp:   440    441                 return False   442    443         return True   444    445     def update_dtstamp(self):   446    447         "Update the DTSTAMP in the current object."   448    449         dtstamp = self.get_utc_datetime("DTSTAMP")   450         utcnow = to_timezone(datetime.utcnow(), "UTC")   451         self.details["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})]   452    453 # Handler registry.   454    455 methods = {   456     "ADD"            : lambda handler: handler.add,   457     "CANCEL"         : lambda handler: handler.cancel,   458     "COUNTER"        : lambda handler: handler.counter,   459     "DECLINECOUNTER" : lambda handler: handler.declinecounter,   460     "PUBLISH"        : lambda handler: handler.publish,   461     "REFRESH"        : lambda handler: handler.refresh,   462     "REPLY"          : lambda handler: handler.reply,   463     "REQUEST"        : lambda handler: handler.request,   464     }   465    466 # vim: tabstop=4 expandtab shiftwidth=4