imip-agent

imipweb/resource.py

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