Since #121376 a clear_cache was removed when unlinking an attachment This clear_cache was not useful when restarting a server with new sources, an other operation changing the content of an assets should invalidate the cache manually. This is the case of ir.asset CUD operations. Unfortunately, a manual update was left missing in website, when archiving ir.assets used for snippets. This was discovered on runbot, with two workers, when the first workers generates assets before the cron, and the second one after the cron. The second worker unlinks attachments creating an inconsistency in the cache of the first one. This problem can be solved quickly by invalidating the assets cache in the cron manually but this will be done in all cases. The proposed solution will check the ir.asset that should change and only clear the cache if the state changed. Regarding performances, this should actually be a slight improvement in query count since at the cost of one more select to prefetch the record we can avoid multiple update, one per snippet. In most case no update at all should be done, at most 2 can be done (one for archive, one for unarchive). Example with some assets to unarchive: Before: TOTAL ENTRIES: 277 SELECT: 158 (~0.16591858863830566s) UNKWOW: 54 (~0.0202481746673584s) UPDATE: 65 (~0.03202557563781738s) After: TOTAL ENTRIES: 214 SELECT: 159 (~0.1663439826965332s) UNKWOW: 54 (~0.029229164123535156s) UPDATE: 1 (~0.0002865791320800781s) If the number of query is lowered, the python processing is slightly higher. Locally the test time is similar, slightly longer since an additional call to _disable_unused_snippets_assets was added to check the cache invalidation closes odoo/odoo#130973 X-original-commit: 3aeab51ff4379e6d76172614fd39f09d6c449ac6 Signed-off-by: Xavier Dollé (xdo) <xdo@odoo.com>
419 lines
17 KiB
Python
419 lines
17 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
import os
|
|
from glob import glob
|
|
from logging import getLogger
|
|
from werkzeug import urls
|
|
|
|
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__)
|
|
|
|
DEFAULT_SEQUENCE = 16
|
|
|
|
# Directives are stored in variables for ease of use and syntax checks.
|
|
APPEND_DIRECTIVE = 'append'
|
|
PREPEND_DIRECTIVE = 'prepend'
|
|
AFTER_DIRECTIVE = 'after'
|
|
BEFORE_DIRECTIVE = 'before'
|
|
REMOVE_DIRECTIVE = 'remove'
|
|
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]
|
|
|
|
|
|
def fs2web(path):
|
|
"""Converts a file system path to a web path"""
|
|
if os.path.sep == '/':
|
|
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 '*' 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):
|
|
"""This model contributes to two things:
|
|
|
|
1. It provides a function returning a list of all file paths declared
|
|
in a given list of addons (see _get_addon_paths);
|
|
|
|
2. It allows to create 'ir.asset' records to add additional directives
|
|
to certain bundles.
|
|
"""
|
|
_name = 'ir.asset'
|
|
_description = 'Asset'
|
|
_order = 'sequence, id'
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
self.env.registry.clear_cache('assets')
|
|
return super().create(vals_list)
|
|
|
|
def write(self, values):
|
|
if self:
|
|
self.env.registry.clear_cache('assets')
|
|
return super().write(values)
|
|
|
|
def unlink(self):
|
|
self.env.registry.clear_cache('assets')
|
|
return super().unlink()
|
|
|
|
name = fields.Char(string='Name', required=True)
|
|
bundle = fields.Char(string='Bundle name', required=True)
|
|
directive = fields.Selection(string='Directive', selection=[
|
|
(APPEND_DIRECTIVE, 'Append'),
|
|
(PREPEND_DIRECTIVE, 'Prepend'),
|
|
(AFTER_DIRECTIVE, 'After'),
|
|
(BEFORE_DIRECTIVE, 'Before'),
|
|
(REMOVE_DIRECTIVE, 'Remove'),
|
|
(REPLACE_DIRECTIVE, 'Replace'),
|
|
(INCLUDE_DIRECTIVE, 'Include')], default=APPEND_DIRECTIVE)
|
|
path = fields.Char(string='Path (or glob pattern)', required=True)
|
|
target = fields.Char(string='Target')
|
|
active = fields.Boolean(string='active', default=True)
|
|
sequence = fields.Integer(string="Sequence", default=DEFAULT_SEQUENCE, required=True)
|
|
|
|
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_extra(self, extra):
|
|
"""
|
|
This method can be overriden to use param as keyword arguments
|
|
Return the extra based on additionnal assets_params
|
|
"""
|
|
return extra
|
|
|
|
@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
|
|
file path [1], the first addon calling it [0] and the bundle name.
|
|
Asset loading is performed as follows:
|
|
|
|
1. All 'ir.asset' records matching the given bundle and with a sequence
|
|
strictly less than 16 are applied.
|
|
|
|
3. The manifests of the given addons are checked for assets declaration
|
|
for the given bundle. If any, they are read sequentially and their
|
|
operations are applied to the current list.
|
|
|
|
4. After all manifests have been parsed, the remaining 'ir.asset'
|
|
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 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()
|
|
addons = self._get_active_addons_list(**assets_params)
|
|
|
|
asset_paths = AssetPaths()
|
|
|
|
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, 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.
|
|
See `_get_asset_paths` for more information.
|
|
|
|
:param bundle: name of the bundle from which to fetch the file paths
|
|
:param addons: list of addon names as strings
|
|
:param css: boolean: whether or not to include style files
|
|
:param js: boolean: whether or not to include script files
|
|
:param xml: boolean: whether or not to include template files
|
|
:param asset_paths: the AssetPath object to fill
|
|
:param seen: a list of bundles already checked to avoid circularity
|
|
"""
|
|
if bundle in seen:
|
|
raise Exception("Circular assets bundle declaration: %s" % " > ".join(seen + [bundle]))
|
|
|
|
# this index is used for prepending: files are inserted at the beginning
|
|
# of the CURRENT bundle.
|
|
bundle_start_index = len(asset_paths.list)
|
|
|
|
assets = self._get_related_assets([('bundle', '=', bundle)], **assets_params).filtered('active')
|
|
# 1. Process the first sequence of 'ir.asset' records
|
|
for asset in assets.filtered(lambda a: a.sequence < DEFAULT_SEQUENCE):
|
|
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 addons:
|
|
for command in odoo.modules.module.get_manifest(addon)['assets'].get(bundle, ()):
|
|
directive, target, path_def = self._process_command(command)
|
|
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):
|
|
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):
|
|
"""
|
|
Returns a set of assets matching the domain, regardless of their
|
|
active state. This method can be overridden to filter the results.
|
|
: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):
|
|
"""
|
|
Returns the first bundle directly defining a glob matching the target
|
|
path. This is useful when generating an 'ir.asset' record to override
|
|
a specific asset and target the right bundle, i.e. the first one
|
|
defining the target path.
|
|
|
|
:param target_path_def: string: path to match.
|
|
:root_bundle: string: bundle from which to initiate the search.
|
|
:returns: the first matching bundle or None
|
|
"""
|
|
installed = self._get_installed_addons_list()
|
|
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)
|
|
|
|
for path, _full_path, bundle, _modified in asset_paths:
|
|
if path == target_path:
|
|
return bundle
|
|
|
|
return root_bundle
|
|
|
|
def _get_active_addons_list(self):
|
|
"""Can be overridden to filter the returned list of active modules."""
|
|
return self._get_installed_addons_list()
|
|
|
|
@api.model
|
|
@tools.ormcache('addons_tuple')
|
|
def _topological_sort(self, addons_tuple):
|
|
"""Returns a list of sorted modules name accord to the spec in ir.module.module
|
|
that is, application desc, sequence, name then topologically sorted"""
|
|
IrModule = self.env['ir.module.module']
|
|
|
|
def mapper(addon):
|
|
manif = odoo.modules.module.get_manifest(addon)
|
|
from_terp = IrModule.get_values_from_terp(manif)
|
|
from_terp['name'] = addon
|
|
from_terp['depends'] = manif.get('depends', ['base'])
|
|
return from_terp
|
|
|
|
manifs = map(mapper, addons_tuple)
|
|
|
|
def sort_key(manif):
|
|
return (not manif['application'], int(manif['sequence']), manif['name'])
|
|
|
|
manifs = sorted(manifs, key=sort_key)
|
|
|
|
return misc.topological_sort({manif['name']: manif['depends'] for manif in manifs})
|
|
|
|
@api.model
|
|
@tools.ormcache()
|
|
def _get_installed_addons_list(self):
|
|
"""
|
|
Returns the list of all installed addons.
|
|
:returns: string[]: list of module names
|
|
"""
|
|
# Main source: the current registry list
|
|
# Second source of modules: server wide modules
|
|
return self.env.registry._init_modules.union(odoo.conf.server_wide_modules or [])
|
|
|
|
def _get_paths(self, path_def, installed):
|
|
"""
|
|
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 list of tuple: (path, full_path, modified)
|
|
"""
|
|
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(addon)
|
|
|
|
safe_path = True
|
|
if addon_manifest:
|
|
if addon not in installed:
|
|
# Assert that the path is in the installed addons
|
|
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
|
|
static_prefix = os.sep.join([addons_path, addon, 'static', ''])
|
|
if full_path.startswith(static_prefix):
|
|
paths_with_timestamps = _glob_static_file(full_path)
|
|
paths = [
|
|
(fs2web(absolute_path[len(addons_path):]), absolute_path, timestamp)
|
|
for absolute_path, timestamp in paths_with_timestamps
|
|
]
|
|
else:
|
|
safe_path = False
|
|
else:
|
|
safe_path = False
|
|
|
|
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.'
|
|
if not safe_path:
|
|
msg += " It may be due to security reasons."
|
|
_logger.warning(msg)
|
|
# Paths are filtered on the extensions (if any).
|
|
return paths
|
|
|
|
def _process_command(self, command):
|
|
"""Parses a given command to return its directive, target and path definition."""
|
|
if isinstance(command, str):
|
|
# Default directive: append
|
|
directive, target, path_def = APPEND_DIRECTIVE, None, command
|
|
elif command[0] in DIRECTIVES_WITH_TARGET:
|
|
directive, target, path_def = command
|
|
else:
|
|
directive, path_def = command
|
|
target = None
|
|
return directive, target, path_def
|
|
|
|
|
|
class AssetPaths:
|
|
""" A list of asset paths (path, addon, bundle) with efficient operations. """
|
|
def __init__(self):
|
|
self.list = []
|
|
self.memo = set()
|
|
|
|
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)
|
|
for index, asset in enumerate(self.list):
|
|
if asset[0] == path:
|
|
return index
|
|
|
|
def append(self, paths, bundle):
|
|
"""Appends the given paths to the current list."""
|
|
for path, full_path, last_modified in paths:
|
|
if path not in self.memo:
|
|
self.list.append((path, full_path, bundle, last_modified))
|
|
self.memo.add(path)
|
|
|
|
def insert(self, paths, bundle, index):
|
|
"""Inserts the given paths to the current list at the given position."""
|
|
to_insert = []
|
|
for path, full_path, last_modified in paths:
|
|
if path not in self.memo:
|
|
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, bundle):
|
|
"""Removes the given paths from the current list."""
|
|
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([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))
|