imip-agent

imip_manager.py

358:3b1c12b25108
2015-02-28 Paul Boddie Added docstrings. recurring-events
     1 #!/usr/bin/env python     2      3 """     4 A Web interface to a user's calendar.     5      6 Copyright (C) 2014, 2015 Paul Boddie <paul@boddie.org.uk>     7      8 This program is free software; you can redistribute it and/or modify it under     9 the terms of the GNU General Public License as published by the Free Software    10 Foundation; either version 3 of the License, or (at your option) any later    11 version.    12     13 This program is distributed in the hope that it will be useful, but WITHOUT    14 ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS    15 FOR A PARTICULAR PURPOSE.  See the GNU General Public License for more    16 details.    17     18 You should have received a copy of the GNU General Public License along with    19 this program.  If not, see <http://www.gnu.org/licenses/>.    20 """    21     22 # Edit this path to refer to the location of the imiptools libraries, if    23 # necessary.    24     25 LIBRARY_PATH = "/var/lib/imip-agent"    26     27 from datetime import date, datetime, timedelta    28 import babel.dates    29 import cgi, os, sys    30     31 sys.path.append(LIBRARY_PATH)    32     33 from imiptools.content import Handler    34 from imiptools.data import get_address, get_uri, make_freebusy, Object, to_part, \    35                            uri_dict, uri_item, uri_items, uri_values    36 from imiptools.dates import format_datetime, format_time, get_date, get_datetime, \    37                             get_datetime_item, get_default_timezone, \    38                             get_end_of_day, get_start_of_day, get_start_of_next_day, \    39                             get_timestamp, ends_on_same_day, to_timezone    40 from imiptools.mail import Messenger    41 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \    42                              convert_periods, get_freebusy_details, \    43                              get_scale, have_conflict, get_slots, get_spans, \    44                              partition_by_day, remove_from_freebusy, update_freebusy, \    45                              _update_freebusy    46 from imiptools.profile import Preferences    47 import imip_store    48 import markup    49     50 getenv = os.environ.get    51 setenv = os.environ.__setitem__    52     53 class CGIEnvironment:    54     55     "A CGI-compatible environment."    56     57     def __init__(self, charset=None):    58         self.charset = charset    59         self.args = None    60         self.method = None    61         self.path = None    62         self.path_info = None    63         self.user = None    64     65     def get_args(self):    66         if self.args is None:    67             if self.get_method() != "POST":    68                 setenv("QUERY_STRING", "")    69             args = cgi.parse(keep_blank_values=True)    70     71             if not self.charset:    72                 self.args = args    73             else:    74                 self.args = {}    75                 for key, values in args.items():    76                     self.args[key] = [unicode(value, self.charset) for value in values]    77     78         return self.args    79     80     def get_method(self):    81         if self.method is None:    82             self.method = getenv("REQUEST_METHOD") or "GET"    83         return self.method    84     85     def get_path(self):    86         if self.path is None:    87             self.path = getenv("SCRIPT_NAME") or ""    88         return self.path    89     90     def get_path_info(self):    91         if self.path_info is None:    92             self.path_info = getenv("PATH_INFO") or ""    93         return self.path_info    94     95     def get_user(self):    96         if self.user is None:    97             self.user = getenv("REMOTE_USER") or ""    98         return self.user    99    100     def get_output(self):   101         return sys.stdout   102    103     def get_url(self):   104         path = self.get_path()   105         path_info = self.get_path_info()   106         return "%s%s" % (path.rstrip("/"), path_info)   107    108     def new_url(self, path_info):   109         path = self.get_path()   110         return "%s/%s" % (path.rstrip("/"), path_info.lstrip("/"))   111    112 class Common:   113    114     "Common handler and manager methods."   115    116     def __init__(self, user):   117         self.user = user   118         self.preferences = None   119    120     def get_preferences(self):   121         if not self.preferences:   122             self.preferences = Preferences(self.user)   123         return self.preferences   124    125     def get_tzid(self):   126         prefs = self.get_preferences()   127         return prefs.get("TZID") or get_default_timezone()   128    129 class ManagerHandler(Handler, Common):   130    131     """   132     A content handler for use by the manager, as opposed to operating within the   133     mail processing pipeline.   134     """   135    136     def __init__(self, obj, user, messenger):   137         Handler.__init__(self, messenger=messenger)   138         Common.__init__(self, user)   139    140         self.set_object(obj)   141    142     # Communication methods.   143    144     def send_message(self, method, sender, for_organiser):   145    146         """   147         Create a full calendar object employing the given 'method', and send it   148         to the appropriate recipients, also sending a copy to the 'sender'. The   149         'for_organiser' value indicates whether the organiser is sending this   150         message.   151         """   152    153         parts = [self.obj.to_part(method)]   154    155         # As organiser, send an invitation to attendees, excluding oneself if   156         # also attending. The updated event will be saved by the outgoing   157         # handler.   158    159         organiser = get_uri(self.obj.get_value("ORGANIZER"))   160         attendees = uri_values(self.obj.get_values("ATTENDEE"))   161    162         if for_organiser:   163             recipients = [get_address(attendee) for attendee in attendees if attendee != self.user]   164         else:   165             recipients = [get_address(organiser)]   166    167         # Bundle free/busy information if appropriate.   168    169         preferences = Preferences(self.user)   170    171         if preferences.get("freebusy_sharing") == "share" and \   172            preferences.get("freebusy_bundling") == "always":   173    174             # Invent a unique identifier.   175    176             utcnow = get_timestamp()   177             uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   178    179             freebusy = self.store.get_freebusy(self.user)   180    181             # Replace the non-updated free/busy details for this event with   182             # newer details (since the outgoing handler updates this user's   183             # free/busy details).   184    185             tzid = self.get_tzid()   186    187             _update_freebusy(freebusy, self.obj.get_periods_for_freebusy(tzid),   188                 self.obj.get_value("TRANSP") or "OPAQUE",   189                 self.uid, self.recurrenceid)   190    191             user_attr = self.messenger and self.messenger.sender != get_address(self.user) and \   192                 {"SENT-BY" : get_uri(self.messenger.sender)} or {}   193    194             parts.append(to_part("PUBLISH", [   195                 make_freebusy(freebusy, uid, self.user, user_attr)   196                 ]))   197    198         message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)   199         self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)   200    201     # Action methods.   202    203     def process_received_request(self, update=False):   204    205         """   206         Process the current request for the given 'user'. Return whether any   207         action was taken.   208    209         If 'update' is given, the sequence number will be incremented in order   210         to override any previous response.   211         """   212    213         # Reply only on behalf of this user.   214    215         for attendee, attendee_attr in uri_items(self.obj.get_items("ATTENDEE")):   216    217             if attendee == self.user:   218                 if attendee_attr.has_key("RSVP"):   219                     del attendee_attr["RSVP"]   220                 if self.messenger and self.messenger.sender != get_address(attendee):   221                     attendee_attr["SENT-BY"] = get_uri(self.messenger.sender)   222                 self.obj["ATTENDEE"] = [(attendee, attendee_attr)]   223    224                 self.update_dtstamp()   225                 self.set_sequence(update)   226    227                 self.send_message("REPLY", get_address(attendee), for_organiser=False)   228    229                 return True   230    231         return False   232    233     def process_created_request(self, method, update=False, removed=None, added=None):   234    235         """   236         Process the current request for the given 'user', sending a created   237         request of the given 'method' to attendees. Return whether any action   238         was taken.   239    240         If 'update' is given, the sequence number will be incremented in order   241         to override any previous message.   242    243         If 'removed' is specified, a list of participants to be removed is   244         provided.   245    246         If 'added' is specified, a list of participants to be added is provided.   247         """   248    249         organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER"))   250    251         if self.messenger and self.messenger.sender != get_address(organiser):   252             organiser_attr["SENT-BY"] = get_uri(self.messenger.sender)   253    254         to_cancel = []   255    256         if added or removed:   257             attendees = uri_items(self.obj.get_items("ATTENDEE") or [])   258    259             if removed:   260                 remaining = []   261    262                 for attendee, attendee_attr in attendees:   263                     if attendee in removed:   264                         to_cancel.append((attendee, attendee_attr))   265                     else:   266                         remaining.append((attendee, attendee_attr))   267    268                 attendees = remaining   269    270             if added:   271                 for attendee in added:   272                     attendees.append((attendee, {"PARTSTAT" : "NEEDS-ACTION", "RSVP" : "TRUE"}))   273    274             self.obj["ATTENDEE"] = attendees   275    276         self.update_dtstamp()   277         self.set_sequence(update)   278    279         self.send_message(method, get_address(organiser), for_organiser=True)   280    281         # When cancelling, replace the attendees with those for whom the event   282         # is now cancelled.   283    284         if to_cancel:   285             self.obj["ATTENDEE"] = to_cancel   286             self.send_message("CANCEL", get_address(organiser), for_organiser=True)   287    288             # Just in case more work is done with this event, the attendees are   289             # now restored.   290    291             self.obj["ATTENDEE"] = remaining   292    293         return True   294    295 class Manager(Common):   296    297     "A simple manager application."   298    299     def __init__(self, messenger=None):   300         self.messenger = messenger or Messenger()   301         self.encoding = "utf-8"   302         self.env = CGIEnvironment(self.encoding)   303    304         user = self.env.get_user()   305         Common.__init__(self, user and get_uri(user) or None)   306    307         self.locale = None   308         self.requests = None   309    310         self.out = self.env.get_output()   311         self.page = markup.page()   312    313         self.store = imip_store.FileStore()   314         self.objects = {}   315    316         try:   317             self.publisher = imip_store.FilePublisher()   318         except OSError:   319             self.publisher = None   320    321     def _get_identifiers(self, path_info):   322         parts = path_info.lstrip("/").split("/")   323         if len(parts) == 1:   324             return parts[0], None   325         else:   326             return parts[:2]   327    328     def _get_object(self, uid, recurrenceid=None):   329         if self.objects.has_key((uid, recurrenceid)):   330             return self.objects[(uid, recurrenceid)]   331    332         fragment = uid and self.store.get_event(self.user, uid, recurrenceid) or None   333         obj = self.objects[(uid, recurrenceid)] = fragment and Object(fragment)   334         return obj   335    336     def _get_requests(self):   337         if self.requests is None:   338             cancellations = self.store.get_cancellations(self.user)   339             requests = set(self.store.get_requests(self.user))   340             self.requests = requests.difference(cancellations)   341         return self.requests   342    343     def _get_request_summary(self):   344         summary = []   345         for uid, recurrenceid in self._get_requests():   346             obj = self._get_object(uid, recurrenceid)   347             if obj:   348                 for start, end in obj.get_periods_for_freebusy(self.get_tzid()):   349                     summary.append((start, end, uid, obj.get_value("TRANSP"), recurrenceid))   350         return summary   351    352     # Preference methods.   353    354     def get_user_locale(self):   355         if not self.locale:   356             self.locale = self.get_preferences().get("LANG", "en")   357         return self.locale   358    359     # Prettyprinting of dates and times.   360    361     def format_date(self, dt, format):   362         return self._format_datetime(babel.dates.format_date, dt, format)   363    364     def format_time(self, dt, format):   365         return self._format_datetime(babel.dates.format_time, dt, format)   366    367     def format_datetime(self, dt, format):   368         return self._format_datetime(   369             isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,   370             dt, format)   371    372     def _format_datetime(self, fn, dt, format):   373         return fn(dt, format=format, locale=self.get_user_locale())   374    375     # Data management methods.   376    377     def remove_request(self, uid, recurrenceid=None):   378         return self.store.dequeue_request(self.user, uid, recurrenceid)   379    380     def remove_event(self, uid, recurrenceid=None):   381         return self.store.remove_event(self.user, uid, recurrenceid)   382    383     def update_freebusy(self, uid, recurrenceid, obj):   384         tzid = self.get_tzid()   385         freebusy = self.store.get_freebusy(self.user)   386         update_freebusy(freebusy, self.user, obj.get_periods_for_freebusy(tzid),   387             obj.get_value("TRANSP"), uid, recurrenceid, self.store)   388    389     def remove_from_freebusy(self, uid, recurrenceid=None):   390         freebusy = self.store.get_freebusy(self.user)   391         remove_from_freebusy(freebusy, self.user, uid, recurrenceid, self.store)   392    393     # Presentation methods.   394    395     def new_page(self, title):   396         self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))   397    398     def status(self, code, message):   399         self.header("Status", "%s %s" % (code, message))   400    401     def header(self, header, value):   402         print >>self.out, "%s: %s" % (header, value)   403    404     def no_user(self):   405         self.status(403, "Forbidden")   406         self.new_page(title="Forbidden")   407         self.page.p("You are not logged in and thus cannot access scheduling requests.")   408    409     def no_page(self):   410         self.status(404, "Not Found")   411         self.new_page(title="Not Found")   412         self.page.p("No page is provided at the given address.")   413    414     def redirect(self, url):   415         self.status(302, "Redirect")   416         self.header("Location", url)   417         self.new_page(title="Redirect")   418         self.page.p("Redirecting to: %s" % url)   419    420     def link_to(self, uid, recurrenceid=None):   421         if recurrenceid:   422             return self.env.new_url("/".join([uid, recurrenceid]))   423         else:   424             return self.env.new_url(uid)   425    426     # Request logic methods.   427    428     def handle_newevent(self):   429    430         """   431         Handle any new event operation, creating a new event and redirecting to   432         the event page for further activity.   433         """   434    435         # Handle a submitted form.   436    437         args = self.env.get_args()   438    439         if not args.has_key("newevent"):   440             return   441    442         # Create a new event using the available information.   443    444         slots = args.get("slot", [])   445         participants = args.get("participants", [])   446    447         if not slots:   448             return   449    450         # Obtain the user's timezone.   451    452         tzid = self.get_tzid()   453    454         # Coalesce the selected slots.   455    456         slots.sort()   457         coalesced = []   458         last = None   459    460         for slot in slots:   461             start, end = slot.split("-")   462             start = get_datetime(start, {"TZID" : tzid})   463             end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)   464    465             if last:   466                 last_start, last_end = last   467    468                 # Merge adjacent dates and datetimes.   469    470                 if start == last_end or get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):   471                     last = last_start, end   472                     continue   473    474                 # Handle datetimes within dates.   475                 # Datetime periods are within single days and are therefore   476                 # discarded.   477    478                 elif get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):   479                     continue   480    481                 # Add separate dates and datetimes.   482    483                 else:   484                     coalesced.append(last)   485    486             last = start, end   487    488         if last:   489             coalesced.append(last)   490    491         # Invent a unique identifier.   492    493         utcnow = get_timestamp()   494         uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   495    496         # Define a single occurrence if only one coalesced slot exists.   497         # Otherwise, many occurrences are defined.   498    499         for i, (start, end) in enumerate(coalesced):   500             this_uid = "%s-%s" % (uid, i)   501    502             start_value, start_attr = get_datetime_item(start, tzid)   503             end_value, end_attr = get_datetime_item(end, tzid)   504    505             # Create a calendar object and store it as a request.   506    507             record = []   508             rwrite = record.append   509    510             rwrite(("UID", {}, this_uid))   511             rwrite(("SUMMARY", {}, "New event at %s" % utcnow))   512             rwrite(("DTSTAMP", {}, utcnow))   513             rwrite(("DTSTART", start_attr, start_value))   514             rwrite(("DTEND", end_attr, end_value))   515             rwrite(("ORGANIZER", {}, self.user))   516    517             for participant in participants:   518                 if not participant:   519                     continue   520                 participant = get_uri(participant)   521                 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))   522    523             node = ("VEVENT", {}, record)   524    525             self.store.set_event(self.user, this_uid, node=node)   526             self.store.queue_request(self.user, this_uid)   527    528         # Redirect to the object (or the first of the objects), where instead of   529         # attendee controls, there will be organiser controls.   530    531         self.redirect(self.link_to("%s-0" % uid))   532    533     def handle_request(self, uid, obj):   534    535         """   536         Handle actions involving the given 'uid' and 'obj' object, returning an   537         error if one occurred, or None if the request was successfully handled.   538         """   539    540         # Handle a submitted form.   541    542         args = self.env.get_args()   543    544         # Get the possible actions.   545    546         reply = args.has_key("reply")   547         discard = args.has_key("discard")   548         invite = args.has_key("invite")   549         cancel = args.has_key("cancel")   550         save = args.has_key("save")   551    552         have_action = reply or discard or invite or cancel or save   553    554         if not have_action:   555             return ["action"]   556    557         # Update the object.   558    559         if args.has_key("summary"):   560             obj["SUMMARY"] = [(args["summary"][0], {})]   561    562         organisers = uri_dict(obj.get_value_map("ORGANIZER"))   563         attendees = uri_dict(obj.get_value_map("ATTENDEE"))   564    565         if args.has_key("partstat"):   566             for d in attendees, organisers:   567                 if d.has_key(self.user):   568                     d[self.user]["PARTSTAT"] = args["partstat"][0]   569                     if d[self.user].has_key("RSVP"):   570                         del d[self.user]["RSVP"]   571    572         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   573    574         # Obtain the user's timezone and process datetime values.   575    576         update = False   577    578         if is_organiser:   579             dtend_enabled = args.get("dtend-control", [None])[0] == "enable"   580             dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable"   581    582             t = self.handle_date_controls("dtstart", dttimes_enabled)   583             if t:   584                 dtstart, attr = t   585                 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update   586             else:   587                 return ["dtstart"]   588    589             # Handle specified end datetimes.   590    591             if dtend_enabled:   592                 t = self.handle_date_controls("dtend", dttimes_enabled)   593                 if t:   594                     dtend, attr = t   595    596                     # Convert end dates to iCalendar "next day" dates.   597    598                     if not isinstance(dtend, datetime):   599                         dtend += timedelta(1)   600                     update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update   601                 else:   602                     return ["dtend"]   603    604             # Otherwise, treat the end date as the start date. Datetimes are   605             # handled by making the event occupy the rest of the day.   606    607             else:   608                 dtend = dtstart + timedelta(1)   609                 if isinstance(dtstart, datetime):   610                     dtend = get_start_of_day(dtend, attr["TZID"])   611                 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update   612    613             if dtstart >= dtend:   614                 return ["dtstart", "dtend"]   615    616         # Obtain any participants to be added or removed.   617    618         removed = args.get("remove")   619         added = args.get("added")   620    621         # Process any action.   622    623         handled = True   624    625         if reply or invite or cancel:   626    627             handler = ManagerHandler(obj, self.user, self.messenger)   628    629             # Process the object and remove it from the list of requests.   630    631             if reply and handler.process_received_request(update) or \   632                is_organiser and (invite or cancel) and \   633                handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):   634    635                 self.remove_request(uid)   636    637         # Save single user events.   638    639         elif save:   640             self.store.set_event(self.user, uid, node=obj.to_node())   641             self.update_freebusy(uid, None, obj=obj)   642             self.remove_request(uid)   643    644         # Remove the request and the object.   645    646         elif discard:   647             self.remove_from_freebusy(uid)   648             self.remove_event(uid)   649             self.remove_request(uid)   650    651         else:   652             handled = False   653    654         # Upon handling an action, redirect to the main page.   655    656         if handled:   657             self.redirect(self.env.get_path())   658    659         return None   660    661     def handle_date_controls(self, name, with_time=True):   662    663         """   664         Handle date control information for fields starting with 'name',   665         returning a (datetime, attr) tuple or None if the fields cannot be used   666         to construct a datetime object.   667         """   668    669         args = self.env.get_args()   670    671         if args.has_key("%s-date" % name):   672             date = args["%s-date" % name][0]   673    674             if with_time:   675                 hour = args.get("%s-hour" % name, [None])[0]   676                 minute = args.get("%s-minute" % name, [None])[0]   677                 second = args.get("%s-second" % name, [None])[0]   678                 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0]   679    680                 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or ""   681                 value = "%s%s" % (date, time)   682                 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"}   683                 dt = get_datetime(value, attr)   684             else:   685                 attr = {"VALUE" : "DATE"}   686                 dt = get_datetime(date)   687    688             if dt:   689                 return dt, attr   690    691         return None   692    693     def set_datetime_in_object(self, dt, tzid, property, obj):   694    695         """   696         Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether   697         an update has occurred.   698         """   699    700         if dt:   701             old_value = obj.get_value(property)   702             obj[property] = [get_datetime_item(dt, tzid)]   703             return format_datetime(dt) != old_value   704    705         return False   706    707     # Page fragment methods.   708    709     def show_request_controls(self, obj):   710    711         "Show form controls for a request concerning 'obj'."   712    713         page = self.page   714         args = self.env.get_args()   715    716         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   717    718         attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", []))   719         is_attendee = self.user in attendees   720    721         is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()   722    723         have_other_attendees = len(attendees) > (is_attendee and 1 or 0)   724    725         # Show appropriate options depending on the role of the user.   726    727         if is_attendee and not is_organiser:   728             page.p("An action is required for this request:")   729    730             page.p()   731             page.input(name="reply", type="submit", value="Reply")   732             page.add(" ")   733             page.input(name="discard", type="submit", value="Discard")   734             page.p.close()   735    736         if is_organiser:   737             if have_other_attendees:   738                 page.p("As organiser, you can perform the following:")   739    740                 page.p()   741                 page.input(name="invite", type="submit", value="Invite")   742                 page.add(" ")   743                 if is_request:   744                     page.input(name="discard", type="submit", value="Discard")   745                 else:   746                     page.input(name="cancel", type="submit", value="Cancel")   747                 page.p.close()   748             else:   749                 page.p("As attendee, you can perform the following:")   750    751                 page.p()   752                 page.input(name="save", type="submit", value="Save")   753                 page.add(" ")   754                 page.input(name="discard", type="submit", value="Discard")   755                 page.p.close()   756    757     property_items = [   758         ("SUMMARY", "Summary"),   759         ("DTSTART", "Start"),   760         ("DTEND", "End"),   761         ("ORGANIZER", "Organiser"),   762         ("ATTENDEE", "Attendee"),   763         ]   764    765     partstat_items = [   766         ("NEEDS-ACTION", "Not confirmed"),   767         ("ACCEPTED", "Attending"),   768         ("TENTATIVE", "Tentatively attending"),   769         ("DECLINED", "Not attending"),   770         ("DELEGATED", "Delegated"),   771         (None, "Not indicated"),   772         ]   773    774     def show_object_on_page(self, uid, obj, error=None):   775    776         """   777         Show the calendar object with the given 'uid' and representation 'obj'   778         on the current page. If 'error' is given, show a suitable message.   779         """   780    781         page = self.page   782         page.form(method="POST")   783    784         # Obtain the user's timezone.   785    786         tzid = self.get_tzid()   787    788         # Provide controls to change the displayed object.   789    790         args = self.env.get_args()   791    792         # Add or remove new attendees.   793         # This does not affect the stored object.   794    795         existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])   796         new_attendees = args.get("added", [])   797         new_attendee = args.get("attendee", [""])[0]   798    799         if args.has_key("add"):   800             if new_attendee.strip():   801                 new_attendee = get_uri(new_attendee.strip())   802                 if new_attendee not in new_attendees and new_attendee not in existing_attendees:   803                     new_attendees.append(new_attendee)   804                 new_attendee = ""   805    806         if args.has_key("removenew"):   807             removed_attendee = args["removenew"][0]   808             if removed_attendee in new_attendees:   809                 new_attendees.remove(removed_attendee)   810    811         # Configure the start and end datetimes.   812    813         dtend_control = args.get("dtend-control", [None])[0]   814         dttimes_control = args.get("dttimes-control", [None])[0]   815         with_time = dttimes_control == "enable"   816    817         t = self.handle_date_controls("dtstart", with_time)   818         if t:   819             dtstart, dtstart_attr = t   820         else:   821             dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")   822    823         if dtend_control == "enable":   824             t = self.handle_date_controls("dtend", with_time)   825             if t:   826                 dtend, dtend_attr = t   827             else:   828                 dtend, dtend_attr = None, {}   829         elif dtend_control == "disable":   830             dtend, dtend_attr = None, {}   831         else:   832             dtend, dtend_attr = obj.get_datetime_item("DTEND")   833    834         # Change end dates to refer to the actual dates, not the iCalendar   835         # "next day" dates.   836    837         if dtend and not isinstance(dtend, datetime):   838             dtend -= timedelta(1)   839    840         # Show the end datetime controls if already active or if an object needs   841         # them.   842    843         dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend   844         dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime)   845    846         if dtend_enabled:   847             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")   848             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")   849         else:   850             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")   851             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")   852    853         if dttimes_enabled:   854             page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked")   855             page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable")   856         else:   857             page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable")   858             page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked")   859    860         # Provide a summary of the object.   861    862         page.table(class_="object", cellspacing=5, cellpadding=5)   863         page.thead()   864         page.tr()   865         page.th("Event", class_="mainheading", colspan=2)   866         page.tr.close()   867         page.thead.close()   868         page.tbody()   869    870         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   871    872         for name, label in self.property_items:   873             page.tr()   874    875             # Handle datetimes specially.   876    877             if name in ["DTSTART", "DTEND"]:   878                 field = name.lower()   879    880                 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""))   881    882                 # Obtain the datetime.   883    884                 if name == "DTSTART":   885                     dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid)   886    887                 # Where no end datetime exists, use the start datetime as the   888                 # basis of any potential datetime specified if dt-control is   889                 # set.   890    891                 else:   892                     dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid)   893    894                 # Show controls for editing as organiser.   895    896                 if is_organiser:   897                     value = format_datetime(dt)   898    899                     page.td(class_="objectvalue %s" % field)   900                     if name == "DTEND":   901                         page.div(class_="dt disabled")   902                         page.label("Specify end date", for_="dtend-enable", class_="enable")   903                         page.div.close()   904    905                     page.div(class_="dt enabled")   906                     self._show_date_controls(field, value, attr, tzid)   907                     if name == "DTSTART":   908                         page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")   909                         page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable")   910                     elif name == "DTEND":   911                         page.label("End on same day", for_="dtend-disable", class_="disable")   912                     page.div.close()   913    914                     page.td.close()   915    916                 # Show a label as attendee.   917    918                 else:   919                     page.td(self.format_datetime(dt, "full"))   920    921                 page.tr.close()   922    923             # Handle the summary specially.   924    925             elif name == "SUMMARY":   926                 value = args.get("summary", [obj.get_value(name)])[0]   927    928                 page.th(label, class_="objectheading")   929                 page.td()   930                 if is_organiser:   931                     page.input(name="summary", type="text", value=value, size=80)   932                 else:   933                     page.add(value)   934                 page.td.close()   935                 page.tr.close()   936    937             # Handle potentially many values.   938    939             else:   940                 items = obj.get_items(name) or []   941                 rowspan = len(items)   942    943                 if name == "ATTENDEE":   944                     rowspan += len(new_attendees) + 1   945                 elif not items:   946                     continue   947    948                 page.th(label, class_="objectheading", rowspan=rowspan)   949    950                 first = True   951    952                 for i, (value, attr) in enumerate(items):   953                     if not first:   954                         page.tr()   955                     else:   956                         first = False   957    958                     if name in ("ATTENDEE", "ORGANIZER"):   959                         value = get_uri(value)   960    961                         page.td(class_="objectvalue")   962                         page.add(value)   963                         page.add(" ")   964    965                         partstat = attr.get("PARTSTAT")   966                         if value == self.user and (not is_organiser or name == "ORGANIZER"):   967                             self._show_menu("partstat", partstat, self.partstat_items, "partstat")   968                         else:   969                             page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")   970    971                         if is_organiser and name == "ATTENDEE":   972                             if value in args.get("remove", []):   973                                 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")   974                             else:   975                                 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")   976                             page.label("Remove", for_="remove-%d" % i, class_="remove")   977                             page.label("Uninvited", for_="remove-%d" % i, class_="removed")   978    979                     else:   980                         page.td(class_="objectvalue")   981                         page.add(value)   982    983                     page.td.close()   984                     page.tr.close()   985    986                 # Allow more attendees to be specified.   987    988                 if is_organiser and name == "ATTENDEE":   989                     for i, attendee in enumerate(new_attendees):   990                         if not first:   991                             page.tr()   992                         else:   993                             first = False   994    995                         page.td()   996                         page.input(name="added", type="value", value=attendee)   997                         page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove")   998                         page.label("Remove", for_="removenew-%d" % i, class_="remove")   999                         page.td.close()  1000                         page.tr.close()  1001   1002                     if not first:  1003                         page.tr()  1004   1005                     page.td()  1006                     page.input(name="attendee", type="value", value=new_attendee)  1007                     page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add")  1008                     page.label("Add", for_="add-%d" % i, class_="add")  1009                     page.td.close()  1010                     page.tr.close()  1011   1012         page.tbody.close()  1013         page.table.close()  1014   1015         self.show_recurrences(obj)  1016         self.show_conflicting_events(uid, obj)  1017         self.show_request_controls(obj)  1018   1019         page.form.close()  1020   1021     def show_recurrences(self, obj):  1022   1023         "Show recurrences for the object having the given representation 'obj'."  1024   1025         page = self.page  1026   1027         # Obtain any parent object if this object is a specific recurrence.  1028   1029         recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))  1030   1031         if recurrenceid:  1032             obj = self._get_object(obj.get_value("UID"))  1033             if not obj:  1034                 return  1035   1036             page.p("This event modifies a recurring event.")  1037   1038         # Obtain the user's timezone.  1039   1040         tzid = self.get_tzid()  1041   1042         window_size = 100  1043   1044         periods = obj.get_periods(self.get_tzid(), window_size)  1045   1046         if len(periods) == 1:  1047             return  1048   1049         page.p("This event occurs on the following occasions within the next %d days:" % window_size)  1050   1051         page.table(cellspacing=5, cellpadding=5, class_="conflicts")  1052         page.thead()  1053         page.tr()  1054         page.th("Start")  1055         page.th("End")  1056         page.tr.close()  1057         page.thead.close()  1058         page.tbody()  1059   1060         for start, end in periods:  1061             start_utc = format_datetime(to_timezone(start, "UTC"))  1062             css = recurrenceid and start_utc == recurrenceid and "replaced" or ""  1063   1064             page.tr()  1065             page.td(self.format_datetime(start, "long"), class_=css)  1066             page.td(self.format_datetime(end, "long"), class_=css)  1067             page.tr.close()  1068   1069         page.tbody.close()  1070         page.table.close()  1071   1072     def show_conflicting_events(self, uid, obj):  1073   1074         """  1075         Show conflicting events for the object having the given 'uid' and  1076         representation 'obj'.  1077         """  1078   1079         page = self.page  1080   1081         # Obtain the user's timezone.  1082   1083         tzid = self.get_tzid()  1084   1085         dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))  1086         dtend = format_datetime(obj.get_utc_datetime("DTEND"))  1087   1088         # Indicate whether there are conflicting events.  1089   1090         freebusy = self.store.get_freebusy(self.user)  1091   1092         if freebusy:  1093   1094             # Obtain any time zone details from the suggested event.  1095   1096             _dtstart, attr = obj.get_item("DTSTART")  1097             tzid = attr.get("TZID", tzid)  1098   1099             # Show any conflicts.  1100   1101             conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid]  1102   1103             if conflicts:  1104                 page.p("This event conflicts with others:")  1105   1106                 page.table(cellspacing=5, cellpadding=5, class_="conflicts")  1107                 page.thead()  1108                 page.tr()  1109                 page.th("Event")  1110                 page.th("Start")  1111                 page.th("End")  1112                 page.tr.close()  1113                 page.thead.close()  1114                 page.tbody()  1115   1116                 for t in conflicts:  1117                     start, end, found_uid, transp, found_recurrenceid = t[:5]  1118   1119                     # Provide details of any conflicting event.  1120   1121                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")  1122                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")  1123   1124                     page.tr()  1125   1126                     # Show the event summary for the conflicting event.  1127   1128                     page.td()  1129   1130                     found_obj = self._get_object(found_uid, found_recurrenceid)  1131                     if found_obj:  1132                         page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid))  1133                     else:  1134                         page.add("No details available")  1135   1136                     page.td.close()  1137   1138                     page.td(start)  1139                     page.td(end)  1140   1141                     page.tr.close()  1142   1143                 page.tbody.close()  1144                 page.table.close()  1145   1146     def show_requests_on_page(self):  1147   1148         "Show requests for the current user."  1149   1150         # NOTE: This list could be more informative, but it is envisaged that  1151         # NOTE: the requests would be visited directly anyway.  1152   1153         requests = self._get_requests()  1154   1155         self.page.div(id="pending-requests")  1156   1157         if requests:  1158             self.page.p("Pending requests:")  1159   1160             self.page.ul()  1161   1162             for uid, recurrenceid in requests:  1163                 obj = self._get_object(uid, recurrenceid)  1164                 if obj:  1165                     self.page.li()  1166                     self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))  1167                     self.page.li.close()  1168   1169             self.page.ul.close()  1170   1171         else:  1172             self.page.p("There are no pending requests.")  1173   1174         self.page.div.close()  1175   1176     def show_participants_on_page(self):  1177   1178         "Show participants for scheduling purposes."  1179   1180         args = self.env.get_args()  1181         participants = args.get("participants", [])  1182   1183         try:  1184             for name, value in args.items():  1185                 if name.startswith("remove-participant-"):  1186                     i = int(name[len("remove-participant-"):])  1187                     del participants[i]  1188                     break  1189         except ValueError:  1190             pass  1191   1192         # Trim empty participants.  1193   1194         while participants and not participants[-1].strip():  1195             participants.pop()  1196   1197         # Show any specified participants together with controls to remove and  1198         # add participants.  1199   1200         self.page.div(id="participants")  1201   1202         self.page.p("Participants for scheduling:")  1203   1204         for i, participant in enumerate(participants):  1205             self.page.p()  1206             self.page.input(name="participants", type="text", value=participant)  1207             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")  1208             self.page.p.close()  1209   1210         self.page.p()  1211         self.page.input(name="participants", type="text")  1212         self.page.input(name="add-participant", type="submit", value="Add")  1213         self.page.p.close()  1214   1215         self.page.div.close()  1216   1217         return participants  1218   1219     # Full page output methods.  1220   1221     def show_object(self, path_info):  1222   1223         "Show an object request using the given 'path_info' for the current user."  1224   1225         uid, recurrenceid = self._get_identifiers(path_info)  1226         obj = self._get_object(uid, recurrenceid)  1227   1228         if not obj:  1229             return False  1230   1231         error = self.handle_request(uid, obj)  1232   1233         if not error:  1234             return True  1235   1236         self.new_page(title="Event")  1237         self.show_object_on_page(uid, obj, error)  1238   1239         return True  1240   1241     def show_calendar(self):  1242   1243         "Show the calendar for the current user."  1244   1245         handled = self.handle_newevent()  1246   1247         self.new_page(title="Calendar")  1248         page = self.page  1249   1250         # Form controls are used in various places on the calendar page.  1251   1252         page.form(method="POST")  1253   1254         self.show_requests_on_page()  1255         participants = self.show_participants_on_page()  1256   1257         # Show a button for scheduling a new event.  1258   1259         page.p(class_="controls")  1260         page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")  1261         page.input(name="reset", type="submit", value="Clear selections", id="reset")  1262         page.p.close()  1263   1264         # Show controls for hiding empty days and busy slots.  1265         # The positioning of the control, paragraph and table are important here.  1266   1267         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")  1268         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")  1269   1270         page.p(class_="controls")  1271         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")  1272         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")  1273         page.label("Show empty days", for_="showdays", class_="showdays disable")  1274         page.label("Hide empty days", for_="showdays", class_="showdays enable")  1275         page.p.close()  1276   1277         freebusy = self.store.get_freebusy(self.user)  1278   1279         if not freebusy:  1280             page.p("No events scheduled.")  1281             return  1282   1283         # Obtain the user's timezone.  1284   1285         tzid = self.get_tzid()  1286   1287         # Day view: start at the earliest known day and produce days until the  1288         # latest known day, perhaps with expandable sections of empty days.  1289   1290         # Month view: start at the earliest known month and produce months until  1291         # the latest known month, perhaps with expandable sections of empty  1292         # months.  1293   1294         # Details of users to invite to new events could be superimposed on the  1295         # calendar.  1296   1297         # Requests are listed and linked to their tentative positions in the  1298         # calendar. Other participants are also shown.  1299   1300         request_summary = self._get_request_summary()  1301   1302         period_groups = [request_summary, freebusy]  1303         period_group_types = ["request", "freebusy"]  1304         period_group_sources = ["Pending requests", "Your schedule"]  1305   1306         for i, participant in enumerate(participants):  1307             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))  1308             period_group_types.append("freebusy-part%d" % i)  1309             period_group_sources.append(participant)  1310   1311         groups = []  1312         group_columns = []  1313         group_types = period_group_types  1314         group_sources = period_group_sources  1315         all_points = set()  1316   1317         # Obtain time point information for each group of periods.  1318   1319         for periods in period_groups:  1320             periods = convert_periods(periods, tzid)  1321   1322             # Get the time scale with start and end points.  1323   1324             scale = get_scale(periods)  1325   1326             # Get the time slots for the periods.  1327   1328             slots = get_slots(scale)  1329   1330             # Add start of day time points for multi-day periods.  1331   1332             add_day_start_points(slots, tzid)  1333   1334             # Record the slots and all time points employed.  1335   1336             groups.append(slots)  1337             all_points.update([point for point, active in slots])  1338   1339         # Partition the groups into days.  1340   1341         days = {}  1342         partitioned_groups = []  1343         partitioned_group_types = []  1344         partitioned_group_sources = []  1345   1346         for slots, group_type, group_source in zip(groups, group_types, group_sources):  1347   1348             # Propagate time points to all groups of time slots.  1349   1350             add_slots(slots, all_points)  1351   1352             # Count the number of columns employed by the group.  1353   1354             columns = 0  1355   1356             # Partition the time slots by day.  1357   1358             partitioned = {}  1359   1360             for day, day_slots in partition_by_day(slots).items():  1361                 intervals = []  1362                 last = None  1363   1364                 for point, active in day_slots:  1365                     columns = max(columns, len(active))  1366                     if last:  1367                         intervals.append((last, point))  1368                     last = point  1369   1370                 if last:  1371                     intervals.append((last, None))  1372   1373                 if not days.has_key(day):  1374                     days[day] = set()  1375   1376                 # Convert each partition to a mapping from points to active  1377                 # periods.  1378   1379                 partitioned[day] = dict(day_slots)  1380   1381                 # Record the divisions or intervals within each day.  1382   1383                 days[day].update(intervals)  1384   1385             if group_type != "request" or columns:  1386                 group_columns.append(columns)  1387                 partitioned_groups.append(partitioned)  1388                 partitioned_group_types.append(group_type)  1389                 partitioned_group_sources.append(group_source)  1390   1391         # Add empty days.  1392   1393         add_empty_days(days, tzid)  1394   1395         # Show the controls permitting day selection.  1396   1397         self.show_calendar_day_controls(days)  1398   1399         # Show the calendar itself.  1400   1401         page.table(cellspacing=5, cellpadding=5, class_="calendar")  1402         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)  1403         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)  1404         page.table.close()  1405   1406         # End the form region.  1407   1408         page.form.close()  1409   1410     # More page fragment methods.  1411   1412     def show_calendar_day_controls(self, days):  1413   1414         "Show controls for the given 'days' in the calendar."  1415   1416         page = self.page  1417         slots = self.env.get_args().get("slot", [])  1418   1419         for day in days:  1420             value, identifier = self._day_value_and_identifier(day)  1421             self._slot_selector(value, identifier, slots)  1422   1423         # Generate a dynamic stylesheet to allow day selections to colour  1424         # specific days.  1425         # NOTE: The style details need to be coordinated with the static  1426         # NOTE: stylesheet.  1427   1428         page.style(type="text/css")  1429   1430         for day in days:  1431             daystr = format_datetime(day)  1432             page.add("""\  1433 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,  1434 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {  1435     background-color: #5f4;  1436     text-decoration: underline;  1437 }  1438 """ % (daystr, daystr, daystr, daystr))  1439   1440         page.style.close()  1441   1442     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):  1443   1444         """  1445         Show headings for the participants and other scheduling contributors,  1446         defined by 'group_types', 'group_sources' and 'group_columns'.  1447         """  1448   1449         page = self.page  1450   1451         page.colgroup(span=1, id="columns-timeslot")  1452   1453         for group_type, columns in zip(group_types, group_columns):  1454             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)  1455   1456         page.thead()  1457         page.tr()  1458         page.th("", class_="emptyheading")  1459   1460         for group_type, source, columns in zip(group_types, group_sources, group_columns):  1461             page.th(source,  1462                 class_=(group_type == "request" and "requestheading" or "participantheading"),  1463                 colspan=max(columns, 1))  1464   1465         page.tr.close()  1466         page.thead.close()  1467   1468     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):  1469   1470         """  1471         Show calendar days, defined by a collection of 'days', the contributing  1472         period information as 'partitioned_groups' (partitioned by day), the  1473         'partitioned_group_types' indicating the kind of contribution involved,  1474         and the 'group_columns' defining the number of columns in each group.  1475         """  1476   1477         page = self.page  1478   1479         # Determine the number of columns required. Where participants provide  1480         # no columns for events, one still needs to be provided for the  1481         # participant itself.  1482   1483         all_columns = sum([max(columns, 1) for columns in group_columns])  1484   1485         # Determine the days providing time slots.  1486   1487         all_days = days.items()  1488         all_days.sort()  1489   1490         # Produce a heading and time points for each day.  1491   1492         for day, intervals in all_days:  1493             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]  1494             is_empty = True  1495   1496             for slots in groups_for_day:  1497                 if not slots:  1498                     continue  1499   1500                 for active in slots.values():  1501                     if active:  1502                         is_empty = False  1503                         break  1504   1505             page.thead(class_="separator%s" % (is_empty and " empty" or ""))  1506             page.tr()  1507             page.th(class_="dayheading container", colspan=all_columns+1)  1508             self._day_heading(day)  1509             page.th.close()  1510             page.tr.close()  1511             page.thead.close()  1512   1513             page.tbody(class_="points%s" % (is_empty and " empty" or ""))  1514             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)  1515             page.tbody.close()  1516   1517     def show_calendar_points(self, intervals, groups, group_types, group_columns):  1518   1519         """  1520         Show the time 'intervals' along with period information from the given  1521         'groups', having the indicated 'group_types', each with the number of  1522         columns given by 'group_columns'.  1523         """  1524   1525         page = self.page  1526   1527         # Obtain the user's timezone.  1528   1529         tzid = self.get_tzid()  1530   1531         # Produce a row for each interval.  1532   1533         intervals = list(intervals)  1534         intervals.sort()  1535   1536         for point, endpoint in intervals:  1537             continuation = point == get_start_of_day(point, tzid)  1538   1539             # Some rows contain no period details and are marked as such.  1540   1541             have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)  1542   1543             css = " ".join(  1544                 ["slot"] +  1545                 (have_active and ["busy"] or ["empty"]) +  1546                 (continuation and ["daystart"] or [])  1547                 )  1548   1549             page.tr(class_=css)  1550             page.th(class_="timeslot")  1551             self._time_point(point, endpoint)  1552             page.th.close()  1553   1554             # Obtain slots for the time point from each group.  1555   1556             for columns, slots, group_type in zip(group_columns, groups, group_types):  1557                 active = slots and slots.get(point)  1558   1559                 # Where no periods exist for the given time interval, generate  1560                 # an empty cell. Where a participant provides no periods at all,  1561                 # the colspan is adjusted to be 1, not 0.  1562   1563                 if not active:  1564                     page.td(class_="empty container", colspan=max(columns, 1))  1565                     self._empty_slot(point, endpoint)  1566                     page.td.close()  1567                     continue  1568   1569                 slots = slots.items()  1570                 slots.sort()  1571                 spans = get_spans(slots)  1572   1573                 empty = 0  1574   1575                 # Show a column for each active period.  1576   1577                 for t in active:  1578                     if t and len(t) >= 2:  1579   1580                         # Flush empty slots preceding this one.  1581   1582                         if empty:  1583                             page.td(class_="empty container", colspan=empty)  1584                             self._empty_slot(point, endpoint)  1585                             page.td.close()  1586                             empty = 0  1587   1588                         start, end, uid, recurrenceid, key = get_freebusy_details(t)  1589                         span = spans[key]  1590   1591                         # Produce a table cell only at the start of the period  1592                         # or when continued at the start of a day.  1593   1594                         if point == start or continuation:  1595   1596                             obj = self._get_object(uid, recurrenceid)  1597   1598                             has_continued = continuation and point != start  1599                             will_continue = not ends_on_same_day(point, end, tzid)  1600                             is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user  1601   1602                             css = " ".join(  1603                                 ["event"] +  1604                                 (has_continued and ["continued"] or []) +  1605                                 (will_continue and ["continues"] or []) +  1606                                 (is_organiser and ["organising"] or ["attending"])  1607                                 )  1608   1609                             # Only anchor the first cell of events.  1610                             # NOTE: Need to only anchor the first period for a  1611                             # NOTE: recurring event.  1612   1613                             if point == start:  1614                                 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or ""))  1615                             else:  1616                                 page.td(class_=css, rowspan=span)  1617   1618                             if not obj:  1619                                 page.span("(Participant is busy)")  1620                             else:  1621                                 summary = obj.get_value("SUMMARY")  1622   1623                                 # Only link to events if they are not being  1624                                 # updated by requests.  1625   1626                                 if (uid, recurrenceid) in self._get_requests() and group_type != "request":  1627                                     page.span(summary)  1628                                 else:  1629                                     page.a(summary, href=self.link_to(uid, recurrenceid))  1630   1631                             page.td.close()  1632                     else:  1633                         empty += 1  1634   1635                 # Pad with empty columns.  1636   1637                 empty = columns - len(active)  1638   1639                 if empty:  1640                     page.td(class_="empty container", colspan=empty)  1641                     self._empty_slot(point, endpoint)  1642                     page.td.close()  1643   1644             page.tr.close()  1645   1646     def _day_heading(self, day):  1647   1648         """  1649         Generate a heading for 'day' of the following form:  1650   1651         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>  1652         """  1653   1654         page = self.page  1655         daystr = format_datetime(day)  1656         value, identifier = self._day_value_and_identifier(day)  1657         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)  1658   1659     def _time_point(self, point, endpoint):  1660   1661         """  1662         Generate headings for the 'point' to 'endpoint' period of the following  1663         form:  1664   1665         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>  1666         <span class="endpoint">10:00:00 CET</span>  1667         """  1668   1669         page = self.page  1670         tzid = self.get_tzid()  1671         daystr = format_datetime(point.date())  1672         value, identifier = self._slot_value_and_identifier(point, endpoint)  1673         slots = self.env.get_args().get("slot", [])  1674         self._slot_selector(value, identifier, slots)  1675         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)  1676         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")  1677   1678     def _slot_selector(self, value, identifier, slots):  1679         reset = self.env.get_args().has_key("reset")  1680         page = self.page  1681         if not reset and value in slots:  1682             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")  1683         else:  1684             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")  1685   1686     def _empty_slot(self, point, endpoint):  1687         page = self.page  1688         value, identifier = self._slot_value_and_identifier(point, endpoint)  1689         page.label("Select/deselect period", class_="newevent popup", for_=identifier)  1690   1691     def _day_value_and_identifier(self, day):  1692         value = "%s-" % format_datetime(day)  1693         identifier = "day-%s" % value  1694         return value, identifier  1695   1696     def _slot_value_and_identifier(self, point, endpoint):  1697         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")  1698         identifier = "slot-%s" % value  1699         return value, identifier  1700   1701     def _show_menu(self, name, default, items, class_=""):  1702         page = self.page  1703         values = self.env.get_args().get(name, [default])  1704         page.select(name=name, class_=class_)  1705         for v, label in items:  1706             if v is None:  1707                 continue  1708             if v in values:  1709                 page.option(label, value=v, selected="selected")  1710             else:  1711                 page.option(label, value=v)  1712         page.select.close()  1713   1714     def _show_date_controls(self, name, default, attr, tzid):  1715   1716         """  1717         Show date controls for a field with the given 'name' and 'default' value  1718         and 'attr', with the given 'tzid' being used if no other time regime  1719         information is provided.  1720         """  1721   1722         page = self.page  1723         args = self.env.get_args()  1724   1725         event_tzid = attr.get("TZID", tzid)  1726         dt = get_datetime(default, attr)  1727   1728         # Show dates for up to one week around the current date.  1729   1730         base = get_date(dt)  1731         items = []  1732         for i in range(-7, 8):  1733             d = base + timedelta(i)  1734             items.append((format_datetime(d), self.format_date(d, "full")))  1735   1736         self._show_menu("%s-date" % name, format_datetime(base), items)  1737   1738         # Show time details.  1739   1740         dt_time = isinstance(dt, datetime) and dt or None  1741         hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0))  1742         minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0))  1743         second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0))  1744   1745         page.span(class_="time enabled")  1746         page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)  1747         page.add(":")  1748         page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)  1749         page.add(":")  1750         page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)  1751         page.add(" ")  1752         self._show_menu("%s-tzid" % name, event_tzid,  1753             [(event_tzid, event_tzid)] + (  1754             event_tzid != tzid and [(tzid, tzid)] or []  1755             ))  1756         page.span.close()  1757   1758     # Incoming HTTP request direction.  1759   1760     def select_action(self):  1761   1762         "Select the desired action and show the result."  1763   1764         path_info = self.env.get_path_info().strip("/")  1765   1766         if not path_info:  1767             self.show_calendar()  1768         elif self.show_object(path_info):  1769             pass  1770         else:  1771             self.no_page()  1772   1773     def __call__(self):  1774   1775         "Interpret a request and show an appropriate response."  1776   1777         if not self.user:  1778             self.no_user()  1779         else:  1780             self.select_action()  1781   1782         # Write the headers and actual content.  1783   1784         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding  1785         print >>self.out  1786         self.out.write(unicode(self.page).encode(self.encoding))  1787   1788 if __name__ == "__main__":  1789     Manager()()  1790   1791 # vim: tabstop=4 expandtab shiftwidth=4