imip-agent

imipweb/calendar.py

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