imip-agent

imipweb/resource.py

1272:65e999dd88f0
2017-09-18 Paul Boddie Added a convenience method for loading objects. Added docstrings. client-editing-simplification
     1 #!/usr/bin/env python     2      3 """     4 Common resource functionality for Web calendar clients.     5      6 Copyright (C) 2014, 2015, 2016, 2017 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 date, datetime, timedelta    23 from imiptools.client import Client, ClientForObject    24 from imiptools.data import get_uri    25 from imiptools.dates import format_datetime, to_date    26 from imiptools.freebusy import FreeBusyCollection    27 from imipweb.data import event_period_from_period, form_period_from_period, \    28                          PeriodError    29 from imipweb.env import CGIEnvironment    30 from urllib import urlencode    31 import babel.dates    32 import hashlib, hmac    33 import markup    34 import pytz    35 import time    36     37 class Resource:    38     39     "A Web application resource."    40     41     def __init__(self, resource=None):    42     43         """    44         Initialise a resource, allowing it to share the environment of any given    45         existing 'resource'.    46         """    47     48         self.encoding = "utf-8"    49         self.env = CGIEnvironment(self.encoding)    50     51         self.objects = {}    52         self.locale = None    53         self.requests = None    54     55         self.out = resource and resource.out or self.env.get_output()    56         self.page = resource and resource.page or markup.page()    57         self.html_ids = None    58     59     # Presentation methods.    60     61     def new_page(self, title):    62         self.page.init(title=title, charset=self.encoding, css=self.env.new_url("styles.css"))    63         self.html_ids = set()    64     65     def status(self, code, message):    66         self.header("Status", "%s %s" % (code, message))    67     68     def header(self, header, value):    69         print >>self.out, "%s: %s" % (header, value)    70     71     def no_user(self):    72         self.status(403, "Forbidden")    73         self.new_page(title="Forbidden")    74         self.page.p("You are not logged in and thus cannot access scheduling requests.")    75     76     def no_page(self):    77         self.status(404, "Not Found")    78         self.new_page(title="Not Found")    79         self.page.p("No page is provided at the given address.")    80     81     def redirect(self, url):    82         self.status(302, "Redirect")    83         self.header("Location", url)    84         self.new_page(title="Redirect")    85         self.page.p("Redirecting to: %s" % url)    86     87     def link_to(self, uid=None, recurrenceid=None, args=None):    88     89         """    90         Return a link to a resource, being an object with any given 'uid' and    91         'recurrenceid', or the main resource otherwise.    92     93         See get_identifiers for the decoding of such links.    94     95         If 'args' is specified, the given dictionary is encoded and included.    96         """    97     98         path = []    99         if uid:   100             path.append(uid)   101         if recurrenceid:   102             path.append(recurrenceid)   103         return "%s%s" % (self.env.new_url("/".join(path)), args and ("?%s" % urlencode(args)) or "")   104    105     # Access to objects.   106    107     def get_identifiers(self, path_info):   108    109         """   110         Return identifiers provided by 'path_info', potentially encoded by   111         'link_to'.   112         """   113    114         parts = path_info.lstrip("/").split("/")   115    116         # UID only.   117    118         if len(parts) == 1:   119             return parts[0], None   120    121         # UID and RECURRENCE-ID.   122    123         else:   124             return parts[:2]   125    126     def _get_object(self, uid, recurrenceid=None, section=None, username=None):   127         if self.objects.has_key((uid, recurrenceid, section, username)):   128             return self.objects[(uid, recurrenceid, section, username)]   129    130         obj = self.objects[(uid, recurrenceid, section, username)] = self.get_stored_object(uid, recurrenceid, section, username)   131         return obj   132    133     def _get_recurrences(self, uid):   134         return self.store.get_recurrences(self.user, uid)   135    136     def _get_active_recurrences(self, uid):   137         return self.store.get_active_recurrences(self.user, uid)   138    139     def _get_requests(self):   140         if self.requests is None:   141             self.requests = self.store.get_requests(self.user)   142         return self.requests   143    144     def _have_request(self, uid, recurrenceid=None, type=None, strict=False):   145         return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict)   146    147     def _is_request(self):   148         return self._have_request(self.uid, self.recurrenceid)   149    150     def _get_counters(self, uid, recurrenceid=None):   151         return self.store.get_counters(self.user, uid, recurrenceid)   152    153     def _get_request_summary(self, view_period):   154    155         """   156         Return a list of periods comprising the request summary within the given   157         'view_period'.   158         """   159    160         summary = FreeBusyCollection()   161    162         for uid, recurrenceid, request_type in self._get_requests():   163    164             # Obtain either normal objects or counter-proposals.   165    166             if not request_type:   167                 objs = [self._get_object(uid, recurrenceid)]   168             elif request_type == "COUNTER":   169                 objs = []   170                 for attendee in self.store.get_counters(self.user, uid, recurrenceid):   171                     objs.append(self._get_object(uid, recurrenceid, "counters", attendee))   172    173             # For each object, obtain the periods involved.   174    175             for obj in objs:   176                 if obj:   177                     recurrenceids = self._get_recurrences(uid)   178    179                     # Obtain only active periods, not those replaced by redefined   180                     # recurrences, converting to free/busy periods.   181    182                     for p in obj.get_active_periods(recurrenceids, self.get_tzid(),   183                         start=view_period.get_start(), end=view_period.get_end()):   184    185                         summary.append(obj.get_freebusy_period(p))   186    187         return summary   188    189     # Preference methods.   190    191     def get_user_locale(self):   192         if not self.locale:   193             self.locale = self.get_preferences().get("LANG", "en", True) or "en"   194         return self.locale   195    196     # Prettyprinting of dates and times.   197    198     def format_date(self, dt, format):   199         return self._format_datetime(babel.dates.format_date, dt, format)   200    201     def format_time(self, dt, format):   202         return self._format_datetime(babel.dates.format_time, dt, format)   203    204     def format_datetime(self, dt, format):   205         return self._format_datetime(   206             isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,   207             dt, format)   208    209     def _format_datetime(self, fn, dt, format):   210         return fn(dt, format=format, locale=self.get_user_locale())   211    212 class ResourceClient(Resource, Client):   213    214     "A Web application resource and calendar client."   215    216     def __init__(self, resource=None):   217         Resource.__init__(self, resource)   218         user = self.env.get_user()   219         Client.__init__(self, user and get_uri(user) or None)   220    221 class ResourceClientForObject(Resource, ClientForObject):   222    223     "A Web application resource and calendar client for a specific object."   224    225     def __init__(self, resource=None, messenger=None):   226         Resource.__init__(self, resource)   227         user = self.env.get_user()   228         ClientForObject.__init__(self, None, user and get_uri(user) or None, messenger)   229    230 class FormUtilities:   231    232     "Utility methods resource mix-in."   233    234     def get_validation_token(self, details=None):   235    236         "Return a token suitable for validating a form submission."   237    238         # Use a secret held in the user's preferences.   239    240         prefs = self.get_preferences()   241         if not prefs.has_key("secret"):   242             prefs["secret"] = str(time.time())   243    244         # Combine it with the user identity and any supplied details.   245    246         secret = prefs["secret"].encode("utf-8")   247         details = u"".join([self.env.get_user()] + (details or [])).encode("utf-8")   248    249         return hmac.new(secret, details, hashlib.sha256).hexdigest()   250    251     def check_validation_token(self, name="token", details=None):   252    253         """   254         Check the field having the given 'name', returning if its value matches   255         the validation token generated using any given 'details'.   256         """   257    258         return self.env.get_args().get(name, [None])[0] == self.get_validation_token(details)   259    260     def validator(self, name="token", details=None):   261    262         """   263         Show a control having the given 'name' that is used to validate form   264         submissions, employing any additional 'details' in the construction of   265         the validation token.   266         """   267    268         self.page.input(name=name, type="hidden", value=self.get_validation_token(details))   269    270     def prefixed_args(self, prefix, convert=None):   271    272         """   273         Return values for all arguments having the given 'prefix' in their   274         names, removing the prefix to obtain each value from the argument name   275         itself. The 'convert' callable can be specified to perform a conversion   276         (to int, for example).   277         """   278    279         args = self.env.get_args()   280    281         values = []   282         for name in args.keys():   283             if name.startswith(prefix):   284                 value = name[len(prefix):]   285                 if convert:   286                     try:   287                         value = convert(value)   288                     except ValueError:   289                         pass   290                 values.append(value)   291         return values   292    293     def control(self, name, type, value, selected=False, **kw):   294    295         """   296         Show a control with the given 'name', 'type' and 'value', with   297         'selected' indicating whether it should be selected (checked or   298         equivalent), and with keyword arguments setting other properties.   299         """   300    301         page = self.page   302         if type in ("checkbox", "radio") and selected:   303             page.input(name=name, type=type, value=value, checked="checked", **kw)   304         else:   305             page.input(name=name, type=type, value=value, **kw)   306    307     def menu(self, name, default, items, values=None, class_=""):   308    309         """   310         Show a select menu having the given 'name', set to the given 'default',   311         providing the given (value, label) 'items', selecting the given 'values'   312         (or using the request parameters if not specified), and employing the   313         given CSS 'class_' if specified.   314         """   315    316         page = self.page   317         values = values or [default]   318    319         page.select(name=name, class_=class_)   320         for v, label in items:   321             if v is None:   322                 continue   323             if v in values:   324                 page.option(label, value=v, selected="selected")   325             else:   326                 page.option(label, value=v)   327         page.select.close()   328    329     def date_controls(self, name, default, show_tzid=True, read_only=False):   330    331         """   332         Show date controls for a field with the given 'name' and 'default' form   333         date value.   334    335         If 'show_tzid' is set to a false value, the time zone menu will not be   336         provided.   337    338         If 'read_only' is set to a true value, the controls will be hidden and   339         labels will be employed instead.   340         """   341    342         page = self.page   343    344         # Show dates for up to one week around the current date.   345    346         page.span(class_="date enabled")   347    348         dt = default.as_datetime()   349    350         # For invalid datetimes, try to get a date instead.   351    352         if not dt:   353             dt = default.as_datetime(with_time=False)   354    355             # For invalid dates, just use today's date.   356    357             if not dt:   358                 dt = date.today()   359    360         base = to_date(dt)   361    362         # Show a date label with a hidden field if read-only.   363    364         if read_only:   365             self.control("%s-date" % name, "hidden", format_datetime(base))   366             page.span(self.format_date(base, "long"))   367    368         # Show dates for up to one week around the current date.   369         # NOTE: Support paging to other dates.   370    371         else:   372             items = []   373             for i in range(-7, 8):   374                 d = base + timedelta(i)   375                 items.append((format_datetime(d), self.format_date(d, "full")))   376             self.menu("%s-date" % name, format_datetime(base), items)   377    378         page.span.close()   379    380         # Show time details.   381    382         page.span(class_="time enabled")   383    384         if read_only:   385             page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second()))   386             self.control("%s-hour" % name, "hidden", default.get_hour())   387             self.control("%s-minute" % name, "hidden", default.get_minute())   388             self.control("%s-second" % name, "hidden", default.get_second())   389         else:   390             self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2)   391             page.add(":")   392             self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2)   393             page.add(":")   394             self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2)   395    396         # Show time zone details.   397    398         if show_tzid:   399             page.add(" ")   400             tzid = default.get_tzid() or self.get_tzid()   401    402             # Show a label if read-only or a menu otherwise.   403    404             if read_only:   405                 self.control("%s-tzid" % name, "hidden", tzid)   406                 page.span(tzid)   407             else:   408                 self.timezone_menu("%s-tzid" % name, tzid)   409    410         page.span.close()   411    412     def timezone_menu(self, name, default):   413    414         """   415         Show timezone controls using a menu with the given 'name', set to the   416         given 'default' unless a field of the given 'name' provides a value.   417         """   418    419         entries = [(tzid, tzid) for tzid in pytz.all_timezones]   420         self.menu(name, default, entries)   421    422 class DateTimeFormUtilities:   423    424     "Date/time control methods resource mix-in."   425    426     # Control naming helpers.   427    428     def element_identifier(self, name, index=None):   429         return index is not None and "%s-%d" % (name, index) or name   430    431     def element_name(self, name, suffix, index=None):   432         return index is not None and "%s-%s" % (name, suffix) or name   433    434     def element_enable(self, index=None):   435         return str(index or 0)   436    437     def show_object_datetime_controls(self, period, index=None):   438    439         """   440         Show datetime-related controls if already active or if an object needs   441         them for the given 'period'. The given 'index' is used to parameterise   442         individual controls for dynamic manipulation.   443         """   444    445         p = form_period_from_period(period)   446    447         page = self.page   448         _id = self.element_identifier   449         _name = self.element_name   450         _enable = self.element_enable   451    452         # Add a dynamic stylesheet to permit the controls to modify the display.   453         # NOTE: The style details need to be coordinated with the static   454         # NOTE: stylesheet.   455    456         if index is not None:   457             page.style(type="text/css")   458    459             # Unlike the rules for object properties, these affect recurrence   460             # properties.   461    462             page.add("""\   463 input#dttimes-enable-%(index)d,   464 input#dtend-enable-%(index)d,   465 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,   466 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,   467 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,   468 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {   469     display: none;   470 }   471    472 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .date.enabled,   473 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .date.disabled {   474     visibility: hidden;   475 }""" % {"index" : index})   476    477             page.style.close()   478    479         self.control(   480             _name("dtend-control", "recur", index), "checkbox",   481             _enable(index), p.end_enabled,   482             id=_id("dtend-enable", index)   483             )   484    485         self.control(   486             _name("dttimes-control", "recur", index), "checkbox",   487             _enable(index), p.times_enabled,   488             id=_id("dttimes-enable", index)   489             )   490    491     def show_datetime_controls(self, formdate, show_start):   492    493         """   494         Show datetime details from the current object for the 'formdate',   495         showing start details if 'show_start' is set to a true value. Details   496         will appear as controls for organisers and labels for attendees.   497         """   498    499         page = self.page   500    501         # Show controls for editing.   502    503         page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))   504    505         if show_start:   506             page.div(class_="dt enabled")   507             self.date_controls("dtstart", formdate)   508             page.br()   509             page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")   510             page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")   511             page.div.close()   512    513         else:   514             self.date_controls("dtend", formdate)   515             page.div(class_="dt disabled")   516             page.label("Specify end date", for_="dtend-enable", class_="enable")   517             page.div.close()   518             page.div(class_="dt enabled")   519             page.label("End on same day", for_="dtend-enable", class_="disable")   520             page.div.close()   521    522         page.td.close()   523    524     def show_recurrence_controls(self, index, period, recurrenceid, show_start):   525    526         """   527         Show datetime details from the current object for the recurrence having   528         the given 'index', with the recurrence period described by 'period',   529         indicating a start, end and origin of the period from the event details,   530         employing any 'recurrenceid' for the object to configure the displayed   531         information.   532    533         If 'show_start' is set to a true value, the start details will be shown;   534         otherwise, the end details will be shown.   535         """   536    537         page = self.page   538         _id = self.element_identifier   539         _name = self.element_name   540    541         period = form_period_from_period(period)   542    543         # Show controls for editing.   544    545         page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))   546    547         read_only = period.origin == "RRULE"   548    549         if show_start:   550             page.div(class_="dt enabled")   551             self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), read_only=read_only)   552             if not read_only:   553                 page.br()   554                 page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable")   555                 page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable")   556             page.div.close()   557    558             self.show_period_state(index, period)   559         else:   560             self.date_controls(_name("dtend", "recur", index), period.get_form_end(), show_tzid=False, read_only=read_only)   561             if not read_only:   562                 page.div(class_="dt disabled")   563                 page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable")   564                 page.div.close()   565                 page.div(class_="dt enabled")   566                 page.label("End on same day", for_=_id("dtend-enable", index), class_="disable")   567                 page.div.close()   568    569         page.td.close()   570    571     def show_period_state(self, index, period):   572    573         "Insert at 'index' additional state held by 'period'."   574    575         if index is not None:   576             prefix = "recur"   577             index = str(index)   578         else:   579             prefix = "main"   580             index = "0"   581    582         if index is not None:   583             self.control("%s-origin" % prefix, "hidden", period.origin or "")   584    585         self.control("%s-cancelled" % prefix, "hidden", period.cancelled and index or "")   586         self.control("%s-replacement" % prefix, "hidden", period.replacement and index or "")   587         self.control("%s-id" % prefix, "hidden", period.recurrenceid or "")   588    589 # vim: tabstop=4 expandtab shiftwidth=4