17.0 vanilla

This commit is contained in:
Ernad Husremovic 2025-10-03 18:05:14 +02:00
parent 2e65bf056a
commit df627a6bba
328 changed files with 578149 additions and 759311 deletions

View file

@ -43,6 +43,7 @@ from . import res_config
from . import res_currency
from . import res_company
from . import res_users
from . import res_users_settings
from . import res_users_deletion
from . import decimal_precision

View file

@ -24,80 +24,23 @@ except ImportError:
# `sassc` executable in the path.
libsass = None
from rjsmin import jsmin as rjsmin
from odoo import release, SUPERUSER_ID, _
from odoo.http import request
from odoo.modules.module import get_resource_path
from odoo.tools import (func, misc, transpile_javascript,
is_odoo_module, SourceMapGenerator, profiler,
apply_inheritance_specs)
from odoo.tools.misc import file_open, file_path, html_escape as escape
from odoo.tools.constants import SCRIPT_EXTENSIONS, STYLE_EXTENSIONS
from odoo.tools.misc import file_open, file_path
from odoo.tools.pycompat import to_text
_logger = logging.getLogger(__name__)
ANY_UNIQUE = '_' * 7
EXTENSIONS = (".js", ".css", ".scss", ".sass", ".less", ".xml")
class CompileError(RuntimeError): pass
def rjsmin(script):
""" Minify js with a clever regex.
Taken from http://opensource.perlig.de/rjsmin (version 1.1.0)
Apache License, Version 2.0 """
def subber(match):
""" Substitution callback """
groups = match.groups()
return (
groups[0] or
groups[1] or
(groups[3] and (groups[2] + '\n')) or
groups[2] or
(groups[5] and "%s%s%s" % (
groups[4] and '\n' or '',
groups[5],
groups[6] and '\n' or '',
)) or
(groups[7] and '\n') or
(groups[8] and ' ') or
(groups[9] and ' ') or
(groups[10] and ' ') or
''
)
result = re.sub(
r'([^\047"\140/\000-\040]+)|((?:(?:\047[^\047\\\r\n]*(?:\\(?:[^'
r'\r\n]|\r?\n|\r)[^\047\\\r\n]*)*\047)|(?:"[^"\\\r\n]*(?:\\(?:[^'
r'\r\n]|\r?\n|\r)[^"\\\r\n]*)*")|(?:\140[^\140\\]*(?:\\(?:[^\r\n'
r']|\r?\n|\r)[^\140\\]*)*\140))[^\047"\140/\000-\040]*)|(?<=[(,='
r':\[!&|?{};\r\n+*-])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*'
r'\*+(?:[^/*][^*]*\*+)*/))*(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-'
r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)*('
r'(?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^\r\n]|(?:\[[^\\\]\r\n]*'
r'(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r\n]*)*/))((?:[\000-\011'
r'\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*(?:(?:('
r'?://[^\r\n]*)?[\r\n])(?:[\000-\011\013\014\016-\040]|(?:/\*[^*'
r']*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040&)+,.:;=?\]|}-]))?|'
r'(?<=[\000-#%-,./:-@\[-^\140{-~-]return)(?:[\000-\011\013\014\0'
r'16-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*(?:((?:(?://[^\r'
r'\n]*)?[\r\n]))(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?'
r':[^/*][^*]*\*+)*/))*)*((?:/(?![\r\n/*])[^/\\\[\r\n]*(?:(?:\\[^'
r'\r\n]|(?:\[[^\\\]\r\n]*(?:\\[^\r\n][^\\\]\r\n]*)*\]))[^/\\\[\r'
r'\n]*)*/))((?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/'
r'*][^*]*\*+)*/))*(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-\011\013'
r'\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000'
r'-\040&)+,.:;=?\]|}-]))?|(?<=[^\000-!#%&(*,./:-@\[\\^{|~])(?:['
r'\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)'
r')*(?:((?:(?://[^\r\n]*)?[\r\n]))(?:[\000-\011\013\014\016-\040'
r']|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+(?=[^\000-\040"#%-\047'
r')*,./:-@\\-^\140|-~])|(?<=[^\000-#%-,./:-@\[-^\140{-~-])((?:['
r'\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)'
r'))+(?=[^\000-#%-,./:-@\[-^\140{-~-])|(?<=\+)((?:[\000-\011\013'
r'\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/)))+(?=\+)|(?<'
r'=-)((?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]'
r'*\*+)*/)))+(?=-)|(?:[\000-\011\013\014\016-\040]|(?:/\*[^*]*\*'
r'+(?:[^/*][^*]*\*+)*/))+|(?:(?:(?://[^\r\n]*)?[\r\n])(?:[\000-'
r'\011\013\014\016-\040]|(?:/\*[^*]*\*+(?:[^/*][^*]*\*+)*/))*)+', subber, '\n%s\n' % script
).strip()
return result
class AssetError(Exception):
pass
@ -112,9 +55,9 @@ class AssetsBundle(object):
rx_preprocess_imports = re.compile(r"""(@import\s?['"]([^'"]+)['"](;?))""")
rx_css_split = re.compile(r"\/\*\! ([a-f0-9-]+) \*\/")
TRACKED_BUNDLES = ['web.assets_common', 'web.assets_backend']
TRACKED_BUNDLES = ['web.assets_web']
def __init__(self, name, files, env=None, css=True, js=True):
def __init__(self, name, files, external_assets=(), env=None, css=True, js=True, debug_assets=False, rtl=False, assets_params=None):
"""
:param name: bundle name
:param files: files to be added to the bundle
@ -128,117 +71,89 @@ class AssetsBundle(object):
self.stylesheets = []
self.css_errors = []
self.files = files
self.user_direction = self.env['res.lang']._lang_get(
self.env.context.get('lang') or self.env.user.lang
).direction
self.rtl = rtl
self.assets_params = assets_params or {}
self.has_css = css
self.has_js = js
self._checksum_cache = {}
self.is_debug_assets = debug_assets
self.external_assets = [
url
for url in external_assets
if (css and url.rpartition('.')[2] in STYLE_EXTENSIONS) or (js and url.rpartition('.')[2] in SCRIPT_EXTENSIONS)
]
# asset-wide html "media" attribute
for f in files:
extension = f['url'].rpartition('.')[2]
params = {
'url': f['url'],
'filename': f['filename'],
'inline': f['content'],
'last_modified': None if self.is_debug_assets else f.get('last_modified'),
}
if css:
if f['atype'] == 'text/sass':
self.stylesheets.append(SassStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
elif f['atype'] == 'text/scss':
self.stylesheets.append(ScssStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
elif f['atype'] == 'text/less':
self.stylesheets.append(LessStylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
elif f['atype'] == 'text/css':
self.stylesheets.append(StylesheetAsset(self, url=f['url'], filename=f['filename'], inline=f['content'], media=f['media'], direction=self.user_direction))
css_params = {
'rtl': self.rtl,
}
if extension == 'sass':
self.stylesheets.append(SassStylesheetAsset(self, **params, **css_params))
elif extension == 'scss':
self.stylesheets.append(ScssStylesheetAsset(self, **params, **css_params))
elif extension == 'less':
self.stylesheets.append(LessStylesheetAsset(self, **params, **css_params))
elif extension == 'css':
self.stylesheets.append(StylesheetAsset(self, **params, **css_params))
if js:
if f['atype'] == 'text/javascript':
self.javascripts.append(JavascriptAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
elif f['atype'] == 'text/xml':
self.templates.append(XMLAsset(self, url=f['url'], filename=f['filename'], inline=f['content']))
if extension == 'js':
self.javascripts.append(JavascriptAsset(self, **params))
elif extension == 'xml':
self.templates.append(XMLAsset(self, **params))
def to_node(self, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False):
def get_links(self):
"""
:returns [(tagName, attributes, content)] if the tag is auto close
:returns a list of tuple. a tuple can be (url, None) or (None, inlineContent)
"""
response = []
is_debug_assets = debug and 'assets' in debug
if css and self.stylesheets:
css_attachments = self.css(is_minified=not is_debug_assets) or []
for attachment in css_attachments:
if is_debug_assets:
href = self.get_debug_asset_url(extra='rtl/' if self.user_direction == 'rtl' else '',
name=css_attachments.name,
extension='')
else:
href = attachment.url
attr = dict([
["type", "text/css"],
["rel", "stylesheet"],
["href", href],
['data-asset-bundle', self.name],
['data-asset-version', self.version],
])
response.append(("link", attr, None))
if self.css_errors:
msg = '\n'.join(self.css_errors)
response.append(JavascriptAsset(self, inline=self.dialog_message(msg)).to_node())
response.append(StylesheetAsset(self, url="/web/static/lib/bootstrap/dist/css/bootstrap.css").to_node())
if js and self.javascripts:
js_attachment = self.js(is_minified=not is_debug_assets)
src = self.get_debug_asset_url(name=js_attachment.name, extension='') if is_debug_assets else js_attachment[0].url
attr = dict([
["async", "async" if async_load else None],
["defer", "defer" if defer_load or lazy_load else None],
["type", "text/javascript"],
["data-src" if lazy_load else "src", src],
['data-asset-bundle', self.name],
['data-asset-version', self.version],
])
response.append(("script", attr, None))
if self.has_css and self.stylesheets:
response.append(self.get_link('css'))
return response
if self.has_js and self.javascripts:
response.append(self.get_link('js'))
@func.lazy_property
def last_modified_combined(self):
"""Returns last modified date of linked files"""
# WebAsset are recreate here when a better solution would be to use self.stylesheets and self.javascripts
# We currently have no garanty that they are present since it will depends on js and css parameters
# last_modified is actually only usefull for the checksum and checksum should be extension specific since
# they are differents bundles. This will be a future work.
return self.external_assets + response
# changing the logic from max date to combined date to fix bundle invalidation issues.
assets = [WebAsset(self, url=f['url'], filename=f['filename'], inline=f['content'])
for f in self.files
if f['atype'] in ['text/sass', "text/scss", "text/less", "text/css", "text/javascript", "text/xml"]]
return ','.join(str(asset.last_modified) for asset in assets)
def get_link(self, asset_type):
unique = self.get_version(asset_type) if not self.is_debug_assets else 'debug'
extension = asset_type if self.is_debug_assets else f'min.{asset_type}'
return self.get_asset_url(unique=unique, extension=extension)
@func.lazy_property
def version(self):
return self.checksum[0:7]
def get_version(self, asset_type):
return self.get_checksum(asset_type)[0:7]
@func.lazy_property
def checksum(self):
def get_checksum(self, asset_type):
"""
Not really a full checksum.
We compute a SHA512/256 on the rendered bundle + combined linked files last_modified date
"""
check = u"%s%s" % (json.dumps(self.files, sort_keys=True), self.last_modified_combined)
return hashlib.sha512(check.encode('utf-8')).hexdigest()[:64]
if asset_type not in self._checksum_cache:
if asset_type == 'css':
assets = self.stylesheets
elif asset_type == 'js':
assets = self.javascripts + self.templates
else:
raise ValueError(f'Asset type {asset_type} not known')
def _get_asset_template_url(self):
return "/web/assets/{id}-{unique}/{extra}{name}{sep}{extension}"
unique_descriptor = ','.join(asset.unique_descriptor for asset in assets)
def _get_asset_url_values(self, id, unique, extra, name, sep, extension): # extra can contain direction or/and website
return {
'id': id,
'unique': unique,
'extra': extra,
'name': name,
'sep': sep,
'extension': extension,
}
self._checksum_cache[asset_type] = hashlib.sha512(unique_descriptor.encode()).hexdigest()[:64]
return self._checksum_cache[asset_type]
def get_asset_url(self, id='%', unique='%', extra='', name='%', sep="%", extension='%'):
return self._get_asset_template_url().format(
**self._get_asset_url_values(id=id, unique=unique, extra=extra, name=name, sep=sep, extension=extension)
)
def get_debug_asset_url(self, extra='', name='%', extension='%'):
return f"/web/assets/debug/{extra}{name}{extension}"
def get_asset_url(self, unique=ANY_UNIQUE, extension='%', ignore_params=False):
direction = '.rtl' if self.is_css(extension) and self.rtl else ''
bundle_name = f"{self.name}{direction}.{extension}"
return self.env['ir.asset']._get_asset_bundle_url(bundle_name, unique, self.assets_params, ignore_params)
def _unlink_attachments(self, attachments):
""" Unlinks attachments without actually calling unlink, so that the ORM cache is not cleared.
@ -251,10 +166,13 @@ class AssetsBundle(object):
self.env.cr.execute(f"""DELETE FROM {attachments._table} WHERE id IN (
SELECT id FROM {attachments._table} WHERE id in %s FOR NO KEY UPDATE SKIP LOCKED
)""", [tuple(attachments.ids)])
for file_path in to_delete:
attachments._file_delete(file_path)
for fpath in to_delete:
attachments._file_delete(fpath)
def clean_attachments(self, extension):
def is_css(self, extension):
return extension in ['css', 'min.css', 'css.map']
def _clean_attachments(self, extension, keep_url):
""" Takes care of deleting any outdated ir.attachment records associated to a bundle before
saving a fresh one.
@ -265,25 +183,23 @@ class AssetsBundle(object):
must exclude the current bundle.
"""
ira = self.env['ir.attachment']
url = self.get_asset_url(
extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''),
name=self.name,
sep='',
extension='.%s' % extension
to_clean_pattern = self.get_asset_url(
unique=ANY_UNIQUE,
extension=extension,
)
domain = [
('url', '=like', url),
'!', ('url', '=like', self.get_asset_url(unique=self.version))
('url', '=like', to_clean_pattern),
('url', '!=', keep_url),
('public', '=', True),
]
attachments = ira.sudo().search(domain)
# avoid to invalidate cache if it's already empty (mainly useful for test)
if attachments:
_logger.info('Deleting ir.attachment %s (from bundle %s)', attachments.ids, self.name)
_logger.info('Deleting attachments %s (matching %s) because it was replaced with %s', attachments.ids, to_clean_pattern, keep_url)
self._unlink_attachments(attachments)
# force bundle invalidation on other workers
self.env['ir.qweb'].clear_caches()
# clear_cache was removed
return True
@ -296,21 +212,17 @@ class AssetsBundle(object):
by file name and only return the one with the max id for each group.
:param extension: file extension (js, min.js, css)
:param ignore_version: if ignore_version, the url contains a version => web/assets/%-%/name.extension
:param ignore_version: if ignore_version, the url contains a version => web/assets/%/name.extension
(the second '%' corresponds to the version),
else: the url contains a version equal to that of the self.version
=> web/assets/%-self.version/name.extension.
else: the url contains a version equal to that of the self.get_version(type)
=> web/assets/self.get_version(type)/name.extension.
"""
unique = "%" if ignore_version else self.version
unique = ANY_UNIQUE if ignore_version else self.get_version('css' if self.is_css(extension) else 'js')
url_pattern = self.get_asset_url(
unique=unique,
extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''),
name=self.name,
sep='',
extension='.%s' % extension
extension=extension,
)
self.env.cr.execute("""
query = """
SELECT max(id)
FROM ir_attachment
WHERE create_uid = %s
@ -320,22 +232,37 @@ class AssetsBundle(object):
AND public = true
GROUP BY name
ORDER BY name
""", [SUPERUSER_ID, url_pattern])
attachment_ids = [r[0] for r in self.env.cr.fetchall()]
if not attachment_ids:
_logger.info('Failed to find attachment for assets %s', url_pattern)
return self.env['ir.attachment'].sudo().browse(attachment_ids)
def add_post_rollback(self):
"""
In some rare cases it is possible that an attachment is created
during a transaction, added to the ormcache but the transaction
is rolled back, leading to 404 when getting the attachments.
This postrollback hook will help fix this issue by clearing the
cache if it is not committed.
"""
self.env.cr.postrollback.add(self.env.registry._Registry__cache.clear)
self.env.cr.execute(query, [SUPERUSER_ID, url_pattern])
attachment_id = [r[0] for r in self.env.cr.fetchall()]
if not attachment_id and not ignore_version:
fallback_url_pattern = self.get_asset_url(
unique=unique,
extension=extension,
ignore_params=True,
)
self.env.cr.execute(query, [SUPERUSER_ID, fallback_url_pattern])
similar_attachment_ids = [r[0] for r in self.env.cr.fetchall()]
if similar_attachment_ids:
similar = self.env['ir.attachment'].sudo().browse(similar_attachment_ids)
_logger.info('Found a similar attachment for %s, copying from %s', url_pattern, similar.url)
url = url_pattern
values = {
'name': similar.name,
'mimetype': similar.mimetype,
'res_model': 'ir.ui.view',
'res_id': False,
'type': 'binary',
'public': True,
'raw': similar.raw,
'url': url,
}
attachment = self.env['ir.attachment'].with_user(SUPERUSER_ID).create(values)
attachment_id = attachment.id
self._clean_attachments(extension, url)
return self.env['ir.attachment'].sudo().browse(attachment_id)
def save_attachment(self, extension, content):
"""Record the given bundle in an ir.attachment and delete
@ -360,6 +287,11 @@ class AssetsBundle(object):
'application/json' if extension in ['js.map', 'css.map'] else
'application/javascript'
)
unique = self.get_version('css' if self.is_css(extension) else 'js')
url = self.get_asset_url(
unique=unique,
extension=extension,
)
values = {
'name': fname,
'mimetype': mimetype,
@ -368,26 +300,13 @@ class AssetsBundle(object):
'type': 'binary',
'public': True,
'raw': content.encode('utf8'),
}
self.add_post_rollback()
attachment = ira.with_user(SUPERUSER_ID).create(values)
url = self.get_asset_url(
id=attachment.id,
unique=self.version,
extra='%s' % ('rtl/' if extension in ['css', 'min.css'] and self.user_direction == 'rtl' else ''),
name=fname,
sep='', # included in fname
extension=''
)
values = {
'url': url,
}
attachment.write(values)
attachment = ira.with_user(SUPERUSER_ID).create(values)
if self.env.context.get('commit_assetsbundle') is True:
self.env.cr.commit()
_logger.info('Generating a new asset bundle attachment %s (id:%s)', attachment.url, attachment.id)
self.clean_attachments(extension)
self._clean_attachments(extension, url)
# For end-user assets (common and backend), send a message on the bus
# to invite the user to refresh their browser
@ -395,11 +314,12 @@ class AssetsBundle(object):
self.env['bus.bus']._sendone('broadcast', 'bundle_changed', {
'server_version': release.version # Needs to be dynamically imported
})
_logger.debug('Asset Changed: bundle: %s -- version: %s', self.name, self.version)
_logger.debug('Asset Changed: bundle: %s -- version: %s', self.name, unique)
return attachment
def js(self, is_minified=True):
def js(self):
is_minified = not self.is_debug_assets
extension = 'min.js' if is_minified else 'js'
js_attachment = self.get_attachments(extension)
@ -417,11 +337,10 @@ class AssetsBundle(object):
* Templates *
*******************************************/
odoo.define('{self.name}.bundle.xml', function(require){{
odoo.define('{self.name}.bundle.xml', ['@web/core/registry'], function(require){{
'use strict';
const {{ loadXML }} = require('@web/core/assets');
const templates = `{templates}`;
return loadXML(templates);
const {{ registry }} = require('@web/core/registry');
registry.category(`xml_templates`).add(`{self.name}`, `{templates}`);
}});""")
if is_minified:
@ -443,7 +362,7 @@ class AssetsBundle(object):
or self.save_attachment('js.map', '')
generator = SourceMapGenerator(
source_root="/".join(
[".." for i in range(0, len(self.get_debug_asset_url(name=self.name).split("/")) - 2)]
[".." for i in range(0, len(self.get_asset_url().split("/")) - 2)]
) + "/",
)
content_bundle_list = []
@ -535,7 +454,7 @@ class AssetsBundle(object):
else:
raise ValueError(_("Module %r not loaded or inexistent (try to inherit %r), or templates of addon being loaded %r are misordered (template %r)", parent_addon, parent_name, addon, template_name))
if parent_name not in template_dict[parent_addon]:
raise ValueError(_("Cannot create %r because the template to inherit %r is not found.") % (f'{addon}.{template_name}', f'{parent_addon}.{parent_name}'))
raise ValueError(_("Cannot create %r because the template to inherit %r is not found.", '%s.%s' % (addon, template_name), '%s.%s' % (parent_addon, parent_name)))
# After several performance tests, we found out that deepcopy is the most efficient
# solution in this case (compared with copy, xpath with '.' and stringifying).
@ -607,28 +526,51 @@ class AssetsBundle(object):
# Returns the string by removing the <root> tag.
return etree.tostring(root, encoding='unicode')[6:-7]
def css(self, is_minified=True):
def css(self):
is_minified = not self.is_debug_assets
extension = 'min.css' if is_minified else 'css'
attachments = self.get_attachments(extension)
if not attachments:
# get css content
css = self.preprocess_css()
if self.css_errors:
return self.get_attachments(extension, ignore_version=True)
if attachments:
return attachments
matches = []
css = re.sub(self.rx_css_import, lambda matchobj: matches.append(matchobj.group(0)) and '', css)
css = self.preprocess_css()
if self.css_errors:
error_message = '\n'.join(self.css_errors).replace('"', r'\"').replace('\n', r'\A').replace('*', r'\*')
previous_attachment = self.get_attachments(extension, ignore_version=True)
previous_css = previous_attachment.raw.decode() if previous_attachment else ''
css_error_message_header = '\n\n/* ## CSS error message ##*/'
previous_css = previous_css.split(css_error_message_header)[0]
css = css_error_message_header.join([
previous_css, """
body::before {
font-weight: bold;
content: "A css error occured, using an old style to render this page";
position: fixed;
left: 0;
bottom: 0;
z-index: 100000000000;
background-color: #C00;
color: #DDD;
}
if is_minified:
# move up all @import rules to the top
matches.append(css)
css = u'\n'.join(matches)
css_error_message {
content: "%s";
}
""" % error_message
])
return self.save_attachment(extension, css)
self.save_attachment(extension, css)
attachments = self.get_attachments(extension)
else:
return self.css_with_sourcemap(u'\n'.join(matches))
return attachments
matches = []
css = re.sub(self.rx_css_import, lambda matchobj: matches.append(matchobj.group(0)) and '', css)
if is_minified:
# move up all @import rules to the top
matches.append(css)
css = u'\n'.join(matches)
return self.save_attachment(extension, css)
else:
return self.css_with_sourcemap(u'\n'.join(matches))
def css_with_sourcemap(self, content_import_rules):
"""Create the ir.attachment representing the not-minified content of the bundleCSS
@ -639,8 +581,7 @@ class AssetsBundle(object):
"""
sourcemap_attachment = self.get_attachments('css.map') \
or self.save_attachment('css.map', '')
debug_asset_url = self.get_debug_asset_url(name=self.name,
extra='rtl/' if self.user_direction == 'rtl' else '')
debug_asset_url = self.get_asset_url(unique='debug')
generator = SourceMapGenerator(
source_root="/".join(
[".." for i in range(0, len(debug_asset_url.split("/")) - 2)]
@ -660,7 +601,7 @@ class AssetsBundle(object):
content_bundle_list.append(content)
content_line_count += len(content.split("\n"))
content_bundle = '\n'.join(content_bundle_list) + f"\n//*# sourceMappingURL={sourcemap_attachment.url} */"
content_bundle = '\n'.join(content_bundle_list) + f"\n/*# sourceMappingURL={sourcemap_attachment.url} */"
css_attachment = self.save_attachment('css', content_bundle)
generator._file = css_attachment.url
@ -670,111 +611,6 @@ class AssetsBundle(object):
return css_attachment
def dialog_message(self, message):
"""
Returns a JS script which shows a warning to the user on page load.
TODO: should be refactored to be a base js file whose code is extended
by related apps (web/website).
"""
return """
(function (message) {
'use strict';
if (window.__assetsBundleErrorSeen) {
return;
}
window.__assetsBundleErrorSeen = true;
if (document.readyState !== 'loading') {
onDOMContentLoaded();
} else {
window.addEventListener('DOMContentLoaded', () => onDOMContentLoaded());
}
async function onDOMContentLoaded() {
var odoo = window.top.odoo;
if (!odoo || !odoo.define) {
useAlert();
return;
}
// Wait for potential JS loading
await new Promise(resolve => {
const noLazyTimeout = setTimeout(() => resolve(), 10); // 10 since need to wait for promise resolutions of odoo.define
odoo.define('AssetsBundle.PotentialLazyLoading', function (require) {
'use strict';
const lazyloader = require('web.public.lazyloader');
clearTimeout(noLazyTimeout);
lazyloader.allScriptsLoaded.then(() => resolve());
});
});
var alertTimeout = setTimeout(useAlert, 10); // 10 since need to wait for promise resolutions of odoo.define
odoo.define('AssetsBundle.ErrorMessage', function (require) {
'use strict';
require('web.dom_ready');
var core = require('web.core');
var Dialog = require('web.Dialog');
var _t = core._t;
clearTimeout(alertTimeout);
new Dialog(null, {
title: _t("Style error"),
$content: $('<div/>')
.append($('<p/>', {text: _t("The style compilation failed, see the error below. Your recent actions may be the cause, please try reverting the changes you made.")}))
.append($('<pre/>', {html: message})),
}).open();
});
}
function useAlert() {
window.alert(message);
}
})("%s");
""" % message.replace('"', '\\"').replace('\n', '&NewLine;')
def _get_assets_domain_for_already_processed_css(self, assets):
""" Method to compute the attachments' domain to search the already process assets (css).
This method was created to be overridden.
"""
return [('url', 'in', list(assets.keys()))]
def is_css_preprocessed(self):
preprocessed = True
old_attachments = self.env['ir.attachment'].sudo()
asset_types = [SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset]
if self.user_direction == 'rtl':
asset_types.append(StylesheetAsset)
for atype in asset_types:
outdated = False
assets = dict((asset.html_url, asset) for asset in self.stylesheets if isinstance(asset, atype))
if assets:
assets_domain = self._get_assets_domain_for_already_processed_css(assets)
attachments = self.env['ir.attachment'].sudo().search(assets_domain)
old_attachments += attachments
for attachment in attachments:
asset = assets[attachment.url]
if asset.last_modified > attachment['__last_update']:
outdated = True
break
if asset._content is None:
asset._content = (attachment.raw or b'').decode('utf8')
if not asset._content and attachment.file_size > 0:
asset._content = None # file missing, force recompile
if any(asset._content is None for asset in assets.values()):
outdated = True
if outdated:
preprocessed = False
return preprocessed, old_attachments
def preprocess_css(self, debug=False, old_attachments=None):
"""
Checks if the bundle contains any sass/less content, then compiles it to css.
@ -791,7 +627,7 @@ class AssetsBundle(object):
compiled += self.compile_css(assets[0].compile, source)
# We want to run rtlcss on normal css, so merge it in compiled
if self.user_direction == 'rtl':
if self.rtl:
stylesheet_assets = [asset for asset in self.stylesheets if not isinstance(asset, (SassStylesheetAsset, ScssStylesheetAsset, LessStylesheetAsset))]
compiled += '\n'.join([asset.get_source() for asset in stylesheet_assets])
compiled = self.run_rtlcss(compiled)
@ -861,7 +697,7 @@ class AssetsBundle(object):
except IOError:
rtlcss = 'rtlcss'
cmd = [rtlcss, '-c', get_resource_path("base", "data/rtlcss.json"), '-']
cmd = [rtlcss, '-c', file_path("base/data/rtlcss.json"), '-']
try:
rtlcss = Popen(cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
@ -915,18 +751,17 @@ class AssetsBundle(object):
class WebAsset(object):
html_url_format = '%s'
_content = None
_filename = None
_ir_attach = None
_id = None
def __init__(self, bundle, inline=None, url=None, filename=None):
def __init__(self, bundle, inline=None, url=None, filename=None, last_modified=None):
self.bundle = bundle
self.inline = inline
self._filename = filename
self.url = url
self.html_url_args = url
self._last_modified = last_modified
if not inline and not url:
raise Exception("An asset should either be inlined or url linked, defined in bundle '%s'" % bundle.name)
@ -935,20 +770,16 @@ class WebAsset(object):
if self._id is None: self._id = str(uuid.uuid4())
return self._id
@func.lazy_property
def unique_descriptor(self):
return f'{self.url or self.inline},{self.last_modified}'
@func.lazy_property
def name(self):
return '<inline asset>' if self.inline else self.url
@property
def html_url(self):
return self.html_url_format % self.html_url_args
def stat(self):
if not (self.inline or self._filename or self._ir_attach):
path = (segment for segment in self.url.split('/') if segment)
self._filename = get_resource_path(*path)
if self._filename:
return
try:
# Test url against ir.attachments
self._ir_attach = self.bundle.env['ir.attachment'].sudo()._get_serve_attachment(self.url)
@ -956,20 +787,20 @@ class WebAsset(object):
except ValueError:
raise AssetNotFound("Could not find %s" % self.name)
def to_node(self):
raise NotImplementedError()
@func.lazy_property
@property
def last_modified(self):
try:
self.stat()
if self._filename:
return datetime.fromtimestamp(os.path.getmtime(self._filename))
if self._last_modified is None:
try:
self.stat()
except Exception: # most likely nor a file or an attachment, skip it
pass
if self._filename and self.bundle.is_debug_assets: # usually _last_modified should be set exept in debug=assets
self._last_modified = os.path.getmtime(self._filename)
elif self._ir_attach:
return self._ir_attach['__last_update']
except Exception:
pass
return datetime(1970, 1, 1)
self._last_modified = self._ir_attach.write_date.timestamp()
if not self._last_modified:
self._last_modified = -1
return self._last_modified
@property
def content(self):
@ -1004,11 +835,15 @@ class WebAsset(object):
class JavascriptAsset(WebAsset):
def __init__(self, bundle, inline=None, url=None, filename=None):
super().__init__(bundle, inline, url, filename)
def __init__(self, bundle, **kwargs):
super().__init__(bundle, **kwargs)
self._is_transpiled = None
self._converted_content = None
@property
def bundle_version(self):
return self.bundle.get_version('js')
@property
def is_transpiled(self):
if self._is_transpiled is None:
@ -1033,21 +868,6 @@ class JavascriptAsset(WebAsset):
except AssetError as e:
return u"console.error(%s);" % json.dumps(to_text(e))
def to_node(self):
if self.url:
return ("script", dict([
["type", "text/javascript"],
["src", self.html_url],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
]), None)
else:
return ("script", dict([
["type", "text/javascript"],
["charset", "utf-8"],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
]), self.with_header())
def with_header(self, content=None, minimal=True):
if minimal:
@ -1078,24 +898,20 @@ class XMLAsset(WebAsset):
try:
content = super()._fetch_content()
except AssetError as e:
return f'<error data-asset-bundle={self.bundle.name!r} data-asset-version={self.bundle.version!r}>{json.dumps(to_text(e))}</error>'
return u"console.error(%s);" % json.dumps(to_text(e))
parser = etree.XMLParser(ns_clean=True, recover=True, remove_comments=True)
root = etree.parse(io.BytesIO(content.encode('utf-8')), parser=parser).getroot()
parser = etree.XMLParser(ns_clean=True, remove_comments=True, resolve_entities=False)
try:
root = etree.fromstring(content.encode('utf-8'), parser=parser)
except etree.XMLSyntaxError as e:
return f'<t t-name="parsing_error{self.url.replace("/","_")}"><parsererror>Invalid XML template: {self.url} \n {e.msg} </parsererror></t>'
if root.tag in ('templates', 'template'):
return ''.join(etree.tostring(el, encoding='unicode') for el in root)
return etree.tostring(root, encoding='unicode')
def to_node(self):
attributes = {
'async': 'async',
'defer': 'defer',
'type': 'text/xml',
'data-src': self.html_url,
'data-asset-bundle': self.bundle.name,
'data-asset-version': self.bundle.version,
}
return ("script", attributes, None)
@property
def bundle_version(self):
return self.bundle.get_version('js')
def with_header(self, content=None):
if content is None:
@ -1128,21 +944,18 @@ class StylesheetAsset(WebAsset):
rx_sourceMap = re.compile(r'(/\*# sourceMappingURL=.*)', re.U)
rx_charset = re.compile(r'(@charset "[^"]+";)', re.U)
def __init__(self, *args, **kw):
self.media = kw.pop('media', None)
self.direction = kw.pop('direction', None)
def __init__(self, *args, rtl=False, **kw):
self.rtl = rtl
super().__init__(*args, **kw)
if self.direction == 'rtl' and self.url:
self.html_url_args = self.url.rsplit('.', 1)
self.html_url_format = '%%s/%s/%s.%%s' % ('rtl', self.bundle.name)
self.html_url_args = tuple(self.html_url_args)
@property
def content(self):
content = super().content
if self.media:
content = '@media %s { %s }' % (self.media, content)
return content
def bundle_version(self):
return self.bundle.get_version('css')
@func.lazy_property
def unique_descriptor(self):
direction = (self.rtl and 'rtl') or 'ltr'
return f'{self.url or self.inline},{self.last_modified},{direction}'
def _fetch_content(self):
try:
@ -1184,35 +997,10 @@ class StylesheetAsset(WebAsset):
content = re.sub(r' *([{}]) *', r'\1', content)
return self.with_header(content)
def to_node(self):
if self.url:
attr = dict([
["type", "text/css"],
["rel", "stylesheet"],
["href", self.html_url],
["media", escape(to_text(self.media)) if self.media else None],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
])
return ("link", attr, None)
else:
attr = dict([
["type", "text/css"],
["media", escape(to_text(self.media)) if self.media else None],
['data-asset-bundle', self.bundle.name],
['data-asset-version', self.bundle.version],
])
return ("style", attr, self.with_header())
class PreprocessedCSS(StylesheetAsset):
rx_import = None
def __init__(self, *args, **kw):
super().__init__(*args, **kw)
self.html_url_args = tuple(self.url.rsplit('/', 1))
self.html_url_format = '%%s/%s%s/%%s.css' % ('rtl/' if self.direction == 'rtl' else '', self.bundle.name)
def get_command(self):
raise NotImplementedError
@ -1271,7 +1059,7 @@ class SassStylesheetAsset(PreprocessedCSS):
class ScssStylesheetAsset(PreprocessedCSS):
@property
def bootstrap_path(self):
return get_resource_path('web', 'static', 'lib', 'bootstrap', 'scss')
return file_path('web/static/lib/bootstrap/scss')
precision = 8
output_style = 'expanded'

View file

@ -36,17 +36,17 @@ class DecimalPrecision(models.Model):
@api.model_create_multi
def create(self, vals_list):
res = super(DecimalPrecision, self).create(vals_list)
self.clear_caches()
self.env.registry.clear_cache()
return res
def write(self, data):
res = super(DecimalPrecision, self).write(data)
self.clear_caches()
self.env.registry.clear_cache()
return res
def unlink(self):
res = super(DecimalPrecision, self).unlink()
self.clear_caches()
self.env.registry.clear_cache()
return res
@api.onchange('digits')

View file

@ -3,21 +3,49 @@
import odoo
from odoo import api, fields, models, tools, _, Command
from odoo.exceptions import MissingError, ValidationError, AccessError
from odoo.exceptions import MissingError, ValidationError, AccessError, UserError
from odoo.tools import frozendict
from odoo.tools.safe_eval import safe_eval, test_python_expr
from odoo.tools.float_utils import float_compare
from odoo.http import request
import base64
from collections import defaultdict
import functools
from functools import partial, reduce
import logging
from operator import getitem
import requests
import json
import contextlib
from pytz import timezone
_logger = logging.getLogger(__name__)
_server_action_logger = _logger.getChild("server_action_safe_eval")
class LoggerProxy:
""" Proxy of the `_logger` element in order to be used in server actions.
We purposefully restrict its method as it will be executed in `safe_eval`.
"""
@staticmethod
def log(level, message, *args, stack_info=False, exc_info=False):
_server_action_logger.log(level, message, *args, stack_info=stack_info, exc_info=exc_info)
@staticmethod
def info(message, *args, stack_info=False, exc_info=False):
_server_action_logger.info(message, *args, stack_info=stack_info, exc_info=exc_info)
@staticmethod
def warning(message, *args, stack_info=False, exc_info=False):
_server_action_logger.warning(message, *args, stack_info=stack_info, exc_info=exc_info)
@staticmethod
def error(message, *args, stack_info=False, exc_info=False):
_server_action_logger.error(message, *args, stack_info=stack_info, exc_info=exc_info)
@staticmethod
def exception(message, *args, stack_info=False, exc_info=True):
_server_action_logger.exception(message, *args, stack_info=stack_info, exc_info=exc_info)
class IrActions(models.Model):
@ -49,23 +77,25 @@ class IrActions(models.Model):
def create(self, vals_list):
res = super(IrActions, self).create(vals_list)
# self.get_bindings() depends on action records
self.clear_caches()
self.env.registry.clear_cache()
return res
def write(self, vals):
res = super(IrActions, self).write(vals)
# self.get_bindings() depends on action records
self.clear_caches()
self.env.registry.clear_cache()
return res
def unlink(self):
"""unlink ir.action.todo which are related to actions which will be deleted.
"""unlink ir.action.todo/ir.filters which are related to actions which will be deleted.
NOTE: ondelete cascade will not work on ir.actions.actions so we will need to do it manually."""
todos = self.env['ir.actions.todo'].search([('action_id', 'in', self.ids)])
todos.unlink()
filters = self.env['ir.filters'].search([('action_id', 'in', self.ids)])
filters.unlink()
res = super(IrActions, self).unlink()
# self.get_bindings() depends on action records
self.clear_caches()
self.env.registry.clear_cache()
return res
@api.ondelete(at_uninstall=True)
@ -237,12 +267,6 @@ class IrActionsActWindow(models.Model):
if ' ' in modes:
raise ValidationError(_('No spaces allowed in view_mode: %r', modes))
@api.depends('res_model', 'search_view_id')
def _compute_search_view(self):
for act in self:
fvg = self.env[act.res_model].get_view(act.search_view_id.id, 'search')
act.search_view = str(fvg)
type = fields.Char(default="ir.actions.act_window")
view_id = fields.Many2one('ir.ui.view', string='View Ref.', ondelete='set null')
domain = fields.Char(string='Domain Value',
@ -255,6 +279,7 @@ class IrActionsActWindow(models.Model):
target = fields.Selection([('current', 'Current Window'), ('new', 'New Window'), ('inline', 'Inline Edit'), ('fullscreen', 'Full Screen'), ('main', 'Main action of Current Window')], default="current", string='Target Window')
view_mode = fields.Char(required=True, default='tree,form',
help="Comma-separated list of allowed view modes, such as 'form', 'tree', 'calendar', etc. (Default: tree,form)")
mobile_view_mode = fields.Char(default="kanban", help="First view mode in mobile and small screen environments (default='kanban'). If it can't be found among available view modes, the same mode as for wider screens is used)")
usage = fields.Char(string='Action Usage',
help="Used to filter menu and home actions from the user form.")
view_ids = fields.One2many('ir.actions.act_window.view', 'act_window_id', string='No of Views')
@ -267,7 +292,6 @@ class IrActionsActWindow(models.Model):
'act_id', 'gid', string='Groups')
search_view_id = fields.Many2one('ir.ui.view', string='Search View Ref.')
filter = fields.Boolean()
search_view = fields.Text(compute='_compute_search_view')
def read(self, fields=None, load='_classic_read'):
""" call the method get_empty_list_help of the model and set the window action help message
@ -287,14 +311,14 @@ class IrActionsActWindow(models.Model):
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
self.env.registry.clear_cache()
for vals in vals_list:
if not vals.get('name') and vals.get('res_model'):
vals['name'] = self.env[vals['res_model']]._description
return super(IrActionsActWindow, self).create(vals_list)
def unlink(self):
self.clear_caches()
self.env.registry.clear_cache()
return super(IrActionsActWindow, self).unlink()
def exists(self):
@ -311,9 +335,8 @@ class IrActionsActWindow(models.Model):
def _get_readable_fields(self):
return super()._get_readable_fields() | {
"context", "domain", "filter", "groups_id", "limit", "res_id",
"res_model", "search_view", "search_view_id", "target", "view_id",
"view_mode", "views",
"context", "mobile_view_mode", "domain", "filter", "groups_id", "limit",
"res_id", "res_model", "search_view_id", "target", "view_id", "view_mode", "views",
# `flags` is not a real field of ir.actions.act_window but is used
# to give the parameters to generate the action
"flags"
@ -379,14 +402,33 @@ class IrActionsActUrl(models.Model):
type = fields.Char(default='ir.actions.act_url')
url = fields.Text(string='Action URL', required=True)
target = fields.Selection([('new', 'New Window'), ('self', 'This Window')],
target = fields.Selection([('new', 'New Window'), ('self', 'This Window'), ('download', 'Download')],
string='Action Target', default='new', required=True)
def _get_readable_fields(self):
return super()._get_readable_fields() | {
"target", "url",
"target", "url", "close",
}
WEBHOOK_SAMPLE_VALUES = {
"integer": 42,
"float": 42.42,
"monetary": 42.42,
"char": "Hello World",
"text": "Hello World",
"html": "<p>Hello World</p>",
"boolean": True,
"selection": "option1",
"date": "2020-01-01",
"datetime": "2020-01-01 00:00:00",
"binary": "<base64_data>",
"many2one": 47,
"many2many": [42, 47],
"one2many": [42, 47],
"reference": "res.partner,42",
None: "some_data",
}
class IrActionsServer(models.Model):
""" Server actions model. Server action work on a base model and offer various
@ -415,43 +457,60 @@ class IrActionsServer(models.Model):
_allow_sudo_commands = False
DEFAULT_PYTHON_CODE = """# Available variables:
# - env: Odoo Environment on which the action is triggered
# - model: Odoo Model of the record on which the action is triggered; is a void recordset
# - env: environment on which the action is triggered
# - model: model of the record on which the action is triggered; is a void recordset
# - record: record on which the action is triggered; may be void
# - records: recordset of all records on which the action is triggered in multi-mode; may be void
# - time, datetime, dateutil, timezone: useful Python libraries
# - float_compare: Odoo function to compare floats based on specific precisions
# - float_compare: utility function to compare floats based on specific precision
# - log: log(message, level='info'): logging function to record debug information in ir.logging table
# - UserError: Warning Exception to use with raise
# - Command: x2Many commands namespace
# - _logger: _logger.info(message): logger to emit messages in server logs
# - UserError: exception class for raising user-facing warning messages
# - Command: x2many commands namespace
# To return an action, assign: action = {...}\n\n\n\n"""
@api.model
def _default_update_path(self):
if not self.env.context.get('default_model_id'):
return ''
ir_model = self.env['ir.model'].browse(self.env.context['default_model_id'])
model = self.env[ir_model.model]
sensible_default_fields = ['partner_id', 'user_id', 'user_ids', 'stage_id', 'state', 'active']
for field_name in sensible_default_fields:
if field_name in model._fields and not model._fields[field_name].readonly:
return field_name
return ''
name = fields.Char(required=True)
type = fields.Char(default='ir.actions.server')
usage = fields.Selection([
('ir_actions_server', 'Server Action'),
('ir_cron', 'Scheduled Action')], string='Usage',
default='ir_actions_server', required=True)
state = fields.Selection([
('code', 'Execute Python Code'),
('object_create', 'Create a new Record'),
('object_write', 'Update the Record'),
('multi', 'Execute several actions')], string='Action To Do',
('object_write', 'Update Record'),
('object_create', 'Create Record'),
('code', 'Execute Code'),
('webhook', 'Send Webhook Notification'),
('multi', 'Execute Existing Actions')], string='Type',
default='object_write', required=True, copy=True,
help="Type of server action. The following values are available:\n"
"- 'Execute Python Code': a block of python code that will be executed\n"
"- 'Create a new Record': create a new record with new values\n"
"- 'Update a Record': update the values of a record\n"
"- 'Execute several actions': define an action that triggers several other server actions\n"
"- 'Create Activity': create an activity (Discuss)\n"
"- 'Send Email': post a message, a note or send an email (Discuss)\n"
"- 'Add Followers': add followers to a record (Discuss)\n"
"- 'Create Next Activity': create an activity (Discuss)\n"
"- 'Send SMS Text Message': send SMS, log them on documents (SMS)")
"- 'Send SMS': send SMS, log them on documents (SMS)"
"- 'Add/Remove Followers': add or remove followers to a record (Discuss)\n"
"- 'Create Record': create a new record with new values\n"
"- 'Execute Code': a block of Python code that will be executed\n"
"- 'Send Webhook Notification': send a POST request to an external system, also known as a Webhook\n"
"- 'Execute Existing Actions': define an action that triggers several other server actions\n")
# Generic
sequence = fields.Integer(default=5,
help="When dealing with multiple actions, the execution order is "
"based on the sequence. Low number means high priority.")
model_id = fields.Many2one('ir.model', string='Model', required=True, ondelete='cascade', index=True,
help="Model on which the server action runs.")
available_model_ids = fields.Many2many('ir.model', string='Available Models', compute='_compute_available_model_ids', store=False)
model_name = fields.Char(related='model_id.model', string='Model Name', readonly=True, store=True)
# Python code
code = fields.Text(string='Python Code', groups='base.group_system',
@ -463,23 +522,194 @@ class IrActionsServer(models.Model):
string='Child Actions', help='Child server actions that will be executed. Note that the last return returned action value will be used as global return value.')
# Create
crud_model_id = fields.Many2one(
'ir.model', string='Target Model',
compute='_compute_crud_model_id', readonly=False, store=True,
help="Model for record creation / update. Set this field only to specify a different model than the base model.")
'ir.model', string='Record to Create',
compute='_compute_crud_relations', readonly=False, store=True,
help="Specify which kind of record should be created. Set this field only to specify a different model than the base model.")
crud_model_name = fields.Char(related='crud_model_id.model', string='Target Model Name', readonly=True)
link_field_id = fields.Many2one(
'ir.model.fields', string='Link Field',
compute='_compute_link_field_id', readonly=False, store=True,
help="Provide the field used to link the newly created record on the record used by the server action.")
fields_lines = fields.One2many('ir.server.object.lines', 'server_id', string='Value Mapping', copy=True)
help="Specify a field used to link the newly created record on the record used by the server action.")
groups_id = fields.Many2many('res.groups', 'ir_act_server_group_rel',
'act_id', 'gid', string='Groups')
'act_id', 'gid', string='Allowed Groups', help='Groups that can execute the server action. Leave empty to allow everybody.')
@api.onchange('model_id')
def _compute_crud_model_id(self):
invalid = self.filtered(lambda act: act.crud_model_id != act.model_id)
if invalid:
invalid.crud_model_id = False
update_field_id = fields.Many2one('ir.model.fields', string='Field to Update', ondelete='cascade', compute='_compute_crud_relations', store=True, readonly=False)
update_path = fields.Char(string='Field to Update Path', help="Path to the field to update, e.g. 'partner_id.name'", default=_default_update_path)
update_related_model_id = fields.Many2one('ir.model', compute='_compute_crud_relations', readonly=False, store=True)
update_field_type = fields.Selection(related='update_field_id.ttype', readonly=True)
update_m2m_operation = fields.Selection([
('add', 'Adding'),
('remove', 'Removing'),
('set', 'Setting it to'),
('clear', 'Clearing it')
], string='Many2many Operations', default='add')
update_boolean_value = fields.Selection([('true', 'Yes (True)'), ('false', "No (False)")], string='Boolean Value', default='true')
value = fields.Text(help="For Python expressions, this field may hold a Python expression "
"that can use the same values as for the code field on the server action,"
"e.g. `env.user.name` to set the current user's name as the value "
"or `record.id` to set the ID of the record on which the action is run.\n\n"
"For Static values, the value will be used directly without evaluation, e.g."
"`42` or `My custom name` or the selected record.")
evaluation_type = fields.Selection([
('value', 'Update'),
('equation', 'Compute')
], 'Value Type', default='value', change_default=True)
resource_ref = fields.Reference(
string='Record', selection='_selection_target_model', inverse='_set_resource_ref')
selection_value = fields.Many2one('ir.model.fields.selection', string="Custom Value", ondelete='cascade',
domain='[("field_id", "=", update_field_id)]', inverse='_set_selection_value')
value_field_to_show = fields.Selection([
('value', 'value'),
('resource_ref', 'reference'),
('update_boolean_value', 'update_boolean_value'),
('selection_value', 'selection_value'),
], compute='_compute_value_field_to_show')
# Webhook
webhook_url = fields.Char(string='Webhook URL', help="URL to send the POST request to.")
webhook_field_ids = fields.Many2many('ir.model.fields', 'ir_act_server_webhook_field_rel', 'server_id', 'field_id',
string='Webhook Fields',
help="Fields to send in the POST request. "
"The id and model of the record are always sent as '_id' and '_model'. "
"The name of the action that triggered the webhook is always sent as '_name'.")
webhook_sample_payload = fields.Text(string='Sample Payload', compute='_compute_webhook_sample_payload')
@api.constrains('webhook_field_ids')
def _check_webhook_field_ids(self):
"""Check that the selected fields don't have group restrictions"""
restricted_fields = dict()
for action in self:
Model = self.env[action.model_id.model]
for model_field in action.webhook_field_ids:
# you might think that the ir.model.field record holds references
# to the groups, but that's not the case - we need to field object itself
field = Model._fields[model_field.name]
if field.groups:
restricted_fields.setdefault(action.name, []).append(model_field.field_description)
if restricted_fields:
restricted_field_per_action = "\n".join([f"{action}: {', '.join(f for f in fields)}" for action, fields in restricted_fields.items()])
raise ValidationError(_("Group-restricted fields cannot be included in "
"webhook payloads, as it could allow any user to "
"accidentally leak sensitive information. You will "
"have to remove the following fields from the webhook payload "
"in the following actions:\n %s", restricted_field_per_action))
@api.depends('state')
def _compute_available_model_ids(self):
allowed_models = self.env['ir.model'].search(
[('model', 'in', list(self.env['ir.model.access']._get_allowed_models()))]
)
self.available_model_ids = allowed_models.ids
@api.depends('model_id', 'update_path', 'state')
def _compute_crud_relations(self):
""" Compute the crud_model_id and update_field_id fields.
The crud_model_id is the model on which the action will create or update
records. In the case of record creation, it is the same as the main model
of the action. For record update, it will be the model linked to the last
field in the update_path.
This is only used for object_create and object_write actions.
The update_field_id is the field at the end of the update_path that will
be updated by the action - only used for object_write actions.
"""
for action in self:
if action.model_id and action.state in ('object_write', 'object_create'):
if action.state == 'object_create':
action.crud_model_id = action.model_id
action.update_field_id = False
action.update_path = False
elif action.state == 'object_write':
if action.update_path:
# we need to traverse relations to find the target model and field
model, field, _ = action._traverse_path()
action.crud_model_id = model
action.update_field_id = field
need_update_model = action.evaluation_type == 'value' and action.update_field_id and action.update_field_id.relation
action.update_related_model_id = action.env["ir.model"]._get_id(field.relation) if need_update_model else False
else:
action.crud_model_id = action.model_id
action.update_field_id = False
else:
action.crud_model_id = False
action.update_field_id = False
action.update_path = False
def _traverse_path(self, record=None):
""" Traverse the update_path to find the target model and field, and optionally
the target record of an action of type 'object_write'.
:param record: optional record to use as starting point for the path traversal
:return: a tuple (model, field, records) where model is the target model and field is the
target field; if no record was provided, records is None, otherwise it is the
recordset at the end of the path starting from the provided record
"""
self.ensure_one()
path = self.update_path.split('.')
Model = self.env[self.model_id.model]
# sanity check: we're starting from a record that belongs to the model
if record and record._name != Model._name:
raise ValidationError(_("I have no idea how you *did that*, but you're trying to use a gibberish configuration: the model of the record on which the action is triggered is not the same as the model of the action."))
for field_name in path:
is_last_field = field_name == path[-1]
field = Model._fields[field_name]
if field.relational and not is_last_field:
Model = self.env[field.comodel_name]
elif not field.relational:
# sanity check: this should be the last field in the path
if not is_last_field:
raise ValidationError(_("The path to the field to update contains a non-relational field (%s) that is not the last field in the path. You can't traverse non-relational fields (even in the quantum realm). Make sure only the last field in the path is non-relational.", field_name))
if isinstance(field, fields.Json):
raise ValidationError(_("I'm sorry to say that JSON fields (such as %s) are currently not supported.", field_name))
target_records = None
if record is not None:
target_records = reduce(getitem, path[:-1], record)
model_id = self.env['ir.model']._get(Model._name)
field_id = self.env['ir.model.fields']._get(Model._name, field_name)
return model_id, field_id, target_records
def _stringify_path(self):
""" Returns a string representation of the update_path, with the field names
separated by the `>` symbol."""
self.ensure_one()
path = self.update_path
if not path:
return ''
model = self.env[self.model_id.model]
pretty_path = []
field = None
for field_name in path.split('.'):
if field and field.type == 'properties':
pretty_path.append(field_name)
continue
field = model._fields[field_name]
field_id = self.env['ir.model.fields']._get(model._name, field_name)
if field.relational:
model = self.env[field.comodel_name]
pretty_path.append(field_id.field_description)
return ' > '.join(pretty_path)
@api.depends('state', 'model_id', 'webhook_field_ids', 'name')
def _compute_webhook_sample_payload(self):
for action in self:
if action.state != 'webhook':
action.webhook_sample_payload = False
continue
payload = {
'id': 1,
'_model': self.model_id.model,
'_name': action.name,
}
if self.model_id:
sample_record = self.env[self.model_id.model].with_context(active_test=False).search([], limit=1)
for field in action.webhook_field_ids:
if sample_record:
payload['id'] = sample_record.id
payload.update(sample_record.read(self.webhook_field_ids.mapped('name'), load=None)[0])
else:
payload[field.name] = WEBHOOK_SAMPLE_VALUES[field.ttype] if field.ttype in WEBHOOK_SAMPLE_VALUES else WEBHOOK_SAMPLE_VALUES[None]
action.webhook_sample_payload = json.dumps(payload, indent=4, sort_keys=True, default=str)
@api.depends('model_id')
def _compute_link_field_id(self):
@ -514,7 +744,7 @@ class IrActionsServer(models.Model):
fn = getattr(t, f'_run_action_{self.state}', None)\
or getattr(t, f'run_action_{self.state}', None)
if fn and fn.__name__.startswith('run_action_'):
fn = functools.partial(fn, self)
fn = partial(fn, self)
return fn, multi
def _register_hook(self):
@ -553,32 +783,67 @@ class IrActionsServer(models.Model):
def _run_action_object_write(self, eval_context=None):
"""Apply specified write changes to active_id."""
vals = self.fields_lines.eval_value(eval_context=eval_context)
res = {line.col1.name: vals[line.id] for line in self.fields_lines}
vals = self._eval_value(eval_context=eval_context)
res = {action.update_field_id.name: vals[action.id] for action in self}
if self._context.get('onchange_self'):
record_cached = self._context['onchange_self']
for field, new_value in res.items():
record_cached[field] = new_value
else:
self.env[self.model_id.model].browse(self._context.get('active_id')).write(res)
starting_record = self.env[self.model_id.model].browse(self._context.get('active_id'))
_, _, target_records = self._traverse_path(record=starting_record)
target_records.write(res)
def _run_action_webhook(self, eval_context=None):
"""Send a post request with a read of the selected field on active_id."""
record = self.env[self.model_id.model].browse(self._context.get('active_id'))
url = self.webhook_url
if not record:
return
if not url:
raise UserError(_("I'll be happy to send a webhook for you, but you really need to give me a URL to reach out to..."))
vals = {
'_model': self.model_id.model,
'_id': record.id,
'_action': f'{self.name}(#{self.id})',
}
if self.webhook_field_ids:
# you might think we could use the default json serializer of the requests library
# but it will fail on many fields, e.g. datetime, date or binary
# so we use the json.dumps serializer instead with the str() function as default
vals.update(record.read(self.webhook_field_ids.mapped('name'), load=None)[0])
json_values = json.dumps(vals, sort_keys=True, default=str)
_logger.info("Webhook call to %s", url)
_logger.debug("POST JSON data for webhook call: %s", json_values)
try:
# 'send and forget' strategy, and avoid locking the user if the webhook
# is slow or non-functional (we still allow for a 1s timeout so that
# if we get a proper error response code like 400, 404 or 500 we can log)
response = requests.post(url, data=json_values, headers={'Content-Type': 'application/json'}, timeout=1)
response.raise_for_status()
except requests.exceptions.ReadTimeout:
_logger.warning("Webhook call timed out after 1s - it may or may not have failed. "
"If this happens often, it may be a sign that the system you're "
"trying to reach is slow or non-functional.")
except requests.exceptions.RequestException as e:
_logger.warning("Webhook call failed: %s", e)
except Exception as e: # noqa: BLE001
raise UserError(_("Wow, your webhook call failed with a really unusual error: %s", e)) from e
def _run_action_object_create(self, eval_context=None):
"""Create specified model object with specified values.
"""Create specified model object with specified name contained in value.
If applicable, link active_id.<self.link_field_id> to the new record.
"""
vals = self.fields_lines.eval_value(eval_context=eval_context)
res = {line.col1.name: vals[line.id] for line in self.fields_lines}
res = self.env[self.crud_model_id.model].create(res)
res_id, _res_name = self.env[self.crud_model_id.model].name_create(self.value)
if self.link_field_id:
record = self.env[self.model_id.model].browse(self._context.get('active_id'))
if self.link_field_id.ttype in ['one2many', 'many2many']:
record.write({self.link_field_id.name: [Command.link(res.id)]})
record.write({self.link_field_id.name: [Command.link(res_id)]})
else:
record.write({self.link_field_id.name: res.id})
record.write({self.link_field_id.name: res_id})
def _get_eval_context(self, action=None):
""" Prepare the context used when evaluating python code, like the
@ -610,13 +875,13 @@ class IrActionsServer(models.Model):
'env': self.env,
'model': model,
# Exceptions
'Warning': odoo.exceptions.Warning,
'UserError': odoo.exceptions.UserError,
# record
'record': record,
'records': records,
# helpers
'log': log,
'_logger': LoggerProxy,
})
return eval_context
@ -648,18 +913,21 @@ class IrActionsServer(models.Model):
if not (action_groups & self.env.user.groups_id):
raise AccessError(_("You don't have enough access rights to run this action."))
else:
model_name = action.model_id.model
try:
self.env[action.model_name].check_access_rights("write")
self.env[model_name].check_access_rights("write")
except AccessError:
_logger.warning("Forbidden server action %r executed while the user %s does not have access to %s.",
action.name, self.env.user.login, action.model_name,
action.name, self.env.user.login, model_name,
)
raise
eval_context = self._get_eval_context(action)
records = eval_context.get('record') or eval_context['model']
records |= eval_context.get('records') or eval_context['model']
if records:
if records.ids:
# check access rules on real records only; base automations of
# type 'onchange' can run server actions on new records
try:
records.check_access_rule('write')
except AccessError:
@ -695,81 +963,72 @@ class IrActionsServer(models.Model):
)
return res or False
class IrServerObjectLines(models.Model):
_name = 'ir.server.object.lines'
_description = 'Server Action value mapping'
_allow_sudo_commands = False
server_id = fields.Many2one('ir.actions.server', string='Related Server Action', ondelete='cascade')
col1 = fields.Many2one('ir.model.fields', string='Field', required=True, ondelete='cascade')
value = fields.Text(required=True, help="Expression containing a value specification. \n"
"When Formula type is selected, this field may be a Python expression "
" that can use the same values as for the code field on the server action.\n"
"If Value type is selected, the value will be used directly without evaluation.")
evaluation_type = fields.Selection([
('value', 'Value'),
('reference', 'Reference'),
('equation', 'Python expression')
], 'Evaluation Type', default='value', required=True, change_default=True)
resource_ref = fields.Reference(
string='Record', selection='_selection_target_model',
compute='_compute_resource_ref', inverse='_set_resource_ref')
@api.depends('evaluation_type', 'update_field_id')
def _compute_value_field_to_show(self): # check if value_field_to_show can be removed and use ttype in xml view instead
for action in self:
if action.update_field_id.ttype in ('one2many', 'many2one', 'many2many'):
action.value_field_to_show = 'resource_ref'
elif action.update_field_id.ttype == 'selection':
action.value_field_to_show = 'selection_value'
elif action.update_field_id.ttype == 'boolean':
action.value_field_to_show = 'update_boolean_value'
else:
action.value_field_to_show = 'value'
@api.model
def _selection_target_model(self):
return [(model.model, model.name) for model in self.env['ir.model'].sudo().search([])]
@api.depends('col1.relation', 'value', 'evaluation_type')
def _compute_resource_ref(self):
for line in self:
if line.evaluation_type in ['reference', 'value'] and line.col1 and line.col1.relation:
value = line.value or ''
try:
value = int(value)
if not self.env[line.col1.relation].browse(value).exists():
record = list(self.env[line.col1.relation]._search([], limit=1))
value = record[0] if record else 0
except ValueError:
record = list(self.env[line.col1.relation]._search([], limit=1))
value = record[0] if record else 0
line.resource_ref = '%s,%s' % (line.col1.relation, value)
else:
line.resource_ref = False
@api.constrains('col1', 'evaluation_type')
@api.constrains('update_field_id', 'evaluation_type')
def _raise_many2many_error(self):
pass # TODO: remove in master
@api.onchange('resource_ref')
def _set_resource_ref(self):
for line in self.filtered(lambda line: line.evaluation_type == 'reference'):
if line.resource_ref:
line.value = str(line.resource_ref.id)
for action in self.filtered(lambda action: action.value_field_to_show == 'resource_ref'):
if action.resource_ref:
action.value = str(action.resource_ref.id)
def eval_value(self, eval_context=None):
@api.onchange('selection_value')
def _set_selection_value(self):
for action in self.filtered(lambda action: action.value_field_to_show == 'selection_value'):
if action.selection_value:
action.value = action.selection_value.value
def _eval_value(self, eval_context=None):
result = {}
m2m_exprs = defaultdict(list)
for line in self:
expr = line.value
if line.evaluation_type == 'equation':
expr = safe_eval(line.value, eval_context)
elif line.col1.ttype in ['many2one', 'integer']:
for action in self:
expr = action.value
if action.evaluation_type == 'equation':
expr = safe_eval(action.value, eval_context)
elif action.update_field_id.ttype in ['one2many', 'many2many']:
operation = action.update_m2m_operation
if operation == 'add':
expr = [Command.link(int(action.value))]
elif operation == 'remove':
expr = [Command.unlink(int(action.value))]
elif operation == 'set':
expr = [Command.set([int(action.value)])]
elif operation == 'clear':
expr = [Command.clear()]
elif action.update_field_id.ttype == 'boolean':
expr = action.update_boolean_value == 'true'
elif action.update_field_id.ttype in ['many2one', 'integer']:
try:
expr = int(line.value)
expr = int(action.value)
except Exception:
pass
elif line.col1.ttype == 'many2many':
elif action.update_field_id.ttype == 'float':
with contextlib.suppress(Exception):
# if multiple lines target the same column, they need to exist in the same list
expr = m2m_exprs[line.col1]
expr.append(Command.link(int(line.value)))
elif line.col1.ttype == 'float':
with contextlib.suppress(Exception):
expr = float(line.value)
result[line.id] = expr
expr = float(action.value)
result[action.id] = expr
return result
def copy_data(self, default=None):
default = default or {}
if not default.get('name'):
default['name'] = _('%s (copy)', self.name)
return super().copy_data(default=default)
class IrActionsTodo(models.Model):
"""

View file

@ -7,7 +7,8 @@ from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo.exceptions import UserError, AccessError, RedirectWarning
from odoo.tools.safe_eval import safe_eval, time
from odoo.tools.misc import find_in_path, ustr
from odoo.tools import check_barcode_encoding, config, is_html_empty, parse_version
from odoo.tools import check_barcode_encoding, config, is_html_empty, parse_version, split_every
from odoo.tools.pdf import PdfFileWriter, PdfFileReader, PdfReadError
from odoo.http import request
from odoo.osv.expression import NEGATIVE_TERM_OPERATORS, FALSE_DOMAIN
@ -24,18 +25,14 @@ from lxml import etree
from contextlib import closing
from reportlab.graphics.barcode import createBarcodeDrawing
from reportlab.pdfbase.pdfmetrics import getFont, TypeFace
from PyPDF2 import PdfFileWriter, PdfFileReader
from collections import OrderedDict
from collections.abc import Iterable
from PIL import Image, ImageFile
from itertools import islice
# Allow truncated images
ImageFile.LOAD_TRUNCATED_IMAGES = True
try:
from PyPDF2.errors import PdfReadError
except ImportError:
from PyPDF2.utils import PdfReadError
_logger = logging.getLogger(__name__)
# A lock occurs when the user wants to print a report having multiple barcode while the server is
@ -61,6 +58,25 @@ except Exception:
def _get_wkhtmltopdf_bin():
return find_in_path('wkhtmltopdf')
def _split_table(tree, max_rows):
"""
Walks through the etree and splits tables with more than max_rows rows into
multiple tables with max_rows rows.
This function is needed because wkhtmltopdf has a exponential processing
time growth when processing tables with many rows. This function is a
workaround for this problem.
:param tree: The etree to process
:param max_rows: The maximum number of rows per table
"""
for table in list(tree.iter('table')):
prev = table
for rows in islice(split_every(max_rows, table), 1, None):
sibling = etree.Element('table', attrib=table.attrib)
sibling.extend(rows)
prev.addnext(sibling)
prev = sibling
# Check the presence of Wkhtmltopdf and return its version at Odoo start-up
wkhtmltopdf_state = 'install'
@ -140,6 +156,9 @@ class IrActionsReport(models.Model):
names = self.env['ir.model'].name_search(value, operator=operator)
ir_model_ids = [n[0] for n in names]
elif operator in ('any', 'not any'):
ir_model_ids = self.env['ir.model']._search(value)
elif isinstance(value, Iterable):
ir_model_ids = value
@ -223,16 +242,6 @@ class IrActionsReport(models.Model):
'''
return wkhtmltopdf_state
@api.model
def datamatrix_available(self):
'''Returns whether or not datamatrix creation is possible.
* True: Reportlab seems to be able to create datamatrix without error.
* False: Reportlab cannot seem to create datamatrix, most likely due to missing package dependency
:return: Boolean
'''
return True
def get_paperformat(self):
return self.paperformat_id or self.env.company.paperformat_id
@ -483,7 +492,19 @@ class IrActionsReport(models.Model):
prefix = '%s%d.' % ('report.body.tmp.', i)
body_file_fd, body_file_path = tempfile.mkstemp(suffix='.html', prefix=prefix)
with closing(os.fdopen(body_file_fd, 'wb')) as body_file:
body_file.write(body.encode())
# HACK: wkhtmltopdf doesn't like big table at all and the
# processing time become exponential with the number
# of rows (like 1H for 250k rows).
#
# So we split the table into multiple tables containing
# 500 rows each. This reduce the processing time to 1min
# for 250k rows. The number 500 was taken from opw-1689673
if len(body) < 4 * 1024 * 1024: # 4Mib
body_file.write(body.encode())
else:
tree = lxml.html.fromstring(body)
_split_table(tree, 500)
body_file.write(lxml.html.tostring(tree))
paths.append(body_file_path)
temporary_files.append(body_file_path)
@ -500,11 +521,18 @@ class IrActionsReport(models.Model):
if process.returncode not in [0, 1]:
if process.returncode == -11:
message = _(
'Wkhtmltopdf failed (error code: %s). Memory limit too low or maximum file number of subprocess reached. Message : %s')
'Wkhtmltopdf failed (error code: %s). Memory limit too low or maximum file number of subprocess reached. Message : %s',
process.returncode,
err[-1000:],
)
else:
message = _('Wkhtmltopdf failed (error code: %s). Message: %s')
_logger.warning(message, process.returncode, err[-1000:])
raise UserError(message % (str(process.returncode), err[-1000:]))
message = _(
'Wkhtmltopdf failed (error code: %s). Message: %s',
process.returncode,
err[-1000:],
)
_logger.warning(message)
raise UserError(message)
else:
if err:
_logger.warning('wkhtmltopdf: %s' % err)
@ -591,9 +619,6 @@ class IrActionsReport(models.Model):
elif barcode_type == 'auto':
symbology_guess = {8: 'EAN8', 13: 'EAN13'}
barcode_type = symbology_guess.get(len(value), 'Code128')
elif barcode_type == 'DataMatrix':
# Prevent a crash due to a lib change from pylibdmtx to reportlab
barcode_type = 'ECC200DataMatrix'
elif barcode_type == 'QR':
# for `QR` type, `quiet` is not supported. And is simply ignored.
# But we can use `barBorder` to get a similar behaviour.
@ -704,7 +729,7 @@ class IrActionsReport(models.Model):
stream = None
attachment = None
if not has_duplicated_ids and report_sudo.attachment:
if not has_duplicated_ids and report_sudo.attachment and not self._context.get("report_pdf_no_attachment"):
attachment = report_sudo.retrieve_attachment(record)
# Extract the stream from the attachment.
@ -747,17 +772,6 @@ class IrActionsReport(models.Model):
additional_context = {'debug': False}
data.setdefault("debug", False)
# As the assets are generated during the same transaction as the rendering of the
# templates calling them, there is a scenario where the assets are unreachable: when
# you make a request to read the assets while the transaction creating them is not done.
# Indeed, when you make an asset request, the controller has to read the `ir.attachment`
# table.
# This scenario happens when you want to print a PDF report for the first time, as the
# assets are not in cache and must be generated. To workaround this issue, we manually
# commit the writes in the `ir.attachment` table. It is done thanks to a key in the context.
if not config['test_enable'] and 'commit_assetsbundle' not in self.env.context:
additional_context['commit_assetsbundle'] = True
html = self.with_context(**additional_context)._render_qweb_html(report_ref, all_res_ids_wo_stream, data=data)[0]
bodies, html_ids, header, footer, specific_paperformat_args = self.with_context(**additional_context)._prepare_html(html, report_model=report_sudo.model)
@ -907,6 +921,7 @@ class IrActionsReport(models.Model):
if (tools.config['test_enable'] or tools.config['test_file']) and not self.env.context.get('force_report_rendering'):
return self._render_qweb_html(report_ref, res_ids, data=data)
self = self.with_context(webp_as_jpg=True)
collected_streams = self._render_qweb_pdf_prepare_streams(report_ref, data, res_ids=res_ids)
has_duplicated_ids = res_ids and len(res_ids) != len(set(res_ids))
@ -914,7 +929,7 @@ class IrActionsReport(models.Model):
report_sudo = self._get_report(report_ref)
# Generate the ir.attachment if needed.
if not has_duplicated_ids and report_sudo.attachment:
if not has_duplicated_ids and report_sudo.attachment and not self._context.get("report_pdf_no_attachment"):
attachment_vals_list = self._prepare_pdf_report_attachment_vals_list(report_sudo, collected_streams)
if attachment_vals_list:
attachment_names = ', '.join(x['name'] for x in attachment_vals_list)
@ -1055,5 +1070,6 @@ class IrActionsReport(models.Model):
py_ctx = json.loads(action.get('context', {}))
report_action['close_on_report_download'] = True
py_ctx['report_action'] = report_action
py_ctx['dialog_size'] = 'large'
action['context'] = py_ctx
return action

View file

@ -1,5 +1,4 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import os
from glob import glob
from logging import getLogger
@ -9,12 +8,10 @@ import odoo
import odoo.modules.module # get_manifest, don't from-import it
from odoo import api, fields, models, tools
from odoo.tools import misc
from odoo.tools.constants import ASSET_EXTENSIONS, EXTERNAL_ASSET
_logger = getLogger(__name__)
SCRIPT_EXTENSIONS = ('js',)
STYLE_EXTENSIONS = ('css', 'scss', 'sass', 'less')
TEMPLATE_EXTENSIONS = ('xml',)
DEFAULT_SEQUENCE = 16
# Directives are stored in variables for ease of use and syntax checks.
@ -27,7 +24,6 @@ REPLACE_DIRECTIVE = 'replace'
INCLUDE_DIRECTIVE = 'include'
# Those are the directives used with a 'target' argument/field.
DIRECTIVES_WITH_TARGET = [AFTER_DIRECTIVE, BEFORE_DIRECTIVE, REPLACE_DIRECTIVE]
WILDCARD_CHARACTERS = {'*', "?", "[", "]"}
def fs2web(path):
@ -36,14 +32,21 @@ def fs2web(path):
return path
return '/'.join(path.split(os.path.sep))
def can_aggregate(url):
parsed = urls.url_parse(url)
return not parsed.scheme and not parsed.netloc and not url.startswith('/web/content')
def is_wildcard_glob(path):
"""Determine whether a path is a wildcarded glob eg: "/web/file[14].*"
or a genuine single file path "/web/myfile.scss"""
return not WILDCARD_CHARACTERS.isdisjoint(path)
return '*' in path or '[' in path or ']' in path or '?' in path
def _glob_static_file(pattern):
files = glob(pattern, recursive=True)
return sorted((file, os.path.getmtime(file)) for file in files if file.rsplit('.', 1)[-1] in ASSET_EXTENSIONS)
class IrAsset(models.Model):
@ -62,15 +65,16 @@ class IrAsset(models.Model):
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
self.env.registry.clear_cache('assets')
return super().create(vals_list)
def write(self, values):
self.clear_caches()
if self:
self.env.registry.clear_cache('assets')
return super().write(values)
def unlink(self):
self.clear_caches()
self.env.registry.clear_cache('assets')
return super().unlink()
name = fields.Char(string='Name', required=True)
@ -88,7 +92,38 @@ class IrAsset(models.Model):
active = fields.Boolean(string='active', default=True)
sequence = fields.Integer(string="Sequence", default=DEFAULT_SEQUENCE, required=True)
def _get_asset_paths(self, bundle, addons=None, css=False, js=False):
def _get_asset_params(self):
"""
This method can be overriden to add param _get_asset_paths call.
Those params will be part of the orm cache key
"""
return {}
def _get_asset_bundle_url(self, filename, unique, assets_params, ignore_params=False):
return f'/web/assets/{unique}/{filename}'
def _parse_bundle_name(self, bundle_name, debug_assets):
bundle_name, asset_type = bundle_name.rsplit('.', 1)
rtl = False
if not debug_assets:
bundle_name, min_ = bundle_name.rsplit('.', 1)
if min_ != 'min':
raise ValueError("'min' expected in extension in non debug mode")
if asset_type == 'css':
if bundle_name.endswith('.rtl'):
bundle_name = bundle_name[:-4]
rtl = True
elif asset_type != 'js':
raise ValueError('Only js and css assets bundle are supported for now')
if len(bundle_name.split('.')) != 2:
raise ValueError(f'{bundle_name} is not a valid bundle name, should have two parts')
return bundle_name, rtl, asset_type
@tools.conditional(
'xml' not in tools.config['dev_mode'],
tools.ormcache('bundle', 'tuple(sorted(assets_params.items()))', cache='assets'),
)
def _get_asset_paths(self, bundle, assets_params):
"""
Fetches all asset file paths from a given list of addons matching a
certain bundle. The returned list is composed of tuples containing the
@ -106,22 +141,21 @@ class IrAsset(models.Model):
records matching the bundle are also applied to the current list.
:param bundle: name of the bundle from which to fetch the file paths
:param addons: list of addon names as strings. The files returned will
only be contained in the given addons.
:param css: boolean: whether or not to include style files
:param js: boolean: whether or not to include script files and template
files
:param assets_params: parameters needed by overrides, mainly website_id
see _get_asset_params
:returns: the list of tuples (path, addon, bundle)
"""
installed = self._get_installed_addons_list()
if addons is None:
addons = self._get_active_addons_list()
addons = self._get_active_addons_list(**assets_params)
asset_paths = AssetPaths()
self._fill_asset_paths(bundle, addons, installed, css, js, asset_paths, [])
addons = self._topological_sort(tuple(addons))
self._fill_asset_paths(bundle, asset_paths, [], addons, installed, **assets_params)
return asset_paths.list
def _fill_asset_paths(self, bundle, addons, installed, css, js, asset_paths, seen):
def _fill_asset_paths(self, bundle, asset_paths, seen, addons, installed, **assets_params):
"""
Fills the given AssetPaths instance by applying the operations found in
the matching bundle of the given addons manifests.
@ -138,77 +172,73 @@ class IrAsset(models.Model):
if bundle in seen:
raise Exception("Circular assets bundle declaration: %s" % " > ".join(seen + [bundle]))
exts = []
if js:
exts += SCRIPT_EXTENSIONS
exts += TEMPLATE_EXTENSIONS
if css:
exts += STYLE_EXTENSIONS
# this index is used for prepending: files are inserted at the beginning
# of the CURRENT bundle.
bundle_start_index = len(asset_paths.list)
def process_path(directive, target, path_def):
"""
This sub function is meant to take a directive and a set of
arguments and apply them to the current asset_paths list
accordingly.
It is nested inside `_get_asset_paths` since we need the current
list of addons, extensions and asset_paths.
:param directive: string
:param target: string or None or False
:param path_def: string
"""
if directive == INCLUDE_DIRECTIVE:
# recursively call this function for each INCLUDE_DIRECTIVE directive.
self._fill_asset_paths(path_def, addons, installed, css, js, asset_paths, seen + [bundle])
return
addon, paths = self._get_paths(path_def, installed, exts)
# retrieve target index when it applies
if directive in DIRECTIVES_WITH_TARGET:
_, target_paths = self._get_paths(target, installed, exts)
if not target_paths and target.rpartition('.')[2] not in exts:
# nothing to do: the extension of the target is wrong
return
target_to_index = len(target_paths) and target_paths[0] or target
target_index = asset_paths.index(target_to_index, addon, bundle)
if directive == APPEND_DIRECTIVE:
asset_paths.append(paths, addon, bundle)
elif directive == PREPEND_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, bundle_start_index)
elif directive == AFTER_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, target_index + 1)
elif directive == BEFORE_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, target_index)
elif directive == REMOVE_DIRECTIVE:
asset_paths.remove(paths, addon, bundle)
elif directive == REPLACE_DIRECTIVE:
asset_paths.insert(paths, addon, bundle, target_index)
asset_paths.remove(target_paths, addon, bundle)
else:
# this should never happen
raise ValueError("Unexpected directive")
assets = self._get_related_assets([('bundle', '=', bundle)], **assets_params).filtered('active')
# 1. Process the first sequence of 'ir.asset' records
assets = self._get_related_assets([('bundle', '=', bundle)]).filtered('active')
for asset in assets.filtered(lambda a: a.sequence < DEFAULT_SEQUENCE):
process_path(asset.directive, asset.target, asset.path)
self._process_path(bundle, asset.directive, asset.target, asset.path, asset_paths, seen, addons, installed, bundle_start_index, **assets_params)
# 2. Process all addons' manifests.
for addon in self._topological_sort(tuple(addons)):
for addon in addons:
for command in odoo.modules.module._get_manifest_cached(addon)['assets'].get(bundle, ()):
directive, target, path_def = self._process_command(command)
process_path(directive, target, path_def)
self._process_path(bundle, directive, target, path_def, asset_paths, seen, addons, installed, bundle_start_index, **assets_params)
# 3. Process the rest of 'ir.asset' records
for asset in assets.filtered(lambda a: a.sequence >= DEFAULT_SEQUENCE):
process_path(asset.directive, asset.target, asset.path)
self._process_path(bundle, asset.directive, asset.target, asset.path, asset_paths, seen, addons, installed, bundle_start_index, **assets_params)
def _process_path(self, bundle, directive, target, path_def, asset_paths, seen, addons, installed, bundle_start_index, **assets_params):
"""
This sub function is meant to take a directive and a set of
arguments and apply them to the current asset_paths list
accordingly.
It is nested inside `_get_asset_paths` since we need the current
list of addons, extensions and asset_paths.
:param directive: string
:param target: string or None or False
:param path_def: string
"""
if directive == INCLUDE_DIRECTIVE:
# recursively call this function for each INCLUDE_DIRECTIVE directive.
self._fill_asset_paths(path_def, asset_paths, seen + [bundle], addons, installed, **assets_params)
return
if can_aggregate(path_def):
paths = self._get_paths(path_def, installed)
else:
paths = [(path_def, EXTERNAL_ASSET, -1)] # external urls
# retrieve target index when it applies
if directive in DIRECTIVES_WITH_TARGET:
target_paths = self._get_paths(target, installed)
if not target_paths and target.rpartition('.')[2] not in ASSET_EXTENSIONS:
# nothing to do: the extension of the target is wrong
return
if target_paths:
target = target_paths[0][0]
target_index = asset_paths.index(target, bundle)
if directive == APPEND_DIRECTIVE:
asset_paths.append(paths, bundle)
elif directive == PREPEND_DIRECTIVE:
asset_paths.insert(paths, bundle, bundle_start_index)
elif directive == AFTER_DIRECTIVE:
asset_paths.insert(paths, bundle, target_index + 1)
elif directive == BEFORE_DIRECTIVE:
asset_paths.insert(paths, bundle, target_index)
elif directive == REMOVE_DIRECTIVE:
asset_paths.remove(paths, bundle)
elif directive == REPLACE_DIRECTIVE:
asset_paths.insert(paths, bundle, target_index)
asset_paths.remove(target_paths, bundle)
else:
# this should never happen
raise ValueError("Unexpected directive")
def _get_related_assets(self, domain):
"""
@ -217,6 +247,8 @@ class IrAsset(models.Model):
:param domain: search domain
:returns: ir.asset recordset
"""
# active_test is needed to disable some assets through filter_duplicate for website
# they will be filtered on active afterward
return self.with_context(active_test=False).sudo().search(domain, order='sequence, id')
def _get_related_bundle(self, target_path_def, root_bundle):
@ -230,16 +262,12 @@ class IrAsset(models.Model):
:root_bundle: string: bundle from which to initiate the search.
:returns: the first matching bundle or None
"""
ext = target_path_def.split('.')[-1]
installed = self._get_installed_addons_list()
target_path = self._get_paths(target_path_def, installed)[1][0]
target_path, _full_path, _modified = self._get_paths(target_path_def, installed)[0]
assets_params = self._get_asset_params()
asset_paths = self._get_asset_paths(root_bundle, assets_params)
css = ext in STYLE_EXTENSIONS
js = ext in SCRIPT_EXTENSIONS or ext in TEMPLATE_EXTENSIONS
asset_paths = self._get_asset_paths(root_bundle, css=css, js=js)
for path, _, bundle in asset_paths:
for path, _full_path, bundle, _modified in asset_paths:
if path == target_path:
return bundle
@ -273,7 +301,7 @@ class IrAsset(models.Model):
return misc.topological_sort({manif['name']: tuple(manif['depends']) for manif in manifs})
@api.model
@tools.ormcache_context(keys='install_module')
@tools.ormcache()
def _get_installed_addons_list(self):
"""
Returns the list of all installed addons.
@ -281,26 +309,33 @@ class IrAsset(models.Model):
"""
# Main source: the current registry list
# Second source of modules: server wide modules
# Third source: the currently loading module from the context (similar to ir_ui_view)
return self.env.registry._init_modules.union(odoo.conf.server_wide_modules or []).union(self.env.context.get('install_module', []))
return self.env.registry._init_modules.union(odoo.conf.server_wide_modules or [])
def _get_paths(self, path_def, installed, extensions=None):
def _get_paths(self, path_def, installed):
"""
Returns a list of file paths matching a given glob (path_def) as well as
the addon targeted by the path definition. If no file matches that glob,
the path definition is returned as is. This is either because the path is
not correctly written or because it points to a URL.
Returns a list of tuple (path, full_path, modified) matching a given glob (path_def).
The glob can only occur in the static direcory of an installed addon.
If the path_def matches a (list of) file, the result will contain the full_path
and the modified time.
Ex: ('/base/static/file.js', '/home/user/source/odoo/odoo/addons/base/static/file.js', 643636800)
If the path_def looks like a non aggregable path (http://, /web/assets), only return the path
Ex: ('http://example.com/lib.js', None, -1)
The timestamp -1 is given to be thruthy while carrying no information.
If the path_def is not a wildward, but may still be a valid addons path, return a False path
with No timetamp
Ex: ('/_custom/web.asset_frontend', False, None)
:param path_def: the definition (glob) of file paths to match
:param installed: the list of installed addons
:param extensions: a list of extensions that found files must match
:returns: a tuple: the addon targeted by the path definition [0] and the
list of file paths matching the definition [1] (or the glob itself if
none). Note that these paths are filtered on the given `extensions`.
:returns: a list of tuple: (path, full_path, modified)
"""
paths = []
path_url = fs2web(path_def)
path_parts = [part for part in path_url.split('/') if part]
paths = None
path_def = fs2web(path_def) # we expect to have all path definition unix style or url style, this is a safety
path_parts = [part for part in path_def.split('/') if part]
addon = path_parts[0]
addon_manifest = odoo.modules.module._get_manifest_cached(addon)
@ -308,49 +343,28 @@ class IrAsset(models.Model):
if addon_manifest:
if addon not in installed:
# Assert that the path is in the installed addons
raise Exception("Unallowed to fetch files from addon %s" % addon)
addons_path = os.path.join(addon_manifest['addons_path'], '')[:-1]
full_path = os.path.normpath(os.path.join(addons_path, *path_parts))
# first security layer: forbid escape from the current addon
raise Exception(f"Unallowed to fetch files from addon {addon} for file {path_def}")
addons_path = addon_manifest['addons_path']
full_path = os.path.normpath(os.sep.join([addons_path, *path_parts]))
# forbid escape from the current addon
# "/mymodule/../myothermodule" is forbidden
# the condition after the or is to further guarantee that we won't access
# a directory that happens to be named like an addon (web....)
if addon not in full_path or addons_path not in full_path:
addon = None
safe_path = False
else:
static_prefix = os.sep.join([addons_path, addon, 'static', ''])
if full_path.startswith(static_prefix):
paths_with_timestamps = _glob_static_file(full_path)
paths = [
path for path in sorted(glob(full_path, recursive=True))
(fs2web(absolute_path[len(addons_path):]), absolute_path, timestamp)
for absolute_path, timestamp in paths_with_timestamps
]
# second security layer: do we have the right to access the files
# that are grabbed by the glob ?
# In particular we don't want to expose data in xmls of the module
def is_safe_path(path):
try:
misc.file_path(path, SCRIPT_EXTENSIONS + STYLE_EXTENSIONS + TEMPLATE_EXTENSIONS)
except (ValueError, FileNotFoundError):
return False
if path.rpartition('.')[2] in TEMPLATE_EXTENSIONS:
# normpath will strip the trailing /, which is why it has to be added afterwards
static_path = os.path.normpath("%s/static" % addon) + os.path.sep
# Forbid xml to leak
return static_path in path
return True
len_paths = len(paths)
paths = list(filter(is_safe_path, paths))
safe_path = safe_path and len_paths == len(paths)
# Web assets must be loaded using relative paths.
paths = [fs2web(path[len(addons_path):]) for path in paths]
else:
safe_path = False
else:
addon = None
safe_path = False
if not paths and (not can_aggregate(path_url) or (safe_path and not is_wildcard_glob(path_url))):
# No file matching the path; the path_def could be a url.
paths = [path_url]
if not paths and not can_aggregate(path_def): # http:// or /web/content
paths = [(path_def, EXTERNAL_ASSET, -1)]
if not paths and not is_wildcard_glob(path_def): # an attachment url most likely
paths = [(path_def, None, None)]
if not paths:
msg = f'IrAsset: the path "{path_def}" did not resolve to anything.'
@ -358,11 +372,7 @@ class IrAsset(models.Model):
msg += " It may be due to security reasons."
_logger.warning(msg)
# Paths are filtered on the extensions (if any).
return addon, [
path
for path in paths
if not extensions or path.split('.')[-1] in extensions
]
return paths
def _process_command(self, command):
"""Parses a given command to return its directive, target and path definition."""
@ -383,7 +393,7 @@ class AssetPaths:
self.list = []
self.memo = set()
def index(self, path, addon, bundle):
def index(self, path, bundle):
"""Returns the index of the given path in the current assets list."""
if path not in self.memo:
self._raise_not_found(path, bundle)
@ -391,32 +401,32 @@ class AssetPaths:
if asset[0] == path:
return index
def append(self, paths, addon, bundle):
def append(self, paths, bundle):
"""Appends the given paths to the current list."""
for path in paths:
for path, full_path, last_modified in paths:
if path not in self.memo:
self.list.append((path, addon, bundle))
self.list.append((path, full_path, bundle, last_modified))
self.memo.add(path)
def insert(self, paths, addon, bundle, index):
def insert(self, paths, bundle, index):
"""Inserts the given paths to the current list at the given position."""
to_insert = []
for path in paths:
for path, full_path, last_modified in paths:
if path not in self.memo:
to_insert.append((path, addon, bundle))
to_insert.append((path, full_path, bundle, last_modified))
self.memo.add(path)
self.list[index:index] = to_insert
def remove(self, paths_to_remove, addon, bundle):
def remove(self, paths_to_remove, bundle):
"""Removes the given paths from the current list."""
paths = {path for path in paths_to_remove if path in self.memo}
paths = {path for path, _full_path, _last_modified in paths_to_remove if path in self.memo}
if paths:
self.list[:] = [asset for asset in self.list if asset[0] not in paths]
self.memo.difference_update(paths)
return
if paths_to_remove:
self._raise_not_found(paths_to_remove, bundle)
self._raise_not_found([path for path, _full_path, _last_modified in paths_to_remove], bundle)
def _raise_not_found(self, path, bundle):
raise ValueError("File(s) %s not found in bundle %s" % (path, bundle))

View file

@ -1,6 +1,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import binascii
import contextlib
import hashlib
import io
@ -8,6 +9,7 @@ import itertools
import logging
import mimetypes
import os
import psycopg2
import re
import uuid
@ -81,6 +83,11 @@ class IrAttachment(models.Model):
def _migrate(self):
record_count = len(self)
storage = self._storage().upper()
# When migrating to filestore verifying if the directory has write permission
if storage == 'FILE':
filestore = self._filestore()
if not os.access(filestore, os.W_OK):
raise PermissionError("Write permission denied for filestore directory.")
for index, attach in enumerate(self):
_logger.debug("Migrate attachment %s/%s to %s", index + 1, record_count, storage)
# pass mimetype, to avoid recomputation
@ -107,7 +114,7 @@ class IrAttachment(models.Model):
full_path = self._full_path(fname)
dirname = os.path.dirname(full_path)
if not os.path.isdir(dirname):
os.makedirs(dirname)
os.makedirs(dirname, exist_ok=True)
# prevent sha-1 collision
if os.path.isfile(full_path) and not self._same_content(bin_data, full_path):
raise UserError(_("The attachment collides with an existing file."))
@ -177,7 +184,11 @@ class IrAttachment(models.Model):
# but only attempt to grab the lock for a little bit, otherwise it'd
# start blocking other transactions. (will be retried later anyway)
cr.execute("SET LOCAL lock_timeout TO '10s'")
cr.execute("LOCK ir_attachment IN SHARE MODE")
try:
cr.execute("LOCK ir_attachment IN SHARE MODE")
except psycopg2.errors.LockNotAvailable:
cr.rollback()
return False
self._gc_file_store_unsafe()
@ -320,7 +331,7 @@ class IrAttachment(models.Model):
supported_subtype = ICP('base.image_autoresize_extensions', 'png,jpeg,bmp,tiff').split(',')
mimetype = values['mimetype'] = self._compute_mimetype(values)
_type, _, _subtype = mimetype.partition('/')
_type, _match, _subtype = mimetype.partition('/')
is_image_resizable = _type == 'image' and _subtype in supported_subtype
if is_image_resizable and (values.get('datas') or values.get('raw')):
is_raw = values.get('raw')
@ -335,6 +346,10 @@ class IrAttachment(models.Model):
else: # datas
img = ImageProcess(base64.b64decode(values['datas']), verify_resolution=False)
if not img.image:
_logger.info('Post processing ignored : Empty source, SVG, or WEBP')
return values
w, h = img.image.size
nw, nh = map(int, max_resolution.split('x'))
if w > nw or h > nh:
@ -356,12 +371,9 @@ class IrAttachment(models.Model):
xml_like = 'ht' in mimetype or ( # hta, html, xhtml, etc.
'xml' in mimetype and # other xml (svg, text/xml, etc)
not mimetype.startswith('application/vnd.openxmlformats')) # exception for Office formats
user = self.env.context.get('binary_field_real_user', self.env.user)
if not isinstance(user, self.pool['res.users']):
raise UserError(_("binary_field_real_user should be a res.users record."))
force_text = xml_like and (
self.env.context.get('attachments_mime_plainxml') or
not self.env['ir.ui.view'].with_user(user).check_access_rights('write', False))
not self.env['ir.ui.view'].sudo(False).check_access_rights('write', False))
if force_text:
values['mimetype'] = 'text/plain'
if not self.env.context.get('image_no_postprocess'):
@ -416,7 +428,7 @@ class IrAttachment(models.Model):
db_datas = fields.Binary('Database Data', attachment=False)
store_fname = fields.Char('Stored Filename', index=True, unaccent=False)
file_size = fields.Integer('File Size', readonly=True)
checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True)
checksum = fields.Char("Checksum/SHA1", size=40, readonly=True)
mimetype = fields.Char('Mime Type', readonly=True)
index_content = fields.Text('Indexed Content', readonly=True, prefetch=False)
@ -457,8 +469,14 @@ class IrAttachment(models.Model):
for res_model, res_id, create_uid, public, res_field in self._cr.fetchall():
if public and mode == 'read':
continue
if not self.env.is_system() and (res_field or (not res_id and create_uid != self.env.uid)):
raise AccessError(_("Sorry, you are not allowed to access this document."))
if not self.env.is_system():
if not res_id and create_uid != self.env.uid:
raise AccessError(_("Sorry, you are not allowed to access this document."))
if res_field:
field = self.env[res_model]._fields[res_field]
if field.groups:
if not self.env.user.user_has_groups(field.groups):
raise AccessError(_("Sorry, you are not allowed to access this document."))
if not (res_model and res_id):
continue
model_ids[res_model].add(res_id)
@ -506,86 +524,71 @@ class IrAttachment(models.Model):
continue
return ret_attachments
def _read_group_allowed_fields(self):
return ['type', 'company_id', 'res_id', 'create_date', 'create_uid', 'name', 'mimetype', 'id', 'url', 'res_field', 'res_model']
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
"""Override read_group to add res_field=False in domain if not present."""
if not fields:
raise AccessError(_("Sorry, you must provide fields to read on attachments"))
groupby = [groupby] if isinstance(groupby, str) else groupby
if any('(' in field for field in fields + groupby):
raise AccessError(_("Sorry, the syntax 'name:agg(field)' is not available for attachments"))
if not any(item[0] in ('id', 'res_field') for item in domain):
domain.insert(0, ('res_field', '=', False))
allowed_fields = self._read_group_allowed_fields()
fields_set = set(field.split(':')[0] for field in fields + groupby)
if not self.env.is_system() and (not fields or fields_set.difference(allowed_fields)):
raise AccessError(_("Sorry, you are not allowed to access these fields on attachments."))
return super().read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
def _search(self, domain, offset=0, limit=None, order=None, access_rights_uid=None):
# add res_field=False in domain if not present; the arg[0] trick below
# works for domain items and '&'/'|'/'!' operators too
discard_binary_fields_attachments = False
if not any(arg[0] in ('id', 'res_field') for arg in args):
discard_binary_fields_attachments = True
args.insert(0, ('res_field', '=', False))
ids = super(IrAttachment, self)._search(args, offset=offset, limit=limit, order=order,
count=False, access_rights_uid=access_rights_uid)
disable_binary_fields_attachments = False
if not self.env.context.get('skip_res_field_check') and not any(arg[0] in ('id', 'res_field') for arg in domain):
disable_binary_fields_attachments = True
domain = [('res_field', '=', False)] + domain
if self.env.is_superuser():
# rules do not apply for the superuser
return len(ids) if count else ids
if not ids:
return 0 if count else []
# Work with a set, as list.remove() is prohibitive for large lists of documents
# (takes 20+ seconds on a db with 100k docs during search_count()!)
orig_ids = ids
ids = set(ids)
return super()._search(domain, offset, limit, order, access_rights_uid)
# For attachments, the permissions of the document they are attached to
# apply, so we must remove attachments for which the user cannot access
# the linked document.
# Use pure SQL rather than read() as it is about 50% faster for large dbs (100k+ docs),
# and the permissions are checked in super() and below anyway.
# the linked document. For the sake of performance, fetch the fields to
# determine those permissions within the same SQL query.
self.flush_model(['res_model', 'res_id', 'res_field', 'public', 'create_uid'])
query = super()._search(domain, offset, limit, order, access_rights_uid)
query_str, params = query.select(
f'"{self._table}"."id"',
f'"{self._table}"."res_model"',
f'"{self._table}"."res_id"',
f'"{self._table}"."res_field"',
f'"{self._table}"."public"',
f'"{self._table}"."create_uid"',
)
self.env.cr.execute(query_str, params)
rows = self.env.cr.fetchall()
# determine permissions based on linked records
all_ids = []
allowed_ids = set()
model_attachments = defaultdict(lambda: defaultdict(set)) # {res_model: {res_id: set(ids)}}
binary_fields_attachments = set()
self._cr.execute("""SELECT id, res_model, res_id, public, res_field FROM ir_attachment WHERE id IN %s""", [tuple(ids)])
for row in self._cr.dictfetchall():
if not row['res_model'] or row['public']:
for id_, res_model, res_id, res_field, public, create_uid in rows:
all_ids.append(id_)
if public:
allowed_ids.add(id_)
continue
# model_attachments = {res_model: {res_id: set(ids)}}
model_attachments[row['res_model']][row['res_id']].add(row['id'])
# Should not retrieve binary fields attachments if not explicitly required
if discard_binary_fields_attachments and row['res_field']:
binary_fields_attachments.add(row['id'])
if binary_fields_attachments:
ids.difference_update(binary_fields_attachments)
if res_field and not self.env.is_system():
field = self.env[res_model]._fields[res_field]
if field.groups and not self.env.user.user_has_groups(field.groups):
continue
# To avoid multiple queries for each attachment found, checks are
# performed in batch as much as possible.
if not res_id and (self.env.is_system() or create_uid == self.env.uid):
allowed_ids.add(id_)
continue
if not (res_field and disable_binary_fields_attachments) and res_model and res_id:
model_attachments[res_model][res_id].add(id_)
# check permissions on records model by model
for res_model, targets in model_attachments.items():
if res_model not in self.env:
allowed_ids.update(id_ for ids in targets.values() for id_ in ids)
continue
if not self.env[res_model].check_access_rights('read', False):
# remove all corresponding attachment ids
ids.difference_update(itertools.chain(*targets.values()))
continue
# filter ids according to what access rules permit
target_ids = list(targets)
allowed = self.env[res_model].with_context(active_test=False).search([('id', 'in', target_ids)])
for res_id in set(target_ids).difference(allowed.ids):
ids.difference_update(targets[res_id])
ResModel = self.env[res_model].with_context(active_test=False)
for res_id in ResModel.search([('id', 'in', list(targets))])._ids:
allowed_ids.update(targets[res_id])
# sort result according to the original sort ordering
result = [id for id in orig_ids if id in ids]
# filter out all_ids by keeping allowed_ids only
result = [id_ for id_ in all_ids if id_ in allowed_ids]
# If the original search reached the limit, it is important the
# filtered record set does so too. When a JS view receive a
@ -593,17 +596,14 @@ class IrAttachment(models.Model):
# reached the last page. To avoid an infinite recursion due to the
# permission checks the sub-call need to be aware of the number of
# expected records to retrieve
if len(orig_ids) == limit and len(result) < self._context.get('need', limit):
if len(all_ids) == limit and len(result) < self._context.get('need', limit):
need = self._context.get('need', limit) - len(result)
result.extend(self.with_context(need=need)._search(args, offset=offset + len(orig_ids),
limit=limit, order=order, count=False,
access_rights_uid=access_rights_uid)[:limit - len(result)])
more_ids = self.with_context(need=need)._search(
domain, offset + len(all_ids), limit, order, access_rights_uid,
)
result.extend(list(more_ids)[:limit - len(result)])
return len(result) if count else list(result)
def _read(self, fields):
self.check('read')
return super(IrAttachment, self)._read(fields)
return self.browse(result)._as_query(order)
def write(self, vals):
self.check('write', values=vals)
@ -672,7 +672,8 @@ class IrAttachment(models.Model):
Attachments.check('create', values={'res_model':res_model, 'res_id':res_id})
return super().create(vals_list)
def _post_add_create(self):
def _post_add_create(self, **kwargs):
# TODO master: rename to _post_upload, better indicating its usage
pass
def generate_access_token(self):
@ -686,6 +687,31 @@ class IrAttachment(models.Model):
tokens.append(access_token)
return tokens
@api.model
def create_unique(self, values_list):
ids = []
for values in values_list:
# Create only if record does not already exist for checksum and size.
try:
bin_data = base64.b64decode(values.get('datas', '')) or False
except binascii.Error:
raise UserError(_("Attachment is not encoded in base64."))
checksum = self._compute_checksum(bin_data)
existing_domain = [
['id', '!=', False], # No implicit condition on res_field.
['checksum', '=', checksum],
['file_size', '=', len(bin_data)],
['mimetype', '=', values['mimetype']],
]
existing = self.sudo().search(existing_domain)
if existing:
for attachment in existing:
ids.append(attachment.id)
else:
attachment = self.create(values)
ids.append(attachment.id)
return ids
def _generate_access_token(self):
return str(uuid.uuid4())
@ -729,4 +755,4 @@ class IrAttachment(models.Model):
('res_id', '=', 0),
('create_uid', '=', SUPERUSER_ID),
]).unlink()
self.clear_caches()
self.env.registry.clear_cache('assets')

View file

@ -40,22 +40,3 @@ class AutoVacuum(models.AbstractModel):
except Exception:
_logger.exception("Failed %s.%s()", model, attr)
self.env.cr.rollback()
# Ensure backward compatibility with the previous autovacuum API
try:
self.power_on()
self.env.cr.commit()
except Exception:
_logger.exception("Failed power_on")
self.env.cr.rollback()
# Deprecated API
@api.model
def power_on(self, *args, **kwargs):
tb = traceback.extract_stack(limit=2)
if tb[-2].name == 'power_on':
warnings.warn(
"You are extending the 'power_on' ir.autovacuum method"
f"in {tb[-2].filename} around line {tb[-2].lineno}. "
"You should instead use the @api.autovacuum decorator "
"on your garbage collecting method.", DeprecationWarning, stacklevel=2)

View file

@ -211,16 +211,17 @@ class IrBinary(models.AbstractModel):
if not placeholder:
placeholder = record._get_placeholder_filename(field_name)
stream = self._get_placeholder_stream(placeholder)
if (width, height) == (0, 0):
width, height = image_guess_size_from_field_name(field_name)
if stream.type == 'url':
return stream # Rezising an external URL is not supported
if not stream.mimetype.startswith('image/'):
stream.mimetype = 'application/octet-stream'
if (width, height) == (0, 0):
width, height = image_guess_size_from_field_name(field_name)
if isinstance(stream.etag, str):
stream.etag += f'-{width}x{height}-crop={crop}-quality={quality}'
if isinstance(stream.last_modified, (int, float)):
stream.last_modified = datetime.fromtimestamp(stream.last_modified, tz=None)
modified = werkzeug.http.is_resource_modified(

View file

@ -104,7 +104,7 @@ class IrConfigParameter(models.Model):
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
self.env.registry.clear_cache()
return super(IrConfigParameter, self).create(vals_list)
def write(self, vals):
@ -112,11 +112,11 @@ class IrConfigParameter(models.Model):
illegal = _default_parameters.keys() & self.mapped('key')
if illegal:
raise ValidationError(_("You cannot rename config parameters with keys %s", ', '.join(illegal)))
self.clear_caches()
self.env.registry.clear_cache()
return super(IrConfigParameter, self).write(vals)
def unlink(self):
self.clear_caches()
self.env.registry.clear_cache()
return super(IrConfigParameter, self).unlink()
@api.ondelete(at_uninstall=False)

View file

@ -7,20 +7,19 @@ import psycopg2
import pytz
from datetime import datetime, timedelta
from dateutil.relativedelta import relativedelta
from psycopg2 import sql
import odoo
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from psycopg2 import sql
_logger = logging.getLogger(__name__)
BASE_VERSION = odoo.modules.get_manifest('base')['version']
MAX_FAIL_TIME = timedelta(hours=5) # chosen with a fair roll of the dice
# custom function to call instead of NOTIFY postgresql command (opt-in)
ODOO_NOTIFY_FUNCTION = os.environ.get('ODOO_NOTIFY_FUNCTION')
# custom function to call instead of default PostgreSQL's `pg_notify`
ODOO_NOTIFY_FUNCTION = os.getenv('ODOO_NOTIFY_FUNCTION', 'pg_notify')
class BadVersion(Exception):
@ -56,7 +55,7 @@ class ir_cron(models.Model):
ir_actions_server_id = fields.Many2one(
'ir.actions.server', 'Server action',
delegate=True, ondelete='restrict', required=True)
cron_name = fields.Char('Name', related='ir_actions_server_id.name', store=True, readonly=False)
cron_name = fields.Char('Name', compute='_compute_cron_name', store=True)
user_id = fields.Many2one('res.users', string='Scheduler User', default=lambda self: self.env.user, required=True)
active = fields.Boolean(default=True)
interval_number = fields.Integer(default=1, group_operator=None, help="Repeat every x.")
@ -71,6 +70,11 @@ class ir_cron(models.Model):
lastcall = fields.Datetime(string='Last Execution Date', help="Previous time the cron ran successfully, provided to the job through the context on the `lastcall` key")
priority = fields.Integer(default=5, group_operator=None, help='The priority of the job, as an integer: 0 means higher priority, 10 means lower priority.')
@api.depends('ir_actions_server_id.name')
def _compute_cron_name(self):
for cron in self.with_context(lang='en_US'):
cron.cron_name = cron.ir_actions_server_id.name
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
@ -131,7 +135,7 @@ class ir_cron(models.Model):
continue
_logger.debug("job %s acquired", job_id)
# take into account overridings of _process_job() on that database
registry = odoo.registry(db_name)
registry = odoo.registry(db_name).check_signaling()
registry[cls._name]._process_job(db, cron_cr, job)
cron_cr.commit()
_logger.debug("job %s updated and released", job_id)
@ -199,7 +203,7 @@ class ir_cron(models.Model):
def _get_all_ready_jobs(cls, cr):
""" Return a list of all jobs that are ready to be executed """
cr.execute("""
SELECT *, cron_name->>'en_US' as cron_name
SELECT *
FROM ir_cron
WHERE active = true
AND numbercall != 0
@ -261,7 +265,7 @@ class ir_cron(models.Model):
# Learn more: https://www.postgresql.org/docs/current/explicit-locking.html#LOCKING-ROWS
query = """
SELECT *, cron_name->>'en_US' as cron_name
SELECT *
FROM ir_cron
WHERE active = true
AND numbercall != 0
@ -384,15 +388,13 @@ class ir_cron(models.Model):
log_depth = (None if _logger.isEnabledFor(logging.DEBUG) else 1)
odoo.netsvc.log(_logger, logging.DEBUG, 'cron.object.execute', (self._cr.dbname, self._uid, '*', cron_name, server_action_id), depth=log_depth)
start_time = False
_logger.info('Starting job `%s`.', cron_name)
if _logger.isEnabledFor(logging.DEBUG):
start_time = time.time()
start_time = time.time()
self.env['ir.actions.server'].browse(server_action_id).run()
self.env.flush_all()
_logger.info('Job `%s` done.', cron_name)
end_time = time.time()
_logger.info('Job done: `%s` (%.3fs).', cron_name, end_time - start_time)
if start_time and _logger.isEnabledFor(logging.DEBUG):
end_time = time.time()
_logger.debug('%.3fs (cron %s, server action %d with uid %d)', end_time - start_time, cron_name, server_action_id, self.env.uid)
self.pool.signal_changes()
except Exception as e:
@ -417,6 +419,8 @@ class ir_cron(models.Model):
the lock aquired by foreign keys when they
reference this row.
"""
if not self:
return
row_level_lock = "UPDATE" if lockfk else "NO KEY UPDATE"
try:
self._cr.execute(f"""
@ -482,6 +486,8 @@ class ir_cron(models.Model):
:param Optional[Union[datetime.datetime, list[datetime.datetime]]] at:
When to execute the cron, at one or several moments in time instead
of as soon as possible.
:return: the created triggers records
:rtype: recordset
"""
if at is None:
at_list = [fields.Datetime.now()]
@ -491,7 +497,7 @@ class ir_cron(models.Model):
at_list = list(at)
assert all(isinstance(at, datetime) for at in at_list)
self._trigger_list(at_list)
return self._trigger_list(at_list)
def _trigger_list(self, at_list):
"""
@ -499,6 +505,8 @@ class ir_cron(models.Model):
:param list[datetime.datetime] at_list:
Execute the cron later, at precise moments in time.
:return: the created triggers records
:rtype: recordset
"""
self.ensure_one()
now = fields.Datetime.now()
@ -508,9 +516,9 @@ class ir_cron(models.Model):
at_list = [at for at in at_list if at > now]
if not at_list:
return
return self.env['ir.cron.trigger']
self.env['ir.cron.trigger'].sudo().create([
triggers = self.env['ir.cron.trigger'].sudo().create([
{'cron_id': self.id, 'call_at': at}
for at in at_list
])
@ -520,6 +528,7 @@ class ir_cron(models.Model):
if min(at_list) <= now or os.getenv('ODOO_NOTIFY_CRON_CHANGES'):
self._cr.postcommit.add(self._notifydb)
return triggers
def _notifydb(self):
""" Wake up the cron workers
@ -527,10 +536,7 @@ class ir_cron(models.Model):
ir_cron modification and on trigger creation (regardless of call_at)
"""
with odoo.sql_db.db_connect('postgres').cursor() as cr:
if ODOO_NOTIFY_FUNCTION:
query = sql.SQL("SELECT {}('cron_trigger', %s)").format(sql.Identifier(ODOO_NOTIFY_FUNCTION))
else:
query = "NOTIFY cron_trigger, %s"
query = sql.SQL("SELECT {}('cron_trigger', %s)").format(sql.Identifier(ODOO_NOTIFY_FUNCTION))
cr.execute(query, [self.env.cr.dbname])
_logger.debug("cron workers notified")
@ -538,10 +544,11 @@ class ir_cron(models.Model):
class ir_cron_trigger(models.Model):
_name = 'ir.cron.trigger'
_description = 'Triggered actions'
_rec_name = 'cron_id'
_allow_sudo_commands = False
cron_id = fields.Many2one("ir.cron", index=True)
call_at = fields.Datetime()
call_at = fields.Datetime(index=True)
@api.autovacuum
def _gc_cron_triggers(self):

View file

@ -33,19 +33,19 @@ class IrDefault(models.Model):
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
self.env.registry.clear_cache()
return super(IrDefault, self).create(vals_list)
def write(self, vals):
if self:
self.clear_caches()
self.env.registry.clear_cache()
new_default = super().write(vals)
self.check_access_rule('write')
return new_default
def unlink(self):
if self:
self.clear_caches()
self.env.registry.clear_cache()
return super(IrDefault, self).unlink()
@api.model
@ -78,9 +78,9 @@ class IrDefault(models.Model):
parsed = field.convert_to_cache(value, model)
json_value = json.dumps(value, ensure_ascii=False)
except KeyError:
raise ValidationError(_("Invalid field %s.%s") % (model_name, field_name))
raise ValidationError(_("Invalid field %s.%s", model_name, field_name))
except Exception:
raise ValidationError(_("Invalid value for %s.%s: %s") % (model_name, field_name, value))
raise ValidationError(_("Invalid value for %s.%s: %s", model_name, field_name, value))
if field.type == 'integer' and not (-2**31 < parsed < 2**31-1):
raise ValidationError(_("Invalid value for %s.%s: %s is out of bounds (integers should be between -2,147,483,648 and 2,147,483,647)", model_name, field_name, value))
@ -107,7 +107,7 @@ class IrDefault(models.Model):
return True
@api.model
def get(self, model_name, field_name, user_id=False, company_id=False, condition=False):
def _get(self, model_name, field_name, user_id=False, company_id=False, condition=False):
""" Return the default value for the given field, user and company, or
``None`` if no default is available.
@ -141,7 +141,7 @@ class IrDefault(models.Model):
# Note about ormcache invalidation: it is not needed when deleting a field,
# a user, or a company, as the corresponding defaults will no longer be
# requested. It must only be done when a user's company is modified.
def get_model_defaults(self, model_name, condition=False):
def _get_model_defaults(self, model_name, condition=False):
""" Return the available default values for the given model (for the
current user), as a dict mapping field names to values.
"""

View file

@ -13,7 +13,7 @@ class IrDemo(models.TransientModel):
@assert_log_admin_access
def install_demo(self):
force_demo(self.env.cr)
force_demo(self.env)
return {
'type': 'ir.actions.act_url',
'target': 'self',

View file

@ -398,7 +398,7 @@ class IrFieldsConverter(models.AbstractModel):
:param model: model to which the field belongs
:param field: relational field for which references are provided
:param subfield: a relational subfield allowing building of refs to
existing records: ``None`` for a name_get/name_search,
existing records: ``None`` for a name_search,
``id`` for an external id and ``.id`` for a database
id
:param value: value of the reference to match to an actual record
@ -432,18 +432,16 @@ class IrFieldsConverter(models.AbstractModel):
field_type = _(u"database id")
if isinstance(value, str) and not self._str_to_boolean(model, field, value)[0]:
return False, field_type, warnings
try: tentative_id = int(value)
except ValueError: tentative_id = value
try:
if RelatedModel.search([('id', '=', tentative_id)]):
id = tentative_id
except psycopg2.DataError:
# type error
tentative_id = int(value)
except ValueError:
raise self._format_import_error(
ValueError,
_(u"Invalid database id '%s' for the field '%%(field)s'"),
value,
{'moreinfo': action})
if RelatedModel.browse(tentative_id).exists():
id = tentative_id
elif subfield == 'id':
field_type = _(u"external id")
if not self._str_to_boolean(model, field, value)[0]:
@ -462,9 +460,11 @@ class IrFieldsConverter(models.AbstractModel):
ids = RelatedModel.name_search(name=value, operator='=')
if ids:
if len(ids) > 1:
warnings.append(ImportWarning(
_(u"Found multiple matches for value '%s' in field '%%(field)s' (%d matches)")
%(str(value).replace('%', '%%'), len(ids))))
warnings.append(ImportWarning(_(
"Found multiple matches for value %r in field %%(field)r (%d matches)",
str(value).replace('%', '%%'),
len(ids),
)))
id, _name = ids[0]
else:
name_create_enabled_fields = self.env.context.get('name_create_enabled_fields') or {}
@ -473,12 +473,11 @@ class IrFieldsConverter(models.AbstractModel):
with self.env.cr.savepoint():
id, _name = RelatedModel.name_create(name=value)
except (Exception, psycopg2.IntegrityError):
error_msg = _(u"Cannot create new '%s' records from their name alone. Please create those records manually and try importing again.", RelatedModel._description)
error_msg = _("Cannot create new '%s' records from their name alone. Please create those records manually and try importing again.", RelatedModel._description)
else:
raise self._format_import_error(
Exception,
_(u"Unknown sub-field '%s'"),
subfield
_("Unknown sub-field %r", subfield)
)
set_empty = False
@ -543,7 +542,7 @@ class IrFieldsConverter(models.AbstractModel):
:return: the record subfield to use for referencing and a list of warnings
:rtype: str, list
"""
# Can import by name_get, external id or database id
# Can import by display_name, external id or database id
fieldset = set(record)
if fieldset - REFERENCING_FIELDS:
raise ValueError(
@ -653,37 +652,3 @@ class IrFieldsConverter(models.AbstractModel):
commands.append(Command.create(writable))
return commands, warnings
class O2MIdMapper(models.AbstractModel):
"""
Updates the base class to support setting xids directly in create by
providing an "id" key (otherwise stripped by create) during an import
(which should strip 'id' from the input data anyway)
"""
_inherit = 'base'
# sadly _load_records_create is only called for the toplevel record so we
# can't hook into that
@api.model_create_multi
@api.returns('self', lambda value: value.id)
def create(self, vals_list):
recs = super().create(vals_list)
import_module = self.env.context.get('_import_current_module')
if not import_module: # not an import -> bail
return recs
noupdate = self.env.context.get('noupdate', False)
xids = (v.get('id') for v in vals_list)
self.env['ir.model.data']._update_xmlids([
{
'xml_id': xid if '.' in xid else ('%s.%s' % (import_module, xid)),
'record': rec,
# note: this is not used when updating o2ms above...
'noupdate': noupdate,
}
for rec, xid in zip(recs, xids)
if xid and isinstance(xid, str)
])
return recs

View file

@ -77,10 +77,12 @@ class IrFilters(models.Model):
"""
# available filters: private filters (user_id=uid) and public filters (uid=NULL),
# and filters for the action (action_id=action_id) or global (action_id=NULL)
action_domain = self._get_action_domain(action_id)
filters = self.search(action_domain + [('model_id', '=', model), ('user_id', 'in', [self._uid, False])])
user_context = self.env['res.users'].context_get()
return filters.with_context(user_context).read(['name', 'is_default', 'domain', 'context', 'user_id', 'sort'])
action_domain = self._get_action_domain(action_id)
return self.with_context(user_context).search_read(
action_domain + [('model_id', '=', model), ('user_id', 'in', [self._uid, False])],
['name', 'is_default', 'domain', 'context', 'user_id', 'sort'],
)
@api.model
def _check_global_default(self, vals, matching_filters):
@ -111,7 +113,7 @@ class IrFilters(models.Model):
if matching_filters and (matching_filters[0]['id'] == defaults.id):
return
raise UserError(_("There is already a shared filter set as default for %(model)s, delete or change it before setting a new default") % {'model': vals.get('model_id')})
raise UserError(_("There is already a shared filter set as default for %(model)s, delete or change it before setting a new default", model=vals.get('model_id')))
@api.model
@api.returns('self', lambda value: value.id)

View file

@ -31,7 +31,6 @@ from odoo.modules.registry import Registry
from odoo.service import security
from odoo.tools import get_lang, submap
from odoo.tools.translate import code_translations
from odoo.modules.module import get_resource_path, get_module_path
_logger = logging.getLogger(__name__)
@ -42,11 +41,11 @@ class RequestUID(object):
class ModelConverter(werkzeug.routing.BaseConverter):
regex = r'[0-9]+'
def __init__(self, url_map, model=False):
super(ModelConverter, self).__init__(url_map)
super().__init__(url_map)
self.model = model
self.regex = r'([0-9]+)'
def to_python(self, value):
_uid = RequestUID(value=value, converter=self)
@ -58,12 +57,11 @@ class ModelConverter(werkzeug.routing.BaseConverter):
class ModelsConverter(werkzeug.routing.BaseConverter):
regex = r'[0-9,]+'
def __init__(self, url_map, model=False):
super(ModelsConverter, self).__init__(url_map)
super().__init__(url_map)
self.model = model
# TODO add support for slug in the form [A-Za-z0-9-] bla-bla-89 -> id 89
self.regex = r'([0-9,]+)'
def to_python(self, value):
_uid = RequestUID(value=value, converter=self)
@ -79,6 +77,42 @@ class SignedIntConverter(NumberConverter):
num_convert = int
class LazyCompiledBuilder:
def __init__(self, rule, _compile_builder, append_unknown):
self.rule = rule
self._callable = None
self._compile_builder = _compile_builder
self._append_unknown = append_unknown
def __get__(self, *args):
# Rule.compile will actually call
#
# self._build = self._compile_builder(False).__get__(self, None)
# self._build_unknown = self._compile_builder(True).__get__(self, None)
#
# meaning the _build and _build unkown will contain _compile_builder().__get__(self, None).
# This is why this override of __get__ is needed.
return self
def __call__(self, *args, **kwargs):
if self._callable is None:
self._callable = self._compile_builder(self._append_unknown).__get__(self.rule, None)
del self.rule
del self._compile_builder
del self._append_unknown
return self._callable(*args, **kwargs)
class FasterRule(werkzeug.routing.Rule):
"""
_compile_builder is a major part of the routing map generation and rules
are actually not build so often.
This classe makes calls to _compile_builder lazy
"""
def _compile_builder(self, append_unknown=True):
return LazyCompiledBuilder(self, super()._compile_builder, append_unknown)
class IrHttp(models.AbstractModel):
_name = 'ir.http'
_description = "HTTP Routing"
@ -92,8 +126,8 @@ class IrHttp(models.AbstractModel):
return {'model': ModelConverter, 'models': ModelsConverter, 'int': SignedIntConverter}
@classmethod
def _match(cls, path_info, key=None):
rule, args = cls.routing_map().bind_to_environ(request.httprequest.environ).match(path_info=path_info, return_rule=True)
def _match(cls, path_info):
rule, args = request.env['ir.http'].routing_map().bind_to_environ(request.httprequest.environ).match(path_info=path_info, return_rule=True)
return rule, args
@classmethod
@ -137,6 +171,21 @@ class IrHttp(models.AbstractModel):
@classmethod
def _pre_dispatch(cls, rule, args):
ICP = request.env['ir.config_parameter'].with_user(SUPERUSER_ID)
# Change the default database-wide 128MiB upload limit on the
# ICP value. Do it before calling http's generic pre_dispatch
# so that the per-route limit @route(..., max_content_length=x)
# takes over.
try:
key = 'web.max_file_upload_size'
if (value := ICP.get_param(key, None)) is not None:
request.httprequest.max_content_length = int(value)
except ValueError: # better not crash on ALL requests
_logger.error("invalid %s: %r, using %s instead",
key, value, request.httprequest.max_content_length,
)
request.dispatcher.pre_dispatch(rule, args)
# Replace uid placeholder by the current request.env.uid
@ -147,7 +196,25 @@ class IrHttp(models.AbstractModel):
# verify the default language set in the context is valid,
# otherwise fallback on the company lang, english or the first
# lang installed
request.update_context(lang=get_lang(request.env)._get_cached('code'))
env = request.env if request.env.uid else request.env['base'].with_user(SUPERUSER_ID).env
request.update_context(lang=get_lang(env)._get_cached('code'))
for key, val in list(args.items()):
if not isinstance(val, models.BaseModel):
continue
try:
# explicitly crash now, instead of crashing later
args[key].check_access_rights('read')
args[key].check_access_rule('read')
except (odoo.exceptions.AccessError, odoo.exceptions.MissingError) as e:
# custom behavior in case a record is not accessible / has been removed
if handle_error := rule.endpoint.routing.get('handle_params_access_error'):
if response := handle_error(e):
werkzeug.exceptions.abort(response)
if request.env.user.is_public or isinstance(e, odoo.exceptions.MissingError):
raise werkzeug.exceptions.NotFound() from e
raise
@classmethod
def _dispatch(cls, endpoint):
@ -160,6 +227,10 @@ class IrHttp(models.AbstractModel):
def _post_dispatch(cls, response):
request.dispatcher.post_dispatch(response)
@classmethod
def _post_logout(cls):
pass
@classmethod
def _handle_error(cls, exception):
return request.dispatcher.handle_error(exception)
@ -175,42 +246,28 @@ class IrHttp(models.AbstractModel):
def _redirect(cls, location, code=303):
return werkzeug.utils.redirect(location, code=code, Response=Response)
@classmethod
def _generate_routing_rules(cls, modules, converters):
def _generate_routing_rules(self, modules, converters):
return http._generate_routing_rules(modules, False, converters)
@classmethod
def routing_map(cls, key=None):
if not hasattr(cls, '_routing_map'):
cls._routing_map = {}
cls._rewrite_len = {}
if key not in cls._routing_map:
_logger.info("Generating routing map for key %s" % str(key))
registry = Registry(threading.current_thread().dbname)
installed = registry._init_modules.union(odoo.conf.server_wide_modules)
mods = sorted(installed)
# Note : when routing map is generated, we put it on the class `cls`
# to make it available for all instance. Since `env` create an new instance
# of the model, each instance will regenared its own routing map and thus
# regenerate its EndPoint. The routing map should be static.
routing_map = werkzeug.routing.Map(strict_slashes=False, converters=cls._get_converters())
for url, endpoint in cls._generate_routing_rules(mods, converters=cls._get_converters()):
routing = submap(endpoint.routing, ROUTING_KEYS)
if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
routing['methods'] = [*routing['methods'], 'OPTIONS']
rule = werkzeug.routing.Rule(url, endpoint=endpoint, **routing)
rule.merge_slashes = False
routing_map.add(rule)
cls._routing_map[key] = routing_map
return cls._routing_map[key]
@classmethod
def _clear_routing_map(cls):
if hasattr(cls, '_routing_map'):
cls._routing_map = {}
_logger.debug("Clear routing map")
@tools.ormcache('key', cache='routing')
def routing_map(self, key=None):
_logger.info("Generating routing map for key %s", str(key))
registry = Registry(threading.current_thread().dbname)
installed = registry._init_modules.union(odoo.conf.server_wide_modules)
mods = sorted(installed)
# Note : when routing map is generated, we put it on the class `cls`
# to make it available for all instance. Since `env` create an new instance
# of the model, each instance will regenared its own routing map and thus
# regenerate its EndPoint. The routing map should be static.
routing_map = werkzeug.routing.Map(strict_slashes=False, converters=self._get_converters())
for url, endpoint in self._generate_routing_rules(mods, converters=self._get_converters()):
routing = submap(endpoint.routing, ROUTING_KEYS)
if routing['methods'] is not None and 'OPTIONS' not in routing['methods']:
routing['methods'] = [*routing['methods'], 'OPTIONS']
rule = FasterRule(url, endpoint=endpoint, **routing)
rule.merge_slashes = False
routing_map.add(rule)
return routing_map
@api.autovacuum
def _gc_sessions(self):
@ -241,8 +298,6 @@ class IrHttp(models.AbstractModel):
}
lang_params['week_start'] = int(lang_params['week_start'])
lang_params['code'] = lang
if lang_params["thousands_sep"]:
lang_params["thousands_sep"] = lang_params["thousands_sep"].replace(' ', '\N{NO-BREAK SPACE}')
# Regional languages (ll_CC) must inherit/override their parent lang (ll), but this is
# done server-side when the language is loaded, so we only need to load the user's lang.

View file

@ -113,20 +113,30 @@ class IrMailServer(models.Model):
"""Represents an SMTP server, able to send outgoing emails, with SSL and TLS capabilities."""
_name = "ir.mail_server"
_description = 'Mail Server'
_order = 'sequence'
_order = 'sequence, id'
_allow_sudo_commands = False
NO_VALID_RECIPIENT = ("At least one valid recipient address should be "
"specified for outgoing emails (To/Cc/Bcc)")
NO_FOUND_FROM = ("You must either provide a sender address explicitly or configure "
"using the combination of `mail.catchall.domain` and `mail.default.from` "
"ICPs, in the server configuration file or with the --email-from startup "
"parameter.")
NO_FOUND_SMTP_FROM = "The Return-Path or From header is required for any outbound email"
NO_VALID_FROM = "Malformed 'Return-Path' or 'From' address. It should contain one valid plain ASCII email"
name = fields.Char(string='Name', required=True, index=True)
from_filter = fields.Char(
"FROM Filtering",
help='Define for which email address or domain this server can be used.\n'
help='Comma-separated list of addresses or domains for which this server can be used.\n'
'e.g.: "notification@odoo.com" or "odoo.com"')
smtp_host = fields.Char(string='SMTP Server', required=True, help="Hostname or IP of SMTP server")
smtp_port = fields.Integer(string='SMTP Port', required=True, default=25, help="SMTP Port. Usually 465 for SSL, and 25 or 587 for other cases.")
smtp_authentication = fields.Selection([('login', 'Username'), ('certificate', 'SSL Certificate')], string='Authenticate with', required=True, default='login')
smtp_host = fields.Char(string='SMTP Server', help="Hostname or IP of SMTP server")
smtp_port = fields.Integer(string='SMTP Port', default=25, help="SMTP Port. Usually 465 for SSL, and 25 or 587 for other cases.")
smtp_authentication = fields.Selection([
('login', 'Username'),
('certificate', 'SSL Certificate'),
('cli', 'Command Line Interface')
], string='Authenticate with', required=True, default='login')
smtp_authentication_info = fields.Text('Authentication Info', compute='_compute_smtp_authentication_info')
smtp_user = fields.Char(string='Username', help="Optional username for SMTP authentication", groups='base.group_system')
smtp_pass = fields.Char(string='Password', help="Optional password for SMTP authentication", groups='base.group_system')
@ -163,6 +173,9 @@ class IrMailServer(models.Model):
server.smtp_authentication_info = _(
'Authenticate by using SSL certificates, belonging to your domain name. \n'
'SSL certificates allow you to authenticate your mail server for the entire domain name.')
elif server.smtp_authentication == 'cli':
server.smtp_authentication_info = _(
'Use the SMTP configuration set in the "Command Line Interface" arguments.')
else:
server.smtp_authentication = False
@ -219,30 +232,25 @@ class IrMailServer(models.Model):
"""
return dict()
def _get_test_email_addresses(self):
def _get_test_email_from(self):
self.ensure_one()
email_to = "noreply@odoo.com"
if self.from_filter:
if "@" in self.from_filter:
# All emails will be sent from the same address
return self.from_filter, email_to
# All emails will be sent from any address in the same domain
default_from = self.env["ir.config_parameter"].sudo().get_param("mail.default.from", "odoo")
if "@" not in default_from:
return f"{default_from}@{self.from_filter}", email_to
elif self._match_from_filter(default_from, self.from_filter):
# the mail server is configured for a domain
# that match the default email address
return default_from, email_to
# the from_filter is configured for a domain different that the one
# of the full email configured in mail.default.from
return f"noreply@{self.from_filter}", email_to
# Fallback to current user email if there's no from filter
email_from = self.env.user.email
email_from = False
if from_filter_parts := [part.strip() for part in (self.from_filter or '').split(",") if part.strip()]:
# find first found complete email in filter parts
email_from = next((email for email in from_filter_parts if "@" in email), False)
# no complete email -> consider noreply
if not email_from:
email_from = f"noreply@{from_filter_parts[0]}"
if not email_from:
# Fallback to current user email if there's no from filter
email_from = self.env.user.email
if not email_from or "@" not in email_from:
raise UserError(_('Please configure an email on the current user to simulate '
'sending an email message via this outgoing server'))
return email_from, email_to
return email_from
def _get_test_email_to(self):
return "noreply@odoo.com"
def test_smtp_connection(self):
for server in self:
@ -250,43 +258,41 @@ class IrMailServer(models.Model):
try:
smtp = self.connect(mail_server_id=server.id, allow_archived=True)
# simulate sending an email from current user's address - without sending it!
email_from, email_to = server._get_test_email_addresses()
email_from = server._get_test_email_from()
email_to = server._get_test_email_to()
# Testing the MAIL FROM step should detect sender filter problems
(code, repl) = smtp.mail(email_from)
if code != 250:
raise UserError(_('The server refused the sender address (%(email_from)s) '
'with error %(repl)s') % locals())
raise UserError(_('The server refused the sender address (%(email_from)s) with error %(repl)s', email_from=email_from, repl=repl)) # noqa: TRY301
# Testing the RCPT TO step should detect most relaying problems
(code, repl) = smtp.rcpt(email_to)
if code not in (250, 251):
raise UserError(_('The server refused the test recipient (%(email_to)s) '
'with error %(repl)s') % locals())
raise UserError(_('The server refused the test recipient (%(email_to)s) with error %(repl)s', email_to=email_to, repl=repl)) # noqa: TRY301
# Beginning the DATA step should detect some deferred rejections
# Can't use self.data() as it would actually send the mail!
smtp.putcmd("data")
(code, repl) = smtp.getreply()
if code != 354:
raise UserError(_('The server refused the test connection '
'with error %(repl)s') % locals())
except UserError as e:
# let UserErrors (messages) bubble up
raise e
raise UserError(_('The server refused the test connection with error %(repl)s', repl=repl)) # noqa: TRY301
except (UnicodeError, idna.core.InvalidCodepoint) as e:
raise UserError(_("Invalid server name !\n %s", ustr(e)))
raise UserError(_("Invalid server name!\n %s", e)) from e
except (gaierror, timeout) as e:
raise UserError(_("No response received. Check server address and port number.\n %s", ustr(e)))
raise UserError(_("No response received. Check server address and port number.\n %s", e)) from e
except smtplib.SMTPServerDisconnected as e:
raise UserError(_("The server has closed the connection unexpectedly. Check configuration served on this port number.\n %s", ustr(e.strerror)))
raise UserError(_("The server has closed the connection unexpectedly. Check configuration served on this port number.\n %s", e)) from e
except smtplib.SMTPResponseException as e:
raise UserError(_("Server replied with following exception:\n %s", ustr(e.smtp_error)))
raise UserError(_("Server replied with following exception:\n %s", e)) from e
except smtplib.SMTPNotSupportedError as e:
raise UserError(_("An option is not supported by the server:\n %s", e.strerror))
raise UserError(_("An option is not supported by the server:\n %s", e)) from e
except smtplib.SMTPException as e:
raise UserError(_("An SMTP exception occurred. Check port number and connection security type.\n %s", ustr(e)))
except SSLError as e:
raise UserError(_("An SSL exception occurred. Check connection security type.\n %s", ustr(e)))
raise UserError(_("An SMTP exception occurred. Check port number and connection security type.\n %s", e)) from e
except (ssl.SSLError, SSLError) as e:
raise UserError(_("An SSL exception occurred. Check connection security type.\n %s", e)) from e
except UserError:
raise
except Exception as e:
raise UserError(_("Connection Test Failed! Here is what we got instead:\n %s", ustr(e)))
_logger.warning("Connection test on %s failed with a generic error.", server, exc_info=True)
raise UserError(_("Connection Test Failed! Here is what we got instead:\n %s", e)) from e
finally:
try:
if smtp:
@ -345,7 +351,7 @@ class IrMailServer(models.Model):
mail_server = self.env['ir.mail_server']
ssl_context = None
if mail_server:
if mail_server and mail_server.smtp_authentication != "cli":
smtp_server = mail_server.smtp_host
smtp_port = mail_server.smtp_port
if mail_server.smtp_authentication == "certificate":
@ -379,8 +385,11 @@ class IrMailServer(models.Model):
smtp_port = tools.config.get('smtp_port', 25) if port is None else port
smtp_user = user or tools.config.get('smtp_user')
smtp_password = password or tools.config.get('smtp_password')
from_filter = self.env['ir.config_parameter'].sudo().get_param(
'mail.default.from_filter', tools.config.get('from_filter'))
if mail_server:
from_filter = mail_server.from_filter
else:
from_filter = self.env['ir.mail_server']._get_default_from_filter()
smtp_encryption = encryption
if smtp_encryption is None and tools.config.get('smtp_ssl'):
smtp_encryption = 'starttls' # smtp_ssl => STARTTLS as of v7
@ -485,11 +494,8 @@ class IrMailServer(models.Model):
:rtype: email.message.EmailMessage
:return: the new RFC2822 email message
"""
email_from = email_from or self._get_default_from_address()
assert email_from, "You must either provide a sender address explicitly or configure "\
"using the combination of `mail.catchall.domain` and `mail.default.from` "\
"ICPs, in the server configuration file or with the "\
"--email-from startup parameter."
email_from = email_from or self.env.context.get('domain_notifications_email') or self._get_default_from_address()
assert email_from, self.NO_FOUND_FROM
headers = headers or {} # need valid dict later
email_cc = email_cc or []
@ -542,49 +548,35 @@ class IrMailServer(models.Model):
@api.model
def _get_default_bounce_address(self):
'''Compute the default bounce address.
""" Computes the default bounce address. It is used to set the envelop
address if no envelop address is provided in the message.
The default bounce address is used to set the envelop address if no
envelop address is provided in the message. It is formed by properly
joining the parameters "mail.bounce.alias" and
"mail.catchall.domain".
If "mail.bounce.alias" is not set it defaults to "postmaster-odoo".
If "mail.catchall.domain" is not set, return None.
'''
ICP = self.env['ir.config_parameter'].sudo()
bounce_alias = ICP.get_param('mail.bounce.alias')
domain = ICP.get_param('mail.catchall.domain')
if bounce_alias and domain:
return '%s@%s' % (bounce_alias, domain)
return
:return str/None: defaults to the ``--email-from`` CLI/config parameter.
"""
return tools.config.get("email_from")
@api.model
def _get_default_from_address(self):
"""Compute the default from address.
""" Computes the default from address. It is used for the "header from"
address when no other has been received.
Used for the "header from" address when no other has been received.
:return str/None:
If the config parameter ``mail.default.from`` contains
a full email address, return it.
Otherwise, combines config parameters ``mail.default.from`` and
``mail.catchall.domain`` to generate a default sender address.
If some of those parameters is not defined, it will default to the
``--email-from`` CLI/config parameter.
:return str/None: defaults to the ``--email-from`` CLI/config parameter.
"""
get_param = self.env['ir.config_parameter'].sudo().get_param
email_from = get_param("mail.default.from")
if email_from and "@" in email_from:
return email_from
domain = get_param("mail.catchall.domain")
if email_from and domain:
return "%s@%s" % (email_from, domain)
return tools.config.get("email_from")
@api.model
def _get_default_from_filter(self):
""" Computes the default from_filter. It is used when no specific
ir.mail_server is used when sending emails, hence having no value for
from_filter.
:return str/None: defaults to 'mail.default.from_filter', then
``--from-filter`` CLI/config parameter.
"""
return self.env['ir.config_parameter'].sudo().get_param(
'mail.default.from_filter', tools.config.get('from_filter')
)
def _prepare_email_message(self, message, smtp_session):
"""Prepare the SMTP information (from, to, message) before sending.
@ -599,9 +591,11 @@ class IrMailServer(models.Model):
# Use the default bounce address **only if** no Return-Path was
# provided by caller. Caller may be using Variable Envelope Return
# Path (VERP) to detect no-longer valid email addresses.
bounce_address = message['Return-Path'] or self._get_default_bounce_address() or message['From']
# context may force a value, e.g. mail.alias.domain usage
bounce_address = self.env.context.get('domain_bounce_address') or message['Return-Path'] or self._get_default_bounce_address() or message['From']
smtp_from = message['From'] or bounce_address
assert smtp_from, "The Return-Path or From header is required for any outbound email"
assert smtp_from, self.NO_FOUND_SMTP_FROM
email_to = message['To']
email_cc = message['Cc']
@ -626,16 +620,18 @@ class IrMailServer(models.Model):
x_forge_to = message['X-Forge-To']
if x_forge_to:
# `To:` header forged, e.g. for posting on mail.channels, to avoid confusion
# `To:` header forged, e.g. for posting on discuss.channels, to avoid confusion
del message['X-Forge-To']
del message['To'] # avoid multiple To: headers!
message['To'] = x_forge_to
# Try to not spoof the mail from headers
# Try to not spoof the mail from headers; fetch session-based or contextualized
# values for encapsulation computation
from_filter = getattr(smtp_session, 'from_filter', False)
smtp_from = getattr(smtp_session, 'smtp_from', False) or smtp_from
notifications_email = email_normalize(self._get_default_from_address())
notifications_email = email_normalize(
self.env.context.get('domain_notifications_email') or self._get_default_from_address()
)
if notifications_email and email_normalize(smtp_from) == notifications_email and email_normalize(message['From']) != notifications_email:
smtp_from = encapsulate_email(message['From'], notifications_email)
@ -651,9 +647,12 @@ class IrMailServer(models.Model):
# The email's "Envelope From" (Return-Path) must only contain ASCII characters.
smtp_from_rfc2822 = extract_rfc2822_addresses(smtp_from)
assert smtp_from_rfc2822, (
f"Malformed 'Return-Path' or 'From' address: {smtp_from} - "
"It should contain one valid plain ASCII email")
if not smtp_from_rfc2822:
raise AssertionError(
self.NO_VALID_FROM,
f"Malformed 'Return-Path' or 'From' address: {smtp_from} - "
"It should contain one valid plain ASCII email"
)
smtp_from = smtp_from_rfc2822[-1]
return smtp_from, smtp_to_list, message
@ -748,7 +747,7 @@ class IrMailServer(models.Model):
"""
email_from_normalized = email_normalize(email_from)
email_from_domain = email_domain_extract(email_from_normalized)
notifications_email = email_normalize(self._get_default_from_address())
notifications_email = self.env.context.get('domain_notifications_email') or email_normalize(self._get_default_from_address())
notifications_domain = email_domain_extract(notifications_email)
if mail_servers is None:
@ -756,33 +755,36 @@ class IrMailServer(models.Model):
# 0. Archived mail server should never be used
mail_servers = mail_servers.filtered('active')
def first_match(target, normalize_method):
for mail_server in mail_servers:
if mail_server.from_filter and any(
normalize_method(email.strip()) == target
for email in mail_server.from_filter.split(',')
):
return mail_server
# 1. Try to find a mail server for the right mail from
# Skip if passed email_from is False (example Odoobot has no email address)
if email_from_normalized:
mail_server = mail_servers.filtered(lambda m: email_normalize(m.from_filter) == email_from_normalized)
if mail_server:
return mail_server[0], email_from
if mail_server := first_match(email_from_normalized, email_normalize):
return mail_server, email_from
mail_server = mail_servers.filtered(lambda m: email_domain_normalize(m.from_filter) == email_from_domain)
if mail_server:
return mail_server[0], email_from
if mail_server := first_match(email_from_domain, email_domain_normalize):
return mail_server, email_from
# 2. Try to find a mail server for <notifications@domain.com>
if notifications_email:
mail_server = mail_servers.filtered(lambda m: email_normalize(m.from_filter) == notifications_email)
if mail_server:
return mail_server[0], notifications_email
if mail_server := first_match(notifications_email, email_normalize):
return mail_server, notifications_email
mail_server = mail_servers.filtered(lambda m: email_domain_normalize(m.from_filter) == notifications_domain)
if mail_server:
return mail_server[0], notifications_email
if mail_server := first_match(notifications_domain, email_domain_normalize):
return mail_server, notifications_email
# 3. Take the first mail server without "from_filter" because
# nothing else has been found... Will spoof the FROM because
# we have no other choices (will use the notification email if available
# otherwise we will use the user email)
mail_server = mail_servers.filtered(lambda m: not m.from_filter)
if mail_server:
if mail_server := mail_servers.filtered(lambda m: not m.from_filter):
return mail_server[0], notifications_email or email_from
# 4. Return the first mail server even if it was configured for another domain
@ -793,8 +795,7 @@ class IrMailServer(models.Model):
return mail_servers[0], notifications_email or email_from
# 5: SMTP config in odoo-bin arguments
from_filter = self.env['ir.config_parameter'].sudo().get_param(
'mail.default.from_filter', tools.config.get('from_filter'))
from_filter = self.env['ir.mail_server']._get_default_from_filter()
if self._match_from_filter(email_from, from_filter):
return None, email_from
@ -819,10 +820,14 @@ class IrMailServer(models.Model):
return True
normalized_mail_from = email_normalize(email_from)
if '@' in from_filter:
return email_normalize(from_filter) == normalized_mail_from
normalized_domain = email_domain_extract(normalized_mail_from)
return email_domain_extract(normalized_mail_from) == email_domain_normalize(from_filter)
for email_filter in [part.strip() for part in (from_filter or '').split(',') if part.strip()]:
if '@' in email_filter and email_normalize(email_filter) == normalized_mail_from:
return True
if '@' not in email_filter and email_domain_normalize(email_filter) == normalized_domain:
return True
return False
@api.onchange('smtp_encryption')
def _onchange_encryption(self):

View file

@ -12,6 +12,7 @@ from operator import itemgetter
from psycopg2 import sql
from psycopg2.extras import Json
from psycopg2.sql import Identifier, SQL, Placeholder
from odoo import api, fields, models, tools, _, _lt, Command
from odoo.exceptions import AccessError, UserError, ValidationError
@ -65,8 +66,8 @@ def selection_xmlid(module, model_name, field_name, value):
# generic INSERT and UPDATE queries
INSERT_QUERY = "INSERT INTO {table} ({cols}) VALUES {rows} RETURNING id"
UPDATE_QUERY = "UPDATE {table} SET {assignment} WHERE {condition} RETURNING id"
INSERT_QUERY = SQL("INSERT INTO {table} ({cols}) VALUES %s RETURNING id")
UPDATE_QUERY = SQL("UPDATE {table} SET {assignment} WHERE {condition} RETURNING id")
quote = '"{}"'.format
@ -79,12 +80,11 @@ def query_insert(cr, table, rows):
rows = [rows]
cols = list(rows[0])
query = INSERT_QUERY.format(
table='"{}"'.format(table),
cols=",".join(['"{}"'.format(col) for col in cols]),
rows=",".join("%s" for row in rows),
table=Identifier(table),
cols=SQL(",").join(map(Identifier, cols)),
)
params = [tuple(row[col] for col in cols) for row in rows]
cr.execute(query, params)
cr.execute_values(query, params)
return [row[0] for row in cr.fetchall()]
@ -94,9 +94,15 @@ def query_update(cr, table, values, selectors):
"""
setters = set(values) - set(selectors)
query = UPDATE_QUERY.format(
table='"{}"'.format(table),
assignment=",".join('"{0}"=%({0})s'.format(s) for s in setters),
condition=" AND ".join('"{0}"=%({0})s'.format(s) for s in selectors),
table=Identifier(table),
assignment=SQL(",").join(
SQL("{} = {}").format(Identifier(s), Placeholder(s))
for s in setters
),
condition=SQL(" AND ").join(
SQL("{} = {}").format(Identifier(s), Placeholder(s))
for s in selectors
),
)
cr.execute(query, values)
return [row[0] for row in cr.fetchall()]
@ -246,8 +252,7 @@ class IrModel(models.Model):
def _check_model_name(self):
for model in self:
if model.state == 'manual':
if not model.model.startswith('x_'):
raise ValidationError(_("The model name must start with 'x_'."))
self._check_manual_name(model.model)
if not models.check_object_name(model.model):
raise ValidationError(_("The model name can only contain lowercase characters, digits, underscores and dots."))
@ -297,12 +302,20 @@ class IrModel(models.Model):
for model in self:
current_model = self.env.get(model.model)
if current_model is not None:
if current_model._abstract:
continue
table = current_model._table
kind = tools.table_kind(self._cr, table)
if kind == 'v':
if kind == tools.TableKind.View:
self._cr.execute(sql.SQL('DROP VIEW {}').format(sql.Identifier(table)))
elif kind == 'r':
elif kind == tools.TableKind.Regular:
self._cr.execute(sql.SQL('DROP TABLE {} CASCADE').format(sql.Identifier(table)))
elif kind is not None:
_logger.warning(
"Unable to drop table %r of model %r: unmanaged or unknown tabe type %r",
table, model.model, kind
)
else:
_logger.runbot('The model %s could not be dropped because it did not exist in the registry.', model.model)
return True
@ -312,7 +325,7 @@ class IrModel(models.Model):
# Prevent manual deletion of module tables
for model in self:
if model.state != 'manual':
raise UserError(_("Model '%s' contains module data and cannot be removed.", model.name))
raise UserError(_("Model %r contains module data and cannot be removed.", model.name))
def unlink(self):
# prevent screwing up fields that depend on these models' fields
@ -375,11 +388,11 @@ class IrModel(models.Model):
@api.model
def name_create(self, name):
""" Infer the model from the name. E.g.: 'My New Model' should become 'x_my_new_model'. """
vals = {
ir_model = self.create({
'name': name,
'model': 'x_' + '_'.join(name.lower().split(' ')),
}
return self.create(vals).name_get()[0]
})
return ir_model.id, ir_model.display_name
def _reflect_model_params(self, model):
""" Return the values to write to the database for the given model. """
@ -447,6 +460,15 @@ class IrModel(models.Model):
return CustomModel
@api.model
def _is_manual_name(self, name):
return name.startswith('x_')
@api.model
def _check_manual_name(self, name):
if not self._is_manual_name(name):
raise ValidationError(_("The model name must start with 'x_'."))
def _add_manual_models(self):
""" Add extra models to the registry. """
# clean up registry first
@ -464,8 +486,12 @@ class IrModel(models.Model):
for model_data in cr.dictfetchall():
model_class = self._instanciate(model_data)
Model = model_class._build_model(self.pool, cr)
if tools.table_kind(cr, Model._table) not in ('r', None):
# not a regular table, so disable schema upgrades
kind = tools.table_kind(cr, Model._table)
if kind not in (tools.TableKind.Regular, None):
_logger.info(
"Model %r is backed by table %r which is not a regular table (%r), disabling automatic schema management",
Model._name, Model._table, kind,
)
Model._auto = False
cr.execute(
'''
@ -549,6 +575,17 @@ class IrModelFields(models.Model):
"a list of comma-separated field names, like\n\n"
" name, partner_id.name")
store = fields.Boolean(string='Stored', default=True, help="Whether the value is stored in the database.")
currency_field = fields.Char(string="Currency field", help="Name of the Many2one field holding the res.currency")
# HTML sanitization reflection, useless for other kinds of fields
sanitize = fields.Boolean(string='Sanitize HTML', default=True)
sanitize_overridable = fields.Boolean(string='Sanitize HTML overridable', default=False)
sanitize_tags = fields.Boolean(string='Sanitize HTML Tags', default=True)
sanitize_attributes = fields.Boolean(string='Sanitize HTML Attributes', default=True)
sanitize_style = fields.Boolean(string='Sanitize HTML Style', default=False)
sanitize_form = fields.Boolean(string='Sanitize HTML Form', default=True)
strip_style = fields.Boolean(string='Strip Style Attribute', default=False)
strip_classes = fields.Boolean(string='Strip Class Attribute', default=False)
@api.depends('relation', 'relation_field')
def _compute_relation_field_id(self):
@ -598,11 +635,9 @@ class IrModelFields(models.Model):
for field in self:
safe_eval(field.domain or '[]')
@api.constrains('name', 'state')
@api.constrains('name')
def _check_name(self):
for field in self:
if field.state == 'manual' and not field.name.startswith('x_'):
raise ValidationError(_("Custom fields must have a name that starts with 'x_' !"))
try:
models.check_pg_name(field.name)
except ValidationError:
@ -612,6 +647,11 @@ class IrModelFields(models.Model):
_sql_constraints = [
('name_unique', 'UNIQUE(model, name)', "Field names must be unique per model."),
('size_gt_zero', 'CHECK (size>=0)', 'Size of the field cannot be negative.'),
(
"name_manual_field",
"CHECK (state != 'manual' OR name LIKE 'x\\_%')",
"Custom fields must have a name that starts with 'x_'!"
),
]
def _related_field(self):
@ -622,10 +662,10 @@ class IrModelFields(models.Model):
for index, name in enumerate(names):
field = self._get(model_name, name)
if not field:
raise UserError(_("Unknown field name '%s' in related field '%s'") % (name, self.related))
raise UserError(_("Unknown field name %r in related field %r", name, self.related))
model_name = field.relation
if index < last and not field.relation:
raise UserError(_("Non-relational field name '%s' in related field '%s'") % (name, self.related))
raise UserError(_("Non-relational field name %r in related field %r", name, self.related))
return field
@api.constrains('related')
@ -634,9 +674,9 @@ class IrModelFields(models.Model):
if rec.state == 'manual' and rec.related:
field = rec._related_field()
if field.ttype != rec.ttype:
raise ValidationError(_("Related field '%s' does not have type '%s'") % (rec.related, rec.ttype))
raise ValidationError(_("Related field %r does not have type %r", rec.related, rec.ttype))
if field.relation != rec.relation:
raise ValidationError(_("Related field '%s' does not have comodel '%s'") % (rec.related, rec.relation))
raise ValidationError(_("Related field %r does not have comodel %r", rec.related, rec.relation))
@api.onchange('related')
def _onchange_related(self):
@ -670,7 +710,7 @@ class IrModelFields(models.Model):
continue
for seq in record.depends.split(","):
if not seq.strip():
raise UserError(_("Empty dependency in %r") % (record.depends))
raise UserError(_("Empty dependency in %r", record.depends))
model = self.env[record.model]
names = seq.strip().split(".")
last = len(names) - 1
@ -679,9 +719,9 @@ class IrModelFields(models.Model):
raise UserError(_("Compute method cannot depend on field 'id'"))
field = model._fields.get(name)
if field is None:
raise UserError(_("Unknown field %r in dependency %r") % (name, seq.strip()))
raise UserError(_("Unknown field %r in dependency %r", name, seq.strip()))
if index < last and not field.relational:
raise UserError(_("Non-relational field %r in dependency %r") % (name, seq.strip()))
raise UserError(_("Non-relational field %r in dependency %r", name, seq.strip()))
model = model[name]
@api.onchange('compute')
@ -695,6 +735,24 @@ class IrModelFields(models.Model):
if rec.relation_table:
models.check_pg_name(rec.relation_table)
@api.constrains('currency_field')
def _check_currency_field(self):
for rec in self:
if rec.state == 'manual' and rec.ttype == 'monetary':
if not rec.currency_field:
currency_field = self._get(rec.model, 'currency_id') or self._get(rec.model, 'x_currency_id')
if not currency_field:
raise ValidationError(_("Currency field is empty and there is no fallback field in the model"))
else:
currency_field = self._get(rec.model, rec.currency_field)
if not currency_field:
raise ValidationError(_("Unknown field name %r in currency_field", rec.currency_field))
if currency_field.ttype != 'many2one':
raise ValidationError(_("Currency field does not have type many2one"))
if currency_field.relation != 'res.currency':
raise ValidationError(_("Currency field should have a res.currency relation"))
@api.model
def _custom_many2many_names(self, model_name, comodel_name):
""" Return default names for the table and columns of a custom many2many field. """
@ -737,14 +795,14 @@ class IrModelFields(models.Model):
'message': _("The table %r if used for other, possibly incompatible fields.", self.relation_table),
}}
@api.onchange('required', 'ttype', 'on_delete')
def _onchange_required(self):
@api.constrains('required', 'ttype', 'on_delete')
def _check_on_delete_required_m2o(self):
for rec in self:
if rec.ttype == 'many2one' and rec.required and rec.on_delete == 'set null':
return {'warning': {'title': _("Warning"), 'message': _(
raise ValidationError(_(
"The m2o field %s is required but declares its ondelete policy "
"as being 'set null'. Only 'restrict' and 'cascade' make sense.", rec.name,
)}}
))
def _get(self, model_name, name):
""" Return the (sudoed) `ir.model.fields` record with the given model and name.
@ -770,7 +828,7 @@ class IrModelFields(models.Model):
if field.store:
# TODO: Refactor this brol in master
if is_model and tools.column_exists(self._cr, model._table, field.name) and \
tools.table_kind(self._cr, model._table) == 'r':
tools.table_kind(self._cr, model._table) == tools.TableKind.Regular:
self._cr.execute(sql.SQL('ALTER TABLE {} DROP COLUMN {} CASCADE').format(
sql.Identifier(model._table), sql.Identifier(field.name),
))
@ -860,16 +918,17 @@ class IrModelFields(models.Model):
view._check_xml()
except Exception:
if not uninstalling:
raise UserError("\n".join([
_("Cannot rename/delete fields that are still present in views:"),
_("Fields: %s") % ", ".join(str(f) for f in fields),
_("View: %s", view.name),
]))
raise UserError(_(
"Cannot rename/delete fields that are still present in views:\nFields: %s\nView: %s",
", ".join(str(f) for f in fields),
view.name,
))
else:
# uninstall mode
_logger.warning("The following fields were force-deleted to prevent a registry crash "
+ ", ".join(str(f) for f in fields)
+ " the following view might be broken %s" % view.name)
_logger.warning(
"The following fields were force-deleted to prevent a registry crash %s the following view might be broken %s",
", ".join(str(f) for f in fields),
view.name)
finally:
if not uninstalling:
# the registry has been modified, restore it
@ -925,7 +984,7 @@ class IrModelFields(models.Model):
vals['model'] = IrModel.browse(vals['model_id']).model
# for self._get_ids() in _update_selection()
self.clear_caches()
self.env.registry.clear_cache()
res = super(IrModelFields, self).create(vals_list)
models = set(res.mapped('model'))
@ -941,7 +1000,7 @@ class IrModelFields(models.Model):
('model', '=', vals['relation']),
('name', '=', vals['relation_field']),
]):
raise UserError(_("Many2one %s on model %s does not exist!") % (vals['relation_field'], vals['relation']))
raise UserError(_("Many2one %s on model %s does not exist!", vals['relation_field'], vals['relation']))
if any(model in self.pool for model in models):
# setup models; this re-initializes model in registry
@ -954,6 +1013,9 @@ class IrModelFields(models.Model):
return res
def write(self, vals):
if not self:
return True
# if set, *one* column can be renamed here
column_rename = None
@ -1031,11 +1093,15 @@ class IrModelFields(models.Model):
return res
def name_get(self):
res = []
@api.depends('field_description', 'model')
def _compute_display_name(self):
IrModel = self.env["ir.model"]
for field in self:
res.append((field.id, '%s (%s)' % (field.field_description, field.model)))
return res
if self.env.context.get('hide_model'):
field.display_name = field.field_description
continue
model_string = IrModel._get(field.model).name
field.display_name = f'{field.field_description} ({model_string})'
def _reflect_field_params(self, field, model_id):
""" Return the values to write to the database for the given field. """
@ -1062,6 +1128,16 @@ class IrModelFields(models.Model):
'relation_table': field.relation if field.type == 'many2many' else None,
'column1': field.column1 if field.type == 'many2many' else None,
'column2': field.column2 if field.type == 'many2many' else None,
'currency_field': field.currency_field if field.type == 'monetary' else None,
# html sanitization attributes (useless for other fields)
'sanitize': field.sanitize if field.type == 'html' else None,
'sanitize_overridable': field.sanitize_overridable if field.type == 'html' else None,
'sanitize_tags': field.sanitize_tags if field.type == 'html' else None,
'sanitize_attributes': field.sanitize_attributes if field.type == 'html' else None,
'sanitize_style': field.sanitize_style if field.type == 'html' else None,
'sanitize_form': field.sanitize_form if field.type == 'html' else None,
'strip_style': field.strip_style if field.type == 'html' else None,
'strip_classes': field.strip_classes if field.type == 'html' else None,
}
def _reflect_fields(self, model_names):
@ -1163,6 +1239,15 @@ class IrModelFields(models.Model):
attrs['translate'] = bool(field_data['translate'])
if field_data['ttype'] == 'char':
attrs['size'] = field_data['size'] or None
elif field_data['ttype'] == 'html':
attrs['sanitize'] = field_data['sanitize']
attrs['sanitize_overridable'] = field_data['sanitize_overridable']
attrs['sanitize_tags'] = field_data['sanitize_tags']
attrs['sanitize_attributes'] = field_data['sanitize_attributes']
attrs['sanitize_style'] = field_data['sanitize_style']
attrs['sanitize_form'] = field_data['sanitize_form']
attrs['strip_style'] = field_data['strip_style']
attrs['strip_classes'] = field_data['strip_classes']
elif field_data['ttype'] in ('selection', 'reference'):
attrs['selection'] = self.env['ir.model.fields.selection']._get_selection_data(field_data['id'])
if field_data['ttype'] == 'selection':
@ -1193,8 +1278,12 @@ class IrModelFields(models.Model):
attrs['column1'] = field_data['column1'] or col1
attrs['column2'] = field_data['column2'] or col2
attrs['domain'] = safe_eval(field_data['domain'] or '[]')
elif field_data['ttype'] == 'monetary' and not self.pool.loaded:
return
elif field_data['ttype'] == 'monetary':
# be sure that custom monetary field are always instanciated
if not self.pool.loaded and \
field_data['currency_field'] and not self._is_manual_name(field_data['currency_field']):
return
attrs['currency_field'] = field_data['currency_field']
# add compute function if given
if field_data['compute']:
attrs['compute'] = make_compute(field_data['compute'], field_data['depends'])
@ -1206,6 +1295,10 @@ class IrModelFields(models.Model):
if attrs:
return fields.Field.by_type[field_data['ttype']](**attrs)
@api.model
def _is_manual_name(self, name):
return name.startswith('x_')
def _add_manual_fields(self, model):
""" Add extra fields on model. """
fields_data = self._get_manual_field_data(model._name)
@ -1255,6 +1348,94 @@ class IrModelFields(models.Model):
field = self._get(model_name, field_name)
return [(sel.value, sel.name) for sel in field.selection_ids]
class ModelInherit(models.Model):
_name = "ir.model.inherit"
_description = "Model Inheritance Tree"
_log_access = False
model_id = fields.Many2one("ir.model", required=True, ondelete="cascade")
parent_id = fields.Many2one("ir.model", required=True, ondelete="cascade")
parent_field_id = fields.Many2one("ir.model.fields", ondelete="cascade") # in case of inherits
_sql_constraints = [
("uniq", "UNIQUE(model_id, parent_id)", "Models inherits from another only once")
]
def _reflect_inherits(self, model_names):
""" Reflect the given models' inherits (_inherit and _inherits). """
IrModel = self.env["ir.model"]
get_model_id = IrModel._get_id
module_mapping = defaultdict(list)
for model_name in model_names:
get_field_id = self.env["ir.model.fields"]._get_ids(model_name).get
model_id = get_model_id(model_name)
model = self.env[model_name]
for cls in reversed(type(model).mro()):
if not models.is_definition_class(cls):
continue
items = [
(model_id, get_model_id(parent_name), None)
for parent_name in cls._inherit
if parent_name not in ("base", model_name)
] + [
(model_id, get_model_id(parent_name), get_field_id(field))
for parent_name, field in cls._inherits.items()
]
for item in items:
module_mapping[item].append(cls._module)
if not module_mapping:
return
cr = self.env.cr
cr.execute(
"""
SELECT i.id, i.model_id, i.parent_id, i.parent_field_id
FROM ir_model_inherit i
JOIN ir_model m
ON m.id = i.model_id
WHERE m.model IN %s
""",
[tuple(model_names)]
)
existing = {}
inh_ids = {}
for iid, model_id, parent_id, parent_field_id in cr.fetchall():
inh_ids[(model_id, parent_id, parent_field_id)] = iid
existing[(model_id, parent_id)] = parent_field_id
sentinel = object()
cols = ["model_id", "parent_id", "parent_field_id"]
rows = [item for item in module_mapping if existing.get(item[:2], sentinel) != item[2]]
if rows:
ids = upsert_en(self, cols, rows, ["model_id", "parent_id"])
for row, id_ in zip(rows, ids):
inh_ids[row] = id_
self.pool.post_init(mark_modified, self.browse(ids), cols[1:])
# update their XML id
IrModel.browse(id_ for item in module_mapping for id_ in item[:2]).fetch(['model'])
data_list = []
for (model_id, parent_id, parent_field_id), modules in module_mapping.items():
model_name = IrModel.browse(model_id).model.replace(".", "_")
parent_name = IrModel.browse(parent_id).model.replace(".", "_")
record_id = inh_ids[(model_id, parent_id, parent_field_id)]
data_list += [
{
"xml_id": f"{module}.model_inherit__{model_name}__{parent_name}",
"record": self.browse(record_id),
}
for module in modules
]
self.env["ir.model.data"]._update_xmlids(data_list)
class IrModelSelection(models.Model):
_name = 'ir.model.fields.selection'
_order = 'sequence, id'
@ -1429,6 +1610,9 @@ class IrModelSelection(models.Model):
return recs
def write(self, vals):
if not self:
return True
if (
not self.env.user._is_admin() and
any(record.field_id.state != 'manual' for record in self)
@ -1543,8 +1727,9 @@ class IrModelSelection(models.Model):
else:
# this shouldn't happen... simply a sanity check
raise ValueError(_(
"The ondelete policy %r is not valid for field %r"
) % (ondelete, selection))
"The ondelete policy %r is not valid for field %r",
ondelete, selection
))
def _get_records(self):
""" Return the records having 'self' as a value. """
@ -1617,6 +1802,7 @@ class IrModelConstraint(models.Model):
_logger.info('Dropped FK CONSTRAINT %s@%s', name, data.model.model)
if typ == 'u':
hname = tools.make_identifier(name)
# test if constraint exists
# Since type='u' means any "other" constraint, to avoid issues we limit to
# 'c' -> check, 'u' -> unique, 'x' -> exclude constraints, effective leaving
@ -1624,10 +1810,10 @@ class IrModelConstraint(models.Model):
# See: https://www.postgresql.org/docs/9.5/catalog-pg-constraint.html
self._cr.execute("""SELECT 1 from pg_constraint cs JOIN pg_class cl ON (cs.conrelid = cl.oid)
WHERE cs.contype in ('c', 'u', 'x') and cs.conname=%s and cl.relname=%s""",
(name[:63], table))
(hname, table))
if self._cr.fetchone():
self._cr.execute(sql.SQL('ALTER TABLE {} DROP CONSTRAINT {}').format(
sql.Identifier(table), sql.Identifier(name[:63])))
sql.Identifier(table), sql.Identifier(hname)))
_logger.info('Dropped CONSTRAINT %s@%s', name, data.model.model)
return super().unlink()
@ -1638,7 +1824,8 @@ class IrModelConstraint(models.Model):
return super(IrModelConstraint, self).copy(default)
def _reflect_constraint(self, model, conname, type, definition, module, message=None):
""" Reflect the given constraint, and return its corresponding record.
""" Reflect the given constraint, and return its corresponding record
if a record is created or modified; returns ``None`` otherwise.
The reflection makes it possible to remove a constraint when its
corresponding module is uninstalled. ``type`` is either 'f' or 'u'
depending on the constraint being a foreign key or not.
@ -1675,7 +1862,7 @@ class IrModelConstraint(models.Model):
write_uid=%s, type=%s, definition=%s, message=%s
WHERE id=%s"""
cr.execute(query, (self.env.uid, type, definition, Json({'en_US': message}), cons_id))
return self.browse(cons_id)
return self.browse(cons_id)
def _reflect_constraints(self, model_names):
""" Reflect the SQL constraints of the given models. """
@ -1700,11 +1887,13 @@ class IrModelConstraint(models.Model):
conname = '%s_%s' % (model._table, key)
module = constraint_module.get(key)
record = self._reflect_constraint(model, conname, 'u', cons_text(definition), module, message)
xml_id = '%s.constraint_%s' % (module, conname)
if record:
xml_id = '%s.constraint_%s' % (module, conname)
data_list.append(dict(xml_id=xml_id, record=record))
self.env['ir.model.data']._update_xmlids(data_list)
else:
self.env['ir.model.data']._load_xmlid(xml_id)
if data_list:
self.env['ir.model.data']._update_xmlids(data_list)
class IrModelRelation(models.Model):
@ -1788,40 +1977,6 @@ class IrModelAccess(models.Model):
perm_create = fields.Boolean(string='Create Access')
perm_unlink = fields.Boolean(string='Delete Access')
@api.model
def check_groups(self, group):
""" Check whether the current user has the given group. """
grouparr = group.split('.')
if not grouparr:
return False
self._cr.execute("""SELECT 1 FROM res_groups_users_rel
WHERE uid=%s AND gid IN (
SELECT res_id FROM ir_model_data WHERE module=%s AND name=%s)""",
(self._uid, grouparr[0], grouparr[1],))
return bool(self._cr.fetchone())
@api.model
def check_group(self, model, mode, group_ids):
""" Check if a specific group has the access mode to the specified model"""
assert mode in ('read', 'write', 'create', 'unlink'), 'Invalid access mode'
if isinstance(model, models.BaseModel):
assert model._name == 'ir.model', 'Invalid model object'
model_name = model.name
else:
model_name = model
if isinstance(group_ids, int):
group_ids = [group_ids]
query = """ SELECT 1 FROM ir_model_access a
JOIN ir_model m ON (m.id = a.model_id)
WHERE a.active AND a.perm_{mode} AND
m.model=%s AND (a.group_id IN %s OR a.group_id IS NULL)
""".format(mode=mode)
self._cr.execute(query, (model_name, tuple(group_ids)))
return bool(self._cr.rowcount)
@api.model
def group_names_with_access(self, model_name, access_mode):
""" Return the names of visible groups which have been granted
@ -1932,27 +2087,15 @@ class IrModelAccess(models.Model):
group_info=group_info,
resolution_info=resolution_info)
raise AccessError(msg)
raise AccessError(msg) from None
return has_access
__cache_clearing_methods = set()
@classmethod
def register_cache_clearing_method(cls, model, method):
cls.__cache_clearing_methods.add((model, method))
@classmethod
def unregister_cache_clearing_method(cls, model, method):
cls.__cache_clearing_methods.discard((model, method))
@api.model
def call_cache_clearing_methods(self):
self.env.invalidate_all()
self._get_allowed_models.clear_cache(self) # clear the cache of check function
for model, method in self.__cache_clearing_methods:
if model in self.env:
getattr(self.env[model], method)()
self.env.registry.clear_cache() # mainly _get_allowed_models
#
# Check rights on actions
@ -1960,6 +2103,13 @@ class IrModelAccess(models.Model):
@api.model_create_multi
def create(self, vals_list):
self.call_cache_clearing_methods()
for ima in vals_list:
if "group_id" in ima and not ima["group_id"] and any([
ima.get("perm_read"),
ima.get("perm_write"),
ima.get("perm_create"),
ima.get("perm_unlink")]):
_logger.warning("Rule %s has no group, this is a deprecated feature. Every access-granting rule should specify a group.", ima['name'])
return super(IrModelAccess, self).create(vals_list)
def write(self, values):
@ -2020,35 +2170,31 @@ class IrModelData(models.Model):
self._table, ['model', 'res_id'])
return res
def name_get(self):
model_id_name = defaultdict(dict) # {res_model: {res_id: name}}
for xid in self:
model_id_name[xid.model][xid.res_id] = None
# fill in model_id_name with name_get() of corresponding records
for model, id_name in model_id_name.items():
try:
ng = self.env[model].browse(id_name).name_get()
id_name.update(ng)
except Exception:
pass
# return results, falling back on complete_name
return [(xid.id, model_id_name[xid.model][xid.res_id] or xid.complete_name)
for xid in self]
@api.depends('res_id', 'model', 'complete_name')
def _compute_display_name(self):
invalid_records = self.filtered(lambda r: not r.res_id or r.model not in self.env)
for invalid_record in invalid_records:
invalid_record.display_name = invalid_record.complete_name
for model, model_data_records in (self - invalid_records).grouped('model').items():
records = self.env[model].browse(model_data_records.mapped('res_id'))
for xid, target_record in zip(model_data_records, records):
try:
xid.display_name = target_record.display_name or xid.complete_name
except Exception: # pylint: disable=broad-except
xid.display_name = xid.complete_name
# NEW V8 API
@api.model
@tools.ormcache('xmlid')
def _xmlid_lookup(self, xmlid):
def _xmlid_lookup(self, xmlid: str) -> tuple:
"""Low level xmlid lookup
Return (id, res_model, res_id) or raise ValueError if not found
Return (res_model, res_id) or raise ValueError if not found
"""
module, name = xmlid.split('.', 1)
query = "SELECT id, model, res_id FROM ir_model_data WHERE module=%s AND name=%s"
query = "SELECT model, res_id FROM ir_model_data WHERE module=%s AND name=%s"
self.env.cr.execute(query, [module, name])
result = self.env.cr.fetchone()
if not (result and result[2]):
if not (result and result[1]):
raise ValueError('External ID not found in the system: %s' % xmlid)
return result
@ -2056,7 +2202,7 @@ class IrModelData(models.Model):
def _xmlid_to_res_model_res_id(self, xmlid, raise_if_not_found=False):
""" Return (res_model, res_id)"""
try:
return self._xmlid_lookup(xmlid)[1:3]
return self._xmlid_lookup(xmlid)
except ValueError:
if raise_if_not_found:
raise
@ -2071,12 +2217,12 @@ class IrModelData(models.Model):
def check_object_reference(self, module, xml_id, raise_on_access_error=False):
"""Returns (model, res_id) corresponding to a given module and xml_id (cached), if and only if the user has the necessary access rights
to see that object, otherwise raise a ValueError if raise_on_access_error is True or returns a tuple (model found, False)"""
model, res_id = self._xmlid_lookup("%s.%s" % (module, xml_id))[1:3]
model, res_id = self._xmlid_lookup("%s.%s" % (module, xml_id))
#search on id found in result to check if current user has read access right
if self.env[model].search([('id', '=', res_id)]):
return model, res_id
if raise_on_access_error:
raise AccessError(_('Not enough access rights on the external ID:') + ' %s.%s' % (module, xml_id))
raise AccessError(_('Not enough access rights on the external ID %r', '%s.%s', (module, xml_id)))
return model, False
@api.returns('self', lambda value: value.id)
@ -2086,9 +2232,13 @@ class IrModelData(models.Model):
default = dict(default or {}, name="%s_%s" % (self.name, rand))
return super().copy(default)
def write(self, values):
self.env.registry.clear_cache() # _xmlid_lookup
return super().write(values)
def unlink(self):
""" Regular unlink method, but make sure to clear the caches. """
self.clear_caches()
self.env.registry.clear_cache() # _xmlid_lookup
return super(IrModelData, self).unlink()
def _lookup_xmlids(self, xml_ids, model):
@ -2140,6 +2290,20 @@ class IrModelData(models.Model):
query = self._build_update_xmlids_query(sub_rows, update)
try:
self.env.cr.execute(query, [arg for row in sub_rows for arg in row])
result = self.env.cr.fetchall()
if result:
for module, name, model, res_id, create_date, write_date in result:
# small optimisation: during install a lot of xmlid are created/updated.
# Instead of clearing the cache, set the correct value in the cache to avoid a bunch of query
self._xmlid_lookup.__cache__.add_value(self, f"{module}.{name}", cache_value=(model, res_id))
if create_date != write_date:
# something was updated, notify other workers
# it is possible that create_date and write_date
# have the same value after an update if it was
# created in the same transaction, no need to invalidate other worker cache
# cache in this case.
self.env.registry.cache_invalidated.add('default')
except Exception:
_logger.error("Failed to insert ir_model_data\n%s", "\n".join(str(row) for row in sub_rows))
raise
@ -2149,18 +2313,32 @@ class IrModelData(models.Model):
# NOTE: this method is overriden in web_studio; if you need to make another
# override, make sure it is compatible with the one that is there.
def _build_insert_xmlids_values(self):
return {
'module': '%s',
'name': '%s',
'model': '%s',
'res_id': '%s',
'noupdate': '%s',
}
def _build_update_xmlids_query(self, sub_rows, update):
rowf = "(%s, %s, %s, %s, %s)"
rows = self._build_insert_xmlids_values()
row_names = f"({','.join(rows.keys())})"
row_placeholders = f"({','.join(rows.values())})"
row_placeholders = ", ".join([row_placeholders] * len(sub_rows))
return """
INSERT INTO ir_model_data (module, name, model, res_id, noupdate)
VALUES {rows}
INSERT INTO ir_model_data {row_names}
VALUES {row_placeholder}
ON CONFLICT (module, name)
DO UPDATE SET (model, res_id, write_date) =
(EXCLUDED.model, EXCLUDED.res_id, now() at time zone 'UTC')
{where}
WHERE (ir_model_data.res_id != EXCLUDED.res_id OR ir_model_data.model != EXCLUDED.model) {and_where}
RETURNING module, name, model, res_id, create_date, write_date
""".format(
rows=", ".join([rowf] * len(sub_rows)),
where="WHERE NOT ir_model_data.noupdate" if update else "",
row_names=row_names,
row_placeholder=row_placeholders,
and_where="AND NOT ir_model_data.noupdate" if update else "",
)
@api.model

View file

@ -1,14 +1,15 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import warnings
from collections import defaultdict, OrderedDict
from decorator import decorator
from operator import attrgetter
from textwrap import dedent
import io
import logging
import os
import shutil
import tempfile
import threading
import zipfile
@ -28,10 +29,10 @@ from odoo.addons.base.models.ir_model import MODULE_UNINSTALL_FLAG
from odoo.exceptions import AccessDenied, UserError, ValidationError
from odoo.osv import expression
from odoo.tools.parse_version import parse_version
from odoo.tools.misc import topological_sort
from odoo.tools.translate import TranslationImporter
from odoo.tools.misc import topological_sort, get_flag
from odoo.tools.translate import TranslationImporter, get_po_paths
from odoo.http import request
from odoo.modules import get_module_path, get_module_resource
from odoo.modules import get_module_path
_logger = logging.getLogger(__name__)
@ -80,28 +81,9 @@ class ModuleCategory(models.Model):
_order = 'name'
_allow_sudo_commands = False
@api.depends('module_ids')
def _compute_module_nr(self):
self.env['ir.module.module'].flush_model(['category_id'])
self.flush_model(['parent_id'])
cr = self._cr
cr.execute('SELECT category_id, COUNT(*) \
FROM ir_module_module \
WHERE category_id IN %(ids)s \
OR category_id IN (SELECT id \
FROM ir_module_category \
WHERE parent_id IN %(ids)s) \
GROUP BY category_id', {'ids': tuple(self.ids)}
)
result = dict(cr.fetchall())
for cat in self.filtered('id'):
cr.execute('SELECT id FROM ir_module_category WHERE parent_id=%s', (cat.id,))
cat.module_nr = sum([result.get(c, 0) for (c,) in cr.fetchall()], result.get(cat.id, 0))
name = fields.Char(string='Name', required=True, translate=True, index=True)
parent_id = fields.Many2one('ir.module.category', string='Parent Application', index=True)
child_ids = fields.One2many('ir.module.category', 'parent_id', string='Child Applications')
module_nr = fields.Integer(string='Number of Apps', compute='_compute_module_nr')
module_ids = fields.One2many('ir.module.module', 'category_id', string='Modules')
description = fields.Text(string='Description', translate=True)
sequence = fields.Integer(string='Sequence')
@ -135,7 +117,14 @@ class MyFilterMessages(Transform):
default_priority = 870
def apply(self):
for node in self.document.traverse(nodes.system_message):
# Use `findall()` if available (docutils >= 0.20), otherwise fallback to `traverse()`.
# This ensures compatibility across environments with different docutils versions.
if hasattr(self.document, 'findall'):
nodes_iter = self.document.findall(nodes.system_message)
else:
nodes_iter = self.document.traverse(nodes.system_message)
for node in nodes_iter:
_logger.warning("docutils' system message present: %s", str(node))
node.parent.remove(node)
@ -174,15 +163,6 @@ class Module(models.Model):
_order = 'application desc,sequence,name'
_allow_sudo_commands = False
@api.model
def get_views(self, views, options=None):
res = super().get_views(views, options)
if res['views'].get('form', {}).get('toolbar'):
install_id = self.env.ref('base.action_server_module_immediate_install').id
action = [rec for rec in res['views']['form']['toolbar']['action'] if rec.get('id', False) != install_id]
res['views']['form']['toolbar'] = {'action': action}
return res
@classmethod
def get_module_info(cls, name):
try:
@ -195,7 +175,7 @@ class Module(models.Model):
def _get_desc(self):
def _apply_description_images(doc):
html = lxml.html.document_fromstring(doc)
for element, attribute, link, pos in html.iterlinks():
for element, _attribute, _link, _pos in html.iterlinks():
if element.get('src') and not '//' in element.get('src') and not 'static/' in element.get('src'):
element.set('src', "/%s/static/description/%s" % (module.name, element.get('src')))
return tools.html_sanitize(lxml.html.tostring(html))
@ -204,19 +184,30 @@ class Module(models.Model):
if not module.name:
module.description_html = False
continue
module_path = modules.get_module_path(module.name, display_warning=False) # avoid to log warning for fake community module
if module_path:
path = modules.check_resource_path(module_path, 'static/description/index.html')
if module_path and path:
path = os.path.join(module.name, 'static/description/index.html')
try:
with tools.file_open(path, 'rb') as desc_file:
doc = desc_file.read()
if not doc.startswith(XML_DECLARATION):
if doc.startswith(XML_DECLARATION):
warnings.warn(
f"XML declarations in HTML module descriptions are "
f"deprecated since Odoo 17, {module.name} can just "
f"have a UTF8 description with not need for a "
f"declaration.",
category=DeprecationWarning,
)
else:
try:
doc = doc.decode('utf-8')
doc = doc.decode()
except UnicodeDecodeError:
pass
warnings.warn(
f"Non-UTF8 module descriptions are deprecated "
f"since Odoo 17 ({module.name}'s description "
f"is not utf-8)",
category=DeprecationWarning,
)
module.description_html = _apply_description_images(doc)
else:
except FileNotFoundError:
overrides = {
'embed_stylesheet': False,
'doctitle_xform': False,
@ -272,12 +263,18 @@ class Module(models.Model):
if not module.id:
continue
if module.icon:
path = modules.get_module_resource(*module.icon.split("/")[1:])
path = os.path.join(module.icon.lstrip("/"))
else:
path = modules.module.get_module_icon_path(module)
if path:
with tools.file_open(path, 'rb', filter_ext=('.png', '.svg', '.gif', '.jpeg', '.jpg')) as image_file:
module.icon_image = base64.b64encode(image_file.read())
try:
with tools.file_open(path, 'rb', filter_ext=('.png', '.svg', '.gif', '.jpeg', '.jpg')) as image_file:
module.icon_image = base64.b64encode(image_file.read())
except FileNotFoundError:
module.icon_image = ''
countries = self.get_module_info(module.name).get('countries', [])
country_code = len(countries) == 1 and countries[0]
module.icon_flag = get_flag(country_code.upper()) if country_code else ''
name = fields.Char('Technical Name', readonly=True, required=True)
category_id = fields.Many2one('ir.module.category', string='Category', readonly=True, index=True)
@ -328,6 +325,7 @@ class Module(models.Model):
application = fields.Boolean('Application', readonly=True)
icon = fields.Char('Icon URL')
icon_image = fields.Binary(string='Icon', compute='_get_icon_image')
icon_flag = fields.Char(string='Flag', compute='_get_icon_image')
to_buy = fields.Boolean('Odoo Enterprise Module', default=False)
has_iap = fields.Boolean(compute='_compute_has_iap')
@ -346,7 +344,7 @@ class Module(models.Model):
raise UserError(_('You are trying to remove a module that is installed or will be installed.'))
def unlink(self):
self.clear_caches()
self.env.registry.clear_cache()
return super(Module, self).unlink()
def _get_modules_to_load_domain(self):
@ -360,16 +358,16 @@ class Module(models.Model):
modules.check_manifest_dependencies(terp)
except Exception as e:
if newstate == 'to install':
msg = _('Unable to install module "%s" because an external dependency is not met: %s')
msg = _('Unable to install module "%s" because an external dependency is not met: %s', module_name, e.args[0])
elif newstate == 'to upgrade':
msg = _('Unable to upgrade module "%s" because an external dependency is not met: %s')
msg = _('Unable to upgrade module "%s" because an external dependency is not met: %s', module_name, e.args[0])
else:
msg = _('Unable to process module "%s" because an external dependency is not met: %s')
raise UserError(msg % (module_name, e.args[0]))
msg = _('Unable to process module "%s" because an external dependency is not met: %s', module_name, e.args[0])
raise UserError(msg)
def _state_update(self, newstate, states_to_update, level=100):
if level < 1:
raise UserError(_('Recursion error in modules dependencies !'))
raise UserError(_('Recursion error in modules dependencies!'))
# whether some modules are installed with demo data
demo = False
@ -383,7 +381,7 @@ class Module(models.Model):
update_mods, ready_mods = self.browse(), self.browse()
for dep in module.dependencies_id:
if dep.state == 'unknown':
raise UserError(_("You try to install module '%s' that depends on module '%s'.\nBut the latter module is not available in your system.") % (module.name, dep.name,))
raise UserError(_("You try to install module %r that depends on module %r.\nBut the latter module is not available in your system.", module.name, dep.name))
if dep.depend_id.state == newstate:
ready_mods += dep.depend_id
else:
@ -430,8 +428,7 @@ class Module(models.Model):
for module in install_mods:
for exclusion in module.exclusion_ids:
if exclusion.name in install_names:
msg = _('Modules "%s" and "%s" are incompatible.')
raise UserError(msg % (module.shortdesc, exclusion.exclusion_id.shortdesc))
raise UserError(_('Modules %r and %r are incompatible.', module.shortdesc, exclusion.exclusion_id.shortdesc))
# check category exclusions
def closure(module):
@ -449,12 +446,13 @@ class Module(models.Model):
# the installation is valid if all installed modules in categories
# belong to the transitive dependencies of one of them
if modules and not any(modules <= closure(module) for module in modules):
msg = _('You are trying to install incompatible modules in category "%s":')
labels = dict(self.fields_get(['state'])['state']['selection'])
raise UserError("\n".join([msg % category.name] + [
"- %s (%s)" % (module.shortdesc, labels[module.state])
for module in modules
]))
raise UserError(
_('You are trying to install incompatible modules in category %r:%s', category.name, ''.join(
f"\n- {module.shortdesc} ({labels[module.state]})"
for module in modules
))
)
return dict(ACTION_DICT, name=_('Install'))
@ -691,7 +689,7 @@ class Module(models.Model):
module = todo[i]
i += 1
if module.state not in ('installed', 'to upgrade'):
raise UserError(_("Can not upgrade module '%s'. It is not installed.") % (module.name,))
raise UserError(_("Can not upgrade module %r. It is not installed.", module.name))
if self.get_module_info(module.name).get("installable", True):
self.check_external_dependencies(module.name, 'to upgrade')
for dep in Dependency.search([('name', '=', module.name)]):
@ -710,7 +708,7 @@ class Module(models.Model):
continue
for dep in module.dependencies_id:
if dep.state == 'unknown':
raise UserError(_('You try to upgrade the module %s that depends on the module: %s.\nBut this module is not available in your system.') % (module.name, dep.name,))
raise UserError(_('You try to upgrade the module %s that depends on the module: %s.\nBut this module is not available in your system.', module.name, dep.name))
if dep.state == 'uninstalled':
to_install += self.search([('name', '=', dep.name)]).ids
@ -725,7 +723,7 @@ class Module(models.Model):
@staticmethod
def get_values_from_terp(terp):
return {
'description': terp.get('description', ''),
'description': dedent(terp.get('description', '')),
'shortdesc': terp.get('name', ''),
'author': terp.get('author', 'Unknown'),
'maintainer': terp.get('maintainer', False),
@ -794,110 +792,6 @@ class Module(models.Model):
return res
@assert_log_admin_access
def download(self, download=True):
return []
@assert_log_admin_access
@api.model
def install_from_urls(self, urls):
if not self.env.user.has_group('base.group_system'):
raise AccessDenied()
# One-click install is opt-in - cfr Issue #15225
ad_dir = tools.config.addons_data_dir
if not os.access(ad_dir, os.W_OK):
msg = (_("Automatic install of downloaded Apps is currently disabled.") + "\n\n" +
_("To enable it, make sure this directory exists and is writable on the server:") +
"\n%s" % ad_dir)
_logger.warning(msg)
raise UserError(msg)
apps_server = werkzeug.urls.url_parse(self.get_apps_server())
OPENERP = odoo.release.product_name.lower()
tmp = tempfile.mkdtemp()
_logger.debug('Install from url: %r', urls)
try:
# 1. Download & unzip missing modules
for module_name, url in urls.items():
if not url:
continue # nothing to download, local version is already the last one
up = werkzeug.urls.url_parse(url)
if up.scheme != apps_server.scheme or up.netloc != apps_server.netloc:
raise AccessDenied()
try:
_logger.info('Downloading module `%s` from OpenERP Apps', module_name)
response = requests.get(url)
response.raise_for_status()
content = response.content
except Exception:
_logger.exception('Failed to fetch module %s', module_name)
raise UserError(_('The `%s` module appears to be unavailable at the moment, please try again later.', module_name))
else:
zipfile.ZipFile(io.BytesIO(content)).extractall(tmp)
assert os.path.isdir(os.path.join(tmp, module_name))
# 2a. Copy/Replace module source in addons path
for module_name, url in urls.items():
if module_name == OPENERP or not url:
continue # OPENERP is special case, handled below, and no URL means local module
module_path = modules.get_module_path(module_name, downloaded=True, display_warning=False)
bck = backup(module_path, False)
_logger.info('Copy downloaded module `%s` to `%s`', module_name, module_path)
shutil.move(os.path.join(tmp, module_name), module_path)
if bck:
shutil.rmtree(bck)
# 2b. Copy/Replace server+base module source if downloaded
if urls.get(OPENERP):
# special case. it contains the server and the base module.
# extract path is not the same
base_path = os.path.dirname(modules.get_module_path('base'))
# copy all modules in the SERVER/odoo/addons directory to the new "odoo" module (except base itself)
for d in os.listdir(base_path):
if d != 'base' and os.path.isdir(os.path.join(base_path, d)):
destdir = os.path.join(tmp, OPENERP, 'addons', d) # XXX 'odoo' subdirectory ?
shutil.copytree(os.path.join(base_path, d), destdir)
# then replace the server by the new "base" module
server_dir = tools.config['root_path'] # XXX or dirname()
bck = backup(server_dir)
_logger.info('Copy downloaded module `odoo` to `%s`', server_dir)
shutil.move(os.path.join(tmp, OPENERP), server_dir)
#if bck:
# shutil.rmtree(bck)
self.update_list()
with_urls = [module_name for module_name, url in urls.items() if url]
downloaded = self.search([('name', 'in', with_urls)])
installed = self.search([('id', 'in', downloaded.ids), ('state', '=', 'installed')])
to_install = self.search([('name', 'in', list(urls)), ('state', '=', 'uninstalled')])
post_install_action = to_install.button_immediate_install()
if installed or to_install:
# in this case, force server restart to reload python code...
self._cr.commit()
odoo.service.server.restart()
return {
'type': 'ir.actions.client',
'tag': 'home',
'params': {'wait': True},
}
return post_install_action
finally:
shutil.rmtree(tmp)
@api.model
def get_apps_server(self):
return tools.config.get('apps_server', 'https://apps.odoo.com/apps')
def _update_from_terp(self, terp):
self._update_dependencies(terp.get('depends', []), terp.get('auto_install'))
self._update_exclusions(terp.get('excludes', []))
@ -961,7 +855,7 @@ class Module(models.Model):
def _check(self):
for module in self:
if not module.description_html:
_logger.warning('module %s: description is empty !', module.name)
_logger.warning('module %s: description is empty!', module.name)
def _get(self, name):
""" Return the (sudoed) `ir.module.module` record with the given name.
@ -1044,36 +938,13 @@ class Module(models.Model):
if not modpath:
continue
for lang in langs:
lang_code = tools.get_iso_codes(lang)
base_lang_code = None
if '_' in lang_code:
base_lang_code = lang_code.split('_')[0]
# Step 1: for sub-languages, load base language first (e.g. es_CL.po is loaded over es.po)
if base_lang_code:
base_trans_file = get_module_resource(module_name, 'i18n', base_lang_code + '.po')
if base_trans_file:
_logger.info('module %s: loading base translation file %s for language %s', module_name, base_lang_code, lang)
translation_importer.load_file(base_trans_file, lang)
# i18n_extra folder is for additional translations handle manually (eg: for l10n_be)
base_trans_extra_file = get_module_resource(module_name, 'i18n_extra', base_lang_code + '.po')
if base_trans_extra_file:
_logger.info('module %s: loading extra base translation file %s for language %s', module_name, base_lang_code, lang)
translation_importer.load_file(base_trans_extra_file, lang)
# Step 2: then load the main translation file, possibly overriding the terms coming from the base language
trans_file = get_module_resource(module_name, 'i18n', lang_code + '.po')
if trans_file:
_logger.info('module %s: loading translation file %s for language %s', module_name, lang_code, lang)
translation_importer.load_file(trans_file, lang)
elif lang_code != 'en_US':
_logger.info('module %s: no translation for language %s', module_name, lang_code)
trans_extra_file = get_module_resource(module_name, 'i18n_extra', lang_code + '.po')
if trans_extra_file:
_logger.info('module %s: loading extra translation file %s for language %s', module_name, lang_code, lang)
translation_importer.load_file(trans_extra_file, lang)
is_lang_imported = False
for po_path in get_po_paths(module_name, lang):
_logger.info('module %s: loading translation file %s for language %s', module_name, po_path, lang)
translation_importer.load_file(po_path, lang)
is_lang_imported = True
if lang != 'en_US' and not is_lang_imported:
_logger.info('module %s: no translation for language %s', module_name, lang)
translation_importer.save(overwrite=overwrite)

View file

@ -17,6 +17,7 @@ TYPE2FIELD = {
'date': 'value_datetime',
'datetime': 'value_datetime',
'selection': 'value_text',
'html': 'value_text',
}
TYPE2CLEAN = {
@ -29,6 +30,7 @@ TYPE2CLEAN = {
'binary': lambda val: val or False,
'date': lambda val: val.date() if val else False,
'datetime': lambda val: val or False,
'html': lambda val: val or False,
}
@ -57,6 +59,7 @@ class Property(models.Model):
('date', 'Date'),
('datetime', 'DateTime'),
('selection', 'Selection'),
('html', 'Html'),
],
required=True,
default='many2one',
@ -111,16 +114,24 @@ class Property(models.Model):
# if any of the records we're writing on has a res_id=False *or*
# we're writing a res_id=False on any record
default_set = False
if self._ids:
default_set = values.get('res_id') is False or any(not p.res_id for p in self)
r = super(Property, self).write(self._update_values(values))
values = self._update_values(values)
default_set = (
# turning a record value into a fallback value
values.get('res_id') is False and any(record.res_id for record in self)
) or any(
# changing a fallback value
not record.res_id and any(record[fname] != self._fields[fname].convert_to_record(value, self) for fname, value in values.items())
for record in self
)
r = super().write(values)
if default_set:
# DLE P44: test `test_27_company_dependent`
# Easy solution, need to flush write when changing a property.
# Maybe it would be better to be able to compute all impacted cache value and update those instead
# Then clear_caches must be removed as well.
# Then clear_cache must be removed as well.
self.env.flush_all()
self.clear_caches()
self.env.registry.clear_cache()
return r
@api.model_create_multi
@ -131,14 +142,14 @@ class Property(models.Model):
if created_default:
# DLE P44: test `test_27_company_dependent`
self.env.flush_all()
self.clear_caches()
self.env.registry.clear_cache()
return r
def unlink(self):
default_deleted = any(not p.res_id for p in self)
r = super().unlink()
if default_deleted:
self.clear_caches()
self.env.registry.clear_cache()
return r
def get_by_record(self):
@ -405,6 +416,11 @@ class Property(models.Model):
target_names = target.name_search(value, operator=operator, limit=None)
target_ids = [n[0] for n in target_names]
operator, value = 'in', [makeref(v) for v in target_ids]
elif operator in ('any', 'not any'):
if operator == 'not any':
negate = True
operator = 'in'
value = list(map(makeref, self.env[field.comodel_name]._search(value)))
elif field.type in ('integer', 'float'):
# No record is created in ir.property if the field's type is float or integer with a value

View file

@ -24,7 +24,7 @@ which executes its directive but doesn't generate any output in and of
itself.
To create new XML template, please see :doc:`QWeb Templates documentation
<https://www.odoo.com/documentation/16.0/developer/reference/frontend/qweb.html>`
<https://www.odoo.com/documentation/17.0/developer/reference/frontend/qweb.html>`
Rendering process
=================
@ -123,15 +123,21 @@ Directives
``t-debug``
~~~~~~~~~~~
**Values**: ``pdb``, ``ipdb``, ``pudb``, ``wdb``
**Values**: `''` (empty string), ``pdb``, ``ipdb``, ``pudb``, ``wdb``
Activate the choosed debugger.
Triggers a debugger breakpoint at that location. With an empty value, calls the
``breakpoint`` builtin invoking whichever breakpoint hook has been set up,
otherwise triggers a breakpoint uses the corresponding debugger.
When dev mode is enabled this allows python developers to have access to the
state of variables being rendered. The code generated by the QWeb engine is
not accessible, only the variables (values, self) can be analyzed or the
methods that called the QWeb rendering.
.. warning:: using a non-empty string is deprecated since 17.0, configure your
preferred debugger via ``PYTHONBREAKPOINT`` or
``sys.setbreakpointhook``.
``t-if``
~~~~~~~~
**Values**: python expression
@ -359,6 +365,7 @@ structure.
"""
import base64
import contextlib
import fnmatch
import io
@ -370,6 +377,7 @@ import time
import token
import tokenize
import traceback
import warnings
import werkzeug
from markupsafe import Markup, escape
@ -380,18 +388,20 @@ from dateutil.relativedelta import relativedelta
from psycopg2.extensions import TransactionRollbackError
from odoo import api, models, tools
from odoo.tools import config, safe_eval, pycompat, SUPPORTED_DEBUGGER
from odoo.modules import registry
from odoo.tools import config, safe_eval, pycompat
from odoo.tools.constants import SUPPORTED_DEBUGGER, EXTERNAL_ASSET
from odoo.tools.safe_eval import assert_valid_codeobj, _BUILTINS, to_opcodes, _EXPR_OPCODES, _BLACKLIST
from odoo.tools.json import scriptsafe
from odoo.tools.lru import LRU
from odoo.tools.misc import str2bool
from odoo.tools.image import image_data_uri
from odoo.tools.image import image_data_uri, FILETYPE_BASE64_MAGICWORD
from odoo.http import request
from odoo.modules.module import get_resource_path, get_module_path
from odoo.tools.profiler import QwebTracker
from odoo.exceptions import UserError, AccessDenied, AccessError, MissingError, ValidationError
from odoo.addons.base.models.assetsbundle import AssetsBundle
from odoo.addons.base.models.ir_asset import can_aggregate, STYLE_EXTENSIONS, SCRIPT_EXTENSIONS, TEMPLATE_EXTENSIONS
from odoo.tools.constants import SCRIPT_EXTENSIONS, STYLE_EXTENSIONS, TEMPLATE_EXTENSIONS
_logger = logging.getLogger(__name__)
@ -414,17 +424,20 @@ _SAFE_QWEB_OPCODES = _EXPR_OPCODES.union(to_opcodes([
'LOAD_FAST', 'STORE_FAST', 'UNPACK_SEQUENCE',
'STORE_SUBSCR',
'LOAD_GLOBAL',
'EXTENDED_ARG',
# Following opcodes were added in 3.11 https://docs.python.org/3/whatsnew/3.11.html#new-opcodes
'RESUME',
'CALL',
'PRECALL',
'POP_JUMP_FORWARD_IF_FALSE',
'PUSH_NULL',
'POP_JUMP_FORWARD_IF_TRUE', 'KW_NAMES',
'KW_NAMES',
'FORMAT_VALUE', 'BUILD_STRING',
'RETURN_GENERATOR',
'POP_JUMP_BACKWARD_IF_FALSE',
'SWAP',
'POP_JUMP_FORWARD_IF_FALSE', 'POP_JUMP_FORWARD_IF_TRUE',
'POP_JUMP_BACKWARD_IF_FALSE', 'POP_JUMP_BACKWARD_IF_TRUE',
'POP_JUMP_FORWARD_IF_NONE', 'POP_JUMP_FORWARD_IF_NOT_NONE',
'POP_JUMP_BACKWARD_IF_NONE', 'POP_JUMP_BACKWARD_IF_NOT_NONE',
# 3.12 https://docs.python.org/3/whatsnew/3.12.html#new-opcodes
'END_FOR',
'LOAD_FAST_AND_CLEAR',
@ -601,7 +614,7 @@ class IrQWeb(models.AbstractModel):
@tools.conditional(
'xml' not in tools.config['dev_mode'],
tools.ormcache('template', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())'),
tools.ormcache('template', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())', cache='templates'),
)
def _get_view_id(self, template):
try:
@ -876,6 +889,30 @@ class IrQWeb(models.AbstractModel):
# values for running time
def _get_converted_image_data_uri(self, base64_source):
if self.env.context.get('webp_as_jpg'):
mimetype = FILETYPE_BASE64_MAGICWORD.get(base64_source[:1], 'png')
if 'webp' in mimetype:
# Use converted image so that is recognized by wkhtmltopdf.
bin_source = base64.b64decode(base64_source)
Attachment = self.env['ir.attachment']
checksum = Attachment._compute_checksum(bin_source)
origins = Attachment.sudo().search([
['id', '!=', False], # No implicit condition on res_field.
['checksum', '=', checksum],
])
if origins:
converted_domain = [
['id', '!=', False], # No implicit condition on res_field.
['res_model', '=', 'ir.attachment'],
['res_id', 'in', origins.ids],
['mimetype', '=', 'image/jpeg'],
]
converted = Attachment.sudo().search(converted_domain, limit=1)
if converted:
base64_source = converted.datas
return image_data_uri(base64_source)
def _prepare_environment(self, values):
""" Prepare the values and context that will sent to the
compiled and evaluated function.
@ -893,7 +930,6 @@ class IrQWeb(models.AbstractModel):
values.setdefault('debug', debug)
values.setdefault('user_id', self.env.user.with_env(self.env))
values.setdefault('res_company', self.env.company.sudo())
values.update(
request=request, # might be unbound if we're not in an httprequest context
test_mode_enabled=bool(config['test_enable'] or config['test_file']),
@ -902,7 +938,7 @@ class IrQWeb(models.AbstractModel):
time=safe_eval.time,
datetime=safe_eval.datetime,
relativedelta=relativedelta,
image_data_uri=image_data_uri,
image_data_uri=self._get_converted_image_data_uri,
# specific 'math' functions to ease rounding in templates and lessen controller marshmalling
floor=math.floor,
ceil=math.ceil,
@ -1296,7 +1332,7 @@ class IrQWeb(models.AbstractModel):
if unqualified_el_tag != 't':
el.set('t-tag-open', el_tag)
if unqualified_el_tag not in VOID_ELEMENTS:
if el_tag not in VOID_ELEMENTS:
el.set('t-tag-close', el_tag)
if not ({'t-out', 't-esc', 't-raw', 't-field'} & set(el.attrib)):
@ -1350,7 +1386,7 @@ class IrQWeb(models.AbstractModel):
attributes = ''.join(f' {name}="{escape(str(value))}"'
for name, value in attrib.items() if value or isinstance(value, str))
self._append_text(f'<{el_tag}{"".join(attributes)}', compile_context)
if unqualified_el_tag in VOID_ELEMENTS:
if el_tag in VOID_ELEMENTS:
self._append_text('/>', compile_context)
else:
self._append_text('>', compile_context)
@ -1365,7 +1401,7 @@ class IrQWeb(models.AbstractModel):
body = self._compile_directive(el, compile_context, 'inner-content', level)
if unqualified_el_tag != 't':
if unqualified_el_tag not in VOID_ELEMENTS:
if el_tag not in VOID_ELEMENTS:
self._append_text(f'</{el_tag}>', compile_context)
return body
@ -2169,7 +2205,7 @@ class IrQWeb(models.AbstractModel):
xmlid = el.attrib.pop('t-call-assets')
css = self._compile_bool(el.attrib.pop('t-css', True))
js = self._compile_bool(el.attrib.pop('t-js', True))
async_load = self._compile_bool(el.attrib.pop('async_load', False))
# async_load support was removed
defer_load = self._compile_bool(el.attrib.pop('defer_load', False))
lazy_load = self._compile_bool(el.attrib.pop('lazy_load', False))
media = el.attrib.pop('media', False)
@ -2179,7 +2215,6 @@ class IrQWeb(models.AbstractModel):
css={css},
js={js},
debug=values.get("debug"),
async_load={async_load},
defer_load={defer_load},
lazy_load={lazy_load},
media={media!r},
@ -2187,7 +2222,7 @@ class IrQWeb(models.AbstractModel):
""".strip(), level))
code.append(indent_code("""
for index, (tagName, asset_attrs, content) in enumerate(t_call_assets_nodes):
for index, (tagName, asset_attrs) in enumerate(t_call_assets_nodes):
if index:
yield '\\n '
yield '<'
@ -2198,12 +2233,10 @@ class IrQWeb(models.AbstractModel):
if value or isinstance(value, str):
yield f' {escape(str(name))}="{escape(str(value))}"'
if not content and tagName in VOID_ELEMENTS:
if tagName in VOID_ELEMENTS:
yield '/>'
else:
yield '>'
if content:
yield content
yield '</'
yield tagName
yield '>'
@ -2352,7 +2385,16 @@ class IrQWeb(models.AbstractModel):
def _debug_trace(self, debugger, values):
"""Method called at running time to load debugger."""
if debugger in SUPPORTED_DEBUGGER:
if not debugger:
breakpoint()
elif debugger in SUPPORTED_DEBUGGER:
warnings.warn(
"Using t-debug with an explicit debugger is deprecated "
"since Odoo 17.0, keep the value empty and configure the "
"``breakpoint`` builtin instead.",
category=DeprecationWarning,
stacklevel=2,
)
__import__(debugger).set_trace()
else:
raise ValueError(f"unsupported t-debug value: {debugger}")
@ -2427,15 +2469,28 @@ class IrQWeb(models.AbstractModel):
return (attributes, content, inherit_branding)
def _get_asset_nodes(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
def _get_asset_nodes(self, bundle, css=True, js=True, debug=False, defer_load=False, lazy_load=False, media=None):
"""Generates asset nodes.
If debug=assets, the assets will be regenerated when a file which composes them has been modified.
Else, the assets will be generated only once and then stored in cache.
"""
if debug and 'assets' in debug:
return self._generate_asset_nodes(bundle, css, js, debug, async_load, defer_load, lazy_load, media)
media = css and media or None
links = self._get_asset_links(bundle, css=css, js=js, debug=debug)
return self._links_to_nodes(links, defer_load=defer_load, lazy_load=lazy_load, media=media)
def _get_asset_links(self, bundle, css=True, js=True, debug=None):
"""Generates asset nodes.
If debug=assets, the assets will be regenerated when a file which composes them has been modified.
Else, the assets will be generated only once and then stored in cache.
"""
rtl = self.env['res.lang'].sudo()._lang_get_direction(self.env.context.get('lang') or self.env.user.lang) == 'rtl'
assets_params = self.env['ir.asset']._get_asset_params() # website_id
debug_assets = debug and 'assets' in debug
if debug_assets:
return self._generate_asset_links(bundle, css=css, js=js, debug_assets=True, assets_params=assets_params, rtl=rtl)
else:
return self._generate_asset_nodes_cache(bundle, css, js, debug, async_load, defer_load, lazy_load, media)
return self._generate_asset_links_cache(bundle, css=css, js=js, assets_params=assets_params, rtl=rtl)
# qweb cache feature
@ -2464,7 +2519,6 @@ class IrQWeb(models.AbstractModel):
""" generate value from the function if the result is not cached. """
if not cache_key:
return get_value()
value = loaded_values and loaded_values.get(cache_key)
if not value:
value = self._get_cached_values(cache_key, get_value)
@ -2476,7 +2530,7 @@ class IrQWeb(models.AbstractModel):
# in '_compile' method contains the write_date of all inherited views.
@tools.conditional(
'xml' not in tools.config['dev_mode'],
tools.ormcache('cache_key'),
tools.ormcache('cache_key', cache='templates.cached_values'),
)
def _get_cached_values(self, cache_key, get_value):
""" generate value from the function if the result is not cached. """
@ -2487,84 +2541,87 @@ class IrQWeb(models.AbstractModel):
# in non-xml-debug mode we want assets to be cached forever, and the admin can force a cache clear
# by restarting the server after updating the source code (or using the "Clear server cache" in debug tools)
'xml' not in tools.config['dev_mode'],
tools.ormcache('bundle', 'css', 'js', 'debug', 'async_load', 'defer_load', 'lazy_load', 'media', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())'),
tools.ormcache('bundle', 'css', 'js', 'tuple(sorted(assets_params.items()))', 'rtl', cache='assets'),
)
def _generate_asset_nodes_cache(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
return self._generate_asset_nodes(bundle, css, js, debug, async_load, defer_load, lazy_load, media)
@tools.ormcache('bundle', 'defer_load', 'lazy_load', 'media', 'tuple(self.env.context.get(k) for k in self._get_template_cache_keys())')
def _get_asset_content(self, bundle, defer_load=False, lazy_load=False, media=None):
asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, css=True, js=True)
def _generate_asset_links_cache(self, bundle, css=True, js=True, assets_params=None, rtl=False):
return self._generate_asset_links(bundle, css, js, False, assets_params, rtl)
def _get_asset_content(self, bundle, assets_params=None):
if assets_params is None:
assets_params = self.env['ir.asset']._get_asset_params() # website_id
asset_paths = self.env['ir.asset']._get_asset_paths(bundle=bundle, assets_params=assets_params)
files = []
remains = []
for path, *_ in asset_paths:
ext = path.split('.')[-1]
is_js = ext in SCRIPT_EXTENSIONS
is_xml = ext in TEMPLATE_EXTENSIONS
is_css = ext in STYLE_EXTENSIONS
if not is_js and not is_xml and not is_css:
continue
if is_xml:
base = get_module_path(bundle.split('.')[0]).rsplit('/', 1)[0]
if path.startswith(base):
path = path[len(base):]
mimetype = None
if is_js:
mimetype = 'text/javascript'
elif is_css:
mimetype = f'text/{ext}'
elif is_xml:
mimetype = 'text/xml'
if can_aggregate(path):
segments = [segment for segment in path.split('/') if segment]
external_asset = []
for path, full_path, _bundle, last_modified in asset_paths:
if full_path is not EXTERNAL_ASSET:
files.append({
'atype': mimetype,
'url': path,
'filename': get_resource_path(*segments) if segments else None,
'filename': full_path,
'content': '',
'media': media,
'last_modified': last_modified,
})
else:
if is_js:
tag = 'script'
attributes = {
"type": mimetype,
}
attributes["data-src" if lazy_load else "src"] = path
if defer_load or lazy_load:
attributes["defer"] = "defer"
elif is_css:
tag = 'link'
attributes = {
"type": mimetype,
"rel": "stylesheet",
"href": path,
'media': media,
}
elif is_xml:
tag = 'script'
attributes = {
"type": mimetype,
"async": "async",
"rel": "prefetch",
"data-src": path,
}
remains.append((tag, attributes, None))
external_asset.append(path)
return (files, external_asset)
return (files, remains)
def _get_asset_bundle(self, bundle_name, css=True, js=True, debug_assets=False, rtl=False, assets_params=None):
if assets_params is None:
assets_params = self.env['ir.asset']._get_asset_params()
files, external_assets = self._get_asset_content(bundle_name, assets_params)
return AssetsBundle(bundle_name, files, external_assets, env=self.env, css=css, js=js, debug_assets=debug_assets, rtl=rtl, assets_params=assets_params)
def _get_asset_bundle(self, bundle_name, files, env=None, css=True, js=True):
return AssetsBundle(bundle_name, files, env=env, css=css, js=js)
def _links_to_nodes(self, paths, defer_load=False, lazy_load=False, media=None):
return [self._link_to_node(path, defer_load=defer_load, lazy_load=lazy_load, media=media) for path in paths]
def _generate_asset_nodes(self, bundle, css=True, js=True, debug=False, async_load=False, defer_load=False, lazy_load=False, media=None):
files, remains = self._get_asset_content(bundle, defer_load=defer_load, lazy_load=lazy_load, media=css and media or None)
asset = self._get_asset_bundle(bundle, files, env=self.env, css=css, js=js)
remains = [node for node in remains if (css and node[0] == 'link') or (js and node[0] == 'script')]
return remains + asset.to_node(css=css, js=js, debug=debug, async_load=async_load, defer_load=defer_load, lazy_load=lazy_load)
def _link_to_node(self, path, defer_load=False, lazy_load=False, media=None):
ext = path.rsplit('.', maxsplit=1)[-1] if path else 'js'
is_js = ext in SCRIPT_EXTENSIONS
is_xml = ext in TEMPLATE_EXTENSIONS
is_css = ext in STYLE_EXTENSIONS
if not is_js and not is_xml and not is_css:
return
if is_js:
is_asset_bundle = path and path.startswith('/web/assets/')
attributes = {
'type': 'text/javascript',
}
if (defer_load or lazy_load):
attributes['defer'] = 'defer'
if path:
if lazy_load:
attributes['data-src'] = path
else:
attributes['src'] = path
if is_asset_bundle:
attributes['onerror'] = "__odooAssetError=1"
return ('script', attributes)
if is_css:
attributes = {
'type': f'text/{ext}', # we don't really expect to have anything else than pure css here
'rel': 'stylesheet',
'href': path,
'media': media,
}
return ('link', attributes)
if is_xml:
attributes = {
'type': 'text/xml',
'async': 'async',
'rel': 'prefetch',
'data-src': path,
}
return ('script', attributes)
def _generate_asset_links(self, bundle, css=True, js=True, debug_assets=False, assets_params=None, rtl=False):
asset_bundle = self._get_asset_bundle(bundle, css=css, js=js, debug_assets=debug_assets, rtl=rtl, assets_params=assets_params)
return asset_bundle.get_links()
def _get_asset_link_urls(self, bundle, debug=False):
asset_nodes = self._get_asset_nodes(bundle, js=False, debug=debug)
@ -2582,6 +2639,24 @@ class IrQWeb(models.AbstractModel):
"""
_logger.runbot('Pregenerating assets bundles')
js_bundles, css_bundles = self._get_bundles_to_pregenarate()
links = []
start = time.time()
for bundle in sorted(js_bundles):
links += self._get_asset_bundle(bundle, css=False, js=True).js()
_logger.info('JS Assets bundles generated in %s seconds', time.time()-start)
start = time.time()
for bundle in sorted(css_bundles):
links += self._get_asset_bundle(bundle, css=True, js=False).css()
_logger.info('CSS Assets bundles generated in %s seconds', time.time()-start)
return links
def _get_bundles_to_pregenarate(self):
"""
Returns the list of bundles to pregenerate.
"""
views = self.env['ir.ui.view'].search([('type', '=', 'qweb'), ('arch_db', 'like', 't-call-assets')])
js_bundles = set()
css_bundles = set()
@ -2594,17 +2669,7 @@ class IrQWeb(models.AbstractModel):
js_bundles.add(asset)
if css:
css_bundles.add(asset)
nodes = []
start = time.time()
for bundle in sorted(js_bundles):
nodes += self._generate_asset_nodes(bundle, css=False, js=True)
_logger.info('JS Assets bundles generated in %s seconds', time.time()-start)
start = time.time()
for bundle in sorted(css_bundles):
nodes += self._generate_asset_nodes(bundle, css=True, js=False)
_logger.info('CSS Assets bundles generated in %s seconds', time.time()-start)
return nodes
return (js_bundles, css_bundles)
def render(template_name, values, load, **options):
""" Rendering of a qweb template without database and outside the registry.
@ -2621,7 +2686,11 @@ def render(template_name, values, load, **options):
"""
class MockPool:
db_name = None
_Registry__cache = {}
_Registry__caches = {cache_name: LRU(cache_size) for cache_name, cache_size in registry._REGISTRY_CACHES.items()}
_Registry__caches_groups = {}
for cache_name, cache in _Registry__caches.items():
_Registry__caches_groups.setdefault(cache_name.split('.')[0], []).append(cache)
class MockIrQWeb(IrQWeb):
_register = False # not visible in real registry

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
import base64
import binascii
from datetime import time
import logging
import re
@ -15,6 +16,7 @@ from odoo import api, fields, models, _, _lt, tools
from odoo.tools import posix_to_ldml, float_utils, format_date, format_duration, pycompat
from odoo.tools.mail import safe_attrs
from odoo.tools.misc import get_lang, babel_locale_parse
from odoo.tools.mimetypes import guess_mimetype
_logger = logging.getLogger(__name__)
@ -28,6 +30,14 @@ def nl2br(string):
"""
return pycompat.to_text(string).replace('\n', Markup('<br>\n'))
def nl2br_enclose(string, enclosure_tag='div'):
""" Like nl2br, but returns enclosed Markup allowing to better manipulate
trusted and untrusted content. New lines added by use are trusted, other
content is escaped. """
converted = nl2br(escape(string))
return Markup(f'<{enclosure_tag}>{converted}</{enclosure_tag}>')
#--------------------------------------------------------------------
# QWeb Fields converters
#--------------------------------------------------------------------
@ -382,19 +392,27 @@ class ImageConverter(models.AbstractModel):
@api.model
def _get_src_data_b64(self, value, options):
try: # FIXME: maaaaaybe it could also take raw bytes?
image = Image.open(BytesIO(base64.b64decode(value)))
try:
img_b64 = base64.b64decode(value)
except binascii.Error:
raise ValueError("Invalid image content") from None
if img_b64 and guess_mimetype(img_b64, '') == 'image/webp':
return self.env["ir.qweb"]._get_converted_image_data_uri(value)
try:
image = Image.open(BytesIO(img_b64))
image.verify()
except IOError:
raise ValueError("Non-image binary fields can not be converted to HTML")
raise ValueError("Non-image binary fields can not be converted to HTML") from None
except: # image.verify() throws "suitable exceptions", I have no idea what they are
raise ValueError("Invalid image content")
raise ValueError("Invalid image content") from None
return "data:%s;base64,%s" % (Image.MIME[image.format], value.decode('ascii'))
@api.model
def value_to_html(self, value, options):
return Markup('<img src="%s">' % self._get_src_data_b64(value, options))
return Markup('<img src="%s">') % self._get_src_data_b64(value, options)
class ImageUrlConverter(models.AbstractModel):
""" ``image_url`` widget rendering, inserts an image tag in the
@ -449,7 +467,7 @@ class MonetaryConverter(models.AbstractModel):
# lang.format will not set one by default. currency.round will not
# provide one either. So we need to generate a precision value
# (integer > 0) from the currency's rounding (a float generally < 1.0).
fmt = "%.{0}f".format(display_currency.decimal_places)
fmt = "%.{0}f".format(options.get('decimal_places', display_currency.decimal_places))
if options.get('from_currency'):
date = options.get('date') or fields.Date.today()
@ -631,13 +649,26 @@ class DurationConverter(models.AbstractModel):
v, r = divmod(r, secs_per_unit)
if not v:
continue
section = babel.dates.format_timedelta(
v*secs_per_unit,
granularity=round_to,
add_direction=options.get('add_direction'),
format=options.get('format', 'long'),
threshold=1,
locale=locale)
try:
section = babel.dates.format_timedelta(
v*secs_per_unit,
granularity=round_to,
add_direction=options.get('add_direction'),
format=options.get('format', 'long'),
threshold=1,
locale=locale)
except KeyError:
# in case of wrong implementation of babel, try to fallback on en_US locale.
# https://github.com/python-babel/babel/pull/827/files
# Some bugs already fixed in 2.10 but ubuntu22 is 2.8
localeUS = babel_locale_parse('en_US')
section = babel.dates.format_timedelta(
v*secs_per_unit,
granularity=round_to,
add_direction=options.get('add_direction'),
format=options.get('format', 'long'),
threshold=1,
locale=localeUS)
if section:
sections.append(section)
@ -715,7 +746,7 @@ class BarcodeConverter(models.AbstractModel):
if k.startswith('img_') and k[4:] in safe_attrs:
img_element.set(k[4:], v)
if not img_element.get('alt'):
img_element.set('alt', _('Barcode %s') % value)
img_element.set('alt', _('Barcode %s', value))
img_element.set('src', 'data:image/png;base64,%s' % base64.b64encode(barcode).decode())
return Markup(html.tostring(img_element, encoding='unicode'))
@ -754,6 +785,12 @@ class Contact(models.AbstractModel):
@api.model
def value_to_html(self, value, options):
if not value:
if options.get('null_text'):
val = {
'options': options,
}
template_options = options.get('template_options', {})
return self.env['ir.qweb']._render('base.no_contact', val, **template_options)
return ''
opf = options.get('fields') or ["name", "address", "phone", "mobile", "email"]
@ -767,16 +804,16 @@ class Contact(models.AbstractModel):
opsep = Markup('<br/>')
value = value.sudo().with_context(show_address=True)
name_get = value.name_get()[0][1]
display_name = value.display_name or ''
# Avoid having something like:
# name_get = 'Foo\n \n' -> This is a res.partner with a name and no address
# display_name = 'Foo\n \n' -> This is a res.partner with a name and no address
# That would return markup('<br/>') as address. But there is no address set.
if any(elem.strip() for elem in name_get.split("\n")[1:]):
address = opsep.join(name_get.split("\n")[1:]).strip()
if any(elem.strip() for elem in display_name.split("\n")[1:]):
address = opsep.join(display_name.split("\n")[1:]).strip()
else:
address = ''
val = {
'name': name_get.split("\n")[0],
'name': display_name.split("\n")[0],
'address': address,
'phone': value.phone,
'mobile': value.mobile,

View file

@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import warnings
from odoo import api, fields, models, tools, SUPERUSER_ID, _
from odoo import api, fields, models, tools, _
from odoo.exceptions import AccessError, ValidationError
from odoo.osv import expression
from odoo.tools import config
@ -22,15 +21,15 @@ class IrRule(models.Model):
model_id = fields.Many2one('ir.model', string='Model', index=True, required=True, ondelete="cascade")
groups = fields.Many2many('res.groups', 'rule_group_rel', 'rule_group_id', 'group_id', ondelete='restrict')
domain_force = fields.Text(string='Domain')
perm_read = fields.Boolean(string='Apply for Read', default=True)
perm_write = fields.Boolean(string='Apply for Write', default=True)
perm_create = fields.Boolean(string='Apply for Create', default=True)
perm_unlink = fields.Boolean(string='Apply for Delete', default=True)
perm_read = fields.Boolean(string='Read', default=True)
perm_write = fields.Boolean(string='Write', default=True)
perm_create = fields.Boolean(string='Create', default=True)
perm_unlink = fields.Boolean(string='Delete', default=True)
_sql_constraints = [
('no_access_rights',
'CHECK (perm_read!=False or perm_write!=False or perm_create!=False or perm_unlink!=False)',
'Rule must have at least one checked access right !'),
'Rule must have at least one checked access right!'),
]
@api.model
@ -65,11 +64,10 @@ class IrRule(models.Model):
def _check_domain(self):
eval_context = self._eval_context()
for rule in self:
model = rule.model_id.model
if rule.active and rule.domain_force and model in self.env:
if rule.active and rule.domain_force:
try:
domain = safe_eval(rule.domain_force, eval_context)
expression.expression(domain, self.env[model].sudo())
expression.expression(domain, self.env[rule.model_id.model].sudo())
except Exception as e:
raise ValidationError(_('Invalid domain: %s', e))
@ -139,14 +137,20 @@ class IrRule(models.Model):
'tuple(self._compute_domain_context_values())'),
)
def _compute_domain(self, model_name, mode="read"):
global_domains = [] # list of domains
# add rules for parent models
for parent_model_name, parent_field_name in self.env[model_name]._inherits.items():
if domain := self._compute_domain(parent_model_name, mode):
global_domains.append([(parent_field_name, 'any', domain)])
rules = self._get_rules(model_name, mode=mode)
if not rules:
return
return expression.AND(global_domains) if global_domains else []
# browse user and rules as SUPERUSER_ID to avoid access errors!
# browse user and rules with sudo to avoid access errors!
eval_context = self._eval_context()
user_groups = self.env.user.groups_id
global_domains = [] # list of domains
group_domains = [] # list of domains
for rule in rules.sudo():
# evaluate the domain for the current user
@ -172,14 +176,9 @@ class IrRule(models.Model):
v = tuple(v)
yield v
@api.model
def clear_cache(self):
warnings.warn("Deprecated IrRule.clear_cache(), use IrRule.clear_caches() instead", DeprecationWarning)
self.clear_caches()
def unlink(self):
res = super(IrRule, self).unlink()
self.clear_caches()
self.env.registry.clear_cache()
return res
@api.model_create_multi
@ -187,7 +186,7 @@ class IrRule(models.Model):
res = super(IrRule, self).create(vals_list)
# DLE P33: tests
self.env.flush_all()
self.clear_caches()
self.env.registry.clear_cache()
return res
def write(self, vals):
@ -197,7 +196,7 @@ class IrRule(models.Model):
# - odoo/addons/test_access_rights/tests/test_ir_rules.py
# - odoo/addons/base/tests/test_orm.py (/home/dle/src/odoo/master-nochange-fp/odoo/addons/base/tests/test_orm.py)
self.env.flush_all()
self.clear_caches()
self.env.registry.clear_cache()
return res
def _make_access_error(self, operation, records):
@ -206,18 +205,22 @@ class IrRule(models.Model):
model = records._name
description = self.env['ir.model']._get(model).name or model
msg_heads = {
# Messages are declared in extenso so they are properly exported in translation terms
'read': _("Due to security restrictions, you are not allowed to access '%(document_kind)s' (%(document_model)s) records.", document_kind=description, document_model=model),
'write': _("Due to security restrictions, you are not allowed to modify '%(document_kind)s' (%(document_model)s) records.", document_kind=description, document_model=model),
'create': _("Due to security restrictions, you are not allowed to create '%(document_kind)s' (%(document_model)s) records.", document_kind=description, document_model=model),
'unlink': _("Due to security restrictions, you are not allowed to delete '%(document_kind)s' (%(document_model)s) records.", document_kind=description, document_model=model)
operations = {
'read': _("read"),
'write': _("write"),
'create': _("create"),
'unlink': _("unlink"),
}
operation_error = msg_heads[operation]
resolution_info = _("Contact your administrator to request access if necessary.")
user_description = f"{self.env.user.name} (id={self.env.user.id})"
operation_error = _("Uh-oh! Looks like you have stumbled upon some top-secret records.\n\n" \
"Sorry, %s doesn't have '%s' access to:", user_description, operations[operation])
failing_model = _("- %s (%s)", description, model)
resolution_info = _("If you really, really need access, perhaps you can win over your friendly administrator with a batch of freshly baked cookies.")
if not self.user_has_groups('base.group_no_one') or not self.env.user.has_group('base.group_user'):
return AccessError(f"{operation_error}\n\n{resolution_info}")
records.invalidate_recordset()
return AccessError(f"{operation_error}\n{failing_model}\n\n{resolution_info}")
# This extended AccessError is only displayed in debug mode.
# Note that by default, public and portal users do not have
@ -232,24 +235,21 @@ class IrRule(models.Model):
# If the user has access to the company of the record, add this
# information in the description to help them to change company
if company_related and 'company_id' in rec and rec.company_id in self.env.user.company_ids:
return f'{rec.display_name} (id={rec.id}, company={rec.company_id.display_name})'
return f'{rec.display_name} (id={rec.id})'
return f'{description}, {rec.display_name} ({model}: {rec.id}, company={rec.company_id.display_name})'
return f'{description}, {rec.display_name} ({model}: {rec.id})'
records_description = ', '.join(get_record_description(rec) for rec in records_sudo)
failing_records = _("Records: %s", records_description)
user_description = f'{self.env.user.name} (id={self.env.user.id})'
failing_user = _("User: %s", user_description)
failing_records = '\n '.join(f'- {get_record_description(rec)}' for rec in records_sudo)
rules_description = '\n'.join(f'- {rule.name}' for rule in rules)
failing_rules = _("This restriction is due to the following rules:\n%s", rules_description)
failing_rules = _("Blame the following rules:\n%s", rules_description)
if company_related:
failing_rules += "\n\n" + _('Note: this might be a multi-company issue.')
failing_rules += "\n\n" + _('Note: this might be a multi-company issue. Switching company may help - in Odoo, not in real life!')
# clean up the cache of records prefetched with display_name above
records_sudo.invalidate_recordset()
msg = f"{operation_error}\n\n{failing_records}\n{failing_user}\n\n{failing_rules}\n\n{resolution_info}"
msg = f"{operation_error}\n{failing_records}\n\n{failing_rules}\n\n{resolution_info}"
return AccessError(msg)

View file

@ -21,6 +21,8 @@ def _create_sequence(cr, seq_name, number_increment, number_next):
def _drop_sequences(cr, seq_names):
""" Drop the PostreSQL sequences if they exist. """
if not seq_names:
return
names = sql.SQL(',').join(map(sql.Identifier, seq_names))
# RESTRICT is the default; it prevents dropping the sequence if an
# object depends on it.
@ -232,8 +234,8 @@ class IrSequence(models.Model):
try:
interpolated_prefix = _interpolate(self.prefix, d)
interpolated_suffix = _interpolate(self.suffix, d)
except (ValueError, TypeError):
raise UserError(_('Invalid prefix or suffix for sequence \'%s\'') % self.name)
except (ValueError, TypeError, KeyError):
raise UserError(_('Invalid prefix or suffix for sequence %r', self.name))
return interpolated_prefix, interpolated_suffix
def get_next_char(self, number_next):
@ -345,7 +347,8 @@ class IrSequenceDateRange(models.Model):
@api.model
def default_get(self, fields):
result = super(IrSequenceDateRange, self).default_get(fields)
result['number_next_actual'] = 1
if 'number_next_actual' in fields:
result['number_next_actual'] = 1
return result
date_from = fields.Date(string='From', required=True)

View file

@ -3,13 +3,13 @@
import base64
from collections import defaultdict
from os.path import join as opj
import operator
import re
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
from odoo.http import request
from odoo.modules import get_module_resource
from odoo.osv import expression
MENU_ITEM_SEPARATOR = "/"
@ -53,22 +53,20 @@ class IrUiMenu(models.Model):
if level <= 0:
return '...'
if self.parent_id:
return self.parent_id._get_full_name(level - 1) + MENU_ITEM_SEPARATOR + (self.name or "")
return (self.parent_id._get_full_name(level - 1) or "") + MENU_ITEM_SEPARATOR + (self.name or "")
else:
return self.name
def read_image(self, path):
def _read_image(self, path):
if not path:
return False
path_info = path.split(',')
icon_path = get_module_resource(path_info[0], path_info[1])
icon_image = False
if icon_path:
with tools.file_open(icon_path, 'rb', filter_ext=(
'.gif', '.ico', '.jfif', '.jpeg', '.jpg', '.png', '.svg', '.webp',
)) as icon_file:
icon_image = base64.encodebytes(icon_file.read())
return icon_image
icon_path = opj(path_info[0], path_info[1])
try:
with tools.file_open(icon_path, 'rb', filter_ext=('.png', '.gif', '.ico', '.jfif', '.jpeg', '.jpg', '.svg', '.webp')) as icon_file:
return base64.encodebytes(icon_file.read())
except FileNotFoundError:
return False
@api.constrains('parent_id')
def _check_parent_id(self):
@ -81,7 +79,7 @@ class IrUiMenu(models.Model):
""" Return the ids of the menu items visible to the user. """
# retrieve all menus, and determine which ones are visible
context = {'ir.ui.menu.full_list': True}
menus = self.with_context(context).search([]).sudo()
menus = self.with_context(context).search_fetch([], ['action', 'parent_id']).sudo()
groups = self.env.user.groups_id
if not debug:
@ -141,9 +139,8 @@ class IrUiMenu(models.Model):
return self.filtered(lambda menu: menu.id in visible_ids)
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
menu_ids = super(IrUiMenu, self)._search(args, offset=0, limit=None, order=order, count=False, access_rights_uid=access_rights_uid)
menus = self.browse(menu_ids)
def search_fetch(self, domain, field_names, offset=0, limit=None, order=None):
menus = super().search_fetch(domain, field_names, order=order)
if menus:
# menu filtering is done only on main menu tree, not other menu lists
if not self._context.get('ir.ui.menu.full_list'):
@ -152,21 +149,28 @@ class IrUiMenu(models.Model):
menus = menus[offset:]
if limit:
menus = menus[:limit]
return len(menus) if count else menus.ids
return menus
def name_get(self):
return [(menu.id, menu._get_full_name()) for menu in self]
@api.model
def search_count(self, domain, limit=None):
# to be consistent with search() above
return len(self.search(domain, limit=limit))
@api.depends('parent_id')
def _compute_display_name(self):
for menu in self:
menu.display_name = menu._get_full_name()
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
self.env.registry.clear_cache()
for values in vals_list:
if 'web_icon' in values:
values['web_icon_data'] = self._compute_web_icon_data(values.get('web_icon'))
return super(IrUiMenu, self).create(vals_list)
def write(self, values):
self.clear_caches()
self.env.registry.clear_cache()
if 'web_icon' in values:
values['web_icon_data'] = self._compute_web_icon_data(values.get('web_icon'))
return super(IrUiMenu, self).write(values)
@ -176,10 +180,10 @@ class IrUiMenu(models.Model):
`web_icon` can either be:
- an image icon [module, path]
- a built icon [icon_class, icon_color, background_color]
and it only has to call `read_image` if it's an image.
and it only has to call `_read_image` if it's an image.
"""
if web_icon and len(web_icon.split(',')) == 2:
return self.read_image(web_icon)
return self._read_image(web_icon)
def unlink(self):
# Detach children and promote them to top-level, because it would be unwise to
@ -191,7 +195,7 @@ class IrUiMenu(models.Model):
direct_children = self.with_context(**extra).search([('parent_id', 'in', self.ids)])
direct_children.write({'parent_id': False})
self.clear_caches()
self.env.registry.clear_cache()
return super(IrUiMenu, self).unlink()
def copy(self, default=None):
@ -246,7 +250,7 @@ class IrUiMenu(models.Model):
:return: the menu root
:rtype: dict('children': menu_nodes)
"""
fields = ['name', 'sequence', 'parent_id', 'action', 'web_icon', 'web_icon_data']
fields = ['name', 'sequence', 'parent_id', 'action', 'web_icon']
menu_roots = self.get_user_roots()
menu_roots_data = menu_roots.read(fields) if menu_roots else []
menu_root = {
@ -276,6 +280,14 @@ class IrUiMenu(models.Model):
# mapping, resulting in children being correctly set on the roots.
menu_items.extend(menu_roots_data)
mi_attachments = self.env['ir.attachment'].sudo().search_read(
domain=[('res_model', '=', 'ir.ui.menu'),
('res_id', 'in', [menu_item['id'] for menu_item in menu_items if menu_item['id']]),
('res_field', '=', 'web_icon_data')],
fields=['res_id', 'datas', 'mimetype'])
mi_attachment_by_res_id = {attachment['res_id']: attachment for attachment in mi_attachments}
# set children ids and xmlids
menu_items_map = {menu_item["id"]: menu_item for menu_item in menu_items}
for menu_item in menu_items:
@ -285,6 +297,13 @@ class IrUiMenu(models.Model):
if parent in menu_items_map:
menu_items_map[parent].setdefault(
'children', []).append(menu_item['id'])
attachment = mi_attachment_by_res_id.get(menu_item['id'])
if attachment:
menu_item['web_icon_data'] = attachment['datas']
menu_item['web_icon_data_mimetype'] = attachment['mimetype']
else:
menu_item['web_icon_data'] = False
menu_item['web_icon_data_mimetype'] = False
all_menus.update(menu_items_map)
# sort by sequence

View file

@ -168,7 +168,7 @@ class report_paperformat(models.Model):
_description = "Paper Format Config"
name = fields.Char('Name', required=True)
default = fields.Boolean('Default paper format ?')
default = fields.Boolean('Default paper format?')
format = fields.Selection([(ps['key'], ps['description']) for ps in PAPER_SIZES], 'Paper size', default='A4', help="Select Proper Paper size")
margin_top = fields.Float('Top Margin (mm)', default=40)
margin_bottom = fields.Float('Bottom Margin (mm)', default=20)

View file

@ -30,22 +30,21 @@ class Bank(models.Model):
active = fields.Boolean(default=True)
bic = fields.Char('Bank Identifier Code', index=True, help="Sometimes called BIC or Swift.")
def name_get(self):
result = []
@api.depends('bic')
def _compute_display_name(self):
for bank in self:
name = bank.name + (bank.bic and (' - ' + bank.bic) or '')
result.append((bank.id, name))
return result
name = (bank.name or '') + (bank.bic and (' - ' + bank.bic) or '')
bank.display_name = name
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
args = args or []
domain = []
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
domain = domain or []
if name:
domain = ['|', ('bic', '=ilike', name + '%'), ('name', operator, name)]
name_domain = ['|', ('bic', '=ilike', name + '%'), ('name', operator, name)]
if operator in expression.NEGATIVE_TERM_OPERATORS:
domain = ['&'] + domain
return self._search(domain + args, limit=limit, access_rights_uid=name_get_uid)
name_domain = ['&', '!'] + name_domain[1:]
domain = domain + name_domain
return self._search(domain, limit=limit, order=order)
@api.onchange('country')
def _onchange_country_id(self):
@ -76,7 +75,7 @@ class ResPartnerBank(models.Model):
acc_type = fields.Selection(selection=lambda x: x.env['res.partner.bank'].get_supported_account_types(), compute='_compute_acc_type', string='Type', help='Bank account type: Normal or IBAN. Inferred from the bank account number.')
acc_number = fields.Char('Account Number', required=True)
sanitized_acc_number = fields.Char(compute='_compute_sanitized_acc_number', string='Sanitized Account Number', readonly=True, store=True)
acc_holder_name = fields.Char(string='Account Holder Name', help="Account holder name, in case it is different than the name of the Account Holder")
acc_holder_name = fields.Char(string='Account Holder Name', help="Account holder name, in case it is different than the name of the Account Holder", compute='_compute_account_holder_name', readonly=False, store=True)
partner_id = fields.Many2one('res.partner', 'Account Holder', ondelete='cascade', index=True, domain=['|', ('is_company', '=', True), ('parent_id', '=', False)], required=True)
allow_out_payment = fields.Boolean('Send Money', help='This account can be used for outgoing payments', default=False, copy=False, readonly=False)
bank_id = fields.Many2one('res.bank', string='Bank')
@ -102,30 +101,49 @@ class ResPartnerBank(models.Model):
for bank in self:
bank.acc_type = self.retrieve_acc_type(bank.acc_number)
@api.depends('partner_id.name')
def _compute_account_holder_name(self):
for bank in self:
bank.acc_holder_name = bank.partner_id.name
@api.model
def retrieve_acc_type(self, acc_number):
""" To be overridden by subclasses in order to support other account_types.
"""
return 'bank'
def name_get(self):
return [(acc.id, '{} - {}'.format(acc.acc_number, acc.bank_id.name) if acc.bank_id else acc.acc_number)
for acc in self]
@api.depends('acc_number', 'bank_id')
def _compute_display_name(self):
for acc in self:
acc.display_name = f'{acc.acc_number} - {acc.bank_id.name}' if acc.bank_id else acc.acc_number
def _sanitize_vals(self, vals):
if 'sanitized_acc_number' in vals: # do not allow to write on sanitized directly
vals['acc_number'] = vals.pop('sanitized_acc_number')
if 'acc_number' in vals:
vals['sanitized_acc_number'] = sanitize_account_number(vals['acc_number'])
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
self._sanitize_vals(vals)
return super().create(vals_list)
def write(self, vals):
self._sanitize_vals(vals)
return super().write(vals)
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
pos = 0
while pos < len(args):
# DLE P14
if args[pos][0] == 'acc_number':
op = args[pos][1]
value = args[pos][2]
def _search(self, domain, offset=0, limit=None, order=None, access_rights_uid=None):
def sanitize(arg):
if isinstance(arg, (tuple, list)) and arg[0] == 'acc_number':
value = arg[2]
if not isinstance(value, str) and isinstance(value, Iterable):
value = [sanitize_account_number(i) for i in value]
else:
value = sanitize_account_number(value)
if 'like' in op:
value = '%' + value + '%'
args[pos] = ('sanitized_acc_number', op, value)
pos += 1
return super(ResPartnerBank, self)._search(args, offset, limit, order, count=count, access_rights_uid=access_rights_uid)
return ('sanitized_acc_number', arg[1], value)
return arg
domain = [sanitize(item) for item in domain]
return super()._search(domain, offset, limit, order, access_rights_uid)

View file

@ -2,17 +2,12 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import io
import logging
import os
import re
import warnings
from odoo import api, fields, models, tools, _, Command
from odoo import api, fields, models, tools, _, Command, SUPERUSER_ID
from odoo.exceptions import ValidationError, UserError
from odoo.modules.module import get_resource_path
from odoo.tools import html2plaintext
from random import randrange
from PIL import Image
from odoo.tools import html2plaintext, file_open, ormcache
_logger = logging.getLogger(__name__)
@ -21,56 +16,37 @@ class Company(models.Model):
_name = "res.company"
_description = 'Companies'
_order = 'sequence, name'
_parent_store = True
def copy(self, default=None):
raise UserError(_('Duplicating a company is not allowed. Please create a new company instead.'))
def _get_logo(self):
return base64.b64encode(open(os.path.join(tools.config['root_path'], 'addons', 'base', 'static', 'img', 'res_company_logo.png'), 'rb') .read())
with file_open('base/static/img/res_company_logo.png', 'rb') as file:
return base64.b64encode(file.read())
def _default_currency_id(self):
return self.env.user.company_id.currency_id
def _get_default_favicon(self, original=False):
img_path = get_resource_path('web', 'static/img/favicon.ico')
with tools.file_open(img_path, 'rb') as f:
if original:
return base64.b64encode(f.read())
# Modify the source image to add a colored bar on the bottom
# This could seem overkill to modify the pixels 1 by 1, but
# Pillow doesn't provide an easy way to do it, and this
# is acceptable for a 16x16 image.
color = (randrange(32, 224, 24), randrange(32, 224, 24), randrange(32, 224, 24))
original = Image.open(f)
new_image = Image.new('RGBA', original.size)
height = original.size[1]
width = original.size[0]
bar_size = 1
for y in range(height):
for x in range(width):
pixel = original.getpixel((x, y))
if height - bar_size <= y + 1 <= height:
new_image.putpixel((x, y), (color[0], color[1], color[2], 255))
else:
new_image.putpixel((x, y), (pixel[0], pixel[1], pixel[2], pixel[3]))
stream = io.BytesIO()
new_image.save(stream, format="ICO")
return base64.b64encode(stream.getvalue())
name = fields.Char(related='partner_id.name', string='Company Name', required=True, store=True, readonly=False)
active = fields.Boolean(default=True)
sequence = fields.Integer(help='Used to order Companies in the company switcher', default=10)
parent_id = fields.Many2one('res.company', string='Parent Company', index=True)
child_ids = fields.One2many('res.company', 'parent_id', string='Child Companies')
child_ids = fields.One2many('res.company', 'parent_id', string='Branches')
all_child_ids = fields.One2many('res.company', 'parent_id', context={'active_test': False})
parent_path = fields.Char(index=True, unaccent=False)
parent_ids = fields.Many2many('res.company', compute='_compute_parent_ids', compute_sudo=True)
root_id = fields.Many2one('res.company', compute='_compute_parent_ids', compute_sudo=True)
partner_id = fields.Many2one('res.partner', string='Partner', required=True)
report_header = fields.Html(string='Company Tagline', help="Appears by default on the top right corner of your printed documents (report header).")
report_header = fields.Html(string='Company Tagline', translate=True, help="Company tagline, which is included in a printed document's header or footer (depending on the selected layout).")
report_footer = fields.Html(string='Report Footer', translate=True, help="Footer text displayed at the bottom of all reports.")
company_details = fields.Html(string='Company Details', help="Header text displayed at the top of all reports.")
company_details = fields.Html(string='Company Details', translate=True, help="Header text displayed at the top of all reports.")
is_company_details_empty = fields.Boolean(compute='_compute_empty_company_details')
logo = fields.Binary(related='partner_id.image_1920', default=_get_logo, string="Company Logo", readonly=False)
# logo_web: do not store in attachments, since the image is retrieved in SQL for
# performance reasons (see addons/web/controllers/main.py, Binary.company_logo)
logo_web = fields.Binary(compute='_compute_logo_web', store=True, attachment=False)
uses_default_logo = fields.Boolean(compute='_compute_uses_default_logo', store=True)
currency_id = fields.Many2one('res.currency', string='Currency', required=True, default=lambda self: self._default_currency_id())
user_ids = fields.Many2many('res.users', 'res_company_users_rel', 'cid', 'user_id', string='Accepted Users')
street = fields.Char(compute='_compute_address', inverse='_inverse_street')
@ -91,16 +67,14 @@ class Company(models.Model):
company_registry = fields.Char(related='partner_id.company_registry', string="Company ID", readonly=False)
paperformat_id = fields.Many2one('report.paperformat', 'Paper format', default=lambda self: self.env.ref('base.paperformat_euro', raise_if_not_found=False))
external_report_layout_id = fields.Many2one('ir.ui.view', 'Document Template')
base_onboarding_company_state = fields.Selection([
('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the onboarding company step", default='not_done')
favicon = fields.Binary(string="Company Favicon", help="This field holds the image used to display a favicon for a given company.", default=_get_default_favicon)
font = fields.Selection([("Lato", "Lato"), ("Roboto", "Roboto"), ("Open_Sans", "Open Sans"), ("Montserrat", "Montserrat"), ("Oswald", "Oswald"), ("Raleway", "Raleway"), ('Tajawal', 'Tajawal')], default="Lato")
primary_color = fields.Char()
secondary_color = fields.Char()
color = fields.Integer(compute='_compute_color', inverse='_inverse_color')
layout_background = fields.Selection([('Blank', 'Blank'), ('Geometric', 'Geometric'), ('Custom', 'Custom')], default="Blank", required=True)
layout_background_image = fields.Binary("Background Image")
_sql_constraints = [
('name_uniq', 'unique (name)', 'The company name must be unique !')
('name_uniq', 'unique (name)', 'The company name must be unique!')
]
def init(self):
@ -112,6 +86,16 @@ class Company(models.Model):
if hasattr(sup, 'init'):
sup.init()
def _get_company_root_delegated_field_names(self):
"""Get the set of fields delegated to the root company.
Some fields need to be identical on all branches of the company. All
fields listed by this function will be copied from the root company and
appear as readonly in the form view.
:rtype: set
"""
return ['currency_id']
def _get_company_address_field_names(self):
""" Return a list of fields coming from the address partner to match
on company address fields. Fields are labeled same on both models. """
@ -120,9 +104,14 @@ class Company(models.Model):
def _get_company_address_update(self, partner):
return dict((fname, partner[fname])
for fname in self._get_company_address_field_names())
# TODO @api.depends(): currently now way to formulate the dependency on the
# partner's contact address
@api.depends('parent_path')
def _compute_parent_ids(self):
for company in self.with_context(active_test=False):
company.parent_ids = self.browse(int(id) for id in company.parent_path.split('/') if id) if company.parent_path else company
company.root_id = company.parent_ids[0]
@api.depends(lambda self: [f'partner_id.{fname}' for fname in self._get_company_address_field_names()])
def _compute_address(self):
for company in self.filtered(lambda company: company.partner_id):
address_data = company.partner_id.sudo().address_get(adr_pref=['contact'])
@ -160,6 +149,21 @@ class Company(models.Model):
img = company.partner_id.image_1920
company.logo_web = img and base64.b64encode(tools.image_process(base64.b64decode(img), size=(180, 0)))
@api.depends('partner_id.image_1920')
def _compute_uses_default_logo(self):
default_logo = self._get_logo()
for company in self:
company.uses_default_logo = not company.logo or company.logo == default_logo
@api.depends('root_id')
def _compute_color(self):
for company in self:
company.color = company.root_id.partner_id.color or (company.root_id._origin.id % 12)
def _inverse_color(self):
for company in self:
company.root_id.partner_id.color = company.color
@api.onchange('state_id')
def _onchange_state(self):
if self.state_id.country_id:
@ -170,8 +174,30 @@ class Company(models.Model):
if self.country_id:
self.currency_id = self.country_id.currency_id
@api.onchange('parent_id')
def _onchange_parent_id(self):
if self.parent_id:
for fname in self._get_company_root_delegated_field_names():
if self[fname] != self.parent_id[fname]:
self[fname] = self.parent_id[fname]
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
def _get_view(self, view_id=None, view_type='form', **options):
def make_delegated_fields_readonly(node):
for child in node.iterchildren():
if child.tag == 'field' and child.get('name') in delegated_fnames:
child.set('readonly', "parent_id != False")
else:
make_delegated_fields_readonly(child)
return node
delegated_fnames = set(self._get_company_root_delegated_field_names())
arch, view = super()._get_view(view_id, view_type, **options)
arch = make_delegated_fields_readonly(arch)
return arch, view
@api.model
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
context = dict(self.env.context)
newself = self
if context.pop('user_preference', None):
@ -180,9 +206,10 @@ class Company(models.Model):
# which are probably to allow to see the child companies) even if
# she belongs to some other companies.
companies = self.env.user.company_ids
args = (args or []) + [('id', 'in', companies.ids)]
domain = (domain or []) + [('id', 'in', companies.ids)]
newself = newself.sudo()
return super(Company, newself.with_context(context))._name_search(name=name, args=args, operator=operator, limit=limit, name_get_uid=name_get_uid)
self = newself.with_context(context)
return super()._name_search(name, domain, operator, limit, order)
@api.model
@api.returns('self', lambda value: value.id)
@ -200,16 +227,8 @@ class Company(models.Model):
for record in self:
record.is_company_details_empty = not html2plaintext(record.company_details or '')
# deprecated, use clear_caches() instead
def cache_restart(self):
self.clear_caches()
@api.model_create_multi
def create(self, vals_list):
# add default favicon
for vals in vals_list:
if not vals.get('favicon'):
vals['favicon'] = self._get_default_favicon()
# create missing partners
no_partner_vals_list = [
@ -218,7 +237,7 @@ class Company(models.Model):
if vals.get('name') and not vals.get('partner_id')
]
if no_partner_vals_list:
partners = self.env['res.partner'].create([
partners = self.env['res.partner'].with_context(default_parent_id=False).create([
{
'name': vals['name'],
'is_company': True,
@ -236,12 +255,18 @@ class Company(models.Model):
for vals, partner in zip(no_partner_vals_list, partners):
vals['partner_id'] = partner.id
self.clear_caches()
for vals in vals_list:
# Copy delegated fields from root to branches
if parent := self.browse(vals.get('parent_id')):
for fname in self._get_company_root_delegated_field_names():
vals.setdefault(fname, self._fields[fname].convert_to_write(parent[fname], parent))
self.env.registry.clear_cache()
companies = super().create(vals_list)
# The write is made on the user to set it automatically in the multi company group.
if companies:
self.env.user.write({
(self.env.user | self.env['res.users'].browse(SUPERUSER_ID)).write({
'company_ids': [Command.link(company.id) for company in companies],
})
@ -250,9 +275,41 @@ class Company(models.Model):
return companies
def cache_invalidation_fields(self):
# This list is not well defined and tests should be improved
return {
'active', # user._get_company_ids and other potential cached search
'sequence', # user._get_company_ids and other potential cached search
}
@api.ondelete(at_uninstall=False)
def _unlink_if_company_has_no_children(self):
if any(company.child_ids for company in self):
raise UserError(_("Companies that have associated branches cannot be deleted. Consider archiving them instead."))
def unlink(self):
"""
Unlink the companies and clear the cache to make sure that
_get_company_ids of res.users gets only existing company ids.
"""
res = super().unlink()
self.env.registry.clear_cache()
return res
def write(self, values):
self.clear_caches()
# Make sure that the selected currency is enabled
invalidation_fields = self.cache_invalidation_fields()
asset_invalidation_fields = {'font', 'primary_color', 'secondary_color', 'external_report_layout_id'}
if not invalidation_fields.isdisjoint(values):
self.env.registry.clear_cache()
if not asset_invalidation_fields.isdisjoint(values):
# this is used in the content of an asset (see asset_styles_company_report)
# and thus needs to invalidate the assets cache when this is changed
self.env.registry.clear_cache('assets') # not 100% it is useful a test is missing if it is the case
if 'parent_id' in values:
raise UserError(_("The company hierarchy cannot be changed."))
if values.get('currency_id'):
currency = self.env['res.currency'].browse(values['currency_id'])
if not currency.active:
@ -260,6 +317,20 @@ class Company(models.Model):
res = super(Company, self).write(values)
# Archiving a company should also archive all of its branches
if values.get('active') is False:
self.child_ids.active = False
for company in self:
# Copy modified delegated fields from root to branches
if (changed := set(values) & set(self._get_company_root_delegated_field_names())) and not company.parent_id:
branches = self.sudo().search([
('id', 'child_of', company.id),
('id', '!=', company.id),
])
for fname in sorted(changed):
branches[fname] = company[fname]
# invalidate company cache to recompute address based on updated partner
company_address_fields = self._get_company_address_field_names()
company_address_fields_upd = set(company_address_fields) & set(values.keys())
@ -284,16 +355,22 @@ class Company(models.Model):
active_users=company_active_users,
))
@api.constrains('parent_id')
def _check_parent_id(self):
if not self._check_recursion():
raise ValidationError(_('You cannot create recursive companies.'))
@api.constrains(lambda self: self._get_company_root_delegated_field_names() +['parent_id'])
def _check_root_delegated_fields(self):
for company in self:
if company.parent_id:
for fname in company._get_company_root_delegated_field_names():
if company[fname] != company.parent_id[fname]:
description = self.env['ir.model.fields']._get("res.company", fname).field_description
raise ValidationError(_("The %s of a subsidiary must be the same as it's root company.", description))
def open_company_edit_report(self):
warnings.warn("Since 17.0.", DeprecationWarning, 2)
self.ensure_one()
return self.env['res.config.settings'].open_company()
def write_company_and_print_report(self):
warnings.warn("Since 17.0.", DeprecationWarning, 2)
context = self.env.context
report_name = context.get('default_report_name')
active_ids = context.get('active_ids')
@ -303,40 +380,6 @@ class Company(models.Model):
return (self.env['ir.actions.report'].search([('report_name', '=', report_name)], limit=1)
.report_action(docids))
@api.model
def action_open_base_onboarding_company(self):
""" Onboarding step for company basic information. """
action = self.env["ir.actions.actions"]._for_xml_id("base.action_open_base_onboarding_company")
action['res_id'] = self.env.company.id
return action
def set_onboarding_step_done(self, step_name):
if self[step_name] == 'not_done':
self[step_name] = 'just_done'
def _get_and_update_onboarding_state(self, onboarding_state, steps_states):
""" Needed to display onboarding animations only one time. """
old_values = {}
all_done = True
for step_state in steps_states:
old_values[step_state] = self[step_state]
if self[step_state] == 'just_done':
self[step_state] = 'done'
all_done = all_done and self[step_state] == 'done'
if all_done:
if self[onboarding_state] == 'not_done':
# string `onboarding_state` instead of variable name is not an error
old_values['onboarding_state'] = 'just_done'
else:
old_values['onboarding_state'] = 'done'
self[onboarding_state] = 'done'
return old_values
def action_save_onboarding_company_step(self):
if bool(self.street):
self.set_onboarding_step_done('base_onboarding_company_state')
@api.model
def _get_main_company(self):
try:
@ -345,3 +388,65 @@ class Company(models.Model):
main_company = self.env['res.company'].sudo().search([], limit=1, order="id")
return main_company
@ormcache('tuple(self.env.companies.ids)', 'self.id', 'self.env.uid')
def __accessible_branches(self):
# Get branches of this company that the current user can use
self.ensure_one()
accessible_branch_ids = []
accessible = self.env.companies
current = self.sudo()
while current:
accessible_branch_ids.extend((current & accessible).ids)
current = current.child_ids
if not accessible_branch_ids and self.env.uid == SUPERUSER_ID:
# Accessible companies will always be the same for super user when called in a cron.
# Because of that, the intersection between them and self might be empty. The super user anyway always has
# access to all companies (as it bypasses the record rules), so we return the current company in this case.
return self.ids
return accessible_branch_ids
def _accessible_branches(self):
return self.browse(self.__accessible_branches())
def _all_branches_selected(self):
"""Return whether or all the branches of the companies in self are selected.
Is ``True`` if all the branches, and only those, are selected.
Can be used when some actions only make sense for whole companies regardless of the
branches.
"""
return self == self.sudo().search([('id', 'child_of', self.root_id.ids)])
def action_all_company_branches(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Branches'),
'res_model': 'res.company',
'domain': [('parent_id', '=', self.id)],
'context': {
'active_test': False,
'default_parent_id': self.id,
},
'views': [[False, 'tree'], [False, 'kanban'], [False, 'form']],
}
def _get_public_user(self):
self.ensure_one()
# We need sudo to be able to see public users from others companies too
public_users = self.env.ref('base.group_public').sudo().with_context(active_test=False).users
public_users_for_company = public_users.filtered(lambda user: user.company_id == self)
if public_users_for_company:
return public_users_for_company[0]
else:
return self.env.ref('base.public_user').sudo().copy({
'name': 'Public user for %s' % self.name,
'login': 'public-user@company-%s.com' % self.id,
'company_id': self.id,
'company_ids': [(6, 0, [self.id])],
})

View file

@ -306,18 +306,7 @@ class ResConfigInstaller(models.TransientModel, ResConfigModuleInstallationMixin
IrModule = self.env['ir.module.module']
modules = IrModule.search([('name', 'in', to_install)])
module_names = {module.name for module in modules}
to_install_missing_names = [name for name in to_install if name not in module_names]
result = self._install_modules(modules)
#FIXME: if result is not none, the corresponding todo will be skipped because it was just marked done
if to_install_missing_names:
return {
'type': 'ir.actions.client',
'tag': 'apps',
'params': {'modules': to_install_missing_names},
}
return result
return self._install_modules(modules)
class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin):
@ -346,7 +335,7 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin)
The attribute 'group' may contain several xml ids, separated by commas.
* For a selection field like 'group_XXX' composed of 2 string values ('0' and '1'),
``execute`` adds/removes 'implied_group' to/from the implied groups of 'group',
``execute`` adds/removes 'implied_group' to/from the implied groups of 'group',
depending on the field's value.
By default 'group' is the group Employee. Groups are given by their xml id.
The attribute 'group' may contain several xml ids, separated by commas.
@ -354,8 +343,8 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin)
* For a boolean field like 'module_XXX', ``execute`` triggers the immediate
installation of the module named 'XXX' if the field has value ``True``.
* For a selection field like 'module_XXX' composed of 2 string values ('0' and '1'),
``execute`` triggers the immediate installation of the module named 'XXX'
* For a selection field like 'module_XXX' composed of 2 string values ('0' and '1'),
``execute`` triggers the immediate installation of the module named 'XXX'
if the field has the value ``'1'``.
* For a field with no specific prefix BUT an attribute 'config_parameter',
@ -466,15 +455,17 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin)
@api.model
def default_get(self, fields):
res = super().default_get(fields)
if not fields:
return res
IrDefault = self.env['ir.default']
IrConfigParameter = self.env['ir.config_parameter'].sudo()
classified = self._get_classified_fields(fields)
res = super(ResConfigSettings, self).default_get(fields)
# defaults: take the corresponding default value they set
for name, model, field in classified['default']:
value = IrDefault.get(model, field)
value = IrDefault._get(model, field)
if value is not None:
res[name] = value
@ -544,16 +535,15 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin)
IrDefault.set(model, field, value)
# group fields: modify group / implied groups
with self.env.norecompute():
for name, groups, implied_group in sorted(classified['group'], key=lambda k: self[k[0]]):
groups = groups.sudo()
implied_group = implied_group.sudo()
if self[name] == current_settings[name]:
continue
if int(self[name]):
groups._apply_group(implied_group)
else:
groups._remove_group(implied_group)
for name, groups, implied_group in sorted(classified['group'], key=lambda k: self[k[0]]):
groups = groups.sudo()
implied_group = implied_group.sudo()
if self[name] == current_settings[name]:
continue
if int(self[name]):
groups._apply_group(implied_group)
else:
groups._remove_group(implied_group)
# config fields: store ir.config_parameters
IrConfigParameter = self.env['ir.config_parameter'].sudo()
@ -638,12 +628,11 @@ class ResConfigSettings(models.TransientModel, ResConfigModuleInstallationMixin)
return actions.read()[0]
return {}
def name_get(self):
""" Override name_get method to return an appropriate configuration wizard
def _compute_display_name(self):
""" Override display_name method to return an appropriate configuration wizard
name, and not the generated name."""
action = self.env['ir.actions.act_window'].search([('res_model', '=', self._name)], limit=1)
name = action.name or self._name
return [(record.id, name) for record in self]
self.display_name = action.name or self._name
@api.model
def get_option_path(self, menu_xml_id):

View file

@ -38,6 +38,7 @@ class Country(models.Model):
string='Country Name', required=True, translate=True)
code = fields.Char(
string='Country Code', size=2,
required=True,
help='The ISO country code in two chars. \nYou can use this field for quick search.')
address_format = fields.Text(string="Layout in Reports",
help="Display format to use for addresses belonging to this country.\n\n"
@ -76,23 +77,23 @@ class Country(models.Model):
_sql_constraints = [
('name_uniq', 'unique (name)',
'The name of the country must be unique !'),
'The name of the country must be unique!'),
('code_uniq', 'unique (code)',
'The code of the country must be unique !')
'The code of the country must be unique!')
]
def _name_search(self, name='', args=None, operator='ilike', limit=100, name_get_uid=None):
if args is None:
args = []
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
if domain is None:
domain = []
ids = []
if len(name) == 2:
ids = list(self._search([('code', 'ilike', name)] + args, limit=limit))
ids = list(self._search([('code', 'ilike', name)] + domain, limit=limit, order=order))
search_domain = [('name', operator, name)]
if ids:
search_domain.append(('id', 'not in', ids))
ids += list(self._search(search_domain + args, limit=limit))
ids += list(self._search(search_domain + domain, limit=limit, order=order))
return ids
@ -114,11 +115,11 @@ class Country(models.Model):
res = super().write(vals)
if ('code' in vals or 'phone_code' in vals):
# Intentionally simplified by not clearing the cache in create and unlink.
self.clear_caches()
self.env.registry.clear_cache()
if 'address_view_id' in vals:
# Changing the address view of the company must invalidate the view cached for res.partner
# because of _view_get_address
self.env['res.partner'].clear_caches()
self.env.registry.clear_cache('templates')
return res
def get_address_fields(self):
@ -144,12 +145,6 @@ class Country(models.Model):
except (ValueError, KeyError):
raise UserError(_('The layout contains an invalid format key'))
@api.constrains('code')
def _check_country_code(self):
for record in self:
if not record.code:
raise UserError(_('Country code cannot be empty'))
class CountryGroup(models.Model):
_description = "Country Group"
_name = 'res.country.group'
@ -170,37 +165,42 @@ class CountryState(models.Model):
code = fields.Char(string='State Code', help='The state code.', required=True)
_sql_constraints = [
('name_code_uniq', 'unique(country_id, code)', 'The code of the state must be unique by country !')
('name_code_uniq', 'unique(country_id, code)', 'The code of the state must be unique by country!')
]
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
args = args or []
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
domain = domain or []
if self.env.context.get('country_id'):
args = expression.AND([args, [('country_id', '=', self.env.context.get('country_id'))]])
domain = expression.AND([domain, [('country_id', '=', self.env.context.get('country_id'))]])
if operator == 'ilike' and not (name or '').strip():
first_domain = []
domain = []
domain1 = []
domain2 = []
else:
first_domain = [('code', '=ilike', name)]
domain = [('name', operator, name)]
domain1 = [('code', '=ilike', name)]
domain2 = [('name', operator, name)]
first_state_ids = []
if domain1:
first_state_ids = list(self._search(
expression.AND([domain1, domain]), limit=limit, order=order,
))
fallback_domain = None
if name and operator in ['ilike', '=']:
fallback_domain = self._get_name_search_domain(name, operator)
if name and operator in ['in', 'any']:
fallback_domain = expression.OR([self._get_name_search_domain(n, '=') for n in name])
first_state_ids = self._search(expression.AND([first_domain, args]), limit=limit, access_rights_uid=name_get_uid) if first_domain else []
return list(first_state_ids) + [
return first_state_ids + [
state_id
for state_id in self._search(expression.AND([domain, args]),
limit=limit, access_rights_uid=name_get_uid)
for state_id in self._search(expression.AND([domain2, domain]),
limit=limit, order=order)
if state_id not in first_state_ids
] or (
list(self._search(expression.AND([fallback_domain, args]), limit=limit))
list(self._search(expression.AND([fallback_domain, domain]), limit=limit))
if fallback_domain
else []
)
@ -215,9 +215,7 @@ class CountryState(models.Model):
]
return None
def name_get(self):
result = []
@api.depends('country_id')
def _compute_display_name(self):
for record in self:
result.append((record.id, "{} ({})".format(record.name, record.country_id.code)))
return result
record.display_name = f"{record.name} ({record.country_id.code})"

View file

@ -7,8 +7,8 @@ import math
from lxml import etree
from odoo import api, fields, models, tools, _
from odoo.exceptions import UserError
from odoo.tools import parse_date
from odoo.exceptions import UserError, ValidationError
from odoo.tools import parse_date, SQL
_logger = logging.getLogger(__name__)
@ -56,15 +56,25 @@ class Currency(models.Model):
def create(self, vals_list):
res = super().create(vals_list)
self._toggle_group_multi_currency()
# Currency info is cached to reduce the number of SQL queries when building the session
# info. See `ir_http.get_currencies`.
self.env.registry.clear_cache()
return res
def unlink(self):
res = super().unlink()
self._toggle_group_multi_currency()
# Currency info is cached to reduce the number of SQL queries when building the session
# info. See `ir_http.get_currencies`.
self.env.registry.clear_cache()
return res
def write(self, vals):
res = super().write(vals)
if vals.keys() & {'active', 'digits', 'position', 'symbol'}:
# Currency info is cached to reduce the number of SQL queries when building the session
# info. See `ir_http.get_currencies`.
self.env.registry.clear_cache()
if 'active' not in vals:
return res
self._toggle_group_multi_currency()
@ -111,7 +121,8 @@ class Currency(models.Model):
if not self.ids:
return {}
self.env['res.currency.rate'].flush_model(['rate', 'currency_id', 'company_id', 'name'])
query = """
query = SQL(
"""
SELECT c.id,
COALESCE(
( -- take the first rate before the given date
@ -135,12 +146,12 @@ class Currency(models.Model):
) AS rate
FROM res_currency c
WHERE c.id IN %(currency_ids)s
"""
self._cr.execute(query, {
'date': date,
'company_id': company.id,
'currency_ids': tuple(self.ids),
})
""",
date=date,
company_id=company.root_id.id,
currency_ids=tuple(self.ids),
)
self._cr.execute(query)
currency_rates = dict(self._cr.fetchall())
return currency_rates
@ -150,17 +161,18 @@ class Currency(models.Model):
currency.is_current_company_currency = self.env.company.currency_id == currency
@api.depends('rate_ids.rate')
@api.depends_context('to_currency', 'date', 'company', 'company_id')
def _compute_current_rate(self):
date = self._context.get('date') or fields.Date.context_today(self)
company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company
to_currency = self.browse(self.env.context.get('to_currency')) or company.currency_id
# the subquery selects the last rate before 'date' for the given currency/company
currency_rates = self._get_rates(company, date)
last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(company)
currency_rates = (self + to_currency)._get_rates(self.env.company, date)
for currency in self:
currency.rate = (currency_rates.get(currency.id) or 1.0) / last_rate[company]
currency.rate = (currency_rates.get(currency.id) or 1.0) / currency_rates.get(to_currency.id)
currency.inverse_rate = 1 / currency.rate
if currency != company.currency_id:
currency.rate_string = '1 %s = %.6f %s' % (company.currency_id.name, currency.rate, currency.name)
currency.rate_string = '1 %s = %.6f %s' % (to_currency.name, currency.rate, currency.name)
else:
currency.rate_string = ''
@ -177,9 +189,6 @@ class Currency(models.Model):
for currency in self:
currency.date = currency.rate_ids[:1].name
def name_get(self):
return [(currency.id, tools.ustr(currency.name)) for currency in self]
def amount_to_text(self, amount):
self.ensure_one()
def _num2words(number, lang):
@ -268,12 +277,14 @@ class Currency(models.Model):
return tools.float_is_zero(amount, precision_rounding=self.rounding)
@api.model
def _get_conversion_rate(self, from_currency, to_currency, company, date):
currency_rates = (from_currency + to_currency)._get_rates(company, date)
res = currency_rates.get(to_currency.id) / currency_rates.get(from_currency.id)
return res
def _get_conversion_rate(self, from_currency, to_currency, company=None, date=None):
if from_currency == to_currency:
return 1
company = company or self.env.company
date = date or fields.Date.context_today(self)
return from_currency.with_company(company).with_context(to_currency=to_currency.id, date=str(date)).inverse_rate
def _convert(self, from_amount, to_currency, company, date, round=True):
def _convert(self, from_amount, to_currency, company=None, date=None, round=True): # noqa: A002 builtin-argument-shadowing
"""Returns the converted amount of ``from_amount``` from the currency
``self`` to the currency ``to_currency`` for the given ``date`` and
company.
@ -285,12 +296,8 @@ class Currency(models.Model):
self, to_currency = self or to_currency, to_currency or self
assert self, "convert amount from unknown currency"
assert to_currency, "convert amount to unknown currency"
assert company, "convert amount from unknown company"
assert date, "convert amount from unknown date"
# apply conversion rate
if self == to_currency:
to_amount = from_amount
elif from_amount:
if from_amount:
to_amount = from_amount * self._get_conversion_rate(self, to_currency, company, date)
else:
return 0.0
@ -298,19 +305,6 @@ class Currency(models.Model):
# apply rounding
return to_currency.round(to_amount) if round else to_amount
@api.model
def _compute(self, from_currency, to_currency, from_amount, round=True):
_logger.warning('The `_compute` method is deprecated. Use `_convert` instead')
date = self._context.get('date') or fields.Date.today()
company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company
return from_currency._convert(from_amount, to_currency, company, date)
def compute(self, from_amount, to_currency, round=True):
_logger.warning('The `compute` method is deprecated. Use `_convert` instead')
date = self._context.get('date') or fields.Date.today()
company = self.env['res.company'].browse(self._context.get('company_id')) or self.env.company
return self._convert(from_amount, to_currency, company, date)
def _select_companies_rates(self):
return """
SELECT
@ -340,11 +334,15 @@ class Currency(models.Model):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type in ('tree', 'form'):
currency_name = (self.env['res.company'].browse(self._context.get('company_id')) or self.env.company).currency_id.name
for field in [['company_rate', _('Unit per %s', currency_name)],
['inverse_company_rate', _('%s per Unit', currency_name)]]:
node = arch.xpath("//tree//field[@name='%s']" % field[0])
fields_maps = [
[['company_rate', 'rate'], _('Unit per %s', currency_name)],
[['inverse_company_rate', 'inverse_rate'], _('%s per Unit', currency_name)],
]
for fnames, label in fields_maps:
xpath_expression = '//tree//field[' + " or ".join(f"@name='{f}'" for f in fnames) + "][1]"
node = arch.xpath(xpath_expression)
if node:
node[0].set('string', field[1])
node[0].set('string', label)
return arch, view
@ -353,6 +351,7 @@ class CurrencyRate(models.Model):
_description = "Currency Rate"
_rec_names_search = ['name', 'rate']
_order = "name desc"
_check_company_domain = models.check_company_domain_parent_of
name = fields.Date(string='Date', required=True, index=True,
default=fields.Date.context_today)
@ -378,7 +377,7 @@ class CurrencyRate(models.Model):
)
currency_id = fields.Many2one('res.currency', string='Currency', readonly=True, required=True, ondelete="cascade")
company_id = fields.Many2one('res.company', string='Company',
default=lambda self: self.env.company)
default=lambda self: self.env.company.root_id)
_sql_constraints = [
('unique_name_per_day', 'unique (name,currency_id,company_id)', 'Only one currency rate per day allowed!'),
@ -393,10 +392,12 @@ class CurrencyRate(models.Model):
return vals
def write(self, vals):
self.env['res.currency'].invalidate_model(['inverse_rate'])
return super().write(self._sanitize_vals(vals))
@api.model_create_multi
def create(self, vals_list):
self.env['res.currency'].invalidate_model(['inverse_rate'])
return super().create([self._sanitize_vals(vals) for vals in vals_list])
def _get_latest_rate(self):
@ -405,13 +406,13 @@ class CurrencyRate(models.Model):
raise UserError(_("The name for the current rate is empty.\nPlease set it."))
return self.currency_id.rate_ids.sudo().filtered(lambda x: (
x.rate
and x.company_id == (self.company_id or self.env.company)
and x.company_id == (self.company_id or self.env.company.root_id)
and x.name < (self.name or fields.Date.today())
)).sorted('name')[-1:]
def _get_last_rates_for_companies(self, companies):
return {
company: company.currency_id.rate_ids.sudo().filtered(lambda x: (
company: company.sudo().currency_id.rate_ids.filtered(lambda x: (
x.rate
and x.company_id == company or not x.company_id
)).sorted('name')[-1:].rate or 1
@ -426,16 +427,16 @@ class CurrencyRate(models.Model):
@api.depends('rate', 'name', 'currency_id', 'company_id', 'currency_id.rate_ids.rate')
@api.depends_context('company')
def _compute_company_rate(self):
last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(self.company_id | self.env.company)
last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(self.company_id | self.env.company.root_id)
for currency_rate in self:
company = currency_rate.company_id or self.env.company
company = currency_rate.company_id or self.env.company.root_id
currency_rate.company_rate = (currency_rate.rate or currency_rate._get_latest_rate().rate or 1.0) / last_rate[company]
@api.onchange('company_rate')
def _inverse_company_rate(self):
last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(self.company_id | self.env.company)
last_rate = self.env['res.currency.rate']._get_last_rates_for_companies(self.company_id | self.env.company.root_id)
for currency_rate in self:
company = currency_rate.company_id or self.env.company
company = currency_rate.company_id or self.env.company.root_id
currency_rate.rate = currency_rate.company_rate * last_rate[company]
@api.depends('company_rate')
@ -463,14 +464,20 @@ class CurrencyRate(models.Model):
'title': _("Warning for %s", self.currency_id.name),
'message': _(
"The new rate is quite far from the previous rate.\n"
"Incorrect currency rates may cause critical problems, make sure the rate is correct !"
"Incorrect currency rates may cause critical problems, make sure the rate is correct!"
)
}
}
@api.constrains('company_id')
def _check_company_id(self):
for rate in self:
if rate.company_id.sudo().parent_id:
raise ValidationError("Currency rates should only be created for main companies")
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
return super()._name_search(parse_date(self.env, name), args, operator, limit, name_get_uid)
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
return super()._name_search(parse_date(self.env, name), domain, operator, limit, order)
@api.model
def _get_view_cache_key(self, view_id=None, view_type='form', **options):

View file

@ -21,6 +21,7 @@ class Lang(models.Model):
_name = "res.lang"
_description = "Languages"
_order = "active desc,name"
_allow_sudo_commands = False
_disallowed_datetime_patterns = list(tools.DATETIME_FORMATS_MAP)
_disallowed_datetime_patterns.remove('%y') # this one is in fact allowed, just not good practice
@ -60,9 +61,9 @@ class Lang(models.Model):
flag_image_url = fields.Char(compute=_compute_field_flag_image_url)
_sql_constraints = [
('name_uniq', 'unique(name)', 'The name of the language must be unique !'),
('code_uniq', 'unique(code)', 'The code of the language must be unique !'),
('url_code_uniq', 'unique(url_code)', 'The URL code of the language must be unique !'),
('name_uniq', 'unique(name)', 'The name of the language must be unique!'),
('code_uniq', 'unique(code)', 'The code of the language must be unique!'),
('url_code_uniq', 'unique(url_code)', 'The URL code of the language must be unique!'),
]
@api.constrains('active')
@ -116,12 +117,6 @@ class Lang(models.Model):
if not self.search_count([]):
_logger.error("No language is active.")
# TODO remove me after v14
def load_lang(self, lang, lang_name=None):
_logger.warning("Call to deprecated method load_lang, use _create_lang or _activate_lang instead")
language = self._activate_lang(lang) or self._create_lang(lang, lang_name)
return language.id
def _activate_lang(self, code):
""" Activate languages
:param code: code of the language to activate
@ -205,7 +200,7 @@ class Lang(models.Model):
lang_code = (tools.config.get('load_language') or 'en_US').split(',')[0]
lang = self._activate_lang(lang_code) or self._create_lang(lang_code)
IrDefault = self.env['ir.default']
default_value = IrDefault.get('res.partner', 'lang')
default_value = IrDefault._get('res.partner', 'lang')
if default_value is None:
IrDefault.set('res.partner', 'lang', lang_code)
# set language of main company, created directly by db bootstrap SQL
@ -287,7 +282,7 @@ class Lang(models.Model):
@api.model_create_multi
def create(self, vals_list):
self.clear_caches()
self.env.registry.clear_cache()
for vals in vals_list:
if not vals.get('url_code'):
vals['url_code'] = vals.get('iso_code') or vals['code']
@ -309,7 +304,7 @@ class Lang(models.Model):
res = super(Lang, self).write(vals)
self.env.flush_all()
self.clear_caches()
self.env.registry.clear_cache()
return res
@api.ondelete(at_uninstall=True)
@ -324,7 +319,7 @@ class Lang(models.Model):
raise UserError(_("You cannot delete the language which is Active!\nPlease de-activate the language first."))
def unlink(self):
self.clear_caches()
self.env.registry.clear_cache()
return super(Lang, self).unlink()
def copy_data(self, default=None):

View file

@ -46,6 +46,15 @@ class FormatAddressMixin(models.AbstractModel):
_name = "format.address.mixin"
_description = 'Address Format'
def _extract_fields_from_address(self, address_line):
"""
Extract keys from the address line.
For example, if the address line is "zip: %(zip)s, city: %(city)s.",
this method will return ['zip', 'city'].
"""
address_fields = ['%(' + field + ')s' for field in ADDRESS_FIELDS + ('state_code', 'state_name')]
return sorted([field[2:-2] for field in address_fields if field in address_line], key=address_line.index)
def _view_get_address(self, arch):
# consider the country of the user, not the country of the partner we want to display
address_view_id = self.env.company.country_id.address_view_id.sudo()
@ -69,12 +78,13 @@ class FormatAddressMixin(models.AbstractModel):
elif address_format and not self._context.get('no_address_format'):
# For the zip, city and state fields we need to move them around in order to follow the country address format.
# The purpose of this is to help the user by following a format he is used to.
city_line = [line.split(' ') for line in address_format.split('\n') if 'city' in line]
city_line = [self._extract_fields_from_address(line) for line in address_format.split('\n') if 'city' in line]
if city_line:
field_order = [field.replace('%(', '').replace(')s', '') for field in city_line[0]]
field_order = city_line[0]
for address_node in arch.xpath("//div[hasclass('o_address_format')]"):
concerned_fields = {'zip', 'city', 'state_id'} - {field_order[0]}
current_field = address_node.find(f".//field[@name='{field_order[0]}']")
first_field = field_order[0] if field_order[0] not in ('state_code', 'state_name') else 'state_id'
concerned_fields = {'zip', 'city', 'state_id'} - {first_field}
current_field = address_node.find(f".//field[@name='{first_field}']")
# First loop into the fields displayed in the address_format, and order them.
for field in field_order[1:]:
if field in ('state_code', 'state_name'):
@ -132,35 +142,27 @@ class PartnerCategory(models.Model):
if not self._check_recursion():
raise ValidationError(_('You can not create recursive tags.'))
def name_get(self):
@api.depends('parent_id')
def _compute_display_name(self):
""" Return the categories' display name, including their direct
parent by default.
If ``context['partner_category_display']`` is ``'short'``, the short
version of the category name (without the direct parent) is used.
The default is the long version.
"""
if self._context.get('partner_category_display') == 'short':
return super(PartnerCategory, self).name_get()
res = []
for category in self:
names = []
current = category
while current:
names.append(current.name)
names.append(current.name or "")
current = current.parent_id
res.append((category.id, ' / '.join(reversed(names))))
return res
category.display_name = ' / '.join(reversed(names))
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
args = args or []
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
domain = domain or []
if name:
# Be sure name_search is symetric to name_get
# Be sure name_search is symetric to display_name
name = name.split(' / ')[-1]
args = [('name', operator, name)] + args
return self._search(args, limit=limit, access_rights_uid=name_get_uid)
domain = [('name', operator, name)] + domain
return self._search(domain, limit=limit, order=order)
class PartnerTitle(models.Model):
@ -176,9 +178,13 @@ class Partner(models.Model):
_description = 'Contact'
_inherit = ['format.address.mixin', 'avatar.mixin']
_name = "res.partner"
_order = "display_name ASC, id DESC"
_order = "complete_name ASC, id DESC"
_rec_names_search = ['complete_name', 'email', 'ref', 'vat', 'company_registry'] # TODO vat must be sanitized the same way for storing/searching
_allow_sudo_commands = False
_rec_names_search = ['display_name', 'email', 'ref', 'vat', 'company_registry'] # TODO vat must be sanitized the same way for storing/searching
_check_company_domain = models.check_company_domain_parent_of
# the partner types that must be added to a partner's complete name, like "Delivery"
_complete_name_displayed_types = ('invoice', 'delivery', 'other')
def _default_category(self):
return self.env['res.partner.category'].browse(self._context.get('category_id'))
@ -201,13 +207,12 @@ class Partner(models.Model):
return values
name = fields.Char(index=True, default_export_compatible=True)
display_name = fields.Char(compute='_compute_display_name', recursive=True, store=True, index=True)
translated_display_name = fields.Char(compute='_compute_translated_display_name')
complete_name = fields.Char(compute='_compute_complete_name', store=True, index=True)
date = fields.Date(index=True)
title = fields.Many2one('res.partner.title')
parent_id = fields.Many2one('res.partner', string='Related Company', index=True)
parent_name = fields.Char(related='parent_id.name', readonly=True, string='Parent name')
child_ids = fields.One2many('res.partner', 'parent_id', string='Contact', domain=[('active', '=', True)]) # force "active_test" domain to bypass _search() override
child_ids = fields.One2many('res.partner', 'parent_id', string='Contact', domain=[('active', '=', True)])
ref = fields.Char(string='Reference', index=True)
lang = fields.Selection(_lang_get, string='Language',
help="All the emails and documents sent to this contact will be translated in this language.")
@ -217,7 +222,7 @@ class Partner(models.Model):
"If the timezone is not set, UTC (Coordinated Universal Time) is used.\n"
"Anywhere else, time values are computed according to the time offset of your web client.")
tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset', invisible=True)
tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset')
user_id = fields.Many2one(
'res.users', string='Salesperson',
compute='_compute_user_id',
@ -242,14 +247,12 @@ class Partner(models.Model):
[('contact', 'Contact'),
('invoice', 'Invoice Address'),
('delivery', 'Delivery Address'),
('private', 'Private Address'),
('other', 'Other Address'),
], string='Address Type',
default='contact',
help="- Contact: Use this to organize the contact details of employees of a given company (e.g. CEO, CFO, ...).\n"
"- Invoice Address : Preferred address for all invoices. Selected by default when you invoice an order that belongs to this company.\n"
"- Delivery Address : Preferred address for all deliveries. Selected by default when you deliver an order that belongs to this company.\n"
"- Private: Private addresses are only visible by authorized users and contain sensitive data (employee home addresses, ...).\n"
"- Invoice Address: Preferred address for all invoices. Selected by default when you invoice an order that belongs to this company.\n"
"- Delivery Address: Preferred address for all deliveries. Selected by default when you deliver an order that belongs to this company.\n"
"- Other: Other address for the company (e.g. subsidiary, ...)")
# address fields
street = fields.Char()
@ -345,19 +348,24 @@ class Partner(models.Model):
return "base/static/img/money.png"
return super()._avatar_get_placeholder_path()
@api.depends('is_company', 'name', 'parent_id.display_name', 'type', 'company_name', 'commercial_company_name')
def _compute_display_name(self):
# retrieve name_get() without any fancy feature
names = dict(self.with_context({}).name_get())
for partner in self:
partner.display_name = names.get(partner.id)
def _get_complete_name(self):
self.ensure_one()
@api.depends_context('lang')
@api.depends('display_name')
def _compute_translated_display_name(self):
names = dict(self.with_context(lang=self.env.lang).name_get())
displayed_types = self._complete_name_displayed_types
type_description = dict(self._fields['type']._description_selection(self.env))
name = self.name or ''
if self.company_name or self.parent_id:
if not name and self.type in displayed_types:
name = type_description[self.type]
if not self.is_company:
name = f"{self.commercial_company_name or self.sudo().parent_id.name}, {name}"
return name.strip()
@api.depends('is_company', 'name', 'parent_id.name', 'type', 'company_name', 'commercial_company_name')
def _compute_complete_name(self):
for partner in self:
partner.translated_display_name = names.get(partner.id)
partner.complete_name = partner.with_context({})._get_complete_name()
@api.depends('lang')
def _compute_active_lang_count(self):
@ -441,8 +449,6 @@ class Partner(models.Model):
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
if (not view_id) and (view_type == 'form') and self._context.get('force_email'):
view_id = self.env.ref('base.view_partner_simple_form').id
arch, view = super()._get_view(view_id, view_type, **options)
company = self.env.company
if company.country_id.vat_label:
@ -501,7 +507,7 @@ class Partner(models.Model):
@api.onchange('state_id')
def _onchange_state(self):
if self.state_id.country_id:
if self.state_id.country_id and self.country_id != self.state_id.country_id:
self.country_id = self.state_id.country_id
@api.onchange('email')
@ -605,6 +611,13 @@ class Partner(models.Model):
extended by inheriting classes. """
return ['vat', 'company_registry', 'industry_id']
@api.model
def _company_dependent_commercial_fields(self):
return [
fname for fname in self._commercial_fields()
if self._fields[fname].company_dependent
]
def _commercial_sync_from_company(self):
""" Handle sync of commercial fields when a new parent commercial entity is set,
as if they were related fields """
@ -612,8 +625,30 @@ class Partner(models.Model):
if commercial_partner != self:
sync_vals = commercial_partner._update_fields_values(self._commercial_fields())
self.write(sync_vals)
self._company_dependent_commercial_sync()
self._commercial_sync_to_children()
def _company_dependent_commercial_sync(self):
company_dependent_commercial_field_ids = [
self.env['ir.model.fields']._get(self._name, fname).id
for fname in self._company_dependent_commercial_fields()
]
if company_dependent_commercial_field_ids:
parent_properties = self.env['ir.property'].search([
('fields_id', 'in', company_dependent_commercial_field_ids),
('res_id', '=', f'res.partner,{self.commercial_partner_id.id}'),
# value was already assigned for current company
('company_id', '!=', self.env.company.id),
])
# prevent duplicate keys by removing existing properties from the partner
self.env['ir.property'].search([
('fields_id', 'in', company_dependent_commercial_field_ids),
('res_id', '=', f'res.partner,{self.id}'),
('company_id', '!=', self.env.company.id),
]).unlink()
for prop in parent_properties:
prop.copy({'res_id': f'res.partner,{self.id}'})
def _commercial_sync_to_children(self):
""" Handle sync of commercial fields to descendants """
commercial_partner = self.commercial_partner_id
@ -849,69 +884,25 @@ class Partner(models.Model):
'target': 'new',
}
def _get_contact_name(self, partner, name):
return "%s, %s" % (partner.commercial_company_name or partner.sudo().parent_id.name, name)
def _get_name(self):
""" Utility method to allow name_get to be overrided without re-browse the partner """
partner = self
name = partner.name or ''
if partner.company_name or partner.parent_id:
if not name and partner.type in ['invoice', 'delivery', 'other']:
types = dict(self._fields['type']._description_selection(self.env))
name = types[partner.type]
if not partner.is_company:
name = self._get_contact_name(partner, name)
if self._context.get('show_address_only'):
name = partner._display_address(without_company=True)
if self._context.get('show_address'):
name = name + "\n" + partner._display_address(without_company=True)
name = re.sub(r'\s+\n', '\n', name)
if self._context.get('partner_show_db_id'):
name = "%s (%s)" % (name, partner.id)
if self._context.get('address_inline'):
splitted_names = name.split("\n")
name = ", ".join([n for n in splitted_names if n.strip()])
if self._context.get('show_email') and partner.email:
name = "%s <%s>" % (name, partner.email)
if self._context.get('html_format'):
name = name.replace('\n', '<br/>')
if self._context.get('show_vat') and partner.vat:
name = "%s %s" % (name, partner.vat)
return name.strip()
def name_get(self):
res = []
@api.depends('complete_name', 'email', 'vat', 'state_id', 'country_id', 'commercial_company_name')
@api.depends_context('show_address', 'partner_show_db_id', 'address_inline', 'show_email', 'show_vat', 'lang')
def _compute_display_name(self):
for partner in self:
name = partner._get_name()
res.append((partner.id, name))
return res
name = partner.with_context(lang=self.env.lang)._get_complete_name()
if partner._context.get('show_address'):
name = name + "\n" + partner._display_address(without_company=True)
name = re.sub(r'\s+\n', '\n', name)
if partner._context.get('partner_show_db_id'):
name = f"{name} ({partner.id})"
if partner._context.get('address_inline'):
splitted_names = name.split("\n")
name = ", ".join([n for n in splitted_names if n.strip()])
if partner._context.get('show_email') and partner.email:
name = f"{name} <{partner.email}>"
if partner._context.get('show_vat') and partner.vat:
name = f"{name} {partner.vat}"
def _parse_partner_name(self, text):
""" Parse partner name (given by text) in order to find a name and an
email. Supported syntax:
* Raoul <raoul@grosbedon.fr>
* "Raoul le Grand" <raoul@grosbedon.fr>
* Raoul raoul@grosbedon.fr (strange fault tolerant support from
df40926d2a57c101a3e2d221ecfd08fbb4fea30e now supported directly
in 'email_split_tuples';
Otherwise: default, everything is set as the name. Starting from 13.3
returned email will be normalized to have a coherent encoding.
"""
name, email = '', ''
split_results = tools.email_split_tuples(text)
if split_results:
name, email = split_results[0]
if email:
email = tools.email_normalize(email)
else:
name, email = text, ''
return name, email
partner.display_name = name.strip()
@api.model
def name_create(self, name):
@ -926,26 +917,15 @@ class Partner(models.Model):
context = dict(self._context)
context.pop('default_type')
self = self.with_context(context)
name, email = self._parse_partner_name(name)
if self._context.get('force_email') and not email:
name, email_normalized = tools.parse_contact_from_email(name)
if self._context.get('force_email') and not email_normalized:
raise ValidationError(_("Couldn't create contact without email address!"))
create_values = {self._rec_name: name or email}
if email: # keep default_email in context
create_values['email'] = email
create_values = {self._rec_name: name or email_normalized}
if email_normalized: # keep default_email in context
create_values['email'] = email_normalized
partner = self.create(create_values)
return partner.name_get()[0]
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
""" Override search() to always show inactive children when searching via ``child_of`` operator. The ORM will
always call search() with a simple domain of the form [('parent_id', 'in', [ids])]. """
# a special ``domain`` is set on the ``child_ids`` o2m to bypass this logic, as it uses similar domain expressions
if len(args) == 1 and len(args[0]) == 3 and args[0][:2] == ('parent_id','in') \
and args[0][2] != [False]:
self = self.with_context(active_test=False)
return super(Partner, self)._search(args, offset=offset, limit=limit, order=order,
count=count, access_rights_uid=access_rights_uid)
return partner.id, partner.display_name
@api.model
@api.returns('self', lambda value: value.id)
@ -961,17 +941,17 @@ class Partner(models.Model):
if not email:
raise ValueError(_('An email is required for find_or_create to work'))
parsed_name, parsed_email = self._parse_partner_name(email)
if not parsed_email and assert_valid_email:
parsed_name, parsed_email_normalized = tools.parse_contact_from_email(email)
if not parsed_email_normalized and assert_valid_email:
raise ValueError(_('A valid email is required for find_or_create to work properly.'))
partners = self.search([('email', '=ilike', parsed_email)], limit=1)
partners = self.search([('email', '=ilike', parsed_email_normalized)], limit=1)
if partners:
return partners
create_values = {self._rec_name: parsed_name or parsed_email}
if parsed_email: # keep default_email in context
create_values['email'] = parsed_email
create_values = {self._rec_name: parsed_name or parsed_email_normalized}
if parsed_email_normalized: # keep default_email in context
create_values['email'] = parsed_email_normalized
return self.create(create_values)
def _get_gravatar_image(self, email):
@ -1106,7 +1086,7 @@ class Partner(models.Model):
"""
States = self.env['res.country.state']
states_ids = {vals['state_id'] for vals in vals_list if vals.get('state_id')}
state_to_country = States.search([('id', 'in', list(states_ids))]).read(['country_id'])
state_to_country = States.search_read([('id', 'in', list(states_ids))], ['country_id'])
for vals in vals_list:
if vals.get('state_id'):
country_id = next(c['country_id'][0] for c in state_to_country if c['id'] == vals.get('state_id'))
@ -1120,6 +1100,15 @@ class Partner(models.Model):
def _get_country_name(self):
return self.country_id.name or ''
def _get_all_addr(self):
self.ensure_one()
return [{
'contact_type': self.street,
'street': self.street,
'zip': self.zip,
'city': self.city,
'country': self.country_id.code,
}]
class ResPartnerIndustry(models.Model):

View file

@ -154,7 +154,7 @@ def check_identity(fn):
w = self.sudo().env['res.users.identitycheck'].create({
'request': json.dumps([
{ # strip non-jsonable keys (e.g. mapped to recordsets like binary_field_real_user)
{ # strip non-jsonable keys (e.g. mapped to recordsets)
k: v for k, v in self.env.context.items()
if _jsonable(v)
},
@ -232,7 +232,7 @@ class Groups(models.Model):
where = []
for group in operand:
values = [v for v in group.split('/') if v]
group_name = values.pop().strip()
group_name = values.pop().strip() if values else ''
category_name = values and '/'.join(values).strip() or group_name
group_domain = [('name', operator, lst and [group_name] or group_name)]
category_ids = self.env['ir.module.category'].sudo()._search(
@ -251,14 +251,14 @@ class Groups(models.Model):
return where
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
def _search(self, domain, offset=0, limit=None, order=None, access_rights_uid=None):
# add explicit ordering if search is sorted on full_name
if order and order.startswith('full_name'):
groups = super(Groups, self).search(args)
groups = super().search(domain)
groups = groups.sorted('full_name', reverse=order.endswith('DESC'))
groups = groups[offset:offset+limit] if limit else groups[offset:]
return len(groups) if count else groups.ids
return super(Groups, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)
return groups._as_query(order)
return super()._search(domain, offset, limit, order, access_rights_uid)
def copy(self, default=None):
self.ensure_one()
@ -303,8 +303,8 @@ class ResUsersLog(models.Model):
_name = 'res.users.log'
_order = 'id desc'
_description = 'Users Log'
# Currenly only uses the magical fields: create_uid, create_date,
# for recording logins. To be extended for other uses (chat presence, etc.)
# Uses the magical fields `create_uid` and `create_date` for recording logins.
# See `bus.presence` for more recent activity tracking purposes.
@api.autovacuum
def _gc_user_logs(self):
@ -332,6 +332,11 @@ class Users(models.Model):
_order = 'name, login'
_allow_sudo_commands = False
def _check_company_domain(self, companies):
if not companies:
return []
return [('company_ids', 'in', models.to_company_ids(companies))]
@property
def SELF_READABLE_FIELDS(self):
""" The list of fields a user can read on their own user record.
@ -340,7 +345,7 @@ class Users(models.Model):
return [
'signature', 'company_id', 'login', 'email', 'name', 'image_1920',
'image_1024', 'image_512', 'image_256', 'image_128', 'lang', 'tz',
'tz_offset', 'groups_id', 'partner_id', '__last_update', 'action_id',
'tz_offset', 'groups_id', 'partner_id', 'write_date', 'action_id',
'avatar_1920', 'avatar_1024', 'avatar_512', 'avatar_256', 'avatar_128',
'share',
]
@ -364,8 +369,7 @@ class Users(models.Model):
string='Related Partner', help='Partner-related data of the user')
login = fields.Char(required=True, help="Used to log into the system")
password = fields.Char(
compute='_compute_password', inverse='_set_password',
invisible=True, copy=False,
compute='_compute_password', inverse='_set_password', copy=False,
help="Keep empty if you don't want the user to be able to connect on the system.")
new_password = fields.Char(string='Set Password',
compute='_compute_password', inverse='_set_new_password',
@ -383,7 +387,10 @@ class Users(models.Model):
share = fields.Boolean(compute='_compute_share', compute_sudo=True, string='Share User', store=True,
help="External user with limited access, created only for the purpose of sharing data.")
companies_count = fields.Integer(compute='_compute_companies_count', string="Number of Companies")
tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset', invisible=True)
tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset')
res_users_settings_ids = fields.One2many('res.users.settings', 'user_id')
# Provide a target for relateds that is not a x2Many field.
res_users_settings_id = fields.Many2one('res.users.settings', string="Settings", compute='_compute_res_users_settings_id', search='_search_res_users_settings_id')
# Special behavior for this field: res.company.search() will only return the companies
# available to the current user (should be the user's companies?), when the user_preference
@ -406,7 +413,7 @@ class Users(models.Model):
compute='_compute_accesses_count', compute_sudo=True)
_sql_constraints = [
('login_key', 'UNIQUE (login)', 'You can not have two users with the same login !')
('login_key', 'UNIQUE (login)', 'You can not have two users with the same login!')
]
def init(self):
@ -517,6 +524,15 @@ class Users(models.Model):
user.rules_count = len(groups.rule_groups)
user.groups_count = len(groups)
@api.depends('res_users_settings_ids')
def _compute_res_users_settings_id(self):
for user in self:
user.res_users_settings_id = user.res_users_settings_ids and user.res_users_settings_ids[0]
@api.model
def _search_res_users_settings_id(self, operator, operand):
return [('res_users_settings_ids', operator, operand)]
@api.onchange('login')
def on_change_login(self):
if self.login and tools.single_email_re.match(self.login):
@ -526,19 +542,14 @@ class Users(models.Model):
def onchange_parent_id(self):
return self.partner_id.onchange_parent_id()
def _read(self, fields):
super(Users, self)._read(fields)
if set(USER_PRIVATE_FIELDS).intersection(fields):
def _fetch_query(self, query, fields):
records = super()._fetch_query(query, fields)
if not set(USER_PRIVATE_FIELDS).isdisjoint(field.name for field in fields):
if self.check_access_rights('write', raise_exception=False):
return
for record in self:
for f in USER_PRIVATE_FIELDS:
try:
record._cache[f]
record._cache[f] = '********'
except Exception:
# skip SpecialValue (e.g. for missing record or access right)
pass
return records
for fname in USER_PRIVATE_FIELDS:
self.env.cache.update(records, self._fields[fname], repeat('********'))
return records
@api.constrains('company_id', 'company_ids', 'active')
def _check_company(self):
@ -619,33 +630,41 @@ class Users(models.Model):
user.partner_id.toggle_active()
super(Users, self).toggle_active()
def read(self, fields=None, load='_classic_read'):
if fields and self == self.env.user:
readable = self.SELF_READABLE_FIELDS
for key in fields:
if not (key in readable or key.startswith('context_')):
break
else:
# safe fields only, so we read as super-user to bypass access rights
self = self.sudo()
def onchange(self, values, field_names, fields_spec):
# Hacky fix to access fields in `SELF_READABLE_FIELDS` in the onchange logic.
# Put field values in the cache.
if self == self.env.user:
[self.sudo()[field_name] for field_name in self.SELF_READABLE_FIELDS]
return super().onchange(values, field_names, fields_spec)
def read(self, fields=None, load='_classic_read'):
readable = self.SELF_READABLE_FIELDS
if fields and self == self.env.user and all(key in readable or key.startswith('context_') for key in fields):
# safe fields only, so we read as super-user to bypass access rights
self = self.sudo()
return super(Users, self).read(fields=fields, load=load)
@api.model
def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True):
groupby_fields = set([groupby] if isinstance(groupby, str) else groupby)
if groupby_fields.intersection(USER_PRIVATE_FIELDS):
raise AccessError(_("Invalid 'group by' parameter"))
return super(Users, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy)
def check_field_access_rights(self, operation, field_names):
readable = self.SELF_READABLE_FIELDS
if field_names and self == self.env.user and all(key in readable or key.startswith('context_') for key in field_names):
# safe fields only, so we read as super-user to bypass access rights
self = self.sudo()
return super(Users, self).check_field_access_rights(operation, field_names)
@api.model
def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None):
if not self.env.su and args:
domain_fields = {term[0] for term in args if isinstance(term, (tuple, list))}
def _read_group_check_field_access_rights(self, field_names):
super()._read_group_check_field_access_rights(field_names)
if set(field_names).intersection(USER_PRIVATE_FIELDS):
raise AccessError(_("Invalid 'group by' parameter"))
@api.model
def _search(self, domain, offset=0, limit=None, order=None, access_rights_uid=None):
if not self.env.su and domain:
domain_fields = {term[0] for term in domain if isinstance(term, (tuple, list))}
if domain_fields.intersection(USER_PRIVATE_FIELDS):
raise AccessError(_('Invalid search criterion'))
return super(Users, self)._search(args, offset=offset, limit=limit, order=order, count=count,
access_rights_uid=access_rights_uid)
return super()._search(domain, offset, limit, order, access_rights_uid)
@api.model_create_multi
def create(self, vals_list):
@ -655,6 +674,10 @@ class Users(models.Model):
if user.partner_id.company_id:
user.partner_id.company_id = user.company_id
user.partner_id.active = user.active
# Generate employee initals as avatar for internal users without image
if not user.image_1920 and not user.share and user.name:
user.image_1920 = user.partner_id._avatar_generate_svg()
return users
def _apply_groups_to_existing_employees(self):
@ -686,7 +709,7 @@ class Users(models.Model):
if values['company_id'] not in self.env.user.company_ids.ids:
del values['company_id']
# safe fields only, so we write as super-user to bypass access rights
self = self.sudo().with_context(binary_field_real_user=self.env.user)
self = self.sudo()
old_groups = []
if 'groups_id' in values and self._apply_groups_to_existing_employees():
@ -711,13 +734,15 @@ class Users(models.Model):
user.partner_id.write({'company_id': user.company_id.id})
if 'company_id' in values or 'company_ids' in values:
# Reset lazy properties `company` & `companies` on all envs
# Reset lazy properties `company` & `companies` on all envs,
# and also their _cache_key, which may depend on them.
# This is unlikely in a business code to change the company of a user and then do business stuff
# but in case it happens this is handled.
# e.g. `account_test_savepoint.py` `setup_company_data`, triggered by `test_account_invoice_report.py`
for env in list(self.env.transaction.envs):
if env.user in self:
lazy_property.reset_all(env)
env._cache_key.clear()
# clear caches linked to the users
if self.ids and 'groups_id' in values:
@ -727,10 +752,10 @@ class Users(models.Model):
# per-method / per-model caches have been removed so the various
# clear_cache/clear_caches methods pretty much just end up calling
# Registry._clear_cache
# Registry.clear_cache
invalidation_fields = self._get_invalidation_fields()
if (invalidation_fields & values.keys()) or any(key.startswith('context_') for key in values):
self.clear_caches()
self.env.registry.clear_cache()
return res
@ -743,22 +768,22 @@ class Users(models.Model):
user_admin = self.env.ref('base.user_admin', raise_if_not_found=False)
if user_admin and user_admin in self:
raise UserError(_('You cannot delete the admin user because it is utilized in various places (such as security configurations,...). Instead, archive it.'))
self.clear_caches()
self.env.registry.clear_cache()
if (portal_user_template and portal_user_template in self) or (default_user_template and default_user_template in self):
raise UserError(_('Deleting the template users is not allowed. Deleting this profile will compromise critical functionalities.'))
@api.model
def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None):
args = args or []
def _name_search(self, name, domain=None, operator='ilike', limit=None, order=None):
domain = domain or []
user_ids = []
if operator not in expression.NEGATIVE_TERM_OPERATORS:
if operator == 'ilike' and not (name or '').strip():
domain = []
name_domain = []
else:
domain = [('login', '=', name)]
user_ids = self._search(expression.AND([domain, args]), limit=limit, access_rights_uid=name_get_uid)
name_domain = [('login', '=', name)]
user_ids = self._search(expression.AND([name_domain, domain]), limit=limit, order=order)
if not user_ids:
user_ids = self._search(expression.AND([[('name', operator, name)], args]), limit=limit, access_rights_uid=name_get_uid)
user_ids = self._search(expression.AND([[('name', operator, name)], domain]), limit=limit, order=order)
return user_ids
def copy(self, default=None):
@ -833,7 +858,7 @@ class Users(models.Model):
def _update_last_login(self):
# only create new records to avoid any side-effect on concurrent transactions
# extra records will be deleted by the periodical garbage collection
self.env['res.users.log'].create({}) # populated by defaults
self.env['res.users.log'].sudo().create({}) # populated by defaults
@api.model
def _get_login_domain(self, login):
@ -929,7 +954,7 @@ class Users(models.Model):
FROM res_users
WHERE id=%%s""" % (session_fields), (self.id,))
if self.env.cr.rowcount != 1:
self.clear_caches()
self.env.registry.clear_cache()
return False
data_fields = self.env.cr.fetchone()
# generate hmac key
@ -1045,6 +1070,18 @@ class Users(models.Model):
'view_mode': 'form',
}
def action_revoke_all_devices(self):
ctx = dict(self.env.context, dialog_size='medium')
return {
'name': _('Log out from all devices?'),
'type': 'ir.actions.act_window',
'target': 'new',
'res_model': 'res.users.identitycheck',
'view_mode': 'form',
'view_id': self.env.ref('base.res_users_identitycheck_view_form_revokedevices').id,
'context': ctx,
}
@api.model
def has_group(self, group_ext_id):
# use singleton's id if called on a non-empty recordset, otherwise
@ -1135,6 +1172,10 @@ class Users(models.Model):
self.ensure_one()
return not self.sudo().share
def _is_portal(self):
self.ensure_one()
return self.has_group('base.group_portal')
def _is_public(self):
self.ensure_one()
return self.has_group('base.group_public')
@ -1231,7 +1272,7 @@ class Users(models.Model):
"and *might* be a proxy. If your Odoo is behind a proxy, "
"it may be mis-configured. Check that you are running "
"Odoo in Proxy Mode and that the proxy is properly configured, see "
"https://www.odoo.com/documentation/16.0/administration/install/deploy.html#https for details.",
"https://www.odoo.com/documentation/17.0/administration/install/deploy.html#https for details.",
source
)
raise AccessDenied(_("Too many login failures, please wait a bit before trying again."))
@ -1282,6 +1323,14 @@ class Users(models.Model):
def _mfa_url(self):
""" If an MFA method is enabled, returns the URL for its second step. """
return
def _should_alert_new_device(self):
""" Determine if an alert should be sent to the user regarding a new device
To be overriden in 2FA modules implementing known devices
"""
return False
#
# Implied groups
#
@ -1404,12 +1453,15 @@ class UsersImplied(models.Model):
if not values.get('groups_id'):
return super(UsersImplied, self).write(values)
users_before = self.filtered(lambda u: u._is_internal())
res = super(UsersImplied, self).write(values)
res = super(UsersImplied, self.with_context(no_add_implied_groups=True)).write(values)
demoted_users = users_before.filtered(lambda u: not u._is_internal())
if demoted_users:
# demoted users are restricted to the assigned groups only
vals = {'groups_id': [Command.clear()] + values['groups_id']}
super(UsersImplied, demoted_users).write(vals)
if self.env.context.get('no_add_implied_groups'):
# in a recursive write, defer adding implied groups to the base call
return res
# add implied groups for all users (in batches)
users_batch = defaultdict(self.browse)
for user in self:
@ -1450,7 +1502,7 @@ class GroupsView(models.Model):
groups = super().create(vals_list)
self._update_user_groups_view()
# actions.get_bindings() depends on action records
self.env['ir.actions.actions'].clear_caches()
self.env.registry.clear_cache()
return groups
def write(self, values):
@ -1463,14 +1515,14 @@ class GroupsView(models.Model):
if view_values0 != view_values1:
self._update_user_groups_view()
# actions.get_bindings() depends on action records
self.env['ir.actions.actions'].clear_caches()
self.env.registry.clear_cache()
return res
def unlink(self):
res = super(GroupsView, self).unlink()
self._update_user_groups_view()
# actions.get_bindings() depends on action records
self.env['ir.actions.actions'].clear_caches()
self.env.registry.clear_cache()
return res
def _get_hidden_extra_categories(self):
@ -1522,7 +1574,7 @@ class GroupsView(models.Model):
# and is therefore removed when not in debug mode.
xml0.append(E.field(name=field_name, invisible="1", on_change="1"))
user_type_field_name = field_name
user_type_readonly = str({'readonly': [(user_type_field_name, '!=', group_employee.id)]})
user_type_readonly = f'{user_type_field_name} != {group_employee.id}'
attrs['widget'] = 'radio'
# Trigger the on_change of this "virtual field"
attrs['on_change'] = '1'
@ -1532,7 +1584,7 @@ class GroupsView(models.Model):
elif kind == 'selection':
# application name with a selection field
field_name = name_selection_groups(gs.ids)
attrs['attrs'] = user_type_readonly
attrs['readonly'] = user_type_readonly
attrs['on_change'] = '1'
if category_name not in xml_by_category:
xml_by_category[category_name] = []
@ -1548,7 +1600,7 @@ class GroupsView(models.Model):
app_name = app.name or 'Other'
xml4.append(E.separator(string=app_name, **attrs))
left_group, right_group = [], []
attrs['attrs'] = user_type_readonly
attrs['readonly'] = user_type_readonly
# we can't use enumerate, as we sometime skip groups
group_count = 0
for g in gs:
@ -1566,10 +1618,7 @@ class GroupsView(models.Model):
xml4.append(E.group(*right_group))
xml4.append({'class': "o_label_nowrap"})
if user_type_field_name:
user_type_attrs = {'invisible': [(user_type_field_name, '!=', group_employee.id)]}
else:
user_type_attrs = {}
user_type_invisible = f'{user_type_field_name} != {group_employee.id}' if user_type_field_name else ''
for xml_cat in sorted(xml_by_category.keys(), key=lambda it: it[0]):
master_category_name = xml_cat[1]
@ -1580,7 +1629,7 @@ class GroupsView(models.Model):
'class': "alert alert-warning",
'role': "alert",
'colspan': "2",
'attrs': str({'invisible': [(field_name, '=', False)]})
'invisible': f'not {field_name}',
})
user_group_warning_xml.append(E.label({
'for': field_name,
@ -1593,9 +1642,9 @@ class GroupsView(models.Model):
xml = E.field(
*(xml0),
E.group(*(xml1), groups="base.group_no_one"),
E.group(*(xml2), attrs=str(user_type_attrs)),
E.group(*(xml3), attrs=str(user_type_attrs)),
E.group(*(xml4), attrs=str(user_type_attrs), groups="base.group_no_one"), name="groups_id", position="replace")
E.group(*(xml2), invisible=user_type_invisible),
E.group(*(xml3), invisible=user_type_invisible),
E.group(*(xml4), invisible=user_type_invisible, groups="base.group_no_one"), name="groups_id", position="replace")
xml.addprevious(etree.Comment("GENERATED AUTOMATICALLY BY GROUPS"))
# serialize and update the view
@ -1811,30 +1860,37 @@ class UsersView(models.Model):
self._add_reified_groups(group_fields, values)
return values
def onchange(self, values, field_name, field_onchange):
# field_name can be either a string, a list or Falsy
if isinstance(field_name, list):
names = field_name
elif field_name:
names = [field_name]
else:
names = []
def _determine_fields_to_fetch(self, field_names, ignore_when_in_cache=False):
valid_fields = partition(is_reified_group, field_names)[1]
return super()._determine_fields_to_fetch(valid_fields, ignore_when_in_cache)
if any(is_reified_group(field) for field in names):
field_name = (
['groups_id']
+ [field for field in names if not is_reified_group(field)]
)
values.pop('groups_id', None)
values.update(self._remove_reified_groups(values))
def _read_format(self, fnames, load='_classic_read'):
valid_fields = partition(is_reified_group, fnames)[1]
return super()._read_format(valid_fields, load)
def onchange(self, values, field_names, fields_spec):
reified_fnames = [fname for fname in fields_spec if is_reified_group(fname)]
if reified_fnames:
values = {key: val for key, val in values.items() if key != 'groups_id'}
values = self._remove_reified_groups(values)
if any(is_reified_group(fname) for fname in field_names):
field_names = [fname for fname in field_names if not is_reified_group(fname)]
field_names.append('groups_id')
fields_spec = {
field_name: field_spec
for field_name, field_spec in fields_spec.items()
if not is_reified_group(field_name)
}
fields_spec['groups_id'] = {}
result = super().onchange(values, field_names, fields_spec)
if reified_fnames and 'groups_id' in result.get('value', {}):
self._add_reified_groups(reified_fnames, result['value'])
result['value'].pop('groups_id', None)
field_onchange['groups_id'] = ''
result = super().onchange(values, field_name, field_onchange)
if not field_name: # merged default_get
self._add_reified_groups(
filter(is_reified_group, field_onchange),
result.setdefault('value', {})
)
return result
def read(self, fields=None, load='_classic_read'):
@ -1939,7 +1995,8 @@ class UsersView(models.Model):
return res
class CheckIdentity(models.TransientModel):
""" Wizard used to re-check the user's credentials (password)
""" Wizard used to re-check the user's credentials (password) and eventually
revoke access to his account to every device he has an active session on.
Might be useful before the more security-sensitive operations, users might be
leaving their computer unlocked & unattended. Re-checking credentials mitigates
@ -1952,8 +2009,7 @@ class CheckIdentity(models.TransientModel):
request = fields.Char(readonly=True, groups=fields.NO_ACCESS)
password = fields.Char()
def run_check(self):
assert request, "This method can only be accessed over HTTP"
def _check_identity(self):
try:
self.create_uid._check_credentials(self.password, {'interactive': True})
except AccessDenied:
@ -1961,12 +2017,23 @@ class CheckIdentity(models.TransientModel):
finally:
self.password = False
def run_check(self):
assert request, "This method can only be accessed over HTTP"
self._check_identity()
request.session['identity-check-last'] = time.time()
ctx, model, ids, method = json.loads(self.sudo().request)
method = getattr(self.env(context=ctx)[model].browse(ids), method)
assert getattr(method, '__has_check_identity', False)
return method()
def revoke_all_devices(self):
current_password = self.password
self._check_identity()
self.env.user._change_password(current_password)
self.sudo().unlink()
return {'type': 'ir.actions.client', 'tag': 'reload'}
#----------------------------------------------------------
# change password wizard
#----------------------------------------------------------
@ -1975,6 +2042,7 @@ class ChangePasswordWizard(models.TransientModel):
""" A wizard to manage the change of users' passwords. """
_name = "change.password.wizard"
_description = "Change Password Wizard"
_transient_max_hours = 0.2
def _default_user_ids(self):
user_ids = self._context.get('active_model') == 'res.users' and self._context.get('active_ids') or []
@ -2005,9 +2073,8 @@ class ChangePasswordUser(models.TransientModel):
def change_password_button(self):
for line in self:
if not line.new_passwd:
raise UserError(_("Before clicking on 'Change Password', you have to write a new password."))
line.user_id._change_password(line.new_passwd)
if line.new_passwd:
line.user_id._change_password(line.new_passwd)
# don't keep temporary passwords in the database longer than necessary
self.write({'new_passwd': False})
@ -2108,7 +2175,7 @@ class APIKeys(models.Model):
CREATE TABLE IF NOT EXISTS {table} (
id serial primary key,
name varchar not null,
user_id integer not null REFERENCES res_users(id),
user_id integer not null REFERENCES res_users(id) ON DELETE CASCADE,
scope varchar,
index varchar({index_size}) not null CHECK (char_length(index) = {index_size}),
key varchar not null,

View file

@ -2,6 +2,7 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
import threading
from odoo import api, fields, models
@ -35,8 +36,8 @@ class ResUsersDeletion(models.Model):
if user_deletion.user_id:
user_deletion.user_id_int = user_deletion.user_id.id
@api.autovacuum
def _gc_portal_users(self):
@api.model
def _gc_portal_users(self, batch_size=10):
"""Remove the portal users that asked to deactivate their account.
(see <res.users>::_deactivate_portal_user)
@ -45,22 +46,51 @@ class ResUsersDeletion(models.Model):
create_uid, write_uid on each models, which are not always indexed). Because of
that, this operation is done in a CRON.
"""
delete_requests = self.search([('state', '=', 'todo')])
delete_requests = self.search([("state", "=", "todo")])
# filter the requests related to a deleted user
done_requests = delete_requests.filtered(lambda request: not request.user_id)
done_requests.state = 'done'
done_requests.state = "done"
for delete_request in (delete_requests - done_requests):
todo_requests = delete_requests - done_requests
batch_requests = todo_requests[:batch_size]
auto_commit = not getattr(threading.current_thread(), "testing", False)
for delete_request in batch_requests:
user = delete_request.user_id
user_name = user.name
partner = user.partner_id
requester_name = delete_request.create_uid.name
# Step 1: Delete User
try:
with self.env.cr.savepoint():
partner = user.partner_id
user.unlink()
_logger.info("User #%i %r, deleted. Original request from %r.",
user.id, user_name, delete_request.create_uid.name)
delete_request.state = 'done'
except Exception as e:
_logger.error("User #%i %r could not be deleted. Original request from %r. Related error: %s",
user.id, user_name, requester_name, e)
delete_request.state = "fail"
# make sure we never rollback the work we've done, this can take a long time
if auto_commit:
self.env.cr.commit()
if delete_request.state == "fail":
continue
# Step 2: Delete Linked Partner
# Could be impossible if the partner is linked to a SO for example
try:
with self.env.cr.savepoint():
partner.unlink()
_logger.info('User #%i %r, deleted. Original request from %r.',
user.id, user_name, delete_request.create_uid.name)
delete_request.state = 'done'
except Exception:
delete_request.state = 'fail'
_logger.info("Partner #%i %r, deleted. Original request from %r.",
partner.id, user_name, delete_request.create_uid.name)
except Exception as e:
_logger.warning("Partner #%i %r could not be deleted. Original request from %r. Related error: %s",
partner.id, user_name, requester_name, e)
# make sure we never rollback the work we've done, this can take a long time
if auto_commit:
self.env.cr.commit()
if len(todo_requests) > batch_size:
self.env.ref("base.ir_cron_res_users_deletion")._trigger()