imip-agent

imipweb/calendar.py

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