imip-agent

imiptools/client.py

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