imip-agent

imipweb/calendar.py

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