imip-agent

imiptools/client.py

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