imip-agent

imip_manager.py

376:3719dd8a62d2
2015-03-04 Paul Boddie Fixed the removal of periods replaced by special event recurrences. 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             participants = uri_values(filter(None, participants))   553    554             for participant in participants:   555                 rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))   556    557             if self.user not in participants:   558                 rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))   559    560             node = ("VEVENT", {}, record)   561    562             self.store.set_event(self.user, this_uid, None, node=node)   563             self.store.queue_request(self.user, this_uid)   564    565         # Redirect to the object (or the first of the objects), where instead of   566         # attendee controls, there will be organiser controls.   567    568         self.redirect(self.link_to("%s-0" % uid))   569    570     def handle_request(self, uid, recurrenceid, obj):   571    572         """   573         Handle actions involving the given 'uid', 'recurrenceid', and 'obj' as   574         the object's representation, returning an error if one occurred, or None   575         if the request was successfully handled.   576         """   577    578         # Handle a submitted form.   579    580         args = self.env.get_args()   581    582         # Get the possible actions.   583    584         reply = args.has_key("reply")   585         discard = args.has_key("discard")   586         invite = args.has_key("invite")   587         cancel = args.has_key("cancel")   588         save = args.has_key("save")   589    590         have_action = reply or discard or invite or cancel or save   591    592         if not have_action:   593             return ["action"]   594    595         # Update the object.   596    597         if args.has_key("summary"):   598             obj["SUMMARY"] = [(args["summary"][0], {})]   599    600         attendees = uri_dict(obj.get_value_map("ATTENDEE"))   601    602         if args.has_key("partstat"):   603             if attendees.has_key(self.user):   604                 attendees[self.user]["PARTSTAT"] = args["partstat"][0]   605                 if attendees[self.user].has_key("RSVP"):   606                     del attendees[self.user]["RSVP"]   607    608         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   609    610         # Obtain the user's timezone and process datetime values.   611    612         update = False   613    614         if is_organiser:   615             dtend_enabled = args.get("dtend-control", [None])[0] == "enable"   616             dttimes_enabled = args.get("dttimes-control", [None])[0] == "enable"   617    618             t = self.handle_date_controls("dtstart", dttimes_enabled)   619             if t:   620                 dtstart, attr = t   621                 update = self.set_datetime_in_object(dtstart, attr.get("TZID"), "DTSTART", obj) or update   622             else:   623                 return ["dtstart"]   624    625             # Handle specified end datetimes.   626    627             if dtend_enabled:   628                 t = self.handle_date_controls("dtend", dttimes_enabled)   629                 if t:   630                     dtend, attr = t   631    632                     # Convert end dates to iCalendar "next day" dates.   633    634                     if not isinstance(dtend, datetime):   635                         dtend += timedelta(1)   636                     update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update   637                 else:   638                     return ["dtend"]   639    640             # Otherwise, treat the end date as the start date. Datetimes are   641             # handled by making the event occupy the rest of the day.   642    643             else:   644                 dtend = dtstart + timedelta(1)   645                 if isinstance(dtstart, datetime):   646                     dtend = get_start_of_day(dtend, attr["TZID"])   647                 update = self.set_datetime_in_object(dtend, attr.get("TZID"), "DTEND", obj) or update   648    649             if dtstart >= dtend:   650                 return ["dtstart", "dtend"]   651    652         # Obtain any participants to be added or removed.   653    654         removed = args.get("remove")   655         added = args.get("added")   656    657         # Process any action.   658    659         handled = True   660    661         if reply or invite or cancel:   662    663             handler = ManagerHandler(obj, self.user, self.messenger)   664    665             # Process the object and remove it from the list of requests.   666    667             if reply and handler.process_received_request(update) or \   668                is_organiser and (invite or cancel) and \   669                handler.process_created_request(invite and "REQUEST" or "CANCEL", update, removed, added):   670    671                 self.remove_request(uid, recurrenceid)   672    673         # Save single user events.   674    675         elif save:   676             to_cancel = self.update_attendees(obj, added, removed)   677             self.store.set_event(self.user, uid, recurrenceid, node=obj.to_node())   678             self.update_freebusy(uid, recurrenceid, obj=obj)   679             self.remove_request(uid, recurrenceid)   680    681         # Remove the request and the object.   682    683         elif discard:   684             self.remove_from_freebusy(uid, recurrenceid)   685             self.remove_event(uid, recurrenceid)   686             self.remove_request(uid, recurrenceid)   687    688         else:   689             handled = False   690    691         # Upon handling an action, redirect to the main page.   692    693         if handled:   694             self.redirect(self.env.get_path())   695    696         return None   697    698     def handle_date_controls(self, name, with_time=True):   699    700         """   701         Handle date control information for fields starting with 'name',   702         returning a (datetime, attr) tuple or None if the fields cannot be used   703         to construct a datetime object.   704         """   705    706         args = self.env.get_args()   707    708         if args.has_key("%s-date" % name):   709             date = args["%s-date" % name][0]   710    711             if with_time:   712                 hour = args.get("%s-hour" % name, [None])[0]   713                 minute = args.get("%s-minute" % name, [None])[0]   714                 second = args.get("%s-second" % name, [None])[0]   715                 tzid = args.get("%s-tzid" % name, [self.get_tzid()])[0]   716    717                 time = (hour or minute or second) and "T%s%s%s" % (hour, minute, second) or ""   718                 value = "%s%s" % (date, time)   719                 attr = {"TZID" : tzid, "VALUE" : "DATE-TIME"}   720                 dt = get_datetime(value, attr)   721             else:   722                 attr = {"VALUE" : "DATE"}   723                 dt = get_datetime(date)   724    725             if dt:   726                 return dt, attr   727    728         return None   729    730     def set_datetime_in_object(self, dt, tzid, property, obj):   731    732         """   733         Set 'dt' and 'tzid' for the given 'property' in 'obj', returning whether   734         an update has occurred.   735         """   736    737         if dt:   738             old_value = obj.get_value(property)   739             obj[property] = [get_datetime_item(dt, tzid)]   740             return format_datetime(dt) != old_value   741    742         return False   743    744     # Page fragment methods.   745    746     def show_request_controls(self, obj):   747    748         "Show form controls for a request concerning 'obj'."   749    750         page = self.page   751         args = self.env.get_args()   752    753         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   754    755         attendees = uri_values((obj.get_values("ATTENDEE") or []) + args.get("attendee", []))   756         is_attendee = self.user in attendees   757    758         is_request = (obj.get_value("UID"), obj.get_value("RECURRENCE-ID")) in self._get_requests()   759    760         have_other_attendees = len(attendees) > (is_attendee and 1 or 0)   761    762         # Show appropriate options depending on the role of the user.   763    764         if is_attendee and not is_organiser:   765             page.p("An action is required for this request:")   766    767             page.p()   768             page.input(name="reply", type="submit", value="Reply")   769             page.add(" ")   770             page.input(name="discard", type="submit", value="Discard")   771             page.p.close()   772    773         if is_organiser:   774             if have_other_attendees:   775                 page.p("As organiser, you can perform the following:")   776    777                 page.p()   778                 page.input(name="invite", type="submit", value="Invite")   779                 page.add(" ")   780                 if is_request:   781                     page.input(name="discard", type="submit", value="Discard")   782                 else:   783                     page.input(name="cancel", type="submit", value="Cancel")   784                 page.p.close()   785             else:   786                 page.p("As attendee, you can perform the following:")   787    788                 page.p()   789                 page.input(name="save", type="submit", value="Save")   790                 page.add(" ")   791                 page.input(name="discard", type="submit", value="Discard")   792                 page.p.close()   793    794     property_items = [   795         ("SUMMARY", "Summary"),   796         ("DTSTART", "Start"),   797         ("DTEND", "End"),   798         ("ORGANIZER", "Organiser"),   799         ("ATTENDEE", "Attendee"),   800         ]   801    802     partstat_items = [   803         ("NEEDS-ACTION", "Not confirmed"),   804         ("ACCEPTED", "Attending"),   805         ("TENTATIVE", "Tentatively attending"),   806         ("DECLINED", "Not attending"),   807         ("DELEGATED", "Delegated"),   808         (None, "Not indicated"),   809         ]   810    811     def show_object_on_page(self, uid, obj, error=None):   812    813         """   814         Show the calendar object with the given 'uid' and representation 'obj'   815         on the current page. If 'error' is given, show a suitable message.   816         """   817    818         page = self.page   819         page.form(method="POST")   820    821         args = self.env.get_args()   822    823         # Obtain the user's timezone.   824    825         tzid = self.get_tzid()   826    827         # Obtain basic event information, showing any necessary editing controls.   828    829         is_organiser = get_uri(obj.get_value("ORGANIZER")) == self.user   830    831         if is_organiser:   832             (dtstart, dtstart_attr), (dtend, dtend_attr) = self.show_object_organiser_controls(obj)   833             new_attendees, new_attendee = self.handle_new_attendees(obj)   834         else:   835             dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")   836             dtend, dtend_attr = obj.get_datetime_item("DTEND")   837             new_attendees = []   838             new_attendee = ""   839    840         # Provide a summary of the object.   841    842         page.table(class_="object", cellspacing=5, cellpadding=5)   843         page.thead()   844         page.tr()   845         page.th("Event", class_="mainheading", colspan=2)   846         page.tr.close()   847         page.thead.close()   848         page.tbody()   849    850         for name, label in self.property_items:   851             page.tr()   852    853             # Handle datetimes specially.   854    855             if name in ["DTSTART", "DTEND"]:   856                 field = name.lower()   857    858                 page.th(label, class_="objectheading %s%s" % (field, error and field in error and " error" or ""))   859    860                 # Obtain the datetime.   861    862                 if name == "DTSTART":   863                     dt, attr, event_tzid = dtstart, dtstart_attr, dtstart_attr.get("TZID", tzid)   864    865                 # Where no end datetime exists, use the start datetime as the   866                 # basis of any potential datetime specified if dt-control is   867                 # set.   868    869                 else:   870                     dt, attr, event_tzid = dtend or dtstart, dtend_attr or dtstart_attr, (dtend_attr or dtstart_attr).get("TZID", tzid)   871    872                 # Show controls for editing as organiser.   873    874                 if is_organiser:   875                     value = format_datetime(dt)   876    877                     page.td(class_="objectvalue %s" % field)   878                     if name == "DTEND":   879                         page.div(class_="dt disabled")   880                         page.label("Specify end date", for_="dtend-enable", class_="enable")   881                         page.div.close()   882    883                     page.div(class_="dt enabled")   884                     self._show_date_controls(field, value, attr, tzid)   885                     if name == "DTSTART":   886                         page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")   887                         page.label("Specify dates only", for_="dttimes-disable", class_="time enabled disable")   888                     elif name == "DTEND":   889                         page.label("End on same day", for_="dtend-disable", class_="disable")   890                     page.div.close()   891    892                     page.td.close()   893    894                 # Show a label as attendee.   895    896                 else:   897                     page.td(self.format_datetime(dt, "full"))   898    899                 page.tr.close()   900    901             # Handle the summary specially.   902    903             elif name == "SUMMARY":   904                 value = args.get("summary", [obj.get_value(name)])[0]   905    906                 page.th(label, class_="objectheading")   907                 page.td()   908                 if is_organiser:   909                     page.input(name="summary", type="text", value=value, size=80)   910                 else:   911                     page.add(value)   912                 page.td.close()   913                 page.tr.close()   914    915             # Handle potentially many values.   916    917             else:   918                 items = obj.get_items(name) or []   919                 rowspan = len(items)   920    921                 if name == "ATTENDEE":   922                     rowspan += len(new_attendees) + 1   923                 elif not items:   924                     continue   925    926                 page.th(label, class_="objectheading", rowspan=rowspan)   927    928                 first = True   929    930                 for i, (value, attr) in enumerate(items):   931                     if not first:   932                         page.tr()   933                     else:   934                         first = False   935    936                     if name == "ATTENDEE":   937                         value = get_uri(value)   938    939                         page.td(class_="objectvalue")   940                         page.add(value)   941                         page.add(" ")   942    943                         partstat = attr.get("PARTSTAT")   944                         if value == self.user:   945                             self._show_menu("partstat", partstat, self.partstat_items, "partstat")   946                         else:   947                             page.span(dict(self.partstat_items).get(partstat, ""), class_="partstat")   948    949                         if is_organiser:   950                             if value in args.get("remove", []):   951                                 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove", checked="checked")   952                             else:   953                                 page.input(name="remove", type="checkbox", value=value, id="remove-%d" % i, class_="remove")   954                             page.label("Remove", for_="remove-%d" % i, class_="remove")   955                             page.label("Uninvited", for_="remove-%d" % i, class_="removed")   956    957                     else:   958                         page.td(class_="objectvalue")   959                         page.add(value)   960    961                     page.td.close()   962                     page.tr.close()   963    964                 # Allow more attendees to be specified.   965    966                 if is_organiser and name == "ATTENDEE":   967                     for i, attendee in enumerate(new_attendees):   968                         if not first:   969                             page.tr()   970                         else:   971                             first = False   972    973                         page.td()   974                         page.input(name="added", type="value", value=attendee)   975                         page.input(name="removenew", type="submit", value=attendee, id="removenew-%d" % i, class_="remove")   976                         page.label("Remove", for_="removenew-%d" % i, class_="remove")   977                         page.td.close()   978                         page.tr.close()   979    980                     if not first:   981                         page.tr()   982    983                     page.td()   984                     page.input(name="attendee", type="value", value=new_attendee)   985                     page.input(name="add", type="submit", value="add", id="add-%d" % i, class_="add")   986                     page.label("Add", for_="add-%d" % i, class_="add")   987                     page.td.close()   988                     page.tr.close()   989    990         page.tbody.close()   991         page.table.close()   992    993         self.show_recurrences(obj)   994         self.show_conflicting_events(uid, obj)   995         self.show_request_controls(obj)   996    997         page.form.close()   998    999     def handle_new_attendees(self, obj):  1000   1001         "Add or remove new attendees. This does not affect the stored object."  1002   1003         args = self.env.get_args()  1004   1005         existing_attendees = uri_values(obj.get_values("ATTENDEE") or [])  1006         new_attendees = args.get("added", [])  1007         new_attendee = args.get("attendee", [""])[0]  1008   1009         if args.has_key("add"):  1010             if new_attendee.strip():  1011                 new_attendee = get_uri(new_attendee.strip())  1012                 if new_attendee not in new_attendees and new_attendee not in existing_attendees:  1013                     new_attendees.append(new_attendee)  1014                 new_attendee = ""  1015   1016         if args.has_key("removenew"):  1017             removed_attendee = args["removenew"][0]  1018             if removed_attendee in new_attendees:  1019                 new_attendees.remove(removed_attendee)  1020   1021         return new_attendees, new_attendee  1022   1023     def show_object_organiser_controls(self, obj):  1024   1025         "Provide controls to change the displayed object 'obj'."  1026   1027         page = self.page  1028         args = self.env.get_args()  1029   1030         # Configure the start and end datetimes.  1031   1032         dtend_control = args.get("dtend-control", [None])[0]  1033         dttimes_control = args.get("dttimes-control", [None])[0]  1034         with_time = dttimes_control == "enable"  1035   1036         t = self.handle_date_controls("dtstart", with_time)  1037         if t:  1038             dtstart, dtstart_attr = t  1039         else:  1040             dtstart, dtstart_attr = obj.get_datetime_item("DTSTART")  1041   1042         if dtend_control == "enable":  1043             t = self.handle_date_controls("dtend", with_time)  1044             if t:  1045                 dtend, dtend_attr = t  1046             else:  1047                 dtend, dtend_attr = None, {}  1048         elif dtend_control == "disable":  1049             dtend, dtend_attr = None, {}  1050         else:  1051             dtend, dtend_attr = obj.get_datetime_item("DTEND")  1052   1053         # Change end dates to refer to the actual dates, not the iCalendar  1054         # "next day" dates.  1055   1056         if dtend and not isinstance(dtend, datetime):  1057             dtend -= timedelta(1)  1058   1059         # Show the end datetime controls if already active or if an object needs  1060         # them.  1061   1062         dtend_enabled = dtend_control == "enable" or isinstance(dtend, datetime) or dtstart != dtend  1063         dttimes_enabled = dttimes_control == "enable" or isinstance(dtstart, datetime) or isinstance(dtend, datetime)  1064   1065         if dtend_enabled:  1066             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable", checked="checked")  1067             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable")  1068         else:  1069             page.input(name="dtend-control", type="radio", value="enable", id="dtend-enable")  1070             page.input(name="dtend-control", type="radio", value="disable", id="dtend-disable", checked="checked")  1071   1072         if dttimes_enabled:  1073             page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable", checked="checked")  1074             page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable")  1075         else:  1076             page.input(name="dttimes-control", type="radio", value="enable", id="dttimes-enable")  1077             page.input(name="dttimes-control", type="radio", value="disable", id="dttimes-disable", checked="checked")  1078   1079         return (dtstart, dtstart_attr), (dtend, dtend_attr)  1080   1081     def show_recurrences(self, obj):  1082   1083         "Show recurrences for the object having the given representation 'obj'."  1084   1085         page = self.page  1086   1087         # Obtain any parent object if this object is a specific recurrence.  1088   1089         recurrenceid = format_datetime(obj.get_utc_datetime("RECURRENCE-ID"))  1090   1091         if recurrenceid:  1092             obj = self._get_object(obj.get_value("UID"))  1093             if not obj:  1094                 return  1095   1096             page.p("This event modifies a recurring event.")  1097   1098         # Obtain the periods associated with the event in the user's time zone.  1099   1100         periods = obj.get_periods(self.get_tzid(), self.get_window_end())  1101   1102         if len(periods) == 1:  1103             return  1104   1105         page.p("This event occurs on the following occasions within the next %d days:" % self.get_window_size())  1106   1107         page.table(cellspacing=5, cellpadding=5, class_="conflicts")  1108         page.thead()  1109         page.tr()  1110         page.th("Start")  1111         page.th("End")  1112         page.tr.close()  1113         page.thead.close()  1114         page.tbody()  1115   1116         for start, end in periods:  1117             start_utc = format_datetime(to_timezone(start, "UTC"))  1118             css = recurrenceid and start_utc == recurrenceid and "replaced" or ""  1119   1120             page.tr()  1121             page.td(self.format_datetime(start, "long"), class_=css)  1122             page.td(self.format_datetime(end, "long"), class_=css)  1123             page.tr.close()  1124   1125         page.tbody.close()  1126         page.table.close()  1127   1128     def show_conflicting_events(self, uid, obj):  1129   1130         """  1131         Show conflicting events for the object having the given 'uid' and  1132         representation 'obj'.  1133         """  1134   1135         page = self.page  1136   1137         # Obtain the user's timezone.  1138   1139         tzid = self.get_tzid()  1140   1141         dtstart = format_datetime(obj.get_utc_datetime("DTSTART"))  1142         dtend = format_datetime(obj.get_utc_datetime("DTEND"))  1143   1144         # Indicate whether there are conflicting events.  1145   1146         freebusy = self.store.get_freebusy(self.user)  1147   1148         if freebusy:  1149   1150             # Obtain any time zone details from the suggested event.  1151   1152             _dtstart, attr = obj.get_item("DTSTART")  1153             tzid = attr.get("TZID", tzid)  1154   1155             # Show any conflicts.  1156   1157             conflicts = [t for t in have_conflict(freebusy, [(dtstart, dtend)], True) if t[2] != uid]  1158   1159             if conflicts:  1160                 page.p("This event conflicts with others:")  1161   1162                 page.table(cellspacing=5, cellpadding=5, class_="conflicts")  1163                 page.thead()  1164                 page.tr()  1165                 page.th("Event")  1166                 page.th("Start")  1167                 page.th("End")  1168                 page.tr.close()  1169                 page.thead.close()  1170                 page.tbody()  1171   1172                 for t in conflicts:  1173                     start, end, found_uid, transp, found_recurrenceid = t[:5]  1174   1175                     # Provide details of any conflicting event.  1176   1177                     start = self.format_datetime(to_timezone(get_datetime(start), tzid), "long")  1178                     end = self.format_datetime(to_timezone(get_datetime(end), tzid), "long")  1179   1180                     page.tr()  1181   1182                     # Show the event summary for the conflicting event.  1183   1184                     page.td()  1185   1186                     found_obj = self._get_object(found_uid, found_recurrenceid)  1187                     if found_obj:  1188                         page.a(found_obj.get_value("SUMMARY"), href=self.link_to(found_uid))  1189                     else:  1190                         page.add("No details available")  1191   1192                     page.td.close()  1193   1194                     page.td(start)  1195                     page.td(end)  1196   1197                     page.tr.close()  1198   1199                 page.tbody.close()  1200                 page.table.close()  1201   1202     def show_requests_on_page(self):  1203   1204         "Show requests for the current user."  1205   1206         # NOTE: This list could be more informative, but it is envisaged that  1207         # NOTE: the requests would be visited directly anyway.  1208   1209         requests = self._get_requests()  1210   1211         self.page.div(id="pending-requests")  1212   1213         if requests:  1214             self.page.p("Pending requests:")  1215   1216             self.page.ul()  1217   1218             for uid, recurrenceid in requests:  1219                 obj = self._get_object(uid, recurrenceid)  1220                 if obj:  1221                     self.page.li()  1222                     self.page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))  1223                     self.page.li.close()  1224   1225             self.page.ul.close()  1226   1227         else:  1228             self.page.p("There are no pending requests.")  1229   1230         self.page.div.close()  1231   1232     def show_participants_on_page(self):  1233   1234         "Show participants for scheduling purposes."  1235   1236         args = self.env.get_args()  1237         participants = args.get("participants", [])  1238   1239         try:  1240             for name, value in args.items():  1241                 if name.startswith("remove-participant-"):  1242                     i = int(name[len("remove-participant-"):])  1243                     del participants[i]  1244                     break  1245         except ValueError:  1246             pass  1247   1248         # Trim empty participants.  1249   1250         while participants and not participants[-1].strip():  1251             participants.pop()  1252   1253         # Show any specified participants together with controls to remove and  1254         # add participants.  1255   1256         self.page.div(id="participants")  1257   1258         self.page.p("Participants for scheduling:")  1259   1260         for i, participant in enumerate(participants):  1261             self.page.p()  1262             self.page.input(name="participants", type="text", value=participant)  1263             self.page.input(name="remove-participant-%d" % i, type="submit", value="Remove")  1264             self.page.p.close()  1265   1266         self.page.p()  1267         self.page.input(name="participants", type="text")  1268         self.page.input(name="add-participant", type="submit", value="Add")  1269         self.page.p.close()  1270   1271         self.page.div.close()  1272   1273         return participants  1274   1275     # Full page output methods.  1276   1277     def show_object(self, path_info):  1278   1279         "Show an object request using the given 'path_info' for the current user."  1280   1281         uid, recurrenceid = self._get_identifiers(path_info)  1282         obj = self._get_object(uid, recurrenceid)  1283   1284         if not obj:  1285             return False  1286   1287         error = self.handle_request(uid, recurrenceid, obj)  1288   1289         if not error:  1290             return True  1291   1292         self.new_page(title="Event")  1293         self.show_object_on_page(uid, obj, error)  1294   1295         return True  1296   1297     def show_calendar(self):  1298   1299         "Show the calendar for the current user."  1300   1301         handled = self.handle_newevent()  1302   1303         self.new_page(title="Calendar")  1304         page = self.page  1305   1306         # Form controls are used in various places on the calendar page.  1307   1308         page.form(method="POST")  1309   1310         self.show_requests_on_page()  1311         participants = self.show_participants_on_page()  1312   1313         # Show a button for scheduling a new event.  1314   1315         page.p(class_="controls")  1316         page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")  1317         page.input(name="reset", type="submit", value="Clear selections", id="reset")  1318         page.p.close()  1319   1320         # Show controls for hiding empty days and busy slots.  1321         # The positioning of the control, paragraph and table are important here.  1322   1323         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")  1324         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")  1325   1326         page.p(class_="controls")  1327         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")  1328         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")  1329         page.label("Show empty days", for_="showdays", class_="showdays disable")  1330         page.label("Hide empty days", for_="showdays", class_="showdays enable")  1331         page.p.close()  1332   1333         freebusy = self.store.get_freebusy(self.user)  1334   1335         if not freebusy:  1336             page.p("No events scheduled.")  1337             return  1338   1339         # Obtain the user's timezone.  1340   1341         tzid = self.get_tzid()  1342   1343         # Day view: start at the earliest known day and produce days until the  1344         # latest known day, perhaps with expandable sections of empty days.  1345   1346         # Month view: start at the earliest known month and produce months until  1347         # the latest known month, perhaps with expandable sections of empty  1348         # months.  1349   1350         # Details of users to invite to new events could be superimposed on the  1351         # calendar.  1352   1353         # Requests are listed and linked to their tentative positions in the  1354         # calendar. Other participants are also shown.  1355   1356         request_summary = self._get_request_summary()  1357   1358         period_groups = [request_summary, freebusy]  1359         period_group_types = ["request", "freebusy"]  1360         period_group_sources = ["Pending requests", "Your schedule"]  1361   1362         for i, participant in enumerate(participants):  1363             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))  1364             period_group_types.append("freebusy-part%d" % i)  1365             period_group_sources.append(participant)  1366   1367         groups = []  1368         group_columns = []  1369         group_types = period_group_types  1370         group_sources = period_group_sources  1371         all_points = set()  1372   1373         # Obtain time point information for each group of periods.  1374   1375         for periods in period_groups:  1376             periods = convert_periods(periods, tzid)  1377   1378             # Get the time scale with start and end points.  1379   1380             scale = get_scale(periods)  1381   1382             # Get the time slots for the periods.  1383   1384             slots = get_slots(scale)  1385   1386             # Add start of day time points for multi-day periods.  1387   1388             add_day_start_points(slots, tzid)  1389   1390             # Record the slots and all time points employed.  1391   1392             groups.append(slots)  1393             all_points.update([point for point, active in slots])  1394   1395         # Partition the groups into days.  1396   1397         days = {}  1398         partitioned_groups = []  1399         partitioned_group_types = []  1400         partitioned_group_sources = []  1401   1402         for slots, group_type, group_source in zip(groups, group_types, group_sources):  1403   1404             # Propagate time points to all groups of time slots.  1405   1406             add_slots(slots, all_points)  1407   1408             # Count the number of columns employed by the group.  1409   1410             columns = 0  1411   1412             # Partition the time slots by day.  1413   1414             partitioned = {}  1415   1416             for day, day_slots in partition_by_day(slots).items():  1417                 intervals = []  1418                 last = None  1419   1420                 for point, active in day_slots:  1421                     columns = max(columns, len(active))  1422                     if last:  1423                         intervals.append((last, point))  1424                     last = point  1425   1426                 if last:  1427                     intervals.append((last, None))  1428   1429                 if not days.has_key(day):  1430                     days[day] = set()  1431   1432                 # Convert each partition to a mapping from points to active  1433                 # periods.  1434   1435                 partitioned[day] = dict(day_slots)  1436   1437                 # Record the divisions or intervals within each day.  1438   1439                 days[day].update(intervals)  1440   1441             if group_type != "request" or columns:  1442                 group_columns.append(columns)  1443                 partitioned_groups.append(partitioned)  1444                 partitioned_group_types.append(group_type)  1445                 partitioned_group_sources.append(group_source)  1446   1447         # Add empty days.  1448   1449         add_empty_days(days, tzid)  1450   1451         # Show the controls permitting day selection.  1452   1453         self.show_calendar_day_controls(days)  1454   1455         # Show the calendar itself.  1456   1457         page.table(cellspacing=5, cellpadding=5, class_="calendar")  1458         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)  1459         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)  1460         page.table.close()  1461   1462         # End the form region.  1463   1464         page.form.close()  1465   1466     # More page fragment methods.  1467   1468     def show_calendar_day_controls(self, days):  1469   1470         "Show controls for the given 'days' in the calendar."  1471   1472         page = self.page  1473         slots = self.env.get_args().get("slot", [])  1474   1475         for day in days:  1476             value, identifier = self._day_value_and_identifier(day)  1477             self._slot_selector(value, identifier, slots)  1478   1479         # Generate a dynamic stylesheet to allow day selections to colour  1480         # specific days.  1481         # NOTE: The style details need to be coordinated with the static  1482         # NOTE: stylesheet.  1483   1484         page.style(type="text/css")  1485   1486         for day in days:  1487             daystr = format_datetime(day)  1488             page.add("""\  1489 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,  1490 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {  1491     background-color: #5f4;  1492     text-decoration: underline;  1493 }  1494 """ % (daystr, daystr, daystr, daystr))  1495   1496         page.style.close()  1497   1498     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):  1499   1500         """  1501         Show headings for the participants and other scheduling contributors,  1502         defined by 'group_types', 'group_sources' and 'group_columns'.  1503         """  1504   1505         page = self.page  1506   1507         page.colgroup(span=1, id="columns-timeslot")  1508   1509         for group_type, columns in zip(group_types, group_columns):  1510             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)  1511   1512         page.thead()  1513         page.tr()  1514         page.th("", class_="emptyheading")  1515   1516         for group_type, source, columns in zip(group_types, group_sources, group_columns):  1517             page.th(source,  1518                 class_=(group_type == "request" and "requestheading" or "participantheading"),  1519                 colspan=max(columns, 1))  1520   1521         page.tr.close()  1522         page.thead.close()  1523   1524     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):  1525   1526         """  1527         Show calendar days, defined by a collection of 'days', the contributing  1528         period information as 'partitioned_groups' (partitioned by day), the  1529         'partitioned_group_types' indicating the kind of contribution involved,  1530         and the 'group_columns' defining the number of columns in each group.  1531         """  1532   1533         page = self.page  1534   1535         # Determine the number of columns required. Where participants provide  1536         # no columns for events, one still needs to be provided for the  1537         # participant itself.  1538   1539         all_columns = sum([max(columns, 1) for columns in group_columns])  1540   1541         # Determine the days providing time slots.  1542   1543         all_days = days.items()  1544         all_days.sort()  1545   1546         # Produce a heading and time points for each day.  1547   1548         for day, intervals in all_days:  1549             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]  1550             is_empty = True  1551   1552             for slots in groups_for_day:  1553                 if not slots:  1554                     continue  1555   1556                 for active in slots.values():  1557                     if active:  1558                         is_empty = False  1559                         break  1560   1561             page.thead(class_="separator%s" % (is_empty and " empty" or ""))  1562             page.tr()  1563             page.th(class_="dayheading container", colspan=all_columns+1)  1564             self._day_heading(day)  1565             page.th.close()  1566             page.tr.close()  1567             page.thead.close()  1568   1569             page.tbody(class_="points%s" % (is_empty and " empty" or ""))  1570             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)  1571             page.tbody.close()  1572   1573     def show_calendar_points(self, intervals, groups, group_types, group_columns):  1574   1575         """  1576         Show the time 'intervals' along with period information from the given  1577         'groups', having the indicated 'group_types', each with the number of  1578         columns given by 'group_columns'.  1579         """  1580   1581         page = self.page  1582   1583         # Obtain the user's timezone.  1584   1585         tzid = self.get_tzid()  1586   1587         # Produce a row for each interval.  1588   1589         intervals = list(intervals)  1590         intervals.sort()  1591   1592         for point, endpoint in intervals:  1593             continuation = point == get_start_of_day(point, tzid)  1594   1595             # Some rows contain no period details and are marked as such.  1596   1597             have_active = reduce(lambda x, y: x or y, [slots and slots.get(point) for slots in groups], None)  1598   1599             css = " ".join(  1600                 ["slot"] +  1601                 (have_active and ["busy"] or ["empty"]) +  1602                 (continuation and ["daystart"] or [])  1603                 )  1604   1605             page.tr(class_=css)  1606             page.th(class_="timeslot")  1607             self._time_point(point, endpoint)  1608             page.th.close()  1609   1610             # Obtain slots for the time point from each group.  1611   1612             for columns, slots, group_type in zip(group_columns, groups, group_types):  1613                 active = slots and slots.get(point)  1614   1615                 # Where no periods exist for the given time interval, generate  1616                 # an empty cell. Where a participant provides no periods at all,  1617                 # the colspan is adjusted to be 1, not 0.  1618   1619                 if not active:  1620                     page.td(class_="empty container", colspan=max(columns, 1))  1621                     self._empty_slot(point, endpoint)  1622                     page.td.close()  1623                     continue  1624   1625                 slots = slots.items()  1626                 slots.sort()  1627                 spans = get_spans(slots)  1628   1629                 empty = 0  1630   1631                 # Show a column for each active period.  1632   1633                 for t in active:  1634                     if t and len(t) >= 2:  1635   1636                         # Flush empty slots preceding this one.  1637   1638                         if empty:  1639                             page.td(class_="empty container", colspan=empty)  1640                             self._empty_slot(point, endpoint)  1641                             page.td.close()  1642                             empty = 0  1643   1644                         start, end, uid, recurrenceid, key = get_freebusy_details(t)  1645                         span = spans[key]  1646   1647                         # Produce a table cell only at the start of the period  1648                         # or when continued at the start of a day.  1649   1650                         if point == start or continuation:  1651   1652                             obj = self._get_object(uid, recurrenceid)  1653   1654                             has_continued = continuation and point != start  1655                             will_continue = not ends_on_same_day(point, end, tzid)  1656                             is_organiser = obj and get_uri(obj.get_value("ORGANIZER")) == self.user  1657   1658                             css = " ".join(  1659                                 ["event"] +  1660                                 (has_continued and ["continued"] or []) +  1661                                 (will_continue and ["continues"] or []) +  1662                                 (is_organiser and ["organising"] or ["attending"])  1663                                 )  1664   1665                             # Only anchor the first cell of events.  1666                             # NOTE: Need to only anchor the first period for a  1667                             # NOTE: recurring event.  1668   1669                             if point == start:  1670                                 page.td(class_=css, rowspan=span, id="%s-%s-%s" % (group_type, uid, recurrenceid or ""))  1671                             else:  1672                                 page.td(class_=css, rowspan=span)  1673   1674                             if not obj:  1675                                 page.span("(Participant is busy)")  1676                             else:  1677                                 summary = obj.get_value("SUMMARY")  1678   1679                                 # Only link to events if they are not being  1680                                 # updated by requests.  1681   1682                                 if (uid, recurrenceid) in self._get_requests() and group_type != "request":  1683                                     page.span(summary)  1684                                 else:  1685                                     page.a(summary, href=self.link_to(uid, recurrenceid))  1686   1687                             page.td.close()  1688                     else:  1689                         empty += 1  1690   1691                 # Pad with empty columns.  1692   1693                 empty = columns - len(active)  1694   1695                 if empty:  1696                     page.td(class_="empty container", colspan=empty)  1697                     self._empty_slot(point, endpoint)  1698                     page.td.close()  1699   1700             page.tr.close()  1701   1702     def _day_heading(self, day):  1703   1704         """  1705         Generate a heading for 'day' of the following form:  1706   1707         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>  1708         """  1709   1710         page = self.page  1711         daystr = format_datetime(day)  1712         value, identifier = self._day_value_and_identifier(day)  1713         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)  1714   1715     def _time_point(self, point, endpoint):  1716   1717         """  1718         Generate headings for the 'point' to 'endpoint' period of the following  1719         form:  1720   1721         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>  1722         <span class="endpoint">10:00:00 CET</span>  1723         """  1724   1725         page = self.page  1726         tzid = self.get_tzid()  1727         daystr = format_datetime(point.date())  1728         value, identifier = self._slot_value_and_identifier(point, endpoint)  1729         slots = self.env.get_args().get("slot", [])  1730         self._slot_selector(value, identifier, slots)  1731         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)  1732         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")  1733   1734     def _slot_selector(self, value, identifier, slots):  1735         reset = self.env.get_args().has_key("reset")  1736         page = self.page  1737         if not reset and value in slots:  1738             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")  1739         else:  1740             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")  1741   1742     def _empty_slot(self, point, endpoint):  1743         page = self.page  1744         value, identifier = self._slot_value_and_identifier(point, endpoint)  1745         page.label("Select/deselect period", class_="newevent popup", for_=identifier)  1746   1747     def _day_value_and_identifier(self, day):  1748         value = "%s-" % format_datetime(day)  1749         identifier = "day-%s" % value  1750         return value, identifier  1751   1752     def _slot_value_and_identifier(self, point, endpoint):  1753         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")  1754         identifier = "slot-%s" % value  1755         return value, identifier  1756   1757     def _show_menu(self, name, default, items, class_=""):  1758         page = self.page  1759         values = self.env.get_args().get(name, [default])  1760         page.select(name=name, class_=class_)  1761         for v, label in items:  1762             if v is None:  1763                 continue  1764             if v in values:  1765                 page.option(label, value=v, selected="selected")  1766             else:  1767                 page.option(label, value=v)  1768         page.select.close()  1769   1770     def _show_date_controls(self, name, default, attr, tzid):  1771   1772         """  1773         Show date controls for a field with the given 'name' and 'default' value  1774         and 'attr', with the given 'tzid' being used if no other time regime  1775         information is provided.  1776         """  1777   1778         page = self.page  1779         args = self.env.get_args()  1780   1781         event_tzid = attr.get("TZID", tzid)  1782         dt = get_datetime(default, attr)  1783   1784         # Show dates for up to one week around the current date.  1785   1786         base = get_date(dt)  1787         items = []  1788         for i in range(-7, 8):  1789             d = base + timedelta(i)  1790             items.append((format_datetime(d), self.format_date(d, "full")))  1791   1792         self._show_menu("%s-date" % name, format_datetime(base), items)  1793   1794         # Show time details.  1795   1796         dt_time = isinstance(dt, datetime) and dt or None  1797         hour = args.get("%s-hour" % name, "%02d" % (dt_time and dt_time.hour or 0))  1798         minute = args.get("%s-minute" % name, "%02d" % (dt_time and dt_time.minute or 0))  1799         second = args.get("%s-second" % name, "%02d" % (dt_time and dt_time.second or 0))  1800   1801         page.span(class_="time enabled")  1802         page.input(name="%s-hour" % name, type="text", value=hour, maxlength=2, size=2)  1803         page.add(":")  1804         page.input(name="%s-minute" % name, type="text", value=minute, maxlength=2, size=2)  1805         page.add(":")  1806         page.input(name="%s-second" % name, type="text", value=second, maxlength=2, size=2)  1807         page.add(" ")  1808         self._show_menu("%s-tzid" % name, event_tzid,  1809             [(event_tzid, event_tzid)] + (  1810             event_tzid != tzid and [(tzid, tzid)] or []  1811             ))  1812         page.span.close()  1813   1814     # Incoming HTTP request direction.  1815   1816     def select_action(self):  1817   1818         "Select the desired action and show the result."  1819   1820         path_info = self.env.get_path_info().strip("/")  1821   1822         if not path_info:  1823             self.show_calendar()  1824         elif self.show_object(path_info):  1825             pass  1826         else:  1827             self.no_page()  1828   1829     def __call__(self):  1830   1831         "Interpret a request and show an appropriate response."  1832   1833         if not self.user:  1834             self.no_user()  1835         else:  1836             self.select_action()  1837   1838         # Write the headers and actual content.  1839   1840         print >>self.out, "Content-Type: text/html; charset=%s" % self.encoding  1841         print >>self.out  1842         self.out.write(unicode(self.page).encode(self.encoding))  1843   1844 if __name__ == "__main__":  1845     Manager()()  1846   1847 # vim: tabstop=4 expandtab shiftwidth=4