imip-agent

imipweb/calendar.py

778:ef1a03f022c4
2015-09-28 Paul Boddie Ensure that the appropriate summary is provided when creating a new event. imipweb-client-simplification
     1 #!/usr/bin/env python     2      3 """     4 A Web interface to an event 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 from datetime import datetime    23 from imiptools.data import get_address, get_uri, uri_values    24 from imiptools.dates import format_datetime, get_datetime, \    25                             get_datetime_item, get_end_of_day, get_start_of_day, \    26                             get_start_of_next_day, get_timestamp, ends_on_same_day, \    27                             to_timezone    28 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \    29                              get_scale, get_slots, get_spans, partition_by_day, Point    30 from imipweb.resource import ResourceClient    31     32 class CalendarPage(ResourceClient):    33     34     "A request handler for the calendar page."    35     36     # Request logic methods.    37     38     def handle_newevent(self):    39     40         """    41         Handle any new event operation, creating a new event and redirecting to    42         the event page for further activity.    43         """    44     45         # Handle a submitted form.    46     47         args = self.env.get_args()    48     49         for key in args.keys():    50             if key.startswith("newevent-"):    51                 i = key[len("newevent-"):]    52                 break    53         else:    54             return    55     56         # Create a new event using the available information.    57     58         slots = args.get("slot", [])    59         participants = args.get("participants", [])    60         summary = args.get("summary-%s" % i, [None])[0]    61     62         if not slots:    63             return    64     65         # Obtain the user's timezone.    66     67         tzid = self.get_tzid()    68     69         # Coalesce the selected slots.    70     71         slots.sort()    72         coalesced = []    73         last = None    74     75         for slot in slots:    76             start, end = (slot.split("-", 1) + [None])[:2]    77             start = get_datetime(start, {"TZID" : tzid})    78             end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)    79     80             if last:    81                 last_start, last_end = last    82     83                 # Merge adjacent dates and datetimes.    84     85                 if start == last_end or \    86                     not isinstance(start, datetime) and \    87                     get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):    88     89                     last = last_start, end    90                     continue    91     92                 # Handle datetimes within dates.    93                 # Datetime periods are within single days and are therefore    94                 # discarded.    95     96                 elif not isinstance(last_start, datetime) and \    97                     get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):    98     99                     continue   100    101                 # Add separate dates and datetimes.   102    103                 else:   104                     coalesced.append(last)   105    106             last = start, end   107    108         if last:   109             coalesced.append(last)   110    111         # Invent a unique identifier.   112    113         utcnow = get_timestamp()   114         uid = "imip-agent-%s-%s" % (utcnow, get_address(self.user))   115    116         # Create a calendar object and store it as a request.   117    118         record = []   119         rwrite = record.append   120    121         # Define a single occurrence if only one coalesced slot exists.   122    123         start, end = coalesced[0]   124         start_value, start_attr = get_datetime_item(start, tzid)   125         end_value, end_attr = get_datetime_item(end, tzid)   126    127         rwrite(("UID", {}, uid))   128         rwrite(("SUMMARY", {}, summary or ("New event at %s" % utcnow)))   129         rwrite(("DTSTAMP", {}, utcnow))   130         rwrite(("DTSTART", start_attr, start_value))   131         rwrite(("DTEND", end_attr, end_value))   132         rwrite(("ORGANIZER", {}, self.user))   133    134         participants = uri_values(filter(None, participants))   135    136         for participant in participants:   137             rwrite(("ATTENDEE", {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}, participant))   138    139         if self.user not in participants:   140             rwrite(("ATTENDEE", {"PARTSTAT" : "ACCEPTED"}, self.user))   141    142         # Define additional occurrences if many slots are defined.   143    144         rdates = []   145    146         for start, end in coalesced[1:]:   147             start_value, start_attr = get_datetime_item(start, tzid)   148             end_value, end_attr = get_datetime_item(end, tzid)   149             rdates.append("%s/%s" % (start_value, end_value))   150    151         if rdates:   152             rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))   153    154         node = ("VEVENT", {}, record)   155    156         self.store.set_event(self.user, uid, None, node=node)   157         self.store.queue_request(self.user, uid)   158    159         # Redirect to the object (or the first of the objects), where instead of   160         # attendee controls, there will be organiser controls.   161    162         self.redirect(self.link_to(uid))   163    164     # Page fragment methods.   165    166     def show_requests_on_page(self):   167    168         "Show requests for the current user."   169    170         page = self.page   171    172         # NOTE: This list could be more informative, but it is envisaged that   173         # NOTE: the requests would be visited directly anyway.   174    175         requests = self._get_requests()   176    177         page.div(id="pending-requests")   178    179         if requests:   180             page.p("Pending requests:")   181    182             page.ul()   183    184             for uid, recurrenceid, request_type in requests:   185                 obj = self._get_object(uid, recurrenceid)   186                 if obj:   187                     page.li()   188                     page.a(obj.get_value("SUMMARY"), href="#request-%s-%s" % (uid, recurrenceid or ""))   189                     page.li.close()   190    191             page.ul.close()   192    193         else:   194             page.p("There are no pending requests.")   195    196         page.div.close()   197    198     def show_participants_on_page(self):   199    200         "Show participants for scheduling purposes."   201    202         page = self.page   203         args = self.env.get_args()   204         participants = args.get("participants", [])   205    206         try:   207             for name, value in args.items():   208                 if name.startswith("remove-participant-"):   209                     i = int(name[len("remove-participant-"):])   210                     del participants[i]   211                     break   212         except ValueError:   213             pass   214    215         # Trim empty participants.   216    217         while participants and not participants[-1].strip():   218             participants.pop()   219    220         # Show any specified participants together with controls to remove and   221         # add participants.   222    223         page.div(id="participants")   224    225         page.p("Participants for scheduling:")   226    227         for i, participant in enumerate(participants):   228             page.p()   229             page.input(name="participants", type="text", value=participant)   230             page.input(name="remove-participant-%d" % i, type="submit", value="Remove")   231             page.p.close()   232    233         page.p()   234         page.input(name="participants", type="text")   235         page.input(name="add-participant", type="submit", value="Add")   236         page.p.close()   237    238         page.div.close()   239    240         return participants   241    242     # Full page output methods.   243    244     def show(self):   245    246         "Show the calendar for the current user."   247    248         self.new_page(title="Calendar")   249         page = self.page   250    251         handled = self.handle_newevent()   252         freebusy = self.store.get_freebusy(self.user)   253    254         if not freebusy:   255             page.p("No events scheduled.")   256             return   257    258         # Form controls are used in various places on the calendar page.   259    260         page.form(method="POST")   261    262         self.show_requests_on_page()   263         participants = self.show_participants_on_page()   264    265         # Obtain the user's timezone.   266    267         tzid = self.get_tzid()   268    269         # Day view: start at the earliest known day and produce days until the   270         # latest known day, perhaps with expandable sections of empty days.   271    272         # Month view: start at the earliest known month and produce months until   273         # the latest known month, perhaps with expandable sections of empty   274         # months.   275    276         # Details of users to invite to new events could be superimposed on the   277         # calendar.   278    279         # Requests are listed and linked to their tentative positions in the   280         # calendar. Other participants are also shown.   281    282         request_summary = self._get_request_summary()   283    284         period_groups = [request_summary, freebusy]   285         period_group_types = ["request", "freebusy"]   286         period_group_sources = ["Pending requests", "Your schedule"]   287    288         for i, participant in enumerate(participants):   289             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))   290             period_group_types.append("freebusy-part%d" % i)   291             period_group_sources.append(participant)   292    293         groups = []   294         group_columns = []   295         group_types = period_group_types   296         group_sources = period_group_sources   297         all_points = set()   298    299         # Obtain time point information for each group of periods.   300    301         for periods in period_groups:   302    303             # Get the time scale with start and end points.   304    305             scale = get_scale(periods, tzid)   306    307             # Get the time slots for the periods.   308             # Time slots are collections of Point objects with lists of active   309             # periods.   310    311             slots = get_slots(scale)   312    313             # Add start of day time points for multi-day periods.   314    315             add_day_start_points(slots, tzid)   316    317             # Record the slots and all time points employed.   318    319             groups.append(slots)   320             all_points.update([point for point, active in slots])   321    322         # Partition the groups into days.   323    324         days = {}   325         partitioned_groups = []   326         partitioned_group_types = []   327         partitioned_group_sources = []   328    329         for slots, group_type, group_source in zip(groups, group_types, group_sources):   330    331             # Propagate time points to all groups of time slots.   332    333             add_slots(slots, all_points)   334    335             # Count the number of columns employed by the group.   336    337             columns = 0   338    339             # Partition the time slots by day.   340    341             partitioned = {}   342    343             for day, day_slots in partition_by_day(slots).items():   344    345                 # Construct a list of time intervals within the day.   346    347                 intervals = []   348    349                 # Convert each partition to a mapping from points to active   350                 # periods.   351    352                 partitioned[day] = day_points = {}   353    354                 last = None   355    356                 for point, active in day_slots:   357                     columns = max(columns, len(active))   358                     day_points[point] = active   359    360                     if last:   361                         intervals.append((last, point))   362    363                     last = point   364    365                 if last:   366                     intervals.append((last, None))   367    368                 if not days.has_key(day):   369                     days[day] = set()   370    371                 # Record the divisions or intervals within each day.   372    373                 days[day].update(intervals)   374    375             # Only include the requests column if it provides objects.   376    377             if group_type != "request" or columns:   378                 group_columns.append(columns)   379                 partitioned_groups.append(partitioned)   380                 partitioned_group_types.append(group_type)   381                 partitioned_group_sources.append(group_source)   382    383         # Add empty days.   384    385         add_empty_days(days, tzid)   386    387         page.p("Select days or periods for a new event.")   388    389         # Show controls for hiding empty days and busy slots.   390         # The positioning of the control, paragraph and table are important here.   391    392         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")   393         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")   394    395         page.p(class_="controls")   396         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")   397         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")   398         page.label("Show empty days", for_="showdays", class_="showdays disable")   399         page.label("Hide empty days", for_="showdays", class_="showdays enable")   400         page.input(name="reset", type="submit", value="Clear selections", id="reset")   401         page.label("Clear selections", for_="reset", class_="reset newevent-with-periods")   402         page.p.close()   403    404         # Show the calendar itself.   405    406         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns)   407    408         # End the form region.   409    410         page.form.close()   411    412     # More page fragment methods.   413    414     def show_calendar_day_controls(self, day):   415    416         "Show controls for the given 'day' in the calendar."   417    418         page = self.page   419         daystr, dayid = self._day_value_and_identifier(day)   420    421         # Generate a dynamic stylesheet to allow day selections to colour   422         # specific days.   423         # NOTE: The style details need to be coordinated with the static   424         # NOTE: stylesheet.   425    426         page.style(type="text/css")   427    428         page.add("""\   429 input.newevent.selector#%s:checked ~ table#region-%s label.day,   430 input.newevent.selector#%s:checked ~ table#region-%s label.timepoint {   431     background-color: #5f4;   432     text-decoration: underline;   433 }   434 """ % (dayid, dayid, dayid, dayid))   435    436         page.style.close()   437    438         # Generate controls to select days.   439    440         slots = self.env.get_args().get("slot", [])   441         value, identifier = self._day_value_and_identifier(day)   442         self._slot_selector(value, identifier, slots)   443    444     def show_calendar_interval_controls(self, day, intervals):   445    446         "Show controls for the intervals provided by 'day' and 'intervals'."   447    448         page = self.page   449         daystr, dayid = self._day_value_and_identifier(day)   450    451         # Generate a dynamic stylesheet to allow day selections to colour   452         # specific days.   453         # NOTE: The style details need to be coordinated with the static   454         # NOTE: stylesheet.   455    456         l = []   457    458         for point, endpoint in intervals:   459             timestr, timeid = self._slot_value_and_identifier(point, endpoint)   460             l.append("""\   461 input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid))   462    463         page.style(type="text/css")   464    465         page.add(",\n".join(l))   466         page.add(""" {   467     background-color: #5f4;   468     text-decoration: underline;   469 }   470 """)   471    472         page.style.close()   473    474         # Generate controls to select time periods.   475    476         slots = self.env.get_args().get("slot", [])   477         last = None   478    479         # Produce controls for the intervals/slots. Where instants in time are   480         # encountered, they are merged with the following slots, permitting the   481         # selection of contiguous time periods. However, the identifiers   482         # employed by controls corresponding to merged periods will encode the   483         # instant so that labels may reference them conveniently.   484    485         intervals = list(intervals)   486         intervals.sort()   487    488         for point, endpoint in intervals:   489    490             # Merge any previous slot with this one, producing a control.   491    492             if last:   493                 _value, identifier = self._slot_value_and_identifier(last, last)   494                 value, _identifier = self._slot_value_and_identifier(last, endpoint)   495                 self._slot_selector(value, identifier, slots)   496    497             # If representing an instant, hold the slot for merging.   498    499             if endpoint and point.point == endpoint.point:   500                 last = point   501    502             # If not representing an instant, produce a control.   503    504             else:   505                 value, identifier = self._slot_value_and_identifier(point, endpoint)   506                 self._slot_selector(value, identifier, slots)   507                 last = None   508    509         # Produce a control for any unmerged slot.   510    511         if last:   512             _value, identifier = self._slot_value_and_identifier(last, last)   513             value, _identifier = self._slot_value_and_identifier(last, endpoint)   514             self._slot_selector(value, identifier, slots)   515    516     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):   517    518         """   519         Show headings for the participants and other scheduling contributors,   520         defined by 'group_types', 'group_sources' and 'group_columns'.   521         """   522    523         page = self.page   524    525         page.colgroup(span=1, id="columns-timeslot")   526    527         for group_type, columns in zip(group_types, group_columns):   528             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)   529    530         page.thead()   531         page.tr()   532         page.th("", class_="emptyheading")   533    534         for group_type, source, columns in zip(group_types, group_sources, group_columns):   535             page.th(source,   536                 class_=(group_type == "request" and "requestheading" or "participantheading"),   537                 colspan=max(columns, 1))   538    539         page.tr.close()   540         page.thead.close()   541    542     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types,   543         partitioned_group_sources, group_columns):   544    545         """   546         Show calendar days, defined by a collection of 'days', the contributing   547         period information as 'partitioned_groups' (partitioned by day), the   548         'partitioned_group_types' indicating the kind of contribution involved,   549         the 'partitioned_group_sources' indicating the origin of each group, and   550         the 'group_columns' defining the number of columns in each group.   551         """   552    553         page = self.page   554    555         # Determine the number of columns required. Where participants provide   556         # no columns for events, one still needs to be provided for the   557         # participant itself.   558    559         all_columns = sum([max(columns, 1) for columns in group_columns])   560    561         # Determine the days providing time slots.   562    563         all_days = days.items()   564         all_days.sort()   565    566         # Produce a heading and time points for each day.   567    568         i = 0   569    570         for day, intervals in all_days:   571             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]   572             is_empty = True   573    574             for slots in groups_for_day:   575                 if not slots:   576                     continue   577    578                 for active in slots.values():   579                     if active:   580                         is_empty = False   581                         break   582    583             daystr, dayid = self._day_value_and_identifier(day)   584    585             # Put calendar tables within elements for quicker CSS selection.   586    587             page.div(class_="calendar")   588    589             # Show the controls permitting day selection as well as the controls   590             # configuring the new event display.   591    592             self.show_calendar_day_controls(day)   593             self.show_calendar_interval_controls(day, intervals)   594    595             # Show an actual table containing the day information.   596    597             page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid)   598    599             page.caption(class_="dayheading container separator")   600             self._day_heading(day)   601             page.caption.close()   602    603             self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)   604    605             page.tbody(class_="points")   606             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)   607             page.tbody.close()   608    609             page.table.close()   610    611             # Show a button for scheduling a new event.   612    613             page.p(class_="newevent-with-periods")   614             page.label("Summary:")   615             page.input(name="summary-%d" % i, type="text")   616             page.input(name="newevent-%d" % i, type="submit", value="New event", accesskey="N")   617             page.p.close()   618    619             page.div.close()   620    621             i += 1   622    623     def show_calendar_points(self, intervals, groups, group_types, group_columns):   624    625         """   626         Show the time 'intervals' along with period information from the given   627         'groups', having the indicated 'group_types', each with the number of   628         columns given by 'group_columns'.   629         """   630    631         page = self.page   632    633         # Obtain the user's timezone.   634    635         tzid = self.get_tzid()   636    637         # Produce a row for each interval.   638    639         intervals = list(intervals)   640         intervals.sort()   641    642         for point, endpoint in intervals:   643             continuation = point.point == get_start_of_day(point.point, tzid)   644    645             # Some rows contain no period details and are marked as such.   646    647             have_active = False   648             have_active_request = False   649    650             for slots, group_type in zip(groups, group_types):   651                 if slots and slots.get(point):   652                     if group_type == "request":   653                         have_active_request = True   654                     else:   655                         have_active = True   656    657             # Emit properties of the time interval, where post-instant intervals   658             # are also treated as busy.   659    660             css = " ".join([   661                 "slot",   662                 (have_active or point.indicator == Point.REPEATED) and "busy" or \   663                     have_active_request and "suggested" or "empty",   664                 continuation and "daystart" or ""   665                 ])   666    667             page.tr(class_=css)   668    669             # Produce a time interval heading, spanning two rows if this point   670             # represents an instant.   671    672             if point.indicator == Point.PRINCIPAL:   673                 timestr, timeid = self._slot_value_and_identifier(point, endpoint)   674                 page.th(class_="timeslot", id="region-%s" % timeid,   675                     rowspan=(endpoint and point.point == endpoint.point and 2 or 1))   676                 self._time_point(point, endpoint)   677                 page.th.close()   678    679             # Obtain slots for the time point from each group.   680    681             for columns, slots, group_type in zip(group_columns, groups, group_types):   682                 active = slots and slots.get(point)   683    684                 # Where no periods exist for the given time interval, generate   685                 # an empty cell. Where a participant provides no periods at all,   686                 # the colspan is adjusted to be 1, not 0.   687    688                 if not active:   689                     self._empty_slot(point, endpoint, max(columns, 1))   690                     continue   691    692                 slots = slots.items()   693                 slots.sort()   694                 spans = get_spans(slots)   695    696                 empty = 0   697    698                 # Show a column for each active period.   699    700                 for p in active:   701    702                     # The period can be None, meaning an empty column.   703    704                     if p:   705    706                         # Flush empty slots preceding this one.   707    708                         if empty:   709                             self._empty_slot(point, endpoint, empty)   710                             empty = 0   711    712                         key = p.get_key()   713                         span = spans[key]   714    715                         # Produce a table cell only at the start of the period   716                         # or when continued at the start of a day.   717                         # Points defining the ends of instant events should   718                         # never define the start of new events.   719    720                         if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation):   721    722                             has_continued = continuation and point.point != p.get_start()   723                             will_continue = not ends_on_same_day(point.point, p.get_end(), tzid)   724                             is_organiser = p.organiser == self.user   725    726                             css = " ".join([   727                                 "event",   728                                 has_continued and "continued" or "",   729                                 will_continue and "continues" or "",   730                                 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending",   731                                 self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "",   732                                 ])   733    734                             # Only anchor the first cell of events.   735                             # Need to only anchor the first period for a recurring   736                             # event.   737    738                             html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "")   739    740                             if point.point == p.get_start() and html_id not in self.html_ids:   741                                 page.td(class_=css, rowspan=span, id=html_id)   742                                 self.html_ids.add(html_id)   743                             else:   744                                 page.td(class_=css, rowspan=span)   745    746                             # Only link to events if they are not being updated   747                             # by requests.   748    749                             if not p.summary or \   750                                 group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True):   751    752                                 page.span(p.summary or "(Participant is busy)")   753    754                             # Link to requests and events (including ones for   755                             # which counter-proposals exist).   756    757                             elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True):   758                                 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid,   759                                     {"counter" : self._period_identifier(p)}))   760    761                             else:   762                                 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid))   763    764                             page.td.close()   765                     else:   766                         empty += 1   767    768                 # Pad with empty columns.   769    770                 empty = columns - len(active)   771    772                 if empty:   773                     self._empty_slot(point, endpoint, empty)   774    775             page.tr.close()   776    777     def _day_heading(self, day):   778    779         """   780         Generate a heading for 'day' of the following form:   781    782         <label class="day" for="day-20150203">Tuesday, 3 February 2015</label>   783         """   784    785         page = self.page   786         value, identifier = self._day_value_and_identifier(day)   787         page.label(self.format_date(day, "full"), class_="day", for_=identifier)   788    789     def _time_point(self, point, endpoint):   790    791         """   792         Generate headings for the 'point' to 'endpoint' period of the following   793         form:   794    795         <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>   796         <span class="endpoint">10:00:00 CET</span>   797         """   798    799         page = self.page   800         tzid = self.get_tzid()   801         value, identifier = self._slot_value_and_identifier(point, endpoint)   802         page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier)   803         page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint")   804    805     def _slot_selector(self, value, identifier, slots):   806    807         """   808         Provide a timeslot control having the given 'value', employing the   809         indicated HTML 'identifier', and using the given 'slots' collection   810         to select any control whose 'value' is in this collection, unless the   811         "reset" request parameter has been asserted.   812         """   813    814         reset = self.env.get_args().has_key("reset")   815         page = self.page   816         if not reset and value in slots:   817             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")   818         else:   819             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")   820    821     def _empty_slot(self, point, endpoint, colspan):   822    823         """   824         Show an empty slot cell for the given 'point' and 'endpoint', with the   825         given 'colspan' configuring the cell's appearance.   826         """   827    828         page = self.page   829         page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan)   830         if point.indicator == Point.PRINCIPAL:   831             value, identifier = self._slot_value_and_identifier(point, endpoint)   832             page.label("Select/deselect period", class_="newevent popup", for_=identifier)   833         page.td.close()   834    835     def _day_value_and_identifier(self, day):   836    837         "Return a day value and HTML identifier for the given 'day'."   838    839         value = format_datetime(day)   840         identifier = "day-%s" % value   841         return value, identifier   842    843     def _slot_value_and_identifier(self, point, endpoint):   844    845         """   846         Return a slot value and HTML identifier for the given 'point' and   847         'endpoint'.   848         """   849    850         value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "")   851         identifier = "slot-%s" % value   852         return value, identifier   853    854     def _period_identifier(self, period):   855         return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end()))   856    857 # vim: tabstop=4 expandtab shiftwidth=4