imip-agent

imipweb/resource.py

813:a32c6487cdaa
2015-10-06 Paul Boddie Added basic counter-proposal acceptance support. Added a common form utility method for getting prefixed form field names as values. Moved request/event removal methods to the common client functionality, simplifying them to work on the current object.
     1 #!/usr/bin/env python     2      3 """     4 Common resource functionality for Web calendar clients.     5      6 Copyright (C) 2014, 2015 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_address, get_uri, uri_item, uri_values    25 from imiptools.dates import format_datetime, get_recurrence_start_point, to_date    26 from imiptools.period import remove_period, remove_affected_period    27 from imipweb.data import event_period_from_period, form_period_from_period, FormDate    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, recurrenceid=None, args=None):    86     87         """    88         Return a link to an object with the given 'uid' and 'recurrenceid'.    89         See get_identifiers for the decoding of such links.    90     91         If 'args' is specified, the given dictionary is encoded and included.    92         """    93     94         path = [uid]    95         if recurrenceid:    96             path.append(recurrenceid)    97         return "%s%s" % (self.env.new_url("/".join(path)), args and ("?%s" % urlencode(args)) or "")    98     99     # Access to objects.   100    101     def get_identifiers(self, path_info):   102    103         """   104         Return identifiers provided by 'path_info', potentially encoded by   105         'link_to'.   106         """   107    108         parts = path_info.lstrip("/").split("/")   109    110         # UID only.   111    112         if len(parts) == 1:   113             return parts[0], None   114    115         # UID and RECURRENCE-ID.   116    117         else:   118             return parts[:2]   119    120     def _get_object(self, uid, recurrenceid=None, section=None, username=None):   121         if self.objects.has_key((uid, recurrenceid, section, username)):   122             return self.objects[(uid, recurrenceid, section, username)]   123    124         obj = self.objects[(uid, recurrenceid, section, username)] = self.get_stored_object(uid, recurrenceid, section, username)   125         return obj   126    127     def _get_recurrences(self, uid):   128         return self.store.get_recurrences(self.user, uid)   129    130     def _get_active_recurrences(self, uid):   131         return self.store.get_active_recurrences(self.user, uid)   132    133     def _get_requests(self):   134         if self.requests is None:   135             self.requests = self.store.get_requests(self.user)   136         return self.requests   137    138     def _have_request(self, uid, recurrenceid=None, type=None, strict=False):   139         return self.store.have_request(self._get_requests(), uid, recurrenceid, type, strict)   140    141     def _get_counters(self, uid, recurrenceid=None):   142         return self.store.get_counters(self.user, uid, recurrenceid)   143    144     def _get_request_summary(self):   145    146         "Return a list of periods comprising the request summary."   147    148         summary = []   149    150         for uid, recurrenceid, request_type in self._get_requests():   151    152             # Obtain either normal objects or counter-proposals.   153    154             if not request_type:   155                 objs = [self._get_object(uid, recurrenceid)]   156             elif request_type == "COUNTER":   157                 objs = []   158                 for attendee in self.store.get_counters(self.user, uid, recurrenceid):   159                     objs.append(self._get_object(uid, recurrenceid, "counters", attendee))   160    161             # For each object, obtain the periods involved.   162    163             for obj in objs:   164                 if obj:   165                     recurrenceids = self._get_active_recurrences(uid)   166    167                     # Obtain only active periods, not those replaced by redefined   168                     # recurrences, converting to free/busy periods.   169    170                     for p in obj.get_active_periods(recurrenceids, self.get_tzid(), self.get_window_end()):   171                         summary.append(obj.get_freebusy_period(p))   172    173         return summary   174    175     # Preference methods.   176    177     def get_user_locale(self):   178         if not self.locale:   179             self.locale = self.get_preferences().get("LANG", "en")   180         return self.locale   181    182     # Prettyprinting of dates and times.   183    184     def format_date(self, dt, format):   185         return self._format_datetime(babel.dates.format_date, dt, format)   186    187     def format_time(self, dt, format):   188         return self._format_datetime(babel.dates.format_time, dt, format)   189    190     def format_datetime(self, dt, format):   191         return self._format_datetime(   192             isinstance(dt, datetime) and babel.dates.format_datetime or babel.dates.format_date,   193             dt, format)   194    195     def _format_datetime(self, fn, dt, format):   196         return fn(dt, format=format, locale=self.get_user_locale())   197    198 class ResourceClient(Resource, Client):   199    200     "A Web application resource and calendar client."   201    202     def __init__(self, resource=None):   203         Resource.__init__(self, resource)   204         user = self.env.get_user()   205         Client.__init__(self, user and get_uri(user) or None)   206    207 class ResourceClientForObject(Resource, ClientForObject):   208    209     "A Web application resource and calendar client for a specific object."   210    211     def __init__(self, resource=None, messenger=None):   212         Resource.__init__(self, resource)   213         user = self.env.get_user()   214         ClientForObject.__init__(self, None, user and get_uri(user) or None, messenger)   215    216     # Communication methods.   217    218     def send_message(self, method, sender, from_organiser, parts=None):   219    220         """   221         Create a full calendar object employing the given 'method', and send it   222         to the appropriate recipients, also sending a copy to the 'sender'. The   223         'from_organiser' value indicates whether the organiser is sending this   224         message (and is thus equivalent to "as organiser").   225         """   226    227         parts = parts or [self.obj.to_part(method)]   228    229         # As organiser, send an invitation to attendees, excluding oneself if   230         # also attending. The updated event will be saved by the outgoing   231         # handler.   232    233         organiser = get_uri(self.obj.get_value("ORGANIZER"))   234         attendees = uri_values(self.obj.get_values("ATTENDEE"))   235    236         if from_organiser:   237             recipients = [get_address(attendee) for attendee in attendees if attendee != self.user]   238         else:   239             recipients = [get_address(organiser)]   240    241         # Since the outgoing handler updates this user's free/busy details,   242         # the stored details will probably not have the updated details at   243         # this point, so we update our copy for serialisation as the bundled   244         # free/busy object.   245    246         freebusy = self.store.get_freebusy(self.user)   247         self.update_freebusy(freebusy, self.user, from_organiser)   248    249         # Bundle free/busy information if appropriate.   250    251         part = self.get_freebusy_part(freebusy)   252         if part:   253             parts.append(part)   254    255         self._send_message(sender, recipients, parts)   256    257     def _send_message(self, sender, recipients, parts):   258    259         # Explicitly specify the outgoing BCC recipient since we are sending as   260         # the generic calendar user.   261    262         message = self.messenger.make_outgoing_message(parts, recipients, outgoing_bcc=sender)   263         self.messenger.sendmail(recipients, message.as_string(), outgoing_bcc=sender)   264    265     # Action methods.   266    267     def process_declined_counter(self, attendee):   268    269         "Process a declined counter-proposal."   270    271         # Obtain the counter-proposal for the attendee.   272    273         obj = self.get_stored_object(self.uid, self.recurrenceid, "counters", attendee)   274         if not obj:   275             return False   276    277         method = "DECLINECOUNTER"   278         obj.update_dtstamp()   279         obj.update_sequence(False)   280         self._send_message(get_address(self.user), [get_address(attendee)], parts=[obj.to_part(method)])   281         return True   282    283     def process_received_request(self):   284    285         """   286         Process the current request for the current user. Return whether any   287         action was taken.   288         """   289    290         # Reply only on behalf of this user.   291    292         attendee_attr = self.update_participation(self.obj)   293    294         if not attendee_attr:   295             return False   296    297         self.obj["ATTENDEE"] = [(self.user, attendee_attr)]   298         self.update_dtstamp()   299         self.update_sequence(False)   300         self.send_message("REPLY", get_address(self.user), from_organiser=False)   301         return True   302    303     def process_created_request(self, method, to_cancel=None, to_unschedule=None):   304    305         """   306         Process the current request, sending a created request of the given   307         'method' to attendees. Return whether any action was taken.   308    309         If 'to_cancel' is specified, a list of participants to be sent cancel   310         messages is provided.   311    312         If 'to_unschedule' is specified, a list of periods to be unscheduled is   313         provided.   314         """   315    316         # Here, the organiser should be the current user.   317    318         organiser, organiser_attr = uri_item(self.obj.get_item("ORGANIZER"))   319    320         self.update_sender(organiser_attr)   321         self.update_dtstamp()   322         self.update_sequence(True)   323    324         parts = [self.obj.to_part(method)]   325    326         # Add message parts with cancelled occurrence information.   327         # NOTE: This could probably be merged with the updated event message.   328    329         if to_unschedule:   330             obj = self.obj.copy()   331             obj.remove_all(["RRULE", "RDATE", "DTSTART", "DTEND", "DURATION"])   332    333             for p in to_unschedule:   334                 if not p.origin:   335                     continue   336                 obj["RECURRENCE-ID"] = [(format_datetime(p.get_start()), p.get_start_attr())]   337                 parts.append(obj.to_part("CANCEL"))   338    339         # Send the updated event, along with a cancellation for each of the   340         # unscheduled occurrences.   341    342         self.send_message("CANCEL", get_address(organiser), from_organiser=True, parts=parts)   343    344         # When cancelling, replace the attendees with those for whom the event   345         # is now cancelled.   346    347         if to_cancel:   348             obj = self.obj.copy()   349             obj["ATTENDEE"] = to_cancel   350    351             # Send a cancellation to all uninvited attendees.   352    353             self.send_message("CANCEL", get_address(organiser), from_organiser=True)   354    355         return True   356    357 class FormUtilities:   358    359     "Utility methods resource mix-in."   360    361     def prefixed_args(self, prefix, convert=None):   362    363         """   364         Return values for all arguments having the given 'prefix' in their   365         names, removing the prefix to obtain each value from the argument name   366         itself. The 'convert' callable can be specified to perform a conversion   367         (to int, for example).   368         """   369    370         args = self.env.get_args()   371    372         values = []   373         for name in args.keys():   374             if name.startswith(prefix):   375                 value = name[len(prefix):]   376                 if convert:   377                     try:   378                         value = convert(value)   379                     except ValueError:   380                         pass   381                 values.append(value)   382         return values   383    384     def control(self, name, type, value, selected=False, **kw):   385    386         """   387         Show a control with the given 'name', 'type' and 'value', with   388         'selected' indicating whether it should be selected (checked or   389         equivalent), and with keyword arguments setting other properties.   390         """   391    392         page = self.page   393         if type in ("checkbox", "radio") and selected:   394             page.input(name=name, type=type, value=value, checked=selected, **kw)   395         else:   396             page.input(name=name, type=type, value=value, **kw)   397    398     def menu(self, name, default, items, class_="", index=None):   399    400         """   401         Show a select menu having the given 'name', set to the given 'default',   402         providing the given (value, label) 'items', and employing the given CSS   403         'class_' if specified.   404         """   405    406         page = self.page   407         values = self.env.get_args().get(name, [default])   408         if index is not None:   409             values = values[index:]   410             values = values and values[0:1] or [default]   411    412         page.select(name=name, class_=class_)   413         for v, label in items:   414             if v is None:   415                 continue   416             if v in values:   417                 page.option(label, value=v, selected="selected")   418             else:   419                 page.option(label, value=v)   420         page.select.close()   421    422     def date_controls(self, name, default, index=None, show_tzid=True, read_only=False):   423    424         """   425         Show date controls for a field with the given 'name' and 'default' form   426         date value.   427    428         If 'index' is specified, default field values will be overridden by the   429         element from a collection of existing form values with the specified   430         index; otherwise, field values will be overridden by a single form   431         value.   432    433         If 'show_tzid' is set to a false value, the time zone menu will not be   434         provided.   435    436         If 'read_only' is set to a true value, the controls will be hidden and   437         labels will be employed instead.   438         """   439    440         page = self.page   441    442         # Show dates for up to one week around the current date.   443    444         dt = default.as_datetime()   445         if not dt:   446             dt = date.today()   447    448         base = to_date(dt)   449    450         # Show a date label with a hidden field if read-only.   451    452         if read_only:   453             self.control("%s-date" % name, "hidden", format_datetime(base))   454             page.span(self.format_date(base, "long"))   455    456         # Show dates for up to one week around the current date.   457         # NOTE: Support paging to other dates.   458    459         else:   460             items = []   461             for i in range(-7, 8):   462                 d = base + timedelta(i)   463                 items.append((format_datetime(d), self.format_date(d, "full")))   464             self.menu("%s-date" % name, format_datetime(base), items, index=index)   465    466         # Show time details.   467    468         page.span(class_="time enabled")   469    470         if read_only:   471             page.span("%s:%s:%s" % (default.get_hour(), default.get_minute(), default.get_second()))   472             self.control("%s-hour" % name, "hidden", default.get_hour())   473             self.control("%s-minute" % name, "hidden", default.get_minute())   474             self.control("%s-second" % name, "hidden", default.get_second())   475         else:   476             self.control("%s-hour" % name, "text", default.get_hour(), maxlength=2, size=2)   477             page.add(":")   478             self.control("%s-minute" % name, "text", default.get_minute(), maxlength=2, size=2)   479             page.add(":")   480             self.control("%s-second" % name, "text", default.get_second(), maxlength=2, size=2)   481    482         # Show time zone details.   483    484         if show_tzid:   485             page.add(" ")   486             tzid = default.get_tzid() or self.get_tzid()   487    488             # Show a label if read-only or a menu otherwise.   489    490             if read_only:   491                 self.control("%s-tzid" % name, "hidden", tzid)   492                 page.span(tzid)   493             else:   494                 self.timezone_menu("%s-tzid" % name, tzid, index)   495    496         page.span.close()   497    498     def timezone_menu(self, name, default, index=None):   499    500         """   501         Show timezone controls using a menu with the given 'name', set to the   502         given 'default' unless a field of the given 'name' provides a value.   503         """   504    505         entries = [(tzid, tzid) for tzid in pytz.all_timezones]   506         self.menu(name, default, entries, index=index)   507    508 class DateTimeFormUtilities:   509    510     "Date/time control methods resource mix-in."   511    512     # Control naming helpers.   513    514     def element_identifier(self, name, index=None):   515         return index is not None and "%s-%d" % (name, index) or name   516    517     def element_name(self, name, suffix, index=None):   518         return index is not None and "%s-%s" % (name, suffix) or name   519    520     def element_enable(self, index=None):   521         return index is not None and str(index) or "enable"   522    523     def show_object_datetime_controls(self, period, index=None):   524    525         """   526         Show datetime-related controls if already active or if an object needs   527         them for the given 'period'. The given 'index' is used to parameterise   528         individual controls for dynamic manipulation.   529         """   530    531         p = form_period_from_period(period)   532    533         page = self.page   534         args = self.env.get_args()   535         _id = self.element_identifier   536         _name = self.element_name   537         _enable = self.element_enable   538    539         # Add a dynamic stylesheet to permit the controls to modify the display.   540         # NOTE: The style details need to be coordinated with the static   541         # NOTE: stylesheet.   542    543         if index is not None:   544             page.style(type="text/css")   545    546             # Unlike the rules for object properties, these affect recurrence   547             # properties.   548    549             page.add("""\   550 input#dttimes-enable-%(index)d,   551 input#dtend-enable-%(index)d,   552 input#dttimes-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue .time.enabled,   553 input#dttimes-enable-%(index)d:checked ~ .recurrence td.objectvalue .time.disabled,   554 input#dtend-enable-%(index)d:not(:checked) ~ .recurrence td.objectvalue.dtend .dt.enabled,   555 input#dtend-enable-%(index)d:checked ~ .recurrence td.objectvalue.dtend .dt.disabled {   556     display: none;   557 }""" % {"index" : index})   558    559             page.style.close()   560    561         self.control(   562             _name("dtend-control", "recur", index), "checkbox",   563             _enable(index), p.end_enabled,   564             id=_id("dtend-enable", index)   565             )   566    567         self.control(   568             _name("dttimes-control", "recur", index), "checkbox",   569             _enable(index), p.times_enabled,   570             id=_id("dttimes-enable", index)   571             )   572    573     def show_datetime_controls(self, formdate, show_start):   574    575         """   576         Show datetime details from the current object for the 'formdate',   577         showing start details if 'show_start' is set to a true value. Details   578         will appear as controls for organisers and labels for attendees.   579         """   580    581         page = self.page   582    583         # Show controls for editing as organiser.   584    585         if self.is_organiser():   586             page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))   587    588             if show_start:   589                 page.div(class_="dt enabled")   590                 self.date_controls("dtstart", formdate)   591                 page.br()   592                 page.label("Specify times", for_="dttimes-enable", class_="time disabled enable")   593                 page.label("Specify dates only", for_="dttimes-enable", class_="time enabled disable")   594                 page.div.close()   595    596             else:   597                 page.div(class_="dt disabled")   598                 page.label("Specify end date", for_="dtend-enable", class_="enable")   599                 page.div.close()   600                 page.div(class_="dt enabled")   601                 self.date_controls("dtend", formdate)   602                 page.br()   603                 page.label("End on same day", for_="dtend-enable", class_="disable")   604                 page.div.close()   605    606             page.td.close()   607    608         # Show a label as attendee.   609    610         else:   611             dt = formdate.as_datetime()   612             if dt:   613                 page.td(self.format_datetime(dt, "full"))   614             else:   615                 page.td("(Unrecognised date)")   616    617     def show_recurrence_controls(self, index, period, recurrenceid, recurrenceids, show_start):   618    619         """   620         Show datetime details from the current object for the recurrence having   621         the given 'index', with the recurrence period described by 'period',   622         indicating a start, end and origin of the period from the event details,   623         employing any 'recurrenceid' and 'recurrenceids' for the object to   624         configure the displayed information.   625    626         If 'show_start' is set to a true value, the start details will be shown;   627         otherwise, the end details will be shown.   628         """   629    630         page = self.page   631         _id = self.element_identifier   632         _name = self.element_name   633    634         p = event_period_from_period(period)   635         replaced = not recurrenceid and p.is_replaced(recurrenceids)   636    637         # Show controls for editing as organiser.   638    639         if self.is_organiser() and not replaced:   640             page.td(class_="objectvalue dt%s" % (show_start and "start" or "end"))   641    642             read_only = period.origin == "RRULE"   643    644             if show_start:   645                 page.div(class_="dt enabled")   646                 self.date_controls(_name("dtstart", "recur", index), p.get_form_start(), index=index, read_only=read_only)   647                 if not read_only:   648                     page.br()   649                     page.label("Specify times", for_=_id("dttimes-enable", index), class_="time disabled enable")   650                     page.label("Specify dates only", for_=_id("dttimes-enable", index), class_="time enabled disable")   651                 page.div.close()   652    653                 # Put the origin somewhere.   654    655                 self.control("recur-origin", "hidden", p.origin or "")   656    657             else:   658                 page.div(class_="dt disabled")   659                 if not read_only:   660                     page.label("Specify end date", for_=_id("dtend-enable", index), class_="enable")   661                 page.div.close()   662                 page.div(class_="dt enabled")   663                 self.date_controls(_name("dtend", "recur", index), p.get_form_end(), index=index, show_tzid=False, read_only=read_only)   664                 if not read_only:   665                     page.br()   666                     page.label("End on same day", for_=_id("dtend-enable", index), class_="disable")   667                 page.div.close()   668    669             page.td.close()   670    671         # Show label as attendee.   672    673         else:   674             self.show_recurrence_label(p, recurrenceid, recurrenceids, show_start)   675    676     def show_recurrence_label(self, period, recurrenceid, recurrenceids, show_start):   677    678         """   679         Show datetime details for the given 'period', employing any   680         'recurrenceid' and 'recurrenceids' for the object to configure the   681         displayed information.   682    683         If 'show_start' is set to a true value, the start details will be shown;   684         otherwise, the end details will be shown.   685         """   686    687         page = self.page   688    689         p = event_period_from_period(period)   690         replaced = not recurrenceid and p.is_replaced(recurrenceids)   691    692         css = " ".join([   693             replaced and "replaced" or "",   694             p.is_affected(recurrenceid) and "affected" or ""   695             ])   696    697         formdate = show_start and p.get_form_start() or p.get_form_end()   698         dt = formdate.as_datetime()   699         if dt:   700             page.td(self.format_datetime(dt, "long"), class_=css)   701         else:   702             page.td("(Unrecognised date)")   703    704     def get_date_control_values(self, name, multiple=False, tzid_name=None):   705    706         """   707         Return a form date object representing fields starting with 'name'. If   708         'multiple' is set to a true value, many date objects will be returned   709         corresponding to a collection of datetimes.   710    711         If 'tzid_name' is specified, the time zone information will be acquired   712         from fields starting with 'tzid_name' instead of 'name'.   713         """   714    715         args = self.env.get_args()   716    717         dates = args.get("%s-date" % name, [])   718         hours = args.get("%s-hour" % name, [])   719         minutes = args.get("%s-minute" % name, [])   720         seconds = args.get("%s-second" % name, [])   721         tzids = args.get("%s-tzid" % (tzid_name or name), [])   722    723         # Handle absent values by employing None values.   724    725         field_values = map(None, dates, hours, minutes, seconds, tzids)   726    727         if not field_values and not multiple:   728             all_values = FormDate()   729         else:   730             all_values = []   731             for date, hour, minute, second, tzid in field_values:   732                 value = FormDate(date, hour, minute, second, tzid or self.get_tzid())   733    734                 # Return a single value or append to a collection of all values.   735    736                 if not multiple:   737                     return value   738                 else:   739                     all_values.append(value)   740    741         return all_values   742    743     def set_date_control_values(self, name, formdates, tzid_name=None):   744    745         """   746         Replace form fields starting with 'name' using the values of the given   747         'formdates'.   748    749         If 'tzid_name' is specified, the time zone information will be stored in   750         fields starting with 'tzid_name' instead of 'name'.   751         """   752    753         args = self.env.get_args()   754    755         args["%s-date" % name] = [d.date for d in formdates]   756         args["%s-hour" % name] = [d.hour for d in formdates]   757         args["%s-minute" % name] = [d.minute for d in formdates]   758         args["%s-second" % name] = [d.second for d in formdates]   759         args["%s-tzid" % (tzid_name or name)] = [d.tzid for d in formdates]   760    761 # vim: tabstop=4 expandtab shiftwidth=4