[MERGE] xmo search views

bzr revid: al@openerp.com-20110329125042-5vaketmxekhv4r39
This commit is contained in:
Antony Lesuisse
2011-03-29 14:50:42 +02:00
14 changed files with 1826 additions and 188 deletions
+128 -22
View File
@@ -6,7 +6,12 @@ from cStringIO import StringIO
import simplejson
import openerpweb
import openerpweb.ast
import openerpweb.nonliterals
__all__ = ['Session', 'Menu', 'DataSet', 'DataRecord',
'View', 'FormView', 'ListView', 'SearchView',
'Action']
class Xml2Json:
# xml2json-direct
@@ -16,7 +21,8 @@ class Xml2Json:
# URL: http://code.google.com/p/xml2json-direct/
@staticmethod
def convert_to_json(s):
return simplejson.dumps(Xml2Json.convert_to_structure(s), sort_keys=True, indent=4)
return simplejson.dumps(
Xml2Json.convert_to_structure(s), sort_keys=True, indent=4)
@staticmethod
def convert_to_structure(s):
@@ -113,6 +119,57 @@ class Session(openerpweb.Controller):
return concat
js.exposed = True
@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 = req.session.eval_contexts(contexts)
domain = req.session.eval_domains(domains, context)
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
}
class Menu(openerpweb.Controller):
_cp_path = "/base/menu"
@@ -157,12 +214,23 @@ class Menu(openerpweb.Controller):
@openerpweb.jsonrequest
def action(self, req, menu_id):
print "QUERY"
m = req.session.model('ir.values')
r = m.get('action', 'tree_but_open', [('ir.ui.menu', menu_id)], False, {})
print r
res = {"action": r}
return res
Values = req.session.model('ir.values')
actions = Values.get('action', 'tree_but_open', [('ir.ui.menu', menu_id)], False, {})
for _, _, action in actions:
# values come from the server, we can just eval them
if isinstance(action['context'], basestring):
action['context'] = eval(
action['context'],
req.session.evaluation_context()) or {}
if isinstance(action['domain'], basestring):
action['domain'] = eval(
action['domain'],
req.session.evaluation_context(
action['context'])) or []
return {"action": actions}
class DataSet(openerpweb.Controller):
@@ -184,20 +252,14 @@ class DataSet(openerpweb.Controller):
:param request: a JSON-RPC request object
:type request: openerpweb.JsonRequest
:param model: the name of the model to search on
:type model: str
:param str model: the name of the model to search on
:param fields: a list of the fields to return in the result records
:type fields: [str]
:param offset: from which index should the results start being returned
:type offset: int
:param limit: the maximum number of records to return
:type limit: int
:param domain: the search domain for the query
:type domain: list
:param context: the context in which the search should be executed
:type context: dict
:param sort: sorting directives
:type sort: list
:param int offset: from which index should the results start being returned
:param int limit: the maximum number of records to return
:param list domain: the search domain for the query
:param dict context: the context in which the search should be executed
:param list sort: sorting directives
:returns: a list of result records
:rtype: list
"""
@@ -248,14 +310,13 @@ class DataRecord(openerpweb.Controller):
value = r[0]
return {'value': value}
class View(openerpweb.Controller):
def fields_view_get(self, session, model, view_id, view_type, transform=True):
Model = session.model(model)
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)
@@ -294,7 +355,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",))
@@ -304,8 +365,53 @@ 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_domain(self, elem, attr_name, session):
""" Parses an attribute of the provided name as a domain, transforms it
to either a literal domain or a :class:`openerpweb.nonliterals.Domain`
:param elem: the node being parsed
:type param: xml.etree.ElementTree.Element
:param str attr_name: the name of the attribute which should be parsed
:param session: Current OpenERP session
:type session: openerpweb.openerpweb.OpenERPSession
"""
domain = elem.get(attr_name)
if domain:
try:
elem.set(
attr_name,
openerpweb.ast.literal_eval(
domain))
except ValueError:
# not a literal
elem.set(attr_name,
openerpweb.nonliterals.Domain(session, domain))
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
"""
self.parse_domain(elem, 'domain', session)
self.parse_domain(elem, 'filter_domain', session)
context_string = elem.get('context')
if context_string:
try:
elem.set('context',
openerpweb.ast.literal_eval(context_string))
except ValueError:
elem.set('context',
openerpweb.nonliterals.Context(
session, context_string))
class FormView(View):
_cp_path = "/base/formview"
+1
View File
@@ -348,6 +348,7 @@ body.openerp {
}
.openerp .filter_label_group {
padding-right: 0.4em;
white-space: nowrap;
}
.openerp .filter_label_group button:first-child {
border-left: 1px solid #666;
+75 -77
View File
@@ -272,86 +272,84 @@
</button>
</t>
<t t-name="SearchView">
<h3 class="title"><t t-esc="fields_view.arch.attrs['string']"/></h3>
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<t t-call="SearchView.node">
<t t-set="node" t-value="fields_view.arch"/>
<t t-set="previous_node_type"></t>
</t>
</tr>
<h3 class="title"><t t-esc="view.attrs['string']"/></h3>
<form>
<t t-call="SearchView.render_lines"/>
<div style="text-align:right;">
<input type="submit" value="Search"/>
<input type="reset" value="Clear"/>
</div>
</form>
</t>
<t t-name="SearchView.render_lines">
<table border="0" cellspacing="0" cellpadding="0"
t-foreach="lines" t-as="line">
<tr>
<td t-foreach="line" t-as="widget">
<t t-raw="widget.render(defaults)"/>
</td>
</tr>
</table>
<div style="text-align:right;">
<input id="search" type="button" value="Search"/>
<input type="button" value="Clear"/>
</t>
<button t-name="SearchView.filter" type="button"
t-att-id="element_id"
t-att-title="attrs.help"
t-att-class="classes.join(' ')">
<img t-if="attrs.icon" t-att-src="'/base/static/openerp/img/icons/' + attrs.icon + '.png'" width="16" height="16"/>
<br t-if="attrs.icon and attrs.string"/>
<t t-esc="attrs.string"/>
</button>
<span t-name="SearchView.filters" class="filter_label_group"
><t t-foreach="filters" t-as="filter"
><t t-raw="filter.render(defaults)"/></t
></span>
<t t-name="SearchView.field">
<label style="display: block" t-att-title="attrs.help"
t-att-for="element_id">
<t t-esc="attrs.string || attrs.name"/>
<span t-if="attrs.help">(?)</span>
</label>
<div style="white-space: nowrap;">
<input type="text" t-att-name="attrs.name"
t-att-id="element_id"
t-att-value="defaults[attrs.name] || ''"/>
<t t-if="filters.length" t-raw="filters.render(defaults)"/>
</div>
</t>
<t t-name="SearchView.node" t-trim="inner">
<t t-if="node.tag == 'filter'">
<t t-if="previous_node_type != 'filter'">
&lt;td nowrap="true" valign="bottom" class="filter_label_group"&gt;
</t>
<button type="button" t-att-title="node.attrs.help" t-att-class="node.attrs.string ? 'filter_label' : 'filter_icon'">
<img t-if="node.attrs.icon" t-att-src="'/base/static/openerp/img/icons/' + node.attrs.icon + '.png'" width="16" height="16"/>
<br t-if="node.attrs.icon and node.attrs.string"/>
<t t-esc="node.attrs.string"/>
</button>
<t t-if="next_node_type != 'filter'">
&lt;/td&gt;
</t>
</t>
<t t-if="node.tag == 'separator'">
<!-- nothing in search view ? -->
</t>
<t t-if="node.tag == 'newline'">
<!-- TODO: check qweb support for cdata -->
&lt;/tr&gt;
&lt;/table&gt;
&lt;table border="0" cellspacing="0" cellpadding="0"&gt;
&lt;tr&gt;
</t>
<t t-if="node.tag == 'field'">
<t t-set="orm" t-value="fields_view.fields[node.attrs.name] || {}"/>
<td>
<label style="display: block" t-att-title="orm.help || node.attrs.help">
<t t-esc="orm.string || node.attrs.string || node.attrs.name"/>
<span t-if="orm.help || node.attrs.help">(?)</span>
</label>
<div>
<input type="text" t-att-name="node.attrs.name"/>
</div>
</td>
</t>
<t t-if="node.tag == 'group'">
<!-- if group is called without a <newline/> before, the table should be closed.
I think we'll need preprocessing, all those escaped tables are too ugly -->
<td>
<div t-att-class="'searchview_group ' + (node.attrs.expand == '0' ? 'folded' : 'expanded')">
<a class="searchview_group_string" href="#" onclick="$(this).parent().toggleClass('folded expanded')"><t t-esc="node.attrs.string"/></a>
<div class="searchview_group_content">
<table border="0" cellspacing="0" cellpadding="0">
<tr>
<t t-call="SearchView.recurse"/>
</tr>
</table>
</div>
</div>
</td>
&lt;/tr&gt;
&lt;/table&gt;
&lt;table border="0" cellspacing="0" cellpadding="0"&gt;
&lt;tr&gt;
</t>
<t t-if="node.children and node.tag != 'newline' and node.tag != 'group'">
<t t-call="SearchView.recurse"/>
</t>
<t t-name="SearchView.field.selection">
<label style="display: block" t-att-title="attrs.help"
t-att-for="element_id">
<t t-esc="attrs.string || attrs.name"/>
<span t-if="attrs.help">(?)</span>
</label>
<div style="white-space: nowrap;">
<select t-att-name="attrs.name" t-att-id="element_id">
<option/>
<t t-foreach="attrs.selection" t-as="option">
<t t-set="selected" t-value="defaults[attrs.name] === option[0]"/>
<option t-if="selected"
t-att-value="option[0]" selected="selected">
<t t-esc="option[1]"/>
</option>
<option t-if="!selected" t-att-value="option[0]">
<t t-esc="option[1]"/>
</option>
</t>
</select>
<t t-if="filters.length" t-raw="filters.render(defaults)"/>
</div>
</t>
<t t-name="SearchView.recurse" t-trim="inner">
<t t-foreach="node.children" t-as="node">
<t t-call="SearchView.node">
<t t-set="previous_node_type" t-value="node_all[node_index - 1] ? node_all[node_index - 1].tag : ''"/>
<t t-set="next_node_type" t-value="node_all[node_index + 1] ? node_all[node_index + 1].tag : ''"/>
</t>
</t>
<t t-name="SearchView.group">
<div t-att-class="'searchview_group ' + (attrs.expand == '0' ? 'folded' : 'expanded')"
t-att-id="element_id">
<a t-if="attrs.string" class="searchview_group_string" href="#">
<t t-esc="attrs.string"/>
</a>
<div class="searchview_group_content">
<t t-call="SearchView.render_lines"/>
</div>
</div>
</t>
</templates>
+2 -2
View File
@@ -527,7 +527,7 @@ openerp.base.WebClient = openerp.base.Controller.extend({
this.session.on_session_invalid.add(this.login.do_ask_login);
this.session.on_session_valid.add_last(this.header.do_update);
this.session.on_session_valid.add_last(this.on_loggued);
this.session.on_session_valid.add_last(this.on_logged);
this.menu = new openerp.base.Menu(this.session, "oe_menu", "oe_secondary_menu");
this.menu.on_action.add(this.on_menu_action);
@@ -538,7 +538,7 @@ openerp.base.WebClient = openerp.base.Controller.extend({
this.login.start();
this.menu.start();
},
on_loggued: function() {
on_logged: function() {
this.action = new openerp.base.ActionManager(this.session, "oe_application");
this.action.start();
},
+595 -76
View File
@@ -41,14 +41,27 @@ openerp.base.ViewManager = openerp.base.Controller.extend({
start: function() {
},
on_mode_switch: function(view_type) {
for (var i in this.views) {
if(i == view_type) {
this.views[i].controller.$element.show();
} else {
this.views[i].controller.$element.hide();
}
for (var i in this.views) {
this.views[i].controller.$element.toggle(i === view_type);
}
},
/**
* Extract search view defaults from the current action's context.
*
* These defaults are of the form {search_default_*: value}
*
* @returns {Object} a clean defaults mapping of {field_name: value}
*/
search_defaults: function () {
var defaults = {};
_.each(this.action.context, function (value, key) {
var match = /^search_default_(.*)$/.exec(key);
if (match) {
defaults[match[1]] = value;
}
});
return defaults;
},
do_action_window: function(action) {
var self = this;
var prefix_id = "#" + this.element_id;
@@ -61,8 +74,17 @@ openerp.base.ViewManager = openerp.base.Controller.extend({
this.searchview_id = false;
if(this.search_visible && action.search_view_id) {
this.searchview_id = action.search_view_id[0];
this.searchview = new openerp.base.SearchView(this.session, this.element_id + "_search", this.dataset, this.searchview_id);
this.searchview.start();
var searchview = this.searchview = new openerp.base.SearchView(
this.session, this.element_id + "_search",
this.dataset, this.searchview_id,
this.search_defaults());
searchview.on_search.add(this.do_search);
searchview.start();
if (action['auto_search']) {
searchview.on_loaded.add_last(
searchview.do_search);
}
}
for(var i = 0; i < action.views.length; i++) {
var view_id, controller;
@@ -92,6 +114,20 @@ openerp.base.ViewManager = openerp.base.Controller.extend({
on_remove: function() {
},
on_edit: function() {
},
do_search: function (domains, contexts, groupbys) {
var self = this;
this.rpc('/base/session/eval_domain_and_context', {
domains: domains,
contexts: contexts,
group_by_seq: groupbys
}, function (results) {
// TODO: handle non-empty results.group_by with read_group
self.dataset.set({
context: results.context,
domain: results.domain
}).fetch(0, self.action.limit);
});
}
});
@@ -358,75 +394,562 @@ openerp.base.DataRecord = openerp.base.Controller.extend({
});
openerp.base.SearchView = openerp.base.Controller.extend({
init: function(session, element_id, dataset, view_id) {
init: function(session, element_id, dataset, view_id, defaults) {
this._super(session, element_id);
this.dataset = dataset;
this.model = dataset.model;
this.view_id = view_id;
this.input_index = 0;
this.input_ids = {};
this.domain = [];
this.defaults = defaults || {};
this.inputs = [];
this.enabled_filters = [];
},
start: function() {
//this.log('Starting SearchView '+this.model+this.view_id)
this.rpc("/base/searchview/load", {"model": this.model, "view_id":this.view_id}, this.on_loaded);
},
/**
* Builds a list of widget rows (each row is an array of widgets)
*
* @param {Array} items a list of nodes to convert to widgets
* @param {Object} fields a mapping of field names to (ORM) field attributes
* @returns Array
*/
make_widgets: function (items, fields) {
var rows = [],
row = [];
rows.push(row);
var filters = [];
_.each(items, function (item) {
if (filters.length && item.tag !== 'filter') {
row.push(
new openerp.base.search.FilterGroup(
filters, this));
filters = [];
}
if (item.tag === 'newline') {
row = [];
rows.push(row);
} else if (item.tag === 'filter') {
filters.push(
new openerp.base.search.Filter(
item, this));
} else if (item.tag === 'separator') {
// a separator is a no-op
} else {
if (item.tag === 'group') {
// TODO: group and field should be fetched from registries, maybe even filters
row.push(
new openerp.base.search.Group(
item, this, fields));
} else if (item.tag === 'field') {
row.push(
this.make_field(
item, fields[item['attrs'].name]));
}
}
}, this);
if (filters.length) {
row.push(new openerp.base.search.FilterGroup(filters, this));
}
return rows;
},
/**
* Creates a field for the provided field descriptor item (which comes
* from fields_view_get)
*
* @param {Object} item fields_view_get node for the field
* @param {Object} field fields_get result for the field
* @returns openerp.base.search.Field
*/
make_field: function (item, field) {
// TODO: should fetch from an actual registry
// TODO: register fields in self?
switch (field.type) {
case 'char':
case 'text':
return new openerp.base.search.CharField(
item, field, this);
case 'boolean':
return new openerp.base.search.BooleanField(
item, field, this);
case 'integer':
return new openerp.base.search.IntegerField(
item, field, this);
case 'float':
return new openerp.base.search.FloatField(
item, field, this);
case 'selection':
return new openerp.base.search.SelectionField(
item, field, this);
case 'datetime':
return new openerp.base.search.DateTimeField(
item, field, this);
case 'date':
return new openerp.base.search.DateField(
item, field, this);
case 'one2many':
return new openerp.base.search.OneToManyField(
item, field, this);
case 'many2one':
return new openerp.base.search.ManyToOneField(
item, field, this);
case 'many2many':
return new openerp.base.search.ManyToManyField(
item, field, this);
default:
console.group('Unknown field type ' + field.type);
console.error('View node', item);
console.info('View field', field);
console.info('In view', this);
console.groupEnd();
return null;
}
},
on_loaded: function(data) {
this.fields_view = data.fields_view;
this.log(this.fields_view);
this.input_ids = {};
this.$element.html(QWeb.render("SearchView", {"fields_view": this.fields_view}));
this.$element.find("#search").bind('click',this.on_search);
// TODO bind click event on all button
// TODO we don't do many2one yet, but in the future bind a many2one controller on them
this.log(this.$element.find("#search"));
},
register_input: function(node) {
// self should be passed in the qweb dict to do:
// <input t-add-id="self.register_input(node)"/>
var lines = this.make_widgets(
data.fields_view['arch'].children,
data.fields_view.fields);
// generate id
var id = this.element_id + "_" + this.input_index++;
// TODO construct a nice object
// save it in our registry
this.input_ids[id] = {
node: node,
type: "filter",
domain: "",
context: "",
disabled: false
};
var render = QWeb.render("SearchView", {
'view': data.fields_view['arch'],
'lines': lines,
'defaults': this.defaults
});
this.$element.html(render);
return id;
this.$element.find('form')
.submit(this.do_search)
.bind('reset', this.do_clear);
// start() all the widgets
_(lines).chain().flatten().each(function (widget) {
widget.start();
});
},
on_click: function() {
// event catched on a button
// flip the disabled flag
// adjust the css class
/**
* Performs the search view collection of widget data.
*
* If the collection went well (all fields are valid), then triggers
* :js:func:`openerp.base.SearchView.on_search`.
*
* If at least one field failed its validation, triggers
* :js:func:`openerp.base.SearchView.on_invalid` instead.
*
* @param e jQuery event object coming from the "Search" button
*/
do_search: function (e) {
if (e && e.preventDefault) { e.preventDefault(); }
var domains = [], contexts = [];
var errors = [];
_.each(this.inputs, function (input) {
try {
var domain = input.get_domain();
if (domain) {
domains.push(domain);
}
var context = input.get_context();
if (context) {
contexts.push(context);
}
} catch (e) {
if (e instanceof openerp.base.search.Invalid) {
errors.push(e);
} else {
throw e;
}
}
});
if (errors.length) {
this.on_invalid(errors);
return;
}
// TODO: do we need to handle *fields* with group_by in their context?
var groupbys = _(this.enabled_filters)
.chain()
.map(function (filter) { return filter.get_context();})
.compact()
.value();
this.on_search(domains, contexts, groupbys);
},
on_search: function() {
this.log("on_search");
// collect all non disabled domains definitions, AND them
// evaluate as python expression
// save the result in this.domain
this.dataset.fetch();
/**
* Triggered after the SearchView has collected all relevant domains and
* contexts.
*
* It is provided with an Array of domains and an Array of contexts, which
* may or may not be evaluated (each item can be either a valid domain or
* context, or a string to evaluate in order in the sequence)
*
* It is also passed an array of contexts used for group_by (they are in
* the correct order for group_by evaluation, which contexts may not be)
*
* @event
* @param {Array} domains an array of literal domains or domain references
* @param {Array} contexts an array of literal contexts or context refs
* @param {Array} groupbys ordered contexts which may or may not have group_by keys
*/
on_search: function (domains, contexts, groupbys) { },
/**
* Triggered after a validation error in the SearchView fields.
*
* Error objects have three keys:
* * ``field`` is the name of the invalid field
* * ``value`` is the invalid value
* * ``message`` is the (in)validation message provided by the field
*
* @event
* @param {Array} errors a never-empty array of error objects
*/
on_invalid: function (errors) { },
do_clear: function (e) {
if (e && e.preventDefault) { e.preventDefault(); }
this.on_clear();
},
on_clear: function() {
/**
* Triggered when the search view gets cleared
*
* @event
*/
on_clear: function () { },
/**
* Called by a filter propagating its state changes
*
* @param {openerp.base.search.Filter} filter a filter which got toggled
* @param {Boolean} default_enabled filter got enabled through the default values, at render time.
*/
do_toggle_filter: function (filter, default_enabled) {
if (default_enabled || filter.is_enabled()) {
this.enabled_filters.push(filter);
} else {
this.enabled_filters = _.without(
this.enabled_filters, filter);
}
if (!default_enabled) {
// selecting a filter after initial loading automatically
// triggers refresh
this.$element.find('form').submit();
}
}
});
openerp.base.SearchViewInput = openerp.base.Controller.extend({
// TODO not sure should we create a controller for every input ?
openerp.base.search = {};
openerp.base.search.Invalid = Class.extend({
init: function (field, value, message) {
this.field = field;
this.value = value;
this.message = message;
},
toString: function () {
return ('Incorrect value for field ' + this.field +
': [' + this.value + '] is ' + this.message);
}
});
openerp.base.search.Widget = openerp.base.Controller.extend({
template: null,
init: function (view) {
this.view = view;
},
/**
* Sets and returns a globally unique identifier for the widget.
*
* If a prefix is specified, the identifier will be appended to it.
*
* @params prefix prefix sections, empty/falsy sections will be removed
*/
make_id: function () {
this.element_id = _.uniqueId(
['search'].concat(
_.compact(_.toArray(arguments)),
['']).join('_'));
return this.element_id;
},
/**
* "Starts" the widgets. Called at the end of the rendering, this allows
* widgets to hook themselves to their view sections.
*
* On widgets, if they kept a reference to a view and have an element_id,
* will fetch and set their root element on $element.
*/
start: function () {
this._super();
if (this.view && this.element_id) {
// id is unique, and no getElementById on elements
this.$element = $(document.getElementById(
this.element_id));
}
},
/**
* "Stops" the widgets. Called when the view destroys itself, this
* lets the widgets clean up after themselves.
*/
stop: function () {
delete this.view;
this._super();
},
render: function (defaults) {
return QWeb.render(
this.template, _.extend(this, {
defaults: defaults
}));
}
});
openerp.base.search.FilterGroup = openerp.base.search.Widget.extend({
template: 'SearchView.filters',
init: function (filters, view) {
this._super(view);
this.filters = filters;
},
start: function () {
this._super();
_.each(this.filters, function (filter) {
filter.start();
});
}
});
openerp.base.search.Group = openerp.base.search.Widget.extend({
template: 'SearchView.group',
// TODO: contain stuff
// TODO: @expand
init: function (view_section, view, fields) {
this._super(view);
this.attrs = view_section.attrs;
this.lines = view.make_widgets(
view_section.children, fields);
this.make_id('group');
},
start: function () {
this._super();
_(this.lines)
.chain()
.flatten()
.each(function (widget) { widget.start(); });
var $root = this.$element;
$root.find('a.searchview_group_string').click(function (e) {
$root.toggleClass('folded expanded');
e.stopPropagation();
e.preventDefault();
});
}
});
openerp.base.search.Input = openerp.base.search.Widget.extend({
init: function (view) {
this._super(view);
this.view.inputs.push(this);
},
get_context: function () {
throw new Error(
"get_context not implemented for widget " + this.attrs.type);
},
get_domain: function () {
throw new Error(
"get_domain not implemented for widget " + this.attrs.type);
}
});
openerp.base.search.Filter = openerp.base.search.Input.extend({
template: 'SearchView.filter',
// TODO: force rendering
init: function (node, view) {
this._super(view);
this.attrs = node.attrs;
this.classes = [this.attrs.string ? 'filter_label' : 'filter_icon'];
this.make_id('filter', this.attrs.name);
},
start: function () {
this._super();
var self = this;
this.$element.click(function () {
$(this).toggleClass('enabled');
self.view.do_toggle_filter(self);
});
},
/**
* Returns whether the filter is currently enabled (in use) or not.
*
* @returns a boolean
*/
is_enabled:function () {
return this.$element.hasClass('enabled');
},
/**
* If the filter is present in the defaults (and has a truthy value),
* enable the filter.
*
* @param {Object} defaults the search view's default values
*/
render: function (defaults) {
if (this.attrs.name && defaults[this.attrs.name]) {
this.classes.push('enabled');
this.view.do_toggle_filter(this, true);
}
return this._super(defaults);
},
get_context: function () {
if (!this.is_enabled()) {
return;
}
return this.attrs.context;
},
get_domain: function () {
if (!this.is_enabled()) {
return;
}
return this.attrs.domain;
}
});
openerp.base.search.Field = openerp.base.search.Input.extend({
template: 'SearchView.field',
default_operator: '=',
// TODO: set default values
// TODO: get context, domain
// TODO: holds Filters
init: function (view_section, field, view) {
this._super(view);
this.attrs = _.extend({}, field, view_section.attrs);
this.filters = new openerp.base.search.FilterGroup(_.map(
view_section.children, function (filter_node) {
return new openerp.base.search.Filter(
filter_node, view);
}), view);
this.make_id('input', field.type, this.attrs.name);
},
start: function () {
this._super();
this.filters.start();
},
get_context: function () {
var val = this.get_value();
// A field needs a value to be "active", and a context to send when
// active
var has_value = (val !== null && val !== '');
var context = this.attrs.context;
if (!(has_value && context)) {
return;
}
return _.extend(
{}, context,
{own_values: {self: val}});
},
get_domain: function () {
var val = this.get_value();
// of we just keep a simple dict for each input in
// openerp.base.SearchView#input_ids
// and use if when we get an event depending on the type
// i think it's less bloated to avoid useless controllers
// but i think for many2one a controller would be nice
// so simple dict for simple inputs
// an controller for many2one ?
var has_value = (val !== null && val !== '');
if(!has_value) {
return;
}
var domain = this.attrs['filter_domain'];
if (!domain) {
return [[
this.attrs.name,
this.attrs.operator || this.default_operator,
this.get_value()
]];
}
return _.extend(
{}, domain,
{own_values: {self: val}});
}
});
openerp.base.search.CharField = openerp.base.search.Field.extend({
default_operator: 'ilike',
get_value: function () {
return this.$element.val();
}
});
openerp.base.search.BooleanField = openerp.base.search.Field.extend({
template: 'SearchView.field.selection',
init: function () {
this._super.apply(this, arguments);
this.attrs.selection = [
['true', 'Yes'],
['false', 'No']
];
},
/**
* Search defaults likely to be boolean values (for a boolean field).
*
* In the HTML, we only get strings, and our strings here are
* <code>'true'</code> and <code>'false'</code>, so ensure we get only
* those by truth-testing the default value.
*
* @param {Object} defaults default values for this search view
*/
render: function (defaults) {
var name = this.attrs.name;
if (name in defaults) {
defaults[name] = defaults[name] ? "true" : "false";
}
return this._super(defaults);
},
get_value: function () {
switch (this.$element.val()) {
case 'false': return false;
case 'true': return true;
default: return null;
}
}
});
openerp.base.search.IntegerField = openerp.base.search.Field.extend({
get_value: function () {
if (!this.$element.val()) {
return null;
}
var val = parseInt(this.$element.val());
var check = Number(this.$element.val());
if (isNaN(check) || val !== check) {
this.$element.addClass('error');
throw new openerp.base.search.Invalid(
this.attrs.name, this.$element.val(), "not a valid integer");
}
this.$element.removeClass('error');
return val;
}
});
openerp.base.search.FloatField = openerp.base.search.Field.extend({
get_value: function () {
var val = Number(this.$element.val());
if (isNaN(val)) {
this.$element.addClass('error');
throw new openerp.base.search.Invalid(
this.attrs.name, this.$element.val(), "not a valid number");
}
this.$element.removeClass('error');
return val;
}
});
openerp.base.search.SelectionField = openerp.base.search.Field.extend({
template: 'SearchView.field.selection',
get_value: function () {
return this.$element.val();
}
});
openerp.base.search.DateTimeField = openerp.base.search.Field.extend({
get_value: function () {
return this.$element.val();
}
});
openerp.base.search.DateField = openerp.base.search.Field.extend({
get_value: function () {
return this.$element.val();
}
});
openerp.base.search.OneToManyField = openerp.base.search.IntegerField.extend({
// TODO: .relation, .context, .domain
});
openerp.base.search.ManyToOneField = openerp.base.search.IntegerField.extend({
// TODO: @widget
// TODO: .relation, .selection, .context, .domain
});
openerp.base.search.ManyToManyField = openerp.base.search.IntegerField.extend({
// TODO: .related_columns (Array), .context, .domain
});
openerp.base.FormView = openerp.base.Controller.extend({
@@ -457,10 +980,10 @@ openerp.base.FormView = openerp.base.Controller.extend({
}
// bind to all wdigets that have onchange ??
this.dataset.on_fetch.add(this.on_record_loaded);
this.dataset.on_active_id.add(this.on_record_loaded);
},
on_record_loaded: function(records) {
this.datarecord = records[0];
on_record_loaded: function(record) {
this.datarecord = record;
for (var f in this.fields) {
this.fields[f].set_value(this.datarecord.values[f]);
}
@@ -514,25 +1037,9 @@ openerp.base.ListView = openerp.base.Controller.extend({
}
this.dataset.fields = this.cols;
this.dataset.on_fetch.add(this.do_fill_table);
},
do_fill_table: function(records) {
this.log("do_fill_table");
var self = this;
//this.log(this.dataset.data);
var rows = [];
for(var i = 0; i < records.length; i++) {
// TODO very strange is sometimes non existing ? even as admin ? example ir.ui.menu
var row = records[i].values;
if(row)
rows.push(row);
// else
// debugger;
}
//this.log(rows);
var width = this.$element.width();
this.$table.jqGrid({
data: rows,
datatype: "local",
height: "100%",
rowNum: 100,
@@ -543,11 +1050,23 @@ openerp.base.ListView = openerp.base.Controller.extend({
viewrecords: true,
caption: this.name
}).setGridWidth(width);
var self = this;
$(window).bind('resize', function() {
self.$element.children().hide();
self.$table.setGridWidth(self.$element.width());
self.$element.children().show();
}).trigger('resize');
},
do_fill_table: function(records) {
this.log("do_fill_table");
this.$table
.clearGridData()
.addRowData('id', _.map(records, function (record) {
return record.values;
}));
}
});
+1 -1
View File
@@ -400,7 +400,7 @@ var QWeb = {
if (e = this.templates[name]) {
return this.render_node(e, v);
}
return "template " + name + " not found";
throw new Error("template " + name + " not found");
}
};
+92 -2
View File
@@ -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 = """
<form string="Title">
<group>
@@ -18,10 +23,95 @@ 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)
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):
session = mock.Mock(spec=openerpweb.openerpweb.OpenERPSession)
session.contexts_store = {}
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, session)
self.assertIsInstance(e.get('context'), openerpweb.nonliterals.Context)
self.assertEqual(
openerpweb.nonliterals.Context(
session, key=e.get('context').key).get_context_string(),
context_string)
+9
View File
@@ -275,6 +275,15 @@ convenient ways to refer to them and their properties.
.. js:attribute:: sort
Python
++++++
.. autoclass:: openerpweb.openerpweb.OpenERPSession
:members:
.. autoclass:: openerpweb.openerpweb.OpenERPModel
:members:
* Addons lifecycle (loading, execution, events, ...)
* Python-side
+285 -3
View File
@@ -1,5 +1,5 @@
Contributing to OpenERP Web
===========================
OpenERP Web Core and standard addons
====================================
* General organization and core ideas (design philosophies)
* Internal documentation, autodoc, Python and JS domains
@@ -8,14 +8,296 @@ Contributing to OpenERP Web
* Style guide and coding conventions (PEP8? More)
* Test frameworks in JS?
Standard Views
--------------
Search View
+++++++++++
The OpenERP search view really is a sub-view, used in support of views
acting on collections of records (list view or graph view, for
instance).
Its main goal is to collect information from its widgets (themselves
collecting information from the users) and make those available to the
rest of the client.
The search view's root is :js:class:`~openerp.base.SearchView`. This
object should never need to be created or managed directly, its
lifecycle should be driven by the
:js:class:`~openerp.base.ViewManager`.
.. TODO: insert SearchView constructor here
The search view defines a number of internal and external protocols to
communicate with the objects around and within it. Most of these
protocols are informal, and types available for inheritance are more
mixins than mandatory.
Events
""""""
``on_loaded``
.. TODO: method openerp.base.SearchView.on_loaded
Fires when the search view receives its view data (the result of
``fields_view_get``). Hooking up before the event allows for
altering view data before it can be used.
By the time ``on_loaded`` is done, the search view is guaranteed to
be fully set up and ready to use.
``on_search``
.. TODO: method openerp.base.SearchView.on_search
Event triggered after a user asked for a search. The search view
fires this event after collecting all input data (contexts, domains
and group_by contexts). Note that the search view does *not* merge
those (or therwise evaluate them), they are returned as provided by
the various inputs within the view.
``on_clear``
.. TODO: method openerp.base.SearchView.on_clear
Triggered after a user asked for a form clearing.
Input management
""""""""""""""""
An important concept in the search view is that of input. It is both
an informal protocol and an abstract type that can be inherited from.
Inputs are widgets which can contain user data (a char widget for
instance, or a selection box). They are able of action and of
reaction:
.. _views-search-registration:
``registration``
This is an input action. Inputs have to register themselves to the
main view (which they receive as a constructor argument). This is
performed by pushing themselves on the
:js:attr:`openerp.base.SearchView.inputs` array.
``get_context``
An input reaction. When it needs to collect contexts, the view calls
``get_context()`` on all its inputs.
Inputs can react in the following manners:
* Return a context (an object), this is the "normal" response if the
input holds a value.
* Return a falsy value (generally ``null``). This value indicates
the input does not contain any value and will not take part in the
research.
* Raise :js:class:`openerp.base.search.Invalid` to indicate that it
holds a value but this value can not be used in the search
(because it is incorrectly formatted or nonsensical). Raising
:js:class:`~openerp.base.search.Invalid` is guaranteed to cancel
the search process.
:js:class:`~openerp.base.search.Invalid` takes three mandatory
arguments: an indentifier (a name for instance), the invalid value
and a validation message indicating the issue.
``get_domain``
The second input reaction, the possible behaviors of inputs are the
same as for ``get_context``.
The :js:class:`openerp.base.search.Input` type implements registration
on its own, but its implementations of ``get_context`` and
``get_domain`` simply raise errors and *have* to be overridden.
One last action is for filters, as an activation order has to be kept
on them for some controls (establish the correct grouping sequence for
instance).
To that end, filters can call
:js:func:`openerp.base.Search.do_toggle_filter`, providing themselves
as first argument.
Filters calling :js:func:`~openerp.base.Search.do_toggle_filter` also
need to implement a method called
:js:func:`~openerp.base.search.Filter.is_enabled`, which the search
view will use to know the current status of the filter.
The search view automatically triggers a search after calls to
:js:func:`~openerp.base.Search.do_toggle_filter`.
Life cycle
""""""""""
The search view has a pretty simple and linear life cycle, in three main steps:
:js:class:`init <openerp.base.SearchView>`
Nothing interesting happens here
:js:func:`~openerp.base.SearchView.start`
Called by the main view's creator, this is the main initialization
step for the list view.
It begins with a remote call to fetch the view's descriptors
(``fields_view_get``).
Once the remote call is complete, the ``on_loaded`` even happens,
holding three main operations:
:js:func:`~openerp.base.SearchView.make_widgets`
Builds and returns the top-level widgets of the search
view. Because it returns an array of widget lines (a 2-dimensional
matrix of widgets) it should be called recursively by container
widgets (:js:class:`openerp.base.search.Group` for instance).
:js:func:`~openerp.base.search.Widget.render`
Called by the search view on all top-level widgets. Container
widgets should recursively call this method on their own children
widgets.
Widgets are provided with a mapping of ``{name: value}`` holding
default values for the search view. They can freely pick their
initial values from there, but must pass the mapping to their
children widgets if they have any.
:js:func:`~openerp.base.search.Widget.start`
The last operation of the search view startup is to initialize all
its widgets in order. This is again done recursively (the search
view starts its children, which have to start their own children).
:js:func:`~openerp.base.SearchView.stop`
Used before discarding a search view, allows the search view to
disable its events and pass the message to its own widgets,
gracefully shutting down the whole view.
Widgets
"""""""
In a search view, the widget is simply a unit of display.
All widgets must be able to react to three events, which will be
called in this order:
:js:func:`~openerp.base.search.Widget.render`
Called with a map of default values. The widget must return a
``String``, which is its HTML representation. That string can be
empty (if the widget should not be represented).
Widgets are responsible for asking their children for rendering, and
for passing along the default values.
:js:func:`~openerp.base.search.Widget.start`
Called without arguments. At this point, the widget has been fully
rendered and can set its events up, if any.
The widget is responsible for starting its children, if it has any.
:js:func:`~openerp.base.search.Widget.stop`
Gives the widget the opportunity to unbind its events, remove itself
from the DOM and perform any other cleanup task it may have.
Event if the widget does not do anything itself, it is responsible
for shutting down its children.
An abstract type is available and can be inherited from, to simplify
the implementation of those tasks:
.. TODO: insert Widget here
.. remember to document all methods
Inputs
""""""
The search namespace (``openerp.base.search``) provides two more
abstract types, used to implement input widgets:
* :js:class:`openerp.base.search.Input` is the most basic input type,
it only implements :ref:`input registration
<views-search-registration>`.
If inherited from, descendant classes should not call its
implementations of ``get_context`` and ``get_domain``.
* :js:class:`openerp.base.search.Field` is used to implement more
"field" widgets (which allow the user to input potentially complex
values).
It provides various services for its subclasses:
* Sets up the field attributes, using attributes from the field and
the view node.
* It fills the widget with :js:class:`~openerp.base.search.Filter`
if the field has any child filter.
* It automatically generates an identifier based on the field type
and the field name, using
:js:func:`~openerp.base.search.Widget.make_id`.
* It sets up a basic (overridable)
:js:attr:`~opererp.base.search.Field.template` attribute, combined
with the previous tasks, this makes subclasses of
:js:class:`~openerp.base.search.Field` render themselves "for
free".
* It provides basic implementations of ``get_context`` and
``get_domain``, both hinging on the subclasses implementing
``get_value()`` (which should return a correct, converted
Javascript value):
:js:func:`~openerp.base.search.Field.get_context`
Checks if the field has a non-``null`` and non-empty
(``String``) value, and that the field has a ``context`` attr.
If both conditions are fullfilled, returns the context.
:js:func:`~openerp.base.search.Field.get_domain`
Only requires that the field has a non-``null`` and non-empty
value.
If the field has a ``filter_domain``, returns it
immediately. Otherwise, builds a context using the field's
name, the field :js:attr:`~openerp.base.search.Field.operator`
and the field value, and returns it.
.. TODO: insert Input, Field, Filter, and just about every Field subclass
Internal API Doc
----------------
Python
++++++
.. autoclass:: base.controllers.main.DataSet
These classes should be moved to other sections of the doc as needed,
probably.
.. automodule:: openerpweb.openerpweb
:members: JsonRequest
See also: :class:`~openerpweb.openerpweb.OpenERPSession`,
:class:`~openerpweb.openerpweb.OpenERPModel`
.. automodule:: base.controllers.main
:members:
:undoc-members:
Testing
-------
+48
View File
@@ -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='<unknown>', 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)
+148
View File
@@ -0,0 +1,148 @@
# -*- 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.decoder
import simplejson.encoder
__all__ = ['Domain', 'Context', 'NonLiteralEncoder, non_literal_decoder']
#: 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 isinstance(object, Domain):
return {
'__ref': 'domain',
'__id': object.key
}
elif isinstance(object, Context):
return {
'__ref': 'context',
'__id': object.key
}
return super(NonLiteralEncoder, self).default(object)
def non_literal_decoder(dct):
""" Decodes JSON dicts into :class:`Domain` and :class:`Context` based on
magic attribute tags.
Also handles private context section for the domain or section via the
``own_values`` dict key.
"""
if '__ref' in dct:
if dct['__ref'] == 'domain':
domain = Domain(None, key=dct['__id'])
if 'own_values' in dct:
domain.own = dct['own_values']
return domain
elif dct['__ref'] == 'context':
context = Context(None, key=dct['__id'])
if 'own_values' in dct:
context.own = dct['own_values']
return context
return dct
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
self.own = {}
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.
"""
ctx = self.session.evaluation_context(context)
if self.own:
ctx.update(self.own)
return eval(self.get_domain_string(), ctx)
class Context(object):
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: openerpweb.openerpweb.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
self.own = {}
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.
:param session: the OpenERP Session used to store the context string in
the first place.
:type session: openerpweb.openerpweb.OpenERPSession
"""
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)
if self.own:
ctx.update(self.own)
return eval(self.get_context_string(),
ctx)
+187 -5
View File
@@ -1,6 +1,9 @@
#!/usr/bin/python
import datetime
import functools
import optparse
import time
import dateutil.relativedelta
import os
import sys
import tempfile
@@ -12,6 +15,7 @@ import cherrypy
import cherrypy.lib.static
import simplejson
import nonliterals
import xmlrpctimeout
#----------------------------------------------------------
@@ -39,6 +43,22 @@ class OpenERPModel(object):
class OpenERPSession(object):
"""
An OpenERP RPC session, a given user can own multiple such sessions
in a web session.
.. attribute:: context
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):
self._server = server
@@ -49,6 +69,10 @@ class OpenERPSession(object):
self._password = False
self.model_factory = model_factory
self.context = {}
self.contexts_store = {}
self.domains_store = {}
def proxy(self, service):
s = xmlrpctimeout.TimeoutServerProxy('http://%s:%s/xmlrpc/%s' % (self._server, self._port, service), timeout=5)
return s
@@ -62,6 +86,9 @@ class OpenERPSession(object):
uid = self.proxy('common').login(db, login, password)
self.bind(db, uid, password)
self._login = login
if uid: self.get_context()
return uid
def execute(self, model, func, *l, **d):
@@ -71,8 +98,159 @@ class OpenERPSession(object):
return r
def model(self, model):
""" Get an RPC proxy for the object ``model``, bound to this session.
:param model: an OpenERP model name
:type model: str
:rtype: :class:`openerpweb.openerpweb.OpenERPModel`
"""
return self.model_factory(self, model)
def get_context(self):
""" Re-initializes the current user's session context (based on
his preferences) by calling res.users.get_context() with the old
context
:returns: the new context
"""
assert self._uid, "The user needs to be logged-in to initialize his context"
self.context = self.model('res.users').context_get(self.context)
return self.context
@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 = {}
d.update(self.base_eval_context)
if context:
d.update(context)
return d
def eval_context(self, context_to_eval, context=None):
""" Evaluates the provided context_to_eval in the context (haha) of
the 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
"""
if not isinstance(context_to_eval, (dict, nonliterals.Domain)):
raise TypeError("Context %r is not a dict or a nonliteral Context",
context_to_eval)
if isinstance(context_to_eval, dict):
return context_to_eval
ctx = context or {}
ctx['context'] = ctx
# if the domain was unpacked from JSON, it needs the current
# OpenERPSession for its data retrieval
context_to_eval.session = self
return context_to_eval.evaluate(ctx)
def eval_contexts(self, contexts, context=None):
""" Evaluates a sequence of contexts to build a single final result
:param list contexts: a list of Context or dict contexts
:param dict context: a base context, if needed
:returns: the final combination of all provided contexts
:rtype: dict
"""
# This is the context we use to evaluate stuff
current_context = dict(
self.base_eval_context,
**(context or {}))
# this is our result, it should not contain the values
# of the base context above
final_context = {}
for ctx in contexts:
# evaluate the current context in the sequence, merge it into
# the result
final_context.update(
self.eval_context(
ctx, current_context))
# update the current evaluation context so that future
# evaluations can use the results we just gathered
current_context.update(final_context)
return final_context
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 not isinstance(domain, (list, nonliterals.Domain)):
raise TypeError("Domain %r is not a list or a nonliteral Domain",
domain)
if isinstance(domain, list):
return domain
ctx = context or {}
ctx['context'] = ctx
# if the domain was unpacked from JSON, it needs the current
# OpenERPSession for its data retrieval
domain.session = self
return domain.evaluate(ctx)
def eval_domains(self, domains, context=None):
""" Evaluates and concatenates the provided domains using the
provided context for all of them.
Returns the final, concatenated result.
:param list domains: a list of Domain or list domains
:param dict context: the context in which the domains
should be evaluated (if evaluations need
to happen)
:returns: the final combination of all domains in the sequence
:rtype: list
"""
final_domain = []
for domain in domains:
final_domain.extend(
self.eval_domain(domain, context))
return final_domain
#----------------------------------------------------------
# OpenERP Web RequestHandler
#----------------------------------------------------------
@@ -80,8 +258,9 @@ class OpenERPSession(object):
class JsonRequest(object):
""" JSON-RPC2 over HTTP POST using non standard POST encoding.
Difference with the standard:
- the json string is passed as a form parameter named "request"
- method is currently ignored
* the json string is passed as a form parameter named "request"
* method is currently ignored
Sucessful request:
--> {"jsonrpc": "2.0", "method": "call", "params": {"session_id": "SID", "context": {}, "arg1": "val1" }, "id": null}
@@ -116,9 +295,11 @@ class JsonRequest(object):
:rtype: bytes
'''
if requestf:
request = simplejson.load(requestf)
request = simplejson.load(
requestf, object_hook=nonliterals.non_literal_decoder)
else:
request = simplejson.loads(request)
request = simplejson.loads(
request, object_hook=nonliterals.non_literal_decoder)
try:
print "--> %s.%s %s" % (controller.__class__.__name__, method.__name__, request)
error = None
@@ -161,7 +342,8 @@ class JsonRequest(object):
print "<--", response
print
content = simplejson.dumps(response)
content = simplejson.dumps(
response, cls=nonliterals.NonLiteralEncoder)
cherrypy.response.headers['Content-Type'] = 'application/json'
cherrypy.response.headers['Content-Length'] = len(content)
return content
+145
View File
@@ -0,0 +1,145 @@
# -*- coding: utf-8 -*-
import mock
import simplejson
import unittest2
from openerpweb.nonliterals import Domain, Context
import openerpweb.nonliterals
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)])
def test_own_values(self):
self.session.evaluation_context.return_value = {}
domain = Domain(self.session, "[('a', '=', self)]")
domain.own = {'self': 3}
result = domain.evaluate()
self.assertEqual(
result, [('a', '=', 3)])
class NonLiteralContextTest(unittest2.TestCase):
def setUp(self):
self.session = mock.Mock(spec=openerpweb.openerpweb.OpenERPSession)
self.session.contexts_store = {}
def test_store_domain(self):
c = Context(self.session, "some arbitrary string")
self.assertEqual(
self.session.contexts_store[c.key],
"some arbitrary string")
def test_get_domain_back(self):
c = Context(self.session, "some arbitrary string")
self.assertEqual(
c.get_context_string(),
"some arbitrary string")
def test_retrieve_second_domain(self):
""" A different domain should be able to retrieve the nonliteral set
previously
"""
key = Context(self.session, "some arbitrary string").key
self.assertEqual(
Context(self.session, key=key).get_context_string(),
"some arbitrary string")
def test_key_and_string(self):
self.assertRaises(
ValueError, Context, None, context_string="a", key="b")
def test_eval(self):
self.session.evaluation_context.return_value = {'foo': 3}
result = Context(self.session, "[('a', '=', foo)]")\
.evaluate({'foo': 3})
self.assertEqual(
result, [('a', '=', 3)])
def test_own_values(self):
self.session.evaluation_context.return_value = {}
context = Context(self.session, "{'a': self}")
context.own = {'self': 3}
result = context.evaluate()
self.assertEqual(
result, {'a': 3})
class NonLiteralJSON(unittest2.TestCase):
def setUp(self):
self.session = mock.Mock(spec=openerpweb.openerpweb.OpenERPSession)
self.session.domains_store = {}
self.session.contexts_store = {}
def test_encode_domain(self):
d = Domain(self.session, "some arbitrary string")
self.assertEqual(
simplejson.dumps(d, cls=openerpweb.nonliterals.NonLiteralEncoder),
simplejson.dumps({'__ref': 'domain', '__id': d.key}))
def test_decode_domain(self):
encoded = simplejson.dumps(
Domain(self.session, "some arbitrary string"),
cls=openerpweb.nonliterals.NonLiteralEncoder)
domain = simplejson.loads(
encoded, object_hook=openerpweb.nonliterals.non_literal_decoder)
domain.session = self.session
self.assertEqual(
domain.get_domain_string(),
"some arbitrary string"
)
def test_encode_context(self):
c = Context(self.session, "some arbitrary string")
self.assertEqual(
simplejson.dumps(c, cls=openerpweb.nonliterals.NonLiteralEncoder),
simplejson.dumps({'__ref': 'context', '__id': c.key}))
def test_decode_context(self):
encoded = simplejson.dumps(
Context(self.session, "some arbitrary string"),
cls=openerpweb.nonliterals.NonLiteralEncoder)
context = simplejson.loads(
encoded, object_hook=openerpweb.nonliterals.non_literal_decoder)
context.session = self.session
self.assertEqual(
context.get_context_string(),
"some arbitrary string"
)
+110
View File
@@ -0,0 +1,110 @@
# -*- coding: utf-8 -*-
import unittest2
from openerpweb.nonliterals import Domain
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)
def test_eval_domain_typeerror(self):
self.assertRaises(
TypeError, self.session.eval_domain, "foo")
def test_eval_domain_list(self):
self.assertEqual(
self.session.eval_domain([]),
[])
def test_eval_nonliteral_domain(self):
d = Domain(self.session, "[('foo', 'is', 3)]")
self.assertEqual(
self.session.eval_domain(d),
[('foo', 'is', 3)])
def test_eval_nonliteral_domain_bykey(self):
key = Domain(
self.session, "[('foo', 'is', 3)]").key
d = Domain(None, key=key)
self.assertEqual(
self.session.eval_domain(d),
[('foo', 'is', 3)])
def test_eval_empty_domains(self):
self.assertEqual(
self.session.eval_domains([]),
[])
def test_eval_literal_domains(self):
domains = [
[('a', 'is', 3)],
[('b', 'ilike', 'foo')],
['|',
('c', '=', False),
('c', 'in', ['a', 'b', 'c'])]
]
self.assertEqual(
self.session.eval_domains(domains),
[
('a', 'is', 3),
('b', 'ilike', 'foo'),
'|',
('c', '=', False),
('c', 'in', ['a', 'b', 'c'])
])
def test_eval_nonliteral_domains(self):
domains = [
Domain(self.session, "[('uid', '=', uid)]"),
Domain(self.session,
"['|', ('date', '<', current_date),"
" ('date', '>', current_date)]")]
self.assertEqual(
self.session.eval_domains(domains),
[('uid', '=', -1),
'|', ('date', '<', '1945-08-05'), ('date', '>', '1945-08-05')]
)