From fd9da7558ad14c8d489369427532ea8ee15110fa Mon Sep 17 00:00:00 2001 From: Xavier Morel Date: Mon, 28 Mar 2011 14:27:24 +0200 Subject: [PATCH] [ADD] start working on adding the concept of literal and non-lit domains, non-lit domains will *not* be pushed to the client but will be stored locally and only a ref will go through bzr revid: xmo@openerp.com-20110328122724-gnxn8cnta4xyotab --- addons/base/controllers/main.py | 30 +++++++++++++- addons/base/tests/test_view.py | 51 +++++++++++++++++++++++- openerpweb/ast.py | 48 ++++++++++++++++++++++ openerpweb/nonliterals.py | 59 ++++++++++++++++++++++++++++ openerpweb/openerpweb.py | 29 ++++++++++++-- openerpweb/tests/test_nonliterals.py | 43 ++++++++++++++++++++ openerpweb/tests/test_session.py | 51 ++++++++++++++++++++++++ 7 files changed, 304 insertions(+), 7 deletions(-) create mode 100644 openerpweb/ast.py create mode 100644 openerpweb/nonliterals.py create mode 100644 openerpweb/tests/test_nonliterals.py create mode 100644 openerpweb/tests/test_session.py diff --git a/addons/base/controllers/main.py b/addons/base/controllers/main.py index 777cd090a76..6752ea8c7d7 100644 --- a/addons/base/controllers/main.py +++ b/addons/base/controllers/main.py @@ -6,6 +6,8 @@ from cStringIO import StringIO import simplejson import openerpweb +import openerpweb.ast +import openerpweb.nonliterals __all__ = ['Session', 'Menu', 'DataSet', 'DataRecord', 'View', 'FormView', 'ListView', 'SearchView', @@ -264,7 +266,7 @@ class View(openerpweb.Controller): r = Model.fields_view_get(view_id, view_type) if transform: context = {} # TODO: dict(ctx_sesssion, **ctx_action) - xml = self.transform_view(r['arch'], context) + xml = self.transform_view(r['arch'], session, context) else: xml = ElementTree.fromstring(r['arch']) r['arch'] = Xml2Json.convert_element(xml) @@ -303,7 +305,7 @@ class View(openerpweb.Controller): if a == 'invisible' and 'attrs' in elem.attrib: del elem.attrib['attrs'] - def transform_view(self, view_string, context=None): + 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",)) @@ -313,8 +315,32 @@ class View(openerpweb.Controller): if root is None: root = elem self.normalize_attrs(elem, context) + 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 + """ + domain = elem.get('domain') + if domain: + try: + elem.set( + 'domain', + openerpweb.ast.literal_eval( + domain)) + except ValueError: + # not a literal + elem.set('domain', + openerpweb.nonliterals.Domain(session, domain)) + class FormView(View): _cp_path = "/base/formview" diff --git a/addons/base/tests/test_view.py b/addons/base/tests/test_view.py index 22b76b2143c..2b668bb3ecd 100644 --- a/addons/base/tests/test_view.py +++ b/addons/base/tests/test_view.py @@ -1,12 +1,17 @@ import xml.etree.ElementTree +import mock import unittest2 import base.controllers.main +import openerpweb.nonliterals +import openerpweb.openerpweb +#noinspection PyCompatibility class ViewTest(unittest2.TestCase): + def setUp(self): + self.view = base.controllers.main.View() def test_identity(self): - view = base.controllers.main.View() base_view = """
@@ -18,10 +23,52 @@ class ViewTest(unittest2.TestCase): """ pristine = xml.etree.ElementTree.fromstring(base_view) - transformed = view.transform_view(base_view) + transformed = self.view.transform_view(base_view, None) self.assertEqual( xml.etree.ElementTree.tostring(transformed), xml.etree.ElementTree.tostring(pristine) ) + def test_convert_literal_domain(self): + e = xml.etree.ElementTree.Element( + 'field', domain="[('somefield', '=', 3)]") + self.view.parse_domains_and_contexts(e, None) + + self.assertEqual( + e.get('domain'), + [('somefield', '=', 3)]) + + def test_convert_complex_domain(self): + e = xml.etree.ElementTree.Element( + 'field', + domain="[('account_id.type','in',['receivable','payable'])," + "('reconcile_id','=',False)," + "('reconcile_partial_id','=',False)," + "('state', '=', 'valid')]" + ) + self.view.parse_domains_and_contexts(e, None) + + self.assertEqual( + e.get('domain'), + [('account_id.type', 'in', ['receivable', 'payable']), + ('reconcile_id', '=', False), + ('reconcile_partial_id', '=', False), + ('state', '=', 'valid')] + ) + + def test_retrieve_nonliteral_domain(self): + session = mock.Mock(spec=openerpweb.openerpweb.OpenERPSession) + session.domains_store = {} + domain_string = ("[('month','=',(datetime.date.today() - " + "datetime.timedelta(365/12)).strftime('%%m'))]") + e = xml.etree.ElementTree.Element( + 'field', domain=domain_string) + + self.view.parse_domains_and_contexts(e, session) + + self.assertIsInstance(e.get('domain'), openerpweb.nonliterals.Domain) + self.assertEqual( + openerpweb.nonliterals.Domain( + session, key=e.get('domain').key).get_domain_string(), + domain_string) diff --git a/openerpweb/ast.py b/openerpweb/ast.py new file mode 100644 index 00000000000..2fd565aa30d --- /dev/null +++ b/openerpweb/ast.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +""" Backport of Python 2.6's ast.py for Python 2.5 +""" +__all__ = ['literal_eval'] +try: + from ast import literal_eval +except ImportError: + from _ast import * + from _ast import __version__ + + + def parse(expr, filename='', mode='exec'): + """ + Parse an expression into an AST node. + Equivalent to compile(expr, filename, mode, PyCF_ONLY_AST). + """ + return compile(expr, filename, mode, PyCF_ONLY_AST) + + + def literal_eval(node_or_string): + """ + Safely evaluate an expression node or a string containing a Python + expression. The string or node provided may only consist of the + following Python literal structures: strings, numbers, tuples, lists, + dicts, booleans, and None. + """ + _safe_names = {'None': None, 'True': True, 'False': False} + if isinstance(node_or_string, basestring): + node_or_string = parse(node_or_string, mode='eval') + if isinstance(node_or_string, Expression): + node_or_string = node_or_string.body + def _convert(node): + if isinstance(node, Str): + return node.s + elif isinstance(node, Num): + return node.n + elif isinstance(node, Tuple): + return tuple(map(_convert, node.elts)) + elif isinstance(node, List): + return list(map(_convert, node.elts)) + elif isinstance(node, Dict): + return dict((_convert(k), _convert(v)) for k, v + in zip(node.keys, node.values)) + elif isinstance(node, Name): + if node.id in _safe_names: + return _safe_names[node.id] + raise ValueError('malformed string') + return _convert(node_or_string) diff --git a/openerpweb/nonliterals.py b/openerpweb/nonliterals.py new file mode 100644 index 00000000000..b209fc9e3e4 --- /dev/null +++ b/openerpweb/nonliterals.py @@ -0,0 +1,59 @@ +# -*- 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 + +__all__ = ['Domain'] + +#: 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 Domain(object): + 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: openerpweb.openerpweb.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. + + :param session: the OpenERP Session used to store the domain string in + the first place. + :type session: openerpweb.openerpweb.OpenERPSession + """ + 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. + """ + return eval(self.get_domain_string(), + self.session.evaluation_context(context)) diff --git a/openerpweb/openerpweb.py b/openerpweb/openerpweb.py index df75cd1d86b..d07df92bb36 100644 --- a/openerpweb/openerpweb.py +++ b/openerpweb/openerpweb.py @@ -50,6 +50,13 @@ class OpenERPSession(object): The session context, a ``dict``. Can be reloaded by calling :meth:`openerpweb.openerpweb.OpenERPSession.get_context` + + .. attribute:: domains_store + + A ``dict`` matching domain keys to evaluable (but non-literal) domains. + + Used to store references to non-literal domains which need to be + round-tripped to the client browser. """ def __init__(self, server='127.0.0.1', port=8069, model_factory=OpenERPModel): @@ -62,6 +69,7 @@ class OpenERPSession(object): self.model_factory = model_factory self.context = {} + self.domains_store = {} def proxy(self, service): s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service), timeout=5) @@ -113,14 +121,29 @@ class OpenERPSession(object): Used to evaluate contexts and domains. """ - return dict( + base = dict( uid=self._uid, current_date=datetime.date.today().strftime('%Y-%m-%d'), time=time, datetime=datetime, - relativedelta=dateutil.relativedelta.relativedelta, - **self.context + 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 = {} + d.update(self.base_eval_context) + if context: + d.update(context) + return d def eval_context(self, context_string, context=None, use_base=True): """ Evaluates the provided context_string in the context (haha) of diff --git a/openerpweb/tests/test_nonliterals.py b/openerpweb/tests/test_nonliterals.py new file mode 100644 index 00000000000..9e1685721d1 --- /dev/null +++ b/openerpweb/tests/test_nonliterals.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +import mock +import unittest2 + +from openerpweb.nonliterals import Domain +import openerpweb.openerpweb + +class NonLiteralDomainTest(unittest2.TestCase): + def setUp(self): + self.session = mock.Mock(spec=openerpweb.openerpweb.OpenERPSession) + self.session.domains_store = {} + def test_store_domain(self): + d = Domain(self.session, "some arbitrary string") + + self.assertEqual( + self.session.domains_store[d.key], + "some arbitrary string") + + def test_get_domain_back(self): + d = Domain(self.session, "some arbitrary string") + + self.assertEqual( + d.get_domain_string(), + "some arbitrary string") + def test_retrieve_second_domain(self): + """ A different domain should be able to retrieve the nonliteral set + previously + """ + key = Domain(self.session, "some arbitrary string").key + + self.assertEqual( + Domain(self.session, key=key).get_domain_string(), + "some arbitrary string") + + def test_key_and_string(self): + self.assertRaises( + ValueError, Domain, None, domain_string="a", key="b") + + def test_eval(self): + self.session.evaluation_context.return_value = {'foo': 3} + result = Domain(self.session, "[('a', '=', foo)]").evaluate({'foo': 3}) + self.assertEqual( + result, [('a', '=', 3)]) diff --git a/openerpweb/tests/test_session.py b/openerpweb/tests/test_session.py new file mode 100644 index 00000000000..2f982e87f7c --- /dev/null +++ b/openerpweb/tests/test_session.py @@ -0,0 +1,51 @@ +# -*- coding: utf-8 -*- +import unittest2 +import openerpweb.openerpweb + +class TestOpenERPSession(unittest2.TestCase): + def setUp(self): + self.module = object() + self.session = openerpweb.openerpweb.OpenERPSession() + self.session._uid = -1 + self.session.context = { + 'current_date': '1945-08-05', + 'date': self.module, + 'time': self.module, + 'datetime': self.module, + 'relativedelta': self.module + } + def test_base_eval_context(self): + self.assertEqual(type(self.session.base_eval_context), dict) + self.assertEqual( + self.session.base_eval_context, + {'uid': -1, 'current_date': '1945-08-05', + 'date': self.module, 'datetime': self.module, 'time': self.module, + 'relativedelta': self.module} + ) + + def test_evaluation_context_nocontext(self): + self.assertEqual( + type(self.session.evaluation_context()), + dict + ) + self.assertEqual( + self.session.evaluation_context(), + self.session.base_eval_context + ) + + def test_evaluation_context(self): + ctx = self.session.evaluation_context({'foo': 3}) + self.assertEqual( + type(ctx), + dict + ) + self.assertIn('foo', ctx) + + def test_eval_with_context(self): + self.assertEqual( + eval('current_date', self.session.evaluation_context()), + '1945-08-05') + + self.assertEqual( + eval('foo + 3', self.session.evaluation_context({'foo': 4})), + 7)