imip-agent

imiptools/client.py

729:f3d6831fcc21
2015-09-12 Paul Boddie Ensure user initialisation before invoking each handler method.
     1 #!/usr/bin/env python     2      3 """     4 Common calendar client utilities.     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 imiptools.config import MANAGER_INTERFACE    24 from imiptools.data import Object, get_address, get_uri, get_window_end, \    25                            is_new_object, make_freebusy, to_part, \    26                            uri_dict, uri_items, uri_values    27 from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \    28                             get_timestamp, to_timezone    29 from imiptools.period import can_schedule, remove_period, \    30                              remove_additional_periods, remove_affected_period, \    31                              update_freebusy    32 from imiptools.profile import Preferences    33 import imip_store    34     35 class Client:    36     37     "Common handler and manager methods."    38     39     default_window_size = 100    40     organiser_methods = "ADD", "CANCEL", "DECLINECOUNTER", "PUBLISH", "REQUEST"    41     42     def __init__(self, user, messenger=None, store=None, publisher=None, preferences_dir=None):    43     44         """    45         Initialise a calendar client with the current 'user', plus any    46         'messenger', 'store' and 'publisher' objects, indicating any specific    47         'preferences_dir'.    48         """    49     50         self.user = user    51         self.messenger = messenger    52         self.store = store or imip_store.FileStore()    53     54         try:    55             self.publisher = publisher or imip_store.FilePublisher()    56         except OSError:    57             self.publisher = None    58     59         self.preferences_dir = preferences_dir    60         self.preferences = None    61     62     def get_preferences(self):    63         if not self.preferences and self.user:    64             self.preferences = Preferences(self.user, self.preferences_dir)    65         return self.preferences    66     67     def get_tzid(self):    68         prefs = self.get_preferences()    69         return prefs and prefs.get("TZID") or get_default_timezone()    70     71     def get_window_size(self):    72         prefs = self.get_preferences()    73         try:    74             return prefs and int(prefs.get("window_size")) or self.default_window_size    75         except (TypeError, ValueError):    76             return self.default_window_size    77     78     def get_window_end(self):    79         return get_window_end(self.get_tzid(), self.get_window_size())    80     81     def is_participating(self):    82         prefs = self.get_preferences()    83         return prefs and prefs.get("participating", "participate") != "no" or False    84     85     def is_sharing(self):    86         prefs = self.get_preferences()    87         return prefs and prefs.get("freebusy_sharing") == "share" or False    88     89     def is_bundling(self):    90         prefs = self.get_preferences()    91         return prefs and prefs.get("freebusy_bundling") == "always" or False    92     93     def is_notifying(self):    94         prefs = self.get_preferences()    95         return prefs and prefs.get("freebusy_messages") == "notify" or False    96     97     def is_refreshing(self):    98         prefs = self.get_preferences()    99         return prefs and prefs.get("event_refreshing") == "always" or False   100    101     def allow_organiser_replacement(self):   102         prefs = self.get_preferences()   103         return prefs and prefs.get("organiser_replacement", "attendee") or False   104    105     def have_manager(self):   106         return MANAGER_INTERFACE   107    108     def get_permitted_values(self):   109    110         """   111         Decode a specification of one of the following forms...   112    113         <minute values>   114         <hour values>:<minute values>   115         <hour values>:<minute values>:<second values>   116    117         ...with each list of values being comma-separated.   118         """   119    120         prefs = self.get_preferences()   121         permitted_values = prefs and prefs.get("permitted_times")   122         if permitted_values:   123             try:   124                 l = []   125                 for component in permitted_values.split(":")[:3]:   126                     if component:   127                         l.append(map(int, component.split(",")))   128                     else:   129                         l.append(None)   130    131             # NOTE: Should probably report an error somehow.   132    133             except ValueError:   134                 return None   135             else:   136                 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or [])   137                 return l   138         else:   139             return None   140    141     # Common operations on calendar data.   142    143     def update_attendees(self, obj, attendees, removed):   144    145         """   146         Update the attendees in 'obj' with the given 'attendees' and 'removed'   147         attendee lists. A list is returned containing the attendees whose   148         attendance should be cancelled.   149         """   150    151         to_cancel = []   152    153         existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])   154         added = set(attendees).difference(existing_attendees)   155    156         if added or removed:   157             attendees = uri_items(obj.get_items("ATTENDEE") or [])   158             sequence = obj.get_value("SEQUENCE")   159    160             if removed:   161                 remaining = []   162    163                 for attendee, attendee_attr in attendees:   164                     if attendee in removed:   165    166                         # Without a sequence number, assume that the event has not   167                         # been published and that attendees can be silently removed.   168    169                         if sequence is not None:   170                             to_cancel.append((attendee, attendee_attr))   171                     else:   172                         remaining.append((attendee, attendee_attr))   173    174                 attendees = remaining   175    176             if added:   177                 for attendee in added:   178                     attendee = attendee.strip()   179                     if attendee:   180                         attendees.append((get_uri(attendee), {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))   181    182             obj["ATTENDEE"] = attendees   183    184         return to_cancel   185    186     def update_participation(self, obj, partstat=None):   187    188         """   189         Update the participation in 'obj' of the user with the given 'partstat'.   190         """   191    192         attendee_attr = uri_dict(obj.get_value_map("ATTENDEE")).get(self.user)   193         if not attendee_attr:   194             return None   195         if partstat:   196             attendee_attr["PARTSTAT"] = partstat   197         if attendee_attr.has_key("RSVP"):   198             del attendee_attr["RSVP"]   199         self.update_sender(attendee_attr)   200         return attendee_attr   201    202     def update_sender(self, attr):   203    204         "Update the SENT-BY attribute of the 'attr' sender metadata."   205    206         if self.messenger and self.messenger.sender != get_address(self.user):   207             attr["SENT-BY"] = get_uri(self.messenger.sender)   208    209     def get_periods(self, obj):   210    211         """   212         Return periods for the given 'obj'. Interpretation of periods can depend   213         on the time zone, which is obtained for the current user.   214         """   215    216         return obj.get_periods(self.get_tzid(), self.get_window_end())   217    218     # Store operations.   219    220     def get_stored_object(self, uid, recurrenceid):   221    222         """   223         Return the stored object for the current user, with the given 'uid' and   224         'recurrenceid'.   225         """   226    227         fragment = self.store.get_event(self.user, uid, recurrenceid)   228         return fragment and Object(fragment)   229    230     # Free/busy operations.   231    232     def get_freebusy_part(self, freebusy=None):   233    234         """   235         Return a message part containing free/busy information for the user,   236         either specified as 'freebusy' or obtained from the store directly.   237         """   238    239         if self.is_sharing() and self.is_bundling():   240    241             # Invent a unique identifier.   242    243             utcnow = get_timestamp()   244             uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   245    246             freebusy = freebusy or self.store.get_freebusy(self.user)   247    248             user_attr = {}   249             self.update_sender(user_attr)   250             return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)])   251    252         return None   253    254     def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser):   255    256         """   257         Update the 'freebusy' collection with the given 'periods', indicating a   258         'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a   259         recurrence or the parent event. The 'summary' and 'organiser' must also   260         be provided.   261         """   262    263         update_freebusy(freebusy, periods, transp, uid, recurrenceid, summary, organiser)   264    265 class ClientForObject(Client):   266    267     "A client maintaining a specific object."   268    269     def __init__(self, obj, user, messenger=None, store=None, publisher=None, preferences_dir=None):   270         Client.__init__(self, user, messenger, store, publisher, preferences_dir)   271         self.set_object(obj)   272    273     def set_object(self, obj):   274    275         "Set the current object to 'obj', obtaining metadata details."   276    277         self.obj = obj   278         self.uid = obj and self.obj.get_uid()   279         self.recurrenceid = obj and self.obj.get_recurrenceid()   280         self.sequence = obj and self.obj.get_value("SEQUENCE")   281         self.dtstamp = obj and self.obj.get_value("DTSTAMP")   282    283     def set_identity(self, method):   284    285         """   286         Set the current user for the current object in the context of the given   287         'method'. It is usually set when initialising the handler, using the   288         recipient details, but outgoing messages do not reference the recipient   289         in this way.   290         """   291    292         pass   293    294     def is_usable(self, method=None):   295    296         "Return whether the current object is usable with the given 'method'."   297    298         return True   299    300     # Object update methods.   301    302     def update_recurrenceid(self):   303    304         """   305         Update the RECURRENCE-ID in the current object, initialising it from   306         DTSTART.   307         """   308    309         self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")]   310         self.recurrenceid = self.obj.get_recurrenceid()   311    312     def update_dtstamp(self):   313    314         "Update the DTSTAMP in the current object."   315    316         dtstamp = self.obj.get_utc_datetime("DTSTAMP")   317         utcnow = to_timezone(datetime.utcnow(), "UTC")   318         self.dtstamp = format_datetime(dtstamp and dtstamp > utcnow and dtstamp or utcnow)   319         self.obj["DTSTAMP"] = [(self.dtstamp, {})]   320    321     def set_sequence(self, increment=False):   322    323         "Update the SEQUENCE in the current object."   324    325         sequence = self.obj.get_value("SEQUENCE") or "0"   326         self.obj["SEQUENCE"] = [(str(int(sequence) + (increment and 1 or 0)), {})]   327    328     def merge_attendance(self, attendees):   329    330         """   331         Merge attendance from the current object's 'attendees' into the version   332         stored for the current user.   333         """   334    335         obj = self.get_stored_object_version()   336    337         if not obj or not self.have_new_object(obj):   338             return False   339    340         # Get attendee details in a usable form.   341    342         attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))   343    344         for attendee, attendee_attr in attendees.items():   345    346             # Update attendance in the loaded object.   347    348             attendee_map[attendee] = attendee_attr   349    350         # Set the new details and store the object.   351    352         obj["ATTENDEE"] = attendee_map.items()   353    354         # Set the complete event if not an additional occurrence.   355    356         event = obj.to_node()   357         self.store.set_event(self.user, self.uid, self.recurrenceid, event)   358    359         return True   360    361     # Object-related tests.   362    363     def is_recognised_organiser(self, organiser):   364    365         """   366         Return whether the given 'organiser' is recognised from   367         previously-received details. If no stored details exist, True is   368         returned.   369         """   370    371         obj = self.get_stored_object_version()   372         if obj:   373             stored_organiser = get_uri(obj.get_value("ORGANIZER"))   374             return stored_organiser == organiser   375         else:   376             return True   377    378     def is_recognised_attendee(self, attendee):   379    380         """   381         Return whether the given 'attendee' is recognised from   382         previously-received details. If no stored details exist, True is   383         returned.   384         """   385    386         obj = self.get_stored_object_version()   387         if obj:   388             stored_attendees = uri_dict(obj.get_value_map("ATTENDEE"))   389             return stored_attendees.has_key(attendee)   390         else:   391             return True   392    393     def get_attendance(self, user=None, obj=None):   394    395         """   396         Return the attendance attributes for 'user', or the current user if   397         'user' is not specified.   398         """   399    400         attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE"))   401         return attendees.get(user or self.user)   402    403     def is_participating(self, user, as_organiser=False, obj=None):   404    405         """   406         Return whether, subject to the 'user' indicating an identity and the   407         'as_organiser' status of that identity, the user concerned is actually   408         participating in the current object event.   409         """   410    411         # Use any attendee property information for an organiser, not the   412         # organiser property attributes.   413    414         attr = self.get_attendance(user, obj=obj)   415         return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") != "DECLINED"   416    417     def get_overriding_transparency(self, user, as_organiser=False):   418    419         """   420         Return the overriding transparency to be associated with the free/busy   421         records for an event, subject to the 'user' indicating an identity and   422         the 'as_organiser' status of that identity.   423    424         Where an identity is only an organiser and not attending, "ORG" is   425         returned. Otherwise, no overriding transparency is defined and None is   426         returned.   427         """   428    429         attr = self.get_attendance(user)   430         return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None   431    432     def is_attendee(self, identity, obj=None):   433    434         """   435         Return whether 'identity' is an attendee in the current object, or in   436         'obj' if specified.   437         """   438    439         return identity in uri_values((obj or self.obj).get_values("ATTENDEE"))   440    441     def can_schedule(self, freebusy, periods):   442    443         """   444         Indicate whether within 'freebusy' the given 'periods' can be scheduled.   445         """   446    447         return can_schedule(freebusy, periods, self.uid, self.recurrenceid)   448    449     def have_new_object(self, obj=None, strict=True):   450    451         """   452         Return whether the current object is new to the current user (or if the   453         given 'obj' is new). If 'strict' is specified and is a false value, the   454         DTSTAMP test will be ignored. This is useful in handling responses from   455         attendees from clients (like Claws Mail) that erase time information   456         from DTSTAMP and make it invalid.   457         """   458    459         obj = obj or self.get_stored_object_version()   460    461         # If found, compare SEQUENCE and potentially DTSTAMP.   462    463         if obj:   464             sequence = obj.get_value("SEQUENCE")   465             dtstamp = obj.get_value("DTSTAMP")   466    467             # If the request refers to an older version of the object, ignore   468             # it.   469    470             return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict)   471    472         return True   473    474     def possibly_recurring_indefinitely(self):   475    476         "Return whether the object recurs indefinitely."   477    478         # Obtain the stored object to make sure that recurrence information   479         # is not being ignored. This might happen if a client sends a   480         # cancellation without the complete set of properties, for instance.   481    482         return self.obj.possibly_recurring_indefinitely() or \   483                self.get_stored_object_version() and \   484                self.get_stored_object_version().possibly_recurring_indefinitely()   485    486     # Constraint application on event periods.   487    488     def check_object(self):   489    490         "Check the object against any scheduling constraints."   491    492         permitted_values = self.get_permitted_values()   493         if not permitted_values:   494             return None   495    496         invalid = []   497    498         for period in self.obj.get_periods(self.get_tzid()):   499             start = period.get_start()   500             end = period.get_end()   501             start_errors = check_permitted_values(start, permitted_values)   502             end_errors = check_permitted_values(end, permitted_values)   503             if start_errors or end_errors:   504                 invalid.append((period.origin, start_errors, end_errors))   505    506         return invalid   507    508     def correct_object(self):   509    510         "Correct the object according to any scheduling constraints."   511    512         permitted_values = self.get_permitted_values()   513         return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values)   514    515     # Object retrieval.   516    517     def get_stored_object_version(self):   518    519         """   520         Return the stored object to which the current object refers for the   521         current user.   522         """   523    524         return self.get_stored_object(self.uid, self.recurrenceid)   525    526     def get_definitive_object(self, as_organiser):   527    528         """   529         Return an object considered definitive for the current transaction,   530         using 'as_organiser' to select the current transaction's object if   531         false, or selecting a stored object if true.   532         """   533    534         return not as_organiser and self.obj or self.get_stored_object_version()   535    536     def get_parent_object(self):   537    538         """   539         Return the parent object to which the current object refers for the   540         current user.   541         """   542    543         return self.recurrenceid and self.get_stored_object(self.uid, None) or None   544    545     # Convenience methods for modifying free/busy collections.   546    547     def get_recurrence_start_point(self, recurrenceid):   548    549         "Get 'recurrenceid' in a form suitable for matching free/busy entries."   550    551         return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid())   552    553     def remove_from_freebusy(self, freebusy):   554    555         "Remove this event from the given 'freebusy' collection."   556    557         if not remove_period(freebusy, self.uid, self.recurrenceid) and self.recurrenceid:   558             remove_affected_period(freebusy, self.uid, self.get_recurrence_start_point(self.recurrenceid))   559    560     def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):   561    562         """   563         Remove from 'freebusy' any original recurrence from parent free/busy   564         details for the current object, if the current object is a specific   565         additional recurrence. Otherwise, remove all additional recurrence   566         information corresponding to 'recurrenceids', or if omitted, all   567         recurrences.   568         """   569    570         if self.recurrenceid:   571             recurrenceid = self.get_recurrence_start_point(self.recurrenceid)   572             remove_affected_period(freebusy, self.uid, recurrenceid)   573         else:   574             # Remove obsolete recurrence periods.   575    576             remove_additional_periods(freebusy, self.uid, recurrenceids)   577    578             # Remove original periods affected by additional recurrences.   579    580             if recurrenceids:   581                 for recurrenceid in recurrenceids:   582                     recurrenceid = self.get_recurrence_start_point(recurrenceid)   583                     remove_affected_period(freebusy, self.uid, recurrenceid)   584    585     def update_freebusy(self, freebusy, user, as_organiser):   586    587         """   588         Update the 'freebusy' collection for this event with the periods and   589         transparency associated with the current object, subject to the 'user'   590         identity and the attendance details provided for them, indicating   591         whether the update is being done 'as_organiser' (for the organiser of   592         an event) or not.   593         """   594    595         # Obtain the stored object if the current object is not issued by the   596         # organiser. Attendees do not have the opportunity to redefine the   597         # periods.   598    599         obj = self.get_definitive_object(as_organiser)   600         if not obj:   601             return   602    603         # Obtain the affected periods.   604    605         periods = self.get_periods(obj)   606    607         # Define an overriding transparency, the indicated event transparency,   608         # or the default transparency for the free/busy entry.   609    610         transp = self.get_overriding_transparency(user, as_organiser) or \   611                  obj.get_value("TRANSP") or \   612                  "OPAQUE"   613    614         # Perform the low-level update.   615    616         Client.update_freebusy(self, freebusy, periods, transp,   617             self.uid, self.recurrenceid,   618             obj.get_value("SUMMARY"),   619             obj.get_value("ORGANIZER"))   620    621     def update_freebusy_for_participant(self, freebusy, user, for_organiser=False,   622                                         updating_other=False):   623    624         """   625         Update the 'freebusy' collection for the given 'user', indicating   626         whether the update is 'for_organiser' (being done for the organiser of   627         an event) or not, and whether it is 'updating_other' (meaning another   628         user's details).   629         """   630    631         # Record in the free/busy details unless a non-participating attendee.   632         # Remove periods for non-participating attendees.   633    634         if self.is_participating(user, for_organiser and not updating_other):   635             self.update_freebusy(freebusy, user,   636                 for_organiser and not updating_other or   637                 not for_organiser and updating_other   638                 )   639         else:   640             self.remove_from_freebusy(freebusy)   641    642     def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False,   643                                         updating_other=False):   644    645         """   646         Remove details from the 'freebusy' collection for the given 'user',   647         indicating whether the modification is 'for_organiser' (being done for   648         the organiser of an event) or not, and whether it is 'updating_other'   649         (meaning another user's details).   650         """   651    652         # Remove from the free/busy details if a specified attendee.   653    654         if self.is_participating(user, for_organiser and not updating_other):   655             self.remove_from_freebusy(freebusy)   656    657     # Convenience methods for updating stored free/busy information received   658     # from other users.   659    660     def update_freebusy_from_participant(self, user, for_organiser, fn=None):   661    662         """   663         For the current user, record the free/busy information for another   664         'user', indicating whether the update is 'for_organiser' or not, thus   665         maintaining a separate record of their free/busy details.   666         """   667    668         fn = fn or self.update_freebusy_for_participant   669    670         # A user does not store free/busy information for themself as another   671         # party.   672    673         if user == self.user:   674             return   675    676         freebusy = self.store.get_freebusy_for_other_for_update(self.user, user)   677         try:   678             fn(freebusy, user, for_organiser, True)   679    680             # Tidy up any obsolete recurrences.   681    682             self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))   683             self.store.set_freebusy_for_other_in_update(self.user, freebusy, user)   684    685         finally:   686             self.store.release_freebusy(self.user)   687    688     def update_freebusy_from_organiser(self, organiser):   689    690         "For the current user, record free/busy information from 'organiser'."   691    692         self.update_freebusy_from_participant(organiser, True)   693    694     def update_freebusy_from_attendees(self, attendees):   695    696         "For the current user, record free/busy information from 'attendees'."   697    698         for attendee in attendees.keys():   699             self.update_freebusy_from_participant(attendee, False)   700    701     def remove_freebusy_from_organiser(self, organiser):   702    703         "For the current user, remove free/busy information from 'organiser'."   704    705         self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant)   706    707     def remove_freebusy_from_attendees(self, attendees):   708    709         "For the current user, remove free/busy information from 'attendees'."   710    711         for attendee in attendees.keys():   712             self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant)   713    714 # vim: tabstop=4 expandtab shiftwidth=4