imip-agent

imipweb/resource.py

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