mirror of
https://github.com/bringout/oca-ocb-core.git
synced 2026-04-20 08:52:08 +02:00
17.0 vanilla
This commit is contained in:
parent
2e65bf056a
commit
df627a6bba
328 changed files with 578149 additions and 759311 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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', '
')
|
||||
|
||||
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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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])],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue