[REM] EVALPOCALYPSE PART 2: no more python-side eval
trigger an error if a nonliteral context is pushed to the server (through a new object_hook) bzr revid: xmo@openerp.com-20121126140525-ni2x5m56upss610b
This commit is contained in:
parent
eb9a1c7d55
commit
4a5cb1ebc4
|
@ -30,7 +30,6 @@ except ImportError:
|
|||
import openerp
|
||||
|
||||
from .. import http
|
||||
from .. import nonliterals
|
||||
openerpweb = http
|
||||
|
||||
#----------------------------------------------------------
|
||||
|
@ -442,39 +441,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:
|
||||
|
@ -970,8 +936,8 @@ class Menu(openerpweb.Controller):
|
|||
|
||||
menu_domain = [('parent_id', '=', False)]
|
||||
if user_menu_id:
|
||||
domain_string = s.model('ir.actions.act_window').read([user_menu_id[0]], ['domain'],
|
||||
req.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)
|
||||
|
||||
|
@ -1176,8 +1142,6 @@ class View(openerpweb.Controller):
|
|||
fvg = Model.fields_view_get(view_id, view_type, req.context, toolbar, submenu)
|
||||
# todo fme?: check that we should pass the evaluated context here
|
||||
self.process_view(req.session, fvg, req.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):
|
||||
|
@ -1194,12 +1158,8 @@ class View(openerpweb.Controller):
|
|||
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)
|
||||
fvg['arch'] = xml2json_from_elementtree(
|
||||
ElementTree.fromstring(arch), preserve_whitespaces)
|
||||
|
||||
if 'id' in fvg['fields']:
|
||||
# Special case for id's
|
||||
|
@ -1208,29 +1168,8 @@ class View(openerpweb.Controller):
|
|||
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)
|
||||
for view in field.get("views", {}).itervalues():
|
||||
self.process_view(session, view, None, transform)
|
||||
|
||||
@openerpweb.jsonrequest
|
||||
def add_custom(self, req, view_id, arch):
|
||||
|
@ -1255,40 +1194,6 @@ class View(openerpweb.Controller):
|
|||
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)
|
||||
|
@ -1488,7 +1393,7 @@ class Action(openerpweb.Controller):
|
|||
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
|
||||
|
|
|
@ -11,7 +11,7 @@ from mako.template import Template
|
|||
from openerp.modules import module
|
||||
|
||||
from .main import module_topological_sort
|
||||
from .. import http, nonliterals
|
||||
from .. import http
|
||||
|
||||
NOMODULE_TEMPLATE = Template(u"""<!DOCTYPE html>
|
||||
<html>
|
||||
|
|
|
@ -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,7 +93,7 @@ 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)
|
||||
|
@ -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
|
||||
|
|
|
@ -1,111 +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 simplejson.encoder
|
||||
|
||||
__all__ = ['Domain', 'Context', 'NonLiteralEncoder', 'non_literal_decoder', 'CompoundDomain', 'CompoundContext']
|
||||
|
||||
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',
|
||||
'__debug': object.domain_string
|
||||
}
|
||||
elif isinstance(object, Context):
|
||||
return {
|
||||
'__ref': 'context',
|
||||
'__debug': object.context_string
|
||||
}
|
||||
raise TypeError('Could not encode unknown non-literal %s' % object)
|
||||
|
||||
def non_literal_decoder(dct):
|
||||
if '__ref' in dct:
|
||||
raise ValueError(
|
||||
"Non literal contexts can not be sent to the server anymore (%r)" % (dct,))
|
||||
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):
|
||||
""" 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
|
||||
"""
|
||||
self.session = session
|
||||
self.domain_string = domain_string
|
||||
|
||||
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.domain_string, SuperDict(ctx))
|
||||
except NameError as e:
|
||||
raise ValueError('Error during evaluation of this domain: "%s", message: "%s"' % (self.domain_string, e.message))
|
||||
|
||||
class Context(BaseContext):
|
||||
def __init__(self, session, context_string):
|
||||
""" 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
|
||||
"""
|
||||
self.session = session
|
||||
self.context_string = context_string
|
||||
|
||||
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.context_string, SuperDict(ctx))
|
||||
except NameError as e:
|
||||
raise ValueError('Error during evaluation of this context: "%s", message: "%s"' % (self.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
|
|
@ -10,8 +10,6 @@ import xmlrpclib
|
|||
|
||||
import openerp
|
||||
|
||||
import nonliterals
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
#----------------------------------------------------------
|
||||
|
|
|
@ -1191,11 +1191,12 @@ instance.web.WebClient = instance.web.Client.extend({
|
|||
var self = this;
|
||||
return this.rpc("/web/action/load", { action_id: options.action_id })
|
||||
.then(function (result) {
|
||||
var action = result;
|
||||
if (options.needaction) {
|
||||
action.context.search_default_message_unread = true;
|
||||
result.context = new instance.web.CompoundContext(
|
||||
result.context,
|
||||
{search_default_message_unread: true});
|
||||
}
|
||||
return $.when(self.action_manager.do_action(action, {
|
||||
return $.when(self.action_manager.do_action(result, {
|
||||
clear_breadcrumbs: true,
|
||||
action_menu_id: self.menu.current_menu,
|
||||
})).fail(function() {
|
||||
|
|
|
@ -44,7 +44,8 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
|
|||
view_id: this.view_id,
|
||||
view_type: "tree",
|
||||
toolbar: this.view_manager ? !!this.view_manager.sidebar : false,
|
||||
context: this.dataset.get_context()
|
||||
context: instance.web.pyeval.eval(
|
||||
'context', this.dataset.get_context())
|
||||
}).done(this.on_loaded);
|
||||
},
|
||||
/**
|
||||
|
@ -227,8 +228,9 @@ instance.web.TreeView = instance.web.View.extend(/** @lends instance.web.TreeVie
|
|||
return this.rpc('/web/treeview/action', {
|
||||
id: id,
|
||||
model: this.dataset.model,
|
||||
context: new instance.web.CompoundContext(
|
||||
this.dataset.get_context(), local_context)
|
||||
context: instance.web.pyeval.eval(
|
||||
'context', new instance.web.CompoundContext(
|
||||
this.dataset.get_context(), local_context))
|
||||
}).then(function (actions) {
|
||||
if (!actions.length) { return; }
|
||||
var action = actions[0][2];
|
||||
|
|
|
@ -265,6 +265,17 @@ instance.web.ActionManager = instance.web.Widget.extend({
|
|||
return self.do_action(result, options);
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure context & domain are evaluated and can be manipulated/used
|
||||
if (action.context) {
|
||||
action.context = instance.web.pyeval.eval(
|
||||
'context', action.context);
|
||||
}
|
||||
if (action.domain) {
|
||||
action.domain = instance.web.pyeval.eval(
|
||||
'domain', action.domain);
|
||||
}
|
||||
|
||||
if (!action.type) {
|
||||
console.error("No type for action", action);
|
||||
return $.Deferred().reject();
|
||||
|
@ -1081,7 +1092,8 @@ instance.web.Sidebar = instance.web.Widget.extend({
|
|||
context: instance.web.pyeval.eval(
|
||||
'context', additional_context)
|
||||
}).done(function(result) {
|
||||
result.context = _.extend(result.context || {},
|
||||
result.context = new instance.web.CompoundContext(
|
||||
result.context,
|
||||
additional_context);
|
||||
result.flags = result.flags || {};
|
||||
result.flags.new_window = true;
|
||||
|
|
|
@ -905,11 +905,11 @@
|
|||
</li>
|
||||
<li t-if="widget.node.attrs.context" data-item="context">
|
||||
<span class="oe_tooltip_technical_title">Context:</span>
|
||||
<t t-esc="widget.node.attrs.context_string"/>
|
||||
<t t-esc="widget.node.attrs.context"/>
|
||||
</li>
|
||||
<li t-if="widget.node.attrs.domain" data-item="domain">
|
||||
<span class="oe_tooltip_technical_title">Domain:</span>
|
||||
<t t-esc="widget.node.attrs.domain_string"/>
|
||||
<t t-esc="widget.node.attrs.domain"/>
|
||||
</li>
|
||||
<li t-if="widget.node.attrs.modifiers and widget.node.attrs.modifiers != '{}'" data-item="modifiers">
|
||||
<span class="oe_tooltip_technical_title">Modifiers:</span>
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from . import test_dataset, test_menu, test_serving_base, test_view, test_js
|
||||
from . import test_dataset, test_menu, test_serving_base, test_js
|
||||
|
||||
fast_suite = []
|
||||
checks = [
|
||||
test_dataset,
|
||||
test_menu,
|
||||
test_serving_base,
|
||||
test_view,
|
||||
]
|
||||
|
|
|
@ -1,113 +0,0 @@
|
|||
import xml.etree.ElementTree
|
||||
|
||||
import unittest2
|
||||
|
||||
import openerp.addons.web.controllers.main
|
||||
from .. import nonliterals, session as s
|
||||
|
||||
def field_attrs(fields_view_get, fieldname):
|
||||
(field,) = filter(lambda f: f['attrs'].get('name') == fieldname,
|
||||
fields_view_get['arch']['children'])
|
||||
return field['attrs']
|
||||
|
||||
#noinspection PyCompatibility
|
||||
class DomainsAndContextsTest(unittest2.TestCase):
|
||||
def setUp(self):
|
||||
self.view = openerp.addons.web.controllers.main.View()
|
||||
|
||||
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):
|
||||
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, None)
|
||||
|
||||
self.assertIsInstance(e.get('domain'), nonliterals.Domain)
|
||||
|
||||
def test_convert_literal_context(self):
|
||||
e = xml.etree.ElementTree.Element(
|
||||
'field', context=" {'some_prop': 3} ")
|
||||
self.view.parse_domains_and_contexts(e, None)
|
||||
|
||||
self.assertEqual(
|
||||
e.get('context'),
|
||||
{'some_prop': 3})
|
||||
|
||||
def test_convert_complex_context(self):
|
||||
e = xml.etree.ElementTree.Element(
|
||||
'field',
|
||||
context="{'account_id.type': ['receivable','payable'],"
|
||||
"'reconcile_id': False,"
|
||||
"'reconcile_partial_id': False,"
|
||||
"'state': 'valid'}"
|
||||
)
|
||||
self.view.parse_domains_and_contexts(e, None)
|
||||
|
||||
self.assertEqual(
|
||||
e.get('context'),
|
||||
{'account_id.type': ['receivable', 'payable'],
|
||||
'reconcile_id': False,
|
||||
'reconcile_partial_id': False,
|
||||
'state': 'valid'}
|
||||
)
|
||||
|
||||
def test_retrieve_nonliteral_context(self):
|
||||
context_string = ("{'month': (datetime.date.today() - "
|
||||
"datetime.timedelta(365/12)).strftime('%%m')}")
|
||||
e = xml.etree.ElementTree.Element(
|
||||
'field', context=context_string)
|
||||
|
||||
self.view.parse_domains_and_contexts(e, None)
|
||||
|
||||
self.assertIsInstance(e.get('context'), nonliterals.Context)
|
||||
|
||||
class AttrsNormalizationTest(unittest2.TestCase):
|
||||
def setUp(self):
|
||||
self.view = openerp.addons.web.controllers.main.View()
|
||||
|
||||
def test_identity(self):
|
||||
web_view = """
|
||||
<form string="Title">
|
||||
<group>
|
||||
<field name="some_field"/>
|
||||
<field name="some_other_field"/>
|
||||
</group>
|
||||
<field name="stuff"/>
|
||||
</form>
|
||||
"""
|
||||
|
||||
pristine = xml.etree.ElementTree.fromstring(web_view)
|
||||
transformed = self.view.transform_view(web_view, None)
|
||||
|
||||
self.assertEqual(
|
||||
xml.etree.ElementTree.tostring(transformed),
|
||||
xml.etree.ElementTree.tostring(pristine)
|
||||
)
|
Loading…
Reference in New Issue