imip-agent

imipweb/calendar.py

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