imip-agent

imiptools/client.py

1063:67369fd525db
2016-03-03 Paul Boddie Introduced a common free/busy collection abstraction and a specific database collection class. freebusy-collections
     1 #!/usr/bin/env python     2      3 """     4 Common calendar client utilities.     5      6 Copyright (C) 2014, 2015, 2016 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 import config    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_item, uri_items, uri_parts, uri_values    27 from imiptools.dates import check_permitted_values, format_datetime, get_default_timezone, \    28                             get_duration, get_timestamp    29 from imiptools.i18n import get_translator    30 from imiptools.profile import Preferences    31 import imip_store    32     33 class Client:    34     35     "Common handler and manager methods."    36     37     default_window_size = 100    38     organiser_methods = "ADD", "CANCEL", "DECLINECOUNTER", "PUBLISH", "REQUEST"    39     40     def __init__(self, user, messenger=None, store=None, publisher=None, journal=None,    41                  preferences_dir=None):    42     43         """    44         Initialise a calendar client with the current 'user', plus any    45         'messenger', 'store', 'publisher' and 'journal' objects, indicating any    46         specific 'preferences_dir'.    47         """    48     49         self.user = user    50         self.messenger = messenger    51         self.store = store or imip_store.FileStore()    52         self.journal = journal or imip_store.FileJournal()    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         # Localise the messenger.    63     64         if self.messenger:    65             self.messenger.gettext = self.get_translator()    66     67     def get_store(self):    68         return self.store    69     70     def get_publisher(self):    71         return self.publisher    72     73     def get_journal(self):    74         return self.journal    75     76     # Store-related methods.    77     78     def acquire_lock(self):    79         self.store.acquire_lock(self.user)    80     81     def release_lock(self):    82         self.store.release_lock(self.user)    83     84     # Preferences-related methods.    85     86     def get_preferences(self):    87         if not self.preferences and self.user:    88             self.preferences = Preferences(self.user, self.preferences_dir)    89         return self.preferences    90     91     def get_locale(self):    92         prefs = self.get_preferences()    93         return prefs and prefs.get("LANG", "en", True) or "en"    94     95     def get_translator(self):    96         return get_translator([self.get_locale()])    97     98     def get_user_attributes(self):    99         prefs = self.get_preferences()   100         return prefs and prefs.get_all(["CN"]) or {}   101    102     def get_tzid(self):   103         prefs = self.get_preferences()   104         return prefs and prefs.get("TZID") or get_default_timezone()   105    106     def get_window_size(self):   107         prefs = self.get_preferences()   108         try:   109             return prefs and int(prefs.get("window_size")) or self.default_window_size   110         except (TypeError, ValueError):   111             return self.default_window_size   112    113     def get_window_end(self):   114         return get_window_end(self.get_tzid(), self.get_window_size())   115    116     def is_participating(self):   117    118         "Return participation in the calendar system."   119    120         prefs = self.get_preferences()   121         return prefs and prefs.get("participating", config.PARTICIPATING_DEFAULT) != "no" or False   122    123     def is_sharing(self):   124    125         "Return whether free/busy information is being generally shared."   126    127         prefs = self.get_preferences()   128         return prefs and prefs.get("freebusy_sharing", config.SHARING_DEFAULT) == "share" or False   129    130     def is_bundling(self):   131    132         "Return whether free/busy information is being bundled in messages."   133    134         prefs = self.get_preferences()   135         return prefs and prefs.get("freebusy_bundling", config.BUNDLING_DEFAULT) == "always" or False   136    137     def is_notifying(self):   138    139         "Return whether recipients are notified about free/busy payloads."   140    141         prefs = self.get_preferences()   142         return prefs and prefs.get("freebusy_messages", config.NOTIFYING_DEFAULT) == "notify" or False   143    144     def is_publishing(self):   145    146         "Return whether free/busy information is being published as Web resources."   147    148         prefs = self.get_preferences()   149         return prefs and prefs.get("freebusy_publishing", config.PUBLISHING_DEFAULT) == "publish" or False   150    151     def is_refreshing(self):   152    153         "Return whether a recipient supports requests to refresh event details."   154    155         prefs = self.get_preferences()   156         return prefs and prefs.get("event_refreshing", config.REFRESHING_DEFAULT) == "always" or False   157    158     def allow_add(self):   159         return self.get_add_method_response() in ("add", "refresh")   160    161     def get_add_method_response(self):   162         prefs = self.get_preferences()   163         return prefs and prefs.get("add_method_response", config.ADD_RESPONSE_DEFAULT) or "refresh"   164    165     def get_offer_period(self):   166    167         "Decode a specification in the iCalendar duration format."   168    169         prefs = self.get_preferences()   170         duration = prefs and prefs.get("freebusy_offers", config.FREEBUSY_OFFER_DEFAULT)   171    172         # NOTE: Should probably report an error somehow if None.   173    174         return duration and get_duration(duration) or None   175    176     def get_organiser_replacement(self):   177         prefs = self.get_preferences()   178         return prefs and prefs.get("organiser_replacement", config.ORGANISER_REPLACEMENT_DEFAULT) or "attendee"   179    180     def have_manager(self):   181         return config.MANAGER_INTERFACE   182    183     def get_permitted_values(self):   184    185         """   186         Decode a specification of one of the following forms...   187    188         <minute values>   189         <hour values>:<minute values>   190         <hour values>:<minute values>:<second values>   191    192         ...with each list of values being comma-separated.   193         """   194    195         prefs = self.get_preferences()   196         permitted_values = prefs and prefs.get("permitted_times")   197         if permitted_values:   198             try:   199                 l = []   200                 for component in permitted_values.split(":")[:3]:   201                     if component:   202                         l.append(map(int, component.split(",")))   203                     else:   204                         l.append(None)   205    206             # NOTE: Should probably report an error somehow.   207    208             except ValueError:   209                 return None   210             else:   211                 l = (len(l) < 2 and [None] or []) + l + (len(l) < 3 and [None] or [])   212                 return l   213         else:   214             return None   215    216     # Common operations on calendar data.   217    218     def update_sender(self, attr):   219    220         "Update the SENT-BY attribute of the 'attr' sender metadata."   221    222         if self.messenger and self.messenger.sender != get_address(self.user):   223             attr["SENT-BY"] = get_uri(self.messenger.sender)   224    225     def get_periods(self, obj, explicit_only=False):   226    227         """   228         Return periods for the given 'obj'. Interpretation of periods can depend   229         on the time zone, which is obtained for the current user. If   230         'explicit_only' is set to a true value, only explicit periods will be   231         returned, not rule-based periods.   232         """   233    234         return obj.get_periods(self.get_tzid(), not explicit_only and self.get_window_end() or None)   235    236     # Store operations.   237    238     def get_stored_object(self, uid, recurrenceid, section=None, username=None):   239    240         """   241         Return the stored object for the current user, with the given 'uid' and   242         'recurrenceid' from the given 'section' and for the given 'username' (if   243         specified), or from the standard object collection otherwise.   244         """   245    246         if section == "counters":   247             fragment = self.store.get_counter(self.user, username, uid, recurrenceid)   248         else:   249             fragment = self.store.get_event(self.user, uid, recurrenceid, section)   250         return fragment and Object(fragment)   251    252     # Free/busy operations.   253    254     def get_freebusy_part(self, freebusy=None):   255    256         """   257         Return a message part containing free/busy information for the user,   258         either specified as 'freebusy' or obtained from the store directly.   259         """   260    261         if self.is_sharing() and self.is_bundling():   262    263             # Invent a unique identifier.   264    265             utcnow = get_timestamp()   266             uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   267    268             freebusy = freebusy or self.store.get_freebusy(self.user)   269    270             user_attr = {}   271             self.update_sender(user_attr)   272             return to_part("PUBLISH", [make_freebusy(freebusy, uid, self.user, user_attr)])   273    274         return None   275    276     def update_freebusy(self, freebusy, periods, transp, uid, recurrenceid, summary, organiser, expires=None):   277    278         """   279         Update the 'freebusy' collection with the given 'periods', indicating a   280         'transp' status, explicit 'uid' and 'recurrenceid' to indicate either a   281         recurrence or the parent event. The 'summary' and 'organiser' must also   282         be provided.   283    284         An optional 'expires' datetime string can be provided to tag a free/busy   285         offer.   286         """   287    288         freebusy.update_freebusy(periods, transp, uid, recurrenceid, summary, organiser, expires)   289    290     # Preparation of messages communicating the state of events.   291    292     def get_message_parts(self, obj, method, attendee=None):   293    294         """   295         Return a tuple containing a list of methods and a list of message parts,   296         with the parts collectively describing the given object 'obj' and its   297         recurrences, using 'method' as the means of publishing details (with   298         CANCEL being used to retract or remove details).   299    300         If 'attendee' is indicated, the attendee's participation will be taken   301         into account when generating the description.   302         """   303    304         # Assume that the outcome will be composed of requests and   305         # cancellations. It would not seem completely bizarre to produce   306         # publishing messages if a refresh message was unprovoked.   307    308         responses = []   309         methods = set()   310    311         # Get the parent event, add SENT-BY details to the organiser.   312    313         if not attendee or self.is_participating(attendee, obj=obj):   314             organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER"))   315             self.update_sender(organiser_attr)   316             responses.append(obj.to_part(method))   317             methods.add(method)   318    319         # Get recurrences for parent events.   320    321         if not self.recurrenceid:   322    323             # Collect active and cancelled recurrences.   324    325             for rl, section, rmethod in [   326                 (self.store.get_active_recurrences(self.user, self.uid), None, method),   327                 (self.store.get_cancelled_recurrences(self.user, self.uid), "cancellations", "CANCEL"),   328                 ]:   329    330                 for recurrenceid in rl:   331    332                     # Get the recurrence, add SENT-BY details to the organiser.   333    334                     obj = self.get_stored_object(self.uid, recurrenceid, section)   335    336                     if not attendee or self.is_participating(attendee, obj=obj):   337                         organiser, organiser_attr = uri_item(obj.get_item("ORGANIZER"))   338                         self.update_sender(organiser_attr)   339                         responses.append(obj.to_part(rmethod))   340                         methods.add(rmethod)   341    342         return methods, responses   343    344 class ClientForObject(Client):   345    346     "A client maintaining a specific object."   347    348     def __init__(self, obj, user, messenger=None, store=None, publisher=None,   349                  journal=None, preferences_dir=None):   350         Client.__init__(self, user, messenger, store, publisher, journal, preferences_dir)   351         self.set_object(obj)   352    353     def set_object(self, obj):   354    355         "Set the current object to 'obj', obtaining metadata details."   356    357         self.obj = obj   358         self.uid = obj and self.obj.get_uid()   359         self.recurrenceid = obj and self.obj.get_recurrenceid()   360         self.sequence = obj and self.obj.get_value("SEQUENCE")   361         self.dtstamp = obj and self.obj.get_value("DTSTAMP")   362    363     def set_identity(self, method):   364    365         """   366         Set the current user for the current object in the context of the given   367         'method'. It is usually set when initialising the handler, using the   368         recipient details, but outgoing messages do not reference the recipient   369         in this way.   370         """   371    372         pass   373    374     def is_usable(self, method=None):   375    376         "Return whether the current object is usable with the given 'method'."   377    378         return True   379    380     def is_organiser(self):   381    382         """   383         Return whether the current user is the organiser in the current object.   384         """   385    386         return get_uri(self.obj.get_value("ORGANIZER")) == self.user   387    388     # Common operations on calendar data.   389    390     def update_senders(self, obj=None):   391    392         """   393         Update sender details in 'obj', or the current object if not indicated,   394         removing SENT-BY attributes for attendees other than the current user if   395         those attributes give the URI of the calendar system.   396         """   397    398         obj = obj or self.obj   399         calendar_uri = self.messenger and get_uri(self.messenger.sender)   400         for attendee, attendee_attr in uri_items(obj.get_items("ATTENDEE")):   401             if attendee != self.user:   402                 if attendee_attr.get("SENT-BY") == calendar_uri:   403                     del attendee_attr["SENT-BY"]   404             else:   405                 attendee_attr["SENT-BY"] = calendar_uri   406    407     def get_sending_attendee(self):   408    409         "Return the attendee who sent the current object."   410    411         # Search for the sender of the message or the calendar system address.   412    413         senders = self.senders or self.messenger and [self.messenger.sender] or []   414    415         for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")):   416             if get_address(attendee) in senders or \   417                get_address(attendee_attr.get("SENT-BY")) in senders:   418                 return get_uri(attendee)   419    420         return None   421    422     def get_unscheduled_parts(self, periods):   423    424         "Return message parts describing unscheduled 'periods'."   425    426         unscheduled_parts = []   427    428         if periods:   429             obj = self.obj.copy()   430             obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"])   431    432             for p in periods:   433                 if not p.origin:   434                     continue   435                 obj["RECURRENCE-ID"] = obj["DTSTART"] = [(format_datetime(p.get_start()), p.get_start_attr())]   436                 obj["DTEND"] = [(format_datetime(p.get_end()), p.get_end_attr())]   437                 unscheduled_parts.append(obj.to_part("CANCEL"))   438    439         return unscheduled_parts   440    441     # Object update methods.   442    443     def update_recurrenceid(self):   444    445         """   446         Update the RECURRENCE-ID in the current object, initialising it from   447         DTSTART.   448         """   449    450         self.obj["RECURRENCE-ID"] = [self.obj.get_item("DTSTART")]   451         self.recurrenceid = self.obj.get_recurrenceid()   452    453     def update_dtstamp(self, obj=None):   454    455         "Update the DTSTAMP in the current object or any given object 'obj'."   456    457         obj = obj or self.obj   458         self.dtstamp = obj.update_dtstamp()   459    460     def update_sequence(self, increment=False, obj=None):   461    462         "Update the SEQUENCE in the current object or any given object 'obj'."   463    464         obj = obj or self.obj   465         obj.update_sequence(increment)   466    467     def merge_attendance(self, attendees):   468    469         """   470         Merge attendance from the current object's 'attendees' into the version   471         stored for the current user.   472         """   473    474         obj = self.get_stored_object_version()   475    476         if not obj or not self.have_new_object():   477             return False   478    479         # Get attendee details in a usable form.   480    481         attendee_map = uri_dict(obj.get_value_map("ATTENDEE"))   482    483         for attendee, attendee_attr in attendees.items():   484    485             # Update attendance in the loaded object for any recognised   486             # attendees.   487    488             if attendee_map.has_key(attendee):   489                 attendee_map[attendee] = attendee_attr   490    491         # Set the new details and store the object.   492    493         obj["ATTENDEE"] = attendee_map.items()   494    495         # Set a specific recurrence or the complete event if not an additional   496         # occurrence.   497    498         return self.store.set_event(self.user, self.uid, self.recurrenceid, obj.to_node())   499    500     def update_attendees(self, attendees, removed):   501    502         """   503         Update the attendees in the current object with the given 'attendees'   504         and 'removed' attendee lists.   505    506         A tuple is returned containing two items: a list of the attendees whose   507         attendance is being proposed (in a counter-proposal), a list of the   508         attendees whose attendance should be cancelled.   509         """   510    511         to_cancel = []   512    513         existing_attendees = uri_items(self.obj.get_items("ATTENDEE") or [])   514         existing_attendees_map = dict(existing_attendees)   515    516         # Added attendees are those from the supplied collection not already   517         # present in the object.   518    519         added = set(uri_values(attendees)).difference([uri for uri, attr in existing_attendees])   520         removed = uri_values(removed)   521    522         if added or removed:   523    524             # The organiser can remove existing attendees.   525    526             if removed and self.is_organiser():   527                 remaining = []   528    529                 for attendee, attendee_attr in existing_attendees:   530                     if attendee in removed:   531    532                         # Only when an event has not been published can   533                         # attendees be silently removed.   534    535                         if self.obj.is_shared():   536                             to_cancel.append((attendee, attendee_attr))   537                     else:   538                         remaining.append((attendee, attendee_attr))   539    540                 existing_attendees = remaining   541    542             # Attendees (when countering) must only include the current user and   543             # any added attendees.   544    545             elif not self.is_organiser():   546                 existing_attendees = []   547    548             # Both organisers and attendees (when countering) can add attendees.   549    550             if added:   551    552                 # Obtain a mapping from URIs to name details.   553    554                 attendee_map = dict([(attendee_uri, cn) for cn, attendee_uri in uri_parts(attendees)])   555    556                 for attendee in added:   557                     attendee = attendee.strip()   558                     if attendee:   559                         cn = attendee_map.get(attendee)   560                         attendee_attr = {"CN" : cn} or {}   561    562                         # Only the organiser can reset the participation attributes.   563    564                         if self.is_organiser():   565                             attendee_attr.update({"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"})   566    567                         existing_attendees.append((attendee, attendee_attr))   568    569             # Attendees (when countering) must only include the current user and   570             # any added attendees.   571    572             if not self.is_organiser() and self.user not in existing_attendees:   573                 user_attr = self.get_user_attributes()   574                 user_attr.update(existing_attendees_map.get(self.user) or {})   575                 existing_attendees.append((self.user, user_attr))   576    577             self.obj["ATTENDEE"] = existing_attendees   578    579         return added, to_cancel   580    581     def update_participation(self, partstat=None):   582    583         """   584         Update the participation in the current object of the user with the   585         given 'partstat'.   586         """   587    588         attendee_attr = uri_dict(self.obj.get_value_map("ATTENDEE")).get(self.user)   589         if not attendee_attr:   590             return None   591         if partstat:   592             attendee_attr["PARTSTAT"] = partstat   593         if attendee_attr.has_key("RSVP"):   594             del attendee_attr["RSVP"]   595         self.update_sender(attendee_attr)   596         return attendee_attr   597    598     # Communication methods.   599    600     def send_message(self, parts, sender, obj, from_organiser, bcc_sender):   601    602         """   603         Send the given 'parts' to the appropriate recipients, also sending a   604         copy to the 'sender'. The 'obj' together with the 'from_organiser' value   605         (which indicates whether the organiser is sending this message) are used   606         to determine the recipients of the message.   607         """   608    609         # As organiser, send an invitation to attendees, excluding oneself if   610         # also attending. The updated event will be saved by the outgoing   611         # handler.   612    613         organiser = get_uri(obj.get_value("ORGANIZER"))   614         attendees = uri_values(obj.get_values("ATTENDEE"))   615    616         if from_organiser:   617             recipients = [get_address(attendee) for attendee in attendees if attendee != self.user]   618         else:   619             recipients = [get_address(organiser)]   620    621         # Since the outgoing handler updates this user's free/busy details,   622         # the stored details will probably not have the updated details at   623         # this point, so we update our copy for serialisation as the bundled   624         # free/busy object.   625    626         freebusy = self.store.get_freebusy(self.user)   627         self.update_freebusy(freebusy, self.user, from_organiser)   628    629         # Bundle free/busy information if appropriate.   630    631         part = self.get_freebusy_part(freebusy)   632         if part:   633             parts.append(part)   634    635         if recipients or bcc_sender:   636             self._send_message(sender, recipients, parts, bcc_sender)   637    638     def _send_message(self, sender, recipients, parts, bcc_sender):   639    640         """   641         Send a message, explicitly specifying the 'sender' as an outgoing BCC   642         recipient since the generic calendar user will be the actual sender.   643         """   644    645         if not self.messenger:   646             return   647    648         if not bcc_sender:   649             message = self.messenger.make_outgoing_message(parts, recipients)   650             self.messenger.sendmail(recipients, message.as_string())   651         else:   652             message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)   653             self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)   654    655     def send_message_to_self(self, parts):   656    657         "Send a message composed of the given 'parts' to the given user."   658    659         if not self.messenger:   660             return   661    662         sender = get_address(self.user)   663         message = self.messenger.make_outgoing_message(parts, [sender])   664         self.messenger.sendmail([sender], message.as_string())   665    666     # Action methods.   667    668     def process_declined_counter(self, attendee):   669    670         "Process a declined counter-proposal."   671    672         # Obtain the counter-proposal for the attendee.   673    674         obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee)   675         if not obj:   676             return False   677    678         method = "DECLINECOUNTER"   679         self.update_senders(obj=obj)   680         obj.update_dtstamp()   681         obj.update_sequence(False)   682         self._send_message(get_address(self.user), [get_address(attendee)], [obj.to_part(method)], True)   683         return True   684    685     def process_received_request(self, changed=False):   686    687         """   688         Process the current request for the current user. Return whether any   689         action was taken. If 'changed' is set to a true value, or if 'attendees'   690         is specified and differs from the stored attendees, a counter-proposal   691         will be sent instead of a reply.   692         """   693    694         # Reply only on behalf of this user.   695    696         attendee_attr = self.update_participation()   697    698         if not attendee_attr:   699             return False   700    701         if not changed:   702             self.obj["ATTENDEE"] = [(self.user, attendee_attr)]   703         else:   704             self.update_senders()   705    706         self.update_dtstamp()   707         self.update_sequence(False)   708         self.send_message([self.obj.to_part(changed and "COUNTER" or "REPLY")], get_address(self.user), self.obj, False, True)   709         return True   710    711     def process_created_request(self, method, to_cancel=None, to_unschedule=None):   712    713         """   714         Process the current request, sending a created request of the given   715         'method' to attendees. Return whether any action was taken.   716    717         If 'to_cancel' is specified, a list of participants to be sent cancel   718         messages is provided.   719    720         If 'to_unschedule' is specified, a list of periods to be unscheduled is   721         provided.   722         """   723    724         # Here, the organiser should be the current user.   725    726         organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER"))   727    728         self.update_sender(organiser_attr)   729         self.update_senders()   730         self.update_dtstamp()   731         self.update_sequence(True)   732    733         if method == "REQUEST":   734             methods, parts = self.get_message_parts(self.obj, "REQUEST")   735    736             # Add message parts with cancelled occurrence information.   737    738             unscheduled_parts = self.get_unscheduled_parts(to_unschedule)   739    740             # Send the updated event, along with a cancellation for each of the   741             # unscheduled occurrences.   742    743             self.send_message(parts + unscheduled_parts, get_address(organiser), self.obj, True, False)   744    745             # Since the organiser can update the SEQUENCE but this can leave any   746             # mail/calendar client lagging, issue a PUBLISH message to the   747             # user's address.   748    749             methods, parts = self.get_message_parts(self.obj, "PUBLISH")   750             self.send_message_to_self(parts + unscheduled_parts)   751    752         # When cancelling, replace the attendees with those for whom the event   753         # is now cancelled.   754    755         if method == "CANCEL" or to_cancel:   756             if to_cancel:   757                 obj = self.obj.copy()   758                 obj["ATTENDEE"] = to_cancel   759             else:   760                 obj = self.obj   761    762             # Send a cancellation to all uninvited attendees.   763    764             parts = [obj.to_part("CANCEL")]   765             self.send_message(parts, get_address(organiser), obj, True, False)   766    767             # Issue a CANCEL message to the user's address.   768    769             if method == "CANCEL":   770                 self.send_message_to_self(parts)   771    772         return True   773    774     # Object-related tests.   775    776     def is_recognised_organiser(self, organiser):   777    778         """   779         Return whether the given 'organiser' is recognised from   780         previously-received details. If no stored details exist, True is   781         returned.   782         """   783    784         obj = self.get_stored_object_version()   785         if obj:   786             stored_organiser = get_uri(obj.get_value("ORGANIZER"))   787             return stored_organiser == organiser   788         else:   789             return True   790    791     def is_recognised_attendee(self, attendee):   792    793         """   794         Return whether the given 'attendee' is recognised from   795         previously-received details. If no stored details exist, True is   796         returned.   797         """   798    799         obj = self.get_stored_object_version()   800         if obj:   801             stored_attendees = uri_dict(obj.get_value_map("ATTENDEE"))   802             return stored_attendees.has_key(attendee)   803         else:   804             return True   805    806     def get_attendance(self, user=None, obj=None):   807    808         """   809         Return the attendance attributes for 'user', or the current user if   810         'user' is not specified.   811         """   812    813         attendees = uri_dict((obj or self.obj).get_value_map("ATTENDEE"))   814         return attendees.get(user or self.user)   815    816     def is_participating(self, user, as_organiser=False, obj=None):   817    818         """   819         Return whether, subject to the 'user' indicating an identity and the   820         'as_organiser' status of that identity, the user concerned is actually   821         participating in the current object event.   822         """   823    824         # Use any attendee property information for an organiser, not the   825         # organiser property attributes.   826    827         attr = self.get_attendance(user, obj)   828         return as_organiser or attr is not None and not attr or attr and attr.get("PARTSTAT") not in ("DECLINED", "NEEDS-ACTION")   829    830     def has_indicated_attendance(self, user=None, obj=None):   831    832         """   833         Return whether the given 'user' (or the current user if not specified)   834         has indicated attendance in the given 'obj' (or the current object if   835         not specified).   836         """   837    838         attr = self.get_attendance(user, obj)   839         return attr and attr.get("PARTSTAT") not in (None, "NEEDS-ACTION")   840    841     def get_overriding_transparency(self, user, as_organiser=False):   842    843         """   844         Return the overriding transparency to be associated with the free/busy   845         records for an event, subject to the 'user' indicating an identity and   846         the 'as_organiser' status of that identity.   847    848         Where an identity is only an organiser and not attending, "ORG" is   849         returned. Otherwise, no overriding transparency is defined and None is   850         returned.   851         """   852    853         attr = self.get_attendance(user)   854         return as_organiser and not (attr and attr.get("PARTSTAT")) and "ORG" or None   855    856     def can_schedule(self, freebusy, periods):   857    858         """   859         Indicate whether within 'freebusy' the given 'periods' can be scheduled.   860         """   861    862         return freebusy.can_schedule(periods, self.uid, self.recurrenceid)   863    864     def have_new_object(self, strict=True):   865    866         """   867         Return whether the current object is new to the current user.   868    869         If 'strict' is specified and is a false value, the DTSTAMP test will be   870         ignored. This is useful in handling responses from attendees from   871         clients (like Claws Mail) that erase time information from DTSTAMP and   872         make it invalid.   873         """   874    875         obj = self.get_stored_object_version()   876    877         # If found, compare SEQUENCE and potentially DTSTAMP.   878    879         if obj:   880             sequence = obj.get_value("SEQUENCE")   881             dtstamp = obj.get_value("DTSTAMP")   882    883             # If the request refers to an older version of the object, ignore   884             # it.   885    886             return is_new_object(sequence, self.sequence, dtstamp, self.dtstamp, not strict)   887    888         return True   889    890     def possibly_recurring_indefinitely(self):   891    892         "Return whether the object recurs indefinitely."   893    894         # Obtain the stored object to make sure that recurrence information   895         # is not being ignored. This might happen if a client sends a   896         # cancellation without the complete set of properties, for instance.   897    898         return self.obj.possibly_recurring_indefinitely() or \   899                self.get_stored_object_version() and \   900                self.get_stored_object_version().possibly_recurring_indefinitely()   901    902     # Constraint application on event periods.   903    904     def check_object(self):   905    906         "Check the object against any scheduling constraints."   907    908         permitted_values = self.get_permitted_values()   909         if not permitted_values:   910             return None   911    912         invalid = []   913    914         for period in self.obj.get_periods(self.get_tzid()):   915             errors = period.check_permitted(permitted_values)   916             if errors:   917                 start_errors, end_errors = errors   918                 invalid.append((period.origin, start_errors, end_errors))   919    920         return invalid   921    922     def correct_object(self):   923    924         "Correct the object according to any scheduling constraints."   925    926         permitted_values = self.get_permitted_values()   927         return permitted_values and self.obj.correct_object(self.get_tzid(), permitted_values)   928    929     def correct_period(self, period):   930    931         "Correct 'period' according to any scheduling constraints."   932    933         permitted_values = self.get_permitted_values()   934         if not permitted_values:   935             return period   936         else:   937             return period.get_corrected(permitted_values)   938    939     # Object retrieval.   940    941     def get_stored_object_version(self):   942    943         """   944         Return the stored object to which the current object refers for the   945         current user.   946         """   947    948         return self.get_stored_object(self.uid, self.recurrenceid)   949    950     def get_definitive_object(self, as_organiser):   951    952         """   953         Return an object considered definitive for the current transaction,   954         using 'as_organiser' to select the current transaction's object if   955         false, or selecting a stored object if true.   956         """   957    958         return not as_organiser and self.obj or self.get_stored_object_version()   959    960     def get_parent_object(self):   961    962         """   963         Return the parent object to which the current object refers for the   964         current user.   965         """   966    967         return self.recurrenceid and self.get_stored_object(self.uid, None) or None   968    969     def revert_cancellations(self, periods):   970    971         """   972         Restore cancelled recurrences corresponding to any of the given   973         'periods'.   974         """   975    976         for recurrenceid in self.store.get_cancelled_recurrences(self.user, self.uid):   977             obj = self.get_stored_object(self.uid, recurrenceid, "cancellations")   978             if set(self.get_periods(obj)).intersection(periods):   979                 self.store.remove_cancellation(self.user, self.uid, recurrenceid)   980    981     # Convenience methods for modifying free/busy collections.   982    983     def get_recurrence_start_point(self, recurrenceid):   984    985         "Get 'recurrenceid' in a form suitable for matching free/busy entries."   986    987         return self.obj.get_recurrence_start_point(recurrenceid, self.get_tzid())   988    989     def remove_from_freebusy(self, freebusy):   990    991         "Remove this event from the given 'freebusy' collection."   992    993         removed = freebusy.remove_event_periods(self.uid, self.recurrenceid)   994         if not removed and self.recurrenceid:   995             return freebusy.remove_affected_period(self.uid, self.get_recurrence_start_point(self.recurrenceid))   996         else:   997             return removed   998    999     def remove_freebusy_for_recurrences(self, freebusy, recurrenceids=None):  1000   1001         """  1002         Remove from 'freebusy' any original recurrence from parent free/busy  1003         details for the current object, if the current object is a specific  1004         additional recurrence. Otherwise, remove all additional recurrence  1005         information corresponding to 'recurrenceids', or if omitted, all  1006         recurrences.  1007         """  1008   1009         if self.recurrenceid:  1010             recurrenceid = self.get_recurrence_start_point(self.recurrenceid)  1011             freebusy.remove_affected_period(self.uid, recurrenceid)  1012         else:  1013             # Remove obsolete recurrence periods.  1014   1015             freebusy.remove_additional_periods(self.uid, recurrenceids)  1016   1017             # Remove original periods affected by additional recurrences.  1018   1019             if recurrenceids:  1020                 for recurrenceid in recurrenceids:  1021                     recurrenceid = self.get_recurrence_start_point(recurrenceid)  1022                     freebusy.remove_affected_period(self.uid, recurrenceid)  1023   1024     def update_freebusy(self, freebusy, user, as_organiser, offer=False):  1025   1026         """  1027         Update the 'freebusy' collection for this event with the periods and  1028         transparency associated with the current object, subject to the 'user'  1029         identity and the attendance details provided for them, indicating  1030         whether the update is being done 'as_organiser' (for the organiser of  1031         an event) or not.  1032   1033         If 'offer' is set to a true value, any free/busy updates will be tagged  1034         with an expiry time.  1035         """  1036   1037         # Obtain the stored object if the current object is not issued by the  1038         # organiser. Attendees do not have the opportunity to redefine the  1039         # periods.  1040   1041         obj = self.get_definitive_object(as_organiser)  1042         if not obj:  1043             return  1044   1045         # Obtain the affected periods.  1046   1047         periods = self.get_periods(obj)  1048   1049         # Define an overriding transparency, the indicated event transparency,  1050         # or the default transparency for the free/busy entry.  1051   1052         transp = self.get_overriding_transparency(user, as_organiser) or \  1053                  obj.get_value("TRANSP") or \  1054                  "OPAQUE"  1055   1056         # Calculate any expiry time. If no offer period is defined, do not  1057         # record the offer periods.  1058   1059         if offer:  1060             offer_period = self.get_offer_period()  1061             if offer_period:  1062                 expires = get_timestamp(offer_period)  1063             else:  1064                 return  1065         else:  1066             expires = None  1067   1068         # Perform the low-level update.  1069   1070         Client.update_freebusy(self, freebusy, periods, transp,  1071             self.uid, self.recurrenceid,  1072             obj.get_value("SUMMARY"),  1073             get_uri(obj.get_value("ORGANIZER")),  1074             expires)  1075   1076     def update_freebusy_for_participant(self, freebusy, user, for_organiser=False,  1077                                         updating_other=False, offer=False):  1078   1079         """  1080         Update the 'freebusy' collection for the given 'user', indicating  1081         whether the update is 'for_organiser' (being done for the organiser of  1082         an event) or not, and whether it is 'updating_other' (meaning another  1083         user's details).  1084   1085         If 'offer' is set to a true value, any free/busy updates will be tagged  1086         with an expiry time.  1087         """  1088   1089         # Record in the free/busy details unless a non-participating attendee.  1090         # Remove periods for non-participating attendees.  1091   1092         if offer or self.is_participating(user, for_organiser and not updating_other):  1093             self.update_freebusy(freebusy, user,  1094                 for_organiser and not updating_other or  1095                 not for_organiser and updating_other,  1096                 offer  1097                 )  1098         else:  1099             self.remove_from_freebusy(freebusy)  1100   1101     def remove_freebusy_for_participant(self, freebusy, user, for_organiser=False,  1102                                         updating_other=False):  1103   1104         """  1105         Remove details from the 'freebusy' collection for the given 'user',  1106         indicating whether the modification is 'for_organiser' (being done for  1107         the organiser of an event) or not, and whether it is 'updating_other'  1108         (meaning another user's details).  1109         """  1110   1111         # Remove from the free/busy details if a specified attendee.  1112   1113         if self.is_participating(user, for_organiser and not updating_other):  1114             self.remove_from_freebusy(freebusy)  1115   1116     # Convenience methods for updating stored free/busy information received  1117     # from other users.  1118   1119     def update_freebusy_from_participant(self, user, for_organiser, fn=None):  1120   1121         """  1122         For the current user, record the free/busy information for another  1123         'user', indicating whether the update is 'for_organiser' or not, thus  1124         maintaining a separate record of their free/busy details.  1125         """  1126   1127         fn = fn or self.update_freebusy_for_participant  1128   1129         # A user does not store free/busy information for themself as another  1130         # party.  1131   1132         if user == self.user:  1133             return  1134   1135         self.acquire_lock()  1136         try:  1137             freebusy = self.store.get_freebusy_for_other(self.user, user)  1138             fn(freebusy, user, for_organiser, True)  1139   1140             # Tidy up any obsolete recurrences.  1141   1142             self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))  1143             self.store.set_freebusy_for_other(self.user, freebusy, user)  1144   1145         finally:  1146             self.release_lock()  1147   1148     def update_freebusy_from_organiser(self, organiser):  1149   1150         "For the current user, record free/busy information from 'organiser'."  1151   1152         self.update_freebusy_from_participant(organiser, True)  1153   1154     def update_freebusy_from_attendees(self, attendees):  1155   1156         "For the current user, record free/busy information from 'attendees'."  1157   1158         obj = self.get_stored_object_version()  1159   1160         if not obj or not self.have_new_object():  1161             return  1162   1163         # Filter out unrecognised attendees.  1164   1165         attendees = set(attendees).intersection(uri_values(obj.get_values("ATTENDEE")))  1166   1167         for attendee in attendees:  1168             self.update_freebusy_from_participant(attendee, False)  1169   1170     def remove_freebusy_from_organiser(self, organiser):  1171   1172         "For the current user, remove free/busy information from 'organiser'."  1173   1174         self.update_freebusy_from_participant(organiser, True, self.remove_freebusy_for_participant)  1175   1176     def remove_freebusy_from_attendees(self, attendees):  1177   1178         "For the current user, remove free/busy information from 'attendees'."  1179   1180         for attendee in attendees.keys():  1181             self.update_freebusy_from_participant(attendee, False, self.remove_freebusy_for_participant)  1182   1183     # Convenience methods for updating free/busy details at the event level.  1184   1185     def update_event_in_freebusy(self, for_organiser=True):  1186   1187         """  1188         Update free/busy information when handling an object, doing so for the  1189         organiser of an event if 'for_organiser' is set to a true value.  1190         """  1191   1192         freebusy = self.store.get_freebusy(self.user)  1193   1194         # Obtain the attendance attributes for this user, if available.  1195   1196         self.update_freebusy_for_participant(freebusy, self.user, for_organiser)  1197   1198         # Remove original recurrence details replaced by additional  1199         # recurrences, as well as obsolete additional recurrences.  1200   1201         self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))  1202         self.store.set_freebusy(self.user, freebusy)  1203   1204         if self.publisher and self.is_sharing() and self.is_publishing():  1205             self.publisher.set_freebusy(self.user, freebusy)  1206   1207         # Update free/busy provider information if the event may recur  1208         # indefinitely.  1209   1210         if self.possibly_recurring_indefinitely():  1211             self.store.append_freebusy_provider(self.user, self.obj)  1212   1213         return True  1214   1215     def remove_event_from_freebusy(self):  1216   1217         "Remove free/busy information when handling an object."  1218   1219         freebusy = self.store.get_freebusy(self.user)  1220   1221         self.remove_from_freebusy(freebusy)  1222         self.remove_freebusy_for_recurrences(freebusy)  1223         self.store.set_freebusy(self.user, freebusy)  1224   1225         if self.publisher and self.is_sharing() and self.is_publishing():  1226             self.publisher.set_freebusy(self.user, freebusy)  1227   1228         # Update free/busy provider information if the event may recur  1229         # indefinitely.  1230   1231         if self.possibly_recurring_indefinitely():  1232             self.store.remove_freebusy_provider(self.user, self.obj)  1233   1234     def update_event_in_freebusy_offers(self):  1235   1236         "Update free/busy offers when handling an object."  1237   1238         freebusy = self.store.get_freebusy_offers(self.user)  1239   1240         # Obtain the attendance attributes for this user, if available.  1241   1242         self.update_freebusy_for_participant(freebusy, self.user, offer=True)  1243   1244         # Remove original recurrence details replaced by additional  1245         # recurrences, as well as obsolete additional recurrences.  1246   1247         self.remove_freebusy_for_recurrences(freebusy, self.store.get_recurrences(self.user, self.uid))  1248         self.store.set_freebusy_offers(self.user, freebusy)  1249   1250         return True  1251   1252     def remove_event_from_freebusy_offers(self):  1253   1254         "Remove free/busy offers when handling an object."  1255   1256         freebusy = self.store.get_freebusy_offers(self.user)  1257   1258         self.remove_from_freebusy(freebusy)  1259         self.remove_freebusy_for_recurrences(freebusy)  1260         self.store.set_freebusy_offers(self.user, freebusy)  1261   1262         return True  1263   1264     # Convenience methods for removing counter-proposals and updating the  1265     # request queue.  1266   1267     def remove_request(self):  1268         return self.store.dequeue_request(self.user, self.uid, self.recurrenceid)  1269   1270     def remove_event(self):  1271         return self.store.remove_event(self.user, self.uid, self.recurrenceid)  1272   1273     def remove_counter(self, attendee):  1274         self.remove_counters([attendee])  1275   1276     def remove_counters(self, attendees):  1277         for attendee in attendees:  1278             self.store.remove_counter(self.user, attendee, self.uid, self.recurrenceid)  1279   1280         if not self.store.get_counters(self.user, self.uid, self.recurrenceid):  1281             self.store.dequeue_request(self.user, self.uid, self.recurrenceid)  1282   1283 # vim: tabstop=4 expandtab shiftwidth=4