web/views/ajaxcontroller.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2013 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-func`` 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 :func:`~cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator:
       
    32 
       
    33 .. sourcecode:: python
       
    34 
       
    35     from cubicweb.predicates 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:`~cubicweb.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 warnings import warn
       
    67 from functools import partial
       
    68 
       
    69 from six import PY2, text_type
       
    70 
       
    71 from logilab.common.date import strptime
       
    72 from logilab.common.registry import yes
       
    73 from logilab.common.deprecation import deprecated
       
    74 
       
    75 from cubicweb import ObjectNotFound, NoSelectableObject
       
    76 from cubicweb.appobject import AppObject
       
    77 from cubicweb.utils import json, json_dumps, UStringIO
       
    78 from cubicweb.uilib import exc_message
       
    79 from cubicweb.web import RemoteCallFailed, DirectResponse
       
    80 from cubicweb.web.controller import Controller
       
    81 from cubicweb.web.views import vid_from_rset
       
    82 from cubicweb.web.views import basecontrollers
       
    83 
       
    84 
       
    85 def optional_kwargs(extraargs):
       
    86     if extraargs is None:
       
    87         return {}
       
    88     # we receive unicode keys which is not supported by the **syntax
       
    89     return dict((str(key), value) for key, value in extraargs.items())
       
    90 
       
    91 
       
    92 class AjaxController(Controller):
       
    93     """AjaxController handles ajax remote calls from javascript
       
    94 
       
    95     The following javascript function call:
       
    96 
       
    97     .. sourcecode:: javascript
       
    98 
       
    99       var d = asyncRemoteExec('foo', 12, "hello");
       
   100       d.addCallback(function(result) {
       
   101           alert('server response is: ' + result);
       
   102       });
       
   103 
       
   104     will generate an ajax HTTP GET on the following url::
       
   105 
       
   106         BASE_URL/ajax?fname=foo&arg=12&arg="hello"
       
   107 
       
   108     The AjaxController controller will therefore be selected to handle those URLs
       
   109     and will itself select the :class:`cubicweb.web.views.ajaxcontroller.AjaxFunction`
       
   110     matching the *fname* parameter.
       
   111     """
       
   112     __regid__ = 'ajax'
       
   113 
       
   114     def publish(self, rset=None):
       
   115         self._cw.ajax_request = True
       
   116         try:
       
   117             fname = self._cw.form['fname']
       
   118         except KeyError:
       
   119             raise RemoteCallFailed('no method specified')
       
   120         # 1/ check first for old-style (JSonController) ajax func for bw compat
       
   121         try:
       
   122             func = getattr(basecontrollers.JSonController, 'js_%s' % fname)
       
   123             if PY2:
       
   124                 func = func.__func__
       
   125             func = partial(func, self)
       
   126         except AttributeError:
       
   127             # 2/ check for new-style (AjaxController) ajax func
       
   128             try:
       
   129                 func = self._cw.vreg['ajax-func'].select(fname, self._cw)
       
   130             except ObjectNotFound:
       
   131                 raise RemoteCallFailed('no %s method' % fname)
       
   132         else:
       
   133             warn('[3.15] remote function %s found on JSonController, '
       
   134                  'use AjaxFunction / @ajaxfunc instead' % fname,
       
   135                  DeprecationWarning, stacklevel=2)
       
   136         # no <arg> attribute means the callback takes no argument
       
   137         args = self._cw.form.get('arg', ())
       
   138         if not isinstance(args, (list, tuple)):
       
   139             args = (args,)
       
   140         try:
       
   141             args = [json.loads(arg) for arg in args]
       
   142         except ValueError as exc:
       
   143             self.exception('error while decoding json arguments for '
       
   144                            'js_%s: %s (err: %s)', fname, args, exc)
       
   145             raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
       
   146         try:
       
   147             result = func(*args)
       
   148         except (RemoteCallFailed, DirectResponse):
       
   149             raise
       
   150         except Exception as exc:
       
   151             self.exception('an exception occurred while calling js_%s(%s): %s',
       
   152                            fname, args, exc)
       
   153             raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
       
   154         if result is None:
       
   155             return ''
       
   156         # get unicode on @htmlize methods, encoded string on @jsonize methods
       
   157         elif isinstance(result, text_type):
       
   158             return result.encode(self._cw.encoding)
       
   159         return result
       
   160 
       
   161 class AjaxFunction(AppObject):
       
   162     """
       
   163     Attributes on this base class are:
       
   164 
       
   165     :attr: `check_pageid`: make sure the pageid received is valid before proceeding
       
   166     :attr: `output_type`:
       
   167 
       
   168            - *None*: no processing, no change on content-type
       
   169 
       
   170            - *json*: serialize with `json_dumps` and set *application/json*
       
   171                      content-type
       
   172 
       
   173            - *xhtml*: wrap result in an XML node and forces HTML / XHTML
       
   174                       content-type (use ``_cw.html_content_type()``)
       
   175 
       
   176     """
       
   177     __registry__ = 'ajax-func'
       
   178     __select__ = yes()
       
   179     __abstract__ = True
       
   180 
       
   181     check_pageid = False
       
   182     output_type = None
       
   183 
       
   184     @staticmethod
       
   185     def _rebuild_posted_form(names, values, action=None):
       
   186         form = {}
       
   187         for name, value in zip(names, values):
       
   188             # remove possible __action_xxx inputs
       
   189             if name.startswith('__action'):
       
   190                 if action is None:
       
   191                     # strip '__action_' to get the actual action name
       
   192                     action = name[9:]
       
   193                 continue
       
   194             # form.setdefault(name, []).append(value)
       
   195             if name in form:
       
   196                 curvalue = form[name]
       
   197                 if isinstance(curvalue, list):
       
   198                     curvalue.append(value)
       
   199                 else:
       
   200                     form[name] = [curvalue, value]
       
   201             else:
       
   202                 form[name] = value
       
   203         # simulate click on __action_%s button to help the controller
       
   204         if action:
       
   205             form['__action_%s' % action] = u'whatever'
       
   206         return form
       
   207 
       
   208     def validate_form(self, action, names, values):
       
   209         self._cw.form = self._rebuild_posted_form(names, values, action)
       
   210         return basecontrollers._validate_form(self._cw, self._cw.vreg)
       
   211 
       
   212     def _exec(self, rql, args=None, rocheck=True):
       
   213         """json mode: execute RQL and return resultset as json"""
       
   214         rql = rql.strip()
       
   215         if rql.startswith('rql:'):
       
   216             rql = rql[4:]
       
   217         if rocheck:
       
   218             self._cw.ensure_ro_rql(rql)
       
   219         try:
       
   220             return self._cw.execute(rql, args)
       
   221         except Exception as ex:
       
   222             self.exception("error in _exec(rql=%s): %s", rql, ex)
       
   223             return None
       
   224         return None
       
   225 
       
   226     def _call_view(self, view, paginate=False, **kwargs):
       
   227         divid = self._cw.form.get('divid')
       
   228         # we need to call pagination before with the stream set
       
   229         try:
       
   230             stream = view.set_stream()
       
   231         except AttributeError:
       
   232             stream = UStringIO()
       
   233             kwargs['w'] = stream.write
       
   234             assert not paginate
       
   235         if divid == 'pageContent':
       
   236             # ensure divid isn't reused by the view (e.g. table view)
       
   237             del self._cw.form['divid']
       
   238             # mimick main template behaviour
       
   239             stream.write(u'<div id="pageContent">')
       
   240             vtitle = self._cw.form.get('vtitle')
       
   241             if vtitle:
       
   242                 stream.write(u'<h1 class="vtitle">%s</h1>\n' % vtitle)
       
   243             paginate = True
       
   244         nav_html = UStringIO()
       
   245         if paginate and not view.handle_pagination:
       
   246             view.paginate(w=nav_html.write)
       
   247         stream.write(nav_html.getvalue())
       
   248         if divid == 'pageContent':
       
   249             stream.write(u'<div id="contentmain">')
       
   250         view.render(**kwargs)
       
   251         extresources = self._cw.html_headers.getvalue(skiphead=True)
       
   252         if extresources:
       
   253             stream.write(u'<div class="ajaxHtmlHead">\n') # XXX use a widget?
       
   254             stream.write(extresources)
       
   255             stream.write(u'</div>\n')
       
   256         if divid == 'pageContent':
       
   257             stream.write(u'</div>%s</div>' % nav_html.getvalue())
       
   258         return stream.getvalue()
       
   259 
       
   260 
       
   261 def _ajaxfunc_factory(implementation, selector=yes(), _output_type=None,
       
   262                       _check_pageid=False, regid=None):
       
   263     """converts a standard python function into an AjaxFunction appobject"""
       
   264     class AnAjaxFunc(AjaxFunction):
       
   265         __regid__ = regid or implementation.__name__
       
   266         __select__ = selector
       
   267         output_type = _output_type
       
   268         check_pageid = _check_pageid
       
   269 
       
   270         def serialize(self, content):
       
   271             if self.output_type is None:
       
   272                 return content
       
   273             elif self.output_type == 'xhtml':
       
   274                 self._cw.set_content_type(self._cw.html_content_type())
       
   275                 return ''.join((u'<div>',
       
   276                                 content.strip(), u'</div>'))
       
   277             elif self.output_type == 'json':
       
   278                 self._cw.set_content_type('application/json')
       
   279                 return json_dumps(content)
       
   280             raise RemoteCallFailed('no serializer found for output type %s'
       
   281                                    % self.output_type)
       
   282 
       
   283         def __call__(self, *args, **kwargs):
       
   284             if self.check_pageid:
       
   285                 data = self._cw.session.data.get(self._cw.pageid)
       
   286                 if data is None:
       
   287                     raise RemoteCallFailed(self._cw._('pageid-not-found'))
       
   288             return self.serialize(implementation(self, *args, **kwargs))
       
   289 
       
   290     AnAjaxFunc.__name__ = implementation.__name__
       
   291     # make sure __module__ refers to the original module otherwise
       
   292     # vreg.register(obj) will ignore ``obj``.
       
   293     AnAjaxFunc.__module__ = implementation.__module__
       
   294     # relate the ``implementation`` object to its wrapper appobject
       
   295     # will be used by e.g.:
       
   296     #   import base_module
       
   297     #   @ajaxfunc
       
   298     #   def foo(self):
       
   299     #       return 42
       
   300     #   assert foo(object) == 42
       
   301     #   vreg.register_and_replace(foo, base_module.older_foo)
       
   302     implementation.__appobject__ = AnAjaxFunc
       
   303     return implementation
       
   304 
       
   305 
       
   306 def ajaxfunc(implementation=None, selector=yes(), output_type=None,
       
   307              check_pageid=False, regid=None):
       
   308     """promote a standard function to an ``AjaxFunction`` appobject.
       
   309 
       
   310     All parameters are optional:
       
   311 
       
   312     :param selector: a custom selector object if needed, default is ``yes()``
       
   313 
       
   314     :param output_type: either None, 'json' or 'xhtml' to customize output
       
   315                         content-type. Default is None
       
   316 
       
   317     :param check_pageid: whether the function requires a valid `pageid` or not
       
   318                          to proceed. Default is False.
       
   319 
       
   320     :param regid: a custom __regid__ for the created ``AjaxFunction`` object. Default
       
   321                   is to keep the wrapped function name.
       
   322 
       
   323     ``ajaxfunc`` can be used both as a standalone decorator:
       
   324 
       
   325     .. sourcecode:: python
       
   326 
       
   327         @ajaxfunc
       
   328         def my_function(self):
       
   329             return 42
       
   330 
       
   331     or as a parametrizable decorator:
       
   332 
       
   333     .. sourcecode:: python
       
   334 
       
   335         @ajaxfunc(output_type='json')
       
   336         def my_function(self):
       
   337             return 42
       
   338 
       
   339     """
       
   340     # if used as a parametrized decorator (e.g. @ajaxfunc(output_type='json'))
       
   341     if implementation is None:
       
   342         def _decorator(func):
       
   343             return _ajaxfunc_factory(func, selector=selector,
       
   344                                      _output_type=output_type,
       
   345                                      _check_pageid=check_pageid,
       
   346                                      regid=regid)
       
   347         return _decorator
       
   348     # else, used as a standalone decorator (i.e. @ajaxfunc)
       
   349     return _ajaxfunc_factory(implementation, selector=selector,
       
   350                              _output_type=output_type,
       
   351                              _check_pageid=check_pageid, regid=regid)
       
   352 
       
   353 
       
   354 
       
   355 ###############################################################################
       
   356 #  Cubicweb remote functions for :                                            #
       
   357 #  - appobject rendering                                                      #
       
   358 #  - user / page session data management                                      #
       
   359 ###############################################################################
       
   360 @ajaxfunc(output_type='xhtml')
       
   361 def view(self):
       
   362     # XXX try to use the page-content template
       
   363     req = self._cw
       
   364     rql = req.form.get('rql')
       
   365     if rql:
       
   366         rset = self._exec(rql)
       
   367     elif 'eid' in req.form:
       
   368         rset = self._cw.eid_rset(req.form['eid'])
       
   369     else:
       
   370         rset = None
       
   371     vid = req.form.get('vid') or vid_from_rset(req, rset, self._cw.vreg.schema)
       
   372     try:
       
   373         viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
       
   374     except NoSelectableObject:
       
   375         vid = req.form.get('fallbackvid', 'noresult')
       
   376         viewobj = self._cw.vreg['views'].select(vid, req, rset=rset)
       
   377     viewobj.set_http_cache_headers()
       
   378     if req.is_client_cache_valid():
       
   379         return ''
       
   380     return self._call_view(viewobj, paginate=req.form.pop('paginate', False))
       
   381 
       
   382 
       
   383 @ajaxfunc(output_type='xhtml')
       
   384 def component(self, compid, rql, registry='components', extraargs=None):
       
   385     if rql:
       
   386         rset = self._exec(rql)
       
   387     else:
       
   388         rset = None
       
   389     # XXX while it sounds good, addition of the try/except below cause pb:
       
   390     # when filtering using facets return an empty rset, the edition box
       
   391     # isn't anymore selectable, as expected. The pb is that with the
       
   392     # try/except below, we see a "an error occurred" message in the ui, while
       
   393     # we don't see it without it. Proper fix would probably be to deal with
       
   394     # this by allowing facet handling code to tell to js_component that such
       
   395     # error is expected and should'nt be reported.
       
   396     #try:
       
   397     comp = self._cw.vreg[registry].select(compid, self._cw, rset=rset,
       
   398                                           **optional_kwargs(extraargs))
       
   399     #except NoSelectableObject:
       
   400     #    raise RemoteCallFailed('unselectable')
       
   401     return self._call_view(comp, **optional_kwargs(extraargs))
       
   402 
       
   403 @ajaxfunc(output_type='xhtml')
       
   404 def render(self, registry, oid, eid=None,
       
   405               selectargs=None, renderargs=None):
       
   406     if eid is not None:
       
   407         rset = self._cw.eid_rset(eid)
       
   408         # XXX set row=0
       
   409     elif self._cw.form.get('rql'):
       
   410         rset = self._cw.execute(self._cw.form['rql'])
       
   411     else:
       
   412         rset = None
       
   413     viewobj = self._cw.vreg[registry].select(oid, self._cw, rset=rset,
       
   414                                              **optional_kwargs(selectargs))
       
   415     return self._call_view(viewobj, **optional_kwargs(renderargs))
       
   416 
       
   417 
       
   418 @ajaxfunc(output_type='json')
       
   419 def i18n(self, msgids):
       
   420     """returns the translation of `msgid`"""
       
   421     return [self._cw._(msgid) for msgid in msgids]
       
   422 
       
   423 @ajaxfunc(output_type='json')
       
   424 def format_date(self, strdate):
       
   425     """returns the formatted date for `msgid`"""
       
   426     date = strptime(strdate, '%Y-%m-%d %H:%M:%S')
       
   427     return self._cw.format_date(date)
       
   428 
       
   429 @ajaxfunc(output_type='json')
       
   430 def external_resource(self, resource):
       
   431     """returns the URL of the external resource named `resource`"""
       
   432     return self._cw.uiprops[resource]
       
   433 
       
   434 @ajaxfunc
       
   435 def unload_page_data(self):
       
   436     """remove user's session data associated to current pageid"""
       
   437     self._cw.session.data.pop(self._cw.pageid, None)
       
   438 
       
   439 @ajaxfunc(output_type='json')
       
   440 @deprecated("[3.13] use jQuery.cookie(cookiename, cookievalue, {path: '/'}) in js land instead")
       
   441 def set_cookie(self, cookiename, cookievalue):
       
   442     """generates the Set-Cookie HTTP reponse header corresponding
       
   443     to `cookiename` / `cookievalue`.
       
   444     """
       
   445     cookiename, cookievalue = str(cookiename), str(cookievalue)
       
   446     self._cw.set_cookie(cookiename, cookievalue)
       
   447 
       
   448 
       
   449 
       
   450 @ajaxfunc
       
   451 def delete_relation(self, rtype, subjeid, objeid):
       
   452     rql = 'DELETE S %s O WHERE S eid %%(s)s, O eid %%(o)s' % rtype
       
   453     self._cw.execute(rql, {'s': subjeid, 'o': objeid})
       
   454 
       
   455 @ajaxfunc
       
   456 def add_relation(self, rtype, subjeid, objeid):
       
   457     rql = 'SET S %s O WHERE S eid %%(s)s, O eid %%(o)s' % rtype
       
   458     self._cw.execute(rql, {'s': subjeid, 'o': objeid})