imip-agent

imipweb/calendar.py

451:3c8e6e1fe138
2015-03-27 Paul Boddie Fixed the collection of slots within days.
     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 from imipweb.resource import Resource    32     33 class CalendarPage(Resource):    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         # Handle a submitted form.    47     48         args = self.env.get_args()    49     50         if not args.has_key("newevent"):    51             return    52     53         # Create a new event using the available information.    54     55         slots = args.get("slot", [])    56         participants = args.get("participants", [])    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("-")    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", {}, "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 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         handled = self.handle_newevent()   245    246         self.new_page(title="Calendar")   247         page = self.page   248    249         # Form controls are used in various places on the calendar page.   250    251         page.form(method="POST")   252    253         self.show_requests_on_page()   254         participants = self.show_participants_on_page()   255    256         # Show a button for scheduling a new event.   257    258         page.p(class_="controls")   259         page.input(name="newevent", type="submit", value="New event", id="newevent", accesskey="N")   260         page.p.close()   261    262         # Show controls for hiding empty days and busy slots.   263         # The positioning of the control, paragraph and table are important here.   264    265         page.input(name="showdays", type="checkbox", value="show", id="showdays", accesskey="D")   266         page.input(name="hidebusy", type="checkbox", value="hide", id="hidebusy", accesskey="B")   267    268         page.p(class_="controls")   269         page.label("Hide busy time periods", for_="hidebusy", class_="hidebusy enable")   270         page.label("Show busy time periods", for_="hidebusy", class_="hidebusy disable")   271         page.label("Show empty days", for_="showdays", class_="showdays disable")   272         page.label("Hide empty days", for_="showdays", class_="showdays enable")   273         page.input(name="reset", type="submit", value="Clear selections", id="reset")   274         page.label("Clear selections", for_="reset", class_="reset")   275         page.p.close()   276    277         freebusy = self.store.get_freebusy(self.user)   278    279         if not freebusy:   280             page.p("No events scheduled.")   281             return   282    283         # Obtain the user's timezone.   284    285         tzid = self.get_tzid()   286    287         # Day view: start at the earliest known day and produce days until the   288         # latest known day, perhaps with expandable sections of empty days.   289    290         # Month view: start at the earliest known month and produce months until   291         # the latest known month, perhaps with expandable sections of empty   292         # months.   293    294         # Details of users to invite to new events could be superimposed on the   295         # calendar.   296    297         # Requests are listed and linked to their tentative positions in the   298         # calendar. Other participants are also shown.   299    300         request_summary = self._get_request_summary()   301    302         period_groups = [request_summary, freebusy]   303         period_group_types = ["request", "freebusy"]   304         period_group_sources = ["Pending requests", "Your schedule"]   305    306         for i, participant in enumerate(participants):   307             period_groups.append(self.store.get_freebusy_for_other(self.user, get_uri(participant)))   308             period_group_types.append("freebusy-part%d" % i)   309             period_group_sources.append(participant)   310    311         groups = []   312         group_columns = []   313         group_types = period_group_types   314         group_sources = period_group_sources   315         all_points = set()   316    317         # Obtain time point information for each group of periods.   318    319         for periods in period_groups:   320             periods = convert_periods(periods, tzid)   321    322             # Get the time scale with start and end points.   323    324             scale = get_scale(periods)   325    326             # Get the time slots for the periods.   327    328             slots = get_slots(scale)   329    330             # Add start of day time points for multi-day periods.   331    332             add_day_start_points(slots, tzid)   333    334             # Record the slots and all time points employed.   335    336             groups.append(slots)   337             all_points.update([point for point, active in slots])   338    339         # Partition the groups into days.   340    341         days = {}   342         partitioned_groups = []   343         partitioned_group_types = []   344         partitioned_group_sources = []   345    346         for slots, group_type, group_source in zip(groups, group_types, group_sources):   347    348             # Propagate time points to all groups of time slots.   349    350             add_slots(slots, all_points)   351    352             # Count the number of columns employed by the group.   353    354             columns = 0   355    356             # Partition the time slots by day.   357    358             partitioned = {}   359    360             for day, day_slots in partition_by_day(slots).items():   361    362                 # Construct a list of time intervals within the day.   363    364                 intervals = []   365    366                 # Convert each partition to a mapping from points to active   367                 # periods.   368    369                 partitioned[day] = day_points = {}   370    371                 last = None   372    373                 for point, active in day_slots:   374                     columns = max(columns, len(active))   375    376                     # Qualify points in the day with an extra indicator to   377                     # handle repeated time points due to instant events.   378    379                     day_points[(point, last == point and 1 or 0)] = active   380    381                     if last:   382                         intervals.append((last, point))   383    384                     last = point   385    386                 if last:   387                     intervals.append((last, None))   388    389                 if not days.has_key(day):   390                     days[day] = set()   391    392                 # Record the divisions or intervals within each day.   393    394                 days[day].update(intervals)   395    396             # Only include the requests column if it provides objects.   397    398             if group_type != "request" or columns:   399                 group_columns.append(columns)   400                 partitioned_groups.append(partitioned)   401                 partitioned_group_types.append(group_type)   402                 partitioned_group_sources.append(group_source)   403    404         # Add empty days.   405    406         add_empty_days(days, tzid)   407    408         # Show the controls permitting day selection.   409    410         self.show_calendar_day_controls(days)   411    412         # Show the calendar itself.   413    414         page.table(cellspacing=5, cellpadding=5, class_="calendar")   415         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)   416         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)   417         page.table.close()   418    419         # End the form region.   420    421         page.form.close()   422    423     # More page fragment methods.   424    425     def show_calendar_day_controls(self, days):   426    427         "Show controls for the given 'days' in the calendar."   428    429         page = self.page   430         slots = self.env.get_args().get("slot", [])   431    432         for day in days:   433             value, identifier = self._day_value_and_identifier(day)   434             self._slot_selector(value, identifier, slots)   435    436         # Generate a dynamic stylesheet to allow day selections to colour   437         # specific days.   438         # NOTE: The style details need to be coordinated with the static   439         # NOTE: stylesheet.   440    441         page.style(type="text/css")   442    443         for day in days:   444             daystr = format_datetime(day)   445             page.add("""\   446 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,   447 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {   448     background-color: #5f4;   449     text-decoration: underline;   450 }   451 """ % (daystr, daystr, daystr, daystr))   452    453         page.style.close()   454    455     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):   456    457         """   458         Show headings for the participants and other scheduling contributors,   459         defined by 'group_types', 'group_sources' and 'group_columns'.   460         """   461    462         page = self.page   463    464         page.colgroup(span=1, id="columns-timeslot")   465    466         for group_type, columns in zip(group_types, group_columns):   467             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)   468    469         page.thead()   470         page.tr()   471         page.th("", class_="emptyheading")   472    473         for group_type, source, columns in zip(group_types, group_sources, group_columns):   474             page.th(source,   475                 class_=(group_type == "request" and "requestheading" or "participantheading"),   476                 colspan=max(columns, 1))   477    478         page.tr.close()   479         page.thead.close()   480    481     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):   482    483         """   484         Show calendar days, defined by a collection of 'days', the contributing   485         period information as 'partitioned_groups' (partitioned by day), the   486         'partitioned_group_types' indicating the kind of contribution involved,   487         and the 'group_columns' defining the number of columns in each group.   488         """   489    490         page = self.page   491    492         # Determine the number of columns required. Where participants provide   493         # no columns for events, one still needs to be provided for the   494         # participant itself.   495    496         all_columns = sum([max(columns, 1) for columns in group_columns])   497    498         # Determine the days providing time slots.   499    500         all_days = days.items()   501         all_days.sort()   502    503         # Produce a heading and time points for each day.   504    505         for day, intervals in all_days:   506             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]   507             is_empty = True   508    509             for slots in groups_for_day:   510                 if not slots:   511                     continue   512    513                 for active in slots.values():   514                     if active:   515                         is_empty = False   516                         break   517    518             page.thead(class_="separator%s" % (is_empty and " empty" or ""))   519             page.tr()   520             page.th(class_="dayheading container", colspan=all_columns+1)   521             self._day_heading(day)   522             page.th.close()   523             page.tr.close()   524             page.thead.close()   525    526             page.tbody(class_="points%s" % (is_empty and " empty" or ""))   527             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)   528             page.tbody.close()   529    530     def show_calendar_points(self, intervals, groups, group_types, group_columns):   531    532         """   533         Show the time 'intervals' along with period information from the given   534         'groups', having the indicated 'group_types', each with the number of   535         columns given by 'group_columns'.   536         """   537    538         page = self.page   539    540         # Obtain the user's timezone.   541    542         tzid = self.get_tzid()   543    544         # Produce a row for each interval.   545    546         intervals = list(intervals)   547         intervals.sort()   548    549         last = None   550    551         for point, endpoint in intervals:   552             indicator = point == last and 1 or 0   553             last = point   554    555             continuation = point == get_start_of_day(point, tzid)   556    557             # Some rows contain no period details and are marked as such.   558    559             have_active = False   560             have_active_request = False   561    562             for slots, group_type in zip(groups, group_types):   563                 if slots and slots.get((point, indicator)):   564                     if group_type == "request":   565                         have_active_request = True   566                     else:   567                         have_active = True   568    569             # Emit properties of the time interval, where post-instant intervals   570             # are also treated as busy.   571    572             css = " ".join([   573                 "slot",   574                 (have_active or indicator) and "busy" or have_active_request and "suggested" or "empty",   575                 continuation and "daystart" or ""   576                 ])   577    578             page.tr(class_=css)   579             page.th(class_="timeslot")   580             if indicator == 0:   581                 self._time_point(point, endpoint)   582             page.th.close()   583    584             # Obtain slots for the time point from each group.   585    586             for columns, slots, group_type in zip(group_columns, groups, group_types):   587                 active = slots and slots.get((point, indicator))   588    589                 # Where no periods exist for the given time interval, generate   590                 # an empty cell. Where a participant provides no periods at all,   591                 # the colspan is adjusted to be 1, not 0.   592    593                 if not active:   594                     page.td(class_="empty container", colspan=max(columns, 1))   595                     self._empty_slot(point, endpoint)   596                     page.td.close()   597                     continue   598    599                 slots = slots.items()   600                 slots.sort()   601                 spans = get_spans(slots)   602    603                 empty = 0   604    605                 # Show a column for each active period.   606    607                 for t in active:   608                     if t and len(t) >= 2:   609    610                         # Flush empty slots preceding this one.   611    612                         if empty:   613                             page.td(class_="empty container", colspan=empty)   614                             self._empty_slot(point, endpoint)   615                             page.td.close()   616                             empty = 0   617    618                         start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t)   619                         span = spans[key]   620    621                         # Produce a table cell only at the start of the period   622                         # or when continued at the start of a day.   623    624                         if point == start or continuation:   625    626                             has_continued = continuation and point != start   627                             will_continue = not ends_on_same_day(point, end, tzid)   628                             is_organiser = organiser == self.user   629    630                             css = " ".join([   631                                 "event",   632                                 has_continued and "continued" or "",   633                                 will_continue and "continues" or "",   634                                 is_organiser and "organising" or "attending"   635                                 ])   636    637                             # Only anchor the first cell of events.   638                             # Need to only anchor the first period for a recurring   639                             # event.   640    641                             html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "")   642    643                             if point == start and html_id not in self.html_ids:   644                                 page.td(class_=css, rowspan=span, id=html_id)   645                                 self.html_ids.add(html_id)   646                             else:   647                                 page.td(class_=css, rowspan=span)   648    649                             # Only link to events if they are not being   650                             # updated by requests.   651    652                             if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request":   653                                 page.span(summary or "(Participant is busy)")   654                             else:   655                                 page.a(summary, href=self.link_to(uid, recurrenceid))   656    657                             page.td.close()   658                     else:   659                         empty += 1   660    661                 # Pad with empty columns.   662    663                 empty = columns - len(active)   664    665                 if empty:   666                     page.td(class_="empty container", colspan=empty)   667                     self._empty_slot(point, endpoint)   668                     page.td.close()   669    670             page.tr.close()   671    672     def _day_heading(self, day):   673    674         """   675         Generate a heading for 'day' of the following form:   676    677         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>   678         """   679    680         page = self.page   681         daystr = format_datetime(day)   682         value, identifier = self._day_value_and_identifier(day)   683         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)   684    685     def _time_point(self, point, endpoint):   686    687         """   688         Generate headings for the 'point' to 'endpoint' period of the following   689         form:   690    691         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>   692         <span class="endpoint">10:00:00 CET</span>   693         """   694    695         page = self.page   696         tzid = self.get_tzid()   697         daystr = format_datetime(point.date())   698         value, identifier = self._slot_value_and_identifier(point, endpoint)   699         slots = self.env.get_args().get("slot", [])   700         self._slot_selector(value, identifier, slots)   701         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)   702         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")   703    704     def _slot_selector(self, value, identifier, slots):   705    706         """   707         Provide a timeslot control having the given 'value', employing the   708         indicated HTML 'identifier', and using the given 'slots' collection   709         to select any control whose 'value' is in this collection, unless the   710         "reset" request parameter has been asserted.   711         """   712    713         reset = self.env.get_args().has_key("reset")   714         page = self.page   715         if not reset and value in slots:   716             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")   717         else:   718             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")   719    720     def _empty_slot(self, point, endpoint):   721    722         "Show an empty slot label for the given 'point' and 'endpoint'."   723    724         page = self.page   725         value, identifier = self._slot_value_and_identifier(point, endpoint)   726         page.label("Select/deselect period", class_="newevent popup", for_=identifier)   727    728     def _day_value_and_identifier(self, day):   729    730         "Return a day value and HTML identifier for the given 'day'."   731    732         value = "%s-" % format_datetime(day)   733         identifier = "day-%s" % value   734         return value, identifier   735    736     def _slot_value_and_identifier(self, point, endpoint):   737    738         """   739         Return a slot value and HTML identifier for the given 'point' and   740         'endpoint'.   741         """   742    743         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")   744         identifier = "slot-%s" % value   745         return value, identifier   746    747 # vim: tabstop=4 expandtab shiftwidth=4