Commit 17054149 authored by Adrien Di Mascio's avatar Adrien Di Mascio
Browse files

[controllers] deprecate JSonController and implement AjaxController /...

[controllers] deprecate JSonController and implement AjaxController / ajax-func registry (closes #2110265)
parent 7070250bf50d
......@@ -605,7 +605,7 @@ class CubicWebTC(TestCase):
dump = json.dumps
args = [dump(arg) for arg in args]
req = self.request(fname=fname, pageid='123', arg=args)
ctrl = self.vreg['controllers'].select('json', req)
ctrl = self.vreg['controllers'].select('ajax', req)
return ctrl.publish(), req
def app_publish(self, req, path='view'):
......
.. _ajax:
Ajax
----
CubicWeb provides a few helpers to facilitate *javascript <-> python* communications.
You can, for instance, register some python functions that will become
callable from javascript through ajax calls. All the ajax URLs are handled
by the ``AjaxController`` controller.
.. automodule:: cubicweb.web.views.ajaxcontroller
......@@ -22,10 +22,6 @@ list them by category. They are all defined in
:exc:`NoSelectableObject` errors that may bubble up to its entry point, in an
end-user-friendly way (but other programming errors will slip through)
* the JSon controller (same module) provides services for Ajax calls,
typically using JSON as a serialization format for input, and
sometimes using either JSON or XML for output;
* the JSonpController is a wrapper around the ``ViewController`` that
provides jsonp_ services. Padding can be specified with the
``callback`` request parameter. Only *jsonexport* / *ejsonexport*
......@@ -36,10 +32,6 @@ list them by category. They are all defined in
* the Login/Logout controllers make effective user login or logout
requests
.. warning::
JsonController will probably be renamed into AjaxController soon since
it has nothing to do with json per se.
.. _jsonp: http://en.wikipedia.org/wiki/JSONP
......@@ -64,6 +56,13 @@ list them by category. They are all defined in
* the MailBugReport controller (web/views/basecontrollers.py) allows
to quickly have a `reportbug` feature in one's application
* the :class:`cubicweb.web.views.ajaxcontroller.AjaxController`
(:mod:`cubicweb.web.views.ajaxcontroller`) provides
services for Ajax calls, typically using JSON as a serialization format
for input, and sometimes using either JSON or XML for output. See
:ref:`ajax` chapter for more information.
Registration
++++++++++++
......
......@@ -12,6 +12,7 @@ the *CubicWeb* framework.
request
views/index
rtags
ajax
js
css
edition/index
......
......@@ -72,21 +72,22 @@ Important javascript AJAX APIS
A simple example with asyncRemoteExec
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
In the python side, we have to extend the ``BaseController``
class. The ``@jsonize`` decorator ensures that the return value of the
method is encoded as JSON data. By construction, the JSonController
inputs everything in JSON format.
On the python side, we have to define an
:class:`cubicweb.web.views.ajaxcontroller.AjaxFunction` object. The
simplest way to do that is to use the
:func:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator (for more
details on this, refer to :ref:`ajax`).
.. sourcecode: python
from cubicweb.web.views.basecontrollers import JSonController, jsonize
from cubicweb.web.views.ajaxcontroller import ajaxfunc
@monkeypatch(JSonController)
@jsonize
# serialize output to json to get it back easily on the javascript side
@ajaxfunc(output_type='json')
def js_say_hello(self, name):
return u'hello %s' % name
In the javascript side, we do the asynchronous call. Notice how it
On the javascript side, we do the asynchronous call. Notice how it
creates a `deferred` object. Proper treatment of the return value or
error handling has to be done through the addCallback and addErrback
methods.
......@@ -131,7 +132,7 @@ A simple reloadComponent example
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
The server side implementation of `reloadComponent` is the
js_component method of the JSonController.
:func:`cubicweb.web.views.ajaxcontroller.component` *AjaxFunction* appobject.
The following function implements a two-steps method to delete a
standard bookmark and refresh the UI, while keeping the UI responsive.
......@@ -166,7 +167,8 @@ API of loadxhtml is roughly similar to that of `jQuery.load`_.
* `url` (mandatory) should be a complete url (typically referencing
the JSonController, but this is not strictly mandatory)
the :class:`cubicweb.web.views.ajaxcontroller.AjaxController`,
but this is not strictly mandatory)
* `data` (optional) is a dictionary of values given to the
controller specified through an `url` argument; some keys may have a
......@@ -204,25 +206,23 @@ server-side using an entity eid provided by the client side.
.. sourcecode:: python
from cubicweb import typed_eid
from cubicweb.web.views.basecontrollers import JSonController, xhtmlize
from cubicweb.web.views.ajaxcontroller import ajaxfunc
@monkeypatch(JSonController)
@xhtmlize
@ajaxfunc(output_type='xhtml')
def js_frob_status(self, eid, frobname):
entity = self._cw.entity_from_eid(typed_eid(eid))
entity = self._cw.entity_from_eid(eid)
return entity.view('frob', name=frobname)
.. sourcecode:: javascript
function update_some_div(divid, eid, frobname) {
function updateSomeDiv(divid, eid, frobname) {
var params = {fname:'frob_status', eid: eid, frobname:frobname};
jQuery('#'+divid).loadxhtml(JSON_BASE_URL, params, 'post');
}
In this example, the url argument is the base json url of a cube
instance (it should contain something like
`http://myinstance/json?`). The actual JSonController method name is
`http://myinstance/ajax?`). The actual AjaxController method name is
encoded in the `params` dictionary using the `fname` key.
A more real-life example
......@@ -250,7 +250,7 @@ and available in web/views/tabs.py, in the `LazyViewMixin` class.
w(u'</div>')
self._cw.add_onload(u"""
jQuery('#lazy-%(vid)s').bind('%(event)s', function() {
load_now('#lazy-%(vid)s');});"""
loadNow('#lazy-%(vid)s');});"""
% {'event': 'load_%s' % vid, 'vid': vid})
This creates a `div` with a specific event associated to it.
......@@ -271,7 +271,7 @@ The javascript side is quite simple, due to loadxhtml awesomeness.
.. sourcecode:: javascript
function load_now(eltsel) {
function loadNow(eltsel) {
var lazydiv = jQuery(eltsel);
lazydiv.loadxhtml(lazydiv.attr('cubicweb:loadurl'));
}
......@@ -306,18 +306,77 @@ sufficient.
"""trigger an event that will force immediate loading of the view
on dom readyness
"""
self._cw.add_onload("trigger_load('%s');" % vid)
self._cw.add_onload("triggerLoad('%s');" % vid)
The browser-side definition follows.
.. sourcecode:: javascript
function trigger_load(divid) {
function triggerLoad(divid) {
jQuery('#lazy-' + divd).trigger('load_' + divid);
}
.. XXX userCallback / user_callback
python/ajax dynamic callbacks
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
CubicWeb provides a way to dynamically register a function and make it
callable from the javascript side. The typical use case for this is a
situation where you have everything at hand to implement an action
(whether it be performing a RQL query or executing a few python
statements) that you'd like to defer to a user click in the web
interface. In other words, generate an HTML ``<a href=...`` link that
would execute your few lines of code.
The trick is to create a python function and store this function in
the user's session data. You will then be able to access it later.
While this might sound hard to implement, it's actually quite easy
thanks to the ``_cw.user_callback()``. This method takes a function,
registers it and returns a javascript instruction suitable for
``href`` or ``onclick`` usage. The call is then performed
asynchronously.
Here's a simplified example taken from the vcreview_ cube that will
generate a link to change an entity state directly without the
standard intermediate *comment / validate* step:
.. sourcecode:: python
def entity_call(self, entity):
# [...]
def change_state(req, eid):
entity = req.entity_from_eid(eid)
entity.cw_adapt_to('IWorkflowable').fire_transition('done')
url = self._cw.user_callback(change_state, (entity.eid,))
self.w(tags.input(type='button', onclick=url, value=self._cw._('mark as done')))
The ``change_state`` callback function is registered with
``self._cw.user_callback()`` which returns the ``url`` value directly
used for the ``onclick`` attribute of the button. On the javascript
side, the ``userCallback()`` function is used but you most probably
won't have to bother with it.
Of course, when dealing with session data, the question of session
cleaning pops up immediately. If you use ``user_callback()``, the
registered function will be deleted automatically at some point
as any other session data. If you want your function to be deleted once
the web page is unloaded or when the user has clicked once on your link, then
``_cw.register_onetime_callback()`` is what you need. It behaves as
``_cw.user_callback()`` but stores the function in page data instead
of global session data.
.. Warning::
Be careful when registering functions with closures, keep in mind that
enclosed data will be kept in memory until the session gets cleared. Also,
if you keep entities or any object referecing the current ``req`` object, you
might have problems reusing them later because the underlying session
might have been closed at the time the callback gets executed.
.. _vcreview: http://www.cubicweb.org/project/cubicweb-vcreview
Javascript library: overview
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
......@@ -356,12 +415,12 @@ API
.. toctree::
:maxdepth: 1
js_api/index
Testing javascript
~~~~~~~~~~~~~~~~~~~~~~
~~~~~~~~~~~~~~~~~~
You with the ``cubicweb.qunit.QUnitTestCase`` can include standard Qunit tests
inside the python unittest run . You simply have to define a new class that
......
......@@ -450,7 +450,7 @@ class CubicWebPublisher(object):
req.remove_header('Etag')
req.reset_message()
req.reset_headers()
if req.json_request:
if req.ajax_request:
raise RemoteCallFailed(unicode(ex))
try:
req.data['ex'] = ex
......
......@@ -82,7 +82,7 @@ def list_form_param(form, param, pop=False):
class CubicWebRequestBase(DBAPIRequest):
"""abstract HTTP request, should be extended according to the HTTP backend"""
json_request = False # to be set to True by json controllers
ajax_request = False # to be set to True by ajax controllers
def __init__(self, vreg, https, form=None):
super(CubicWebRequestBase, self).__init__(vreg)
......@@ -121,6 +121,12 @@ class CubicWebRequestBase(DBAPIRequest):
self.html_headers.define_var('pageid', pid, override=False)
self.pageid = pid
@property
def json_request(self):
warn('[3.15] self._cw.json_request is deprecated, use self._cw.ajax_request instead',
DeprecationWarning, stacklevel=2)
return self.ajax_request
@property
def authmode(self):
return self.vreg.config['auth-mode']
......
......@@ -20,15 +20,19 @@
from __future__ import with_statement
from logilab.common.testlib import unittest_main, mock_object
from logilab.common.decorators import monkeypatch
from cubicweb import Binary, NoSelectableObject, ValidationError
from cubicweb.view import STRICT_DOCTYPE
from cubicweb.devtools.testlib import CubicWebTC
from cubicweb.utils import json_dumps
from cubicweb.uilib import rql_for_eid
from cubicweb.web import INTERNAL_FIELD_VALUE, Redirect, RequestError
from cubicweb.web import INTERNAL_FIELD_VALUE, Redirect, RequestError, RemoteCallFailed
from cubicweb.entities.authobjs import CWUser
from cubicweb.web.views.autoform import get_pending_inserts, get_pending_deletes
from cubicweb.web.views.basecontrollers import JSonController, xhtmlize, jsonize
from cubicweb.web.views.ajaxcontroller import ajaxfunc, AjaxFunction
u = unicode
def req_form(user):
......@@ -557,11 +561,12 @@ class SendMailControllerTC(CubicWebTC):
class JSONControllerTC(CubicWebTC):
class AjaxControllerTC(CubicWebTC):
tested_controller = 'ajax'
def ctrl(self, req=None):
req = req or self.request(url='http://whatever.fr/')
return self.vreg['controllers'].select('json', req)
return self.vreg['controllers'].select(self.tested_controller, req)
def setup_database(self):
req = self.request()
......@@ -679,8 +684,89 @@ class JSONControllerTC(CubicWebTC):
self.assertEqual(self.remote_call('format_date', '2007-01-01 12:00:00')[0],
json_dumps('2007/01/01'))
def test_ajaxfunc_noparameter(self):
@ajaxfunc
def foo(self, x, y):
return 'hello'
self.assertTrue(issubclass(foo, AjaxFunction))
self.assertEqual(foo.__regid__, 'foo')
self.assertEqual(foo.check_pageid, False)
self.assertEqual(foo.output_type, None)
req = self.request()
f = foo(req)
self.assertEqual(f(12, 13), 'hello')
def test_ajaxfunc_checkpageid(self):
@ajaxfunc( check_pageid=True)
def foo(self, x, y):
pass
self.assertTrue(issubclass(foo, AjaxFunction))
self.assertEqual(foo.__regid__, 'foo')
self.assertEqual(foo.check_pageid, True)
self.assertEqual(foo.output_type, None)
# no pageid
req = self.request()
f = foo(req)
self.assertRaises(RemoteCallFailed, f, 12, 13)
def test_ajaxfunc_json(self):
@ajaxfunc(output_type='json')
def foo(self, x, y):
return x + y
self.assertTrue(issubclass(foo, AjaxFunction))
self.assertEqual(foo.__regid__, 'foo')
self.assertEqual(foo.check_pageid, False)
self.assertEqual(foo.output_type, 'json')
# no pageid
req = self.request()
f = foo(req)
self.assertEqual(f(12, 13), '25')
class JSonControllerTC(AjaxControllerTC):
# NOTE: this class performs the same tests as AjaxController but with
# deprecated 'json' controller (i.e. check backward compatibility)
tested_controller = 'json'
def setUp(self):
super(JSonControllerTC, self).setUp()
self.exposed_remote_funcs = [fname for fname in dir(JSonController)
if fname.startswith('js_')]
def tearDown(self):
super(JSonControllerTC, self).tearDown()
for funcname in dir(JSonController):
# remove functions added dynamically during tests
if funcname.startswith('js_') and funcname not in self.exposed_remote_funcs:
delattr(JSonController, funcname)
def test_monkeypatch_jsoncontroller(self):
self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
@monkeypatch(JSonController)
def js_foo(self):
return u'hello'
res, req = self.remote_call('foo')
self.assertEqual(res, u'hello')
def test_monkeypatch_jsoncontroller_xhtmlize(self):
self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
@monkeypatch(JSonController)
@xhtmlize
def js_foo(self):
return u'hello'
res, req = self.remote_call('foo')
self.assertEqual(res,
'<?xml version="1.0"?>\n' + STRICT_DOCTYPE +
u'<div xmlns="http://www.w3.org/1999/xhtml" xmlns:cubicweb="http://www.logilab.org/2008/cubicweb">hello</div>')
def test_monkeypatch_jsoncontroller_jsonize(self):
self.assertRaises(RemoteCallFailed, self.remote_call, 'foo')
@monkeypatch(JSonController)
@jsonize
def js_foo(self):
return 12
res, req = self.remote_call('foo')
self.assertEqual(res, '12')
if __name__ == '__main__':
unittest_main()
......@@ -130,7 +130,7 @@ class ViewAction(action.Action):
params = self._cw.form.copy()
for param in ('vid', '__message') + controller.NAV_FORM_PARAMETERS:
params.pop(param, None)
if self._cw.json_request:
if self._cw.ajax_request:
path = 'view'
if self.cw_rset is not None:
params = {'rql': self.cw_rset.printable_rql()}
......
# copyright 2003-2011 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
#
# This file is part of CubicWeb.
#
# CubicWeb is free software: you can redistribute it and/or modify it under the
# terms of the GNU Lesser General Public License as published by the Free
# Software Foundation, either version 2.1 of the License, or (at your option)
# any later version.
#
# CubicWeb is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
# details.
#
# You should have received a copy of the GNU Lesser General Public License along
# with CubicWeb. If not, see <http://www.gnu.org/licenses/>.
#
# (disable pylint msg for client obj access to protected member as in obj._cw)
# pylint: disable=W0212
"""The ``ajaxcontroller`` module defines the :class:`AjaxController`
controller and the ``ajax-funcs`` cubicweb registry.
.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxController
:members:
``ajax-funcs`` registry hosts exposed remote functions, that is
functions that can be called from the javascript world.
To register a new remote function, either decorate your function
with the :ref:`cubicweb.web.views.ajaxcontroller.ajaxfunc` decorator:
.. sourcecode:: python
from cubicweb.selectors import mactch_user_groups
from cubicweb.web.views.ajaxcontroller import ajaxfunc
@ajaxfunc(output_type='json', selector=match_user_groups('managers'))
def list_users(self):
return [u for (u,) in self._cw.execute('Any L WHERE U login L')]
or inherit from :class:`cubicwbe.web.views.ajaxcontroller.AjaxFunction` and
implement the ``__call__`` method:
.. sourcecode:: python
from cubicweb.web.views.ajaxcontroller import AjaxFunction
class ListUser(AjaxFunction):
__regid__ = 'list_users' # __regid__ is the name of the exposed function
__select__ = match_user_groups('managers')
output_type = 'json'
def __call__(self):
return [u for (u, ) in self._cw.execute('Any L WHERE U login L')]
.. autoclass:: cubicweb.web.views.ajaxcontroller.AjaxFunction
:members:
.. autofunction:: cubicweb.web.views.ajaxcontroller.ajaxfunc
"""
__docformat__ = "restructuredtext en"
from functools import partial
from logilab.common.date import strptime
from logilab.common.deprecation import deprecated
from cubicweb import ObjectNotFound, NoSelectableObject
from cubicweb.appobject import AppObject
from cubicweb.selectors import yes
from cubicweb.utils import json, json_dumps, UStringIO
from cubicweb.uilib import exc_message
from cubicweb.web import RemoteCallFailed, DirectResponse
from cubicweb.web.controller import Controller
from cubicweb.web.views import vid_from_rset
from cubicweb.web.views import basecontrollers
def optional_kwargs(extraargs):
if extraargs is None:
return {}
# we receive unicode keys which is not supported by the **syntax
return dict((str(key), value) for key, value in extraargs.iteritems())
class AjaxController(Controller):
"""AjaxController handles ajax remote calls from javascript
The following javascript function call:
.. sourcecode:: javascript
var d = asyncRemoteExec('foo', 12, "hello");
d.addCallback(function(result) {
alert('server response is: ' + result);
});
will generate an ajax HTTP GET on the following url::
BASE_URL/ajax?fname=foo&arg=12&arg="hello"
The AjaxController controller will therefore be selected to handle those URLs
and will itself select the :class:`cubicweb.web.views.ajaxcontroller.AjaxFunction`
matching the *fname* parameter.
"""
__regid__ = 'ajax'
def publish(self, rset=None):
self._cw.ajax_request = True
try:
fname = self._cw.form['fname']
except KeyError:
raise RemoteCallFailed('no method specified')
try:
func = self._cw.vreg['ajax-func'].select(fname, self._cw)
except ObjectNotFound:
# function not found in the registry, inspect JSonController for
# backward compatibility
try:
func = getattr(basecontrollers.JSonController, 'js_%s' % fname).im_func
func = partial(func, self)
except AttributeError:
raise RemoteCallFailed('no %s method' % fname)
else:
self.warning('remote function %s found on JSonController, '
'use AjaxFunction / @ajaxfunc instead', fname)
except NoSelectableObject:
raise RemoteCallFailed('method %s not available in this context'
% fname)
# no <arg> attribute means the callback takes no argument
args = self._cw.form.get('arg', ())
if not isinstance(args, (list, tuple)):
args = (args,)
try:
args = [json.loads(arg) for arg in args]
except ValueError, exc:
self.exception('error while decoding json arguments for '
'js_%s: %s (err: %s)', fname, args, exc)
raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
try:
result = func(*args)
except (RemoteCallFailed, DirectResponse):
raise
except Exception, exc:
self.exception('an exception occurred while calling js_%s(%s): %s',
fname, args, exc)
raise RemoteCallFailed(exc_message(exc, self._cw.encoding))
if result is None:
return ''
# get unicode on @htmlize methods, encoded string on @jsonize methods
elif isinstance(result, unicode):
return result.encode(self._cw.encoding)
return result
class AjaxFunction(AppObject):
"""
Attributes on this base class are:
:attr: `check_pageid`: make sure the pageid received is valid before proceeding
:attr: `output_type`:
- *None*: no processing, no change on content-type
- *json*: serialize with `json_dumps` and set *application/json*
content-type
- *xhtml*: wrap result in an XML node and forces HTML / XHTML
content-type (use ``_cw.html_content_type()``)
"""
__registry__ = 'ajax-func'
__select__ = yes()
__abstract__ = True
check_pageid = False
output_type = None
@staticmethod
def _rebuild_posted_form(names, values, action=None):
form = {}
for name, value in zip(names, values):
# remove possible __action_xxx inputs
if name.startswith('__action'):
if action is None:
# strip '__action_' to get the actual action name
action = name[9:]
continue
# form.setdefault(name, []).append(value)
if name in form:
curvalue = form[name]
if isinstance(curvalue, list):
curvalue.append(value)