imip-agent

imiptools/client.py

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