imip-agent

imipweb/calendar.py

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