imip-agent

imiptools/client.py

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