imip-agent

imiptools/handlers/__init__.py

491:c2eae45fba5d
2015-04-05 Paul Boddie Fixed attendance recording in the free/busy details for outgoing messages, properly testing for organiser-only events.
     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_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 remove_from_freebusy(self, freebusy):   124    125         "Remove this event from the given 'freebusy' collection."   126    127         remove_period(freebusy, self.uid, self.recurrenceid)   128    129     def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):   130    131         """   132         Remove from 'freebusy' any original recurrence from parent free/busy   133         details for the current object, if the current object is a specific   134         additional recurrence. Otherwise, remove all additional recurrence   135         information corresponding to 'recurrenceids', or if omitted, all   136         recurrences.   137         """   138    139         if self.recurrenceid:   140             remove_affected_period(freebusy, self.uid, self.recurrenceid)   141         else:   142             # Remove obsolete recurrence periods.   143    144             remove_additional_periods(freebusy, self.uid, recurrenceids)   145    146             # Remove original periods affected by additional recurrences.   147    148             if recurrenceids:   149                 for recurrenceid in recurrenceids:   150                     remove_affected_period(freebusy, self.uid, recurrenceid)   151    152     def _update_freebusy(self, freebusy, periods, recurrenceid, transp=None):   153    154         """   155         Update the 'freebusy' collection with the given 'periods', indicating an   156         explicit 'recurrenceid' to affect either a recurrence or the parent   157         event.   158         """   159    160         update_freebusy(freebusy, periods,   161             transp or self.obj.get_value("TRANSP"),   162             self.uid, recurrenceid,   163             self.obj.get_value("SUMMARY"),   164             self.obj.get_value("ORGANIZER"))   165    166     def update_freebusy(self, freebusy, periods, transp=None):   167    168         """   169         Update the 'freebusy' collection for this event with the given   170         'periods'.   171         """   172    173         self._update_freebusy(freebusy, periods, self.recurrenceid, transp)   174    175     def update_freebusy_for_participant(self, freebusy, periods, attr, for_organiser=False):   176    177         """   178         Update the 'freebusy' collection using the given 'periods', subject to   179         the 'attr' provided for the participant, indicating whether this is   180         being generated 'for_organiser' or not.   181         """   182    183         # Organisers employ a special transparency if not attending.   184    185         if for_organiser or attr.get("PARTSTAT") != "DECLINED":   186             self.update_freebusy(freebusy, periods, transp=(   187                 for_organiser and not attr.get("PARTSTAT") and "ORG" or None))   188         else:   189             self.remove_from_freebusy(freebusy)   190    191     # Convenience methods for updating stored free/busy information.   192    193     def update_freebusy_from_participant(self, participant_item, for_organiser):   194    195         """   196         For the calendar user, record the free/busy information for the   197         'participant_item' (a value plus attributes) representing a different   198         identity, thus maintaining a separate record of their free/busy details.   199         """   200    201         participant, participant_attr = participant_item   202    203         if participant == self.user:   204             return   205    206         freebusy = self.store.get_freebusy_for_other(self.user, participant)   207    208         # Obtain the stored object if the current object is not issued by the   209         # organiser.   210    211         obj = for_organiser and self.obj or self.get_object()   212         if not obj:   213             return   214    215         # Obtain the affected periods.   216    217         periods = obj.get_periods_for_freebusy(self.get_tzid(), self.get_window_end())   218    219         # Record in the free/busy details unless a non-participating attendee.   220    221         self.update_freebusy_for_participant(freebusy, periods, participant_attr,   222             for_organiser and not self.is_attendee(participant))   223    224         self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))   225         self.store.set_freebusy_for_other(self.user, freebusy, participant)   226    227     def update_freebusy_from_organiser(self, organiser_item):   228    229         """   230         For the current user, record free/busy information from the   231         'organiser_item' (a value plus attributes).   232         """   233    234         self.update_freebusy_from_participant(organiser_item, True)   235    236     def update_freebusy_from_attendees(self, attendees):   237    238         "For the current user, record free/busy information from 'attendees'."   239    240         for attendee_item in attendees.items():   241             self.update_freebusy_from_participant(attendee_item, False)   242    243     # Logic, filtering and access to calendar structures and other data.   244    245     def is_attendee(self, identity, obj=None):   246    247         """   248         Return whether 'identity' is an attendee in the current object, or in   249         'obj' if specified.   250         """   251    252         return identity in uri_values((obj or self.obj).get_values("ATTENDEE"))   253    254     def can_schedule(self, freebusy, periods):   255         return can_schedule(freebusy, periods, self.uid, self.recurrenceid)   256    257     def filter_by_senders(self, mapping):   258    259         """   260         Return a list of items from 'mapping' filtered using sender information.   261         """   262    263         if self.senders:   264    265             # Get a mapping from senders to identities.   266    267             identities = self.get_sender_identities(mapping)   268    269             # Find the senders that are valid.   270    271             senders = map(get_address, identities)   272             valid = self.senders.intersection(senders)   273    274             # Return the true identities.   275    276             return [identities[get_uri(address)] for address in valid]   277         else:   278             return mapping   279    280     def filter_by_recipient(self, mapping):   281    282         """   283         Return a list of items from 'mapping' filtered using recipient   284         information.   285         """   286    287         if self.recipient:   288             addresses = set(map(get_address, mapping))   289             return map(get_uri, addresses.intersection([self.recipient]))   290         else:   291             return mapping   292    293     def require_organiser(self, from_organiser=True):   294    295         """   296         Return the organiser for the current object, filtered for the sender or   297         recipient of interest. Return None if no identities are eligible.   298    299         The organiser identity is normalized.   300         """   301    302         organiser_item = uri_item(self.obj.get_item("ORGANIZER"))   303    304         # Only provide details for an organiser who sent/receives the message.   305    306         organiser_filter_fn = from_organiser and self.filter_by_senders or self.filter_by_recipient   307    308         if not organiser_filter_fn(dict([organiser_item])):   309             return None   310    311         return organiser_item   312    313     def require_attendees(self, from_organiser=True):   314    315         """   316         Return the attendees for the current object, filtered for the sender or   317         recipient of interest. Return None if no identities are eligible.   318    319         The attendee identities are normalized.   320         """   321    322         attendee_map = uri_dict(self.obj.get_value_map("ATTENDEE"))   323    324         # Only provide details for attendees who sent/receive the message.   325    326         attendee_filter_fn = from_organiser and self.filter_by_recipient or self.filter_by_senders   327    328         attendees = {}   329         for attendee in attendee_filter_fn(attendee_map):   330             attendees[attendee] = attendee_map[attendee]   331    332         return attendees   333    334     def require_organiser_and_attendees(self, from_organiser=True):   335    336         """   337         Return the organiser and attendees for the current object, filtered for   338         the recipient of interest. Return None if no identities are eligible.   339    340         Organiser and attendee identities are normalized.   341         """   342    343         organiser_item = self.require_organiser(from_organiser)   344         attendees = self.require_attendees(from_organiser)   345    346         if not attendees or not organiser_item:   347             return None   348    349         return organiser_item, attendees   350    351     def get_sender_identities(self, mapping):   352    353         """   354         Return a mapping from actual senders to the identities for which they   355         have provided data, extracting this information from the given   356         'mapping'.   357         """   358    359         senders = {}   360    361         for value, attr in mapping.items():   362             sent_by = attr.get("SENT-BY")   363             if sent_by:   364                 senders[get_uri(sent_by)] = value   365             else:   366                 senders[value] = value   367    368         return senders   369    370     def _get_object(self, uid, recurrenceid):   371    372         """   373         Return the stored object for the current user, with the given 'uid' and   374         'recurrenceid'.   375         """   376    377         fragment = self.store.get_event(self.user, uid, recurrenceid)   378         return fragment and Object(fragment)   379    380     def get_object(self):   381    382         """   383         Return the stored object to which the current object refers for the   384         current user.   385         """   386    387         return self._get_object(self.uid, self.recurrenceid)   388    389     def get_parent_object(self):   390    391         """   392         Return the parent object to which the current object refers for the   393         current user.   394         """   395    396         return self.recurrenceid and self._get_object(self.uid, None) or None   397    398     def have_new_object(self, obj=None):   399    400         """   401         Return whether the current object is new to the current user (or if the   402         given 'obj' is new).   403         """   404    405         obj = obj or self.get_object()   406    407         # If found, compare SEQUENCE and potentially DTSTAMP.   408    409         if obj:   410             sequence = obj.get_value("SEQUENCE")   411             dtstamp = obj.get_value("DTSTAMP")   412    413             # If the request refers to an older version of the object, ignore   414             # it.   415    416             return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp,   417                 self.is_partstat_updated(obj))   418    419         return True   420    421     def is_partstat_updated(self, obj):   422    423         """   424         Return whether the participant status has been updated in the current   425         object in comparison to the given 'obj'.   426    427         NOTE: Some clients like Claws Mail erase time information from DTSTAMP   428         NOTE: and make it invalid. Thus, such attendance information may also be   429         NOTE: incorporated into any new object assessment.   430         """   431    432         old_attendees = uri_dict(obj.get_value_map("ATTENDEE"))   433         new_attendees = uri_dict(self.obj.get_value_map("ATTENDEE"))   434    435         for attendee, attr in old_attendees.items():   436             old_partstat = attr.get("PARTSTAT")   437             new_attr = new_attendees.get(attendee)   438             new_partstat = new_attr and new_attr.get("PARTSTAT")   439    440             if old_partstat == "NEEDS-ACTION" and new_partstat and \   441                new_partstat != old_partstat:   442    443                 return True   444    445         return False   446    447     def merge_attendance(self, attendees):   448    449         """   450         Merge attendance from the current object's 'attendees' into the version   451         stored for the current user.   452         """   453    454         obj = self.get_object()   455    456         if not obj or not self.have_new_object(obj):   457             return False   458    459         # Get attendee details in a usable form.   460    461         attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))   462    463         for attendee, attendee_attr in attendees.items():   464    465             # Update attendance in the loaded object.   466    467             attendee_map[attendee] = attendee_attr   468    469         # Set the new details and store the object.   470    471         obj["ATTENDEE"] = attendee_map.items()   472    473         # Set the complete event if not an additional occurrence.   474    475         event = obj.to_node()   476         recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))   477    478         self.store.set_event(self.user, self.uid, self.recurrenceid, event)   479    480         return True   481    482     def update_dtstamp(self):   483    484         "Update the DTSTAMP in the current object."   485    486         dtstamp = self.obj.get_utc_datetime("DTSTAMP")   487         utcnow = to_timezone(datetime.utcnow(), "UTC")   488         self.obj["DTSTAMP"] = [(format_datetime(dtstamp > utcnow and dtstamp or utcnow), {})]   489    490     def set_sequence(self, increment=False):   491    492         "Update the SEQUENCE in the current object."   493    494         sequence = self.obj.get_value("SEQUENCE") or "0"   495         self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]   496    497 # vim: tabstop=4 expandtab shiftwidth=4