imip-agent

imiptools/content.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 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.config import MANAGER_PATH, MANAGER_URL    26 from imiptools.data import Object, parse_object, \    27                            get_address, get_uri, get_value, get_window_end, \    28                            is_new_object, uri_dict, uri_item    29 from imiptools.dates import format_datetime, to_timezone    30 from imiptools.period import can_schedule, insert_period, remove_period, \    31                              remove_from_freebusy, \    32                              remove_from_freebusy_for_other, \    33                              update_freebusy, update_freebusy_for_other    34 from socket import gethostname    35 import imip_store    36     37 try:    38     from cStringIO import StringIO    39 except ImportError:    40     from StringIO import StringIO    41     42 # Handler mechanism objects.    43     44 def handle_itip_part(part, handlers):    45     46     """    47     Handle the given iTIP 'part' using the given 'handlers' dictionary.    48     49     Return a list of responses, each response being a tuple of the form    50     (outgoing-recipients, message-part).    51     """    52     53     method = part.get_param("method")    54     55     # Decode the data and parse it.    56     57     f = StringIO(part.get_payload(decode=True))    58     59     itip = parse_object(f, part.get_content_charset(), "VCALENDAR")    60     61     # Ignore the part if not a calendar object.    62     63     if not itip:    64         return    65     66     # Require consistency between declared and employed methods.    67     68     if get_value(itip, "METHOD") == method:    69     70         # Look for different kinds of sections.    71     72         all_results = []    73     74         for name, items in itip.items():    75     76             # Get a handler for the given section.    77     78             handler = handlers.get(name)    79             if not handler:    80                 continue    81     82             for item in items:    83     84                 # Dispatch to a handler and obtain any response.    85     86                 handler.set_object(Object({name : item}))    87                 methods[method](handler)()    88     89 # References to the Web interface.    90     91 def get_manager_url():    92     url_base = MANAGER_URL or "http://%s/" % gethostname()    93     return "%s/%s" % (url_base.rstrip("/"), MANAGER_PATH.lstrip("/"))    94     95 def get_object_url(uid):    96     return "%s/%s" % (get_manager_url().rstrip("/"), uid)    97     98 class Handler:    99    100     "General handler support."   101    102     def __init__(self, senders=None, recipient=None, messenger=None):   103    104         """   105         Initialise the handler with the calendar 'obj' and the 'senders' and   106         'recipient' of the object (if specifically indicated).   107         """   108    109         self.senders = senders and set(map(get_address, senders))   110         self.recipient = recipient and get_address(recipient)   111         self.messenger = messenger   112    113         self.results = []   114         self.outgoing_methods = set()   115    116         self.obj = None   117         self.uid = None   118         self.recurrenceid = None   119         self.sequence = None   120         self.dtstamp = None   121    122         self.store = imip_store.FileStore()   123    124         try:   125             self.publisher = imip_store.FilePublisher()   126         except OSError:   127             self.publisher = None   128    129     def set_object(self, obj):   130         self.obj = obj   131         self.uid = self.obj.get_value("UID")   132         self.recurrenceid = format_datetime(self.obj.get_utc_datetime("RECURRENCE-ID"))   133         self.sequence = self.obj.get_value("SEQUENCE")   134         self.dtstamp = self.obj.get_value("DTSTAMP")   135    136     def wrap(self, text, link=True):   137    138         "Wrap any valid message for passing to the recipient."   139    140         texts = []   141         texts.append(text)   142         if link:   143             texts.append("If your mail program cannot handle this "   144                          "message, you may view the details here:\n\n%s" %   145                          get_object_url(self.uid))   146    147         return self.add_result(None, None, MIMEText("\n".join(texts)))   148    149     # Result registration.   150    151     def add_result(self, method, outgoing_recipients, part):   152    153         """   154         Record a result having the given 'method', 'outgoing_recipients' and   155         message part.   156         """   157    158         if outgoing_recipients:   159             self.outgoing_methods.add(method)   160         self.results.append((outgoing_recipients, part))   161    162     def get_results(self):   163         return self.results   164    165     def get_outgoing_methods(self):   166         return self.outgoing_methods   167    168     # Access to calendar structures and other data.   169    170     def remove_from_freebusy(self, freebusy, attendee):   171         remove_from_freebusy(freebusy, attendee, self.uid, self.recurrenceid, self.store)   172    173     def remove_from_freebusy_for_other(self, freebusy, user, other):   174         remove_from_freebusy_for_other(freebusy, user, other, self.uid, self.recurrenceid, self.store)   175    176     def _update_freebusy(self, freebusy, attendee, periods, recurrenceid):   177         update_freebusy(freebusy, attendee, periods, self.obj.get_value("TRANSP"),   178             self.uid, recurrenceid, self.store)   179    180     def update_freebusy(self, freebusy, attendee, periods):   181         self._update_freebusy(freebusy, attendee, periods, self.recurrenceid)   182    183     def update_freebusy_from_participant(self, user, participant_item):   184    185         """   186         For the given 'user', record the free/busy information for the   187         'participant_item' (a value plus attributes), using the 'tzid' to define   188         period information.   189         """   190    191         participant, participant_attr = participant_item   192    193         if participant != user:   194             freebusy = self.store.get_freebusy_for_other(user, participant)   195    196             window_end = get_window_end(tzid=None)   197    198             if participant_attr.get("PARTSTAT") != "DECLINED":   199                 update_freebusy_for_other(freebusy, user, participant,   200                     self.obj.get_periods_for_freebusy(tzid=None, end=window_end),   201                     self.obj.get_value("TRANSP"),   202                     self.uid, self.recurrenceid, self.store)   203             else:   204                 self.remove_from_freebusy_for_other(freebusy, user, participant)   205    206     def update_freebusy_from_organiser(self, attendee, organiser_item):   207    208         """   209         For the 'attendee', record free/busy information from the   210         'organiser_item' (a value plus attributes).   211         """   212    213         self.update_freebusy_from_participant(attendee, organiser_item)   214    215     def update_freebusy_from_attendees(self, organiser, attendees):   216    217         "For the 'organiser', record free/busy information from 'attendees'."   218    219         for attendee_item in attendees.items():   220             self.update_freebusy_from_participant(organiser, attendee_item)   221    222     def can_schedule(self, freebusy, periods):   223         return can_schedule(freebusy, periods, self.uid, self.recurrenceid)   224    225     def filter_by_senders(self, mapping):   226    227         """   228         Return a list of items from 'mapping' filtered using sender information.   229         """   230    231         if self.senders:   232    233             # Get a mapping from senders to identities.   234    235             identities = self.get_sender_identities(mapping)   236    237             # Find the senders that are valid.   238    239             senders = map(get_address, identities)   240             valid = self.senders.intersection(senders)   241    242             # Return the true identities.   243    244             return [identities[get_uri(address)] for address in valid]   245         else:   246             return mapping   247    248     def filter_by_recipient(self, mapping):   249    250         """   251         Return a list of items from 'mapping' filtered using recipient   252         information.   253         """   254    255         if self.recipient:   256             addresses = set(map(get_address, mapping))   257             return map(get_uri, addresses.intersection([self.recipient]))   258         else:   259             return mapping   260    261     def require_organiser(self, from_organiser=True):   262    263         """   264         Return the organiser for the current object, filtered for the sender or   265         recipient of interest. Return None if no identities are eligible.   266    267         The organiser identity is normalized.   268         """   269    270         organiser_item = uri_item(self.obj.get_item("ORGANIZER"))   271    272         # Only provide details for an organiser who sent/receives the message.   273    274         organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient   275    276         if not organiser_filter_fn(dict([organiser_item])):   277             return None   278    279         return organiser_item   280    281     def require_attendees(self, from_organiser=True):   282    283         """   284         Return the attendees for the current object, filtered for the sender or   285         recipient of interest. Return None if no identities are eligible.   286    287         The attendee identities are normalized.   288         """   289    290         attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE"))   291    292         # Only provide details for attendees who sent/receive the message.   293    294         attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders   295    296         attendees = {}   297         for attendee in attendee_filter_fn(attendee_map):   298             attendees[attendee] = attendee_map[attendee]   299    300         return attendees   301    302     def require_organiser_and_attendees(self, from_organiser=True):   303    304         """   305         Return the organiser and attendees for the current object, filtered for   306         the recipient of interest. Return None if no identities are eligible.   307    308         Organiser and attendee identities are normalized.   309         """   310    311         organiser_item = self.require_organiser(from_organiser)   312         attendees = self.require_attendees(from_organiser)   313    314         if not attendees or not organiser_item:   315             return None   316    317         return organiser_item, attendees   318    319     def get_sender_identities(self, mapping):   320    321         """   322         Return a mapping from actual senders to the identities for which they   323         have provided data, extracting this information from the given   324         'mapping'.   325         """   326    327         senders = {}   328    329         for value, attr in mapping.items():   330             sent_by = attr.get("SENT-BY")   331             if sent_by:   332                 senders[get_uri(sent_by)] = value   333             else:   334                 senders[value] = value   335    336         return senders   337    338     def _get_object(self, user, uid, recurrenceid):   339    340         """   341         Return the stored object for the given 'user', 'uid' and 'recurrenceid'.   342         """   343    344         fragment = self.store.get_event(user, uid, recurrenceid)   345         return fragment and Object(fragment)   346    347     def get_object(self, user):   348    349         """   350         Return the stored object to which the current object refers for the   351         given 'user'.   352         """   353    354         return self._get_object(user, self.uid, self.recurrenceid)   355    356     def get_parent_object(self, user):   357    358         """   359         Return the parent object to which the current object refers for the   360         given 'user'.   361         """   362    363         return self._get_object(user, self.uid, None)   364    365     def have_new_object(self, attendee, obj=None):   366    367         """   368         Return whether the current object is new to the 'attendee' (or if the   369         given 'obj' is new).   370         """   371    372         obj = obj or self.get_object(attendee)   373    374         # If found, compare SEQUENCE and potentially DTSTAMP.   375    376         if obj:   377             sequence = obj.get_value("SEQUENCE")   378             dtstamp = obj.get_value("DTSTAMP")   379    380             # If the request refers to an older version of the object, ignore   381             # it.   382    383             return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp,   384                 self.is_partstat_updated(obj))   385    386         return True   387    388     def is_partstat_updated(self, obj):   389    390         """   391         Return whether the participant status has been updated in the current   392         object in comparison to the given 'obj'.   393    394         NOTE: Some clients like Claws Mail erase time information from DTSTAMP   395         NOTE: and make it invalid. Thus, such attendance information may also be   396         NOTE: incorporated into any new object assessment.   397         """   398    399         old_attendees = uri_dict(obj.get_value_map("ATTENDEE"))   400         new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE"))   401    402         for attendee, attr in old_attendees.items():   403             old_partstat = attr.get("PARTSTAT")   404             new_attr = new_attendees.get(attendee)   405             new_partstat = new_attr and new_attr.get("PARTSTAT")   406    407             if old_partstat == "NEEDS-ACTION" and new_partstat and \   408                new_partstat != old_partstat:   409    410                 return True   411    412         return False   413    414     def merge_attendance(self, attendees, identity):   415    416         """   417         Merge attendance from the current object's 'attendees' into the version   418         stored for the given 'identity'.   419         """   420    421         obj = self.get_object(identity)   422    423         if not obj or not self.have_new_object(identity, obj=obj):   424             return False   425    426         # Get attendee details in a usable form.   427    428         attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))   429    430         for attendee, attendee_attr in attendees.items():   431    432             # Update attendance in the loaded object.   433    434             attendee_map[attendee] = attendee_attr   435    436         # Set the new details and store the object.   437    438         obj["ATTENDEE"] = attendee_map.items()   439    440         # Set the complete event if not an additional occurrence.   441    442         event = obj.to_node()   443         recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))   444    445         self.store.set_event(identity, self.uid, self.recurrenceid, event)   446    447         return True   448    449     def update_dtstamp(self):   450    451         "Update the DTSTAMP in the current object."   452    453         dtstamp = self.obj.get_utc_datetime("DTSTAMP")   454         utcnow = to_timezone(datetime.utcnow(), "UTC")   455         self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})]   456    457     def set_sequence(self, increment=False):   458    459         "Update the SEQUENCE in the current object."   460    461         sequence = self.obj.get_value("SEQUENCE") or "0"   462         self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]   463    464 # Handler registry.   465    466 methods = {   467     "ADD"            : lambda handler: handler.add,   468     "CANCEL"         : lambda handler: handler.cancel,   469     "COUNTER"        : lambda handler: handler.counter,   470     "DECLINECOUNTER" : lambda handler: handler.declinecounter,   471     "PUBLISH"        : lambda handler: handler.publish,   472     "REFRESH"        : lambda handler: handler.refresh,   473     "REPLY"          : lambda handler: handler.reply,   474     "REQUEST"        : lambda handler: handler.request,   475     }   476    477 # vim: tabstop=4 expandtab shiftwidth=4