imip-agent

imip_manager.py

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