imip-agent

imipweb/calendar.py

458:00fcdf47658c
2015-03-28 Paul Boddie Introduced abstractions to make working with different forms of periods easier (free/busy, recurring, simple periods).
     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_scale, get_slots, get_spans, \    30                              partition_by_day, Point    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             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             # Time slots are collections of Point objects with lists of active   328             # periods.   329    330             slots = get_slots(scale)   331    332             # Add start of day time points for multi-day periods.   333    334             add_day_start_points(slots, tzid)   335    336             # Record the slots and all time points employed.   337    338             groups.append(slots)   339             all_points.update([point for point, active in slots])   340    341         # Partition the groups into days.   342    343         days = {}   344         partitioned_groups = []   345         partitioned_group_types = []   346         partitioned_group_sources = []   347    348         for slots, group_type, group_source in zip(groups, group_types, group_sources):   349    350             # Propagate time points to all groups of time slots.   351    352             add_slots(slots, all_points)   353    354             # Count the number of columns employed by the group.   355    356             columns = 0   357    358             # Partition the time slots by day.   359    360             partitioned = {}   361    362             for day, day_slots in partition_by_day(slots).items():   363    364                 # Construct a list of time intervals within the day.   365    366                 intervals = []   367    368                 # Convert each partition to a mapping from points to active   369                 # periods.   370    371                 partitioned[day] = day_points = {}   372    373                 last = None   374    375                 for point, active in day_slots:   376                     columns = max(columns, len(active))   377                     day_points[point] = active   378    379                     if last:   380                         intervals.append((last, point))   381    382                     last = point   383    384                 if last:   385                     intervals.append((last, None))   386    387                 if not days.has_key(day):   388                     days[day] = set()   389    390                 # Record the divisions or intervals within each day.   391    392                 days[day].update(intervals)   393    394             # Only include the requests column if it provides objects.   395    396             if group_type != "request" or columns:   397                 group_columns.append(columns)   398                 partitioned_groups.append(partitioned)   399                 partitioned_group_types.append(group_type)   400                 partitioned_group_sources.append(group_source)   401    402         # Add empty days.   403    404         add_empty_days(days, tzid)   405    406         # Show the controls permitting day selection.   407    408         self.show_calendar_day_controls(days)   409    410         # Show the calendar itself.   411    412         page.table(cellspacing=5, cellpadding=5, class_="calendar")   413         self.show_calendar_participant_headings(partitioned_group_types, partitioned_group_sources, group_columns)   414         self.show_calendar_days(days, partitioned_groups, partitioned_group_types, group_columns)   415         page.table.close()   416    417         # End the form region.   418    419         page.form.close()   420    421     # More page fragment methods.   422    423     def show_calendar_day_controls(self, days):   424    425         "Show controls for the given 'days' in the calendar."   426    427         page = self.page   428         slots = self.env.get_args().get("slot", [])   429    430         for day in days:   431             value, identifier = self._day_value_and_identifier(day)   432             self._slot_selector(value, identifier, slots)   433    434         # Generate a dynamic stylesheet to allow day selections to colour   435         # specific days.   436         # NOTE: The style details need to be coordinated with the static   437         # NOTE: stylesheet.   438    439         page.style(type="text/css")   440    441         for day in days:   442             daystr = format_datetime(day)   443             page.add("""\   444 input.newevent.selector#day-%s-:checked ~ table label.day.day-%s,   445 input.newevent.selector#day-%s-:checked ~ table label.timepoint.day-%s {   446     background-color: #5f4;   447     text-decoration: underline;   448 }   449 """ % (daystr, daystr, daystr, daystr))   450    451         page.style.close()   452    453     def show_calendar_participant_headings(self, group_types, group_sources, group_columns):   454    455         """   456         Show headings for the participants and other scheduling contributors,   457         defined by 'group_types', 'group_sources' and 'group_columns'.   458         """   459    460         page = self.page   461    462         page.colgroup(span=1, id="columns-timeslot")   463    464         for group_type, columns in zip(group_types, group_columns):   465             page.colgroup(span=max(columns, 1), id="columns-%s" % group_type)   466    467         page.thead()   468         page.tr()   469         page.th("", class_="emptyheading")   470    471         for group_type, source, columns in zip(group_types, group_sources, group_columns):   472             page.th(source,   473                 class_=(group_type == "request" and "requestheading" or "participantheading"),   474                 colspan=max(columns, 1))   475    476         page.tr.close()   477         page.thead.close()   478    479     def show_calendar_days(self, days, partitioned_groups, partitioned_group_types, group_columns):   480    481         """   482         Show calendar days, defined by a collection of 'days', the contributing   483         period information as 'partitioned_groups' (partitioned by day), the   484         'partitioned_group_types' indicating the kind of contribution involved,   485         and the 'group_columns' defining the number of columns in each group.   486         """   487    488         page = self.page   489    490         # Determine the number of columns required. Where participants provide   491         # no columns for events, one still needs to be provided for the   492         # participant itself.   493    494         all_columns = sum([max(columns, 1) for columns in group_columns])   495    496         # Determine the days providing time slots.   497    498         all_days = days.items()   499         all_days.sort()   500    501         # Produce a heading and time points for each day.   502    503         for day, intervals in all_days:   504             groups_for_day = [partitioned.get(day) for partitioned in partitioned_groups]   505             is_empty = True   506    507             for slots in groups_for_day:   508                 if not slots:   509                     continue   510    511                 for active in slots.values():   512                     if active:   513                         is_empty = False   514                         break   515    516             page.thead(class_="separator%s" % (is_empty and " empty" or ""))   517             page.tr()   518             page.th(class_="dayheading container", colspan=all_columns+1)   519             self._day_heading(day)   520             page.th.close()   521             page.tr.close()   522             page.thead.close()   523    524             page.tbody(class_="points%s" % (is_empty and " empty" or ""))   525             self.show_calendar_points(intervals, groups_for_day, partitioned_group_types, group_columns)   526             page.tbody.close()   527    528     def show_calendar_points(self, intervals, groups, group_types, group_columns):   529    530         """   531         Show the time 'intervals' along with period information from the given   532         'groups', having the indicated 'group_types', each with the number of   533         columns given by 'group_columns'.   534         """   535    536         page = self.page   537    538         # Obtain the user's timezone.   539    540         tzid = self.get_tzid()   541    542         # Produce a row for each interval.   543    544         intervals = list(intervals)   545         intervals.sort()   546    547         for point, endpoint in intervals:   548             continuation = point.point == get_start_of_day(point.point, tzid)   549    550             # Some rows contain no period details and are marked as such.   551    552             have_active = False   553             have_active_request = False   554    555             for slots, group_type in zip(groups, group_types):   556                 if slots and slots.get(point):   557                     if group_type == "request":   558                         have_active_request = True   559                     else:   560                         have_active = True   561    562             # Emit properties of the time interval, where post-instant intervals   563             # are also treated as busy.   564    565             css = " ".join([   566                 "slot",   567                 (have_active or point.indicator == Point.REPEATED) and "busy" or \   568                     have_active_request and "suggested" or "empty",   569                 continuation and "daystart" or ""   570                 ])   571    572             page.tr(class_=css)   573             if point.indicator == Point.PRINCIPAL:   574                 page.th(class_="timeslot")   575                 self._time_point(point, endpoint)   576             else:   577                 page.th()   578             page.th.close()   579    580             # Obtain slots for the time point from each group.   581    582             for columns, slots, group_type in zip(group_columns, groups, group_types):   583                 active = slots and slots.get(point)   584    585                 # Where no periods exist for the given time interval, generate   586                 # an empty cell. Where a participant provides no periods at all,   587                 # the colspan is adjusted to be 1, not 0.   588    589                 if not active:   590                     self._empty_slot(point, endpoint, max(columns, 1))   591                     continue   592    593                 slots = slots.items()   594                 slots.sort()   595                 spans = get_spans(slots)   596    597                 empty = 0   598    599                 # Show a column for each active period.   600    601                 for p in active:   602    603                     # The period can be None, meaning an empty column.   604    605                     if p:   606    607                         # Flush empty slots preceding this one.   608    609                         if empty:   610                             self._empty_slot(point, endpoint, empty)   611                             empty = 0   612    613                         key = p.get_key()   614                         span = spans[key]   615    616                         # Produce a table cell only at the start of the period   617                         # or when continued at the start of a day.   618                         # Points defining the ends of instant events should   619                         # never define the start of new events.   620    621                         if point.indicator == Point.PRINCIPAL and (point.point == p.start or continuation):   622    623                             has_continued = continuation and point.point != p.start   624                             will_continue = not ends_on_same_day(point.point, p.end, tzid)   625                             is_organiser = p.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, p.uid, p.recurrenceid or "")   639    640                             if point.point == p.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 p.summary or (p.uid, p.recurrenceid) in self._get_requests() and group_type != "request":   650                                 page.span(p.summary or "(Participant is busy)")   651                             else:   652                                 page.a(p.summary, href=self.link_to(p.uid, p.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                     self._empty_slot(point, endpoint, empty)   664    665             page.tr.close()   666    667     def _day_heading(self, day):   668    669         """   670         Generate a heading for 'day' of the following form:   671    672         <label class="day day-20150203" for="day-20150203">Tuesday, 3 February 2015</label>   673         """   674    675         page = self.page   676         daystr = format_datetime(day)   677         value, identifier = self._day_value_and_identifier(day)   678         page.label(self.format_date(day, "full"), class_="day day-%s" % daystr, for_=identifier)   679    680     def _time_point(self, point, endpoint):   681    682         """   683         Generate headings for the 'point' to 'endpoint' period of the following   684         form:   685    686         <label class="timepoint day-20150203" for="slot-20150203T090000-20150203T100000">09:00:00 CET</label>   687         <span class="endpoint">10:00:00 CET</span>   688         """   689    690         page = self.page   691         tzid = self.get_tzid()   692         daystr = format_datetime(point.point.date())   693         value, identifier = self._slot_value_and_identifier(point, endpoint)   694         slots = self.env.get_args().get("slot", [])   695         self._slot_selector(value, identifier, slots)   696         page.label(self.format_time(point.point, "long"), class_="timepoint day-%s" % daystr, for_=identifier)   697         page.span(self.format_time(endpoint and endpoint.point or get_end_of_day(point.point, tzid), "long"), class_="endpoint")   698    699     def _slot_selector(self, value, identifier, slots):   700    701         """   702         Provide a timeslot control having the given 'value', employing the   703         indicated HTML 'identifier', and using the given 'slots' collection   704         to select any control whose 'value' is in this collection, unless the   705         "reset" request parameter has been asserted.   706         """   707    708         reset = self.env.get_args().has_key("reset")   709         page = self.page   710         if not reset and value in slots:   711             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector", checked="checked")   712         else:   713             page.input(name="slot", type="checkbox", value=value, id=identifier, class_="newevent selector")   714    715     def _empty_slot(self, point, endpoint, colspan):   716    717         """   718         Show an empty slot cell for the given 'point' and 'endpoint', with the   719         given 'colspan' configuring the cell's appearance.   720         """   721    722         page = self.page   723         page.td(class_="empty%s" % (point.indicator == Point.PRINCIPAL and " container" or ""), colspan=colspan)   724         if point.indicator == Point.PRINCIPAL:   725             value, identifier = self._slot_value_and_identifier(point, endpoint)   726             page.label("Select/deselect period", class_="newevent popup", for_=identifier)   727         page.td.close()   728    729     def _day_value_and_identifier(self, day):   730    731         "Return a day value and HTML identifier for the given 'day'."   732    733         value = "%s-" % format_datetime(day)   734         identifier = "day-%s" % value   735         return value, identifier   736    737     def _slot_value_and_identifier(self, point, endpoint):   738    739         """   740         Return a slot value and HTML identifier for the given 'point' and   741         'endpoint'.   742         """   743    744         value = "%s-%s" % (format_datetime(point.point), endpoint and format_datetime(endpoint.point) or "")   745         identifier = "slot-%s" % value   746         return value, identifier   747    748 # vim: tabstop=4 expandtab shiftwidth=4