[MERGE] trunk
bzr revid: fme@openerp.com-20121212133800-k67gy1gctt5dxzwk
This commit is contained in:
+13
-18
@@ -1,19 +1,14 @@
|
||||
.*.swp
|
||||
.bzrignore
|
||||
.idea
|
||||
.project
|
||||
.pydevproject
|
||||
.ropeproject
|
||||
.settings
|
||||
.DS_Store
|
||||
openerp/addons/*
|
||||
openerp/filestore*
|
||||
.Python
|
||||
*.pyc
|
||||
*.pyo
|
||||
bin/*
|
||||
.*
|
||||
*.egg-info
|
||||
*.orig
|
||||
*.vim
|
||||
build/
|
||||
include/
|
||||
lib/
|
||||
share/
|
||||
doc/_build/*
|
||||
RE:^bin/
|
||||
RE:^dist/
|
||||
RE:^include/
|
||||
|
||||
RE:^share/
|
||||
RE:^man/
|
||||
RE:^lib/
|
||||
|
||||
RE:^addons/\w+/doc/_build/
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import http
|
||||
import controllers
|
||||
import cli
|
||||
|
||||
wsgi_postload = http.wsgi_postload
|
||||
|
||||
@@ -18,7 +18,7 @@ This module provides the core of the OpenERP Web Client.
|
||||
"static/lib/datejs/parser.js",
|
||||
"static/lib/datejs/sugarpak.js",
|
||||
"static/lib/datejs/extras.js",
|
||||
"static/lib/jquery/jquery-1.8.2.js",
|
||||
"static/lib/jquery/jquery-1.8.3.js",
|
||||
"static/lib/jquery.MD5/jquery.md5.js",
|
||||
"static/lib/jquery.form/jquery.form.js",
|
||||
"static/lib/jquery.validate/jquery.validate.js",
|
||||
@@ -41,6 +41,8 @@ This module provides the core of the OpenERP Web Client.
|
||||
"static/lib/cleditor/jquery.cleditor.js",
|
||||
"static/lib/py.js/lib/py.js",
|
||||
"static/src/js/boot.js",
|
||||
"static/src/js/testing.js",
|
||||
"static/src/js/pyeval.js",
|
||||
"static/src/js/corelib.js",
|
||||
"static/src/js/coresetup.js",
|
||||
"static/src/js/dates.js",
|
||||
@@ -68,5 +70,21 @@ This module provides the core of the OpenERP Web Client.
|
||||
'qweb' : [
|
||||
"static/src/xml/*.xml",
|
||||
],
|
||||
'test': [
|
||||
"static/test/testing.js",
|
||||
"static/test/class.js",
|
||||
"static/test/registry.js",
|
||||
"static/test/form.js",
|
||||
"static/test/list-utils.js",
|
||||
"static/test/formats.js",
|
||||
"static/test/rpc.js",
|
||||
"static/test/evals.js",
|
||||
"static/test/search.js",
|
||||
"static/test/Widget.js",
|
||||
"static/test/list.js",
|
||||
"static/test/list-editable.js",
|
||||
"static/test/mutex.js"
|
||||
],
|
||||
'bootstrap': True,
|
||||
'twitter': False,
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import test_js
|
||||
@@ -0,0 +1,35 @@
|
||||
import logging
|
||||
import optparse
|
||||
import sys
|
||||
|
||||
import unittest2
|
||||
|
||||
import openerp
|
||||
import openerp.addons.web.tests
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class TestJs(openerp.cli.Command):
|
||||
def run(self, args):
|
||||
self.parser = parser = optparse.OptionParser()
|
||||
parser.add_option("-d", "--database", dest="db_name", default=False, help="specify the database name")
|
||||
parser.add_option("--xmlrpc-port", dest="xmlrpc_port", default=8069, help="specify the TCP port for the XML-RPC protocol", type="int")
|
||||
# proably need to add both --superadmin-password and --database-admin-password
|
||||
self.parser.parse_args(args)
|
||||
|
||||
# test ony uses db_name xmlrpc_port admin_passwd, so use the server one for the actual parsing
|
||||
|
||||
config = openerp.tools.config
|
||||
config.parse_config(args)
|
||||
# needed until runbot is fixed
|
||||
config['db_password'] = config['admin_passwd']
|
||||
|
||||
# run js tests
|
||||
openerp.netsvc.init_alternative_logger()
|
||||
suite = unittest2.TestSuite()
|
||||
suite.addTests(unittest2.TestLoader().loadTestsFromModule(openerp.addons.web.tests.test_js))
|
||||
r = unittest2.TextTestRunner(verbosity=2).run(suite)
|
||||
if r.errors or r.failures:
|
||||
sys.exit(1)
|
||||
|
||||
# vim:et:ts=4:sw=4:
|
||||
@@ -1 +1,2 @@
|
||||
from . import main
|
||||
from . import testing
|
||||
|
||||
+139
-428
@@ -28,9 +28,9 @@ except ImportError:
|
||||
xlwt = None
|
||||
|
||||
import openerp
|
||||
from openerp.tools.translate import _
|
||||
|
||||
from .. import http
|
||||
from .. import nonliterals
|
||||
openerpweb = http
|
||||
|
||||
#----------------------------------------------------------
|
||||
@@ -82,7 +82,6 @@ def rjsmin(script):
|
||||
return result
|
||||
|
||||
def db_list(req):
|
||||
dbs = []
|
||||
proxy = req.session.proxy("db")
|
||||
dbs = proxy.list()
|
||||
h = req.httprequest.environ['HTTP_HOST'].split(':')[0]
|
||||
@@ -92,7 +91,7 @@ def db_list(req):
|
||||
return dbs
|
||||
|
||||
def db_monodb(req):
|
||||
# if only one db is listed returns it else return False
|
||||
# if only one db exists, return it else return False
|
||||
try:
|
||||
dbs = db_list(req)
|
||||
if len(dbs) == 1:
|
||||
@@ -189,14 +188,14 @@ def module_installed_bypass_session(dbname):
|
||||
sorted_modules = module_topological_sort(modules)
|
||||
return sorted_modules
|
||||
|
||||
def module_boot(req):
|
||||
def module_boot(req, db=None):
|
||||
server_wide_modules = openerp.conf.server_wide_modules or ['web']
|
||||
serverside = []
|
||||
dbside = []
|
||||
for i in server_wide_modules:
|
||||
if i in openerpweb.addons_manifest:
|
||||
serverside.append(i)
|
||||
monodb = db_monodb(req)
|
||||
monodb = db or db_monodb(req)
|
||||
if monodb:
|
||||
dbside = module_installed_bypass_session(monodb)
|
||||
dbside = [i for i in dbside if i not in serverside]
|
||||
@@ -258,18 +257,24 @@ def concat_files(file_list, reader=None, intersperse=""):
|
||||
files_concat = intersperse.join(files_content)
|
||||
return files_concat, checksum.hexdigest()
|
||||
|
||||
concat_js_cache = {}
|
||||
|
||||
def concat_js(file_list):
|
||||
content, checksum = concat_files(file_list, intersperse=';')
|
||||
content = rjsmin(content)
|
||||
return content, checksum
|
||||
if checksum in concat_js_cache:
|
||||
content = concat_js_cache[checksum]
|
||||
else:
|
||||
content = rjsmin(content)
|
||||
concat_js_cache[checksum] = content
|
||||
return content, checksum
|
||||
|
||||
def fs2web(path):
|
||||
"""convert FS path into web path"""
|
||||
return '/'.join(path.split(os.path.sep))
|
||||
|
||||
def manifest_glob(req, addons, key):
|
||||
def manifest_glob(req, extension, addons=None, db=None):
|
||||
if addons is None:
|
||||
addons = module_boot(req)
|
||||
addons = module_boot(req, db=db)
|
||||
else:
|
||||
addons = addons.split(',')
|
||||
r = []
|
||||
@@ -279,19 +284,21 @@ def manifest_glob(req, addons, key):
|
||||
continue
|
||||
# ensure does not ends with /
|
||||
addons_path = os.path.join(manifest['addons_path'], '')[:-1]
|
||||
globlist = manifest.get(key, [])
|
||||
globlist = manifest.get(extension, [])
|
||||
for pattern in globlist:
|
||||
for path in glob.glob(os.path.normpath(os.path.join(addons_path, addon, pattern))):
|
||||
r.append((path, fs2web(path[len(addons_path):])))
|
||||
return r
|
||||
|
||||
def manifest_list(req, mods, extension):
|
||||
def manifest_list(req, extension, mods=None, db=None):
|
||||
if not req.debug:
|
||||
path = '/web/webclient/' + extension
|
||||
if mods is not None:
|
||||
path += '?mods=' + mods
|
||||
elif db:
|
||||
path += '?db=' + db
|
||||
return [path]
|
||||
files = manifest_glob(req, mods, extension)
|
||||
files = manifest_glob(req, extension, addons=mods, db=db)
|
||||
i_am_diabetic = req.httprequest.environ["QUERY_STRING"].count("no_sugar") >= 1 or \
|
||||
req.httprequest.environ.get('HTTP_REFERER', '').count("no_sugar") >= 1
|
||||
if i_am_diabetic:
|
||||
@@ -338,7 +345,13 @@ def make_conditional(req, response, last_modified=None, etag=None):
|
||||
return response.make_conditional(req.httprequest)
|
||||
|
||||
def login_and_redirect(req, db, login, key, redirect_url='/'):
|
||||
req.session.authenticate(db, login, key, {})
|
||||
wsgienv = req.httprequest.environ
|
||||
env = dict(
|
||||
base_location=req.httprequest.url_root.rstrip('/'),
|
||||
HTTP_HOST=wsgienv['HTTP_HOST'],
|
||||
REMOTE_ADDR=wsgienv['REMOTE_ADDR'],
|
||||
)
|
||||
req.session.authenticate(db, login, key, env)
|
||||
return set_cookie_and_redirect(req, redirect_url)
|
||||
|
||||
def set_cookie_and_redirect(req, redirect_url):
|
||||
@@ -348,40 +361,15 @@ def set_cookie_and_redirect(req, redirect_url):
|
||||
redirect.set_cookie('instance0|session_id', cookie_val)
|
||||
return redirect
|
||||
|
||||
def eval_context_and_domain(session, context, domain=None):
|
||||
e_context = session.eval_context(context)
|
||||
# should we give the evaluated context as an evaluation context to the domain?
|
||||
e_domain = session.eval_domain(domain or [])
|
||||
|
||||
return e_context, e_domain
|
||||
|
||||
def load_actions_from_ir_values(req, key, key2, models, meta):
|
||||
context = req.session.eval_context(req.context)
|
||||
Values = req.session.model('ir.values')
|
||||
actions = Values.get(key, key2, models, meta, context)
|
||||
actions = Values.get(key, key2, models, meta, req.context)
|
||||
|
||||
return [(id, name, clean_action(req, action))
|
||||
for id, name, action in actions]
|
||||
|
||||
def clean_action(req, action, do_not_eval=False):
|
||||
def clean_action(req, action):
|
||||
action.setdefault('flags', {})
|
||||
|
||||
context = req.session.eval_context(req.context)
|
||||
eval_ctx = req.session.evaluation_context(context)
|
||||
|
||||
if not do_not_eval:
|
||||
# values come from the server, we can just eval them
|
||||
if action.get('context') and isinstance(action.get('context'), basestring):
|
||||
action['context'] = eval( action['context'], eval_ctx ) or {}
|
||||
|
||||
if action.get('domain') and isinstance(action.get('domain'), basestring):
|
||||
action['domain'] = eval( action['domain'], eval_ctx ) or []
|
||||
else:
|
||||
if 'context' in action:
|
||||
action['context'] = parse_context(action['context'], req.session)
|
||||
if 'domain' in action:
|
||||
action['domain'] = parse_domain(action['domain'], req.session)
|
||||
|
||||
action_type = action.setdefault('type', 'ir.actions.act_window_close')
|
||||
if action_type == 'ir.actions.act_window':
|
||||
return fix_view_modes(action)
|
||||
@@ -460,39 +448,6 @@ def fix_view_modes(action):
|
||||
|
||||
return action
|
||||
|
||||
def parse_domain(domain, session):
|
||||
""" Parses an arbitrary string containing a domain, transforms it
|
||||
to either a literal domain or a :class:`nonliterals.Domain`
|
||||
|
||||
:param domain: the domain to parse, if the domain is not a string it
|
||||
is assumed to be a literal domain and is returned as-is
|
||||
:param session: Current OpenERP session
|
||||
:type session: openerpweb.OpenERPSession
|
||||
"""
|
||||
if not isinstance(domain, basestring):
|
||||
return domain
|
||||
try:
|
||||
return ast.literal_eval(domain)
|
||||
except ValueError:
|
||||
# not a literal
|
||||
return nonliterals.Domain(session, domain)
|
||||
|
||||
def parse_context(context, session):
|
||||
""" Parses an arbitrary string containing a context, transforms it
|
||||
to either a literal context or a :class:`nonliterals.Context`
|
||||
|
||||
:param context: the context to parse, if the context is not a string it
|
||||
is assumed to be a literal domain and is returned as-is
|
||||
:param session: Current OpenERP session
|
||||
:type session: openerpweb.OpenERPSession
|
||||
"""
|
||||
if not isinstance(context, basestring):
|
||||
return context
|
||||
try:
|
||||
return ast.literal_eval(context)
|
||||
except ValueError:
|
||||
return nonliterals.Context(session, context)
|
||||
|
||||
def _local_web_translations(trans_file):
|
||||
messages = []
|
||||
try:
|
||||
@@ -568,21 +523,7 @@ html_template = """<!DOCTYPE html>
|
||||
<body>
|
||||
<!--[if lte IE 8]>
|
||||
<script src="http://ajax.googleapis.com/ajax/libs/chrome-frame/1/CFInstall.min.js"></script>
|
||||
<script>
|
||||
var test = function() {
|
||||
CFInstall.check({
|
||||
mode: "overlay"
|
||||
});
|
||||
};
|
||||
if (window.localStorage && false) {
|
||||
if (! localStorage.getItem("hasShownGFramePopup")) {
|
||||
test();
|
||||
localStorage.setItem("hasShownGFramePopup", true);
|
||||
}
|
||||
} else {
|
||||
test();
|
||||
}
|
||||
</script>
|
||||
<script>CFInstall.check({mode: "overlay"});</script>
|
||||
<![endif]-->
|
||||
</body>
|
||||
</html>
|
||||
@@ -592,14 +533,14 @@ class Home(openerpweb.Controller):
|
||||
_cp_path = '/'
|
||||
|
||||
@openerpweb.httprequest
|
||||
def index(self, req, s_action=None, **kw):
|
||||
js = "\n ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list(req, None, 'js'))
|
||||
css = "\n ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list(req, None, 'css'))
|
||||
def index(self, req, s_action=None, db=None, **kw):
|
||||
js = "\n ".join('<script type="text/javascript" src="%s"></script>' % i for i in manifest_list(req, 'js', db=db))
|
||||
css = "\n ".join('<link rel="stylesheet" href="%s">' % i for i in manifest_list(req, 'css', db=db))
|
||||
|
||||
r = html_template % {
|
||||
'js': js,
|
||||
'css': css,
|
||||
'modules': simplejson.dumps(module_boot(req)),
|
||||
'modules': simplejson.dumps(module_boot(req, db=db)),
|
||||
'init': 'var wc = new s.web.WebClient();wc.appendTo($(document.body));'
|
||||
}
|
||||
return r
|
||||
@@ -613,19 +554,19 @@ class WebClient(openerpweb.Controller):
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def csslist(self, req, mods=None):
|
||||
return manifest_list(req, mods, 'css')
|
||||
return manifest_list(req, 'css', mods=mods)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def jslist(self, req, mods=None):
|
||||
return manifest_list(req, mods, 'js')
|
||||
return manifest_list(req, 'js', mods=mods)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def qweblist(self, req, mods=None):
|
||||
return manifest_list(req, mods, 'qweb')
|
||||
return manifest_list(req, 'qweb', mods=mods)
|
||||
|
||||
@openerpweb.httprequest
|
||||
def css(self, req, mods=None):
|
||||
files = list(manifest_glob(req, mods, 'css'))
|
||||
def css(self, req, mods=None, db=None):
|
||||
files = list(manifest_glob(req, 'css', addons=mods, db=db))
|
||||
last_modified = get_last_modified(f[0] for f in files)
|
||||
if req.httprequest.if_modified_since and req.httprequest.if_modified_since >= last_modified:
|
||||
return werkzeug.wrappers.Response(status=304)
|
||||
@@ -663,8 +604,8 @@ class WebClient(openerpweb.Controller):
|
||||
last_modified, checksum)
|
||||
|
||||
@openerpweb.httprequest
|
||||
def js(self, req, mods=None):
|
||||
files = [f[0] for f in manifest_glob(req, mods, 'js')]
|
||||
def js(self, req, mods=None, db=None):
|
||||
files = [f[0] for f in manifest_glob(req, 'js', addons=mods, db=db)]
|
||||
last_modified = get_last_modified(files)
|
||||
if req.httprequest.if_modified_since and req.httprequest.if_modified_since >= last_modified:
|
||||
return werkzeug.wrappers.Response(status=304)
|
||||
@@ -676,8 +617,8 @@ class WebClient(openerpweb.Controller):
|
||||
last_modified, checksum)
|
||||
|
||||
@openerpweb.httprequest
|
||||
def qweb(self, req, mods=None):
|
||||
files = [f[0] for f in manifest_glob(req, mods, 'qweb')]
|
||||
def qweb(self, req, mods=None, db=None):
|
||||
files = [f[0] for f in manifest_glob(req, 'qweb', addons=mods, db=db)]
|
||||
last_modified = get_last_modified(files)
|
||||
if req.httprequest.if_modified_since and req.httprequest.if_modified_since >= last_modified:
|
||||
return werkzeug.wrappers.Response(status=304)
|
||||
@@ -786,6 +727,17 @@ class Database(openerpweb.Controller):
|
||||
params['db_original_name'],
|
||||
params['db_name'])
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def duplicate(self, req, fields):
|
||||
params = dict(map(operator.itemgetter('name', 'value'), fields))
|
||||
duplicate_attrs = (
|
||||
params['super_admin_pwd'],
|
||||
params['db_original_name'],
|
||||
params['db_name'],
|
||||
)
|
||||
|
||||
return req.session.proxy("db").duplicate_database(*duplicate_attrs)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def drop(self, req, fields):
|
||||
password, db = operator.itemgetter(
|
||||
@@ -797,7 +749,7 @@ class Database(openerpweb.Controller):
|
||||
except xmlrpclib.Fault, e:
|
||||
if e.faultCode and e.faultCode.split(':')[0] == 'AccessDenied':
|
||||
return {'error': e.faultCode, 'title': 'Drop Database'}
|
||||
return {'error': 'Could not drop database !', 'title': 'Drop Database'}
|
||||
return {'error': _('Could not drop database !'), 'title': _('Drop Database')}
|
||||
|
||||
@openerpweb.httprequest
|
||||
def backup(self, req, backup_db, backup_pwd, token):
|
||||
@@ -815,7 +767,7 @@ class Database(openerpweb.Controller):
|
||||
{'fileToken': int(token)}
|
||||
)
|
||||
except xmlrpclib.Fault, e:
|
||||
return simplejson.dumps([[],[{'error': e.faultCode, 'title': 'backup Database'}]])
|
||||
return simplejson.dumps([[],[{'error': e.faultCode, 'title': _('Backup Database')}]])
|
||||
|
||||
@openerpweb.httprequest
|
||||
def restore(self, req, db_file, restore_pwd, new_db):
|
||||
@@ -836,8 +788,8 @@ class Database(openerpweb.Controller):
|
||||
return req.session.proxy("db").change_admin_password(old_password, new_password)
|
||||
except xmlrpclib.Fault, e:
|
||||
if e.faultCode and e.faultCode.split(':')[0] == 'AccessDenied':
|
||||
return {'error': e.faultCode, 'title': 'Change Password'}
|
||||
return {'error': 'Error, password not changed !', 'title': 'Change Password'}
|
||||
return {'error': e.faultCode, 'title': _('Change Password')}
|
||||
return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
|
||||
|
||||
class Session(openerpweb.Controller):
|
||||
_cp_path = "/web/session"
|
||||
@@ -873,90 +825,34 @@ class Session(openerpweb.Controller):
|
||||
old_password, new_password,confirm_password = operator.itemgetter('old_pwd', 'new_password','confirm_pwd')(
|
||||
dict(map(operator.itemgetter('name', 'value'), fields)))
|
||||
if not (old_password.strip() and new_password.strip() and confirm_password.strip()):
|
||||
return {'error':'All passwords have to be filled.','title': 'Change Password'}
|
||||
return {'error':_('You cannot leave any password empty.'),'title': _('Change Password')}
|
||||
if new_password != confirm_password:
|
||||
return {'error': 'The new password and its confirmation must be identical.','title': 'Change Password'}
|
||||
return {'error': _('The new password and its confirmation must be identical.'),'title': _('Change Password')}
|
||||
try:
|
||||
if req.session.model('res.users').change_password(
|
||||
old_password, new_password):
|
||||
return {'new_password':new_password}
|
||||
except Exception:
|
||||
return {'error': 'Original password incorrect, your password was not changed.', 'title': 'Change Password'}
|
||||
return {'error': 'Error, password not changed !', 'title': 'Change Password'}
|
||||
return {'error': _('The old password you provided is incorrect, your password was not changed.'), 'title': _('Change Password')}
|
||||
return {'error': _('Error, password not changed !'), 'title': _('Change Password')}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def sc_list(self, req):
|
||||
return req.session.model('ir.ui.view_sc').get_sc(
|
||||
req.session._uid, "ir.ui.menu", req.session.eval_context(req.context))
|
||||
req.session._uid, "ir.ui.menu", req.context)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def get_lang_list(self, req):
|
||||
try:
|
||||
return {
|
||||
'lang_list': (req.session.proxy("db").list_lang() or []),
|
||||
'error': ""
|
||||
}
|
||||
return req.session.proxy("db").list_lang() or []
|
||||
except Exception, e:
|
||||
return {"error": e, "title": "Languages"}
|
||||
return {"error": e, "title": _("Languages")}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def modules(self, req):
|
||||
# return all installed modules. Web client is smart enough to not load a module twice
|
||||
return module_installed(req)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def eval_domain_and_context(self, req, contexts, domains,
|
||||
group_by_seq=None):
|
||||
""" Evaluates sequences of domains and contexts, composing them into
|
||||
a single context, domain or group_by sequence.
|
||||
|
||||
:param list contexts: list of contexts to merge together. Contexts are
|
||||
evaluated in sequence, all previous contexts
|
||||
are part of their own evaluation context
|
||||
(starting at the session context).
|
||||
:param list domains: list of domains to merge together. Domains are
|
||||
evaluated in sequence and appended to one another
|
||||
(implicit AND), their evaluation domain is the
|
||||
result of merging all contexts.
|
||||
:param list group_by_seq: list of domains (which may be in a different
|
||||
order than the ``contexts`` parameter),
|
||||
evaluated in sequence, their ``'group_by'``
|
||||
key is extracted if they have one.
|
||||
:returns:
|
||||
a 3-dict of:
|
||||
|
||||
context (``dict``)
|
||||
the global context created by merging all of
|
||||
``contexts``
|
||||
|
||||
domain (``list``)
|
||||
the concatenation of all domains
|
||||
|
||||
group_by (``list``)
|
||||
a list of fields to group by, potentially empty (in which case
|
||||
no group by should be performed)
|
||||
"""
|
||||
context, domain = eval_context_and_domain(req.session,
|
||||
nonliterals.CompoundContext(*(contexts or [])),
|
||||
nonliterals.CompoundDomain(*(domains or [])))
|
||||
|
||||
group_by_sequence = []
|
||||
for candidate in (group_by_seq or []):
|
||||
ctx = req.session.eval_context(candidate, context)
|
||||
group_by = ctx.get('group_by')
|
||||
if not group_by:
|
||||
continue
|
||||
elif isinstance(group_by, basestring):
|
||||
group_by_sequence.append(group_by)
|
||||
else:
|
||||
group_by_sequence.extend(group_by)
|
||||
|
||||
return {
|
||||
'context': context,
|
||||
'domain': domain,
|
||||
'group_by': group_by_sequence
|
||||
}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def save_session_action(self, req, the_action):
|
||||
"""
|
||||
@@ -1009,15 +905,8 @@ class Session(openerpweb.Controller):
|
||||
class Menu(openerpweb.Controller):
|
||||
_cp_path = "/web/menu"
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def load(self, req):
|
||||
return {'data': self.do_load(req)}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def get_user_roots(self, req):
|
||||
return self.do_get_user_roots(req)
|
||||
|
||||
def do_get_user_roots(self, req):
|
||||
""" Return all root menu ids visible for the session user.
|
||||
|
||||
:param req: A request object, with an OpenERP session attribute
|
||||
@@ -1026,20 +915,22 @@ class Menu(openerpweb.Controller):
|
||||
:rtype: list(int)
|
||||
"""
|
||||
s = req.session
|
||||
context = s.eval_context(req.context)
|
||||
Menus = s.model('ir.ui.menu')
|
||||
# If a menu action is defined use its domain to get the root menu items
|
||||
user_menu_id = s.model('res.users').read([s._uid], ['menu_id'], context)[0]['menu_id']
|
||||
user_menu_id = s.model('res.users').read([s._uid], ['menu_id'],
|
||||
req.context)[0]['menu_id']
|
||||
|
||||
menu_domain = [('parent_id', '=', False)]
|
||||
if user_menu_id:
|
||||
domain_string = s.model('ir.actions.act_window').read([user_menu_id[0]], ['domain'], context)[0]['domain']
|
||||
domain_string = s.model('ir.actions.act_window').read(
|
||||
[user_menu_id[0]], ['domain'],req.context)[0]['domain']
|
||||
if domain_string:
|
||||
menu_domain = ast.literal_eval(domain_string)
|
||||
|
||||
return Menus.search(menu_domain, 0, False, False, context)
|
||||
return Menus.search(menu_domain, 0, False, False, req.context)
|
||||
|
||||
def do_load(self, req):
|
||||
@openerpweb.jsonrequest
|
||||
def load(self, req):
|
||||
""" Loads all menu items (all applications and their sub-menus).
|
||||
|
||||
:param req: A request object, with an OpenERP session attribute
|
||||
@@ -1047,23 +938,30 @@ class Menu(openerpweb.Controller):
|
||||
:return: the menu root
|
||||
:rtype: dict('children': menu_nodes)
|
||||
"""
|
||||
context = req.session.eval_context(req.context)
|
||||
Menus = req.session.model('ir.ui.menu')
|
||||
|
||||
menu_roots = Menus.read(self.do_get_user_roots(req), ['name', 'sequence', 'parent_id', 'action', 'needaction_enabled', 'needaction_counter'], context)
|
||||
menu_root = {'id': False, 'name': 'root', 'parent_id': [-1, ''], 'children' : menu_roots}
|
||||
fields = ['name', 'sequence', 'parent_id', 'action',
|
||||
'needaction_enabled']
|
||||
menu_roots = Menus.read(self.get_user_roots(req), fields, req.context)
|
||||
menu_root = {
|
||||
'id': False,
|
||||
'name': 'root',
|
||||
'parent_id': [-1, ''],
|
||||
'children': menu_roots
|
||||
}
|
||||
|
||||
# menus are loaded fully unlike a regular tree view, cause there are a
|
||||
# limited number of items (752 when all 6.1 addons are installed)
|
||||
menu_ids = Menus.search([], 0, False, False, context)
|
||||
menu_items = Menus.read(menu_ids, ['name', 'sequence', 'parent_id', 'action', 'needaction_enabled', 'needaction_counter'], context)
|
||||
menu_ids = Menus.search([], 0, False, False, req.context)
|
||||
menu_items = Menus.read(menu_ids, fields, req.context)
|
||||
# adds roots at the end of the sequence, so that they will overwrite
|
||||
# equivalent menu items from full menu read when put into id:item
|
||||
# mapping, resulting in children being correctly set on the roots.
|
||||
menu_items.extend(menu_roots)
|
||||
|
||||
# make a tree using parent_id
|
||||
menu_items_map = dict((menu_item["id"], menu_item) for menu_item in menu_items)
|
||||
menu_items_map = dict(
|
||||
(menu_item["id"], menu_item) for menu_item in menu_items)
|
||||
for menu_item in menu_items:
|
||||
if menu_item['parent_id']:
|
||||
parent = menu_item['parent_id'][0]
|
||||
@@ -1080,8 +978,24 @@ class Menu(openerpweb.Controller):
|
||||
|
||||
return menu_root
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def load_needaction(self, req, menu_ids=False):
|
||||
""" Loads needaction counters for all or some specific menu ids.
|
||||
|
||||
:return: needaction data
|
||||
:rtype: dict(menu_id: {'needaction_enabled': boolean, 'needaction_counter': int})
|
||||
"""
|
||||
Menus = req.session.model('ir.ui.menu')
|
||||
|
||||
if menu_ids == False:
|
||||
menu_ids = Menus.search([('needaction_enabled', '=', True)], context=req.context)
|
||||
|
||||
menu_needaction_data = Menus.get_needaction_data(menu_ids, req.context)
|
||||
return menu_needaction_data
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def action(self, req, menu_id):
|
||||
# still used by web_shortcut
|
||||
actions = load_actions_from_ir_values(req,'action', 'tree_but_open',
|
||||
[('ir.ui.menu', menu_id)], False)
|
||||
return {"action": actions}
|
||||
@@ -1113,12 +1027,10 @@ class DataSet(openerpweb.Controller):
|
||||
"""
|
||||
Model = req.session.model(model)
|
||||
|
||||
context, domain = eval_context_and_domain(
|
||||
req.session, req.context, domain)
|
||||
|
||||
ids = Model.search(domain, offset or 0, limit or False, sort or False, context)
|
||||
ids = Model.search(domain, offset or 0, limit or False, sort or False,
|
||||
req.context)
|
||||
if limit and len(ids) == limit:
|
||||
length = Model.search_count(domain, context)
|
||||
length = Model.search_count(domain, req.context)
|
||||
else:
|
||||
length = len(ids) + (offset or 0)
|
||||
if fields and fields == ['id']:
|
||||
@@ -1128,7 +1040,7 @@ class DataSet(openerpweb.Controller):
|
||||
'records': [{'id': id} for id in ids]
|
||||
}
|
||||
|
||||
records = Model.read(ids, fields or False, context)
|
||||
records = Model.read(ids, fields or False, req.context)
|
||||
records.sort(key=lambda obj: ids.index(obj['id']))
|
||||
return {
|
||||
'length': length,
|
||||
@@ -1139,37 +1051,15 @@ class DataSet(openerpweb.Controller):
|
||||
def load(self, req, model, id, fields):
|
||||
m = req.session.model(model)
|
||||
value = {}
|
||||
r = m.read([id], False, req.session.eval_context(req.context))
|
||||
r = m.read([id], False, req.context)
|
||||
if r:
|
||||
value = r[0]
|
||||
return {'value': value}
|
||||
|
||||
def call_common(self, req, model, method, args, domain_id=None, context_id=None):
|
||||
has_domain = domain_id is not None and domain_id < len(args)
|
||||
has_context = context_id is not None and context_id < len(args)
|
||||
|
||||
domain = args[domain_id] if has_domain else []
|
||||
context = args[context_id] if has_context else {}
|
||||
c, d = eval_context_and_domain(req.session, context, domain)
|
||||
if has_domain:
|
||||
args[domain_id] = d
|
||||
if has_context:
|
||||
args[context_id] = c
|
||||
|
||||
return self._call_kw(req, model, method, args, {})
|
||||
|
||||
def _call_kw(self, req, model, method, args, kwargs):
|
||||
for i in xrange(len(args)):
|
||||
if isinstance(args[i], nonliterals.BaseContext):
|
||||
args[i] = req.session.eval_context(args[i])
|
||||
elif isinstance(args[i], nonliterals.BaseDomain):
|
||||
args[i] = req.session.eval_domain(args[i])
|
||||
for k in kwargs.keys():
|
||||
if isinstance(kwargs[k], nonliterals.BaseContext):
|
||||
kwargs[k] = req.session.eval_context(kwargs[k])
|
||||
elif isinstance(kwargs[k], nonliterals.BaseDomain):
|
||||
kwargs[k] = req.session.eval_domain(kwargs[k])
|
||||
|
||||
def _call_kw(self, req, model, method, args, kwargs):
|
||||
# Temporary implements future display_name special field for model#read()
|
||||
if method == 'read' and kwargs.get('context') and kwargs['context'].get('future_display_name'):
|
||||
if 'display_name' in args[1]:
|
||||
@@ -1182,39 +1072,9 @@ class DataSet(openerpweb.Controller):
|
||||
|
||||
return getattr(req.session.model(model), method)(*args, **kwargs)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def onchange(self, req, model, method, args, context_id=None):
|
||||
""" Support method for handling onchange calls: behaves much like call
|
||||
with the following differences:
|
||||
|
||||
* Does not take a domain_id
|
||||
* Is aware of the return value's structure, and will parse the domains
|
||||
if needed in order to return either parsed literal domains (in JSON)
|
||||
or non-literal domain instances, allowing those domains to be used
|
||||
from JS
|
||||
|
||||
:param req:
|
||||
:type req: web.common.http.JsonRequest
|
||||
:param str model: object type on which to call the method
|
||||
:param str method: name of the onchange handler method
|
||||
:param list args: arguments to call the onchange handler with
|
||||
:param int context_id: index of the context object in the list of
|
||||
arguments
|
||||
:return: result of the onchange call with all domains parsed
|
||||
"""
|
||||
result = self.call_common(req, model, method, args, context_id=context_id)
|
||||
if not result or 'domain' not in result:
|
||||
return result
|
||||
|
||||
result['domain'] = dict(
|
||||
(k, parse_domain(v, req.session))
|
||||
for k, v in result['domain'].iteritems())
|
||||
|
||||
return result
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def call(self, req, model, method, args, domain_id=None, context_id=None):
|
||||
return self.call_common(req, model, method, args, domain_id, context_id)
|
||||
return self._call_kw(req, model, method, args, {})
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def call_kw(self, req, model, method, args, kwargs):
|
||||
@@ -1222,7 +1082,7 @@ class DataSet(openerpweb.Controller):
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def call_button(self, req, model, method, args, domain_id=None, context_id=None):
|
||||
action = self.call_common(req, model, method, args, domain_id, context_id)
|
||||
action = self._call_kw(req, model, method, args, {})
|
||||
if isinstance(action, dict) and action.get('type') != '':
|
||||
return clean_action(req, action)
|
||||
return False
|
||||
@@ -1257,69 +1117,6 @@ class DataSet(openerpweb.Controller):
|
||||
class View(openerpweb.Controller):
|
||||
_cp_path = "/web/view"
|
||||
|
||||
def fields_view_get(self, req, model, view_id, view_type,
|
||||
transform=True, toolbar=False, submenu=False):
|
||||
Model = req.session.model(model)
|
||||
context = req.session.eval_context(req.context)
|
||||
fvg = Model.fields_view_get(view_id, view_type, context, toolbar, submenu)
|
||||
# todo fme?: check that we should pass the evaluated context here
|
||||
self.process_view(req.session, fvg, context, transform, (view_type == 'kanban'))
|
||||
if toolbar and transform:
|
||||
self.process_toolbar(req, fvg['toolbar'])
|
||||
return fvg
|
||||
|
||||
def process_view(self, session, fvg, context, transform, preserve_whitespaces=False):
|
||||
# depending on how it feels, xmlrpclib.ServerProxy can translate
|
||||
# XML-RPC strings to ``str`` or ``unicode``. ElementTree does not
|
||||
# enjoy unicode strings which can not be trivially converted to
|
||||
# strings, and it blows up during parsing.
|
||||
|
||||
# So ensure we fix this retardation by converting view xml back to
|
||||
# bit strings.
|
||||
if isinstance(fvg['arch'], unicode):
|
||||
arch = fvg['arch'].encode('utf-8')
|
||||
else:
|
||||
arch = fvg['arch']
|
||||
fvg['arch_string'] = arch
|
||||
|
||||
if transform:
|
||||
evaluation_context = session.evaluation_context(context or {})
|
||||
xml = self.transform_view(arch, session, evaluation_context)
|
||||
else:
|
||||
xml = ElementTree.fromstring(arch)
|
||||
fvg['arch'] = xml2json_from_elementtree(xml, preserve_whitespaces)
|
||||
|
||||
if 'id' in fvg['fields']:
|
||||
# Special case for id's
|
||||
id_field = fvg['fields']['id']
|
||||
id_field['original_type'] = id_field['type']
|
||||
id_field['type'] = 'id'
|
||||
|
||||
for field in fvg['fields'].itervalues():
|
||||
if field.get('views'):
|
||||
for view in field["views"].itervalues():
|
||||
self.process_view(session, view, None, transform)
|
||||
if field.get('domain'):
|
||||
field["domain"] = parse_domain(field["domain"], session)
|
||||
if field.get('context'):
|
||||
field["context"] = parse_context(field["context"], session)
|
||||
|
||||
def process_toolbar(self, req, toolbar):
|
||||
"""
|
||||
The toolbar is a mapping of section_key: [action_descriptor]
|
||||
|
||||
We need to clean all those actions in order to ensure correct
|
||||
round-tripping
|
||||
"""
|
||||
for actions in toolbar.itervalues():
|
||||
for action in actions:
|
||||
if 'context' in action:
|
||||
action['context'] = parse_context(
|
||||
action['context'], req.session)
|
||||
if 'domain' in action:
|
||||
action['domain'] = parse_domain(
|
||||
action['domain'], req.session)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def add_custom(self, req, view_id, arch):
|
||||
CustomView = req.session.model('ir.ui.view.custom')
|
||||
@@ -1327,61 +1124,22 @@ class View(openerpweb.Controller):
|
||||
'user_id': req.session._uid,
|
||||
'ref_id': view_id,
|
||||
'arch': arch
|
||||
}, req.session.eval_context(req.context))
|
||||
}, req.context)
|
||||
return {'result': True}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def undo_custom(self, req, view_id, reset=False):
|
||||
CustomView = req.session.model('ir.ui.view.custom')
|
||||
context = req.session.eval_context(req.context)
|
||||
vcustom = CustomView.search([('user_id', '=', req.session._uid), ('ref_id' ,'=', view_id)],
|
||||
0, False, False, context)
|
||||
0, False, False, req.context)
|
||||
if vcustom:
|
||||
if reset:
|
||||
CustomView.unlink(vcustom, context)
|
||||
CustomView.unlink(vcustom, req.context)
|
||||
else:
|
||||
CustomView.unlink([vcustom[0]], context)
|
||||
CustomView.unlink([vcustom[0]], req.context)
|
||||
return {'result': True}
|
||||
return {'result': False}
|
||||
|
||||
def transform_view(self, view_string, session, context=None):
|
||||
# transform nodes on the fly via iterparse, instead of
|
||||
# doing it statically on the parsing result
|
||||
parser = ElementTree.iterparse(StringIO(view_string), events=("start",))
|
||||
root = None
|
||||
for event, elem in parser:
|
||||
if event == "start":
|
||||
if root is None:
|
||||
root = elem
|
||||
self.parse_domains_and_contexts(elem, session)
|
||||
return root
|
||||
|
||||
def parse_domains_and_contexts(self, elem, session):
|
||||
""" Converts domains and contexts from the view into Python objects,
|
||||
either literals if they can be parsed by literal_eval or a special
|
||||
placeholder object if the domain or context refers to free variables.
|
||||
|
||||
:param elem: the current node being parsed
|
||||
:type param: xml.etree.ElementTree.Element
|
||||
:param session: OpenERP session object, used to store and retrieve
|
||||
non-literal objects
|
||||
:type session: openerpweb.openerpweb.OpenERPSession
|
||||
"""
|
||||
for el in ['domain', 'filter_domain']:
|
||||
domain = elem.get(el, '').strip()
|
||||
if domain:
|
||||
elem.set(el, parse_domain(domain, session))
|
||||
elem.set(el + '_string', domain)
|
||||
for el in ['context', 'default_get']:
|
||||
context_string = elem.get(el, '').strip()
|
||||
if context_string:
|
||||
elem.set(el, parse_context(context_string, session))
|
||||
elem.set(el + '_string', context_string)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def load(self, req, model, view_id, view_type, toolbar=False):
|
||||
return self.fields_view_get(req, model, view_id, view_type, toolbar=toolbar)
|
||||
|
||||
class TreeView(View):
|
||||
_cp_path = "/web/treeview"
|
||||
|
||||
@@ -1391,50 +1149,6 @@ class TreeView(View):
|
||||
req,'action', 'tree_but_open',[(model, id)],
|
||||
False)
|
||||
|
||||
class SearchView(View):
|
||||
_cp_path = "/web/searchview"
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def load(self, req, model, view_id):
|
||||
fields_view = self.fields_view_get(req, model, view_id, 'search')
|
||||
return {'fields_view': fields_view}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def fields_get(self, req, model):
|
||||
Model = req.session.model(model)
|
||||
fields = Model.fields_get(False, req.session.eval_context(req.context))
|
||||
for field in fields.values():
|
||||
# shouldn't convert the views too?
|
||||
if field.get('domain'):
|
||||
field["domain"] = parse_domain(field["domain"], req.session)
|
||||
if field.get('context'):
|
||||
field["context"] = parse_context(field["context"], req.session)
|
||||
return {'fields': fields}
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def get_filters(self, req, model):
|
||||
logger = logging.getLogger(__name__ + '.SearchView.get_filters')
|
||||
Model = req.session.model("ir.filters")
|
||||
filters = Model.get_filters(model)
|
||||
for filter in filters:
|
||||
try:
|
||||
parsed_context = parse_context(filter["context"], req.session)
|
||||
filter["context"] = (parsed_context
|
||||
if not isinstance(parsed_context, nonliterals.BaseContext)
|
||||
else req.session.eval_context(parsed_context))
|
||||
|
||||
parsed_domain = parse_domain(filter["domain"], req.session)
|
||||
filter["domain"] = (parsed_domain
|
||||
if not isinstance(parsed_domain, nonliterals.BaseDomain)
|
||||
else req.session.eval_domain(parsed_domain))
|
||||
except Exception:
|
||||
logger.exception("Failed to parse custom filter %s in %s",
|
||||
filter['name'], model)
|
||||
filter['disabled'] = True
|
||||
del filter['context']
|
||||
del filter['domain']
|
||||
return filters
|
||||
|
||||
class Binary(openerpweb.Controller):
|
||||
_cp_path = "/web/binary"
|
||||
|
||||
@@ -1442,7 +1156,6 @@ class Binary(openerpweb.Controller):
|
||||
def image(self, req, model, id, field, **kw):
|
||||
last_update = '__last_update'
|
||||
Model = req.session.model(model)
|
||||
context = req.session.eval_context(req.context)
|
||||
headers = [('Content-Type', 'image/png')]
|
||||
etag = req.httprequest.headers.get('If-None-Match')
|
||||
hashed_session = hashlib.md5(req.session_id).hexdigest()
|
||||
@@ -1453,22 +1166,22 @@ class Binary(openerpweb.Controller):
|
||||
if not id and hashed_session == etag:
|
||||
return werkzeug.wrappers.Response(status=304)
|
||||
else:
|
||||
date = Model.read([id], [last_update], context)[0].get(last_update)
|
||||
date = Model.read([id], [last_update], req.context)[0].get(last_update)
|
||||
if hashlib.md5(date).hexdigest() == etag:
|
||||
return werkzeug.wrappers.Response(status=304)
|
||||
|
||||
retag = hashed_session
|
||||
try:
|
||||
if not id:
|
||||
res = Model.default_get([field], context).get(field)
|
||||
res = Model.default_get([field], req.context).get(field)
|
||||
image_base64 = res
|
||||
else:
|
||||
res = Model.read([id], [last_update, field], context)[0]
|
||||
res = Model.read([id], [last_update, field], req.context)[0]
|
||||
retag = hashlib.md5(res.get(last_update)).hexdigest()
|
||||
image_base64 = res.get(field)
|
||||
|
||||
if kw.get('resize'):
|
||||
resize = kw.get('resize').split(',');
|
||||
resize = kw.get('resize').split(',')
|
||||
if len(resize) == 2 and int(resize[0]) and int(resize[1]):
|
||||
width = int(resize[0])
|
||||
height = int(resize[1])
|
||||
@@ -1510,14 +1223,13 @@ class Binary(openerpweb.Controller):
|
||||
:returns: :class:`werkzeug.wrappers.Response`
|
||||
"""
|
||||
Model = req.session.model(model)
|
||||
context = req.session.eval_context(req.context)
|
||||
fields = [field]
|
||||
if filename_field:
|
||||
fields.append(filename_field)
|
||||
if id:
|
||||
res = Model.read([int(id)], fields, context)[0]
|
||||
res = Model.read([int(id)], fields, req.context)[0]
|
||||
else:
|
||||
res = Model.default_get(fields, context)
|
||||
res = Model.default_get(fields, req.context)
|
||||
filecontent = base64.b64decode(res.get(field, ''))
|
||||
if not filecontent:
|
||||
return req.not_found()
|
||||
@@ -1534,22 +1246,24 @@ class Binary(openerpweb.Controller):
|
||||
jdata = simplejson.loads(data)
|
||||
model = jdata['model']
|
||||
field = jdata['field']
|
||||
data = jdata['data']
|
||||
id = jdata.get('id', None)
|
||||
filename_field = jdata.get('filename_field', None)
|
||||
context = jdata.get('context', dict())
|
||||
context = jdata.get('context', {})
|
||||
|
||||
context = req.session.eval_context(context)
|
||||
Model = req.session.model(model)
|
||||
fields = [field]
|
||||
if filename_field:
|
||||
fields.append(filename_field)
|
||||
if id:
|
||||
if data:
|
||||
res = { field: data }
|
||||
elif id:
|
||||
res = Model.read([int(id)], fields, context)[0]
|
||||
else:
|
||||
res = Model.default_get(fields, context)
|
||||
filecontent = base64.b64decode(res.get(field, ''))
|
||||
if not filecontent:
|
||||
raise ValueError("No content found for field '%s' on '%s:%s'" %
|
||||
raise ValueError(_("No content found for field '%s' on '%s:%s'") %
|
||||
(field, model, id))
|
||||
else:
|
||||
filename = '%s_%s' % (model.replace('.', '_'), id)
|
||||
@@ -1577,7 +1291,6 @@ class Binary(openerpweb.Controller):
|
||||
|
||||
@openerpweb.httprequest
|
||||
def upload_attachment(self, req, callback, model, id, ufile):
|
||||
context = req.session.eval_context(req.context)
|
||||
Model = req.session.model('ir.attachment')
|
||||
try:
|
||||
out = """<script language="javascript" type="text/javascript">
|
||||
@@ -1590,13 +1303,13 @@ class Binary(openerpweb.Controller):
|
||||
'datas_fname': ufile.filename,
|
||||
'res_model': model,
|
||||
'res_id': int(id)
|
||||
}, context)
|
||||
}, req.context)
|
||||
args = {
|
||||
'filename': ufile.filename,
|
||||
'id': attachment_id
|
||||
}
|
||||
except Exception, e:
|
||||
args = { 'error': e.message }
|
||||
except Exception,e:
|
||||
args = {'error':e.faultCode }
|
||||
return out % (simplejson.dumps(callback), simplejson.dumps(args))
|
||||
|
||||
class Action(openerpweb.Controller):
|
||||
@@ -1606,8 +1319,6 @@ class Action(openerpweb.Controller):
|
||||
def load(self, req, action_id, do_not_eval=False):
|
||||
Actions = req.session.model('ir.actions.actions')
|
||||
value = False
|
||||
context = req.session.eval_context(req.context)
|
||||
|
||||
try:
|
||||
action_id = int(action_id)
|
||||
except ValueError:
|
||||
@@ -1618,22 +1329,22 @@ class Action(openerpweb.Controller):
|
||||
except Exception:
|
||||
action_id = 0 # force failed read
|
||||
|
||||
base_action = Actions.read([action_id], ['type'], context)
|
||||
base_action = Actions.read([action_id], ['type'], req.context)
|
||||
if base_action:
|
||||
ctx = {}
|
||||
action_type = base_action[0]['type']
|
||||
if action_type == 'ir.actions.report.xml':
|
||||
ctx.update({'bin_size': True})
|
||||
ctx.update(context)
|
||||
ctx.update(req.context)
|
||||
action = req.session.model(action_type).read([action_id], False, ctx)
|
||||
if action:
|
||||
value = clean_action(req, action[0], do_not_eval)
|
||||
value = clean_action(req, action[0])
|
||||
return value
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def run(self, req, action_id):
|
||||
return_action = req.session.model('ir.actions.server').run(
|
||||
[action_id], req.session.eval_context(req.context))
|
||||
[action_id], req.context)
|
||||
if return_action:
|
||||
return clean_action(req, return_action)
|
||||
else:
|
||||
@@ -1658,7 +1369,7 @@ class Export(View):
|
||||
|
||||
def fields_get(self, req, model):
|
||||
Model = req.session.model(model)
|
||||
fields = Model.fields_get(False, req.session.eval_context(req.context))
|
||||
fields = Model.fields_get(False, req.context)
|
||||
return fields
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
@@ -1728,7 +1439,9 @@ class Export(View):
|
||||
def fields_info(self, req, model, export_fields):
|
||||
info = {}
|
||||
fields = self.fields_get(req, model)
|
||||
|
||||
if ".id" in export_fields:
|
||||
fields['.id'] = fields.pop('id', {'string': 'ID'})
|
||||
|
||||
# To make fields retrieval more efficient, fetch all sub-fields of a
|
||||
# given field at the same time. Because the order in the export list is
|
||||
# arbitrary, this requires ordering all sub-fields of a given field
|
||||
@@ -1808,12 +1521,11 @@ class Export(View):
|
||||
'import_compat')(
|
||||
simplejson.loads(data))
|
||||
|
||||
context = req.session.eval_context(req.context)
|
||||
Model = req.session.model(model)
|
||||
ids = ids or Model.search(domain, 0, False, False, context)
|
||||
ids = ids or Model.search(domain, 0, False, False, req.context)
|
||||
|
||||
field_names = map(operator.itemgetter('name'), fields)
|
||||
import_data = Model.export_data(ids, field_names, context).get('datas',[])
|
||||
import_data = Model.export_data(ids, field_names, req.context).get('datas',[])
|
||||
|
||||
if import_compat:
|
||||
columns_headers = field_names
|
||||
@@ -1918,9 +1630,8 @@ class Reports(View):
|
||||
action = simplejson.loads(action)
|
||||
|
||||
report_srv = req.session.proxy("report")
|
||||
context = req.session.eval_context(
|
||||
nonliterals.CompoundContext(
|
||||
req.context or {}, action[ "context"]))
|
||||
context = dict(req.context)
|
||||
context.update(action["context"])
|
||||
|
||||
report_data = {}
|
||||
report_ids = context["active_ids"]
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
# coding=utf-8
|
||||
# -*- encoding: utf-8 -*-
|
||||
|
||||
import glob
|
||||
import itertools
|
||||
import json
|
||||
import operator
|
||||
import os
|
||||
|
||||
from mako.template import Template
|
||||
from openerp.modules import module
|
||||
|
||||
from .main import module_topological_sort
|
||||
from .. import http
|
||||
|
||||
NOMODULE_TEMPLATE = Template(u"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"/>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<title>OpenERP Testing</title>
|
||||
</head>
|
||||
<body>
|
||||
<form action="/web/tests" method="GET">
|
||||
<button name="mod" value="*">Run all tests</button>
|
||||
<ul>
|
||||
% for name, module in modules:
|
||||
<li>${name} <button name="mod" value="${module}">
|
||||
Run Tests</button></li>
|
||||
% endfor
|
||||
</ul>
|
||||
</form>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
NOTFOUND = Template(u"""
|
||||
<p>Unable to find the module [${module}], please check that the module
|
||||
name is correct and the module is on OpenERP's path.</p>
|
||||
<a href="/web/tests"><< Back to tests</a>
|
||||
""")
|
||||
TESTING = Template(u"""<!DOCTYPE html>
|
||||
<html style="height: 100%">
|
||||
<%def name="to_path(module, p)">/${module}/${p}</%def>
|
||||
<head>
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
|
||||
<meta http-equiv="content-type" content="text/html; charset=utf-8" />
|
||||
<title>OpenERP Web Tests</title>
|
||||
<link rel="shortcut icon" href="/web/static/src/img/favicon.ico" type="image/x-icon"/>
|
||||
|
||||
<link rel="stylesheet" href="/web/static/lib/qunit/qunit.css">
|
||||
<script src="/web/static/lib/qunit/qunit.js"></script>
|
||||
|
||||
<script type="text/javascript">
|
||||
var oe_db_info = ${db_info};
|
||||
// List of modules, each module is preceded by its dependencies
|
||||
var oe_all_dependencies = ${dependencies};
|
||||
QUnit.config.testTimeout = 5 * 60 * 1000;
|
||||
</script>
|
||||
</head>
|
||||
<body id="oe" class="openerp">
|
||||
<div id="qunit"></div>
|
||||
<div id="qunit-fixture"></div>
|
||||
</body>
|
||||
% for module, jss, tests, templates in files:
|
||||
% for js in jss:
|
||||
<script src="${to_path(module, js)}"></script>
|
||||
% endfor
|
||||
% if tests or templates:
|
||||
<script>
|
||||
openerp.testing.current_module = "${module}";
|
||||
% for template in templates:
|
||||
openerp.testing.add_template("${to_path(module, template)}");
|
||||
% endfor
|
||||
</script>
|
||||
% endif
|
||||
% if tests:
|
||||
% for test in tests:
|
||||
<script type="text/javascript" src="${to_path(module, test)}"></script>
|
||||
% endfor
|
||||
% endif
|
||||
% endfor
|
||||
</html>
|
||||
""")
|
||||
|
||||
class TestRunnerController(http.Controller):
|
||||
_cp_path = '/web/tests'
|
||||
|
||||
@http.httprequest
|
||||
def index(self, req, mod=None, **kwargs):
|
||||
ms = module.get_modules()
|
||||
manifests = dict(
|
||||
(name, desc)
|
||||
for name, desc in zip(ms, map(self.load_manifest, ms))
|
||||
if desc # remove not-actually-openerp-modules
|
||||
)
|
||||
|
||||
if not mod:
|
||||
return NOMODULE_TEMPLATE.render(modules=(
|
||||
(manifest['name'], name)
|
||||
for name, manifest in manifests.iteritems()
|
||||
if any(testfile.endswith('.js')
|
||||
for testfile in manifest['test'])
|
||||
))
|
||||
sorted_mods = module_topological_sort(dict(
|
||||
(name, manifest.get('depends', []))
|
||||
for name, manifest in manifests.iteritems()
|
||||
))
|
||||
# to_load and to_test should be zippable lists of the same length.
|
||||
# A falsy value in to_test indicate nothing to test at that index (just
|
||||
# load the corresponding part of to_load)
|
||||
to_test = sorted_mods
|
||||
if mod != '*':
|
||||
if mod not in manifests:
|
||||
return req.not_found(NOTFOUND.render(module=mod))
|
||||
idx = sorted_mods.index(mod)
|
||||
to_test = [None] * len(sorted_mods)
|
||||
to_test[idx] = mod
|
||||
|
||||
tests_candicates = [
|
||||
filter(lambda path: path.endswith('.js'),
|
||||
manifests[mod]['test'] if mod else [])
|
||||
for mod in to_test]
|
||||
# remove trailing test-less modules
|
||||
tests = reversed(list(
|
||||
itertools.dropwhile(
|
||||
operator.not_,
|
||||
reversed(tests_candicates))))
|
||||
|
||||
files = [
|
||||
(mod, manifests[mod]['js'], tests, manifests[mod]['qweb'])
|
||||
for mod, tests in itertools.izip(sorted_mods, tests)
|
||||
]
|
||||
|
||||
# if all three db_info parameters are present, send them to the page
|
||||
db_info = dict((k, v) for k, v in kwargs.iteritems()
|
||||
if k in ['source', 'supadmin', 'password'])
|
||||
if len(db_info) != 3:
|
||||
db_info = None
|
||||
|
||||
return TESTING.render(files=files, dependencies=json.dumps(
|
||||
[name for name in sorted_mods
|
||||
if module.get_module_resource(name, 'static')
|
||||
if manifests[name]['js']]), db_info=json.dumps(db_info))
|
||||
|
||||
def load_manifest(self, name):
|
||||
manifest = module.load_information_from_description_file(name)
|
||||
if manifest:
|
||||
path = module.get_module_path(name)
|
||||
manifest['js'] = list(
|
||||
self.expand_patterns(path, manifest.get('js', [])))
|
||||
manifest['test'] = list(
|
||||
self.expand_patterns(path, manifest.get('test', [])))
|
||||
manifest['qweb'] = list(
|
||||
self.expand_patterns(path, manifest.get('qweb', [])))
|
||||
return manifest
|
||||
|
||||
def expand_patterns(self, root, patterns):
|
||||
for pattern in patterns:
|
||||
normalized_pattern = os.path.normpath(os.path.join(root, pattern))
|
||||
for path in glob.glob(normalized_pattern):
|
||||
# replace OS path separators (from join & normpath) by URI ones
|
||||
yield path[len(root):].replace(os.path.sep, '/')
|
||||
@@ -240,6 +240,103 @@ for instance (to take advantage of :js:func:`when` 's special
|
||||
treatment of single-value promises).
|
||||
|
||||
|
||||
jQuery.Deferred API
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
.. js:function:: when(deferreds…)
|
||||
|
||||
:param deferreds: deferred objects to multiplex
|
||||
:returns: a multiplexed deferred
|
||||
:rtype: :js:class:`Deferred`
|
||||
|
||||
.. js:class:: Deferred
|
||||
|
||||
.. js:function:: Deferred.then(doneCallback[, failCallback])
|
||||
|
||||
Attaches new callbacks to the resolution or rejection of the
|
||||
deferred object. Callbacks are executed in the order they are
|
||||
attached to the deferred.
|
||||
|
||||
To provide only a failure callback, pass ``null`` as the
|
||||
``doneCallback``, to provide only a success callback the
|
||||
second argument can just be ignored (and not passed at all).
|
||||
|
||||
Returns a new deferred which resolves to the result of the
|
||||
corresponding callback, if a callback returns a deferred
|
||||
itself that new deferred will be used as the resolution of the
|
||||
chain.
|
||||
|
||||
:param doneCallback: function called when the deferred is resolved
|
||||
:type doneCallback: Function
|
||||
:param failCallback: function called when the deferred is rejected
|
||||
:type failCallback: Function
|
||||
:returns: the deferred object on which it was called
|
||||
:rtype: :js:class:`Deferred`
|
||||
|
||||
.. js:function:: Deferred.done(doneCallback)
|
||||
|
||||
Attaches a new success callback to the deferred, shortcut for
|
||||
``deferred.then(doneCallback)``.
|
||||
|
||||
This is a jQuery extension to `CommonJS Promises/A`_ providing
|
||||
little value over calling :js:func:`~Deferred.then` directly,
|
||||
it should be avoided.
|
||||
|
||||
:param doneCallback: function called when the deferred is resolved
|
||||
:type doneCallback: Function
|
||||
:returns: the deferred object on which it was called
|
||||
:rtype: :js:class:`Deferred`
|
||||
|
||||
.. js:function:: Deferred.fail(failCallback)
|
||||
|
||||
Attaches a new failure callback to the deferred, shortcut for
|
||||
``deferred.then(null, failCallback)``.
|
||||
|
||||
A second jQuery extension to `Promises/A <CommonJS
|
||||
Promises/A>`_. Although it provides more value than
|
||||
:js:func:`~Deferred.done`, it still is not much and should be
|
||||
avoided as well.
|
||||
|
||||
:param failCallback: function called when the deferred is rejected
|
||||
:type failCallback: Function
|
||||
:returns: the deferred object on which it was called
|
||||
:rtype: :js:class:`Deferred`
|
||||
|
||||
.. js:function:: Deferred.promise()
|
||||
|
||||
Returns a read-only view of the deferred object, with all
|
||||
mutators (resolve and reject) methods removed.
|
||||
|
||||
.. js:function:: Deferred.resolve(value…)
|
||||
|
||||
Called to resolve a deferred, any value provided will be
|
||||
passed onto the success handlers of the deferred object.
|
||||
|
||||
Resolving a deferred which has already been resolved or
|
||||
rejected has no effect.
|
||||
|
||||
.. js:function:: Deferred.reject(value…)
|
||||
|
||||
Called to reject (fail) a deferred, any value provided will be
|
||||
passed onto the failure handler of the deferred object.
|
||||
|
||||
Rejecting a deferred which has already been resolved or
|
||||
rejected has no effect.
|
||||
|
||||
.. [#] or simply calling :js:class:`Deferred` as a function, the
|
||||
result is the same
|
||||
|
||||
.. [#] or not-promises, the `CommonJS Promises/B`_ role of
|
||||
:js:func:`when` is to be able to treat values and promises
|
||||
uniformly: :js:func:`when` will pass promises through directly,
|
||||
but non-promise values and objects will be transformed into a
|
||||
resolved promise (resolving themselves with the value itself).
|
||||
|
||||
jQuery's :js:func:`when` keeps this behavior making deferreds
|
||||
easy to build from "static" values, or allowing defensive code
|
||||
where expected promises are wrapped in :js:func:`when` just in
|
||||
case.
|
||||
|
||||
.. _promises: http://en.wikipedia.org/wiki/Promise_(programming)
|
||||
.. _jQuery's deferred: http://api.jquery.com/category/deferred-object/
|
||||
.. _CommonJS Promises/A: http://wiki.commonjs.org/wiki/Promises/A
|
||||
|
||||
@@ -32,11 +32,15 @@ information:
|
||||
|
||||
The major difference is in the lifecycle of these:
|
||||
|
||||
* if the client action maps to a function, the function will simply be
|
||||
called when executing the action. The function can have no further
|
||||
* if the client action maps to a function, the function will be called
|
||||
when executing the action. The function can have no further
|
||||
interaction with the Web Client itself, although it can return an
|
||||
action which will be executed after it.
|
||||
|
||||
The function takes 2 parameters: the ActionManager calling it and
|
||||
the descriptor for the current action (the ``ir.actions.client``
|
||||
dictionary).
|
||||
|
||||
* if, on the other hand, the client action maps to a
|
||||
:js:class:`~openerp.web.Widget`, that
|
||||
:js:class:`~openerp.web.Widget` will be instantiated and added to
|
||||
@@ -51,7 +55,7 @@ object::
|
||||
// Registers the object 'openerp.web_dashboard.Widget' to the client
|
||||
// action tag 'board.home.widgets'
|
||||
instance.web.client_actions.add(
|
||||
'board.home.widgets', 'openerp.web_dashboard.Widget');
|
||||
'board.home.widgets', 'instance.web_dashboard.Widget');
|
||||
instance.web_dashboard.Widget = instance.web.Widget.extend({
|
||||
template: 'HomeWidget'
|
||||
});
|
||||
@@ -60,15 +64,15 @@ At this point, the generic :js:class:`~openerp.web.Widget` lifecycle
|
||||
takes over, the template is rendered, inserted in the client DOM,
|
||||
bound on the object's ``$el`` property and the object is started.
|
||||
|
||||
If the client action takes parameters, these parameters are passed in as a
|
||||
second positional parameter to the constructor::
|
||||
The second parameter to the constructor is the descriptor for the
|
||||
action itself, which contains any parameter provided::
|
||||
|
||||
init: function (parent, params) {
|
||||
init: function (parent, action) {
|
||||
// execute the Widget's init
|
||||
this._super(parent);
|
||||
// board.home.widgets only takes a single param, the identifier of the
|
||||
// res.widget object it should display. Store it for later
|
||||
this.widget_id = params.widget_id;
|
||||
this.widget_id = action.params.widget_id;
|
||||
}
|
||||
|
||||
More complex initialization (DOM manipulations, RPC requests, ...)
|
||||
@@ -82,9 +86,6 @@ method.
|
||||
code it should return a ``$.Deferred`` so callers know when it's
|
||||
ready for interaction.
|
||||
|
||||
Although generally speaking client actions are not really
|
||||
interacted with.
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
start: function () {
|
||||
@@ -93,7 +94,7 @@ method.
|
||||
// Simply read the res.widget object this action should display
|
||||
new instance.web.Model('res.widget').call(
|
||||
'read', [[this.widget_id], ['title']])
|
||||
.then(this.proxy('on_widget_loaded'));
|
||||
.then(this.proxy('on_widget_loaded'));
|
||||
}
|
||||
|
||||
The client action can then behave exactly as it wishes to within its
|
||||
|
||||
+12
-11
@@ -16,10 +16,9 @@ import sys, os
|
||||
# If extensions (or modules to document with autodoc) are in another directory,
|
||||
# add these directories to sys.path here. If the directory is relative to the
|
||||
# documentation root, use os.path.abspath to make it absolute, like shown here.
|
||||
#sys.path.insert(0, os.path.abspath('.'))
|
||||
sys.path.append(os.path.abspath('_themes'))
|
||||
sys.path.append(os.path.abspath('..'))
|
||||
sys.path.append(os.path.abspath('../openerp'))
|
||||
sys.path.insert(0, os.path.abspath('../addons'))
|
||||
sys.path.insert(0, os.path.abspath('..'))
|
||||
|
||||
# -- General configuration -----------------------------------------------------
|
||||
|
||||
@@ -43,7 +42,7 @@ source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
|
||||
# General information about the project.
|
||||
project = u'OpenERP Server Developers Documentation'
|
||||
project = u'OpenERP Web Developers Documentation'
|
||||
copyright = u'2012, OpenERP s.a.'
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
@@ -53,7 +52,7 @@ copyright = u'2012, OpenERP s.a.'
|
||||
# The short X.Y version.
|
||||
version = '7.0'
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = '7.0b'
|
||||
release = '7.0'
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
@@ -171,7 +170,7 @@ html_sidebars = {
|
||||
#html_file_suffix = None
|
||||
|
||||
# Output file base name for HTML help builder.
|
||||
htmlhelp_basename = 'openerp-server-doc'
|
||||
htmlhelp_basename = 'openerp-web-doc'
|
||||
|
||||
|
||||
# -- Options for LaTeX output --------------------------------------------------
|
||||
@@ -190,7 +189,7 @@ latex_elements = {
|
||||
# Grouping the document tree into LaTeX files. List of tuples
|
||||
# (source start file, target name, title, author, documentclass [howto/manual]).
|
||||
latex_documents = [
|
||||
('index', 'openerp-server-doc.tex', u'OpenERP Server Developers Documentation',
|
||||
('index', 'openerp-web-doc.tex', u'OpenERP Web Developers Documentation',
|
||||
u'OpenERP s.a.', 'manual'),
|
||||
]
|
||||
|
||||
@@ -220,7 +219,7 @@ latex_documents = [
|
||||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
('index', 'openerp-server-doc', u'OpenERP Server Developers Documentation',
|
||||
('index', 'openerp-web-doc', u'OpenERP Web Developers Documentation',
|
||||
[u'OpenERP s.a.'], 1)
|
||||
]
|
||||
|
||||
@@ -234,8 +233,8 @@ man_pages = [
|
||||
# (source start file, target name, title, author,
|
||||
# dir menu entry, description, category)
|
||||
texinfo_documents = [
|
||||
('index', 'OpenERPServerDocumentation', u'OpenERP Server Developers Documentation',
|
||||
u'OpenERP s.a.', 'OpenERPServerDocumentation', 'Developers documentation for the openobject-server project.',
|
||||
('index', 'OpenERPWebDocumentation', u'OpenERP Web Developers Documentation',
|
||||
u'OpenERP s.a.', 'OpenERPWebDocumentation', 'Developers documentation for the openerp-web project.',
|
||||
'Miscellaneous'),
|
||||
]
|
||||
|
||||
@@ -248,10 +247,12 @@ texinfo_documents = [
|
||||
# How to display URL addresses: 'footnote', 'no', or 'inline'.
|
||||
#texinfo_show_urls = 'footnote'
|
||||
|
||||
todo_include_todos = True
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {
|
||||
'python': ('http://docs.python.org/', None),
|
||||
'openerpweb': ('http://doc.openerp.com/trunk/developers/web', None),
|
||||
'openerpserver': ('http://doc.openerp.com/trunk/developers/server', None),
|
||||
'openerpdev': ('http://doc.openerp.com/trunk/developers', None),
|
||||
'openerpcommand': ('http://doc.openerp.com/trunk/developers/command', None),
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 7.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -11,20 +11,22 @@ Contents:
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
presentation
|
||||
async
|
||||
|
||||
module
|
||||
widget
|
||||
qweb
|
||||
|
||||
async
|
||||
rpc
|
||||
qweb
|
||||
client_action
|
||||
form_view
|
||||
|
||||
testing
|
||||
|
||||
search_view
|
||||
list_view
|
||||
form_view
|
||||
|
||||
changelog-7.0
|
||||
|
||||
|
||||
Indices and tables
|
||||
==================
|
||||
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
Building an OpenERP Web module
|
||||
==============================
|
||||
|
||||
There is no significant distinction between an OpenERP Web module and
|
||||
an OpenERP module, the web part is mostly additional data and code
|
||||
inside a regular OpenERP module. This allows providing more seamless
|
||||
features by integrating your module deeper into the web client.
|
||||
|
||||
A Basic Module
|
||||
--------------
|
||||
|
||||
A very basic OpenERP module structure will be our starting point:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
web_example
|
||||
├── __init__.py
|
||||
└── __openerp__.py
|
||||
|
||||
.. literalinclude:: module/__openerp__.py
|
||||
:language: python
|
||||
|
||||
This is a sufficient minimal declaration of a valid OpenERP module.
|
||||
|
||||
Web Declaration
|
||||
---------------
|
||||
|
||||
There is no such thing as a "web module" declaration. An OpenERP
|
||||
module is automatically recognized as "web-enabled" if it contains a
|
||||
``static`` directory at its root, so:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
web_example
|
||||
├── __init__.py
|
||||
├── __openerp__.py
|
||||
└── static
|
||||
|
||||
is the extent of it. You should also change the dependency to list
|
||||
``web``:
|
||||
|
||||
.. literalinclude:: module/__openerp__.py.1.diff
|
||||
:language: diff
|
||||
|
||||
.. note::
|
||||
|
||||
This does not matter in normal operation so you may not realize
|
||||
it's wrong (the web module does the loading of everything else, so
|
||||
it can only be loaded), but when e.g. testing the loading process
|
||||
is slightly different than normal, and incorrect dependency may
|
||||
lead to broken code.
|
||||
|
||||
This makes the "web" discovery system consider the module as having a
|
||||
"web part", and check if it has web controllers to mount or javascript
|
||||
files to load. The content of the ``static/`` folder is also
|
||||
automatically made available to web browser at the URL
|
||||
``$module-name/static/$file-path``. This is sufficient to provide
|
||||
pictures (of cats, usually) through your module. However there are
|
||||
still a few more steps to running javascript code.
|
||||
|
||||
Getting Things Done
|
||||
-------------------
|
||||
|
||||
The first one is to add javascript code. It's customary to put it in
|
||||
``static/src/js``, to have room for e.g. other file types, or
|
||||
third-party libraries.
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js
|
||||
:language: javascript
|
||||
|
||||
The client won't load any file unless specified, thus the new file
|
||||
should be listed in the module's manifest file, under a new key ``js``
|
||||
(a list of file names, or glob patterns):
|
||||
|
||||
.. literalinclude:: module/__openerp__.py.2.diff
|
||||
:language: diff
|
||||
|
||||
At this point, if the module is installed and the client reloaded the
|
||||
message should appear in your browser's development console.
|
||||
|
||||
.. note::
|
||||
|
||||
Because the manifest file has been edited, you will have to
|
||||
restart the OpenERP server itself for it to be taken in account.
|
||||
|
||||
You may also want to open your browser's console *before*
|
||||
reloading, depending on the browser messages printed while the
|
||||
console is closed may not work or may not appear after opening it.
|
||||
|
||||
.. note::
|
||||
|
||||
If the message does not appear, try cleaning your browser's caches
|
||||
and ensure the file is correctly loaded from the server logs or
|
||||
the "resources" tab of your browser's developers tools.
|
||||
|
||||
At this point the code runs, but it runs only once when the module is
|
||||
initialized, and it can't get access to the various APIs of the web
|
||||
client (such as making RPC requests to the server). This is done by
|
||||
providing a `javascript module`_:
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js.1.diff
|
||||
:language: diff
|
||||
|
||||
If you reload the client, you'll see a message in the console exactly
|
||||
as previously. The differences, though invisible at this point, are:
|
||||
|
||||
* All javascript files specified in the manifest (only this one so
|
||||
far) have been fully loaded
|
||||
* An instance of the web client and a namespace inside that instance
|
||||
(with the same name as the module) have been created and are
|
||||
available for use
|
||||
|
||||
The latter point is what the ``instance`` parameter to the function
|
||||
provides: an instance of the OpenERP Web client, with the contents of
|
||||
all the new module's dependencies loaded in and initialized. These are
|
||||
the entry points to the web client's APIs.
|
||||
|
||||
To demonstrate, let's build a simple :doc:`client action
|
||||
<client_action>`: a stopwatch
|
||||
|
||||
First, the action declaration:
|
||||
|
||||
.. literalinclude:: module/__openerp__.py.3.diff
|
||||
:language: diff
|
||||
|
||||
.. literalinclude:: module/web_example.xml
|
||||
:language: xml
|
||||
|
||||
then set up the :doc:`client action hook <client_action>` to register
|
||||
a function (for now):
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js.2.diff
|
||||
:language: diff
|
||||
|
||||
Updating the module (in order to load the XML description) and
|
||||
re-starting the server should display a new menu *Example Client
|
||||
Action* at the top-level. Opening said menu will make the message
|
||||
appear, as usual, in the browser's console.
|
||||
|
||||
Paint it black
|
||||
--------------
|
||||
|
||||
The next step is to take control of the page itself, rather than just
|
||||
print little messages in the console. This we can do by replacing our
|
||||
client action function by a :doc:`widget`. Our widget will simply use
|
||||
its :js:func:`~openerp.web.Widget.start` to add some content to its
|
||||
DOM:
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js.3.diff
|
||||
:language: diff
|
||||
|
||||
after reloading the client (to update the javascript file), instead of
|
||||
printing to the console the menu item clears the whole screen and
|
||||
displays the specified message in the page.
|
||||
|
||||
Since we've added a class on the widget's :ref:`DOM root
|
||||
<widget-dom_root>` we can now see how to add a stylesheet to a module:
|
||||
first create the stylesheet file:
|
||||
|
||||
.. literalinclude:: module/static/src/css/web_example.css
|
||||
:language: css
|
||||
|
||||
then add a reference to the stylesheet in the module's manifest (which
|
||||
will require restarting the OpenERP Server to see the changes, as
|
||||
usual):
|
||||
|
||||
.. literalinclude:: module/__openerp__.py.4.diff
|
||||
:language: diff
|
||||
|
||||
the text displayed by the menu item should now be huge, and
|
||||
white-on-black (instead of small and black-on-white). From there on,
|
||||
the world's your canvas.
|
||||
|
||||
.. note::
|
||||
|
||||
Prefixing CSS rules with both ``.openerp`` (to ensure the rule
|
||||
will apply only within the confines of the OpenERP Web client) and
|
||||
a class at the root of your own hierarchy of widgets is strongly
|
||||
recommended to avoid "leaking" styles in case the code is running
|
||||
embedded in an other web page, and does not have the whole screen
|
||||
to itself.
|
||||
|
||||
So far we haven't built much (any, really) DOM content. It could all
|
||||
be done in :js:func:`~openerp.web.Widget.start` but that gets unwieldy
|
||||
and hard to maintain fast. It is also very difficult to extend by
|
||||
third parties (trying to add or change things in your widgets) unless
|
||||
broken up into multiple methods which each perform a little bit of the
|
||||
rendering.
|
||||
|
||||
The first way to handle this method is to delegate the content to
|
||||
plenty of sub-widgets, which can be individually overridden. An other
|
||||
method [#DOM-building]_ is to use `a template
|
||||
<http://en.wikipedia.org/wiki/Web_template>`_ to render a widget's
|
||||
DOM.
|
||||
|
||||
OpenERP Web's template language is :doc:`qweb`. Although any
|
||||
templating engine can be used (e.g. `mustache
|
||||
<http://mustache.github.com/>`_ or `_.template
|
||||
<http://underscorejs.org/#template>`_) QWeb has important features
|
||||
which other template engines may not provide, and has special
|
||||
integration to OpenERP Web widgets.
|
||||
|
||||
Adding a template file is similar to adding a style sheet:
|
||||
|
||||
.. literalinclude:: module/static/src/xml/web_example.xml
|
||||
:language: xml
|
||||
|
||||
.. literalinclude:: module/__openerp__.py.5.diff
|
||||
:language: diff
|
||||
|
||||
The template can then easily be hooked in the widget:
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js.4.diff
|
||||
:language: diff
|
||||
|
||||
And finally the CSS can be altered to style the new (and more complex)
|
||||
template-generated DOM, rather than the code-generated one:
|
||||
|
||||
.. literalinclude:: module/static/src/css/web_example.css.1.diff
|
||||
:language: diff
|
||||
|
||||
.. note::
|
||||
|
||||
The last section of the CSS change is an example of "state
|
||||
classes": a CSS class (or set of classes) on the root of the
|
||||
widget, which is toggled when the state of the widget changes and
|
||||
can perform drastic alterations in rendering (usually
|
||||
showing/hiding various elements).
|
||||
|
||||
This pattern is both fairly simple (to read and understand) and
|
||||
efficient (because most of the hard work is pushed to the
|
||||
browser's CSS engine, which is usually highly optimized, and done
|
||||
in a single repaint after toggling the class).
|
||||
|
||||
The last step (until the next one) is to add some behavior and make
|
||||
our stopwatch watch. First hook some events on the buttons to toggle
|
||||
the widget's state:
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js.5.diff
|
||||
:language: diff
|
||||
|
||||
This demonstrates the use of the "events hash" and event delegation to
|
||||
declaratively handle events on the widget's DOM. And already changes
|
||||
the button displayed in the UI. Then comes some actual logic:
|
||||
|
||||
.. literalinclude:: module/static/src/js/first_module.js.6.diff
|
||||
:language: diff
|
||||
|
||||
* An initializer (the ``init`` method) is introduced to set-up a few
|
||||
internal variables: ``_start`` will hold the start of the timer (as
|
||||
a javascript Date object), and ``_watch`` will hold a ticker to
|
||||
update the interface regularly and display the "current time".
|
||||
|
||||
* ``update_counter`` is in charge of taking the time difference
|
||||
between "now" and ``_start``, formatting as ``HH:MM:SS`` and
|
||||
displaying the result on screen.
|
||||
|
||||
* ``watch_start`` is augmented to initialize ``_start`` with its value
|
||||
and set-up the update of the counter display every 33ms.
|
||||
|
||||
* ``watch_stop`` disables the updater, does a final update of the
|
||||
counter display and resets everything.
|
||||
|
||||
* Finally, because javascript Interval and Timeout objects execute
|
||||
"outside" the widget, they will keep going even after the widget has
|
||||
been destroyed (especially an issue with intervals as they repeat
|
||||
indefinitely). So ``_watch`` *must* be cleared when the widget is
|
||||
destroyed (then the ``_super`` must be called as well in order to
|
||||
perform the "normal" widget cleanup).
|
||||
|
||||
Starting and stopping the watch now works, and correctly tracks time
|
||||
since having started the watch, neatly formatted.
|
||||
|
||||
.. [#DOM-building] they are not alternative solutions: they work very
|
||||
well together. Templates are used to build "just
|
||||
DOM", sub-widgets are used to build DOM subsections
|
||||
*and* delegate part of the behavior (e.g. events
|
||||
handling).
|
||||
|
||||
.. _javascript module:
|
||||
http://addyosmani.com/resources/essentialjsdesignpatterns/book/#modulepatternjavascript
|
||||
@@ -0,0 +1,7 @@
|
||||
# __openerp__.py
|
||||
{
|
||||
'name': "Web Example",
|
||||
'description': "Basic example of a (future) web module",
|
||||
'category': 'Hidden',
|
||||
'depends': ['base'],
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- web_example/__openerp__.py
|
||||
+++ web_example/__openerp__.py
|
||||
@@ -1,7 +1,7 @@
|
||||
# __openerp__.py
|
||||
{
|
||||
'name': "Web Example",
|
||||
'description': "Basic example of a (future) web module",
|
||||
'category': 'Hidden',
|
||||
- 'depends': ['base'],
|
||||
+ 'depends': ['web'],
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
--- web_example/__openerp__.py
|
||||
+++ web_example/__openerp__.py
|
||||
@@ -1,7 +1,8 @@
|
||||
# __openerp__.py
|
||||
{
|
||||
'name': "Web Example",
|
||||
'description': "Basic example of a (future) web module",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
+ 'js': ['static/src/js/first_module.js'],
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
--- web_example/__openerp__.py
|
||||
+++ web_example/__openerp__.py
|
||||
@@ -1,8 +1,9 @@
|
||||
# __openerp__.py
|
||||
{
|
||||
'name': "Web Example",
|
||||
'description': "Basic example of a (future) web module",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
+ 'data': ['web_example.xml'],
|
||||
'js': ['static/src/js/first_module.js'],
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
--- web_example/__openerp__.py
|
||||
+++ web_example/__openerp__.py
|
||||
@@ -1,9 +1,10 @@
|
||||
# __openerp__.py
|
||||
{
|
||||
'name': "Web Example",
|
||||
'description': "Basic example of a (future) web module",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
'data': ['web_example.xml'],
|
||||
'js': ['static/src/js/first_module.js'],
|
||||
+ 'css': ['static/src/css/web_example.css'],
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
--- web_example/__openerp__.py
|
||||
+++ web_example/__openerp__.py
|
||||
@@ -1,10 +1,11 @@
|
||||
# __openerp__.py
|
||||
{
|
||||
'name': "Web Example",
|
||||
'description': "Basic example of a (future) web module",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
'data': ['web_example.xml'],
|
||||
'js': ['static/src/js/first_module.js'],
|
||||
'css': ['static/src/css/web_example.css'],
|
||||
+ 'qweb': ['static/src/xml/web_example.xml'],
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.openerp .oe_web_example {
|
||||
color: white;
|
||||
background-color: black;
|
||||
height: 100%;
|
||||
font-size: 400%;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
--- web_example/static/src/css/web_example.css
|
||||
+++ web_example/static/src/css/web_example.css
|
||||
@@ -1,6 +1,13 @@
|
||||
.openerp .oe_web_example {
|
||||
color: white;
|
||||
background-color: black;
|
||||
height: 100%;
|
||||
- font-size: 400%;
|
||||
}
|
||||
+.openerp .oe_web_example h4 {
|
||||
+ margin: 0;
|
||||
+ font-size: 200%;
|
||||
+}
|
||||
+.openerp .oe_web_example.oe_web_example_started .oe_web_example_start button,
|
||||
+.openerp .oe_web_example.oe_web_example_stopped .oe_web_example_stop button {
|
||||
+ display: none
|
||||
+}
|
||||
@@ -0,0 +1,2 @@
|
||||
// static/src/js/first_module.js
|
||||
console.log("Debug statement: file loaded");
|
||||
@@ -0,0 +1,8 @@
|
||||
--- web_example/static/src/js/first_module.js
|
||||
+++ web_example/static/src/js/first_module.js
|
||||
@@ -1,2 +1,4 @@
|
||||
// static/src/js/first_module.js
|
||||
-console.log("Debug statement: file loaded");
|
||||
+openerp.web_example = function (instance) {
|
||||
+ console.log("Module loaded");
|
||||
+};
|
||||
@@ -0,0 +1,11 @@
|
||||
--- web_example/static/src/js/first_module.js
|
||||
+++ web_example/static/src/js/first_module.js
|
||||
@@ -1,4 +1,7 @@
|
||||
// static/src/js/first_module.js
|
||||
openerp.web_example = function (instance) {
|
||||
- console.log("Module loaded");
|
||||
+ instance.web.client_actions.add('example.action', 'instance.web_example.action');
|
||||
+ instance.web_example.action = function (parent, action) {
|
||||
+ console.log("Executed the action", action);
|
||||
+ };
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
--- web_example/static/src/js/first_module.js
|
||||
+++ web_example/static/src/js/first_module.js
|
||||
@@ -1,7 +1,11 @@
|
||||
// static/src/js/first_module.js
|
||||
openerp.web_example = function (instance) {
|
||||
- instance.web.client_actions.add('example.action', 'instance.web_example.action');
|
||||
- instance.web_example.action = function (parent, action) {
|
||||
- console.log("Executed the action", action);
|
||||
- };
|
||||
+ instance.web.client_actions.add('example.action', 'instance.web_example.Action');
|
||||
+ instance.web_example.Action = instance.web.Widget.extend({
|
||||
+ className: 'oe_web_example',
|
||||
+ start: function () {
|
||||
+ this.$el.text("Hello, world!");
|
||||
+ return this._super();
|
||||
+ }
|
||||
+ });
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
--- web_example/static/src/js/first_module.js
|
||||
+++ web_example/static/src/js/first_module.js
|
||||
@@ -1,11 +1,7 @@
|
||||
// static/src/js/first_module.js
|
||||
openerp.web_example = function (instance) {
|
||||
instance.web.client_actions.add('example.action', 'instance.web_example.Action');
|
||||
instance.web_example.Action = instance.web.Widget.extend({
|
||||
+ template: 'web_example.action'
|
||||
- className: 'oe_web_example',
|
||||
- start: function () {
|
||||
- this.$el.text("Hello, world!");
|
||||
- return this._super();
|
||||
- }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
--- web_example/static/src/js/first_module.js
|
||||
+++ web_example/static/src/js/first_module.js
|
||||
@@ -1,7 +1,19 @@
|
||||
// static/src/js/first_module.js
|
||||
openerp.web_example = function (instance) {
|
||||
instance.web.client_actions.add('example.action', 'instance.web_example.Action');
|
||||
instance.web_example.Action = instance.web.Widget.extend({
|
||||
- template: 'web_example.action'
|
||||
+ template: 'web_example.action',
|
||||
+ events: {
|
||||
+ 'click .oe_web_example_start button': 'watch_start',
|
||||
+ 'click .oe_web_example_stop button': 'watch_stop'
|
||||
+ },
|
||||
+ watch_start: function () {
|
||||
+ this.$el.addClass('oe_web_example_started')
|
||||
+ .removeClass('oe_web_example_stopped');
|
||||
+ },
|
||||
+ watch_stop: function () {
|
||||
+ this.$el.removeClass('oe_web_example_started')
|
||||
+ .addClass('oe_web_example_stopped');
|
||||
+ },
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
--- web_example/static/src/js/first_module.js
|
||||
+++ web_example/static/src/js/first_module.js
|
||||
@@ -1,19 +1,52 @@
|
||||
// static/src/js/first_module.js
|
||||
openerp.web_example = function (instance) {
|
||||
instance.web.client_actions.add('example.action', 'instance.web_example.Action');
|
||||
instance.web_example.Action = instance.web.Widget.extend({
|
||||
template: 'web_example.action',
|
||||
events: {
|
||||
'click .oe_web_example_start button': 'watch_start',
|
||||
'click .oe_web_example_stop button': 'watch_stop'
|
||||
},
|
||||
+ init: function () {
|
||||
+ this._super.apply(this, arguments);
|
||||
+ this._start = null;
|
||||
+ this._watch = null;
|
||||
+ },
|
||||
+ update_counter: function () {
|
||||
+ var h, m, s;
|
||||
+ // Subtracting javascript dates returns the difference in milliseconds
|
||||
+ var diff = new Date() - this._start;
|
||||
+ s = diff / 1000;
|
||||
+ m = Math.floor(s / 60);
|
||||
+ s -= 60*m;
|
||||
+ h = Math.floor(m / 60);
|
||||
+ m -= 60*h;
|
||||
+ this.$('.oe_web_example_timer').text(
|
||||
+ _.str.sprintf("%02d:%02d:%02d", h, m, s));
|
||||
+ },
|
||||
watch_start: function () {
|
||||
this.$el.addClass('oe_web_example_started')
|
||||
.removeClass('oe_web_example_stopped');
|
||||
+ this._start = new Date();
|
||||
+ // Update the UI to the current time
|
||||
+ this.update_counter();
|
||||
+ // Update the counter at 30 FPS (33ms/frame)
|
||||
+ this._watch = setInterval(
|
||||
+ this.proxy('update_counter'),
|
||||
+ 33);
|
||||
},
|
||||
watch_stop: function () {
|
||||
+ clearInterval(this._watch);
|
||||
+ this.update_counter();
|
||||
+ this._start = this._watch = null;
|
||||
this.$el.removeClass('oe_web_example_started')
|
||||
.addClass('oe_web_example_stopped');
|
||||
},
|
||||
+ destroy: function () {
|
||||
+ if (this._watch) {
|
||||
+ clearInterval(this._watch);
|
||||
+ }
|
||||
+ this._super();
|
||||
+ }
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
<templates>
|
||||
<div t-name="web_example.action" class="oe_web_example oe_web_example_stopped">
|
||||
<h4 class="oe_web_example_timer">00:00:00</h4>
|
||||
<p class="oe_web_example_start">
|
||||
<button type="button">Start</button>
|
||||
</p>
|
||||
<p class="oe_web_example_stop">
|
||||
<button type="button">Stop</button>
|
||||
</p>
|
||||
</div>
|
||||
</templates>
|
||||
@@ -0,0 +1,11 @@
|
||||
<!-- web_example/web_example.xml -->
|
||||
<openerp>
|
||||
<data>
|
||||
<record model="ir.actions.client" id="action_client_example">
|
||||
<field name="name">Example Client Action</field>
|
||||
<field name="tag">example.action</field>
|
||||
</record>
|
||||
<menuitem action="action_client_example"
|
||||
id="menu_client_example"/>
|
||||
</data>
|
||||
</openerp>
|
||||
@@ -35,7 +35,7 @@ More recommendations related to the specific case of the OpenERP Web Client:
|
||||
|
||||
* Use QWeb templates for html rendering.
|
||||
|
||||
* All css classes should have the prefix *oe-* .
|
||||
* All css classes should have the prefix *oe_* .
|
||||
|
||||
* Functions that call rpc() should return a deferred, even if it calls it indirectly. So a function that calls a function that calls a function that calls rpc() should return a deferred too.
|
||||
|
||||
|
||||
+527
-60
@@ -1,79 +1,546 @@
|
||||
|
||||
QWeb
|
||||
====
|
||||
|
||||
QWeb is the template engine used by the OpenERP Web Client. It is a home made engine create by OpenERP developers. There are a few things to note about it:
|
||||
QWeb is the template engine used by the OpenERP Web Client. It is an
|
||||
XML-based templating language, similar to `Genshi
|
||||
<http://en.wikipedia.org/wiki/Genshi_(templating_language)>`_,
|
||||
`Thymeleaf <http://en.wikipedia.org/wiki/Thymeleaf>`_ or `Facelets
|
||||
<http://en.wikipedia.org/wiki/Facelets>`_ with a few peculiarities:
|
||||
|
||||
* Template are rendered in javascript on the client-side, the server does nothing.
|
||||
* It is an xml template engine, like Facelets_ for example. The source file must be a valid xml.
|
||||
* Templates are not interpreted. There are compiled to javascript. This makes them a lot faster to render, but sometimes harder to debug.
|
||||
* Most of the time it is used through the Widget class, but you can also use it directly using *openerp.web.qweb.render()* .
|
||||
* It's implemented fully in javascript and rendered in the browser.
|
||||
* Each template file (XML files) contains multiple templates, where
|
||||
template engine usually have a 1:1 mapping between template files
|
||||
and templates.
|
||||
* It has special support in OpenERP Web's
|
||||
:class:`~instance.web.Widget`, though it can be used outside of
|
||||
OpenERP Web (and it's possible to use :class:`~instance.web.Widget`
|
||||
without relying on the QWeb integration).
|
||||
|
||||
.. _Facelets: http://en.wikipedia.org/wiki/Facelets
|
||||
The rationale behind using QWeb instead of a more popular template syntax is
|
||||
that its extension mechanism is very similar to the openerp view inheritance
|
||||
mechanism. Like openerp views a QWeb template is an xml tree and therefore
|
||||
xpath or dom manipulations are easy to performs on it.
|
||||
|
||||
Here is a typical QWeb file:
|
||||
Here's an example demonstrating most of the basic QWeb features:
|
||||
|
||||
::
|
||||
.. code-block:: xml
|
||||
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates>
|
||||
<t t-name="Template1">
|
||||
<div>...</div>
|
||||
</t>
|
||||
<t t-name="Template2">
|
||||
<div>...</div>
|
||||
</t>
|
||||
<div t-name="example_template" t-attf-class="base #{cls}">
|
||||
<h4 t-if="title"><t t-esc="title"/></h4>
|
||||
<ul>
|
||||
<li t-foreach="items" t-as="item" t-att-class="item_parity">
|
||||
<t t-call="example_template.sub">
|
||||
<t t-set="arg" t-value="item_value"/>
|
||||
</t>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<t t-name="example_template.sub">
|
||||
<t t-esc="arg.name"/>
|
||||
<dl>
|
||||
<t t-foreach="arg.tags" t-as="tag" t-if="tag_index lt 5">
|
||||
<dt><t t-esc="tag"/></dt>
|
||||
<dd><t t-esc="tag_value"/></dd>
|
||||
</t>
|
||||
</dl>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
A QWeb file contains multiple templates, they are simply identified by a name.
|
||||
rendered with the following context:
|
||||
|
||||
Here is a sample QWeb template:
|
||||
.. code-block:: json
|
||||
|
||||
::
|
||||
{
|
||||
"class1": "foo",
|
||||
"title": "Random Title",
|
||||
"items": [
|
||||
{ "name": "foo", "tags": {"bar": "baz", "qux": "quux"} },
|
||||
{ "name": "Lorem", "tags": {
|
||||
"ipsum": "dolor",
|
||||
"sit": "amet",
|
||||
"consectetur": "adipiscing",
|
||||
"elit": "Sed",
|
||||
"hendrerit": "ullamcorper",
|
||||
"ante": "id",
|
||||
"vestibulum": "Lorem",
|
||||
"ipsum": "dolor",
|
||||
"sit": "amet"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
<t t-name="UserPage">
|
||||
<div>
|
||||
<p>Name: <t t-esc="widget.user_name"/></p>
|
||||
<p>Password: <input type="text" t-att-value="widget.password"/></p>
|
||||
<p t-if="widget.is_admin">This user is an Administrator</p>
|
||||
<t t-foreach="widget.roles" t-as="role">
|
||||
<p>User has role: <t t-esc="role"/></p>
|
||||
</t>
|
||||
</div>
|
||||
</t>
|
||||
will yield this section of HTML document (reformated for readability):
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
*widget* is a variable given to the template engine by Widget sub-classes when they decide to render their associated template, it is simply *this*. Here is the corresponding Widget sub-class:
|
||||
|
||||
::
|
||||
|
||||
UserPageWidget = openerp.base.Widget.extend({
|
||||
template: "UserPage",
|
||||
init: function(parent) {
|
||||
this._super(parent);
|
||||
this.user_name = "Xavier";
|
||||
this.password = "lilo";
|
||||
this.is_admin = true;
|
||||
this.roles = ["Web Developer", "IE Hater", "Steve Jobs Worshiper"];
|
||||
},
|
||||
});
|
||||
|
||||
It could output something like this:
|
||||
|
||||
::
|
||||
|
||||
<div>
|
||||
<p>Name: Xavier</p>
|
||||
<p>Password: <input type="text" value="lilo"/></p>
|
||||
<p>This user is an Administrator</p
|
||||
<p>User has role: Web Developer</p>
|
||||
<p>User has role: IE Hater</p>
|
||||
<p>User has role: Steve Jobs Worshiper</p>
|
||||
<div class="base foo">
|
||||
<h4>Random Title</h4>
|
||||
<ul>
|
||||
<li class="even">
|
||||
foo
|
||||
<dl>
|
||||
<dt>bar</dt>
|
||||
<dd>baz</dd>
|
||||
<dt>qux</dt>
|
||||
<dd>quux</dd>
|
||||
</dl>
|
||||
</li>
|
||||
<li class="odd">
|
||||
Lorem
|
||||
<dl>
|
||||
<dt>ipsum</dt>
|
||||
<dd>dolor</dd>
|
||||
<dt>sit</dt>
|
||||
<dd>amet</dd>
|
||||
<dt>consectetur</dt>
|
||||
<dd>adipiscing</dd>
|
||||
<dt>elit</dt>
|
||||
<dd>Sed</dd>
|
||||
<dt>hendrerit</dt>
|
||||
<dd>ullamcorper</dd>
|
||||
</dl>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
A QWeb template should always contain one unique root element to be used effectively with the Widget class, here it is a *<div>*. QWeb only react to *<t>* elements or attributes prefixed by *t-*. The *<t>* is simply a null element, it is only used when you need to use a *t-* attribute without outputting an html element at the same time. Here are the effects of the most common QWeb attributes:
|
||||
API
|
||||
---
|
||||
|
||||
* *t-esc* outputs the result of the evaluation of the given javascript expression
|
||||
* *t-att-ATTR* sets the value of the *ATTR* attribute to the result of the evaluation of the given javascript expression
|
||||
* *t-if* outputs the element and its content only if the given javascript expression returns true
|
||||
* *t-foreach* outputs as many times as contained in the list returned by the given javascript expression. For each iteration, a variable with the name defined by *t-as* contains the current element in the list.
|
||||
While QWeb implements a number of attributes and methods for
|
||||
customization and configuration, only two things are really important
|
||||
to the user:
|
||||
|
||||
.. js:class:: QWeb2.Engine
|
||||
|
||||
The QWeb "renderer", handles most of QWeb's logic (loading,
|
||||
parsing, compiling and rendering templates).
|
||||
|
||||
OpenERP Web instantiates one for the user, and sets it to
|
||||
``instance.web.qweb``. It also loads all the template files of the
|
||||
various modules into that QWeb instance.
|
||||
|
||||
A :js:class:`QWeb2.Engine` also serves as a "template namespace".
|
||||
|
||||
.. js:function:: QWeb2.Engine.render(template[, context])
|
||||
|
||||
Renders a previously loaded template to a String, using
|
||||
``context`` (if provided) to find the variables accessed
|
||||
during template rendering (e.g. strings to display).
|
||||
|
||||
:param String template: the name of the template to render
|
||||
:param Object context: the basic namespace to use for template
|
||||
rendering
|
||||
:returns: String
|
||||
|
||||
The engine exposes an other method which may be useful in some
|
||||
cases (e.g. if you need a separate template namespace with, in
|
||||
OpenERP Web, Kanban views get their own :js:class:`QWeb2.Engine`
|
||||
instance so their templates don't collide with more general
|
||||
"module" templates):
|
||||
|
||||
.. js:function:: QWeb2.Engine.add_template(templates)
|
||||
|
||||
Loads a template file (a collection of templates) in the QWeb
|
||||
instance. The templates can be specified as:
|
||||
|
||||
An XML string
|
||||
QWeb will attempt to parse it to an XML document then load
|
||||
it.
|
||||
|
||||
A URL
|
||||
QWeb will attempt to download the URL content, then load
|
||||
the resulting XML string.
|
||||
|
||||
A ``Document`` or ``Node``
|
||||
QWeb will traverse the first level of the document (the
|
||||
child nodes of the provided root) and load any named
|
||||
template or template override.
|
||||
|
||||
:type templates: String | Document | Node
|
||||
|
||||
A :js:class:`QWeb2.Engine` also exposes various attributes for
|
||||
behavior customization:
|
||||
|
||||
.. js:attribute:: QWeb2.Engine.prefix
|
||||
|
||||
Prefix used to recognize :ref:`directives <qweb-directives>`
|
||||
during parsing. A string. By default, ``t``.
|
||||
|
||||
.. js:attribute:: QWeb2.Engine.debug
|
||||
|
||||
Boolean flag putting the engine in "debug mode". Normally,
|
||||
QWeb intercepts any error raised during template execution. In
|
||||
debug mode, it leaves all exceptions go through without
|
||||
intercepting them.
|
||||
|
||||
.. js:attribute:: QWeb2.Engine.jQuery
|
||||
|
||||
The jQuery instance used during :ref:`template inheritance
|
||||
<qweb-directives-inheritance>` processing. Defaults to
|
||||
``window.jQuery``.
|
||||
|
||||
.. js:attribute:: QWeb2.Engine.preprocess_node
|
||||
|
||||
A ``Function``. If present, called before compiling each DOM
|
||||
node to template code. In OpenERP Web, this is used to
|
||||
automatically translate text content and some attributes in
|
||||
templates. Defaults to ``null``.
|
||||
|
||||
.. _qweb-directives:
|
||||
|
||||
Directives
|
||||
----------
|
||||
|
||||
A basic QWeb template is nothing more than an XHTML document (as it
|
||||
must be valid XML), which will be output as-is. But the rendering can
|
||||
be customized with bits of logic called "directives". Directives are
|
||||
attributes elements prefixed by :js:attr:`~QWeb2.Engine.prefix` (this
|
||||
document will use the default prefix ``t``, as does OpenERP Web).
|
||||
|
||||
A directive will usually control or alter the output of the element it
|
||||
is set on. If no suitable element is available, the prefix itself can
|
||||
be used as a "no-operation" element solely for supporting directives
|
||||
(or internal content, which will be rendered). This means:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<t>Something something</t>
|
||||
|
||||
will simply output the string "Something something" (the element
|
||||
itself will be skipped and "unwrapped"):
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
var e = new QWeb2.Engine();
|
||||
e.add_template('<templates>\
|
||||
<t t-name="test1"><t>Test 1</t></t>\
|
||||
<t t-name="test2"><span>Test 2</span></t>\
|
||||
</templates>');
|
||||
e.render('test1'); // Test 1
|
||||
e.render('test2'); // <span>Test 2</span>
|
||||
|
||||
.. note::
|
||||
|
||||
The conventions used in directive descriptions are the following:
|
||||
|
||||
* directives are described as compound functions, potentially with
|
||||
optional sections. Each section of the function name is an
|
||||
attribute of the element bearing the directive.
|
||||
|
||||
* a special parameter is ``BODY``, which does not have a name and
|
||||
designates the content of the element.
|
||||
|
||||
* special parameter types (aside from ``BODY`` which remains
|
||||
untyped) are ``Name``, which designates a valid javascript
|
||||
variable name, ``Expression`` which designates a valid
|
||||
javascript expression, and ``Format`` which designates a
|
||||
Ruby-style format string (a literal string with
|
||||
``#{Expression}`` inclusions executed and replaced by their
|
||||
result)
|
||||
|
||||
.. note::
|
||||
|
||||
``Expression`` actually supports a few extensions on the
|
||||
javascript syntax: because some syntactic elements of javascript
|
||||
are not compatible with XML and must be escaped, text
|
||||
substitutions are performed from forms which don't need to be
|
||||
escaped. Thus the following "keyword operators" are available in
|
||||
an ``Expression``: ``and`` (maps to ``&&``), ``or`` (maps to
|
||||
``||``), ``gt`` (maps to ``>``), ``gte`` (maps to ``>=``), ``lt``
|
||||
(maps to ``<``) and ``lte`` (maps to ``<=``).
|
||||
|
||||
.. _qweb-directives-templates:
|
||||
|
||||
Defining Templates
|
||||
++++++++++++++++++
|
||||
|
||||
.. _qweb-directive-name:
|
||||
|
||||
.. function:: t-name=name
|
||||
|
||||
:param String name: an arbitrary javascript string. Each template
|
||||
name is unique in a given
|
||||
:js:class:`QWeb2.Engine` instance, defining a
|
||||
new template with an existing name will
|
||||
overwrite the previous one without warning.
|
||||
|
||||
When multiple templates are related, it is
|
||||
customary to use dotted names as a kind of
|
||||
"namespace" e.g. ``foo`` and ``foo.bar`` which
|
||||
will be used either by ``foo`` or by a
|
||||
sub-widget of the widget used by ``foo``.
|
||||
|
||||
Templates can only be defined as the children of the document
|
||||
root. The document root's name is irrelevant (it's not checked)
|
||||
but is usually ``<templates>`` for simplicity.
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<templates>
|
||||
<t t-name="template1">
|
||||
<!-- template code -->
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
:ref:`t-name <qweb-directive-name>` can be used on an element with
|
||||
an output as well:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<templates>
|
||||
<div t-name="template2">
|
||||
<!-- template code -->
|
||||
</div>
|
||||
</templates>
|
||||
|
||||
which ensures the template has a single root (if a template has
|
||||
multiple roots and is then passed directly to jQuery, odd things
|
||||
occur).
|
||||
|
||||
.. _qweb-directives-output:
|
||||
|
||||
Output
|
||||
++++++
|
||||
|
||||
.. _qweb-directive-esc:
|
||||
|
||||
.. function:: t-esc=content
|
||||
|
||||
:param Expression content:
|
||||
|
||||
Evaluates, html-escapes and outputs ``content``.
|
||||
|
||||
.. _qweb-directive-escf:
|
||||
|
||||
.. function:: t-escf=content
|
||||
|
||||
:param Format content:
|
||||
|
||||
Similar to :ref:`t-esc <qweb-directive-esc>` but evaluates a
|
||||
``Format`` instead of just an expression.
|
||||
|
||||
.. _qweb-directive-raw:
|
||||
|
||||
.. function:: t-raw=content
|
||||
|
||||
:param Expression content:
|
||||
|
||||
Similar to :ref:`t-esc <qweb-directive-esc>` but does *not*
|
||||
html-escape the result of evaluating ``content``. Should only ever
|
||||
be used for known-secure content, or will be an XSS attack vector.
|
||||
|
||||
.. _qweb-directive-rawf:
|
||||
|
||||
.. function:: t-rawf=content
|
||||
|
||||
:param Format content:
|
||||
|
||||
``Format``-based version of :ref:`t-raw <qweb-directive-raw>`.
|
||||
|
||||
.. _qweb-directive-att:
|
||||
|
||||
.. function:: t-att=map
|
||||
|
||||
:param Expression map:
|
||||
|
||||
Evaluates ``map`` expecting an ``Object`` result, sets each
|
||||
key:value pair as an attribute (and its value) on the holder
|
||||
element:
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<span t-att="{foo: 3, bar: 42}"/>
|
||||
|
||||
will yield
|
||||
|
||||
.. code-block:: html
|
||||
|
||||
<span foo="3" bar="42"/>
|
||||
|
||||
.. function:: t-att-ATTNAME=value
|
||||
|
||||
:param Name ATTNAME:
|
||||
:param Expression value:
|
||||
|
||||
Evaluates ``value`` and sets it on the attribute ``ATTNAME`` on
|
||||
the holder element.
|
||||
|
||||
If ``value``'s result is ``undefined``, suppresses the creation of
|
||||
the attribute.
|
||||
|
||||
.. _qweb-directive-attf:
|
||||
|
||||
.. function:: t-attf-ATTNAME=value
|
||||
|
||||
:param Name ATTNAME:
|
||||
:param Format value:
|
||||
|
||||
Similar to :ref:`t-att-* <qweb-directive-att>` but the value of
|
||||
the attribute is specified via a ``Format`` instead of an
|
||||
expression. Useful for specifying e.g. classes mixing literal
|
||||
classes and computed ones.
|
||||
|
||||
.. _qweb-directives-flow:
|
||||
|
||||
Flow Control
|
||||
++++++++++++
|
||||
|
||||
.. _qweb-directive-set:
|
||||
|
||||
.. function:: t-set=name (t-value=value | BODY)
|
||||
|
||||
:param Name name:
|
||||
:param Expression value:
|
||||
:param BODY:
|
||||
|
||||
Creates a new binding in the template context. If ``value`` is
|
||||
specified, evaluates it and sets it to the specified
|
||||
``name``. Otherwise, processes ``BODY`` and uses that instead.
|
||||
|
||||
.. _qweb-directive-if:
|
||||
|
||||
.. function:: t-if=condition
|
||||
|
||||
:param Expression condition:
|
||||
|
||||
Evaluates ``condition``, suppresses the output of the holder
|
||||
element and its content of the result is falsy.
|
||||
|
||||
.. _qweb-directive-foreach:
|
||||
|
||||
.. function:: t-foreach=iterable [t-as=name]
|
||||
|
||||
:param Expression iterable:
|
||||
:param Name name:
|
||||
|
||||
Evaluates ``iterable``, iterates on it and evaluates the holder
|
||||
element and its body once per iteration round.
|
||||
|
||||
If ``name`` is not specified, computes a ``name`` based on
|
||||
``iterable`` (by replacing non-``Name`` characters by ``_``).
|
||||
|
||||
If ``iterable`` yields a ``Number``, treats it as a range from 0
|
||||
to that number (excluded).
|
||||
|
||||
While iterating, :ref:`t-foreach <qweb-directive-foreach>` adds a
|
||||
number of variables in the context:
|
||||
|
||||
``#{name}``
|
||||
If iterating on an array (or a range), the current value in
|
||||
the iteration. If iterating on an *object*, the current key.
|
||||
``#{name}_all``
|
||||
The collection being iterated (the array generated for a
|
||||
``Number``)
|
||||
``#{name}_value``
|
||||
The current iteration value (current item for an array, value
|
||||
for the current item for an object)
|
||||
``#{name}_index``
|
||||
The 0-based index of the current iteration round.
|
||||
``#{name}_first``
|
||||
Whether the current iteration round is the first one.
|
||||
``#{name}_parity``
|
||||
``"odd"`` if the current iteration round is odd, ``"even"``
|
||||
otherwise. ``0`` is considered even.
|
||||
|
||||
.. _qweb-directive-call:
|
||||
|
||||
.. function:: t-call=template [BODY]
|
||||
|
||||
:param String template:
|
||||
:param BODY:
|
||||
|
||||
Calls the specified ``template`` and returns its result. If
|
||||
``BODY`` is specified, it is evaluated *before* calling
|
||||
``template`` and can be used to specify e.g. parameters. This
|
||||
usage is similar to `call-template with with-param in XSLT
|
||||
<http://zvon.org/xxl/XSLTreference/OutputOverview/xslt_with-param_frame.html>`_.
|
||||
|
||||
.. _qweb-directives-inheritance:
|
||||
|
||||
Template Inheritance and Extension
|
||||
++++++++++++++++++++++++++++++++++
|
||||
|
||||
.. _qweb-directive-extend:
|
||||
|
||||
.. function:: t-extend=template BODY
|
||||
|
||||
:param String template: name of the template to extend
|
||||
|
||||
Works similarly to OpenERP models: if used on its own, will alter
|
||||
the specified template in-place; if used in conjunction with
|
||||
:ref:`t-name <qweb-directive-name>` will create a new template
|
||||
using the old one as a base.
|
||||
|
||||
``BODY`` should be a sequence of :ref:`t-jquery
|
||||
<qweb-directive-jquery>` alteration directives.
|
||||
|
||||
.. note::
|
||||
|
||||
The inheritance in the second form is *static*: the parent
|
||||
template is copied and transformed when :ref:`t-extend
|
||||
<qweb-directive-extend>` is called. If it is altered later (by
|
||||
a :ref:`t-extend <qweb-directive-extend>` without a
|
||||
:ref:`t-name <qweb-directive-name>`), these changes will *not*
|
||||
appear in the "child" templates.
|
||||
|
||||
.. _qweb-directive-jquery:
|
||||
|
||||
.. function:: t-jquery=selector [t-operation=operation] BODY
|
||||
|
||||
:param String selector: a CSS selector into the parent template
|
||||
:param operation: one of ``append``, ``prepend``, ``before``,
|
||||
``after``, ``inner`` or ``replace``.
|
||||
:param BODY: ``operation`` argument, or alterations to perform
|
||||
|
||||
* If ``operation`` is specified, applies the selector to the
|
||||
parent template to find a *context node*, then applies
|
||||
``operation`` (as a jQuery operation) to the *context node*,
|
||||
passing ``BODY`` as parameter.
|
||||
|
||||
.. note::
|
||||
|
||||
``replace`` maps to jQuery's `replaceWith(newContent)
|
||||
<http://api.jquery.com/replaceWith/>`_, ``inner`` maps to
|
||||
`html(htmlString) <http://api.jquery.com/html/>`_.
|
||||
|
||||
* If ``operation`` is not provided, ``BODY`` is evaluated as
|
||||
javascript code, with the *context node* as ``this``.
|
||||
|
||||
.. warning::
|
||||
|
||||
While this second form is much more powerful than the first,
|
||||
it is also much harder to read and maintain and should be
|
||||
avoided. It is usually possible to either avoid it or
|
||||
replace it with a sequence of ``t-jquery:t-operation:``.
|
||||
|
||||
Escape Hatches / debugging
|
||||
++++++++++++++++++++++++++
|
||||
|
||||
.. _qweb-directive-log:
|
||||
|
||||
.. function:: t-log=expression
|
||||
|
||||
:param Expression expression:
|
||||
|
||||
Evaluates the provided expression (in the current template
|
||||
context) and logs its result via ``console.log``.
|
||||
|
||||
.. _qweb-directive-debug:
|
||||
|
||||
.. function:: t-debug
|
||||
|
||||
Injects a debugger breakpoint (via the ``debugger;`` statement) in
|
||||
the compiled template output.
|
||||
|
||||
.. _qweb-directive-js:
|
||||
|
||||
.. function:: t-js=context BODY
|
||||
|
||||
:param Name context:
|
||||
:param BODY: javascript code
|
||||
|
||||
Injects the provided ``BODY`` javascript code into the compiled
|
||||
template, passing it the current template context using the name
|
||||
specified by ``context``.
|
||||
|
||||
@@ -257,16 +257,17 @@ method you want to call) and a mapping of attributes to values (applied
|
||||
as keyword arguments on the Python method [#]_). This function fetches
|
||||
the return value of the Python methods, converted to JSON.
|
||||
|
||||
For instance, to call the ``eval_domain_and_context`` of the
|
||||
:class:`~web.controllers.main.Session` controller:
|
||||
For instance, to call the ``resequence`` of the
|
||||
:class:`~web.controllers.main.DataSet` controller:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
openerp.connection.rpc('/web/session/eval_domain_and_context', {
|
||||
domains: ds,
|
||||
contexts: cs
|
||||
openerp.connection.rpc('/web/dataset/resequence', {
|
||||
model: some_model,
|
||||
ids: array_of_ids,
|
||||
offset: 42
|
||||
}).then(function (result) {
|
||||
// handle result
|
||||
// resequenced on server
|
||||
});
|
||||
|
||||
.. [#] with a small twist: SQLAlchemy's ``orm.query.Query.group_by``
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
test_empty_find (openerp.addons.web.tests.test_dataset.TestDataSetController) ... ok
|
||||
test_ids_shortcut (openerp.addons.web.tests.test_dataset.TestDataSetController) ... ok
|
||||
test_regular_find (openerp.addons.web.tests.test_dataset.TestDataSetController) ... ok
|
||||
web.testing.stack: direct, value, success ... ok
|
||||
web.testing.stack: direct, deferred, success ... ok
|
||||
web.testing.stack: direct, value, error ... ok
|
||||
web.testing.stack: direct, deferred, failure ... ok
|
||||
web.testing.stack: successful setup ... ok
|
||||
web.testing.stack: successful teardown ... ok
|
||||
web.testing.stack: successful setup and teardown ... ok
|
||||
|
||||
[snip ~150 lines]
|
||||
|
||||
test_convert_complex_context (openerp.addons.web.tests.test_view.DomainsAndContextsTest) ... ok
|
||||
test_convert_complex_domain (openerp.addons.web.tests.test_view.DomainsAndContextsTest) ... ok
|
||||
test_convert_literal_context (openerp.addons.web.tests.test_view.DomainsAndContextsTest) ... ok
|
||||
test_convert_literal_domain (openerp.addons.web.tests.test_view.DomainsAndContextsTest) ... ok
|
||||
test_retrieve_nonliteral_context (openerp.addons.web.tests.test_view.DomainsAndContextsTest) ... ok
|
||||
test_retrieve_nonliteral_domain (openerp.addons.web.tests.test_view.DomainsAndContextsTest) ... ok
|
||||
|
||||
----------------------------------------------------------------------
|
||||
Ran 181 tests in 15.706s
|
||||
|
||||
OK
|
||||
|
||||
@@ -0,0 +1,693 @@
|
||||
.. highlight:: javascript
|
||||
|
||||
Testing in OpenERP Web
|
||||
======================
|
||||
|
||||
Javascript Unit Testing
|
||||
-----------------------
|
||||
|
||||
OpenERP Web 7.0 includes means to unit-test both the core code of
|
||||
OpenERP Web and your own javascript modules. On the javascript side,
|
||||
unit-testing is based on QUnit_ with a number of helpers and
|
||||
extensions for better integration with OpenERP.
|
||||
|
||||
To see what the runner looks like, find (or start) an OpenERP server
|
||||
with the web client enabled, and navigate to ``/web/tests`` e.g. `on
|
||||
OpenERP's CI <http://trunk.runbot.openerp.com/web/tests>`_. This will
|
||||
show the runner selector, which lists all modules with javascript unit
|
||||
tests, and allows starting any of them (or all javascript tests in all
|
||||
modules at once).
|
||||
|
||||
.. image:: ./images/runner.png
|
||||
:align: center
|
||||
|
||||
Clicking any runner button will launch the corresponding tests in the
|
||||
bundled QUnit_ runner:
|
||||
|
||||
.. image:: ./images/tests.png
|
||||
:align: center
|
||||
|
||||
Writing a test case
|
||||
-------------------
|
||||
|
||||
The first step is to list the test file(s). This is done through the
|
||||
``test`` key of the openerp manifest, by adding javascript files to it
|
||||
(next to the usual YAML files, if any):
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
'name': "Demonstration of web/javascript tests",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
'test': ['static/test/demo.js'],
|
||||
}
|
||||
|
||||
and to create the corresponding test file(s)
|
||||
|
||||
.. note::
|
||||
|
||||
Test files which do not exist will be ignored, if all test files
|
||||
of a module are ignored (can not be found), the test runner will
|
||||
consider that the module has no javascript tests.
|
||||
|
||||
After that, refreshing the runner selector will display the new module
|
||||
and allow running all of its (0 so far) tests:
|
||||
|
||||
.. image:: ./images/runner2.png
|
||||
:align: center
|
||||
|
||||
The next step is to create a test case::
|
||||
|
||||
openerp.testing.section('basic section', function (test) {
|
||||
test('my first test', function () {
|
||||
ok(false, "this test has run");
|
||||
});
|
||||
});
|
||||
|
||||
All testing helpers and structures live in the ``openerp.testing``
|
||||
module. OpenERP tests live in a :js:func:`~openerp.testing.section`,
|
||||
which is itself part of a module. The first argument to a section is
|
||||
the name of the section, the second one is the section body.
|
||||
|
||||
:js:func:`test <openerp.testing.case>`, provided by the
|
||||
:js:func:`~openerp.testing.section` to the callback, is used to
|
||||
register a given test case which will be run whenever the test runner
|
||||
actually does its job. OpenERP Web test case use standard `QUnit
|
||||
assertions`_ within them.
|
||||
|
||||
Launching the test runner at this point will run the test and display
|
||||
the corresponding assertion message, with red colors indicating the
|
||||
test failed:
|
||||
|
||||
.. image:: ./images/tests2.png
|
||||
:align: center
|
||||
|
||||
Fixing the test (by replacing ``false`` to ``true`` in the assertion)
|
||||
will make it pass:
|
||||
|
||||
.. image:: ./images/tests3.png
|
||||
:align: center
|
||||
|
||||
Assertions
|
||||
----------
|
||||
|
||||
As noted above, OpenERP Web's tests use `qunit assertions`_. They are
|
||||
available globally (so they can just be called without references to
|
||||
anything). The following list is available:
|
||||
|
||||
.. js:function:: ok(state[, message])
|
||||
|
||||
checks that ``state`` is truthy (in the javascript sense)
|
||||
|
||||
.. js:function:: strictEqual(actual, expected[, message])
|
||||
|
||||
checks that the actual (produced by a method being tested) and
|
||||
expected values are identical (roughly equivalent to ``ok(actual
|
||||
=== expected, message)``)
|
||||
|
||||
.. js:function:: notStrictEqual(actual, expected[, message])
|
||||
|
||||
checks that the actual and expected values are *not* identical
|
||||
(roughly equivalent to ``ok(actual !== expected, message)``)
|
||||
|
||||
.. js:function:: deepEqual(actual, expected[, message])
|
||||
|
||||
deep comparison between actual and expected: recurse into
|
||||
containers (objects and arrays) to ensure that they have the same
|
||||
keys/number of elements, and the values match.
|
||||
|
||||
.. js:function:: notDeepEqual(actual, expected[, message])
|
||||
|
||||
inverse operation to :js:func:`deepEqual`
|
||||
|
||||
.. js:function:: throws(block[, expected][, message])
|
||||
|
||||
checks that, when called, the ``block`` throws an
|
||||
error. Optionally validates that error against ``expected``.
|
||||
|
||||
:param Function block:
|
||||
:param expected: if a regexp, checks that the thrown error's
|
||||
message matches the regular expression. If an
|
||||
error type, checks that the thrown error is of
|
||||
that type.
|
||||
:type expected: Error | RegExp
|
||||
|
||||
.. js:function:: equal(actual, expected[, message])
|
||||
|
||||
checks that ``actual`` and ``expected`` are loosely equal, using
|
||||
the ``==`` operator and its coercion rules.
|
||||
|
||||
.. js:function:: notEqual(actual, expected[, message])
|
||||
|
||||
inverse operation to :js:func:`equal`
|
||||
|
||||
Getting an OpenERP instance
|
||||
---------------------------
|
||||
|
||||
The OpenERP instance is the base through which most OpenERP Web
|
||||
modules behaviors (functions, objects, …) are accessed. As a result,
|
||||
the test framework automatically builds one, and loads the module
|
||||
being tested and all of its dependencies inside it. This new instance
|
||||
is provided as the first positional parameter to your test
|
||||
cases. Let's observe by adding javascript code (not test code) to the
|
||||
test module:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
'name': "Demonstration of web/javascript tests",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
'js': ['static/src/js/demo.js'],
|
||||
'test': ['static/test/demo.js'],
|
||||
}
|
||||
|
||||
::
|
||||
|
||||
// src/js/demo.js
|
||||
openerp.web_tests_demo = function (instance) {
|
||||
instance.web_tests_demo = {
|
||||
value_true: true,
|
||||
SomeType: instance.web.Class.extend({
|
||||
init: function (value) {
|
||||
this.value = value;
|
||||
}
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
and then adding a new test case, which simply checks that the
|
||||
``instance`` contains all the expected stuff we created in the
|
||||
module::
|
||||
|
||||
// test/demo.js
|
||||
test('module content', function (instance) {
|
||||
ok(instance.web_tests_demo.value_true, "should have a true value");
|
||||
var type_instance = new instance.web_tests_demo.SomeType(42);
|
||||
strictEqual(type_instance.value, 42, "should have provided value");
|
||||
});
|
||||
|
||||
DOM Scratchpad
|
||||
--------------
|
||||
|
||||
As in the wider client, arbitrarily accessing document content is
|
||||
strongly discouraged during tests. But DOM access is still needed to
|
||||
e.g. fully initialize :js:class:`widgets <~openerp.web.Widget>` before
|
||||
testing them.
|
||||
|
||||
Thus, a test case gets a DOM scratchpad as its second positional
|
||||
parameter, in a jQuery instance. That scratchpad is fully cleaned up
|
||||
before each test, and as long as it doesn't do anything outside the
|
||||
scratchpad your code can do whatever it wants::
|
||||
|
||||
// test/demo.js
|
||||
test('DOM content', function (instance, $scratchpad) {
|
||||
$scratchpad.html('<div><span class="foo bar">ok</span></div>');
|
||||
ok($scratchpad.find('span').hasClass('foo'),
|
||||
"should have provided class");
|
||||
});
|
||||
test('clean scratchpad', function (instance, $scratchpad) {
|
||||
ok(!$scratchpad.children().length, "should have no content");
|
||||
ok(!$scratchpad.text(), "should have no text");
|
||||
});
|
||||
|
||||
.. note::
|
||||
|
||||
The top-level element of the scratchpad is not cleaned up, test
|
||||
cases can add text or DOM children but shoud not alter
|
||||
``$scratchpad`` itself.
|
||||
|
||||
Loading templates
|
||||
-----------------
|
||||
|
||||
To avoid the corresponding processing costs, by default templates are
|
||||
not loaded into QWeb. If you need to render e.g. widgets making use of
|
||||
QWeb templates, you can request their loading through the
|
||||
:js:attr:`~TestOptions.templates` option to the :js:func:`test case
|
||||
function <openerp.testing.case>`.
|
||||
|
||||
This will automatically load all relevant templates in the instance's
|
||||
qweb before running the test case:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
{
|
||||
'name': "Demonstration of web/javascript tests",
|
||||
'category': 'Hidden',
|
||||
'depends': ['web'],
|
||||
'js': ['static/src/js/demo.js'],
|
||||
'test': ['static/test/demo.js'],
|
||||
'qweb': ['static/src/xml/demo.xml'],
|
||||
}
|
||||
|
||||
.. code-block:: xml
|
||||
|
||||
<!-- src/xml/demo.xml -->
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="DemoTemplate">
|
||||
<t t-foreach="5" t-as="value">
|
||||
<p><t t-esc="value"/></p>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
::
|
||||
|
||||
// test/demo.js
|
||||
test('templates', {templates: true}, function (instance) {
|
||||
var s = instance.web.qweb.render('DemoTemplate');
|
||||
var texts = $(s).find('p').map(function () {
|
||||
return $(this).text();
|
||||
}).get();
|
||||
|
||||
deepEqual(texts, ['0', '1', '2', '3', '4']);
|
||||
});
|
||||
|
||||
Asynchronous cases
|
||||
------------------
|
||||
|
||||
The test case examples so far are all synchronous, they execute from
|
||||
the first to the last line and once the last line has executed the
|
||||
test is done. But the web client is full of :doc:`asynchronous code
|
||||
</async>`, and thus test cases need to be async-aware.
|
||||
|
||||
This is done by returning a :js:class:`deferred <Deferred>` from the
|
||||
case callback::
|
||||
|
||||
// test/demo.js
|
||||
test('asynchronous', {
|
||||
asserts: 1
|
||||
}, function () {
|
||||
var d = $.Deferred();
|
||||
setTimeout(function () {
|
||||
ok(true);
|
||||
d.resolve();
|
||||
}, 100);
|
||||
return d;
|
||||
});
|
||||
|
||||
This example also uses the :js:class:`options parameter <TestOptions>`
|
||||
to specify the number of assertions the case should expect, if less or
|
||||
more assertions are specified the case will count as failed.
|
||||
|
||||
Asynchronous test cases *must* specify the number of assertions they
|
||||
will run. This allows more easily catching situations where e.g. the
|
||||
test architecture was not warned about asynchronous operations.
|
||||
|
||||
.. note::
|
||||
|
||||
Asynchronous test cases also have a 2 seconds timeout: if the test
|
||||
does not finish within 2 seconds, it will be considered
|
||||
failed. This pretty much always means the test will not
|
||||
resolve. This timeout *only* applies to the test itself, not to
|
||||
the setup and teardown processes.
|
||||
|
||||
.. note::
|
||||
|
||||
If the returned deferred is rejected, the test will be failed
|
||||
unless :js:attr:`~TestOptions.fail_on_rejection` is set to
|
||||
``false``.
|
||||
|
||||
RPC
|
||||
---
|
||||
|
||||
An important subset of asynchronous test cases is test cases which
|
||||
need to perform (and chain, to an extent) RPC calls.
|
||||
|
||||
.. note::
|
||||
|
||||
Because they are a subset of asynchronous cases, RPC cases must
|
||||
also provide a valid :js:attr:`assertions count
|
||||
<TestOptions.asserts>`.
|
||||
|
||||
By default, test cases will fail when trying to perform an RPC
|
||||
call. The ability to perform RPC calls must be explicitly requested by
|
||||
a test case (or its containing test suite) through
|
||||
:js:attr:`~TestOptions.rpc`, and can be one of two modes: ``mock`` or
|
||||
``rpc``.
|
||||
|
||||
Mock RPC
|
||||
++++++++
|
||||
|
||||
The preferred (and fastest from a setup and execution time point of
|
||||
view) way to do RPC during tests is to mock the RPC calls: while
|
||||
setting up the test case, provide what the RPC responses "should" be,
|
||||
and only test the code between the "user" (the test itself) and the
|
||||
RPC call, before the call is effectively done.
|
||||
|
||||
To do this, set the :js:attr:`rpc option <TestOptions.rpc>` to
|
||||
``mock``. This will add a third parameter to the test case callback:
|
||||
|
||||
.. js:function:: mock(rpc_spec, handler)
|
||||
|
||||
Can be used in two different ways depending on the shape of the
|
||||
first parameter:
|
||||
|
||||
* If it matches the pattern ``model:method`` (if it contains a
|
||||
colon, essentially) the call will set up the mocking of an RPC
|
||||
call straight to the OpenERP server (through XMLRPC) as
|
||||
performed via e.g. :js:func:`openerp.web.Model.call`.
|
||||
|
||||
In that case, ``handler`` should be a function taking two
|
||||
arguments ``args`` and ``kwargs``, matching the corresponding
|
||||
arguments on the server side and should simply return the value
|
||||
as if it were returned by the Python XMLRPC handler::
|
||||
|
||||
test('XML-RPC', {rpc: 'mock', asserts: 3}, function (instance, $s, mock) {
|
||||
// set up mocking
|
||||
mock('people.famous:name_search', function (args, kwargs) {
|
||||
strictEqual(kwargs.name, 'bob');
|
||||
return [
|
||||
[1, "Microsoft Bob"],
|
||||
[2, "Bob the Builder"],
|
||||
[3, "Silent Bob"]
|
||||
];
|
||||
});
|
||||
|
||||
// actual test code
|
||||
return new instance.web.Model('people.famous')
|
||||
.call('name_search', {name: 'bob'}).then(function (result) {
|
||||
strictEqual(result.length, 3, "shoud return 3 people");
|
||||
strictEqual(result[0][1], "Microsoft Bob",
|
||||
"the most famous bob should be Microsoft Bob");
|
||||
});
|
||||
});
|
||||
|
||||
* Otherwise, if it matches an absolute path (e.g. ``/a/b/c``) it
|
||||
will mock a JSON-RPC call to a web client controller, such as
|
||||
``/web/webclient/translations``. In that case, the handler takes
|
||||
a single ``params`` argument holding all of the parameters
|
||||
provided over JSON-RPC.
|
||||
|
||||
As previously, the handler should simply return the result value
|
||||
as if returned by the original JSON-RPC handler::
|
||||
|
||||
test('JSON-RPC', {rpc: 'mock', asserts: 3, templates: true}, function (instance, $s, mock) {
|
||||
var fetched_dbs = false, fetched_langs = false;
|
||||
mock('/web/database/get_list', function () {
|
||||
fetched_dbs = true;
|
||||
return ['foo', 'bar', 'baz'];
|
||||
});
|
||||
mock('/web/session/get_lang_list', function () {
|
||||
fetched_langs = true;
|
||||
return [['vo_IS', 'Hopelandic / Vonlenska']];
|
||||
});
|
||||
|
||||
// widget needs that or it blows up
|
||||
instance.webclient = {toggle_bars: openerp.testing.noop};
|
||||
var dbm = new instance.web.DatabaseManager({});
|
||||
return dbm.appendTo($s).then(function () {
|
||||
ok(fetched_dbs, "should have fetched databases");
|
||||
ok(fetched_langs, "should have fetched languages");
|
||||
deepEqual(dbm.db_list, ['foo', 'bar', 'baz']);
|
||||
});
|
||||
});
|
||||
|
||||
.. note::
|
||||
|
||||
Mock handlers can contain assertions, these assertions should be
|
||||
part of the assertions count (and if multiple calls are made to a
|
||||
handler containing assertions, it multiplies the effective number
|
||||
of assertions).
|
||||
|
||||
.. _testing-rpc-rpc:
|
||||
|
||||
Actual RPC
|
||||
++++++++++
|
||||
|
||||
A more realistic (but significantly slower and more expensive) way to
|
||||
perform RPC calls is to perform actual calls to an actually running
|
||||
OpenERP server. To do this, set the :js:attr:`rpc option
|
||||
<~TestOptions.rpc>` to ``rpc``, it will not provide any new parameter
|
||||
but will enable actual RPC, and the automatic creation and destruction
|
||||
of databases (from a specified source) around tests.
|
||||
|
||||
First, create a basic model we can test stuff with:
|
||||
|
||||
.. code-block:: javascript
|
||||
|
||||
from openerp.osv import orm, fields
|
||||
|
||||
class TestObject(orm.Model):
|
||||
_name = 'web_tests_demo.model'
|
||||
|
||||
_columns = {
|
||||
'name': fields.char("Name", required=True),
|
||||
'thing': fields.char("Thing"),
|
||||
'other': fields.char("Other", required=True)
|
||||
}
|
||||
_defaults = {
|
||||
'other': "bob"
|
||||
}
|
||||
|
||||
then the actual test::
|
||||
|
||||
test('actual RPC', {rpc: 'rpc', asserts: 4}, function (instance) {
|
||||
var Model = new instance.web.Model('web_tests_demo.model');
|
||||
return Model.call('create', [{name: "Bob"}])
|
||||
.then(function (id) {
|
||||
return Model.call('read', [[id]]);
|
||||
}).then(function (records) {
|
||||
strictEqual(records.length, 1);
|
||||
var record = records[0];
|
||||
strictEqual(record.name, "Bob");
|
||||
strictEqual(record.thing, false);
|
||||
// default value
|
||||
strictEqual(record.other, 'bob');
|
||||
});
|
||||
});
|
||||
|
||||
This test looks like a "mock" RPC test but for the lack of mock
|
||||
response (and the different ``rpc`` type), however it has further
|
||||
ranging consequences in that it will copy an existing database to a
|
||||
new one, run the test in full on that temporary database and destroy
|
||||
the database, to simulate an isolated and transactional context and
|
||||
avoid affecting other tests. One of the consequences is that it takes
|
||||
a *long* time to run (5~10s, most of that time being spent waiting for
|
||||
a database duplication).
|
||||
|
||||
Furthermore, as the test needs to clone a database, it also has to ask
|
||||
which database to clone, the database/super-admin password and the
|
||||
password of the ``admin`` user (in order to authenticate as said
|
||||
user). As a result, the first time the test runner encounters an
|
||||
``rpc: "rpc"`` test configuration it will produce the following
|
||||
prompt:
|
||||
|
||||
.. image:: ./images/db-query.png
|
||||
:align: center
|
||||
|
||||
and stop the testing process until the necessary information has been
|
||||
provided.
|
||||
|
||||
The prompt will only appear once per test run, all tests will use the
|
||||
same "source" database.
|
||||
|
||||
.. note::
|
||||
|
||||
The handling of that information is currently rather brittle and
|
||||
unchecked, incorrect values will likely crash the runner.
|
||||
|
||||
.. note::
|
||||
|
||||
The runner does not currently store this information (for any
|
||||
longer than a test run that is), the prompt will have to be filled
|
||||
every time.
|
||||
|
||||
Testing API
|
||||
-----------
|
||||
|
||||
.. js:function:: openerp.testing.section(name[, options], body)
|
||||
|
||||
A test section, serves as shared namespace for related tests (for
|
||||
constants or values to only set up once). The ``body`` function
|
||||
should contain the tests themselves.
|
||||
|
||||
Note that the order in which tests are run is essentially
|
||||
undefined, do *not* rely on it.
|
||||
|
||||
:param String name:
|
||||
:param TestOptions options:
|
||||
:param body:
|
||||
:type body: Function<:js:func:`~openerp.testing.case`, void>
|
||||
|
||||
.. js:function:: openerp.testing.case(name[, options], callback)
|
||||
|
||||
Registers a test case callback in the test runner, the callback
|
||||
will only be run once the runner is started (or maybe not at all,
|
||||
if the test is filtered out).
|
||||
|
||||
:param String name:
|
||||
:param TestOptions options:
|
||||
:param callback:
|
||||
:type callback: Function<instance, $, Function<String, Function, void>>
|
||||
|
||||
.. js:class:: TestOptions
|
||||
|
||||
the various options which can be passed to
|
||||
:js:func:`~openerp.testing.section` or
|
||||
:js:func:`~openerp.testing.case`. Except for
|
||||
:js:attr:`~TestOptions.setup` and
|
||||
:js:attr:`~TestOptions.teardown`, an option on
|
||||
:js:func:`~openerp.testing.case` will overwrite the corresponding
|
||||
option on :js:func:`~openerp.testing.section` so
|
||||
e.g. :js:attr:`~TestOptions.rpc` can be set for a
|
||||
:js:func:`~openerp.testing.section` and then differently set for
|
||||
some :js:func:`~openerp.testing.case` of that
|
||||
:js:func:`~openerp.testing.section`
|
||||
|
||||
.. js:attribute:: TestOptions.asserts
|
||||
|
||||
An integer, the number of assertions which should run during a
|
||||
normal execution of the test. Mandatory for asynchronous tests.
|
||||
|
||||
.. js:attribute:: TestOptions.setup
|
||||
|
||||
Test case setup, run right before each test case. A section's
|
||||
:js:func:`~TestOptions.setup` is run before the case's own, if
|
||||
both are specified.
|
||||
|
||||
.. js:attribute:: TestOptions.teardown
|
||||
|
||||
Test case teardown, a case's :js:func:`~TestOptions.teardown`
|
||||
is run before the corresponding section if both are present.
|
||||
|
||||
.. js:attribute:: TestOptions.fail_on_rejection
|
||||
|
||||
If the test is asynchronous and its resulting promise is
|
||||
rejected, fail the test. Defaults to ``true``, set to
|
||||
``false`` to not fail the test in case of rejection::
|
||||
|
||||
// test/demo.js
|
||||
test('unfail rejection', {
|
||||
asserts: 1,
|
||||
fail_on_rejection: false
|
||||
}, function () {
|
||||
var d = $.Deferred();
|
||||
setTimeout(function () {
|
||||
ok(true);
|
||||
d.reject();
|
||||
}, 100);
|
||||
return d;
|
||||
});
|
||||
|
||||
.. js:attribute:: TestOptions.rpc
|
||||
|
||||
RPC method to use during tests, one of ``"mock"`` or
|
||||
``"rpc"``. Any other value will disable RPC for the test (if
|
||||
they were enabled by the suite for instance).
|
||||
|
||||
.. js:attribute:: TestOptions.templates
|
||||
|
||||
Whether the current module (and its dependencies)'s templates
|
||||
should be loaded into QWeb before starting the test. A
|
||||
boolean, ``false`` by default.
|
||||
|
||||
The test runner can also use two global configuration values set
|
||||
directly on the ``window`` object:
|
||||
|
||||
* ``oe_all_dependencies`` is an ``Array`` of all modules with a web
|
||||
component, ordered by dependency (for a module ``A`` with
|
||||
dependencies ``A'``, any module of ``A'`` must come before ``A`` in
|
||||
the array)
|
||||
|
||||
* ``oe_db_info`` is an object with 3 keys ``source``, ``supadmin`` and
|
||||
``password``. It is used to pre-configure :ref:`actual RPC
|
||||
<testing-rpc-rpc>` tests, to avoid a prompt being displayed
|
||||
(especially for headless situations).
|
||||
|
||||
Running through Python
|
||||
----------------------
|
||||
|
||||
The web client includes the means to run these tests on the
|
||||
command-line (or in a CI system), but while actually running it is
|
||||
pretty simple the setup of the pre-requisite parts has some
|
||||
complexities.
|
||||
|
||||
1. Install unittest2_ and QUnitSuite_ in your Python environment. Both
|
||||
can trivially be installed via `pip <http://pip-installer.org>`_ or
|
||||
`easy_install
|
||||
<http://packages.python.org/distribute/easy_install.html>`_.
|
||||
|
||||
The former is the unit-testing framework used by OpenERP, the
|
||||
latter is an adapter module to run qunit_ test suites and convert
|
||||
their result into something unittest2_ can understand and report.
|
||||
|
||||
2. Install PhantomJS_. It is a headless
|
||||
browser which allows automating running and testing web
|
||||
pages. QUnitSuite_ uses it to actually run the qunit_ test suite.
|
||||
|
||||
The PhantomJS_ website provides pre-built binaries for some
|
||||
platforms, and your OS's package management probably provides it as
|
||||
well.
|
||||
|
||||
If you're building PhantomJS_ from source, I recommend preparing
|
||||
for some knitting time as it's not exactly fast (it needs to
|
||||
compile both `Qt <http://qt-project.org/>`_ and `Webkit
|
||||
<http://www.webkit.org/>`_, both being pretty big projects).
|
||||
|
||||
.. note::
|
||||
|
||||
Because PhantomJS_ is webkit-based, it will not be able to test
|
||||
if Firefox, Opera or Internet Explorer can correctly run the
|
||||
test suite (and it is only an approximation for Safari and
|
||||
Chrome). It is therefore recommended to *also* run the test
|
||||
suites in actual browsers once in a while.
|
||||
|
||||
.. note::
|
||||
|
||||
The version of PhantomJS_ this was build through is 1.7,
|
||||
previous versions *should* work but are not actually supported
|
||||
(and tend to just segfault when something goes wrong in
|
||||
PhantomJS_ itself so they're a pain to debug).
|
||||
|
||||
3. Set up :ref:`OpenERP Command <openerpcommand:openerp-command>`,
|
||||
which will be used to actually run the tests: running the qunit_
|
||||
test suite requires a running server, so at this point OpenERP
|
||||
Server isn't able to do it on its own during the building/testing
|
||||
process.
|
||||
|
||||
4. Install a new database with all relevant modules (all modules with
|
||||
a web component at least), then restart the server
|
||||
|
||||
.. note::
|
||||
|
||||
For some tests, a source database needs to be duplicated. This
|
||||
operation requires that there be no connection to the database
|
||||
being duplicated, but OpenERP doesn't currently break
|
||||
existing/outstanding connections, so restarting the server is
|
||||
the simplest way to ensure everything is in the right state.
|
||||
|
||||
5. Launch ``oe run-tests -d $DATABASE -mweb`` with the correct
|
||||
addons-path specified (and replacing ``$DATABASE`` by the source
|
||||
database you created above)
|
||||
|
||||
.. note::
|
||||
|
||||
If you leave out ``-mweb``, the runner will attempt to run all
|
||||
the tests in all the modules, which may or may not work.
|
||||
|
||||
If everything went correctly, you should now see a list of tests with
|
||||
(hopefully) ``ok`` next to their names, closing with a report of the
|
||||
number of tests run and the time it took:
|
||||
|
||||
.. literalinclude:: test-report.txt
|
||||
:language: text
|
||||
|
||||
Congratulation, you have just performed a successful "offline" run of
|
||||
the OpenERP Web test suite.
|
||||
|
||||
.. note::
|
||||
|
||||
Note that this runs all the Python tests for the ``web`` module,
|
||||
but all the web tests for all of OpenERP. This can be surprising.
|
||||
|
||||
.. _qunit: http://qunitjs.com/
|
||||
|
||||
.. _qunit assertions: http://api.qunitjs.com/category/assert/
|
||||
|
||||
.. _unittest2: http://pypi.python.org/pypi/unittest2
|
||||
|
||||
.. _QUnitSuite: http://pypi.python.org/pypi/QUnitSuite/
|
||||
|
||||
.. _PhantomJS: http://phantomjs.org/
|
||||
@@ -33,6 +33,8 @@ view. It provides a number of services to handle a section of a page:
|
||||
|
||||
* Backbone-compatible shortcuts
|
||||
|
||||
.. _widget-dom_root:
|
||||
|
||||
DOM Root
|
||||
--------
|
||||
|
||||
|
||||
+28
-19
@@ -17,7 +17,6 @@ import tempfile
|
||||
import threading
|
||||
import time
|
||||
import traceback
|
||||
import urllib
|
||||
import urlparse
|
||||
import uuid
|
||||
import xmlrpclib
|
||||
@@ -33,7 +32,6 @@ import werkzeug.wsgi
|
||||
|
||||
import openerp
|
||||
|
||||
import nonliterals
|
||||
import session
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -95,12 +93,12 @@ class WebRequest(object):
|
||||
if not self.session:
|
||||
self.session = session.OpenERPSession()
|
||||
self.httpsession[self.session_id] = self.session
|
||||
self.context = self.params.pop('context', None)
|
||||
self.context = self.params.pop('context', {})
|
||||
self.debug = self.params.pop('debug', False) is not False
|
||||
# Determine self.lang
|
||||
lang = self.params.get('lang', None)
|
||||
if lang is None:
|
||||
lang = self.session.eval_context(self.context).get('lang')
|
||||
lang = self.context.get('lang')
|
||||
if lang is None:
|
||||
lang = self.httprequest.cookies.get('lang')
|
||||
if lang is None:
|
||||
@@ -112,6 +110,11 @@ class WebRequest(object):
|
||||
# we use _ as seprator where RFC2616 uses '-'
|
||||
self.lang = lang.replace('-', '_')
|
||||
|
||||
def reject_nonliteral(dct):
|
||||
if '__ref' in dct:
|
||||
raise ValueError(
|
||||
"Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
|
||||
return dct
|
||||
|
||||
class JsonRequest(WebRequest):
|
||||
""" JSON-RPC2 over HTTP.
|
||||
@@ -182,9 +185,9 @@ class JsonRequest(WebRequest):
|
||||
try:
|
||||
# Read POST content or POST Form Data named "request"
|
||||
if requestf:
|
||||
self.jsonrequest = simplejson.load(requestf, object_hook=nonliterals.non_literal_decoder)
|
||||
self.jsonrequest = simplejson.load(requestf, object_hook=reject_nonliteral)
|
||||
else:
|
||||
self.jsonrequest = simplejson.loads(request, object_hook=nonliterals.non_literal_decoder)
|
||||
self.jsonrequest = simplejson.loads(request, object_hook=reject_nonliteral)
|
||||
self.init(self.jsonrequest.get("params", {}))
|
||||
if _logger.isEnabledFor(logging.DEBUG):
|
||||
_logger.debug("--> %s.%s\n%s", method.im_class.__name__, method.__name__, pprint.pformat(self.jsonrequest))
|
||||
@@ -233,10 +236,10 @@ class JsonRequest(WebRequest):
|
||||
# We need then to manage http sessions manually.
|
||||
response['httpsessionid'] = self.httpsession.sid
|
||||
mime = 'application/javascript'
|
||||
body = "%s(%s);" % (jsonp, simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder),)
|
||||
body = "%s(%s);" % (jsonp, simplejson.dumps(response),)
|
||||
else:
|
||||
mime = 'application/json'
|
||||
body = simplejson.dumps(response, cls=nonliterals.NonLiteralEncoder)
|
||||
body = simplejson.dumps(response)
|
||||
|
||||
r = werkzeug.wrappers.Response(body, headers=[('Content-Type', mime), ('Content-Length', len(body))])
|
||||
return r
|
||||
@@ -402,8 +405,10 @@ def session_context(request, session_store, session_lock, sid):
|
||||
for k, v in request.session.iteritems():
|
||||
stored = in_store.get(k)
|
||||
if stored and isinstance(v, session.OpenERPSession):
|
||||
v.contexts_store.update(stored.contexts_store)
|
||||
v.domains_store.update(stored.domains_store)
|
||||
if hasattr(v, 'contexts_store'):
|
||||
del v.contexts_store
|
||||
if hasattr(v, 'domains_store'):
|
||||
del v.domains_store
|
||||
if not hasattr(v, 'jsonp_requests'):
|
||||
v.jsonp_requests = {}
|
||||
v.jsonp_requests.update(getattr(
|
||||
@@ -458,6 +463,16 @@ class DisableCacheMiddleware(object):
|
||||
start_response(status, new_headers)
|
||||
return self.app(environ, start_wrapped)
|
||||
|
||||
def session_path():
|
||||
try:
|
||||
username = getpass.getuser()
|
||||
except Exception:
|
||||
username = "unknown"
|
||||
path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
|
||||
if not os.path.exists(path):
|
||||
os.mkdir(path, 0700)
|
||||
return path
|
||||
|
||||
class Root(object):
|
||||
"""Root WSGI application for the OpenERP Web Client.
|
||||
"""
|
||||
@@ -469,13 +484,7 @@ class Root(object):
|
||||
self.dispatch = DisableCacheMiddleware(app)
|
||||
|
||||
# Setup http sessions
|
||||
try:
|
||||
username = getpass.getuser()
|
||||
except Exception:
|
||||
username = "unknown"
|
||||
path = os.path.join(tempfile.gettempdir(), "oe-sessions-" + username)
|
||||
if not os.path.exists(path):
|
||||
os.mkdir(path, 0700)
|
||||
path = session_path()
|
||||
self.session_store = werkzeug.contrib.sessions.FilesystemSessionStore(path)
|
||||
self.session_lock = threading.Lock()
|
||||
_logger.debug('HTTP sessions stored in: %s', path)
|
||||
@@ -528,7 +537,7 @@ class Root(object):
|
||||
"""
|
||||
statics = {}
|
||||
for addons_path in openerp.modules.module.ad_paths:
|
||||
for module in os.listdir(addons_path):
|
||||
for module in sorted(os.listdir(addons_path)):
|
||||
if module not in addons_module:
|
||||
manifest_path = os.path.join(addons_path, module, '__openerp__.py')
|
||||
path_static = os.path.join(addons_path, module, 'static')
|
||||
@@ -561,7 +570,7 @@ class Root(object):
|
||||
:rtype: ``Controller | None``
|
||||
"""
|
||||
if l:
|
||||
ps = '/' + '/'.join(l)
|
||||
ps = '/' + '/'.join(filter(None, l))
|
||||
method_name = 'index'
|
||||
while ps:
|
||||
c = controllers_path.get(ps)
|
||||
|
||||
+2513
-1385
File diff suppressed because it is too large
Load Diff
+2520
-1392
File diff suppressed because it is too large
Load Diff
+2521
-1401
File diff suppressed because it is too large
Load Diff
+2389
-1347
File diff suppressed because it is too large
Load Diff
+2327
-1338
File diff suppressed because it is too large
Load Diff
+2510
-1390
File diff suppressed because it is too large
Load Diff
+2490
-1396
File diff suppressed because it is too large
Load Diff
+2523
-1391
File diff suppressed because it is too large
Load Diff
+2512
-1399
File diff suppressed because it is too large
Load Diff
+2530
-1402
File diff suppressed because it is too large
Load Diff
+2562
-1422
File diff suppressed because it is too large
Load Diff
+2426
-1369
File diff suppressed because it is too large
Load Diff
+2520
-1400
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2541
-1413
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2343
-1344
File diff suppressed because it is too large
Load Diff
+2340
-1339
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2500
-1380
File diff suppressed because it is too large
Load Diff
+2574
-1421
File diff suppressed because it is too large
Load Diff
+2327
-1338
File diff suppressed because it is too large
Load Diff
+2521
-1401
File diff suppressed because it is too large
Load Diff
+2401
-1372
File diff suppressed because it is too large
Load Diff
+2398
-1358
File diff suppressed because it is too large
Load Diff
+2549
-1421
File diff suppressed because it is too large
Load Diff
+2466
-1371
File diff suppressed because it is too large
Load Diff
+2340
-1339
File diff suppressed because it is too large
Load Diff
+2538
-1400
File diff suppressed because it is too large
Load Diff
+2522
-1394
File diff suppressed because it is too large
Load Diff
+2514
-1394
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+2429
-1369
File diff suppressed because it is too large
Load Diff
+2468
-1375
File diff suppressed because it is too large
Load Diff
+2421
-1287
File diff suppressed because it is too large
Load Diff
+2440
-1370
File diff suppressed because it is too large
Load Diff
+2546
-1395
File diff suppressed because it is too large
Load Diff
+2488
-1397
File diff suppressed because it is too large
Load Diff
+2464
-1348
File diff suppressed because it is too large
Load Diff
+2524
-1389
File diff suppressed because it is too large
Load Diff
+2540
-1397
File diff suppressed because it is too large
Load Diff
+2521
-1393
File diff suppressed because it is too large
Load Diff
+2541
-1396
File diff suppressed because it is too large
Load Diff
+2327
-1338
File diff suppressed because it is too large
Load Diff
+2490
-1387
File diff suppressed because it is too large
Load Diff
+2327
-1338
File diff suppressed because it is too large
Load Diff
+2518
-1404
File diff suppressed because it is too large
Load Diff
+2515
-1387
File diff suppressed because it is too large
Load Diff
+2549
-1419
File diff suppressed because it is too large
Load Diff
+2327
-1338
File diff suppressed because it is too large
Load Diff
+2354
-1367
File diff suppressed because it is too large
Load Diff
+2525
-1393
File diff suppressed because it is too large
Load Diff
+2367
-1358
File diff suppressed because it is too large
Load Diff
@@ -1,267 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Manages the storage and lifecycle of non-literal domains and contexts
|
||||
(and potentially other structures) which have to be evaluated with client data,
|
||||
but still need to be safely round-tripped to and from the browser (and thus
|
||||
can't be sent there themselves).
|
||||
"""
|
||||
import binascii
|
||||
import hashlib
|
||||
import simplejson.encoder
|
||||
|
||||
__all__ = ['Domain', 'Context', 'NonLiteralEncoder', 'non_literal_decoder', 'CompoundDomain', 'CompoundContext']
|
||||
|
||||
#: 48 bits should be sufficient to have almost no chance of collision
|
||||
#: with a million hashes, according to hg@67081329d49a
|
||||
SHORT_HASH_BYTES_SIZE = 6
|
||||
|
||||
class NonLiteralEncoder(simplejson.encoder.JSONEncoder):
|
||||
def default(self, object):
|
||||
if not isinstance(object, (BaseDomain, BaseContext)):
|
||||
return super(NonLiteralEncoder, self).default(object)
|
||||
if isinstance(object, Domain):
|
||||
return {
|
||||
'__ref': 'domain',
|
||||
'__id': object.key,
|
||||
'__debug': object.get_domain_string()
|
||||
}
|
||||
elif isinstance(object, Context):
|
||||
return {
|
||||
'__ref': 'context',
|
||||
'__id': object.key,
|
||||
'__debug': object.get_context_string()
|
||||
}
|
||||
elif isinstance(object, CompoundDomain):
|
||||
return {
|
||||
'__ref': 'compound_domain',
|
||||
'__domains': object.domains,
|
||||
'__eval_context': object.get_eval_context()
|
||||
}
|
||||
elif isinstance(object, CompoundContext):
|
||||
return {
|
||||
'__ref': 'compound_context',
|
||||
'__contexts': object.contexts,
|
||||
'__eval_context': object.get_eval_context()
|
||||
}
|
||||
raise TypeError('Could not encode unknown non-literal %s' % object)
|
||||
|
||||
_ALLOWED_KEYS = frozenset(['__ref', "__id", '__domains', '__debug',
|
||||
'__contexts', '__eval_context'])
|
||||
|
||||
def non_literal_decoder(dct):
|
||||
""" Decodes JSON dicts into :class:`Domain` and :class:`Context` based on
|
||||
magic attribute tags.
|
||||
"""
|
||||
if '__ref' in dct:
|
||||
for x in dct:
|
||||
if x not in _ALLOWED_KEYS:
|
||||
raise ValueError("'%s' key not allowed in non literal domain/context" % x)
|
||||
if dct['__ref'] == 'domain':
|
||||
return Domain(None, key=dct['__id'])
|
||||
elif dct['__ref'] == 'context':
|
||||
return Context(None, key=dct['__id'])
|
||||
elif dct["__ref"] == "compound_domain":
|
||||
cdomain = CompoundDomain()
|
||||
for el in dct["__domains"]:
|
||||
cdomain.domains.append(el)
|
||||
cdomain.set_eval_context(dct.get("__eval_context"))
|
||||
return cdomain
|
||||
elif dct["__ref"] == "compound_context":
|
||||
ccontext = CompoundContext()
|
||||
for el in dct["__contexts"]:
|
||||
ccontext.contexts.append(el)
|
||||
ccontext.set_eval_context(dct.get("__eval_context"))
|
||||
return ccontext
|
||||
return dct
|
||||
|
||||
# TODO: use abstract base classes if 2.6+?
|
||||
class BaseDomain(object):
|
||||
def evaluate(self, context=None):
|
||||
raise NotImplementedError('Non literals must implement evaluate()')
|
||||
|
||||
class BaseContext(object):
|
||||
def evaluate(self, context=None):
|
||||
raise NotImplementedError('Non literals must implement evaluate()')
|
||||
|
||||
class Domain(BaseDomain):
|
||||
def __init__(self, session, domain_string=None, key=None):
|
||||
""" Uses session information to store the domain string and map it to a
|
||||
domain key, which can be safely round-tripped to the client.
|
||||
|
||||
If initialized with a domain string, will generate a key for that
|
||||
string and store the domain string out of the way. When initialized
|
||||
with a key, considers this key is a reference to an existing domain
|
||||
string.
|
||||
|
||||
:param session: the OpenERP Session to use when evaluating the domain
|
||||
:type session: web.common.session.OpenERPSession
|
||||
:param str domain_string: a non-literal domain in string form
|
||||
:param str key: key used to retrieve the domain string
|
||||
"""
|
||||
if domain_string and key:
|
||||
raise ValueError("A nonliteral domain can not take both a key "
|
||||
"and a domain string")
|
||||
|
||||
self.session = session
|
||||
if domain_string:
|
||||
self.key = binascii.hexlify(
|
||||
hashlib.sha256(domain_string).digest()[:SHORT_HASH_BYTES_SIZE])
|
||||
self.session.domains_store[self.key] = domain_string
|
||||
elif key:
|
||||
self.key = key
|
||||
|
||||
def get_domain_string(self):
|
||||
""" Retrieves the domain string linked to this non-literal domain in
|
||||
the provided session.
|
||||
"""
|
||||
return self.session.domains_store[self.key]
|
||||
|
||||
def evaluate(self, context=None):
|
||||
""" Forces the evaluation of the linked domain, using the provided
|
||||
context (as well as the session's base context), and returns the
|
||||
evaluated result.
|
||||
"""
|
||||
ctx = self.session.evaluation_context(context)
|
||||
|
||||
try:
|
||||
return eval(self.get_domain_string(), SuperDict(ctx))
|
||||
except NameError as e:
|
||||
raise ValueError('Error during evaluation of this domain: "%s", message: "%s"' % (self.get_domain_string(), e.message))
|
||||
|
||||
class Context(BaseContext):
|
||||
def __init__(self, session, context_string=None, key=None):
|
||||
""" Uses session information to store the context string and map it to
|
||||
a key (stored in a secret location under a secret mountain), which can
|
||||
be safely round-tripped to the client.
|
||||
|
||||
If initialized with a context string, will generate a key for that
|
||||
string and store the context string out of the way. When initialized
|
||||
with a key, considers this key is a reference to an existing context
|
||||
string.
|
||||
|
||||
:param session: the OpenERP Session to use when evaluating the context
|
||||
:type session: web.common.session.OpenERPSession
|
||||
:param str context_string: a non-literal context in string form
|
||||
:param str key: key used to retrieve the context string
|
||||
"""
|
||||
if context_string and key:
|
||||
raise ValueError("A nonliteral domain can not take both a key "
|
||||
"and a domain string")
|
||||
|
||||
self.session = session
|
||||
|
||||
if context_string:
|
||||
self.key = binascii.hexlify(
|
||||
hashlib.sha256(context_string).digest()[:SHORT_HASH_BYTES_SIZE])
|
||||
self.session.contexts_store[self.key] = context_string
|
||||
elif key:
|
||||
self.key = key
|
||||
|
||||
def get_context_string(self):
|
||||
""" Retrieves the context string linked to this non-literal context in
|
||||
the provided session.
|
||||
"""
|
||||
return self.session.contexts_store[self.key]
|
||||
|
||||
def evaluate(self, context=None):
|
||||
""" Forces the evaluation of the linked context, using the provided
|
||||
context (as well as the session's base context), and returns the
|
||||
evaluated result.
|
||||
"""
|
||||
ctx = self.session.evaluation_context(context)
|
||||
|
||||
try:
|
||||
return eval(self.get_context_string(), SuperDict(ctx))
|
||||
except NameError as e:
|
||||
raise ValueError('Error during evaluation of this context: "%s", message: "%s"' % (self.get_context_string(), e.message))
|
||||
|
||||
class SuperDict(dict):
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self[name]
|
||||
except KeyError:
|
||||
raise AttributeError(name)
|
||||
def __getitem__(self, key):
|
||||
tmp = super(SuperDict, self).__getitem__(key)
|
||||
if isinstance(tmp, dict):
|
||||
return SuperDict(tmp)
|
||||
return tmp
|
||||
|
||||
class CompoundDomain(BaseDomain):
|
||||
def __init__(self, *domains):
|
||||
self.domains = []
|
||||
self.session = None
|
||||
self.eval_context = None
|
||||
for domain in domains:
|
||||
self.add(domain)
|
||||
|
||||
def evaluate(self, context=None):
|
||||
ctx = dict(context or {})
|
||||
eval_context = self.get_eval_context()
|
||||
if eval_context:
|
||||
eval_context = self.session.eval_context(eval_context)
|
||||
ctx.update(eval_context)
|
||||
final_domain = []
|
||||
for domain in self.domains:
|
||||
if not isinstance(domain, (list, BaseDomain)):
|
||||
raise TypeError(
|
||||
"Domain %r is not a list or a nonliteral Domain" % domain)
|
||||
|
||||
if isinstance(domain, list):
|
||||
final_domain.extend(domain)
|
||||
continue
|
||||
|
||||
domain.session = self.session
|
||||
final_domain.extend(domain.evaluate(ctx))
|
||||
return final_domain
|
||||
|
||||
def add(self, domain):
|
||||
self.domains.append(domain)
|
||||
return self
|
||||
|
||||
def set_eval_context(self, eval_context):
|
||||
self.eval_context = eval_context
|
||||
return self
|
||||
|
||||
def get_eval_context(self):
|
||||
return self.eval_context
|
||||
|
||||
class CompoundContext(BaseContext):
|
||||
def __init__(self, *contexts):
|
||||
self.contexts = []
|
||||
self.eval_context = None
|
||||
self.session = None
|
||||
for context in contexts:
|
||||
self.add(context)
|
||||
|
||||
def evaluate(self, context=None):
|
||||
ctx = dict(context or {})
|
||||
eval_context = self.get_eval_context()
|
||||
if eval_context:
|
||||
eval_context = self.session.eval_context(eval_context)
|
||||
ctx.update(eval_context)
|
||||
final_context = {}
|
||||
for context_to_eval in self.contexts:
|
||||
if not isinstance(context_to_eval, (dict, BaseContext)):
|
||||
raise TypeError(
|
||||
"Context %r is not a dict or a nonliteral Context" % context_to_eval)
|
||||
|
||||
if isinstance(context_to_eval, dict):
|
||||
final_context.update(context_to_eval)
|
||||
continue
|
||||
|
||||
ctx.update(final_context)
|
||||
|
||||
context_to_eval.session = self.session
|
||||
final_context.update(context_to_eval.evaluate(ctx))
|
||||
return final_context
|
||||
|
||||
def add(self, context):
|
||||
self.contexts.append(context)
|
||||
return self
|
||||
|
||||
def set_eval_context(self, eval_context):
|
||||
self.eval_context = eval_context
|
||||
return self
|
||||
|
||||
def get_eval_context(self):
|
||||
return self.eval_context
|
||||
@@ -10,8 +10,6 @@ import xmlrpclib
|
||||
|
||||
import openerp
|
||||
|
||||
import nonliterals
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#----------------------------------------------------------
|
||||
@@ -81,8 +79,6 @@ class OpenERPSession(object):
|
||||
self._password = False
|
||||
self._suicide = False
|
||||
self.context = {}
|
||||
self.contexts_store = {}
|
||||
self.domains_store = {}
|
||||
self.jsonp_requests = {} # FIXME use a LRU
|
||||
|
||||
def send(self, service_name, method, *args):
|
||||
@@ -192,79 +188,4 @@ class OpenERPSession(object):
|
||||
|
||||
context['lang'] = lang
|
||||
|
||||
@property
|
||||
def base_eval_context(self):
|
||||
""" Default evaluation context for the session.
|
||||
|
||||
Used to evaluate contexts and domains.
|
||||
"""
|
||||
base = dict(
|
||||
uid=self._uid,
|
||||
current_date=datetime.date.today().strftime('%Y-%m-%d'),
|
||||
time=time,
|
||||
datetime=datetime,
|
||||
relativedelta=dateutil.relativedelta.relativedelta
|
||||
)
|
||||
base.update(self.context)
|
||||
return base
|
||||
|
||||
def evaluation_context(self, context=None):
|
||||
""" Returns the session's evaluation context, augmented with the
|
||||
provided context if any.
|
||||
|
||||
:param dict context: to add merge in the session's base eval context
|
||||
:returns: the augmented context
|
||||
:rtype: dict
|
||||
"""
|
||||
d = dict(self.base_eval_context)
|
||||
if context:
|
||||
d.update(context)
|
||||
d['context'] = d
|
||||
return d
|
||||
|
||||
def eval_context(self, context_to_eval, context=None):
|
||||
""" Evaluates the provided context_to_eval in the context (haha) of
|
||||
the context. Also merges the evaluated context with the session's context.
|
||||
|
||||
:param context_to_eval: a context to evaluate. Must be a dict or a
|
||||
non-literal context. If it's a dict, will be
|
||||
returned as-is
|
||||
:type context_to_eval: openerpweb.nonliterals.Context
|
||||
:returns: the evaluated context
|
||||
:rtype: dict
|
||||
|
||||
:raises: ``TypeError`` if ``context_to_eval`` is neither a dict nor
|
||||
a Context
|
||||
"""
|
||||
ctx = dict(
|
||||
self.base_eval_context,
|
||||
**(context or {}))
|
||||
|
||||
# adding the context of the session to send to the openerp server
|
||||
ccontext = nonliterals.CompoundContext(self.context, context_to_eval or {})
|
||||
ccontext.session = self
|
||||
return ccontext.evaluate(ctx)
|
||||
|
||||
def eval_domain(self, domain, context=None):
|
||||
""" Evaluates the provided domain using the provided context
|
||||
(merged with the session's evaluation context)
|
||||
|
||||
:param domain: an OpenERP domain as a list or as a
|
||||
:class:`openerpweb.nonliterals.Domain` instance
|
||||
|
||||
In the second case, it will be evaluated and returned.
|
||||
:type domain: openerpweb.nonliterals.Domain
|
||||
:param dict context: the context to use in the evaluation, if any.
|
||||
:returns: the evaluated domain
|
||||
:rtype: list
|
||||
|
||||
:raises: ``TypeError`` if ``domain`` is neither a list nor a Domain
|
||||
"""
|
||||
if isinstance(domain, list):
|
||||
return domain
|
||||
|
||||
cdomain = nonliterals.CompoundDomain(domain)
|
||||
cdomain.session = self
|
||||
return cdomain.evaluate(context or {})
|
||||
|
||||
# vim:et:ts=4:sw=4:
|
||||
|
||||
@@ -8952,7 +8952,8 @@ $.extend( $.ui.dialog.overlay, {
|
||||
$( document ).bind( $.ui.dialog.overlay.events, function( event ) {
|
||||
// stop events if the z-index of the target is < the z-index of the overlay
|
||||
// we cannot return true when we don't want to cancel the event (#3523)
|
||||
if ( $( event.target ).zIndex() < $.ui.dialog.overlay.maxZ ) {
|
||||
if ($(event.target).zIndex() < $.ui.dialog.overlay.maxZ &&
|
||||
$(event.target).closest('.ui-dialog').zIndex() < $.ui.dialog.overlay.maxZ) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user