imip-agent

imipweb/calendar.py

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