imip-agent

imipweb/calendar.py

1272:65e999dd88f0
2017-09-18 Paul Boddie Added a convenience method for loading objects. Added docstrings. client-editing-simplification
     1 #!/usr/bin/env python     2      3 """     4 A Web interface to an event calendar.     5      6 Copyright (C) 2014, 2015, 2016, 2017 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, timedelta    23 from imiptools.data import get_address, get_uri, get_verbose_address, make_uid, \    24                            uri_parts    25 from imiptools.dates import format_datetime, get_date, get_datetime, \    26                             get_datetime_item, get_end_of_day, get_start_of_day, \    27                             get_start_of_next_day, get_timestamp, ends_on_same_day, \    28                             to_date, to_timezone    29 from imiptools.period import add_day_start_points, add_empty_days, add_slots, \    30                              get_scale, get_slots, get_spans, partition_by_day, \    31                              remove_end_slot, Period, Point    32 from imipweb.resource import FormUtilities, ResourceClient    33     34 class CalendarPage(ResourceClient, FormUtilities):    35     36     "A request handler for the calendar page."    37     38     # Request logic methods.    39     40     def handle_newevent(self):    41     42         """    43         Handle any new event operation, creating a new event and redirecting to    44         the event page for further activity.    45         """    46     47         _ = self.get_translator()    48     49         # Check the validation token.    50     51         if not self.check_validation_token():    52             return False    53     54         # Handle a submitted form.    55     56         args = self.env.get_args()    57     58         for key in args.keys():    59             if key.startswith("newevent-"):    60                 i = key[len("newevent-"):]    61                 break    62         else:    63             return False    64     65         # Create a new event using the available information.    66     67         slots = args.get("slot", [])    68         participants = args.get("participants", [])    69         summary = args.get("summary-%s" % i, [None])[0]    70     71         if not slots:    72             return False    73     74         # Obtain the user's timezone.    75     76         tzid = self.get_tzid()    77     78         # Coalesce the selected slots.    79     80         slots.sort()    81         coalesced = []    82         last = None    83     84         for slot in slots:    85             start, end = (slot.split("-", 1) + [None])[:2]    86             start = get_datetime(start, {"TZID" : tzid})    87             end = end and get_datetime(end, {"TZID" : tzid}) or get_start_of_next_day(start, tzid)    88     89             if last:    90                 last_start, last_end = last    91     92                 # Merge adjacent dates and datetimes.    93     94                 if start == last_end or \    95                     not isinstance(start, datetime) and \    96                     get_start_of_day(last_end, tzid) == get_start_of_day(start, tzid):    97     98                     last = last_start, end    99                     continue   100    101                 # Handle datetimes within dates.   102                 # Datetime periods are within single days and are therefore   103                 # discarded.   104    105                 elif not isinstance(last_start, datetime) and \   106                     get_start_of_day(start, tzid) == get_start_of_day(last_start, tzid):   107    108                     continue   109    110                 # Add separate dates and datetimes.   111    112                 else:   113                     coalesced.append(last)   114    115             last = start, end   116    117         if last:   118             coalesced.append(last)   119    120         # Invent a unique identifier.   121    122         uid = make_uid(self.user)   123    124         # Create a calendar object and store it as a request.   125    126         record = []   127         rwrite = record.append   128    129         # Define a single occurrence if only one coalesced slot exists.   130    131         start, end = coalesced[0]   132         start_value, start_attr = get_datetime_item(start, tzid)   133         end_value, end_attr = get_datetime_item(end, tzid)   134         user_attr = self.get_user_attributes()   135    136         utcnow = get_timestamp()   137    138         rwrite(("UID", {}, uid))   139         rwrite(("SUMMARY", {}, summary or (_("New event at %s") % utcnow)))   140         rwrite(("DTSTAMP", {}, utcnow))   141         rwrite(("DTSTART", start_attr, start_value))   142         rwrite(("DTEND", end_attr, end_value))   143         rwrite(("ORGANIZER", user_attr, self.user))   144    145         cn_participants = uri_parts(filter(None, participants))   146         participants = []   147    148         for cn, participant in cn_participants:   149             d = {"RSVP" : "TRUE", "PARTSTAT" : "NEEDS-ACTION"}   150             if cn:   151                 d["CN"] = cn   152             rwrite(("ATTENDEE", d, participant))   153             participants.append(participant)   154    155         if self.user not in participants:   156             d = {"PARTSTAT" : "ACCEPTED"}   157             d.update(user_attr)   158             rwrite(("ATTENDEE", d, self.user))   159    160         # Define additional occurrences if many slots are defined.   161    162         rdates = []   163    164         for start, end in coalesced[1:]:   165             start_value, start_attr = get_datetime_item(start, tzid)   166             end_value, end_attr = get_datetime_item(end, tzid)   167             rdates.append("%s/%s" % (start_value, end_value))   168    169         if rdates:   170             rwrite(("RDATE", {"VALUE" : "PERIOD", "TZID" : tzid}, rdates))   171    172         node = ("VEVENT", {}, record)   173    174         self.store.set_event(self.user, uid, None, node=node)   175         self.store.queue_request(self.user, uid)   176    177         # Redirect to the object (or the first of the objects), where instead of   178         # attendee controls, there will be organiser controls.   179    180         self.redirect(self.link_to(uid, args=self.get_time_navigation_args()))   181         return True   182    183     def update_participants(self):   184    185         "Update the participants used for scheduling purposes."   186    187         args = self.env.get_args()   188         participants = args.get("participants", [])   189    190         try:   191             for name, value in args.items():   192                 if name.startswith("remove-participant-"):   193                     i = int(name[len("remove-participant-"):])   194                     del participants[i]   195                     break   196         except ValueError:   197             pass   198    199         # Trim empty participants.   200    201         while participants and not participants[-1].strip():   202             participants.pop()   203    204         return participants   205    206     # Page fragment methods.   207    208     def show_user_navigation(self):   209    210         "Show user-specific navigation."   211    212         page = self.page   213         user_attr = self.get_user_attributes()   214    215         page.p(id_="user-navigation")   216         page.a(get_verbose_address(self.user, user_attr), href=self.link_to("profile"), class_="username")   217         page.p.close()   218    219     def show_requests_on_page(self):   220    221         "Show requests for the current user."   222    223         _ = self.get_translator()   224    225         page = self.page   226         view_period = self.get_view_period()   227         duration = view_period and view_period.get_duration() or timedelta(1)   228    229         # NOTE: This list could be more informative, but it is envisaged that   230         # NOTE: the requests would be visited directly anyway.   231    232         requests = self._get_requests()   233    234         page.div(id="pending-requests")   235    236         if requests:   237             page.p(_("Pending requests:"))   238    239             page.ul()   240    241             for uid, recurrenceid, request_type in requests:   242                 obj = self._get_object(uid, recurrenceid)   243                 if obj:   244    245                     # Provide a link showing the request in context.   246    247                     periods = self.get_periods(obj)   248                     if periods:   249                         start = to_date(periods[0].get_start())   250                         end = max(to_date(periods[0].get_end()), start + duration)   251                         d = {"start" : format_datetime(start), "end" : format_datetime(end)}   252                         page.li()   253                         page.a(obj.get_value("SUMMARY"), href="%s#request-%s-%s" % (self.link_to(args=d), uid, recurrenceid or ""))   254                         page.li.close()   255    256             page.ul.close()   257    258         else:   259             page.p(_("There are no pending requests."))   260    261         page.div.close()   262    263     def show_participants_on_page(self, participants):   264    265         "Show participants for scheduling purposes."   266    267         _ = self.get_translator()   268    269         page = self.page   270    271         # Show any specified participants together with controls to remove and   272         # add participants.   273    274         page.div(id="participants")   275    276         page.p(_("Participants for scheduling:"))   277    278         for i, participant in enumerate(participants):   279             page.p()   280             page.input(name="participants", type="text", value=participant)   281             page.input(name="remove-participant-%d" % i, type="submit", value=_("Remove"))   282             page.p.close()   283    284         page.p()   285         page.input(name="participants", type="text")   286         page.input(name="add-participant", type="submit", value=_("Add"))   287         page.p.close()   288    289         page.div.close()   290    291     def show_calendar_controls(self):   292    293         """   294         Show controls for hiding empty days and busy slots in the calendar.   295    296         The positioning of the controls, paragraph and table are important here:   297         the CSS file describes the relationship between them and the calendar   298         tables.   299         """   300    301         _ = self.get_translator()   302    303         page = self.page   304         args = self.env.get_args()   305    306         self.control("showdays", "checkbox", "show", ("show" in args.get("showdays", [])), id="showdays", accesskey="D")   307         self.control("hidebusy", "checkbox", "hide", ("hide" in args.get("hidebusy", [])), id="hidebusy", accesskey="B")   308    309         page.p(id_="calendar-controls", class_="controls")   310         page.span(_("Select days or periods for a new event."))   311         page.label(_("Hide busy time periods"), for_="hidebusy", class_="hidebusy enable")   312         page.label(_("Show busy time periods"), for_="hidebusy", class_="hidebusy disable")   313         page.label(_("Show empty days"), for_="showdays", class_="showdays disable")   314         page.label(_("Hide empty days"), for_="showdays", class_="showdays enable")   315         page.input(name="reset", type="submit", value=_("Clear selections"), id="reset")   316         page.p.close()   317    318     def show_time_navigation(self, freebusy, view_period):   319    320         """   321         Show the calendar navigation links for the schedule defined by   322         'freebusy' and for the period defined by 'view_period'.   323         """   324    325         _ = self.get_translator()   326    327         page = self.page   328         view_start = view_period.get_start()   329         view_end = view_period.get_end()   330         duration = view_period.get_duration()   331    332         preceding_events = view_start and freebusy.get_overlapping([Period(None, view_start, self.get_tzid())]) or []   333         following_events = view_end and freebusy.get_overlapping([Period(view_end, None, self.get_tzid())]) or []   334    335         last_preceding = preceding_events and to_date(preceding_events[-1].get_end()) + timedelta(1) or None   336         first_following = following_events and to_date(following_events[0].get_start()) or None   337    338         page.p(id_="time-navigation")   339    340         if view_start:   341             page.input(name="start", type="hidden", value=format_datetime(view_start))   342    343             if last_preceding:   344                 preceding_start = last_preceding - duration   345                 page.label(_("Show earlier events"), for_="earlier-events", class_="earlier-events")   346                 page.input(name="earlier-events", id_="earlier-events", type="submit")   347                 page.input(name="earlier-events-start", type="hidden", value=format_datetime(preceding_start))   348                 page.input(name="earlier-events-end", type="hidden", value=format_datetime(last_preceding))   349    350             earlier_start = view_start - duration   351             page.label(_("Show earlier"), for_="earlier", class_="earlier")   352             page.input(name="earlier", id_="earlier", type="submit")   353             page.input(name="earlier-start", type="hidden", value=format_datetime(earlier_start))   354             page.input(name="earlier-end", type="hidden", value=format_datetime(view_start))   355    356         if view_end:   357             page.input(name="end", type="hidden", value=format_datetime(view_end))   358    359             later_end = view_end + duration   360             page.label(_("Show later"), for_="later", class_="later")   361             page.input(name="later", id_="later", type="submit")   362             page.input(name="later-start", type="hidden", value=format_datetime(view_end))   363             page.input(name="later-end", type="hidden", value=format_datetime(later_end))   364    365             if first_following:   366                 following_end = first_following + duration   367                 page.label(_("Show later events"), for_="later-events", class_="later-events")   368                 page.input(name="later-events", id_="later-events", type="submit")   369                 page.input(name="later-events-start", type="hidden", value=format_datetime(first_following))   370                 page.input(name="later-events-end", type="hidden", value=format_datetime(following_end))   371    372         page.p.close()   373    374     def get_time_navigation(self):   375    376         "Return the start and end dates for the calendar view."   377    378         for args in [self.env.get_args(), self.env.get_query()]:   379             if args.has_key("earlier"):   380                 start_name, end_name = "earlier-start", "earlier-end"   381                 break   382             elif args.has_key("earlier-events"):   383                 start_name, end_name = "earlier-events-start", "earlier-events-end"   384                 break   385             elif args.has_key("later"):   386                 start_name, end_name = "later-start", "later-end"   387                 break   388             elif args.has_key("later-events"):   389                 start_name, end_name = "later-events-start", "later-events-end"   390                 break   391             elif args.has_key("start") or args.has_key("end"):   392                 start_name, end_name = "start", "end"   393                 break   394         else:   395             return None, None   396    397         view_start = self.get_date_arg(args, start_name)   398         view_end = self.get_date_arg(args, end_name)   399         return view_start, view_end   400    401     def get_time_navigation_args(self):   402    403         "Return a dictionary containing start and/or end navigation details."   404    405         view_period = self.get_view_period()   406         view_start = view_period.get_start()   407         view_end = view_period.get_end()   408         link_args = {}   409         if view_start:   410             link_args["start"] = format_datetime(view_start)   411         if view_end:   412             link_args["end"] = format_datetime(view_end)   413         return link_args   414    415     def get_view_period(self):   416    417         "Return the current view period."   418    419         view_start, view_end = self.get_time_navigation()   420    421         # Without any explicit limits, impose a reasonable view period.   422    423         if not (view_start or view_end):   424             view_start = get_date()   425             view_end = get_date(timedelta(7))   426    427         return Period(view_start, view_end, self.get_tzid())   428    429     def show_view_period(self, view_period):   430    431         "Show a description of the 'view_period'."   432    433         _ = self.get_translator()   434    435         page = self.page   436    437         view_start = view_period.get_start()   438         view_end = view_period.get_end()   439    440         if not (view_start or view_end):   441             return   442    443         page.p(class_="view-period")   444    445         if view_start and view_end:   446             page.add(_("Showing events from %(start)s until %(end)s") % {   447                 "start" : self.format_date(view_start, "full"),   448                 "end" : self.format_date(view_end, "full")})   449         elif view_start:   450             page.add(_("Showing events from %s") % self.format_date(view_start, "full"))   451         elif view_end:   452             page.add(_("Showing events until %s") % self.format_date(view_end, "full"))   453    454         page.p.close()   455    456     def get_period_group_details(self, freebusy, participants, view_period):   457    458         """   459         Return details of periods in the given 'freebusy' collection and for the   460         collections of the given 'participants'.   461         """   462    463         _ = self.get_translator()   464    465         # Obtain the user's timezone.   466    467         tzid = self.get_tzid()   468    469         # Requests are listed and linked to their tentative positions in the   470         # calendar. Other participants are also shown.   471    472         request_summary = self._get_request_summary(view_period)   473    474         period_groups = [request_summary, freebusy]   475         period_group_types = ["request", "freebusy"]   476         period_group_sources = [_("Pending requests"), _("Your schedule")]   477    478         for i, participant in enumerate(participants):   479             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))   480             period_group_types.append("freebusy-part%d" % i)   481             period_group_sources.append(participant)   482    483         groups = []   484         group_columns = []   485         group_types = period_group_types   486         group_sources = period_group_sources   487         all_points = set()   488    489         # Obtain time point information for each group of periods.   490    491         for periods in period_groups:   492    493             # Filter periods outside the given view.   494    495             if view_period:   496                 periods = periods.get_overlapping([view_period])   497    498             # Get the time scale with start and end points.   499    500             scale = get_scale(periods, tzid, view_period)   501    502             # Get the time slots for the periods.   503             # Time slots are collections of Point objects with lists of active   504             # periods.   505    506             slots = get_slots(scale)   507    508             # Add start of day time points for multi-day periods.   509    510             add_day_start_points(slots, tzid)   511    512             # Remove the slot at the end of a view.   513    514             if view_period:   515                 remove_end_slot(slots, view_period)   516    517             # Record the slots and all time points employed.   518    519             groups.append(slots)   520             all_points.update([point for point, active in slots])   521    522         # Partition the groups into days.   523    524         days = {}   525         partitioned_groups = []   526         partitioned_group_types = []   527         partitioned_group_sources = []   528    529         for slots, group_type, group_source in zip(groups, group_types, group_sources):   530    531             # Propagate time points to all groups of time slots.   532    533             add_slots(slots, all_points)   534    535             # Count the number of columns employed by the group.   536    537             columns = 0   538    539             # Partition the time slots by day.   540    541             partitioned = {}   542    543             for day, day_slots in partition_by_day(slots).items():   544    545                 # Construct a list of time intervals within the day.   546    547                 intervals = []   548    549                 # Convert each partition to a mapping from points to active   550                 # periods.   551    552                 partitioned[day] = day_points = {}   553    554                 last = None   555    556                 for point, active in day_slots:   557                     columns = max(columns, len(active))   558                     day_points[point] = active   559    560                     if last:   561                         intervals.append((last, point))   562    563                     last = point   564    565                 if last:   566                     intervals.append((last, None))   567    568                 if not days.has_key(day):   569                     days[day] = set()   570    571                 # Record the divisions or intervals within each day.   572    573                 days[day].update(intervals)   574    575             # Only include the requests column if it provides objects.   576    577             if group_type != "request" or columns:   578                 if group_type != "request":   579                     columns += 1   580                 group_columns.append(columns)   581                 partitioned_groups.append(partitioned)   582                 partitioned_group_types.append(group_type)   583                 partitioned_group_sources.append(group_source)   584    585         return days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns   586    587     # Full page output methods.   588    589     def show(self):   590    591         "Show the calendar for the current user."   592    593         _ = self.get_translator()   594    595         self.new_page(title=_("Calendar"))   596         page = self.page   597    598         if self.handle_newevent():   599             return   600    601         freebusy = self.store.get_freebusy(self.user)   602         participants = self.update_participants()   603    604         # Form controls are used in various places on the calendar page.   605    606         page.form(method="POST")   607         self.validator()   608         self.show_user_navigation()   609         self.show_requests_on_page()   610         self.show_participants_on_page(participants)   611    612         # Get the view period and details of events within it and outside it.   613    614         view_period = self.get_view_period()   615    616         # Day view: start at the earliest known day and produce days until the   617         # latest known day, with expandable sections of empty days.   618    619         (days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns) = \   620             self.get_period_group_details(freebusy, participants, view_period)   621    622         # Add empty days.   623    624         add_empty_days(days, self.get_tzid(), view_period.get_start(), view_period.get_end())   625    626         # Show controls to change the calendar appearance.   627    628         self.show_view_period(view_period)   629         self.show_calendar_controls()   630         self.show_time_navigation(freebusy, view_period)   631    632         # Show the calendar itself.   633    634         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, partitioned_group_sources, group_columns)   635    636         # End the form region.   637    638         page.form.close()   639    640     # More page fragment methods.   641    642     def show_calendar_day_controls(self, day):   643    644         "Show controls for the given 'day' in the calendar."   645    646         page = self.page   647         daystr, dayid = self._day_value_and_identifier(day)   648    649         # Generate a dynamic stylesheet to allow day selections to colour   650         # specific days.   651         # NOTE: The style details need to be coordinated with the static   652         # NOTE: stylesheet.   653    654         page.style(type="text/css")   655    656         page.add("""\   657 input.newevent.selector#%s:checked ~ table#region-%s label.day,   658 input.newevent.selector#%s:checked ~ table#region-%s label.timepoint {   659     background-color: #5f4;   660     text-decoration: underline;   661 }   662 """ % (dayid, dayid, dayid, dayid))   663    664         page.style.close()   665    666         # Generate controls to select days.   667    668         slots = self.env.get_args().get("slot", [])   669         value, identifier = self._day_value_and_identifier(day)   670         self._slot_selector(value, identifier, slots)   671    672     def show_calendar_interval_controls(self, day, intervals):   673    674         "Show controls for the intervals provided by 'day' and 'intervals'."   675    676         page = self.page   677         daystr, dayid = self._day_value_and_identifier(day)   678    679         # Generate a dynamic stylesheet to allow day selections to colour   680         # specific days.   681         # NOTE: The style details need to be coordinated with the static   682         # NOTE: stylesheet.   683    684         l = []   685    686         for point, endpoint in intervals:   687             timestr, timeid = self._slot_value_and_identifier(point, endpoint)   688             l.append("""\   689 input.newevent.selector#%s:checked ~ table#region-%s th#region-%s""" % (timeid, dayid, timeid))   690    691         page.style(type="text/css")   692    693         page.add(",\n".join(l))   694         page.add(""" {   695     background-color: #5f4;   696     text-decoration: underline;   697 }   698 """)   699    700         page.style.close()   701    702         # Generate controls to select time periods.   703    704         slots = self.env.get_args().get("slot", [])   705         last = None   706    707         # Produce controls for the intervals/slots. Where instants in time are   708         # encountered, they are merged with the following slots, permitting the   709         # selection of contiguous time periods. However, the identifiers   710         # employed by controls corresponding to merged periods will encode the   711         # instant so that labels may reference them conveniently.   712    713         intervals = list(intervals)   714         intervals.sort()   715    716         for point, endpoint in intervals:   717    718             # Merge any previous slot with this one, producing a control.   719    720             if last:   721                 _value, identifier = self._slot_value_and_identifier(last, last)   722                 value, _identifier = self._slot_value_and_identifier(last, endpoint)   723                 self._slot_selector(value, identifier, slots)   724    725             # If representing an instant, hold the slot for merging.   726    727             if endpoint and point.point == endpoint.point:   728                 last = point   729    730             # If not representing an instant, produce a control.   731    732             else:   733                 value, identifier = self._slot_value_and_identifier(point, endpoint)   734                 self._slot_selector(value, identifier, slots)   735                 last = None   736    737         # Produce a control for any unmerged slot.   738    739         if last:   740             _value, identifier = self._slot_value_and_identifier(last, last)   741             value, _identifier = self._slot_value_and_identifier(last, endpoint)   742             self._slot_selector(value, identifier, slots)   743    744     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):   745    746         """   747         Show headings for the participants and other scheduling contributors,   748         defined by 'group_types', 'group_sources' and 'group_columns'.   749         """   750    751         page = self.page   752    753         page.colgroup(span=1, id="columns-timeslot")   754    755         # Make column groups at least two cells wide.   756    757         for group_type, columns in zip(group_types, group_columns):   758             page.colgroup(span=max(columns, 2), id="columns-%s" % group_type)   759    760         page.thead()   761         page.tr()   762         page.th("", class_="emptyheading")   763    764         for group_type, source, columns in zip(group_types, group_sources, group_columns):   765             page.th(source,   766                 class_=(group_type == "request" and "requestheading" or "participantheading"),   767                 colspan=max(columns, 2))   768    769         page.tr.close()   770         page.thead.close()   771    772     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types,   773         partitioned_group_sources, group_columns):   774    775         """   776         Show calendar days, defined by a collection of 'days', the contributing   777         period information as 'partitioned_groups' (partitioned by day), the   778         'partitioned_group_types' indicating the kind of contribution involved,   779         the 'partitioned_group_sources' indicating the origin of each group, and   780         the 'group_columns' defining the number of columns in each group.   781         """   782    783         _ = self.get_translator()   784    785         page = self.page   786    787         # Determine the number of columns required. Where participants provide   788         # no columns for events, one still needs to be provided for the   789         # participant itself.   790    791         all_columns = sum([max(columns, 1) for columns in group_columns])   792    793         # Determine the days providing time slots.   794    795         all_days = days.items()   796         all_days.sort()   797    798         # Produce a heading and time points for each day.   799    800         i = 0   801    802         for day, intervals in all_days:   803             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]   804             is_empty = True   805    806             for slots in groups_for_day:   807                 if not slots:   808                     continue   809    810                 for active in slots.values():   811                     if active:   812                         is_empty = False   813                         break   814    815             daystr, dayid = self._day_value_and_identifier(day)   816    817             # Put calendar tables within elements for quicker CSS selection.   818    819             page.div(class_="calendar")   820    821             # Show the controls permitting day selection as well as the controls   822             # configuring the new event display.   823    824             self.show_calendar_day_controls(day)   825             self.show_calendar_interval_controls(day, intervals)   826    827             # Show an actual table containing the day information.   828    829             page.table(cellspacing=5, cellpadding=5, class_="calendar %s" % (is_empty and " empty" or ""), id="region-%s" % dayid)   830    831             page.caption(class_="dayheading container separator")   832             self._day_heading(day)   833             page.caption.close()   834    835             self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)   836    837             page.tbody(class_="points")   838             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)   839             page.tbody.close()   840    841             page.table.close()   842    843             # Show a button for scheduling a new event.   844    845             page.p(class_="newevent-with-periods")   846             page.label(_("Summary:"))   847             page.input(name="summary-%d" % i, type="text")   848             page.input(name="newevent-%d" % i, type="submit", value=_("New event"), accesskey="N")   849             page.p.close()   850    851             page.p(class_="newevent-with-periods")   852             page.label(_("Clear selections"), for_="reset", class_="reset")   853             page.p.close()   854    855             page.div.close()   856    857             i += 1   858    859     def show_calendar_points(self, intervals, groups, group_types, group_columns):   860    861         """   862         Show the time 'intervals' along with period information from the given   863         'groups', having the indicated 'group_types', each with the number of   864         columns given by 'group_columns'.   865         """   866    867         _ = self.get_translator()   868    869         page = self.page   870    871         # Obtain the user's timezone.   872    873         tzid = self.get_tzid()   874    875         # Get view information for links.   876    877         link_args = self.get_time_navigation_args()   878    879         # Produce a row for each interval.   880    881         intervals = list(intervals)   882         intervals.sort()   883    884         for point, endpoint in intervals:   885             continuation = point.point == get_start_of_day(point.point, tzid)   886    887             # Some rows contain no period details and are marked as such.   888    889             have_active = False   890             have_active_request = False   891    892             for slots, group_type in zip(groups, group_types):   893                 if slots and slots.get(point):   894                     if group_type == "request":   895                         have_active_request = True   896                     else:   897                         have_active = True   898    899             # Emit properties of the time interval, where post-instant intervals   900             # are also treated as busy.   901    902             css = " ".join([   903                 "slot",   904                 (have_active or point.indicator == Point.REPEATED) and "busy" or \   905                     have_active_request and "suggested" or "empty",   906                 continuation and "daystart" or ""   907                 ])   908    909             page.tr(class_=css)   910    911             # Produce a time interval heading, spanning two rows if this point   912             # represents an instant.   913    914             if point.indicator == Point.PRINCIPAL:   915                 timestr, timeid = self._slot_value_and_identifier(point, endpoint)   916                 page.th(class_="timeslot", id="region-%s" % timeid,   917                     rowspan=(endpoint and point.point == endpoint.point and 2 or 1))   918                 self._time_point(point, endpoint)   919                 page.th.close()   920    921             # Obtain slots for the time point from each group.   922    923             for columns, slots, group_type in zip(group_columns, groups, group_types):   924    925                 # Make column groups at least two cells wide.   926    927                 columns = max(columns, 2)   928                 active = slots and slots.get(point)   929    930                 # Where no periods exist for the given time interval, generate   931                 # an empty cell. Where a participant provides no periods at all,   932                 # one column is provided; otherwise, one more column than the   933                 # number required is provided.   934    935                 if not active:   936                     self._empty_slot(point, endpoint, max(columns, 2))   937                     continue   938    939                 slots = slots.items()   940                 slots.sort()   941                 spans = get_spans(slots)   942    943                 empty = 0   944    945                 # Show a column for each active period.   946    947                 for p in active:   948    949                     # The period can be None, meaning an empty column.   950    951                     if p:   952    953                         # Flush empty slots preceding this one.   954    955                         if empty:   956                             self._empty_slot(point, endpoint, empty)   957                             empty = 0   958    959                         key = p.get_key()   960                         span = spans[key]   961    962                         # Produce a table cell only at the start of the period   963                         # or when continued at the start of a day.   964                         # Points defining the ends of instant events should   965                         # never define the start of new events.   966    967                         if point.indicator == Point.PRINCIPAL and (point.point == p.get_start() or continuation):   968    969                             has_continued = continuation and point.point != p.get_start()   970                             will_continue = not ends_on_same_day(point.point, p.get_end(), tzid)   971                             is_organiser = p.organiser == self.user   972    973                             css = " ".join([   974                                 "event",   975                                 has_continued and "continued" or "",   976                                 will_continue and "continues" or "",   977                                 p.transp == "ORG" and "only-organising" or is_organiser and "organising" or "attending",   978                                 self._have_request(p.uid, p.recurrenceid, "COUNTER", True) and "counter" or "",   979                                 ])   980    981                             # Only anchor the first cell of events.   982                             # Need to only anchor the first period for a recurring   983                             # event.   984    985                             html_id = "%s-%s-%s" % (group_type, p.uid, p.recurrenceid or "")   986    987                             if point.point == p.get_start() and html_id not in self.html_ids:   988                                 page.td(class_=css, rowspan=span, id=html_id)   989                                 self.html_ids.add(html_id)   990                             else:   991                                 page.td(class_=css, rowspan=span)   992    993                             # Only link to events if they are not being updated   994                             # by requests.   995    996                             if not p.summary or \   997                                 group_type != "request" and self._have_request(p.uid, p.recurrenceid, None, True):   998    999                                 page.span(p.summary or _("(Participant is busy)"))  1000   1001                             # Link to requests and events (including ones for  1002                             # which counter-proposals exist).  1003   1004                             elif group_type == "request" and self._have_request(p.uid, p.recurrenceid, "COUNTER", True):  1005                                 d = {"counter" : self._period_identifier(p)}  1006                                 d.update(link_args)  1007                                 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, d))  1008   1009                             else:  1010                                 page.a(p.summary, href=self.link_to(p.uid, p.recurrenceid, link_args))  1011   1012                             page.td.close()  1013                     else:  1014                         empty += 1  1015   1016                 # Pad with empty columns.  1017   1018                 empty = columns - len(active)  1019   1020                 if empty:  1021                     self._empty_slot(point, endpoint, empty, True)  1022   1023             page.tr.close()  1024   1025     def _day_heading(self, day):  1026   1027         """  1028         Generate a heading for 'day' of the following form:  1029   1030         <label class="day" for="day-20150203">Tuesday, 3 February 2015</label>  1031         """  1032   1033         page = self.page  1034         value, identifier = self._day_value_and_identifier(day)  1035         page.label(self.format_date(day, "full"), class_="day", for_=identifier)  1036   1037     def _time_point(self, point, endpoint):  1038   1039         """  1040         Generate headings for the 'point' to 'endpoint' period of the following  1041         form:  1042   1043         <label class="timepoint" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>  1044         <span class="endpoint">10:00:00 CET</span>  1045         """  1046   1047         page = self.page  1048         tzid = self.get_tzid()  1049         value, identifier = self._slot_value_and_identifier(point, endpoint)  1050         page.label(self.format_time(point.point, "long"), class_="timepoint", for_=identifier)  1051         page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint")  1052   1053     def _slot_selector(self, value, identifier, slots):  1054   1055         """  1056         Provide a timeslot control having the given 'value', employing the  1057         indicated HTML 'identifier', and using the given 'slots' collection  1058         to select any control whose 'value' is in this collection, unless the  1059         "reset" request parameter has been asserted.  1060         """  1061   1062         reset = self.env.get_args().has_key("reset")  1063         page = self.page  1064         if not reset and value in slots:  1065             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")  1066         else:  1067             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")  1068   1069     def _empty_slot(self, point, endpoint, colspan, at_end=False):  1070   1071         """  1072         Show an empty slot cell for the given 'point' and 'endpoint', with the  1073         given 'colspan' configuring the cell's appearance.  1074         """  1075   1076         _ = self.get_translator()  1077   1078         page = self.page  1079         page.td(class_="empty%s%s" % (point.indicator == Point.PRINCIPAL and " container" or "", at_end and " padding" or ""), colspan=colspan)  1080         if point.indicator == Point.PRINCIPAL:  1081             value, identifier = self._slot_value_and_identifier(point, endpoint)  1082             page.label(_("Select/deselect period"), class_="newevent popup", for_=identifier)  1083         page.td.close()  1084   1085     def _day_value_and_identifier(self, day):  1086   1087         "Return a day value and HTML identifier for the given 'day'."  1088   1089         value = format_datetime(day)  1090         identifier = "day-%s" % value  1091         return value, identifier  1092   1093     def _slot_value_and_identifier(self, point, endpoint):  1094   1095         """  1096         Return a slot value and HTML identifier for the given 'point' and  1097         'endpoint'.  1098         """  1099   1100         value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "")  1101         identifier = "slot-%s" % value  1102         return value, identifier  1103   1104     def _period_identifier(self, period):  1105         return "%s-%s" % (format_datetime(period.get_start()), format_datetime(period.get_end()))  1106   1107     def get_date_arg(self, args, name):  1108         values = args.get(name)  1109         if not values:  1110             return None  1111         return get_datetime(values[0], {"VALUE-TYPE" : "DATE"})  1112   1113 # vim: tabstop=4 expandtab shiftwidth=4