[MERGE] forward port of branch 8.0 up to ed1c173
[odoo/odoo.git] / openerp / addons / base / ir / ir_ui_view.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21 import collections
22 import copy
23 import datetime
24 import dateutil
25 from dateutil.relativedelta import relativedelta
26 import fnmatch
27 import logging
28 import os
29 import time
30 from operator import itemgetter
31
32 import simplejson
33 import werkzeug
34 import HTMLParser
35 from lxml import etree
36
37 import openerp
38 from openerp import tools, api
39 from openerp.http import request
40 from openerp.modules.module import get_resource_path, get_resource_from_path
41 from openerp.osv import fields, osv, orm
42 from openerp.tools import config, graph, SKIPPED_ELEMENT_TYPES, SKIPPED_ELEMENTS
43 from openerp.tools.convert import _fix_multiple_roots
44 from openerp.tools.parse_version import parse_version
45 from openerp.tools.safe_eval import safe_eval as eval
46 from openerp.tools.view_validation import valid_view
47 from openerp.tools import misc
48 from openerp.tools.translate import _
49
50 _logger = logging.getLogger(__name__)
51
52 MOVABLE_BRANDING = ['data-oe-model', 'data-oe-id', 'data-oe-field', 'data-oe-xpath', 'data-oe-source-id']
53
54 def keep_query(*keep_params, **additional_params):
55     """
56     Generate a query string keeping the current request querystring's parameters specified
57     in ``keep_params`` and also adds the parameters specified in ``additional_params``.
58
59     Multiple values query string params will be merged into a single one with comma seperated
60     values.
61
62     The ``keep_params`` arguments can use wildcards too, eg:
63
64         keep_query('search', 'shop_*', page=4)
65     """
66     if not keep_params and not additional_params:
67         keep_params = ('*',)
68     params = additional_params.copy()
69     qs_keys = request.httprequest.args.keys()
70     for keep_param in keep_params:
71         for param in fnmatch.filter(qs_keys, keep_param):
72             if param not in additional_params and param in qs_keys:
73                 params[param] = ','.join(request.httprequest.args.getlist(param))
74     return werkzeug.urls.url_encode(params)
75
76 class view_custom(osv.osv):
77     _name = 'ir.ui.view.custom'
78     _order = 'create_date desc'  # search(limit=1) should return the last customization
79     _columns = {
80         'ref_id': fields.many2one('ir.ui.view', 'Original View', select=True, required=True, ondelete='cascade'),
81         'user_id': fields.many2one('res.users', 'User', select=True, required=True, ondelete='cascade'),
82         'arch': fields.text('View Architecture', required=True),
83     }
84
85     def name_get(self, cr, uid, ids, context=None):
86         return [(rec.id, rec.user_id.name) for rec in self.browse(cr, uid, ids, context=context)]
87
88     def name_search(self, cr, user, name, args=None, operator='ilike', context=None, limit=100):
89         if args is None:
90             args = []
91         if name:
92             ids = self.search(cr, user, [('user_id', operator, name)] + args, limit=limit)
93             return self.name_get(cr, user, ids, context=context)
94         return super(view_custom, self).name_search(cr, user, name, args=args, operator=operator, context=context, limit=limit)
95
96
97     def _auto_init(self, cr, context=None):
98         super(view_custom, self)._auto_init(cr, context)
99         cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = \'ir_ui_view_custom_user_id_ref_id\'')
100         if not cr.fetchone():
101             cr.execute('CREATE INDEX ir_ui_view_custom_user_id_ref_id ON ir_ui_view_custom (user_id, ref_id)')
102
103 def _hasclass(context, *cls):
104     """ Checks if the context node has all the classes passed as arguments
105     """
106     node_classes = set(context.context_node.attrib.get('class', '').split())
107
108     return node_classes.issuperset(cls)
109
110 def get_view_arch_from_file(filename, xmlid):
111     doc = etree.parse(filename)
112     node = None
113     for n in doc.xpath('//*[@id="%s"] | //*[@id="%s"]' % (xmlid, xmlid.split('.')[1])):
114         if n.tag in ('template', 'record'):
115             node = n
116             break
117     if node is not None:
118         if node.tag == 'record':
119             field = node.find('field[@name="arch"]')
120             _fix_multiple_roots(field)
121             inner = ''.join([etree.tostring(child) for child in field.iterchildren()])
122             return field.text + inner
123         elif node.tag == 'template':
124             # The following dom operations has been copied from convert.py's _tag_template()
125             if not node.get('inherit_id'):
126                 node.set('t-name', xmlid)
127                 node.tag = 't'
128             else:
129                 node.tag = 'data'
130             node.attrib.pop('id', None)
131             return etree.tostring(node)
132     raise ValueError("Could not find view arch definition in file '%s' for xmlid '%s'" % (filename, xmlid))
133
134 xpath_utils = etree.FunctionNamespace(None)
135 xpath_utils['hasclass'] = _hasclass
136
137 class view(osv.osv):
138     _name = 'ir.ui.view'
139
140     def _get_model_data(self, cr, uid, ids, fname, args, context=None):
141         result = dict.fromkeys(ids, False)
142         IMD = self.pool['ir.model.data']
143         data_ids = IMD.search_read(cr, uid, [('res_id', 'in', ids), ('model', '=', 'ir.ui.view')], ['res_id'], context=context)
144         result.update(map(itemgetter('res_id', 'id'), data_ids))
145         return result
146
147     def _views_from_model_data(self, cr, uid, ids, context=None):
148         IMD = self.pool['ir.model.data']
149         data_ids = IMD.search_read(cr, uid, [('id', 'in', ids), ('model', '=', 'ir.ui.view')], ['res_id'], context=context)
150         return map(itemgetter('res_id'), data_ids)
151
152     def _arch_get(self, cr, uid, ids, name, arg, context=None):
153         result = {}
154         for view in self.browse(cr, uid, ids, context=context):
155             arch_fs = None
156             if config['dev_mode'] and view.arch_fs and view.xml_id:
157                 # It is safe to split on / herebelow because arch_fs is explicitely stored with '/'
158                 fullpath = get_resource_path(*view.arch_fs.split('/'))
159                 arch_fs = get_view_arch_from_file(fullpath, view.xml_id)
160             result[view.id] = arch_fs or view.arch_db
161         return result
162
163     def _arch_set(self, cr, uid, ids, field_name, field_value, args, context=None):
164         if not isinstance(ids, list):
165             ids = [ids]
166         if field_value:
167             for view in self.browse(cr, uid, ids, context=context):
168                 data = dict(arch_db=field_value)
169                 key = 'install_mode_data'
170                 if context and key in context:
171                     imd = context[key]
172                     if self._model._name == imd['model'] and (not view.xml_id or view.xml_id == imd['xml_id']):
173                         # we store the relative path to the resource instead of the absolute path
174                         data['arch_fs'] = '/'.join(get_resource_from_path(imd['xml_file'])[0:2])
175                 self.write(cr, uid, ids, data, context=context)
176
177         return True
178
179     _columns = {
180         'name': fields.char('View Name', required=True),
181         'model': fields.char('Object', select=True),
182         'priority': fields.integer('Sequence', required=True),
183         'type': fields.selection([
184             ('tree','Tree'),
185             ('form','Form'),
186             ('graph', 'Graph'),
187             ('calendar', 'Calendar'),
188             ('diagram','Diagram'),
189             ('gantt', 'Gantt'),
190             ('kanban', 'Kanban'),
191             ('search','Search'),
192             ('qweb', 'QWeb')], string='View Type'),
193         'arch': fields.function(_arch_get, fnct_inv=_arch_set, string='View Architecture', type="text", nodrop=True),
194         'arch_db': fields.text('Arch Blob'),
195         'arch_fs': fields.char('Arch Filename'),
196         'inherit_id': fields.many2one('ir.ui.view', 'Inherited View', ondelete='cascade', select=True),
197         'inherit_children_ids': fields.one2many('ir.ui.view','inherit_id', 'Inherit Views'),
198         'field_parent': fields.char('Child Field'),
199         'model_data_id': fields.function(_get_model_data, type='many2one', relation='ir.model.data', string="Model Data",
200                                          store={
201                                              _name: (lambda s, c, u, i, ctx=None: i, None, 10),
202                                              'ir.model.data': (_views_from_model_data, ['model', 'res_id'], 10),
203                                          }),
204         'xml_id': fields.function(osv.osv.get_xml_id, type='char', size=128, string="External ID",
205                                   help="ID of the view defined in xml file"),
206         'groups_id': fields.many2many('res.groups', 'ir_ui_view_group_rel', 'view_id', 'group_id',
207             string='Groups', help="If this field is empty, the view applies to all users. Otherwise, the view applies to the users of those groups only."),
208         'model_ids': fields.one2many('ir.model.data', 'res_id', domain=[('model','=','ir.ui.view')], auto_join=True),
209         'create_date': fields.datetime('Create Date', readonly=True),
210         'write_date': fields.datetime('Last Modification Date', readonly=True),
211
212         'mode': fields.selection(
213             [('primary', "Base view"), ('extension', "Extension View")],
214             string="View inheritance mode", required=True,
215             help="""Only applies if this view inherits from an other one (inherit_id is not False/Null).
216
217 * if extension (default), if this view is requested the closest primary view
218   is looked up (via inherit_id), then all views inheriting from it with this
219   view's model are applied
220 * if primary, the closest primary view is fully resolved (even if it uses a
221   different model than this one), then this view's inheritance specs
222   (<xpath/>) are applied, and the result is used as if it were this view's
223   actual arch.
224 """),
225         'active': fields.boolean("Active", required=True,
226             help="""If this view is inherited,
227 * if True, the view always extends its parent
228 * if False, the view currently does not extend its parent but can be enabled
229              """),
230     }
231     _defaults = {
232         'mode': 'primary',
233         'active': True,
234         'priority': 16,
235     }
236     _order = "priority,name"
237
238     # Holds the RNG schema
239     _relaxng_validator = None
240
241     def _relaxng(self):
242         if not self._relaxng_validator:
243             frng = tools.file_open(os.path.join('base','rng','view.rng'))
244             try:
245                 relaxng_doc = etree.parse(frng)
246                 self._relaxng_validator = etree.RelaxNG(relaxng_doc)
247             except Exception:
248                 _logger.exception('Failed to load RelaxNG XML schema for views validation')
249             finally:
250                 frng.close()
251         return self._relaxng_validator
252
253     def _check_xml(self, cr, uid, ids, context=None):
254         if context is None:
255             context = {}
256         context = dict(context, check_view_ids=ids)
257
258         # Sanity checks: the view should not break anything upon rendering!
259         # Any exception raised below will cause a transaction rollback.
260         for view in self.browse(cr, uid, ids, context):
261             view_def = self.read_combined(cr, uid, view.id, None, context=context)
262             view_arch_utf8 = view_def['arch']
263             if view.type != 'qweb':
264                 view_doc = etree.fromstring(view_arch_utf8)
265                 # verify that all fields used are valid, etc.
266                 self.postprocess_and_fields(cr, uid, view.model, view_doc, view.id, context=context)
267                 # RNG-based validation is not possible anymore with 7.0 forms
268                 view_docs = [view_doc]
269                 if view_docs[0].tag == 'data':
270                     # A <data> element is a wrapper for multiple root nodes
271                     view_docs = view_docs[0]
272                 validator = self._relaxng()
273                 for view_arch in view_docs:
274                     version = view_arch.get('version', '7.0')
275                     if parse_version(version) < parse_version('7.0') and validator and not validator.validate(view_arch):
276                         for error in validator.error_log:
277                             _logger.error(tools.ustr(error))
278                         return False
279                     if not valid_view(view_arch):
280                         return False
281         return True
282
283     _sql_constraints = [
284         ('inheritance_mode',
285          "CHECK (mode != 'extension' OR inherit_id IS NOT NULL)",
286          "Invalid inheritance mode: if the mode is 'extension', the view must"
287          " extend an other view"),
288     ]
289     _constraints = [
290         (_check_xml, 'Invalid view definition', ['arch']),
291     ]
292
293     def _auto_init(self, cr, context=None):
294         super(view, self)._auto_init(cr, context)
295         cr.execute('SELECT indexname FROM pg_indexes WHERE indexname = \'ir_ui_view_model_type_inherit_id\'')
296         if not cr.fetchone():
297             cr.execute('CREATE INDEX ir_ui_view_model_type_inherit_id ON ir_ui_view (model, inherit_id)')
298
299     def _compute_defaults(self, cr, uid, values, context=None):
300         if 'inherit_id' in values:
301             values.setdefault(
302                 'mode', 'extension' if values['inherit_id'] else 'primary')
303         return values
304
305     def create(self, cr, uid, values, context=None):
306         if 'type' not in values:
307             if values.get('inherit_id'):
308                 values['type'] = self.browse(cr, uid, values['inherit_id'], context).type
309             else:
310                 values['type'] = etree.fromstring(values['arch']).tag
311
312         if not values.get('name'):
313             values['name'] = "%s %s" % (values.get('model'), values['type'])
314
315         self.read_template.clear_cache(self)
316         return super(view, self).create(
317             cr, uid,
318             self._compute_defaults(cr, uid, values, context=context),
319             context=context)
320
321     def write(self, cr, uid, ids, vals, context=None):
322         if not isinstance(ids, (list, tuple)):
323             ids = [ids]
324         if context is None:
325             context = {}
326
327         # If view is modified we remove the arch_fs information thus activating the arch_db
328         # version. An `init` of the view will restore the arch_fs for the --dev mode
329         if 'arch' in vals and 'install_mode_data' not in context:
330             vals['arch_fs'] = False
331
332         # drop the corresponding view customizations (used for dashboards for example), otherwise
333         # not all users would see the updated views
334         custom_view_ids = self.pool.get('ir.ui.view.custom').search(cr, uid, [('ref_id', 'in', ids)])
335         if custom_view_ids:
336             self.pool.get('ir.ui.view.custom').unlink(cr, uid, custom_view_ids)
337
338         self.read_template.clear_cache(self)
339         ret = super(view, self).write(
340             cr, uid, ids,
341             self._compute_defaults(cr, uid, vals, context=context),
342             context)
343         return ret
344
345     def toggle(self, cr, uid, ids, context=None):
346         """ Switches between enabled and disabled statuses
347         """
348         for view in self.browse(cr, uid, ids, context=dict(context or {}, active_test=False)):
349             view.write({'active': not view.active})
350
351     # default view selection
352     def default_view(self, cr, uid, model, view_type, context=None):
353         """ Fetches the default view for the provided (model, view_type) pair:
354          primary view with the lowest priority.
355
356         :param str model:
357         :param int view_type:
358         :return: id of the default view of False if none found
359         :rtype: int
360         """
361         domain = [
362             ['model', '=', model],
363             ['type', '=', view_type],
364             ['mode', '=', 'primary'],
365         ]
366         ids = self.search(cr, uid, domain, limit=1, context=context)
367         if not ids:
368             return False
369         return ids[0]
370
371     #------------------------------------------------------
372     # Inheritance mecanism
373     #------------------------------------------------------
374     def get_inheriting_views_arch(self, cr, uid, view_id, model, context=None):
375         """Retrieves the architecture of views that inherit from the given view, from the sets of
376            views that should currently be used in the system. During the module upgrade phase it
377            may happen that a view is present in the database but the fields it relies on are not
378            fully loaded yet. This method only considers views that belong to modules whose code
379            is already loaded. Custom views defined directly in the database are loaded only
380            after the module initialization phase is completely finished.
381
382            :param int view_id: id of the view whose inheriting views should be retrieved
383            :param str model: model identifier of the inheriting views.
384            :rtype: list of tuples
385            :return: [(view_arch,view_id), ...]
386         """
387
388         user = self.pool['res.users'].browse(cr, 1, uid, context=context)
389         user_groups = frozenset(user.groups_id or ())
390
391         conditions = [
392             ['inherit_id', '=', view_id],
393             ['model', '=', model],
394             ['mode', '=', 'extension'],
395             ['active', '=', True],
396         ]
397         if self.pool._init:
398             # Module init currently in progress, only consider views from
399             # modules whose code is already loaded
400             conditions.extend([
401                 '|',
402                 ['model_ids.module', 'in', tuple(self.pool._init_modules)],
403                 ['id', 'in', context and context.get('check_view_ids') or (0,)],
404             ])
405         view_ids = self.search(cr, uid, conditions, context=context)
406
407         return [(view.arch, view.id)
408                 for view in self.browse(cr, 1, view_ids, context)
409                 if not (view.groups_id and user_groups.isdisjoint(view.groups_id))]
410
411     def raise_view_error(self, cr, uid, message, view_id, context=None):
412         view = self.browse(cr, uid, view_id, context)
413         not_avail = _('n/a')
414         message = ("%(msg)s\n\n" +
415                    _("Error context:\nView `%(view_name)s`") + 
416                    "\n[view_id: %(viewid)s, xml_id: %(xmlid)s, "
417                    "model: %(model)s, parent_id: %(parent)s]") % \
418                         {
419                           'view_name': view.name or not_avail, 
420                           'viewid': view_id or not_avail,
421                           'xmlid': view.xml_id or not_avail,
422                           'model': view.model or not_avail,
423                           'parent': view.inherit_id.id or not_avail,
424                           'msg': message,
425                         }
426         _logger.error(message)
427         raise AttributeError(message)
428
429     def locate_node(self, arch, spec):
430         """ Locate a node in a source (parent) architecture.
431
432         Given a complete source (parent) architecture (i.e. the field
433         `arch` in a view), and a 'spec' node (a node in an inheriting
434         view that specifies the location in the source view of what
435         should be changed), return (if it exists) the node in the
436         source view matching the specification.
437
438         :param arch: a parent architecture to modify
439         :param spec: a modifying node in an inheriting view
440         :return: a node in the source matching the spec
441         """
442         if spec.tag == 'xpath':
443             nodes = arch.xpath(spec.get('expr'))
444             return nodes[0] if nodes else None
445         elif spec.tag == 'field':
446             # Only compare the field name: a field can be only once in a given view
447             # at a given level (and for multilevel expressions, we should use xpath
448             # inheritance spec anyway).
449             for node in arch.iter('field'):
450                 if node.get('name') == spec.get('name'):
451                     return node
452             return None
453
454         for node in arch.iter(spec.tag):
455             if isinstance(node, SKIPPED_ELEMENT_TYPES):
456                 continue
457             if all(node.get(attr) == spec.get(attr) for attr in spec.attrib
458                    if attr not in ('position','version')):
459                 # Version spec should match parent's root element's version
460                 if spec.get('version') and spec.get('version') != arch.get('version'):
461                     return None
462                 return node
463         return None
464
465     def inherit_branding(self, specs_tree, view_id, root_id):
466         for node in specs_tree.iterchildren(tag=etree.Element):
467             xpath = node.getroottree().getpath(node)
468             if node.tag == 'data' or node.tag == 'xpath':
469                 self.inherit_branding(node, view_id, root_id)
470             else:
471                 node.set('data-oe-id', str(view_id))
472                 node.set('data-oe-source-id', str(root_id))
473                 node.set('data-oe-xpath', xpath)
474                 node.set('data-oe-model', 'ir.ui.view')
475                 node.set('data-oe-field', 'arch')
476
477         return specs_tree
478
479     def apply_inheritance_specs(self, cr, uid, source, specs_tree, inherit_id, context=None):
480         """ Apply an inheriting view (a descendant of the base view)
481
482         Apply to a source architecture all the spec nodes (i.e. nodes
483         describing where and what changes to apply to some parent
484         architecture) given by an inheriting view.
485
486         :param Element source: a parent architecture to modify
487         :param Elepect specs_tree: a modifying architecture in an inheriting view
488         :param inherit_id: the database id of specs_arch
489         :return: a modified source where the specs are applied
490         :rtype: Element
491         """
492         # Queue of specification nodes (i.e. nodes describing where and
493         # changes to apply to some parent architecture).
494         specs = [specs_tree]
495
496         while len(specs):
497             spec = specs.pop(0)
498             if isinstance(spec, SKIPPED_ELEMENT_TYPES):
499                 continue
500             if spec.tag == 'data':
501                 specs += [c for c in spec]
502                 continue
503             node = self.locate_node(source, spec)
504             if node is not None:
505                 pos = spec.get('position', 'inside')
506                 if pos == 'replace':
507                     if node.getparent() is None:
508                         source = copy.deepcopy(spec[0])
509                     else:
510                         for child in spec:
511                             node.addprevious(child)
512                         node.getparent().remove(node)
513                 elif pos == 'attributes':
514                     for child in spec.getiterator('attribute'):
515                         attribute = (child.get('name'), child.text or None)
516                         if attribute[1]:
517                             node.set(attribute[0], attribute[1])
518                         elif attribute[0] in node.attrib:
519                             del node.attrib[attribute[0]]
520                 else:
521                     sib = node.getnext()
522                     for child in spec:
523                         if pos == 'inside':
524                             node.append(child)
525                         elif pos == 'after':
526                             if sib is None:
527                                 node.addnext(child)
528                                 node = child
529                             else:
530                                 sib.addprevious(child)
531                         elif pos == 'before':
532                             node.addprevious(child)
533                         else:
534                             self.raise_view_error(cr, uid, _("Invalid position attribute: '%s'") % pos, inherit_id, context=context)
535             else:
536                 attrs = ''.join([
537                     ' %s="%s"' % (attr, spec.get(attr))
538                     for attr in spec.attrib
539                     if attr != 'position'
540                 ])
541                 tag = "<%s%s>" % (spec.tag, attrs)
542                 self.raise_view_error(cr, uid, _("Element '%s' cannot be located in parent view") % tag, inherit_id, context=context)
543
544         return source
545
546     def apply_view_inheritance(self, cr, uid, source, source_id, model, root_id=None, context=None):
547         """ Apply all the (directly and indirectly) inheriting views.
548
549         :param source: a parent architecture to modify (with parent modifications already applied)
550         :param source_id: the database view_id of the parent view
551         :param model: the original model for which we create a view (not
552             necessarily the same as the source's model); only the inheriting
553             views with that specific model will be applied.
554         :return: a modified source where all the modifying architecture are applied
555         """
556         if context is None: context = {}
557         if root_id is None:
558             root_id = source_id
559         sql_inherit = self.get_inheriting_views_arch(cr, uid, source_id, model, context=context)
560         for (specs, view_id) in sql_inherit:
561             specs_tree = etree.fromstring(specs.encode('utf-8'))
562             if context.get('inherit_branding'):
563                 self.inherit_branding(specs_tree, view_id, root_id)
564             source = self.apply_inheritance_specs(cr, uid, source, specs_tree, view_id, context=context)
565             source = self.apply_view_inheritance(cr, uid, source, view_id, model, root_id=root_id, context=context)
566         return source
567
568     def read_combined(self, cr, uid, view_id, fields=None, context=None):
569         """
570         Utility function to get a view combined with its inherited views.
571
572         * Gets the top of the view tree if a sub-view is requested
573         * Applies all inherited archs on the root view
574         * Returns the view with all requested fields
575           .. note:: ``arch`` is always added to the fields list even if not
576                     requested (similar to ``id``)
577         """
578         if context is None: context = {}
579
580         # if view_id is not a root view, climb back to the top.
581         base = v = self.browse(cr, uid, view_id, context=context)
582         while v.mode != 'primary':
583             v = v.inherit_id
584         root_id = v.id
585
586         # arch and model fields are always returned
587         if fields:
588             fields = list(set(fields) | set(['arch', 'model']))
589
590         # read the view arch
591         [view] = self.read(cr, uid, [root_id], fields=fields, context=context)
592         view_arch = etree.fromstring(view['arch'].encode('utf-8'))
593         if not v.inherit_id:
594             arch_tree = view_arch
595         else:
596             parent_view = self.read_combined(
597                 cr, uid, v.inherit_id.id, fields=fields, context=context)
598             arch_tree = etree.fromstring(parent_view['arch'])
599             self.apply_inheritance_specs(
600                 cr, uid, arch_tree, view_arch, parent_view['id'], context=context)
601
602
603         if context.get('inherit_branding'):
604             arch_tree.attrib.update({
605                 'data-oe-model': 'ir.ui.view',
606                 'data-oe-id': str(root_id),
607                 'data-oe-field': 'arch',
608             })
609
610         # and apply inheritance
611         arch = self.apply_view_inheritance(
612             cr, uid, arch_tree, root_id, base.model, context=context)
613
614         return dict(view, arch=etree.tostring(arch, encoding='utf-8'))
615
616     #------------------------------------------------------
617     # Postprocessing: translation, groups and modifiers
618     #------------------------------------------------------
619     # TODO: 
620     # - split postprocess so that it can be used instead of translate_qweb
621     # - remove group processing from ir_qweb
622     #------------------------------------------------------
623     def postprocess(self, cr, user, model, node, view_id, in_tree_view, model_fields, context=None):
624         """Return the description of the fields in the node.
625
626         In a normal call to this method, node is a complete view architecture
627         but it is actually possible to give some sub-node (this is used so
628         that the method can call itself recursively).
629
630         Originally, the field descriptions are drawn from the node itself.
631         But there is now some code calling fields_get() in order to merge some
632         of those information in the architecture.
633
634         """
635         if context is None:
636             context = {}
637         result = False
638         fields = {}
639         children = True
640
641         modifiers = {}
642         Model = self.pool.get(model)
643         if Model is None:
644             self.raise_view_error(cr, user, _('Model not found: %(model)s') % dict(model=model),
645                                   view_id, context)
646
647         def encode(s):
648             if isinstance(s, unicode):
649                 return s.encode('utf8')
650             return s
651
652         def check_group(node):
653             """Apply group restrictions,  may be set at view level or model level::
654                * at view level this means the element should be made invisible to
655                  people who are not members
656                * at model level (exclusively for fields, obviously), this means
657                  the field should be completely removed from the view, as it is
658                  completely unavailable for non-members
659
660                :return: True if field should be included in the result of fields_view_get
661             """
662             if node.tag == 'field' and node.get('name') in Model._fields:
663                 field = Model._fields[node.get('name')]
664                 if field.groups and not self.user_has_groups(
665                         cr, user, groups=field.groups, context=context):
666                     node.getparent().remove(node)
667                     fields.pop(node.get('name'), None)
668                     # no point processing view-level ``groups`` anymore, return
669                     return False
670             if node.get('groups'):
671                 can_see = self.user_has_groups(
672                     cr, user, groups=node.get('groups'), context=context)
673                 if not can_see:
674                     node.set('invisible', '1')
675                     modifiers['invisible'] = True
676                     if 'attrs' in node.attrib:
677                         del(node.attrib['attrs']) #avoid making field visible later
678                 del(node.attrib['groups'])
679             return True
680
681         if node.tag in ('field', 'node', 'arrow'):
682             if node.get('object'):
683                 attrs = {}
684                 views = {}
685                 xml = "<form>"
686                 for f in node:
687                     if f.tag == 'field':
688                         xml += etree.tostring(f, encoding="utf-8")
689                 xml += "</form>"
690                 new_xml = etree.fromstring(encode(xml))
691                 ctx = context.copy()
692                 ctx['base_model_name'] = model
693                 xarch, xfields = self.postprocess_and_fields(cr, user, node.get('object'), new_xml, view_id, ctx)
694                 views['form'] = {
695                     'arch': xarch,
696                     'fields': xfields
697                 }
698                 attrs = {'views': views}
699                 fields = xfields
700             if node.get('name'):
701                 attrs = {}
702                 field = Model._fields.get(node.get('name'))
703                 if field:
704                     children = False
705                     views = {}
706                     for f in node:
707                         if f.tag in ('form', 'tree', 'graph', 'kanban', 'calendar'):
708                             node.remove(f)
709                             ctx = context.copy()
710                             ctx['base_model_name'] = model
711                             xarch, xfields = self.postprocess_and_fields(cr, user, field.comodel_name, f, view_id, ctx)
712                             views[str(f.tag)] = {
713                                 'arch': xarch,
714                                 'fields': xfields
715                             }
716                     attrs = {'views': views}
717                 fields[node.get('name')] = attrs
718
719                 field = model_fields.get(node.get('name'))
720                 if field:
721                     orm.transfer_field_to_modifiers(field, modifiers)
722
723         elif node.tag in ('form', 'tree'):
724             result = Model.view_header_get(cr, user, False, node.tag, context)
725             if result:
726                 node.set('string', result)
727             in_tree_view = node.tag == 'tree'
728
729         elif node.tag == 'calendar':
730             for additional_field in ('date_start', 'date_delay', 'date_stop', 'color', 'all_day', 'attendee'):
731                 if node.get(additional_field):
732                     fields[node.get(additional_field)] = {}
733
734         if not check_group(node):
735             # node must be removed, no need to proceed further with its children
736             return fields
737
738         # The view architeture overrides the python model.
739         # Get the attrs before they are (possibly) deleted by check_group below
740         orm.transfer_node_to_modifiers(node, modifiers, context, in_tree_view)
741
742         # TODO remove attrs counterpart in modifiers when invisible is true ?
743
744         # translate view
745         if 'lang' in context:
746             Translations = self.pool['ir.translation']
747             if node.text and node.text.strip():
748                 trans = Translations._get_source(cr, user, model, 'view', context['lang'], node.text.strip())
749                 if trans:
750                     node.text = node.text.replace(node.text.strip(), trans)
751             if node.tail and node.tail.strip():
752                 trans = Translations._get_source(cr, user, model, 'view', context['lang'], node.tail.strip())
753                 if trans:
754                     node.tail =  node.tail.replace(node.tail.strip(), trans)
755
756             if node.get('string') and not result:
757                 trans = Translations._get_source(cr, user, model, 'view', context['lang'], node.get('string'))
758                 if trans == node.get('string') and ('base_model_name' in context):
759                     # If translation is same as source, perhaps we'd have more luck with the alternative model name
760                     # (in case we are in a mixed situation, such as an inherited view where parent_view.model != model
761                     trans = Translations._get_source(cr, user, context['base_model_name'], 'view', context['lang'], node.get('string'))
762                 if trans:
763                     node.set('string', trans)
764
765             for attr_name in ('confirm', 'sum', 'avg', 'help', 'placeholder'):
766                 attr_value = node.get(attr_name)
767                 if attr_value:
768                     trans = Translations._get_source(cr, user, model, 'view', context['lang'], attr_value)
769                     if trans:
770                         node.set(attr_name, trans)
771
772         for f in node:
773             if children or (node.tag == 'field' and f.tag in ('filter','separator')):
774                 fields.update(self.postprocess(cr, user, model, f, view_id, in_tree_view, model_fields, context))
775
776         orm.transfer_modifiers_to_node(modifiers, node)
777         return fields
778
779     def add_on_change(self, cr, user, model_name, arch):
780         """ Add attribute on_change="1" on fields that are dependencies of
781             computed fields on the same view.
782         """
783         # map each field object to its corresponding nodes in arch
784         field_nodes = collections.defaultdict(list)
785
786         def collect(node, model):
787             if node.tag == 'field':
788                 field = model._fields.get(node.get('name'))
789                 if field:
790                     field_nodes[field].append(node)
791                     if field.relational:
792                         model = self.pool.get(field.comodel_name)
793             for child in node:
794                 collect(child, model)
795
796         collect(arch, self.pool[model_name])
797
798         for field, nodes in field_nodes.iteritems():
799             # if field should trigger an onchange, add on_change="1" on the
800             # nodes referring to field
801             model = self.pool[field.model_name]
802             if model._has_onchange(field, field_nodes):
803                 for node in nodes:
804                     if not node.get('on_change'):
805                         node.set('on_change', '1')
806
807         return arch
808
809     def _disable_workflow_buttons(self, cr, user, model, node):
810         """ Set the buttons in node to readonly if the user can't activate them. """
811         if model is None or user == 1:
812             # admin user can always activate workflow buttons
813             return node
814
815         # TODO handle the case of more than one workflow for a model or multiple
816         # transitions with different groups and same signal
817         usersobj = self.pool.get('res.users')
818         buttons = (n for n in node.getiterator('button') if n.get('type') != 'object')
819         for button in buttons:
820             user_groups = usersobj.read(cr, user, [user], ['groups_id'])[0]['groups_id']
821             cr.execute("""SELECT DISTINCT t.group_id
822                         FROM wkf
823                   INNER JOIN wkf_activity a ON a.wkf_id = wkf.id
824                   INNER JOIN wkf_transition t ON (t.act_to = a.id)
825                        WHERE wkf.osv = %s
826                          AND t.signal = %s
827                          AND t.group_id is NOT NULL
828                    """, (model, button.get('name')))
829             group_ids = [x[0] for x in cr.fetchall() if x[0]]
830             can_click = not group_ids or bool(set(user_groups).intersection(group_ids))
831             button.set('readonly', str(int(not can_click)))
832         return node
833
834     def postprocess_and_fields(self, cr, user, model, node, view_id, context=None):
835         """ Return an architecture and a description of all the fields.
836
837         The field description combines the result of fields_get() and
838         postprocess().
839
840         :param node: the architecture as as an etree
841         :return: a tuple (arch, fields) where arch is the given node as a
842             string and fields is the description of all the fields.
843
844         """
845         fields = {}
846         Model = self.pool.get(model)
847         if Model is None:
848             self.raise_view_error(cr, user, _('Model not found: %(model)s') % dict(model=model), view_id, context)
849
850         if node.tag == 'diagram':
851             if node.getchildren()[0].tag == 'node':
852                 node_model = self.pool[node.getchildren()[0].get('object')]
853                 node_fields = node_model.fields_get(cr, user, None, context)
854                 fields.update(node_fields)
855                 if not node.get("create") and not node_model.check_access_rights(cr, user, 'create', raise_exception=False):
856                     node.set("create", 'false')
857             if node.getchildren()[1].tag == 'arrow':
858                 arrow_fields = self.pool[node.getchildren()[1].get('object')].fields_get(cr, user, None, context)
859                 fields.update(arrow_fields)
860         else:
861             fields = Model.fields_get(cr, user, None, context)
862
863         node = self.add_on_change(cr, user, model, node)
864         fields_def = self.postprocess(cr, user, model, node, view_id, False, fields, context=context)
865         node = self._disable_workflow_buttons(cr, user, model, node)
866         if node.tag in ('kanban', 'tree', 'form', 'gantt'):
867             for action, operation in (('create', 'create'), ('delete', 'unlink'), ('edit', 'write')):
868                 if not node.get(action) and not Model.check_access_rights(cr, user, operation, raise_exception=False):
869                     node.set(action, 'false')
870         if node.tag in ('kanban'):
871             group_by_field = node.get('default_group_by')
872             if group_by_field and Model._all_columns.get(group_by_field):
873                 group_by_column = Model._all_columns[group_by_field].column
874                 if group_by_column._type == 'many2one':
875                     group_by_model = Model.pool.get(group_by_column._obj)
876                     for action, operation in (('group_create', 'create'), ('group_delete', 'unlink'), ('group_edit', 'write')):
877                         if not node.get(action) and not group_by_model.check_access_rights(cr, user, operation, raise_exception=False):
878                             node.set(action, 'false')
879
880         arch = etree.tostring(node, encoding="utf-8").replace('\t', '')
881         for k in fields.keys():
882             if k not in fields_def:
883                 del fields[k]
884         for field in fields_def:
885             if field == 'id':
886                 # sometime, the view may contain the (invisible) field 'id' needed for a domain (when 2 objects have cross references)
887                 fields['id'] = {'readonly': True, 'type': 'integer', 'string': 'ID'}
888             elif field in fields:
889                 fields[field].update(fields_def[field])
890             else:
891                 message = _("Field `%(field_name)s` does not exist") % \
892                                 dict(field_name=field)
893                 self.raise_view_error(cr, user, message, view_id, context)
894         return arch, fields
895
896     #------------------------------------------------------
897     # QWeb template views
898     #------------------------------------------------------
899     read_template_cache = dict(accepted_keys=('lang','inherit_branding', 'editable', 'translatable'))
900     if config['dev_mode']:
901         read_template_cache['size'] = 0
902     @tools.ormcache_context(**read_template_cache)
903     def read_template(self, cr, uid, xml_id, context=None):
904         if isinstance(xml_id, (int, long)):
905             view_id = xml_id
906         else:
907             if '.' not in xml_id:
908                 raise ValueError('Invalid template id: %r' % (xml_id,))
909             view_id = self.pool['ir.model.data'].xmlid_to_res_id(cr, uid, xml_id, raise_if_not_found=True)
910
911         arch = self.read_combined(cr, uid, view_id, fields=['arch'], context=context)['arch']
912         arch_tree = etree.fromstring(arch)
913
914         if 'lang' in context:
915             arch_tree = self.translate_qweb(cr, uid, view_id, arch_tree, context['lang'], context)
916
917         self.distribute_branding(arch_tree)
918         root = etree.Element('templates')
919         root.append(arch_tree)
920         arch = etree.tostring(root, encoding='utf-8', xml_declaration=True)
921         return arch
922
923     def clear_cache(self):
924         self.read_template.clear_cache(self)
925
926     def _contains_branded(self, node):
927         return node.tag == 't'\
928             or 't-raw' in node.attrib\
929             or any(self.is_node_branded(child) for child in node.iterdescendants())
930
931     def _pop_view_branding(self, element):
932         distributed_branding = dict(
933             (attribute, element.attrib.pop(attribute))
934             for attribute in MOVABLE_BRANDING
935             if element.get(attribute))
936         return distributed_branding
937
938     def distribute_branding(self, e, branding=None, parent_xpath='',
939                             index_map=misc.ConstantMapping(1)):
940         if e.get('t-ignore') or e.tag == 'head':
941             # remove any view branding possibly injected by inheritance
942             attrs = set(MOVABLE_BRANDING)
943             for descendant in e.iterdescendants(tag=etree.Element):
944                 if not attrs.intersection(descendant.attrib): continue
945                 self._pop_view_branding(descendant)
946             # TODO: find a better name and check if we have a string to boolean helper
947             return
948
949         node_path = e.get('data-oe-xpath')
950         if node_path is None:
951             node_path = "%s/%s[%d]" % (parent_xpath, e.tag, index_map[e.tag])
952         if branding and not (e.get('data-oe-model') or e.get('t-field')):
953             e.attrib.update(branding)
954             e.set('data-oe-xpath', node_path)
955         if not e.get('data-oe-model'): return
956
957         if set(('t-esc', 't-escf', 't-raw', 't-rawf')).intersection(e.attrib):
958             # nodes which fully generate their content and have no reason to
959             # be branded because they can not sensibly be edited
960             self._pop_view_branding(e)
961         elif self._contains_branded(e):
962             # if a branded element contains branded elements distribute own
963             # branding to children unless it's t-raw, then just remove branding
964             # on current element
965             distributed_branding = self._pop_view_branding(e)
966
967             if 't-raw' not in e.attrib:
968                 # TODO: collections.Counter if remove p2.6 compat
969                 # running index by tag type, for XPath query generation
970                 indexes = collections.defaultdict(lambda: 0)
971                 for child in e.iterchildren(tag=etree.Element):
972                     if child.get('data-oe-xpath'):
973                         # injected by view inheritance, skip otherwise
974                         # generated xpath is incorrect
975                         self.distribute_branding(child)
976                     else:
977                         indexes[child.tag] += 1
978                         self.distribute_branding(
979                             child, distributed_branding,
980                             parent_xpath=node_path, index_map=indexes)
981
982     def is_node_branded(self, node):
983         """ Finds out whether a node is branded or qweb-active (bears a
984         @data-oe-model or a @t-* *which is not t-field* as t-field does not
985         section out views)
986
987         :param node: an etree-compatible element to test
988         :type node: etree._Element
989         :rtype: boolean
990         """
991         return any(
992             (attr == 'data-oe-model' or (attr != 't-field' and attr.startswith('t-')))
993             for attr in node.attrib
994         )
995
996     def translate_qweb(self, cr, uid, id_, arch, lang, context=None):
997         # TODO: this should be moved in a place before inheritance is applied
998         #       but process() is only called on fields_view_get()
999         Translations = self.pool['ir.translation']
1000         h = HTMLParser.HTMLParser()
1001         def get_trans(text):
1002             if not text or not text.strip():
1003                 return None
1004             text = h.unescape(text.strip())
1005             if len(text) < 2 or (text.startswith('<!') and text.endswith('>')):
1006                 return None
1007             return Translations._get_source(cr, uid, 'website', 'view', lang, text, id_)
1008
1009         if type(arch) not in SKIPPED_ELEMENT_TYPES and arch.tag not in SKIPPED_ELEMENTS:
1010             text = get_trans(arch.text)
1011             if text:
1012                 arch.text = arch.text.replace(arch.text.strip(), text)
1013             tail = get_trans(arch.tail)
1014             if tail:
1015                 arch.tail = arch.tail.replace(arch.tail.strip(), tail)
1016
1017             for attr_name in ('title', 'alt', 'label', 'placeholder'):
1018                 attr = get_trans(arch.get(attr_name))
1019                 if attr:
1020                     arch.set(attr_name, attr)
1021             for node in arch.iterchildren("*"):
1022                 self.translate_qweb(cr, uid, id_, node, lang, context)
1023         return arch
1024
1025     @openerp.tools.ormcache()
1026     def get_view_xmlid(self, cr, uid, id):
1027         imd = self.pool['ir.model.data']
1028         domain = [('model', '=', 'ir.ui.view'), ('res_id', '=', id)]
1029         xmlid = imd.search_read(cr, uid, domain, ['module', 'name'])[0]
1030         return '%s.%s' % (xmlid['module'], xmlid['name'])
1031
1032     @api.cr_uid_ids_context
1033     def render(self, cr, uid, id_or_xml_id, values=None, engine='ir.qweb', context=None):
1034         if isinstance(id_or_xml_id, list):
1035             id_or_xml_id = id_or_xml_id[0]
1036
1037         if not context:
1038             context = {}
1039
1040         if values is None:
1041             values = dict()
1042         qcontext = dict(
1043             keep_query=keep_query,
1044             request=request, # might be unbound if we're not in an httprequest context
1045             debug=request.debug if request else False,
1046             json=simplejson,
1047             quote_plus=werkzeug.url_quote_plus,
1048             time=time,
1049             datetime=datetime,
1050             relativedelta=relativedelta,
1051         )
1052         qcontext.update(values)
1053
1054         # TODO: This helper can be used by any template that wants to embedd the backend.
1055         #       It is currently necessary because the ir.ui.view bundle inheritance does not
1056         #       match the module dependency graph.
1057         def get_modules_order():
1058             if request:
1059                 from openerp.addons.web.controllers.main import module_boot
1060                 return simplejson.dumps(module_boot())
1061             return '[]'
1062         qcontext['get_modules_order'] = get_modules_order
1063
1064         def loader(name):
1065             return self.read_template(cr, uid, name, context=context)
1066
1067         return self.pool[engine].render(cr, uid, id_or_xml_id, qcontext, loader=loader, context=context)
1068
1069     #------------------------------------------------------
1070     # Misc
1071     #------------------------------------------------------
1072
1073     def graph_get(self, cr, uid, id, model, node_obj, conn_obj, src_node, des_node, label, scale, context=None):
1074         nodes=[]
1075         nodes_name=[]
1076         transitions=[]
1077         start=[]
1078         tres={}
1079         labels={}
1080         no_ancester=[]
1081         blank_nodes = []
1082
1083         _Model_Obj = self.pool[model]
1084         _Node_Obj = self.pool[node_obj]
1085         _Arrow_Obj = self.pool[conn_obj]
1086
1087         for model_key,model_value in _Model_Obj._columns.items():
1088                 if model_value._type=='one2many':
1089                     if model_value._obj==node_obj:
1090                         _Node_Field=model_key
1091                         _Model_Field=model_value._fields_id
1092                     flag=False
1093                     for node_key,node_value in _Node_Obj._columns.items():
1094                         if node_value._type=='one2many':
1095                              if node_value._obj==conn_obj:
1096                                  if src_node in _Arrow_Obj._columns and flag:
1097                                     _Source_Field=node_key
1098                                  if des_node in _Arrow_Obj._columns and not flag:
1099                                     _Destination_Field=node_key
1100                                     flag = True
1101
1102         datas = _Model_Obj.read(cr, uid, id, [],context)
1103         for a in _Node_Obj.read(cr,uid,datas[_Node_Field],[]):
1104             if a[_Source_Field] or a[_Destination_Field]:
1105                 nodes_name.append((a['id'],a['name']))
1106                 nodes.append(a['id'])
1107             else:
1108                 blank_nodes.append({'id': a['id'],'name':a['name']})
1109
1110             if a.has_key('flow_start') and a['flow_start']:
1111                 start.append(a['id'])
1112             else:
1113                 if not a[_Source_Field]:
1114                     no_ancester.append(a['id'])
1115             for t in _Arrow_Obj.read(cr,uid, a[_Destination_Field],[]):
1116                 transitions.append((a['id'], t[des_node][0]))
1117                 tres[str(t['id'])] = (a['id'],t[des_node][0])
1118                 label_string = ""
1119                 if label:
1120                     for lbl in eval(label):
1121                         if t.has_key(tools.ustr(lbl)) and tools.ustr(t[lbl])=='False':
1122                             label_string += ' '
1123                         else:
1124                             label_string = label_string + " " + tools.ustr(t[lbl])
1125                 labels[str(t['id'])] = (a['id'],label_string)
1126         g  = graph(nodes, transitions, no_ancester)
1127         g.process(start)
1128         g.scale(*scale)
1129         result = g.result_get()
1130         results = {}
1131         for node in nodes_name:
1132             results[str(node[0])] = result[node[0]]
1133             results[str(node[0])]['name'] = node[1]
1134         return {'nodes': results,
1135                 'transitions': tres,
1136                 'label' : labels,
1137                 'blank_nodes': blank_nodes,
1138                 'node_parent_field': _Model_Field,}
1139
1140     def _validate_custom_views(self, cr, uid, model):
1141         """Validate architecture of custom views (= without xml id) for a given model.
1142             This method is called at the end of registry update.
1143         """
1144         cr.execute("""SELECT max(v.id)
1145                         FROM ir_ui_view v
1146                    LEFT JOIN ir_model_data md ON (md.model = 'ir.ui.view' AND md.res_id = v.id)
1147                        WHERE md.module IS NULL
1148                          AND v.model = %s
1149                     GROUP BY coalesce(v.inherit_id, v.id)
1150                    """, (model,))
1151
1152         ids = map(itemgetter(0), cr.fetchall())
1153         return self._check_xml(cr, uid, ids)
1154
1155     def _validate_module_views(self, cr, uid, module):
1156         """Validate architecture of all the views of a given module"""
1157         assert not self.pool._init or module in self.pool._init_modules
1158         xmlid_filter = ''
1159         params = (module,)
1160         if self.pool._init:
1161             # only validate the views that are still existing...
1162             xmlid_filter = "AND md.name IN %s"
1163             names = tuple(name for (xmod, name), (model, res_id) in self.pool.model_data_reference_ids.items() if xmod == module and model == self._name)
1164             if not names:
1165                 # no views for this module, nothing to validate
1166                 return
1167             params += (names,)
1168         cr.execute("""SELECT max(v.id)
1169                         FROM ir_ui_view v
1170                    LEFT JOIN ir_model_data md ON (md.model = 'ir.ui.view' AND md.res_id = v.id)
1171                        WHERE md.module = %s
1172                          {0}
1173                     GROUP BY coalesce(v.inherit_id, v.id)
1174                    """.format(xmlid_filter), params)
1175
1176         for vid, in cr.fetchall():
1177             if not self._check_xml(cr, uid, [vid]):
1178                 self.raise_view_error(cr, uid, "Can't validate view", vid)
1179
1180 # vim:et: