From eaf7d3cad1dc1aaa2c2414b9d06f446683c5dd05 Mon Sep 17 00:00:00 2001 From: Erik Papernyuk <80971885+Erp4759@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:21:03 +0800 Subject: [PATCH] [FIX] dms: complete Odoo 19 migration fixes --- dms/models/directory.py | 62 ++++++++++++++++++------------------- dms/models/dms_file.py | 3 ++ dms/models/storage.py | 3 +- dms/tests/test_directory.py | 15 ++++++++- dms/tests/test_file.py | 45 +++++++++++++++++++++++++++ dms/tests/test_portal.py | 43 +++++++++++++++++++++++++ dms/views/dms_directory.xml | 5 ++- dms/views/dms_file.xml | 5 ++- 8 files changed, 146 insertions(+), 35 deletions(-) diff --git a/dms/models/directory.py b/dms/models/directory.py index 33dc931ea..b147e017f 100644 --- a/dms/models/directory.py +++ b/dms/models/directory.py @@ -8,7 +8,6 @@ import base64 import logging import os -from ast import literal_eval from collections import defaultdict from typing import Literal # noqa # pylint: disable=unused-import @@ -88,7 +87,7 @@ def _default_parent_id(self): if context.get("active_model") == self._name and context.get("active_id"): return context["active_id"] else: - return False + return context.get("dms_parent_id") group_ids = fields.Many2many( comodel_name="dms.access.group", @@ -380,8 +379,8 @@ def _search_panel_directory(self, **kwargs): @api.model def _search_starred(self, operator, operand): if operator in ("=", "in") and operand: - return [("user_star_ids", "in", [self.env.uid])] - return [("user_star_ids", "not in", [self.env.uid])] + return Domain([("user_star_ids", "in", [self.env.uid])]) + return Domain([("user_star_ids", "not in", [self.env.uid])]) @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): @@ -490,8 +489,12 @@ def _compute_parent_id(self): if record.is_root_directory: record.parent_id = None else: - # HACK: Not needed in v14 due to odoo/odoo#64359 - record.parent_id = record.parent_id + record.parent_id = ( + record.parent_id + or record._origin.parent_id + or self.env.context.get("default_parent_id") + or self.env.context.get("dms_parent_id") + ) @api.depends("is_root_directory", "parent_id") def _compute_root_id(self): @@ -753,37 +756,34 @@ def _search_panel_domain_image( def action_dms_directories_all_directory(self): self.ensure_one() action = self.env["ir.actions.act_window"]._for_xml_id( - "dms.action_dms_directory" - ) - domain = Domain.AND( - [ - literal_eval(action["domain"].strip()), - [("parent_id", "child_of", self.id)], - ] + "dms.action_dms_directories_all_directory" ) action["display_name"] = self.name - action["domain"] = domain - action["context"] = dict( - self.env.context, - default_parent_id=self.id, - searchpanel_default_parent_id=self.id, - ) + action["domain"] = [ + ("parent_id", "child_of", self.id), + ("is_hidden", "=", False), + ("id", "!=", self.id), + ] + action["context"] = { + "default_parent_id": self.id, + "dms_parent_id": self.id, + "searchpanel_default_parent_id": self.id, + } return action 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 = Domain.AND( - [ - literal_eval(action["domain"].strip()), - [("directory_id", "child_of", self.id)], - ] + action = self.env["ir.actions.act_window"]._for_xml_id( + "dms.action_dms_files_all_directory" ) action["display_name"] = self.name - action["domain"] = domain - action["context"] = dict( - self.env.context, - default_directory_id=self.id, - searchpanel_default_directory_id=self.id, - ) + action["domain"] = [ + ("directory_id", "child_of", self.id), + ("is_hidden", "=", False), + ] + action["context"] = { + "default_directory_id": self.id, + "dms_directory_id": self.id, + "searchpanel_default_directory_id": self.id, + } return action diff --git a/dms/models/dms_file.py b/dms/models/dms_file.py index 544201498..1d141fd6f 100644 --- a/dms/models/dms_file.py +++ b/dms/models/dms_file.py @@ -54,6 +54,7 @@ class DMSFile(models.Model): required=True, index="btree", tracking=True, # Leave log if "moved" to another directory + default=lambda self: self.env.context.get("dms_directory_id"), ) root_directory_id = fields.Many2one(related="directory_id.root_directory_id") # Override acording to defined in AbstractDmsMixin @@ -568,6 +569,8 @@ def _create_model_attachment(self, vals): directory_id = self.env.context.get("active_id") elif self.env.context.get("default_directory_id"): directory_id = self.env.context.get("default_directory_id") + elif self.env.context.get("dms_directory_id"): + directory_id = self.env.context.get("dms_directory_id") directory = self.env["dms.directory"].browse(directory_id) if ( directory.res_model diff --git a/dms/models/storage.py b/dms/models/storage.py index 7f29d3bdc..4e5e1d621 100644 --- a/dms/models/storage.py +++ b/dms/models/storage.py @@ -7,6 +7,7 @@ from odoo import api, fields, models from odoo.exceptions import AccessError +from odoo.fields import Domain _logger = logging.getLogger(__name__) @@ -88,7 +89,7 @@ class Storage(models.Model): def _search_model(self, operator, value): allowed_items = self.env["ir.model"].sudo().search([("model", operator, value)]) - return [("model_ids", "in", allowed_items.ids)] + return Domain([("model_ids", "in", allowed_items.ids)]) @api.onchange("save_type") def _onchange_save_type(self): diff --git a/dms/tests/test_directory.py b/dms/tests/test_directory.py index 3f69b5df7..b08473152 100644 --- a/dms/tests/test_directory.py +++ b/dms/tests/test_directory.py @@ -8,7 +8,7 @@ from odoo import Command from odoo.exceptions import AccessError, UserError -from odoo.tests import new_test_user +from odoo.tests import Form, new_test_user from odoo.tests.common import users from odoo.tools import mute_logger @@ -41,6 +41,19 @@ def test_create_directory(self): msg="The root directory should have one subdirectory", ) + def test_default_parent_from_context(self): + action = self.directory.action_dms_directories_all_directory() + action_context = action["context"].copy() + action_context.pop("default_parent_id") + directory_form = Form(self.directory_model.with_context(**action_context)) + self.assertEqual(directory_form.parent_id, self.directory) + + action = self.directory.action_dms_files_all_directory() + action_context = action["context"].copy() + action_context.pop("default_directory_id") + file_form = Form(self.file_model.with_context(**action_context)) + self.assertEqual(file_form.directory_id, self.directory) + @users("dms-manager", "dms-user") def test_copy_root_directory(self): copy_root_directory = self.directory.copy() diff --git a/dms/tests/test_file.py b/dms/tests/test_file.py index e91526271..bb2cae185 100644 --- a/dms/tests/test_file.py +++ b/dms/tests/test_file.py @@ -47,6 +47,21 @@ def setUpClass(cls): ) cls.directory_group_a.group_ids = [(4, cls.group_a.id)] cls.file2 = cls.create_file(directory=cls.sub_directory_x) + cls.readonly_user = new_test_user( + cls.env, login="read-only", groups="dms.group_dms_user" + ) + cls.readonly_group = cls.access_group_model.create( + { + "name": "Read only", + "explicit_user_ids": [(6, 0, [cls.readonly_user.id])], + } + ) + cls.readonly_directory = cls.create_directory(storage=cls.storage) + cls.readonly_directory.group_ids = [(6, 0, cls.readonly_group.ids)] + cls.readonly_subdirectory = cls.create_directory( + directory=cls.readonly_directory + ) + cls.readonly_file = cls.create_file(directory=cls.readonly_subdirectory) @users("user-a") def test_unaccessible_file(self): @@ -140,6 +155,36 @@ def test_record_level_access(self): } ) + @users("read-only") + @mute_logger("odoo.addons.base.models.ir_rule", "odoo.models") + def test_read_only_access(self): + """Read-only access groups must not grant mutation permissions.""" + readonly_file = self.readonly_file.with_user(self.env.user) + readonly_file.check_access("read") + for operation in ("write", "unlink"): + with self.assertRaises( + AccessError, msg=f"read-only user {operation} must be denied" + ): + readonly_file.check_access(operation) + with self.assertRaises(AccessError, msg="read-only file write must fail"): + readonly_file.write({"name": "forbidden.txt"}) + with self.assertRaises(AccessError, msg="read-only file create must fail"): + self.file_model.with_user(self.env.user).create( + { + "name": "forbidden.txt", + "directory_id": self.readonly_subdirectory.id, + "content": self.content_base64(), + } + ) + with self.assertRaises(AccessError, msg="read-only directory create must fail"): + self.directory_model.with_user(self.env.user).create( + { + "name": "Forbidden child", + "is_root_directory": False, + "parent_id": self.readonly_directory.id, + } + ) + @users("dms-manager", "dms-user") @mute_logger("odoo.models.unlink") def test_content_file(self): diff --git a/dms/tests/test_portal.py b/dms/tests/test_portal.py index ee6841b4d..12f397924 100644 --- a/dms/tests/test_portal.py +++ b/dms/tests/test_portal.py @@ -1,7 +1,10 @@ # Copyright 2021-2025 Tecnativa - Víctor Martínez # License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl) +import json + import odoo.tests +from odoo import http from odoo.exceptions import AccessError from odoo.tests.common import new_test_user, users from odoo.tools import mute_logger @@ -86,6 +89,46 @@ def test_access_portal(self): self.assertEqual( response.status_code, 200, "Can access directory with correct access_token" ) + # A direct binary URL must not bypass DMS access checks. + response = self.url_open( + f"/web/content?id={self.other_file_partner.id}&field=content" + "&model=dms.file&filename_field=name&download=true", + timeout=20, + ) + self.assertNotEqual( + response.status_code, + 200, + "Can't download a restricted file through a direct URL", + ) + + def test_upload_and_download(self): + upload_directory = self.create_directory(storage=self.create_storage()) + self.authenticate("dms-manager", "dms-manager") + response = self.url_open( + "/web/binary/upload_dms_file", + data={ + "csrf_token": http.Request.csrf_token(self), + "directory_id": upload_directory.id, + }, + files={ + "ufile": ( + "uploaded.txt", + b"Odoo 19 DMS upload", + "text/plain", + ) + }, + ) + response.raise_for_status() + result = json.loads(response.content) + self.assertFalse(result[0].get("error")) + dms_file = self.file_model.browse(result[0]["id"]) + self.assertEqual(dms_file.name, "uploaded.txt") + download = self.url_open( + f"/web/content?id={dms_file.id}&field=content&model=dms.file" + "&filename_field=name&download=true" + ) + download.raise_for_status() + self.assertEqual(download.content, b"Odoo 19 DMS upload") def test_tour(self): for tour in ("dms_portal_mail_tour", "dms_portal_partners_tour"): diff --git a/dms/views/dms_directory.xml b/dms/views/dms_directory.xml index 4d048a53f..ddde91de1 100644 --- a/dms/views/dms_directory.xml +++ b/dms/views/dms_directory.xml @@ -306,7 +306,10 @@ widget="many2many_tags" options="{'color_field': 'color'}" /> - +