imip-agent

imiptools/client.py

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