Files
odoo_source/addons/web/controllers/utils.py
T
Florian Charlier dae28c4b46 [IMP] base: add _is_internal method to res.users
No method was readily available to know if a user is `internal` (has
group `base.group_user`), which was inconsistent with other base groups.

_is_internal is now used in the codebase where it is clear that
`.has_group('base.group_user')` is called on a single record.

Part-of: odoo/odoo#85703
2022-06-14 09:35:57 +02:00

427 lines
17 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import copy
import hashlib
import io
import logging
import re
from collections import OrderedDict, defaultdict
import babel.messages.pofile
import werkzeug
import werkzeug.exceptions
import werkzeug.utils
import werkzeug.wrappers
import werkzeug.wsgi
from lxml import etree
from werkzeug.urls import iri_to_uri
from odoo.tools import apply_inheritance_specs
from odoo.tools.translate import _
from odoo.tools.misc import file_open
from odoo import http
from odoo.http import request
_logger = logging.getLogger(__name__)
def clean_action(action, env):
action_type = action.setdefault('type', 'ir.actions.act_window_close')
if action_type == 'ir.actions.act_window':
action = fix_view_modes(action)
# When returning an action, keep only relevant fields/properties
readable_fields = env[action['type']]._get_readable_fields()
action_type_fields = env[action['type']]._fields.keys()
cleaned_action = {
field: value
for field, value in action.items()
# keep allowed fields and custom properties fields
if field in readable_fields or field not in action_type_fields
}
# Warn about custom properties fields, because use is discouraged
action_name = action.get('name') or action
custom_properties = action.keys() - readable_fields - action_type_fields
if custom_properties:
_logger.warning("Action %r contains custom properties %s. Passing them "
"via the `params` or `context` properties is recommended instead",
action_name, ', '.join(map(repr, custom_properties)))
return cleaned_action
def ensure_db(redirect='/web/database/selector'):
# This helper should be used in web client auth="none" routes
# if those routes needs a db to work with.
# If the heuristics does not find any database, then the users will be
# redirected to db selector or any url specified by `redirect` argument.
# If the db is taken out of a query parameter, it will be checked against
# `http.db_filter()` in order to ensure it's legit and thus avoid db
# forgering that could lead to xss attacks.
db = request.params.get('db') and request.params.get('db').strip()
# Ensure db is legit
if db and db not in http.db_filter([db]):
db = None
if db and not request.session.db:
# User asked a specific database on a new session.
# That mean the nodb router has been used to find the route
# Depending on installed module in the database, the rendering of the page
# may depend on data injected by the database route dispatcher.
# Thus, we redirect the user to the same page but with the session cookie set.
# This will force using the database route dispatcher...
r = request.httprequest
url_redirect = werkzeug.urls.url_parse(r.base_url)
if r.query_string:
# in P3, request.query_string is bytes, the rest is text, can't mix them
query_string = iri_to_uri(r.query_string)
url_redirect = url_redirect.replace(query=query_string)
request.session.db = db
werkzeug.exceptions.abort(request.redirect(url_redirect.to_url(), 302))
# if db not provided, use the session one
if not db and request.session.db and http.db_filter([request.session.db]):
db = request.session.db
# if no database provided and no database in session, use monodb
if not db:
all_dbs = http.db_list(force=True)
if len(all_dbs) == 1:
db = all_dbs[0]
# if no db can be found til here, send to the database selector
# the database selector will redirect to database manager if needed
if not db:
werkzeug.exceptions.abort(request.redirect(redirect, 303))
# always switch the session to the computed db
if db != request.session.db:
request.session = http.root.session_store.new()
request.session.update(http.DEFAULT_SESSION, db=db)
request.session.context['lang'] = request.default_lang()
werkzeug.exceptions.abort(request.redirect(request.httprequest.url, 302))
def fix_view_modes(action):
""" For historical reasons, Odoo has weird dealings in relation to
view_mode and the view_type attribute (on window actions):
* one of the view modes is ``tree``, which stands for both list views
and tree views
* the choice is made by checking ``view_type``, which is either
``form`` for a list view or ``tree`` for an actual tree view
This methods simply folds the view_type into view_mode by adding a
new view mode ``list`` which is the result of the ``tree`` view_mode
in conjunction with the ``form`` view_type.
TODO: this should go into the doc, some kind of "peculiarities" section
:param dict action: an action descriptor
:returns: nothing, the action is modified in place
"""
if not action.get('views'):
generate_views(action)
if action.pop('view_type', 'form') != 'form':
return action
if 'view_mode' in action:
action['view_mode'] = ','.join(
mode if mode != 'tree' else 'list'
for mode in action['view_mode'].split(','))
action['views'] = [
[id, mode if mode != 'tree' else 'list']
for id, mode in action['views']
]
return action
# I think generate_views,fix_view_modes should go into js ActionManager
def generate_views(action):
"""
While the server generates a sequence called "views" computing dependencies
between a bunch of stuff for views coming directly from the database
(the ``ir.actions.act_window model``), it's also possible for e.g. buttons
to return custom view dictionaries generated on the fly.
In that case, there is no ``views`` key available on the action.
Since the web client relies on ``action['views']``, generate it here from
``view_mode`` and ``view_id``.
Currently handles two different cases:
* no view_id, multiple view_mode
* single view_id, single view_mode
:param dict action: action descriptor dictionary to generate a views key for
"""
view_id = action.get('view_id') or False
if isinstance(view_id, (list, tuple)):
view_id = view_id[0]
# providing at least one view mode is a requirement, not an option
view_modes = action['view_mode'].split(',')
if len(view_modes) > 1:
if view_id:
raise ValueError('Non-db action dictionaries should provide '
'either multiple view modes or a single view '
'mode and an optional view id.\n\n Got view '
'modes %r and view id %r for action %r' % (
view_modes, view_id, action))
action['views'] = [(False, mode) for mode in view_modes]
return
action['views'] = [(view_id, view_modes[0])]
def _get_login_redirect_url(uid, redirect=None):
""" Decide if user requires a specific post-login redirect, e.g. for 2FA, or if they are
fully logged and can proceed to the requested URL
"""
if request.session.uid: # fully logged
return redirect or ('/web' if is_user_internal(request.session.uid)
else '/web/login_successful')
# partial session (MFA)
url = request.env(user=uid)['res.users'].browse(uid)._mfa_url()
if not redirect:
return url
parsed = werkzeug.urls.url_parse(url)
qs = parsed.decode_query()
qs['redirect'] = redirect
return parsed.replace(query=werkzeug.urls.url_encode(qs)).to_url()
def is_user_internal(uid):
return request.env['res.users'].browse(uid)._is_internal()
def _local_web_translations(trans_file):
messages = []
try:
with file_open(trans_file, filter_ext=('.po')) as t_file:
po = babel.messages.pofile.read_po(t_file)
except Exception:
return
for x in po:
if x.id and x.string and "openerp-web" in x.auto_comments:
messages.append({'id': x.id, 'string': x.string})
return messages
class HomeStaticTemplateHelpers:
r"""
Helper Class that wraps the reading of static qweb templates files
and xpath inheritance applied to those templates
/!\ Template inheritance order is defined by ir.module.module natural order
which is "sequence, name"
Then a topological sort is applied, which just puts dependencies
of a module before that module
"""
NAME_TEMPLATE_DIRECTIVE = 't-name'
STATIC_INHERIT_DIRECTIVE = 't-inherit'
STATIC_INHERIT_MODE_DIRECTIVE = 't-inherit-mode'
PRIMARY_MODE = 'primary'
EXTENSION_MODE = 'extension'
DEFAULT_MODE = PRIMARY_MODE
COMMENT_PATTERN = r'Modified by [\s\w\-.]+ from [\s\w\-.]+'
def __init__(self, addons, db, checksum_only=False, debug=False):
"""
:param str|list addons: plain list or comma separated list of addons
:param str db: the current db we are working on
:param bool checksum_only: only computes the checksum of all files for addons
:param str debug: the debug mode of the session
"""
self.addons = addons.split(',') if isinstance(addons, str) else addons
self.db = db
self.debug = debug
self.checksum_only = checksum_only
self.template_dict = OrderedDict()
def _get_parent_template(self, addon, template):
"""Computes the real addon name and the template name
of the parent template (the one that is inherited from)
:param str addon: the addon the template is declared in
:param etree template: the current template we are are handling
:returns: (str, str)
"""
original_template_name = template.attrib[self.STATIC_INHERIT_DIRECTIVE]
split_name_attempt = original_template_name.split('.', 1)
parent_addon, parent_name = tuple(split_name_attempt) if len(split_name_attempt) == 2 else (addon, original_template_name)
if parent_addon not in self.template_dict:
if original_template_name in self.template_dict[addon]:
parent_addon = addon
parent_name = original_template_name
else:
raise ValueError(_('Module %s not loaded or inexistent, or templates of addon being loaded (%s) are misordered') % (parent_addon, addon))
if parent_name not in self.template_dict[parent_addon]:
raise ValueError(_("No template found to inherit from. Module %s and template name %s") % (parent_addon, parent_name))
return parent_addon, parent_name
def _compute_xml_tree(self, addon, file_name, source):
"""Computes the xml tree that 'source' contains
Applies inheritance specs in the process
:param str addon: the current addon we are reading files for
:param str file_name: the current name of the file we are reading
:param str source: the content of the file
:returns: etree
"""
try:
all_templates_tree = etree.parse(io.BytesIO(source), parser=etree.XMLParser(remove_comments=True)).getroot()
except etree.ParseError as e:
_logger.error("Could not parse file %s: %s", file_name, e.msg)
raise
self.template_dict.setdefault(addon, OrderedDict())
for template_tree in list(all_templates_tree):
if self.NAME_TEMPLATE_DIRECTIVE in template_tree.attrib:
template_name = template_tree.attrib[self.NAME_TEMPLATE_DIRECTIVE]
dotted_names = template_name.split('.', 1)
if len(dotted_names) > 1 and dotted_names[0] == addon:
template_name = dotted_names[1]
else:
# self.template_dict[addon] grows after processing each template
template_name = 'anonymous_template_%s' % len(self.template_dict[addon])
if self.STATIC_INHERIT_DIRECTIVE in template_tree.attrib:
inherit_mode = template_tree.attrib.get(self.STATIC_INHERIT_MODE_DIRECTIVE, self.DEFAULT_MODE)
if inherit_mode not in [self.PRIMARY_MODE, self.EXTENSION_MODE]:
raise ValueError(_("Invalid inherit mode. Module %s and template name %s") % (addon, template_name))
parent_addon, parent_name = self._get_parent_template(addon, template_tree)
# After several performance tests, we found out that deepcopy is the most efficient
# solution in this case (compared with copy, xpath with '.' and stringifying).
parent_tree = copy.deepcopy(self.template_dict[parent_addon][parent_name])
xpaths = list(template_tree)
# owl chokes on comments, disable debug comments for now
# pylint: disable=W0125
if False: # self.debug and inherit_mode == self.EXTENSION_MODE:
for xpath in xpaths:
xpath.insert(0, etree.Comment(" Modified by %s from %s " % (template_name, addon)))
elif inherit_mode == self.PRIMARY_MODE:
parent_tree.tag = template_tree.tag
inherited_template = apply_inheritance_specs(parent_tree, xpaths)
if inherit_mode == self.PRIMARY_MODE: # New template_tree: A' = B(A)
for attr_name, attr_val in template_tree.attrib.items():
if attr_name not in ('t-inherit', 't-inherit-mode'):
inherited_template.set(attr_name, attr_val)
if self.debug:
self._remove_inheritance_comments(inherited_template)
self.template_dict[addon][template_name] = inherited_template
else: # Modifies original: A = B(A)
self.template_dict[parent_addon][parent_name] = inherited_template
else:
if template_name in self.template_dict[addon]:
raise ValueError(_("Template %s already exists in module %s") % (template_name, addon))
self.template_dict[addon][template_name] = template_tree
return all_templates_tree
def _remove_inheritance_comments(self, inherited_template):
'''Remove the comments added in the template already, they come from other templates extending
the base of this inheritance
:param inherited_template:
'''
for comment in inherited_template.xpath('//comment()'):
if re.match(self.COMMENT_PATTERN, comment.text.strip()):
comment.getparent().remove(comment)
def _read_addon_file(self, path_or_url):
"""Read the content of a file or an ``ir.attachment`` record given by
``path_or_url``.
:param str path_or_url:
:returns: bytes
:raises FileNotFoundError: if the path does not match a module file
or an attachment
"""
try:
with file_open(path_or_url, 'rb') as fp:
contents = fp.read()
except FileNotFoundError as e:
attachment = request.env['ir.attachment'].sudo().search([
('url', '=', path_or_url),
('type', '=', 'binary'),
], limit=1)
if attachment:
contents = attachment.raw
else:
raise e
return contents
def _concat_xml(self, file_dict):
"""Concatenate xml files
:param dict(list) file_dict:
key: addon name
value: list of files for an addon
:returns: (concatenation_result, checksum)
:rtype: (bytes, str)
"""
checksum = hashlib.new('sha512') # sha512/256
if not file_dict:
return b'', checksum.hexdigest()
root = None
for addon, fnames in file_dict.items():
for fname in fnames:
contents = self._read_addon_file(fname)
checksum.update(contents)
if not self.checksum_only:
self._compute_xml_tree(addon, fname, contents)
if root is None:
root = etree.Element('templates')
for addon in self.template_dict.values():
for template in addon.values():
root.append(template)
return etree.tostring(root, encoding='utf-8') if root is not None else b'', checksum.hexdigest()[:64]
def _get_asset_paths(self, bundle):
"""Proxy for ir_asset._get_asset_paths
Useful to make 'self' testable.
"""
return request.env['ir.asset']._get_asset_paths(addons=self.addons, bundle=bundle, xml=True)
def _get_qweb_templates(self, bundle):
"""One and only entry point that gets and evaluates static qweb templates
:rtype: (str, str)
"""
xml_paths = defaultdict(list)
# group paths by module, keeping them in order
for path, addon, _ in self._get_asset_paths(bundle):
addon_paths = xml_paths[addon]
if path not in addon_paths:
addon_paths.append(path)
content, checksum = self._concat_xml(xml_paths)
return content, checksum
@classmethod
def get_qweb_templates_checksum(cls, addons=None, db=None, debug=False, bundle=None):
return cls(addons, db, checksum_only=True, debug=debug)._get_qweb_templates(bundle)[1]
@classmethod
def get_qweb_templates(cls, addons=None, db=None, debug=False, bundle=None):
return cls(addons, db, debug=debug)._get_qweb_templates(bundle)[0]