diff --git a/addons/base/controllers/main.py b/addons/base/controllers/main.py index 8ea477ccca0..40d1c7f5823 100644 --- a/addons/base/controllers/main.py +++ b/addons/base/controllers/main.py @@ -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" diff --git a/addons/base/static/openerp/base.css b/addons/base/static/openerp/base.css index 316b0aa3902..f27a4a23ccc 100644 --- a/addons/base/static/openerp/base.css +++ b/addons/base/static/openerp/base.css @@ -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; diff --git a/addons/base/static/openerp/base.xml b/addons/base/static/openerp/base.xml index 6a175f9595f..297289755da 100644 --- a/addons/base/static/openerp/base.xml +++ b/addons/base/static/openerp/base.xml @@ -272,86 +272,84 @@ -

- - - - - - - +

+ + + + +
+ + +
+ +
+ +
+ + +
+ +
-
- - + + + + + +
+ +
- - - - <td nowrap="true" valign="bottom" class="filter_label_group"> - - - - </td> - - - - - - - - </tr> - </table> - <table border="0" cellspacing="0" cellpadding="0"> - <tr> - - - - - -
- -
- -
- - - -
- -
- - - - -
-
-
- - </tr> - </table> - <table border="0" cellspacing="0" cellpadding="0"> - <tr> -
- - - + + +
+ + +
- - - - - - - + +
+ + + +
+ +
+
diff --git a/addons/base/static/openerp/js/base_chrome.js b/addons/base/static/openerp/js/base_chrome.js index 3b9c1b0eb00..1cf78618817 100644 --- a/addons/base/static/openerp/js/base_chrome.js +++ b/addons/base/static/openerp/js/base_chrome.js @@ -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(); }, diff --git a/addons/base/static/openerp/js/base_views.js b/addons/base/static/openerp/js/base_views.js index 595e83da275..704306acf73 100644 --- a/addons/base/static/openerp/js/base_views.js +++ b/addons/base/static/openerp/js/base_views.js @@ -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: - // + 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 + * 'true' and 'false', 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; + })); + } }); diff --git a/addons/base/static/qweb/qweb.js b/addons/base/static/qweb/qweb.js index 46c2824a278..c12af4e0f45 100644 --- a/addons/base/static/qweb/qweb.js +++ b/addons/base/static/qweb/qweb.js @@ -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"); } }; diff --git a/addons/base/tests/test_view.py b/addons/base/tests/test_view.py index 22b76b2143c..fa1779665f8 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,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) diff --git a/doc/source/addons.rst b/doc/source/addons.rst index 71bfe9ceb37..70e753084de 100644 --- a/doc/source/addons.rst +++ b/doc/source/addons.rst @@ -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 diff --git a/doc/source/development.rst b/doc/source/development.rst index afc6040227d..a1d989b46ac 100644 --- a/doc/source/development.rst +++ b/doc/source/development.rst @@ -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 ` + + 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 + `. + + 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 ------- 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..a0805c9c0c2 --- /dev/null +++ b/openerpweb/nonliterals.py @@ -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) diff --git a/openerpweb/openerpweb.py b/openerpweb/openerpweb.py index f858ff61aa5..fb200d23217 100644 --- a/openerpweb/openerpweb.py +++ b/openerpweb/openerpweb.py @@ -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 diff --git a/openerpweb/tests/test_nonliterals.py b/openerpweb/tests/test_nonliterals.py new file mode 100644 index 00000000000..938b44ee728 --- /dev/null +++ b/openerpweb/tests/test_nonliterals.py @@ -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" + ) diff --git a/openerpweb/tests/test_session.py b/openerpweb/tests/test_session.py new file mode 100644 index 00000000000..e7e0381cec5 --- /dev/null +++ b/openerpweb/tests/test_session.py @@ -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')] + )