imip-agent

imiptools/handlers/__init__.py

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