423 lines
18 KiB
Python
423 lines
18 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
|
|
|
|
_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.
|
|
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]
|
|
WILDCARD_CHARACTERS = {'*', "?", "[", "]"}
|
|
|
|
|
|
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 not WILDCARD_CHARACTERS.isdisjoint(path)
|
|
|
|
|
|
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'
|
|
_allow_sudo_commands = False
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
self.clear_caches()
|
|
return super().create(vals_list)
|
|
|
|
def write(self, values):
|
|
self.clear_caches()
|
|
return super().write(values)
|
|
|
|
def unlink(self):
|
|
self.clear_caches()
|
|
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_paths(self, bundle, addons=None, css=False, js=False):
|
|
"""
|
|
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 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
|
|
:returns: the list of tuples (path, addon, bundle)
|
|
"""
|
|
installed = self._get_installed_addons_list()
|
|
if addons is None:
|
|
addons = self._get_active_addons_list()
|
|
|
|
asset_paths = AssetPaths()
|
|
self._fill_asset_paths(bundle, addons, installed, css, js, asset_paths, [])
|
|
return asset_paths.list
|
|
|
|
def _fill_asset_paths(self, bundle, addons, installed, css, js, asset_paths, seen):
|
|
"""
|
|
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]))
|
|
|
|
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")
|
|
|
|
# 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)
|
|
|
|
# 2. Process all addons' manifests.
|
|
for addon in self._topological_sort(tuple(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)
|
|
|
|
# 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)
|
|
|
|
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
|
|
"""
|
|
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
|
|
"""
|
|
ext = target_path_def.split('.')[-1]
|
|
installed = self._get_installed_addons_list()
|
|
target_path = self._get_paths(target_path_def, installed)[1][0]
|
|
|
|
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:
|
|
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_cached(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']: tuple(manif['depends']) for manif in manifs})
|
|
|
|
@api.model
|
|
@tools.ormcache_context(keys='install_module')
|
|
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
|
|
# 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', []))
|
|
|
|
def _get_paths(self, path_def, installed, extensions=None):
|
|
"""
|
|
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.
|
|
|
|
: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`.
|
|
"""
|
|
paths = []
|
|
path_url = fs2web(path_def)
|
|
path_parts = [part for part in path_url.split('/') if part]
|
|
addon = path_parts[0]
|
|
addon_manifest = odoo.modules.module._get_manifest_cached(addon)
|
|
|
|
safe_path = True
|
|
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
|
|
# "/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:
|
|
paths = [
|
|
path for path in sorted(glob(full_path, recursive=True))
|
|
]
|
|
|
|
# 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:
|
|
addon = None
|
|
|
|
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:
|
|
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 addon, [
|
|
path
|
|
for path in paths
|
|
if not extensions or path.split('.')[-1] in extensions
|
|
]
|
|
|
|
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, addon, 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, addon, bundle):
|
|
"""Appends the given paths to the current list."""
|
|
for path in paths:
|
|
if path not in self.memo:
|
|
self.list.append((path, addon, bundle))
|
|
self.memo.add(path)
|
|
|
|
def insert(self, paths, addon, bundle, index):
|
|
"""Inserts the given paths to the current list at the given position."""
|
|
to_insert = []
|
|
for path in paths:
|
|
if path not in self.memo:
|
|
to_insert.append((path, addon, bundle))
|
|
self.memo.add(path)
|
|
self.list[index:index] = to_insert
|
|
|
|
def remove(self, paths_to_remove, addon, bundle):
|
|
"""Removes the given paths from the current list."""
|
|
paths = {path for path 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)
|
|
|
|
def _raise_not_found(self, path, bundle):
|
|
raise ValueError("File(s) %s not found in bundle %s" % (path, bundle))
|