imip-agent

imipweb/calendar.py

449:115d5a169262
2015-03-27 Paul Boddie Added initial support for events occurring on an instant in time.
     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                     if last:   376                         intervals.append((last, point))   377    378                         # Qualify points in the day with an extra indicator to   379                         # handle repeated time points due to instant events.   380    381                         day_points[(point, last == point and 1 or 0)] = active   382    383                     last = point   384    385                 if last:   386                     intervals.append((last, None))   387                     day_points[(point, last == point and 1 or 0)] = active   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             css = " ".join([   570                 "slot",   571                 have_active and "busy" or have_active_request and "suggested" or "empty",   572                 continuation and "daystart" or ""   573                 ])   574    575             page.tr(class_=css)   576             page.th(class_="timeslot")   577             if indicator == 0:   578                 self._time_point(point, endpoint)   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, indicator))   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                     page.td(class_="empty container", colspan=max(columns, 1))   592                     self._empty_slot(point, endpoint)   593                     page.td.close()   594                     continue   595    596                 slots = slots.items()   597                 slots.sort()   598                 spans = get_spans(slots)   599    600                 empty = 0   601    602                 # Show a column for each active period.   603    604                 for t in active:   605                     if t and len(t) >= 2:   606    607                         # Flush empty slots preceding this one.   608    609                         if empty:   610                             page.td(class_="empty container", colspan=empty)   611                             self._empty_slot(point, endpoint)   612                             page.td.close()   613                             empty = 0   614    615                         start, end, uid, recurrenceid, summary, organiser, key = get_freebusy_details(t)   616                         span = spans[key]   617    618                         # Produce a table cell only at the start of the period   619                         # or when continued at the start of a day.   620    621                         if point == start or continuation:   622    623                             has_continued = continuation and point != start   624                             will_continue = not ends_on_same_day(point, end, tzid)   625                             is_organiser = organiser == self.user   626    627                             css = " ".join([   628                                 "event",   629                                 has_continued and "continued" or "",   630                                 will_continue and "continues" or "",   631                                 is_organiser and "organising" or "attending"   632                                 ])   633    634                             # Only anchor the first cell of events.   635                             # Need to only anchor the first period for a recurring   636                             # event.   637    638                             html_id = "%s-%s-%s" % (group_type, uid, recurrenceid or "")   639    640                             if point == start and html_id not in self.html_ids:   641                                 page.td(class_=css, rowspan=span, id=html_id)   642                                 self.html_ids.add(html_id)   643                             else:   644                                 page.td(class_=css, rowspan=span)   645    646                             # Only link to events if they are not being   647                             # updated by requests.   648    649                             if not summary or (uid, recurrenceid) in self._get_requests() and group_type != "request":   650                                 page.span(summary or "(Participant is busy)")   651                             else:   652                                 page.a(summary, href=self.link_to(uid, recurrenceid))   653    654                             page.td.close()   655                     else:   656                         empty += 1   657    658                 # Pad with empty columns.   659    660                 empty = columns - len(active)   661    662                 if empty:   663                     page.td(class_="empty container", colspan=empty)   664                     self._empty_slot(point, endpoint)   665                     page.td.close()   666    667             page.tr.close()   668    669     def _day_heading(self, day):   670    671         """   672         Generate a heading for 'day' of the following form:   673    674         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>   675         """   676    677         page = self.page   678         daystr = format_datetime(day)   679         value, identifier = self._day_value_and_identifier(day)   680         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)   681    682     def _time_point(self, point, endpoint):   683    684         """   685         Generate headings for the 'point' to 'endpoint' period of the following   686         form:   687    688         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>   689         <span class="endpoint">10:00:00 CET</span>   690         """   691    692         page = self.page   693         tzid = self.get_tzid()   694         daystr = format_datetime(point.date())   695         value, identifier = self._slot_value_and_identifier(point, endpoint)   696         slots = self.env.get_args().get("slot", [])   697         self._slot_selector(value, identifier, slots)   698         page.label(self.format_time(point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)   699         page.span(self.format_time(endpoint or get_end_of_day(point, tzid), "long"), class_="endpoint")   700    701     def _slot_selector(self, value, identifier, slots):   702    703         """   704         Provide a timeslot control having the given 'value', employing the   705         indicated HTML 'identifier', and using the given 'slots' collection   706         to select any control whose 'value' is in this collection, unless the   707         "reset" request parameter has been asserted.   708         """   709    710         reset = self.env.get_args().has_key("reset")   711         page = self.page   712         if not reset and value in slots:   713             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")   714         else:   715             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")   716    717     def _empty_slot(self, point, endpoint):   718    719         "Show an empty slot label for the given 'point' and 'endpoint'."   720    721         page = self.page   722         value, identifier = self._slot_value_and_identifier(point, endpoint)   723         page.label("Select/deselect period", class_="newevent popup", for_=identifier)   724    725     def _day_value_and_identifier(self, day):   726    727         "Return a day value and HTML identifier for the given 'day'."   728    729         value = "%s-" % format_datetime(day)   730         identifier = "day-%s" % value   731         return value, identifier   732    733     def _slot_value_and_identifier(self, point, endpoint):   734    735         """   736         Return a slot value and HTML identifier for the given 'point' and   737         'endpoint'.   738         """   739    740         value = "%s-%s" % (format_datetime(point), endpoint and format_datetime(endpoint) or "")   741         identifier = "slot-%s" % value   742         return value, identifier   743    744 # vim: tabstop=4 expandtab shiftwidth=4