diff --git a/dms/controllers/main.py b/dms/controllers/main.py index 5898e80fb..b09d85e97 100644 --- a/dms/controllers/main.py +++ b/dms/controllers/main.py @@ -5,7 +5,7 @@ class OnboardingController(http.Controller): - @http.route("/config/dms.forbidden_extensions", type="json", auth="user") + @http.route("/config/dms.forbidden_extensions", type="jsonrpc", auth="user") def forbidden_extensions(self, **_kwargs): params = request.env["ir.config_parameter"].sudo() return { diff --git a/dms/demo/res_users.xml b/dms/demo/res_users.xml index c586a6007..ca02b6a9a 100644 --- a/dms/demo/res_users.xml +++ b/dms/demo/res_users.xml @@ -7,6 +7,6 @@ --> - + diff --git a/dms/models/access_groups.py b/dms/models/access_groups.py index 71113d28b..1b2edab63 100644 --- a/dms/models/access_groups.py +++ b/dms/models/access_groups.py @@ -3,7 +3,7 @@ # Copyright 2024 Timothée Vannier - Subteno (https://www.subteno.com). # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). -from odoo import _, api, fields, models +from odoo import Command, api, fields, models from odoo.exceptions import ValidationError @@ -47,7 +47,7 @@ class DmsAccessGroups(models.Model): string="Directories", column1="gid", column2="aid", - auto_join=True, + bypass_search_access=True, readonly=True, ) complete_directory_ids = fields.Many2many( @@ -56,7 +56,7 @@ class DmsAccessGroups(models.Model): column1="gid", column2="aid", string="Complete directories", - auto_join=True, + bypass_search_access=True, readonly=True, ) count_users = fields.Integer(compute="_compute_users", store=True) @@ -94,7 +94,7 @@ class DmsAccessGroups(models.Model): column2="uid", string="Group Users", compute="_compute_users", - auto_join=True, + bypass_search_access=True, store=True, recursive=True, ) @@ -104,9 +104,10 @@ def _compute_count_directories(self): for record in self: record.count_directories = len(record.directory_ids) - _sql_constraints = [ - ("name_uniq", "unique (name)", "The name of the group must be unique!") - ] + _name_uniq = models.Constraint( + "unique (name)", + "The name of the group must be unique!", + ) @api.depends( "parent_group_id.perm_inclusive_create", @@ -136,20 +137,20 @@ def default_get(self, fields_list): if res.get("explicit_user_ids"): res["explicit_user_ids"] = res["explicit_user_ids"] + [self.env.uid] else: - res["explicit_user_ids"] = [(6, 0, [self.env.uid])] + res["explicit_user_ids"] = [Command.set([self.env.uid])] return res @api.depends( "parent_group_id", "parent_group_id.users", "group_ids", - "group_ids.users", + "group_ids.user_ids", "explicit_user_ids", ) def _compute_users(self): for record in self: users = ( - record.group_ids.users + record.group_ids.user_ids | record.explicit_user_ids | record.parent_group_id.users ) @@ -158,7 +159,7 @@ def _compute_users(self): def copy_data(self, default=None): vals_list = super().copy_data(default) for group, vals in zip(self, vals_list, strict=False): - vals["name"] = _("%s (copy)") % group.name + vals["name"] = self.env._("%s (copy)", group.name) return vals_list @api.constrains("parent_path") @@ -169,9 +170,9 @@ def _check_parent_recursiveness(self): for one in self.filtered("parent_group_id"): if str(one.id) in one.parent_path.split("/"): raise ValidationError( - _("Parent group '%(parent)s' is child of '%(current)s'.") - % { - "parent": one.parent_group_id.display_name, - "current": one.display_name, - } + self.env._( + "Parent group '%(parent)s' is child of '%(current)s'.", + parent=one.parent_group_id.display_name, + current=one.display_name, + ) ) diff --git a/dms/models/directory.py b/dms/models/directory.py index 34127d2d2..467133be3 100644 --- a/dms/models/directory.py +++ b/dms/models/directory.py @@ -12,9 +12,9 @@ from collections import defaultdict from typing import Literal # noqa # pylint: disable=unused-import -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import UserError, ValidationError -from odoo.osv.expression import AND, OR +from odoo.fields import Domain from odoo.tools import consteq, human_size from ..tools.file import check_name, unique_name @@ -60,7 +60,7 @@ class DmsDirectory(models.Model): comodel_name="dms.storage", string="Storage", ondelete="restrict", - auto_join=True, + bypass_search_access=True, store=True, ) parent_id = fields.Many2one( @@ -116,7 +116,7 @@ def _default_parent_id(self): comodel_name="dms.directory", inverse_name="parent_id", string="Subdirectories", - auto_join=False, + bypass_search_access=False, copy=True, ) @@ -153,7 +153,7 @@ def _default_parent_id(self): comodel_name="dms.file", inverse_name="directory_id", string="Files", - auto_join=False, + bypass_search_access=False, copy=True, ) @@ -221,7 +221,7 @@ def _get_domain_by_access_groups(self, operation): if operation == "create": # When creating, I need create access in parent directory, or # self-create permission if it's a root directory - result = OR( + result = Domain.OR( [ [("is_root_directory", "=", False)] + result, [("is_root_directory", "=", True)] + self_filter, @@ -379,7 +379,11 @@ def _search_panel_directory(self, **kwargs): # Search @api.model def _search_starred(self, operator, operand): - if operator == "=" and operand: + # The 19.0 Domain optimizer normalises ('starred', '=', True) to + # ('starred', 'in', {True}); accept both shapes. + if isinstance(operand, (set, list, tuple)): + operand = True in operand + if operator in ("=", "in") and operand: return [("user_star_ids", "in", [self.env.uid])] return [("user_star_ids", "not in", [self.env.uid])] @@ -412,14 +416,16 @@ def _compute_count_directories(self): for record in self: directories = len(record.child_directory_ids) record.count_directories = directories - record.count_directories_title = _("%s Subdirectories") % directories + record.count_directories_title = self.env._( + "%s Subdirectories", directories + ) @api.depends("file_ids") def _compute_count_files(self): for record in self: files = len(record.file_ids) record.count_files = files - record.count_files_title = _("%s Files") % files + record.count_files_title = self.env._("%s Files", files) @api.depends("child_directory_ids", "file_ids") def _compute_count_elements(self): @@ -528,7 +534,9 @@ def _onchange_model_id(self): @api.constrains("parent_id") def _check_directory_recursion(self): if self._has_cycle(): - raise ValidationError(_("Error! You cannot create recursive directories.")) + raise ValidationError( + self.env._("Error! You cannot create recursive directories.") + ) return True @api.constrains("storage_id", "model_id") @@ -538,34 +546,40 @@ def _check_storage_id_attachment_model_id(self): ): if not record.model_id: raise ValidationError( - _("A directory has to have model in attachment storage.") + self.env._("A directory has to have model in attachment storage.") ) if not record.is_root_directory and not record.res_id: raise ValidationError( - _("This directory needs to be associated to a record.") + self.env._("This directory needs to be associated to a record.") ) @api.constrains("is_root_directory", "storage_id") def _check_directory_storage(self): for record in self: if record.is_root_directory and not record.storage_id: - raise ValidationError(_("A root directory has to have a storage.")) + raise ValidationError( + self.env._("A root directory has to have a storage.") + ) @api.constrains("is_root_directory", "parent_id") def _check_directory_parent(self): for record in self: if record.is_root_directory and record.parent_id: raise ValidationError( - _("A directory can't be a root and have a parent directory.") + self.env._( + "A directory can't be a root and have a parent directory." + ) ) if not record.is_root_directory and not record.parent_id: - raise ValidationError(_("A directory has to have a parent directory.")) + raise ValidationError( + self.env._("A directory has to have a parent directory.") + ) @api.constrains("name") def _check_name(self): for record in self: if self.env.context.get("check_name", True) and not check_name(record.name): - raise ValidationError(_("The directory name is invalid.")) + raise ValidationError(self.env._("The directory name is invalid.")) if record.is_root_directory: children = record.sudo().storage_id.root_directory_ids else: @@ -576,7 +590,7 @@ def _check_name(self): and child != record ): raise ValidationError( - _("A directory with the same name already exists.") + self.env._("A directory with the same name already exists.") ) # Create, Update, Delete @@ -626,7 +640,7 @@ def message_new(self, msg_dict, custom_values=None): return parent_directory names = parent_directory.child_directory_ids.mapped("name") slug = self.env["ir.http"]._slug - subject = slug(msg_dict.get("subject", _("Alias-Mail-Extraction"))) + subject = slug(msg_dict.get("subject", self.env._("Alias-Mail-Extraction"))) defaults = dict( {"name": unique_name(subject, names, escape_suffix=True)}, **custom_values ) @@ -678,13 +692,15 @@ def write(self, vals): if new_parent_id: if old_storage_id != self.browse(new_parent_id).storage_id.id: raise UserError( - _( + self.env._( "It is not possible to change to a parent " "with other storage." ) ) elif old_storage_id != new_storage_id: - raise UserError(_("It is not possible to change the storage.")) + raise UserError( + self.env._("It is not possible to change the storage.") + ) # Groups part if any(key in vals for key in ["group_ids", "inherit_group_ids"]): res = super().write(vals) @@ -743,7 +759,7 @@ def action_dms_directories_all_directory(self): action = self.env["ir.actions.act_window"]._for_xml_id( "dms.action_dms_directory" ) - domain = AND( + domain = Domain.AND( [ literal_eval(action["domain"].strip()), [("parent_id", "child_of", self.id)], @@ -761,7 +777,7 @@ def action_dms_directories_all_directory(self): def action_dms_files_all_directory(self): self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id("dms.action_dms_file") - domain = AND( + domain = Domain.AND( [ literal_eval(action["domain"].strip()), [("directory_id", "child_of", self.id)], diff --git a/dms/models/dms_category.py b/dms/models/dms_category.py index 5900f4d93..575b01f3f 100644 --- a/dms/models/dms_category.py +++ b/dms/models/dms_category.py @@ -5,7 +5,7 @@ import logging -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import ValidationError _logger = logging.getLogger(__name__) @@ -63,9 +63,10 @@ class DMSCategory(models.Model): count_directories = fields.Integer(compute="_compute_count_directories") count_files = fields.Integer(compute="_compute_count_files") - _sql_constraints = [ - ("name_uniq", "unique (name)", "Category name already exists!"), - ] + _name_uniq = models.Constraint( + "unique (name)", + "Category name already exists!", + ) @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): @@ -100,5 +101,7 @@ def _compute_count_files(self): @api.constrains("parent_id") def _check_category_recursion(self): if self._has_cycle(): - raise ValidationError(_("Error! You cannot create recursive categories.")) + raise ValidationError( + self.env._("Error! You cannot create recursive categories.") + ) return True diff --git a/dms/models/dms_file.py b/dms/models/dms_file.py index 328ebb434..ea4c3df32 100644 --- a/dms/models/dms_file.py +++ b/dms/models/dms_file.py @@ -12,9 +12,9 @@ from PIL import Image -from odoo import _, api, fields, models, tools +from odoo import api, fields, models, tools from odoo.exceptions import UserError, ValidationError -from odoo.osv import expression +from odoo.fields import Domain from odoo.tools import consteq, human_size from odoo.tools.mimetypes import guess_mimetype @@ -50,7 +50,7 @@ class DMSFile(models.Model): domain="[('permission_create', '=', True)]", context={"dms_directory_show_path": True}, ondelete="restrict", - auto_join=True, + bypass_search_access=True, required=True, index="btree", tracking=True, # Leave log if "moved" to another directory @@ -197,7 +197,7 @@ def check_access_token(self, access_token=False): string="Attachment File", prefetch=False, ondelete="cascade", - index=True, + index="btree", ) def get_human_size(self): @@ -250,7 +250,7 @@ def action_migrate(self, should_logging=True): for dms_file in self: if should_logging: _logger.info( - _( + self.env._( "Migrate File %(index)s of %(record_count)s [ %(" "dms_file_migration)s ]", index=index, @@ -275,7 +275,9 @@ def action_wizard_dms_file_move(self): items = self.browse(self.env.context.get("active_ids")) root_directories = items.mapped("root_directory_id") if len(root_directories) > 1: - raise UserError(_("Only files in the same root directory can be moved.")) + raise UserError( + self.env._("Only files in the same root directory can be moved.") + ) result = self.env["ir.actions.act_window"]._for_xml_id( "dms.wizard_dms_file_move_act_window" ) @@ -300,7 +302,7 @@ def _search_panel_domain(self, field, operator, directory_id, comodel_domain=Fal if not comodel_domain: comodel_domain = [] files_ids = self.search([("directory_id", operator, directory_id)]).ids - return expression.AND([comodel_domain, [(field, "in", files_ids)]]) + return Domain.AND([comodel_domain, [(field, "in", files_ids)]]) @api.model def search_panel_select_range(self, field_name, **kwargs): @@ -505,20 +507,24 @@ def _check_storage_id_attachment_res_model(self): record.res_model and record.res_id ): raise ValidationError( - _("A file must have model and resource ID in attachment storage.") + self.env._( + "A file must have model and resource ID in attachment storage." + ) ) @api.constrains("name") def _check_name(self): for record in self: if not file.check_name(record.name): - raise ValidationError(_("The file name is invalid.")) + raise ValidationError(self.env._("The file name is invalid.")) files = record.sudo().directory_id.file_ids if files.filtered( lambda file, record=record: file.name == record.name and file != record ): raise ValidationError( - _("A file with the same name already exists in this directory.") + self.env._( + "A file with the same name already exists in this directory." + ) ) @api.constrains("extension") @@ -527,7 +533,9 @@ def _check_extension(self): lambda rec: rec.extension and rec.extension in self._get_forbidden_extensions() ): - raise ValidationError(_("The file has a forbidden file extension.")) + raise ValidationError( + self.env._("The file has a forbidden file extension.") + ) @api.constrains("size") def _check_size(self): @@ -535,7 +543,9 @@ def _check_size(self): lambda rec: rec.size > self._get_binary_max_size() * 1024 * 1024 ): raise ValidationError( - _("The maximum upload size is %s MB.") % self._get_binary_max_size() + self.env._( + "The maximum upload size is %s MB.", self._get_binary_max_size() + ) ) # Create, Update, Delete @@ -655,7 +665,7 @@ def get_dms_files_from_attachments(self, attachment_ids=None): :return: An Array of dms files. """ if not attachment_ids: - raise UserError(_("No attachment was provided")) + raise UserError(self.env._("No attachment was provided")) attachments = self.env["ir.attachment"].browse(attachment_ids) @@ -663,6 +673,6 @@ def get_dms_files_from_attachments(self, attachment_ids=None): attachment.res_id or attachment.res_model != "dms.file" for attachment in attachments ): - raise UserError(_("Invalid attachments!")) + raise UserError(self.env._("Invalid attachments!")) return [self.get_attachment_object(attachment) for attachment in attachments] diff --git a/dms/models/dms_security_mixin.py b/dms/models/dms_security_mixin.py index 87f65a449..3c2fa7fa4 100644 --- a/dms/models/dms_security_mixin.py +++ b/dms/models/dms_security_mixin.py @@ -8,12 +8,7 @@ from odoo import api, fields, models from odoo.exceptions import AccessError -from odoo.osv.expression import ( - FALSE_DOMAIN, - NEGATIVE_TERM_OPERATORS, - OR, - TRUE_DOMAIN, -) +from odoo.fields import Domain from odoo.tools import SQL _logger = getLogger(__name__) @@ -60,6 +55,8 @@ class DmsSecurityMixin(models.AbstractModel): @api.model def _get_ref_selection(self): + # All registered models are an intentional choice here. + # pylint: disable=no-search-all models = self.env["ir.model"].sudo().search([]) return [(model.model, model.name) for model in models] @@ -116,14 +113,14 @@ def _get_domain_by_inheritance(self, operation): ] domains = [] # Get all used related records - related_groups = self.sudo().read_group( + related_groups = self.sudo()._read_group( domain=inherited_access_domain + [("res_model", "!=", False)], - fields=["res_id:array_agg"], groupby=["res_model"], + aggregates=["res_id:array_agg"], ) - for group in related_groups: + for res_model, res_id_array in related_groups: try: - model = self.env[group["res_model"]] + model = self.env[res_model] except KeyError: # The model might not be registered. # This is normal if you are upgrading the database. @@ -131,7 +128,7 @@ def _get_domain_by_inheritance(self, operation): # These records will be accessible by DB users only. domains.append( [ - ("res_model", "=", group["res_model"]), + ("res_model", "=", res_model), (True, "=", self.env.user.has_group("base.group_user")), ] ) @@ -143,7 +140,7 @@ def _get_domain_by_inheritance(self, operation): continue domains.append([("res_model", "=", model._name), ("res_id", "=", False)]) # Check record access in batch too - res_ids = [i for i in group["res_id"] if i] # Hack to remove None res_id + res_ids = [i for i in res_id_array if i] # Hack to remove None res_id # Apply exists to skip records that do not exist. (e.g. a res.partner # deleted by database). model_records = model.browse(res_ids).exists() @@ -153,8 +150,7 @@ def _get_domain_by_inheritance(self, operation): domains.append( [("res_model", "=", model._name), ("res_id", "in", related_ok.ids)] ) - result = inherited_access_domain + OR(domains) - return result + return Domain.AND([Domain(inherited_access_domain), Domain.OR(domains)]) @api.model def _get_access_groups_query(self, operation): @@ -211,19 +207,21 @@ def _get_permission_domain(self, operator, value, operation): value = bool(value) # Tricky one, to know if you want to search # positive or negative access - positive = (operator not in NEGATIVE_TERM_OPERATORS) == bool(value) + positive = (operator not in Domain.NEGATIVE_OPERATORS) == bool(value) if _self.env.su: # You're SUPERUSER_ID - return TRUE_DOMAIN if positive else FALSE_DOMAIN + return Domain.TRUE if positive else Domain.FALSE - result = OR( + result = Domain.OR( [ _self._get_domain_by_access_groups(operation), _self._get_domain_by_inheritance(operation), ] ) if not positive: - result.insert(0, "!") + # Domain is immutable in 19.0; use the bitwise invert operator + # (replaces the 18.0 ``result.insert(0, "!")`` mutation). + result = ~Domain(result) return result @api.model @@ -286,6 +284,42 @@ def _check_access_dms_record(self, operation: str) -> tuple | None: if any(x_id not in items.ids for x_id in self.ids): raise Rule._make_access_error(operation, (self - items)) + @api.model + def _search(self, domain, *args, **kwargs): + """Inject the DMS access-group + inheritance filter into reads. + + ``ir.rule._compute_domain()`` runs the basic Domain optimizer + (``Domain.optimize``, not ``Domain.optimize_full``), so ``search=`` + methods on non-stored computed fields like ``permission_read`` are + silently bypassed when ``domain_force`` is evaluated. The read-side + ``ir.rule`` records on ``dms.directory`` / ``dms.file`` would + therefore no-op and every user would see every record. We restore + the intended behaviour by AND-ing the same domain directly into the + search here, where SQL translation runs the full optimizer that + does honour ``search=`` methods. + + Notes: + - ``env.su`` short-circuits the filter (superuser sees everything). + - The context flag ``dms_skip_access_filter`` lets internal helpers + (``_get_domain_by_inheritance``'s ``_read_group`` etc.) bypass the + filter on their recursive reads, avoiding O(N) blow-up. + - The filter targets the ``read`` operation. Write/create/unlink + validation continues via ``_check_access_dms_record`` (called from + ``create``/``write``/``unlink`` in this mixin) plus the + ``_search_permission_*`` methods for explicit + ``('permission_', '=', user.id)`` clauses in caller domains. + """ + if not self.env.su and not self.env.context.get("dms_skip_access_filter"): + self = self.with_context(dms_skip_access_filter=True) + dms_domain = Domain.OR( + [ + self._get_domain_by_access_groups("read"), + self._get_domain_by_inheritance("read"), + ] + ) + domain = Domain.AND([Domain(domain), dms_domain]) + return super()._search(domain, *args, **kwargs) + @api.model_create_multi def create(self, vals_list): # Create as sudo to avoid testing creation permissions before DMS security diff --git a/dms/models/storage.py b/dms/models/storage.py index cbc74cf01..7f29d3bdc 100644 --- a/dms/models/storage.py +++ b/dms/models/storage.py @@ -5,7 +5,7 @@ import logging -from odoo import _, api, fields, models +from odoo import api, fields, models from odoo.exceptions import AccessError _logger = logging.getLogger(__name__) @@ -44,7 +44,7 @@ class Storage(models.Model): comodel_name="dms.directory", inverse_name="storage_id", string="Root Directories", - auto_join=False, + bypass_search_access=False, readonly=False, copy=False, ) @@ -52,7 +52,7 @@ class Storage(models.Model): comodel_name="dms.directory", inverse_name="storage_id", string="Directories", - auto_join=False, + bypass_search_access=False, readonly=True, copy=False, ) @@ -60,7 +60,7 @@ class Storage(models.Model): comodel_name="dms.file", inverse_name="storage_id", string="Files", - auto_join=False, + bypass_search_access=False, readonly=True, copy=False, ) @@ -100,7 +100,7 @@ def _onchange_save_type(self): def action_storage_migrate(self): if self.save_type != "attachment": if not self.env.user.has_group("dms.group_dms_manager"): - raise AccessError(_("Only managers can execute this action.")) + raise AccessError(self.env._("Only managers can execute this action.")) files = self.env["dms.file"].with_context(active_test=False).sudo() for record in self: diff --git a/dms/models/tag.py b/dms/models/tag.py index 25dfb9a57..dac0dc799 100644 --- a/dms/models/tag.py +++ b/dms/models/tag.py @@ -16,7 +16,7 @@ class Tag(models.Model): name = fields.Char(required=True, translate=True) active = fields.Boolean( default=True, - help="The active field allows you " "to hide the tag without removing it.", + help="The active field allows you to hide the tag without removing it.", ) category_id = fields.Many2one( comodel_name="dms.category", @@ -44,9 +44,10 @@ class Tag(models.Model): count_directories = fields.Integer(compute="_compute_count_directories") count_files = fields.Integer(compute="_compute_count_files") - _sql_constraints = [ - ("name_uniq", "unique (name, category_id)", "Tag name already exists!"), - ] + _name_uniq = models.Constraint( + "unique (name, category_id)", + "Tag name already exists!", + ) @api.depends("directory_ids") def _compute_count_directories(self): diff --git a/dms/security/security.xml b/dms/security/security.xml index 463f914c3..5f6bf0db8 100644 --- a/dms/security/security.xml +++ b/dms/security/security.xml @@ -12,17 +12,21 @@ Documents + + Documents + + User - + Manager - + diff --git a/dms/static/src/js/fields/path_json/path_owl.xml b/dms/static/src/js/fields/path_json/path_owl.xml index b2a8e7c70..ff0090c9a 100644 --- a/dms/static/src/js/fields/path_json/path_owl.xml +++ b/dms/static/src/js/fields/path_json/path_owl.xml @@ -6,25 +6,27 @@ - - - / - - - + diff --git a/dms/static/src/js/fields/preview_binary/preview_record.esm.js b/dms/static/src/js/fields/preview_binary/preview_record.esm.js index ca55cdd5b..65fd2bbaf 100644 --- a/dms/static/src/js/fields/preview_binary/preview_record.esm.js +++ b/dms/static/src/js/fields/preview_binary/preview_record.esm.js @@ -18,7 +18,7 @@ export class PreviewRecordField extends BinaryField { onFilePreview() { const self = this; - const attachment = this.store.Attachment.insert({ + const attachment = this.store["ir.attachment"].insert({ id: self.props.record.resId, filename: self.props.record.data.display_name || "", name: self.props.record.data.display_name || "", diff --git a/dms/static/src/js/views/file_kanban_record.esm.js b/dms/static/src/js/views/file_kanban_record.esm.js index 335fb08dd..002ec429e 100644 --- a/dms/static/src/js/views/file_kanban_record.esm.js +++ b/dms/static/src/js/views/file_kanban_record.esm.js @@ -44,7 +44,7 @@ export class FileKanbanRecord extends KanbanRecord { mimetype = self.props.record.data.mimetype; } - const attachment = this.store.Attachment.insert({ + const attachment = this.store["ir.attachment"].insert({ id: self.props.record.data.id, filename: self.props.record.data.name, name: self.props.record.data.name, diff --git a/dms/static/src/js/views/file_kanban_renderer.xml b/dms/static/src/js/views/file_kanban_renderer.xml index a1bb3c4fa..3d3626bc4 100644 --- a/dms/static/src/js/views/file_kanban_renderer.xml +++ b/dms/static/src/js/views/file_kanban_renderer.xml @@ -16,12 +16,21 @@ - - + + + diff --git a/dms/static/src/js/views/file_list_renderer.xml b/dms/static/src/js/views/file_list_renderer.xml index 0b1b83418..6a88307b9 100644 --- a/dms/static/src/js/views/file_list_renderer.xml +++ b/dms/static/src/js/views/file_list_renderer.xml @@ -13,7 +13,7 @@ t-inherit="web.ListView.Buttons" t-inherit-mode="primary" > - +