imip-agent

imipweb/resource.py

1253:333740ca50b6
2017-09-12 Paul Boddie Consider period replacement status when comparing form periods.
     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_="", index=None):   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 self.env.get_args().get(name, [default])   318         if index is not None:   319             values = values[index:]   320             values = values and values[0:1] or [default]   321    322         page.select(name=name, class_=class_)   323         for v, label in items:   324             if v is None:   325                 continue   326             if v in values:   327                 page.option(label, value=v, selected="selected")   328             else:   329                 page.option(label, value=v)   330         page.select.close()   331    332     def date_controls(self, name, default, index=None, show_tzid=True, read_only=False):   333    334         """   335         Show date controls for a field with the given 'name' and 'default' form   336         date value.   337    338         If 'index' is specified, default field values will be overridden by the   339         element from a collection of existing form values with the specified   340         index; otherwise, field values will be overridden by a single form   341         value.   342    343         If 'show_tzid' is set to a false value, the time zone menu will not be   344         provided.   345    346         If 'read_only' is set to a true value, the controls will be hidden and   347         labels will be employed instead.   348         """   349    350         page = self.page   351    352         # Show dates for up to one week around the current date.   353    354         page.span(class_="date enabled")   355    356         dt = default.as_datetime()   357    358         # For invalid datetimes, try to get a date instead.   359    360         if not dt:   361             dt = default.as_datetime(with_time=False)   362    363             # For invalid dates, just use today's date.   364    365             if not dt:   366                 dt = date.today()   367    368         base = to_date(dt)   369    370         # Show a date label with a hidden field if read-only.   371    372         if read_only:   373             self.control("%s-date" % name, "hidden", format_datetime(base))   374             page.span(self.format_date(base, "long"))   375    376         # Show dates for up to one week around the current date.   377         # NOTE: Support paging to other dates.   378    379         else:   380             items = []   381             for i in range(-7, 8):   382                 d = base + timedelta(i)   383                 items.append((format_datetime(d), self.format_date(d, "full")))   384             self.menu("%s-date" % name, format_datetime(base), items, index=index)   385    386         page.span.close()   387    388         # Show time details.   389    390         page.span(class_="time enabled")   391    392         if read_only:   393             page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second()))   394             self.control("%s-hour" % name, "hidden", default.get_hour())   395             self.control("%s-minute" % name, "hidden", default.get_minute())   396             self.control("%s-second" % name, "hidden", default.get_second())   397         else:   398             self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2)   399             page.add(":")   400             self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2)   401             page.add(":")   402             self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2)   403    404         # Show time zone details.   405    406         if show_tzid:   407             page.add(" ")   408             tzid = default.get_tzid() or self.get_tzid()   409    410             # Show a label if read-only or a menu otherwise.   411    412             if read_only:   413                 self.control("%s-tzid" % name, "hidden", tzid)   414                 page.span(tzid)   415             else:   416                 self.timezone_menu("%s-tzid" % name, tzid, index)   417    418         page.span.close()   419    420     def timezone_menu(self, name, default, index=None):   421    422         """   423         Show timezone controls using a menu with the given 'name', set to the   424         given 'default' unless a field of the given 'name' provides a value.   425         """   426    427         entries = [(tzid, tzid) for tzid in pytz.all_timezones]   428         self.menu(name, default, entries, index=index)   429    430 class DateTimeFormUtilities:   431    432     "Date/time control methods resource mix-in."   433    434     # Control naming helpers.   435    436     def element_identifier(self, name, index=None):   437         return index is not None and "%s-%d" % (name, index) or name   438    439     def element_name(self, name, suffix, index=None):   440         return index is not None and "%s-%s" % (name, suffix) or name   441    442     def element_enable(self, index=None):   443         return str(index or 0)   444    445     def show_object_datetime_controls(self, period, index=None):   446    447         """   448         Show datetime-related controls if already active or if an object needs   449         them for the given 'period'. The given 'index' is used to parameterise   450         individual controls for dynamic manipulation.   451         """   452    453         p = form_period_from_period(period)   454    455         page = self.page   456         _id = self.element_identifier   457         _name = self.element_name   458         _enable = self.element_enable   459    460         # Add a dynamic stylesheet to permit the controls to modify the display.   461         # NOTE: The style details need to be coordinated with the static   462         # NOTE: stylesheet.   463    464         if index is not None:   465             page.style(type="text/css")   466    467             # Unlike the rules for object properties, these affect recurrence   468             # properties.   469    470             page.add("""\   471 input#dttimes-enable-%(index)d,   472 input#dtend-enable-%(index)d,   473 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,   474 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,   475 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,   476 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {   477     display: none;   478 }   479    480 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .date.enabled,   481 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .date.disabled {   482     visibility: hidden;   483 }""" % {"index" : index})   484    485             page.style.close()   486    487         self.control(   488             _name("dtend-control", "recur", index), "checkbox",   489             _enable(index), p.end_enabled,   490             id=_id("dtend-enable", index)   491             )   492    493         self.control(   494             _name("dttimes-control", "recur", index), "checkbox",   495             _enable(index), p.times_enabled,   496             id=_id("dttimes-enable", index)   497             )   498    499     def show_datetime_controls(self, formdate, show_start):   500    501         """   502         Show datetime details from the current object for the 'formdate',   503         showing start details if 'show_start' is set to a true value. Details   504         will appear as controls for organisers and labels for attendees.   505         """   506    507         page = self.page   508    509         # Show controls for editing.   510    511         page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))   512    513         if show_start:   514             page.div(class_="dt enabled")   515             self.date_controls("dtstart", formdate)   516             page.br()   517             page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")   518             page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")   519             page.div.close()   520    521         else:   522             self.date_controls("dtend", formdate)   523             page.div(class_="dt disabled")   524             page.label("Specify end date", for_="dtend-enable", class_="enable")   525             page.div.close()   526             page.div(class_="dt enabled")   527             page.label("End on same day", for_="dtend-enable", class_="disable")   528             page.div.close()   529    530         page.td.close()   531    532     def show_recurrence_controls(self, index, period, recurrenceid, show_start):   533    534         """   535         Show datetime details from the current object for the recurrence having   536         the given 'index', with the recurrence period described by 'period',   537         indicating a start, end and origin of the period from the event details,   538         employing any 'recurrenceid' for the object to configure the displayed   539         information.   540    541         If 'show_start' is set to a true value, the start details will be shown;   542         otherwise, the end details will be shown.   543         """   544    545         page = self.page   546         _id = self.element_identifier   547         _name = self.element_name   548    549         period = form_period_from_period(period)   550    551         # Show controls for editing.   552    553         if not period.replaced:   554             page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))   555    556             read_only = period.origin == "RRULE"   557    558             if show_start:   559                 page.div(class_="dt enabled")   560                 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=read_only)   561                 if not read_only:   562                     page.br()   563                     page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable")   564                     page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable")   565                 page.div.close()   566    567                 # Put the origin somewhere.   568    569                 self.control("recur-origin", "hidden", period.origin or "")   570                 self.control("recur-replaced", "hidden", period.replaced and str(index) or "")   571    572             else:   573                 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=read_only)   574                 if not read_only:   575                     page.div(class_="dt disabled")   576                     page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable")   577                     page.div.close()   578                     page.div(class_="dt enabled")   579                     page.label("End on same day", for_=_id("dtend-enable", index), class_="disable")   580                     page.div.close()   581    582             page.td.close()   583    584         # Show label as attendee.   585    586         else:   587             self.show_recurrence_label(index, period, recurrenceid, show_start)   588    589     def show_recurrence_label(self, index, period, recurrenceid, show_start):   590    591         """   592         Show datetime details from the current object for the recurrence having   593         the given 'index', for the given recurrence 'period', employing any   594         'recurrenceid' for the object to configure the displayed information.   595    596         If 'show_start' is set to a true value, the start details will be shown;   597         otherwise, the end details will be shown.   598         """   599    600         page = self.page   601         _name = self.element_name   602    603         try:   604             p = event_period_from_period(period)   605         except PeriodError, exc:   606             affected = False   607         else:   608             affected = p.is_affected(recurrenceid)   609    610         period = form_period_from_period(period)   611    612         css = " ".join([   613             period.replaced and "replaced" or "",   614             affected and "affected" or ""   615             ])   616    617         formdate = show_start and period.get_form_start() or period.get_form_end()   618         dt = formdate.as_datetime()   619         if dt:   620             page.td(class_=css)   621             if show_start:   622                 self.date_controls(_name("dtstart", "recur", index), period.get_form_start(), index=index, read_only=True)   623                 self.control("recur-origin", "hidden", period.origin or "")   624                 self.control("recur-replaced", "hidden", period.replaced and str(index) or "")   625             else:   626                 self.date_controls(_name("dtend", "recur", index), period.get_form_end(), index=index, show_tzid=False, read_only=True)   627             page.td.close()   628         else:   629             page.td("(Unrecognised date)")   630    631 # vim: tabstop=4 expandtab shiftwidth=4