imip-agent

imiptools/handlers/__init__.py

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