web/views/ajaxcontroller.py
changeset 8128 0a927fe4541b
child 8190 2a3c1b787688
equal deleted inserted replaced
8125:7070250bf50d 8128:0a927fe4541b
       
     1 # copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 #
       
    19 # (disable pylint msg for client obj access to protected member as in obj._cw)
       
    20 # pylint: disable=W0212
       
    21 """The ``ajaxcontroller`` module defines the :class:`AjaxController`
       
    22 controller and the ``ajax-funcs`` cubicweb registry.
       
    23 
       
    24 .. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxController
       
    25    :members:
       
    26 
       
    27 ``ajax-funcs`` registry hosts exposed remote functions, that is
       
    28 functions that can be called from the javascript world.
       
    29 
       
    30 To register a new remote function, either decorate your function
       
    31 with the :ref:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator:
       
    32 
       
    33 .. sourcecode:: python
       
    34 
       
    35     from cubicweb.selectors import mactch_user_groups
       
    36     from cubicweb.web.views.ajaxcontroller import ajaxfunc
       
    37 
       
    38     @ajaxfunc(output_type='json', selector=match_user_groups('managers'))
       
    39     def list_users(self):
       
    40         return [u for (u,) in self._cw.execute('Any L WHERE U login L')]
       
    41 
       
    42 or inherit from :class:`cubicwbe.web.views.ajaxcontroller.AjaxFunction` and
       
    43 implement the ``__call__`` method:
       
    44 
       
    45 .. sourcecode:: python
       
    46 
       
    47     from cubicweb.web.views.ajaxcontroller import AjaxFunction
       
    48     class ListUser(AjaxFunction):
       
    49         __regid__ = 'list_users' # __regid__ is the name of the exposed function
       
    50         __select__ = match_user_groups('managers')
       
    51         output_type = 'json'
       
    52 
       
    53         def __call__(self):
       
    54             return [u for (u, ) in self._cw.execute('Any L WHERE U login L')]
       
    55 
       
    56 
       
    57 .. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxFunction
       
    58    :members:
       
    59 
       
    60 .. autofunction:: cubicweb.web.views.ajaxcontroller.ajaxfunc
       
    61 
       
    62 """
       
    63 
       
    64 __docformat__ = "restructuredtext en"
       
    65 
       
    66 from functools import partial
       
    67 
       
    68 from logilab.common.date import strptime
       
    69 from logilab.common.deprecation import deprecated
       
    70 
       
    71 from cubicweb import ObjectNotFound, NoSelectableObject
       
    72 from cubicweb.appobject import AppObject
       
    73 from cubicweb.selectors import yes
       
    74 from cubicweb.utils import json, json_dumps, UStringIO
       
    75 from cubicweb.uilib import exc_message
       
    76 from cubicweb.web import RemoteCallFailed, DirectResponse
       
    77 from cubicweb.web.controller import Controller
       
    78 from cubicweb.web.views import vid_from_rset
       
    79 from cubicweb.web.views import basecontrollers
       
    80 
       
    81 
       
    82 def optional_kwargs(extraargs):
       
    83     if extraargs is None:
       
    84         return {}
       
    85     # we receive unicode keys which is not supported by the **syntax
       
    86     return dict((str(key), value) for key, value in extraargs.iteritems())
       
    87 
       
    88 
       
    89 class AjaxController(Controller):
       
    90     """AjaxController handles ajax remote calls from javascript
       
    91 
       
    92     The following javascript function call:
       
    93 
       
    94     .. sourcecode:: javascript
       
    95 
       
    96       var d = asyncRemoteExec('foo', 12, "hello");
       
    97       d.addCallback(function(result) {
       
    98           alert('server response is: ' + result);
       
    99       });
       
   100 
       
   101     will generate an ajax HTTP GET on the following url::
       
   102 
       
   103         BASE_URL/ajax?fname=foo&arg=12&arg="hello"
       
   104 
       
   105     The AjaxController controller will therefore be selected to handle those URLs
       
   106     and will itself select the :class:`cubicweb.web.views.ajaxcontroller.AjaxFunction`
       
   107     matching the *fname* parameter.
       
   108     """
       
   109     __regid__ = 'ajax'
       
   110 
       
   111     def publish(self, rset=None):
       
   112         self._cw.ajax_request = True
       
   113         try:
       
   114             fname = self._cw.form['fname']
       
   115         except KeyError:
       
   116             raise RemoteCallFailed('no method specified')
       
   117         try:
       
   118             func = self._cw.vreg['ajax-func'].select(fname, self._cw)
       
   119         except ObjectNotFound:
       
   120             # function not found in the registry, inspect JSonController for
       
   121             # backward compatibility
       
   122             try:
       
   123                 func = getattr(basecontrollers.JSonController, 'js_%s' % fname).im_func
       
   124                 func = partial(func, self)
       
   125             except AttributeError:
       
   126                 raise RemoteCallFailed('no %s method' % fname)
       
   127             else:
       
   128                 self.warning('remote function %s found on JSonController, '
       
   129                              'use AjaxFunction / @ajaxfunc instead', fname)
       
   130         except NoSelectableObject:
       
   131             raise RemoteCallFailed('method %s not available in this context'
       
   132                                    % fname)
       
   133         # no <arg> attribute means the callback takes no argument
       
   134         args = self._cw.form.get('arg', ())
       
   135         if not isinstance(args, (list, tuple)):
       
   136             args = (args,)
       
   137         try:
       
   138             args = [json.loads(arg) for arg in args]
       
   139         except ValueError, exc:
       
   140             self.exception('error while decoding json arguments for '
       
   141                            'js_%s: %s (err: %s)', fname, args, exc)
       
   142             raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
       
   143         try:
       
   144             result = func(*args)
       
   145         except (RemoteCallFailed, DirectResponse):
       
   146             raise
       
   147         except Exception, exc:
       
   148             self.exception('an exception occurred while calling js_%s(%s): %s',
       
   149                            fname, args, exc)
       
   150             raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
       
   151         if result is None:
       
   152             return ''
       
   153         # get unicode on @htmlize methods, encoded string on @jsonize methods
       
   154         elif isinstance(result, unicode):
       
   155             return result.encode(self._cw.encoding)
       
   156         return result
       
   157 
       
   158 class AjaxFunction(AppObject):
       
   159     """
       
   160     Attributes on this base class are:
       
   161 
       
   162     :attr: `check_pageid`: make sure the pageid received is valid before proceeding
       
   163     :attr: `output_type`:
       
   164 
       
   165            - *None*: no processing, no change on content-type
       
   166 
       
   167            - *json*: serialize with `json_dumps` and set *application/json*
       
   168                      content-type
       
   169 
       
   170            - *xhtml*: wrap result in an XML node and forces HTML / XHTML
       
   171                       content-type (use ``_cw.html_content_type()``)
       
   172 
       
   173     """
       
   174     __registry__ = 'ajax-func'
       
   175     __select__ = yes()
       
   176     __abstract__ = True
       
   177 
       
   178     check_pageid = False
       
   179     output_type = None
       
   180 
       
   181     @staticmethod
       
   182     def _rebuild_posted_form(names, values, action=None):
       
   183         form = {}
       
   184         for name, value in zip(names, values):
       
   185             # remove possible __action_xxx inputs
       
   186             if name.startswith('__action'):
       
   187                 if action is None:
       
   188                     # strip '__action_' to get the actual action name
       
   189                     action = name[9:]
       
   190                 continue
       
   191             # form.setdefault(name, []).append(value)
       
   192             if name in form:
       
   193                 curvalue = form[name]
       
   194                 if isinstance(curvalue, list):
       
   195                     curvalue.append(value)
       
   196                 else:
       
   197                     form[name] = [curvalue, value]
       
   198             else:
       
   199                 form[name] = value
       
   200         # simulate click on __action_%s button to help the controller
       
   201         if action:
       
   202             form['__action_%s' % action] = u'whatever'
       
   203         return form
       
   204 
       
   205     def validate_form(self, action, names, values):
       
   206         self._cw.form = self._rebuild_posted_form(names, values, action)
       
   207         return basecontrollers._validate_form(self._cw, self._cw.vreg)
       
   208 
       
   209     def _exec(self, rql, args=None, rocheck=True):
       
   210         """json mode: execute RQL and return resultset as json"""
       
   211         rql = rql.strip()
       
   212         if rql.startswith('rql:'):
       
   213             rql = rql[4:]
       
   214         if rocheck:
       
   215             self._cw.ensure_ro_rql(rql)
       
   216         try:
       
   217             return self._cw.execute(rql, args)
       
   218         except Exception, ex:
       
   219             self.exception("error in _exec(rql=%s): %s", rql, ex)
       
   220             return None
       
   221         return None
       
   222 
       
   223     def _call_view(self, view, paginate=False, **kwargs):
       
   224         divid = self._cw.form.get('divid')
       
   225         # we need to call pagination before with the stream set
       
   226         try:
       
   227             stream = view.set_stream()
       
   228         except AttributeError:
       
   229             stream = UStringIO()
       
   230             kwargs['w'] = stream.write
       
   231             assert not paginate
       
   232         if divid == 'pageContent':
       
   233             # ensure divid isn't reused by the view (e.g. table view)
       
   234             del self._cw.form['divid']
       
   235             # mimick main template behaviour
       
   236             stream.write(u'<div id="pageContent">')
       
   237             vtitle = self._cw.form.get('vtitle')
       
   238             if vtitle:
       
   239                 stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
       
   240             paginate = True
       
   241         nav_html = UStringIO()
       
   242         if paginate and not view.handle_pagination:
       
   243             view.paginate(w=nav_html.write)
       
   244         stream.write(nav_html.getvalue())
       
   245         if divid == 'pageContent':
       
   246             stream.write(u'<div id="contentmain">')
       
   247         view.render(**kwargs)
       
   248         extresources = self._cw.html_headers.getvalue(skiphead=True)
       
   249         if extresources:
       
   250             stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget ?
       
   251             stream.write(extresources)
       
   252             stream.write(u'</div>\n')
       
   253         if divid == 'pageContent':
       
   254             stream.write(u'</div>%s</div>' % nav_html.getvalue())
       
   255         return stream.getvalue()
       
   256 
       
   257 
       
   258 def _ajaxfunc_factory(implementation, selector=yes(), _output_type=None,
       
   259                       _check_pageid=False, regid=None):
       
   260     """converts a standard python function into an AjaxFunction appobject"""
       
   261     class AnAjaxFunc(AjaxFunction):
       
   262         __regid__ = regid or implementation.__name__
       
   263         __select__ = selector
       
   264         output_type = _output_type
       
   265         check_pageid = _check_pageid
       
   266 
       
   267         def serialize(self, content):
       
   268             if self.output_type is None:
       
   269                 return content
       
   270             elif self.output_type == 'xhtml':
       
   271                 self._cw.set_content_type(self._cw.html_content_type())
       
   272                 return ''.join((self._cw.document_surrounding_div(),
       
   273                                 content.strip(), u'</div>'))
       
   274             elif self.output_type == 'json':
       
   275                 self._cw.set_content_type('application/json')
       
   276                 return json_dumps(content)
       
   277             raise RemoteCallFailed('no serializer found for output type %s'
       
   278                                    % self.output_type)
       
   279 
       
   280         def __call__(self, *args, **kwargs):
       
   281             if self.check_pageid:
       
   282                 data = self._cw.session.data.get(self._cw.pageid)
       
   283                 if data is None:
       
   284                     raise RemoteCallFailed(self._cw._('pageid-not-found'))
       
   285             return self.serialize(implementation(self, *args, **kwargs))
       
   286     AnAjaxFunc.__name__ = implementation.__name__
       
   287     # make sure __module__ refers to the original module otherwise
       
   288     # vreg.register(obj) will ignore ``obj``.
       
   289     AnAjaxFunc.__module__ = implementation.__module__
       
   290     return AnAjaxFunc
       
   291 
       
   292 
       
   293 def ajaxfunc(implementation=None, selector=yes(), output_type=None,
       
   294              check_pageid=False, regid=None):
       
   295     """promote a standard function to an ``AjaxFunction`` appobject.
       
   296 
       
   297     All parameters are optional:
       
   298 
       
   299     :param selector: a custom selector object if needed, default is ``yes()``
       
   300 
       
   301     :param output_type: either None, 'json' or 'xhtml' to customize output
       
   302                         content-type. Default is None
       
   303 
       
   304     :param check_pageid: whether the function requires a valid `pageid` or not
       
   305                          to proceed. Default is False.
       
   306 
       
   307     :param regid: a custom __regid__ for the created ``AjaxFunction`` object. Default
       
   308                   is to keep the wrapped function name.
       
   309 
       
   310     ``ajaxfunc`` can be used both as a standalone decorator:
       
   311 
       
   312     .. sourcecode:: python
       
   313 
       
   314         @ajaxfunc
       
   315         def my_function(self):
       
   316             return 42
       
   317 
       
   318     or as a parametrizable decorator:
       
   319 
       
   320     .. sourcecode:: python
       
   321 
       
   322         @ajaxfunc(output_type='json')
       
   323         def my_function(self):
       
   324             return 42
       
   325 
       
   326     """
       
   327     # if used as a parametrized decorator (e.g. @ajaxfunc(output_type='json'))
       
   328     if implementation is None:
       
   329         def _decorator(func):
       
   330             return _ajaxfunc_factory(func, selector=selector,
       
   331                                      _output_type=output_type,
       
   332                                      _check_pageid=check_pageid,
       
   333                                      regid=regid)
       
   334         return _decorator
       
   335     # else, used as a standalone decorator (i.e. @ajaxfunc)
       
   336     return _ajaxfunc_factory(implementation, selector=selector,
       
   337                              _output_type=output_type,
       
   338                              _check_pageid=check_pageid, regid=regid)
       
   339 
       
   340 
       
   341 
       
   342 ###############################################################################
       
   343 #  Cubicweb remote functions for :                                            #
       
   344 #  - appobject rendering                                                      #
       
   345 #  - user / page session data management                                      #
       
   346 ###############################################################################
       
   347 @ajaxfunc(output_type='xhtml')
       
   348 def view(self):
       
   349     # XXX try to use the page-content template
       
   350     req = self._cw
       
   351     rql = req.form.get('rql')
       
   352     if rql:
       
   353         rset = self._exec(rql)
       
   354     elif 'eid' in req.form:
       
   355         rset = self._cw.eid_rset(req.form['eid'])
       
   356     else:
       
   357         rset = None
       
   358     vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
       
   359     try:
       
   360         viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
       
   361     except NoSelectableObject:
       
   362         vid = req.form.get('fallbackvid', 'noresult')
       
   363         viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
       
   364     viewobj.set_http_cache_headers()
       
   365     req.validate_cache()
       
   366     return self._call_view(viewobj, paginate=req.form.pop('paginate', False))
       
   367 
       
   368 
       
   369 @ajaxfunc(output_type='xhtml')
       
   370 def component(self, compid, rql, registry='components', extraargs=None):
       
   371     if rql:
       
   372         rset = self._exec(rql)
       
   373     else:
       
   374         rset = None
       
   375     # XXX while it sounds good, addition of the try/except below cause pb:
       
   376     # when filtering using facets return an empty rset, the edition box
       
   377     # isn't anymore selectable, as expected. The pb is that with the
       
   378     # try/except below, we see a "an error occurred" message in the ui, while
       
   379     # we don't see it without it. Proper fix would probably be to deal with
       
   380     # this by allowing facet handling code to tell to js_component that such
       
   381     # error is expected and should'nt be reported.
       
   382     #try:
       
   383     comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
       
   384                                           **optional_kwargs(extraargs))
       
   385     #except NoSelectableObject:
       
   386     #    raise RemoteCallFailed('unselectable')
       
   387     return self._call_view(comp, **optional_kwargs(extraargs))
       
   388 
       
   389 @ajaxfunc(output_type='xhtml')
       
   390 def render(self, registry, oid, eid=None,
       
   391               selectargs=None, renderargs=None):
       
   392     if eid is not None:
       
   393         rset = self._cw.eid_rset(eid)
       
   394         # XXX set row=0
       
   395     elif self._cw.form.get('rql'):
       
   396         rset = self._cw.execute(self._cw.form['rql'])
       
   397     else:
       
   398         rset = None
       
   399     viewobj = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
       
   400                                              **optional_kwargs(selectargs))
       
   401     return self._call_view(viewobj, **optional_kwargs(renderargs))
       
   402 
       
   403 
       
   404 @ajaxfunc(output_type='json')
       
   405 def i18n(self, msgids):
       
   406     """returns the translation of `msgid`"""
       
   407     return [self._cw._(msgid) for msgid in msgids]
       
   408 
       
   409 @ajaxfunc(output_type='json')
       
   410 def format_date(self, strdate):
       
   411     """returns the formatted date for `msgid`"""
       
   412     date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
       
   413     return self._cw.format_date(date)
       
   414 
       
   415 @ajaxfunc(output_type='json')
       
   416 def external_resource(self, resource):
       
   417     """returns the URL of the external resource named `resource`"""
       
   418     return self._cw.uiprops[resource]
       
   419 
       
   420 @ajaxfunc(output_type='json', check_pageid=True)
       
   421 def user_callback(self, cbname):
       
   422     """execute the previously registered user callback `cbname`.
       
   423 
       
   424     If matching callback is not found, return None
       
   425     """
       
   426     page_data = self._cw.session.data.get(self._cw.pageid, {})
       
   427     try:
       
   428         cb = page_data[cbname]
       
   429     except KeyError:
       
   430         self.warning('unable to find user callback %s', cbname)
       
   431         return None
       
   432     return cb(self._cw)
       
   433 
       
   434 
       
   435 @ajaxfunc
       
   436 def unregister_user_callback(self, cbname):
       
   437     """unregister user callback `cbname`"""
       
   438     self._cw.unregister_callback(self._cw.pageid, cbname)
       
   439 
       
   440 @ajaxfunc
       
   441 def unload_page_data(self):
       
   442     """remove user's session data associated to current pageid"""
       
   443     self._cw.session.data.pop(self._cw.pageid, None)
       
   444 
       
   445 @ajaxfunc(output_type='json')
       
   446 @deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
       
   447 def set_cookie(self, cookiename, cookievalue):
       
   448     """generates the Set-Cookie HTTP reponse header corresponding
       
   449     to `cookiename` / `cookievalue`.
       
   450     """
       
   451     cookiename, cookievalue = str(cookiename), str(cookievalue)
       
   452     self._cw.set_cookie(cookiename, cookievalue)