imip-agent

imiptools/client.py

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