imip-agent

imip_manager.py

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