imip-agent

imipweb/resource.py

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