From 29c5963e8b3c46af8744738de1655420895d0d84 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 3 Nov 2026 17:24:57 +0100 Subject: [PATCH 1/4] [IMP] odoo_repository: provide a real module fixture for tests --- odoo_repository/tests/odoo_repo_mixin.py | 35 ++++++------------- .../tests/samples/my_module/__manifest__.py | 20 +++++++++++ .../tests/samples/my_module/demo/my_model.xml | 8 +++++ .../samples/my_module/models/__init__.py | 2 ++ .../samples/my_module/models/my_model.py | 14 ++++++++ .../samples/my_module/models/res_partner.py | 10 ++++++ .../my_module/security/ir.model.access.csv | 2 ++ .../my_module/static/description/icon.png | 1 + .../samples/my_module/views/my_model.xml | 18 ++++++++++ 9 files changed, 85 insertions(+), 25 deletions(-) create mode 100644 odoo_repository/tests/samples/my_module/__manifest__.py create mode 100644 odoo_repository/tests/samples/my_module/demo/my_model.xml create mode 100644 odoo_repository/tests/samples/my_module/models/__init__.py create mode 100644 odoo_repository/tests/samples/my_module/models/my_model.py create mode 100644 odoo_repository/tests/samples/my_module/models/res_partner.py create mode 100644 odoo_repository/tests/samples/my_module/security/ir.model.access.csv create mode 100644 odoo_repository/tests/samples/my_module/static/description/icon.png create mode 100644 odoo_repository/tests/samples/my_module/views/my_model.xml diff --git a/odoo_repository/tests/odoo_repo_mixin.py b/odoo_repository/tests/odoo_repo_mixin.py index 09d8acc..914feaf 100644 --- a/odoo_repository/tests/odoo_repo_mixin.py +++ b/odoo_repository/tests/odoo_repo_mixin.py @@ -14,6 +14,10 @@ cache = threading.local() +# Real Odoo module (located in 'tests/samples/') copied into the fixture Git +# repository, and scanned by the tests +FIXTURE_MODULE_PATH = Path(__file__).parent / "samples" / "my_module" + class OdooRepoMixin(unittest.TestCase): @classmethod @@ -124,31 +128,12 @@ def _fill_git_repository(cls, repo_path: Path, addon_path: Path): @classmethod def _create_module(cls, module_path: Path): - manifest_lines = [ - "# Copyright 2026 Sébastien Alix\n", - "# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)\n", - "{\n", - ' "name": "Test",\n', - ' "version": "1.0.0",\n', - ' "category": "Test Module",\n', - ' "author": "Odoo Community Association (OCA)",\n', - ' "website": "https://github.com/OCA/module-composition-analysis",\n', - ' "license": "AGPL-3",\n', - ' "depends": ["base"],\n', - ' "data": [],\n', - ' "demo": [],\n', - ' "installable": True,\n', - "}\n", - ] - module_path.mkdir(parents=True, exist_ok=True) - manifest_path = module_path / "__manifest__.py" - with open(manifest_path, "w") as manifest: - manifest.writelines(manifest_lines) - # Add a file which should not be checked out with sparse checkout - icon_path = module_path / "static" / "description" / "icon.png" - icon_path.parent.mkdir(parents=True, exist_ok=True) - with open(icon_path, "wb") as icon: - icon.write(b"fake icon data") + shutil.copytree( + FIXTURE_MODULE_PATH, + module_path, + dirs_exist_ok=True, + ignore=shutil.ignore_patterns("__pycache__", "*.py[cod]"), + ) @classmethod def tearDownClass(cls): diff --git a/odoo_repository/tests/samples/my_module/__manifest__.py b/odoo_repository/tests/samples/my_module/__manifest__.py new file mode 100644 index 0000000..9a09f62 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/__manifest__.py @@ -0,0 +1,20 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +{ + "name": "Test", + "version": "1.0.0", + "category": "Test Module", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/module-composition-analysis", + "license": "AGPL-3", + "depends": ["base"], + "data": [ + "security/ir.model.access.csv", + "views/my_model.xml", + ], + "demo": [ + "demo/my_model.xml", + ], + "installable": True, +} diff --git a/odoo_repository/tests/samples/my_module/demo/my_model.xml b/odoo_repository/tests/samples/my_module/demo/my_model.xml new file mode 100644 index 0000000..813e177 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/demo/my_model.xml @@ -0,0 +1,8 @@ + + + + + Demo + + + diff --git a/odoo_repository/tests/samples/my_module/models/__init__.py b/odoo_repository/tests/samples/my_module/models/__init__.py new file mode 100644 index 0000000..db8ccea --- /dev/null +++ b/odoo_repository/tests/samples/my_module/models/__init__.py @@ -0,0 +1,2 @@ +from . import my_model +from . import res_partner diff --git a/odoo_repository/tests/samples/my_module/models/my_model.py b/odoo_repository/tests/samples/my_module/models/my_model.py new file mode 100644 index 0000000..55fde15 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/models/my_model.py @@ -0,0 +1,14 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class MyModel(models.Model): + _name = "my_module.my_model" + _description = "My Model" + + name = fields.Char() + + def my_method(self): + return True diff --git a/odoo_repository/tests/samples/my_module/models/res_partner.py b/odoo_repository/tests/samples/my_module/models/res_partner.py new file mode 100644 index 0000000..64d4020 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/models/res_partner.py @@ -0,0 +1,10 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + my_field = fields.Char() diff --git a/odoo_repository/tests/samples/my_module/security/ir.model.access.csv b/odoo_repository/tests/samples/my_module/security/ir.model.access.csv new file mode 100644 index 0000000..2f1b119 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_my_module_my_model,access.my_module.my_model,model_my_module_my_model,base.group_user,1,1,0,0 diff --git a/odoo_repository/tests/samples/my_module/static/description/icon.png b/odoo_repository/tests/samples/my_module/static/description/icon.png new file mode 100644 index 0000000..73741d0 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/static/description/icon.png @@ -0,0 +1 @@ +fake icon data \ No newline at end of file diff --git a/odoo_repository/tests/samples/my_module/views/my_model.xml b/odoo_repository/tests/samples/my_module/views/my_model.xml new file mode 100644 index 0000000..f797a18 --- /dev/null +++ b/odoo_repository/tests/samples/my_module/views/my_model.xml @@ -0,0 +1,18 @@ + + + + + my_module.my_model.form + my_module.my_model + +
+ + +
+
+ +
From c0cb1f424e10181e7e54c7e9fd2679b2b33bb978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 21 Oct 2025 16:50:31 +0200 Subject: [PATCH 2/4] [IMP] RepositoryScanner: enable code scanning with new 'parse_code' parameter --- odoo_repository/lib/scanner.py | 30 ++++++++++++++++++++++++++---- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 5490839..45761d1 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -15,7 +15,8 @@ import git import oca_port -from odoo_addons_parser import ModuleParser +from odoo_addons_parser import ModuleParser, OdooParser +from odoo_addons_parser.odoo import ODOO_BASE_ADDONS_PATH # Disable logging from 'pygount' (used by odoo_addons_parser) logging.getLogger("pygount").setLevel(logging.ERROR) @@ -923,6 +924,7 @@ def __init__( token: str = None, workaround_fs_errors: bool = False, clone_name: str = None, + parse_code: bool = False, ): super().__init__( org, @@ -939,6 +941,7 @@ def __init__( self.version = version self.branch = branch self.addons_paths_data = addons_paths_data + self.parse_code = parse_code def detect_modules_to_scan(self): res = self.sync() @@ -1111,9 +1114,28 @@ def _run_module_code_analysis( self, repo, module_path, branch, from_commit, to_commit ): """Perform a code analysis of `module_path`.""" - # Get current code analysis data - parser = ModuleParser(f"{self.path}/{module_path}", scan_models=False) - data = parser.to_dict() + module = pathlib.Path(module_path).parts[-1] + # When scanning 'base' module, we want to include framework base models + # in it as well (AbstractModel, Model...) to ease the data exploration + # afterwards. + if self.parse_code and module == "base": + parser = OdooParser( + self.path, + scan_models=self.parse_code, + # No addons path provided to scan only framework code + # Put ODOO_BASE_ADDONS_PATH to avoid the merge of modules data + # with results of two parsers. + # This will also scan other modules (mainly Odoo test modules) but + # it's fine. + addons_paths=(ODOO_BASE_ADDONS_PATH,), + base_models_key=module, + ) + data = parser.to_dict()[module] + else: + # Get current code analysis data + full_module_path = self.path.joinpath(module_path) + parser = ModuleParser(full_module_path, scan_models=self.parse_code) + data = parser.to_dict() # Append the history of versions versions = self._read_module_versions( repo, module_path, branch, from_commit, to_commit From 214b7aff43df3dc1a57152e1987e180993f063c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 21 Oct 2025 16:50:31 +0200 Subject: [PATCH 3/4] [ADD] odoo_repository_code: to scan modules code --- odoo_repository_code/README.rst | 83 ++++ odoo_repository_code/__init__.py | 1 + odoo_repository_code/__manifest__.py | 24 + odoo_repository_code/models/__init__.py | 8 + odoo_repository_code/models/odoo_model.py | 11 + .../models/odoo_model_version.py | 35 ++ .../models/odoo_module_branch.py | 221 +++++++++ .../models/odoo_module_branch_model.py | 346 ++++++++++++++ .../models/odoo_module_branch_model_field.py | 157 +++++++ .../models/odoo_module_branch_model_method.py | 90 ++++ ...odoo_module_branch_model_resource_mixin.py | 79 ++++ .../models/odoo_repository.py | 13 + odoo_repository_code/pyproject.toml | 3 + odoo_repository_code/readme/CONTRIBUTORS.md | 1 + odoo_repository_code/readme/DESCRIPTION.md | 6 + .../security/ir.model.access.csv | 6 + .../static/description/index.html | 430 ++++++++++++++++++ odoo_repository_code/views/menu.xml | 11 + .../views/odoo_model_version.xml | 37 ++ .../views/odoo_module_branch.xml | 34 ++ .../views/odoo_module_branch_model.xml | 189 ++++++++ .../views/odoo_module_branch_model_field.xml | 164 +++++++ .../views/odoo_module_branch_model_method.xml | 145 ++++++ 23 files changed, 2094 insertions(+) create mode 100644 odoo_repository_code/README.rst create mode 100644 odoo_repository_code/__init__.py create mode 100644 odoo_repository_code/__manifest__.py create mode 100644 odoo_repository_code/models/__init__.py create mode 100644 odoo_repository_code/models/odoo_model.py create mode 100644 odoo_repository_code/models/odoo_model_version.py create mode 100644 odoo_repository_code/models/odoo_module_branch.py create mode 100644 odoo_repository_code/models/odoo_module_branch_model.py create mode 100644 odoo_repository_code/models/odoo_module_branch_model_field.py create mode 100644 odoo_repository_code/models/odoo_module_branch_model_method.py create mode 100644 odoo_repository_code/models/odoo_module_branch_model_resource_mixin.py create mode 100644 odoo_repository_code/models/odoo_repository.py create mode 100644 odoo_repository_code/pyproject.toml create mode 100644 odoo_repository_code/readme/CONTRIBUTORS.md create mode 100644 odoo_repository_code/readme/DESCRIPTION.md create mode 100644 odoo_repository_code/security/ir.model.access.csv create mode 100644 odoo_repository_code/static/description/index.html create mode 100644 odoo_repository_code/views/menu.xml create mode 100644 odoo_repository_code/views/odoo_model_version.xml create mode 100644 odoo_repository_code/views/odoo_module_branch.xml create mode 100644 odoo_repository_code/views/odoo_module_branch_model.xml create mode 100644 odoo_repository_code/views/odoo_module_branch_model_field.xml create mode 100644 odoo_repository_code/views/odoo_module_branch_model_method.xml diff --git a/odoo_repository_code/README.rst b/odoo_repository_code/README.rst new file mode 100644 index 0000000..f4480eb --- /dev/null +++ b/odoo_repository_code/README.rst @@ -0,0 +1,83 @@ +==================== +Odoo Repository Code +==================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:359273ec7d389c3dd991ea041c8277187c0af57b041d284ae6ac681670b6688c + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fmodule--composition--analysis-lightgray.png?logo=github + :target: https://github.com/OCA/module-composition-analysis/tree/18.0/odoo_repository_code + :alt: OCA/module-composition-analysis +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/module-composition-analysis-18-0/module-composition-analysis-18-0-odoo_repository_code + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/module-composition-analysis&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This modules extends ``odoo_repository`` to also collect coding elements +from Odoo modules. + +It will collect the following: + +- Odoo models +- Fields +- Methods with their signature and body + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* sebalix + +Contributors +------------ + +- Sébastien Alix + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/module-composition-analysis `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/odoo_repository_code/__init__.py b/odoo_repository_code/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/odoo_repository_code/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/odoo_repository_code/__manifest__.py b/odoo_repository_code/__manifest__.py new file mode 100644 index 0000000..2722d24 --- /dev/null +++ b/odoo_repository_code/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +{ + "name": "Odoo Repository Code", + "summary": "Collect modules coding elements from Odoo Repositories.", + "version": "18.0.1.0.0", + "category": "Tools", + "author": "sebalix, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/module-composition-analysis", + "data": [ + "security/ir.model.access.csv", + "views/menu.xml", + "views/odoo_model_version.xml", + "views/odoo_module_branch_model.xml", + "views/odoo_module_branch_model_field.xml", + "views/odoo_module_branch_model_method.xml", + "views/odoo_module_branch.xml", + ], + "installable": True, + "depends": [ + "odoo_repository", + ], + "license": "AGPL-3", +} diff --git a/odoo_repository_code/models/__init__.py b/odoo_repository_code/models/__init__.py new file mode 100644 index 0000000..d0b7a52 --- /dev/null +++ b/odoo_repository_code/models/__init__.py @@ -0,0 +1,8 @@ +from . import odoo_repository +from . import odoo_model +from . import odoo_model_version +from . import odoo_module_branch +from . import odoo_module_branch_model +from . import odoo_module_branch_model_resource_mixin +from . import odoo_module_branch_model_field +from . import odoo_module_branch_model_method diff --git a/odoo_repository_code/models/odoo_model.py b/odoo_repository_code/models/odoo_model.py new file mode 100644 index 0000000..b41b67a --- /dev/null +++ b/odoo_repository_code/models/odoo_model.py @@ -0,0 +1,11 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class OdooModel(models.Model): + _name = "odoo.model" + _description = "Odoo Model Technical Name" + + name = fields.Char(required=True, index=True) diff --git a/odoo_repository_code/models/odoo_model_version.py b/odoo_repository_code/models/odoo_model_version.py new file mode 100644 index 0000000..4e9f06d --- /dev/null +++ b/odoo_repository_code/models/odoo_model_version.py @@ -0,0 +1,35 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OdooModelVersion(models.Model): + _name = "odoo.model.version" + _description = "Odoo Model Technical Name for Odoo Version" + + odoo_model_id = fields.Many2one( + comodel_name="odoo.model", + ondelete="cascade", + string="Odoo Model", + required=True, + index=True, + ) + odoo_version_id = fields.Many2one( + comodel_name="odoo.branch", + ondelete="restrict", + string="Odoo Version", + required=True, + index=True, + ) + name = fields.Char(compute="_compute_name", store=True) + module_branch_model_ids = fields.One2many( + comodel_name="odoo.module.branch.model", + inverse_name="odoo_model_version_id", + string="Models", + ) + + @api.depends("odoo_model_id", "odoo_version_id") + def _compute_name(self): + for rec in self: + rec.name = f"[{rec.odoo_version_id.name}] {rec.odoo_model_id.name}" diff --git a/odoo_repository_code/models/odoo_module_branch.py b/odoo_repository_code/models/odoo_module_branch.py new file mode 100644 index 0000000..c94b033 --- /dev/null +++ b/odoo_repository_code/models/odoo_module_branch.py @@ -0,0 +1,221 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class OdooModuleBranch(models.Model): + _inherit = "odoo.module.branch" + + model_ids = fields.One2many( + comodel_name="odoo.module.branch.model", + inverse_name="module_branch_id", + string="Models", + ) + models_count = fields.Integer(compute="_compute_models_count") + + @api.depends("model_ids") + def _compute_models_count(self): + for rec in self: + rec.models_count = len(self.model_ids) + + def open_models(self): + self.ensure_one() + xml_id = "odoo_repository_code.odoo_module_branch_model_action" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Models") + action["domain"] = [("id", "in", self.model_ids.ids)] + action["context"] = {} + return action + + def push_scanned_data(self, repo_branch_id, module, data): + module_branch = super().push_scanned_data(repo_branch_id, module, data) + module_branch._import_code(data) + return module_branch + + def _import_code(self, data): + self.ensure_one() + # Import Odoo data models only if the module is installable + if data.get("models") and self.installable: + self._import_code_odoo_models(data["models"]) + else: + self._cleanup_code_odoo_models() + + def _import_code_odoo_models(self, data_models): + self.ensure_one() + # These are entries created/updated from scanned module + mb_model_ids = [] + mb_model_field_ids = [] + mb_model_method_ids = [] + for model_name, data in data_models.items(): + odoo_model = self._get_or_create_odoo_model(model_name, data) + odoo_model_version = self._get_or_create_odoo_model_version(odoo_model) + vals = self._prepare_odoo_module_branch_model_values( + odoo_model_version, data + ) + mb_model = self._create_or_update_odoo_module_branch_model(vals) + mb_model_ids.append(mb_model.id) + # Import fields + for field_data in data.get("fields", {}).values(): + vals = self._prepare_odoo_module_branch_model_field_values( + mb_model, field_data + ) + field_ = self._create_or_update_odoo_module_branch_model_field(vals) + mb_model_field_ids.append(field_.id) + # Import methods + for method_data in data.get("methods", {}).values(): + vals = self._prepare_odoo_module_branch_model_method_values( + mb_model, method_data + ) + method = self._create_or_update_odoo_module_branch_model_method(vals) + mb_model_method_ids.append(method.id) + self._archive_code_elements( + mb_model_ids, mb_model_field_ids, mb_model_method_ids + ) + + def _cleanup_code_odoo_models(self): + self.ensure_one() + self.model_ids.sudo().unlink() + + def _archive_code_elements( + self, scanned_model_ids, scanned_field_ids, scanned_method_ids + ): + """Archive code elements that doesn't exist anymore.""" + models = ( + self.model_ids + - self.env["odoo.module.branch.model"].browse(scanned_model_ids).sudo() + ) + fields = ( + self.model_ids.field_ids + - self.env["odoo.module.branch.model.field"] + .browse(scanned_field_ids) + .sudo() + ) + methods = ( + self.model_ids.method_ids + - self.env["odoo.module.branch.model.method"] + .browse(scanned_method_ids) + .sudo() + ) + models.active = fields.active = methods.active = False + + def _prepare_odoo_module_branch_model_values(self, odoo_model_version, data): + self.ensure_one() + # Handle custom types + model_type = data["type"] + available_types = [ + elt[0] + for elt in self.env["odoo.module.branch.model"] + ._fields["model_type"] + .selection + ] + if model_type not in available_types: + model_type = "Other" + return { + "module_branch_id": self.id, + "odoo_model_version_id": odoo_model_version.id, + "data": data, + "model_type": model_type, + "active": True, + } + + def _create_or_update_odoo_module_branch_model(self, vals): + self.ensure_one() + rec = self.env["odoo.module.branch.model"].search( + [ + ("module_branch_id", "=", vals["module_branch_id"]), + ("odoo_model_version_id", "=", vals["odoo_model_version_id"]), + ] + ) + if rec: + rec.sudo().write(vals) + else: + rec = self.env["odoo.module.branch.model"].sudo().create(vals) + return rec + + def _get_or_create_odoo_model(self, model_name, data): + self.ensure_one() + rec = self.env["odoo.model"].search([("name", "=", model_name)], limit=1) + if not rec: + vals = {"name": model_name} + rec = self.env["odoo.model"].sudo().create(vals) + return rec + + def _get_or_create_odoo_model_version(self, odoo_model): + self.ensure_one() + rec = self.env["odoo.model.version"].search( + [ + ("odoo_model_id", "=", odoo_model.id), + ("odoo_version_id", "=", self.branch_id.id), + ], + limit=1, + ) + if not rec: + vals = { + "odoo_model_id": odoo_model.id, + "odoo_version_id": self.branch_id.id, + } + rec = self.env["odoo.model.version"].sudo().create(vals) + return rec + + # == fields import == + + def _prepare_odoo_module_branch_model_field_values(self, module_branch_model, data): + self.ensure_one() + return { + "module_branch_model_id": module_branch_model.id, + "name": data["name"], + "data": data, + "field_type": data["type"], + "active": True, + } + + def _create_or_update_odoo_module_branch_model_field(self, vals): + self.ensure_one() + model_ = self.env["odoo.module.branch.model.field"] + rec = model_.search( + [ + ("module_branch_model_id", "=", vals["module_branch_model_id"]), + ("name", "=", vals["name"]), + ], + limit=1, + ) + if rec: + rec.sudo().write(vals) + else: + rec = model_.sudo().create(vals) + return rec + + # == methods import == + + def _prepare_odoo_module_branch_model_method_values( + self, module_branch_model, data + ): + self.ensure_one() + return { + "module_branch_model_id": module_branch_model.id, + "name": data["name"], + "data": data, + "active": True, + } + + def _create_or_update_odoo_module_branch_model_method(self, vals): + self.ensure_one() + model_ = self.env["odoo.module.branch.model.method"] + rec = model_.search( + [ + ("module_branch_model_id", "=", vals["module_branch_model_id"]), + ("name", "=", vals["name"]), + ], + limit=1, + ) + if rec: + rec.sudo().write(vals) + else: + rec = model_.sudo().create(vals) + return rec + + def _to_dict(self): + data = super()._to_dict() + data["models"] = [model._to_dict() for model in self.model_ids] + return data diff --git a/odoo_repository_code/models/odoo_module_branch_model.py b/odoo_repository_code/models/odoo_module_branch_model.py new file mode 100644 index 0000000..95a853d --- /dev/null +++ b/odoo_repository_code/models/odoo_module_branch_model.py @@ -0,0 +1,346 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import psycopg2.sql as pgsql +from markupsafe import Markup +from odoo_addons_parser.code import BASE_CLASSES + +from odoo import _, api, fields, models +from odoo.osv.expression import SQL_OPERATORS +from odoo.tools.safe_eval import safe_eval + + +class OdooModuleBranchModel(models.Model): + _name = "odoo.module.branch.model" + _description = "Odoo model" + _order = ( + "repository_sequence, odoo_model_name, " + "odoo_version_sequence DESC, global_dependency_level, module_name" + ) + + module_branch_id = fields.Many2one( + comodel_name="odoo.module.branch", + ondelete="cascade", + string="Module", + index=True, + ) + module_id = fields.Many2one( + string="Module technical name", + related="module_branch_id.module_id", + store=True, + index=True, + ) + module_name = fields.Char( + string="Module technical name ", + related="module_branch_id.module_name", + store=True, + index="trigram", + ) + odoo_model_version_id = fields.Many2one( + comodel_name="odoo.model.version", + ondelete="restrict", + string="Model (for this Odoo version)", + required=True, + index=True, + ) + odoo_model_id = fields.Many2one( + related="odoo_model_version_id.odoo_model_id", + string="Model", + store=True, + index=True, + ) + odoo_model_name = fields.Char( + related="odoo_model_version_id.odoo_model_id.name", + string="Model name", + store=True, + index=True, + ) + odoo_version_id = fields.Many2one( + related="module_branch_id.branch_id", store=True, index=True + ) + odoo_version_sequence = fields.Integer( + related="module_branch_id.branch_id.sequence", + store=True, + string="Odoo Version Sequence", + ) + org_id = fields.Many2one(related="module_branch_id.org_id", store=True, index=True) + repository_id = fields.Many2one( + related="module_branch_id.repository_id", store=True, index=True + ) + repository_sequence = fields.Integer( + related="module_branch_id.repository_id.sequence", + store=True, + string="Repository Sequence", + ) + global_dependency_level = fields.Integer( + string="Dep. Level", + related="module_branch_id.global_dependency_level", + store=True, + ) + display_name = fields.Char( + compute="_compute_display_name", store=True, index="trigram" + ) + active = fields.Boolean(default=True) + data = fields.Serialized() + model_type = fields.Selection( + selection=[ + ("AbstractModel", "AbstractModel"), + ("Model", "Model"), + ("TransientModel", "TransientModel"), + ("Other", "Other"), + ], + string="Type", + default="Model", + required=True, + index=True, + ) + order = fields.Char() + field_ids = fields.One2many( + comodel_name="odoo.module.branch.model.field", + inverse_name="module_branch_model_id", + string="Fields", + ) + method_ids = fields.One2many( + comodel_name="odoo.module.branch.model.method", + inverse_name="module_branch_model_id", + string="Methods", + ) + root_id = fields.Many2one( + string="Origin", + comodel_name="odoo.module.branch.model", + compute="_compute_root_id", + recursive=True, + ) + root_warning = fields.Html(compute="_compute_root_id") + inherit_ids = fields.Many2many( + comodel_name="odoo.model.version", + compute="_compute_inherit_ids", + search="_search_inherit_ids", + ) + inherits_ids = fields.Many2many( + comodel_name="odoo.model.version", + compute="_compute_inherits_ids", + search="_search_inherits_ids", + ) + next_odoo_version_model_id = fields.Many2one( + comodel_name="odoo.module.branch.model", + compute="_compute_next_odoo_version_model_id", + ) + + @api.depends("odoo_model_id", "module_branch_id") + def _compute_display_name(self): + for rec in self: + model_name = rec.odoo_model_id.name + if model_name not in BASE_CLASSES: + model_name = f"<{model_name}>" + rec.display_name = f"{model_name} in {rec.module_branch_id.display_name}" + + @api.depends("data") + def _compute_inherit_ids(self): + for rec in self: + rec.inherit_ids = False + inherit = safe_eval(repr(rec.data.get("inherit"))) + if isinstance(inherit, str): + inherit = [inherit] + if inherit: + models = self.env["odoo.model.version"].search( + [ + ("odoo_model_id", "in", inherit), + ("odoo_version_id", "=", rec.odoo_version_id.id), + ] + ) + rec.inherit_ids = models + + def _search_inherit_ids(self, operator, value): + sql_operator = SQL_OPERATORS[operator].code + query = pgsql.SQL( + """ + SELECT array_agg(id) + FROM {table} + WHERE data::json->>'inherit' {operator} %s; + """ + ) + query = query.format( + table=pgsql.Identifier(self._table), + operator=pgsql.SQL(sql_operator), + ) + args = (f"%{value}%",) + self.env.cr.execute(query, args) + model_ids = self.env.cr.fetchone()[0] or [] + return [("id", "in", model_ids)] + + @api.depends("data") + def _compute_inherits_ids(self): + for rec in self: + rec.inherits_ids = False + if not rec.data.get("inherits"): + continue + inherits_list = list(rec.data["inherits"]) + inherits = [safe_eval(elt) for elt in inherits_list] + if inherits: + models = self.env["odoo.model.version"].search( + [ + ("odoo_model_id", "in", list(inherits)), + ("odoo_version_id", "=", rec.odoo_version_id.id), + ] + ) + rec.inherits_ids = models + + def _search_inherits_ids(self, operator, value): + sql_operator = SQL_OPERATORS[operator].code + query = pgsql.SQL( + """ + SELECT array_agg(id) + FROM {table} + WHERE data::json->>'inherits' {operator} %s; + """ + ) + query = query.format( + table=pgsql.Identifier(self._table), + operator=pgsql.SQL(sql_operator), + ) + args = (f"%{value}%",) + self.env.cr.execute(query, args) + model_ids = self.env.cr.fetchone()[0] or [] + return [("id", "in", model_ids)] + + @api.depends("odoo_model_version_id") + def _compute_root_id(self): + for rec in self: + root = rec.search( + [("odoo_model_version_id", "=", rec.odoo_model_version_id.id)], + order="global_dependency_level", + limit=1, + ) + rec.root_id = root if root != rec else False + # Warning if the root module is not found in known dependencies + rec.root_warning = False + deps = rec.module_branch_id._get_recursive_dependencies() + if ( + rec.root_id + and root.module_branch_id not in deps + # All modules are depending on base even if not listed, + # no need to display a warning in such case + and rec.root_id.module_name != "base" + ): + rec.root_warning = _( + Markup( + "{root} is not in the known dependencies " + "of {module}." + ) + ).format(root=rec.root_id.module_name, module=rec.module_name) + + def _get_parent_models(self, include_self=False): + """Return all parent models, sorted by level of dependency. + + This is based on actual dependencies of current module. + """ + self.ensure_one() + # Get all dependencies of current module (including self) + dependencies = self.module_branch_id._get_recursive_dependencies() + dependencies |= self.module_branch_id + # Collect parent models recursively + # NOTE: includes base models + parent_model_ids = self.search( + [ + ("odoo_version_id", "=", self.odoo_version_id.id), + ("odoo_model_name", "in", BASE_CLASSES), + ] + ).ids + visited = set() + + def collect_parents(current_model, parent_model_ids=parent_model_ids): + if current_model.id in visited: + return + visited.add(current_model.id) + + # For each model version this model inherits from + all_inherits = current_model.inherit_ids | current_model.inherits_ids + for model_version in all_inherits: + # Find implementations of this model version in dependencies + implementations = self.search( + [ + ("odoo_model_version_id", "=", model_version.id), + ("module_branch_id", "in", dependencies.ids), + ] + ) + for impl in implementations: + if impl.id not in parent_model_ids: + parent_model_ids.append(impl.id) + collect_parents(impl, parent_model_ids=parent_model_ids) + + collect_parents(self, parent_model_ids=parent_model_ids) + # Remove self from results + if not include_self and self.id in parent_model_ids: + parent_model_ids.remove(self.id) + # Sort by dependency level (lower levels first = base modules first) + return self.search( + [("id", "in", parent_model_ids)], order="global_dependency_level" + ) + + @api.depends("odoo_version_id.next_id") + def _compute_next_odoo_version_model_id(self): + for rec in self: + rec.next_odoo_version_model_id = False + # Stop there if no next version + if not rec.odoo_version_id.next_id: + continue + # Look for the next available version for this module name + rec.next_odoo_version_model_id = self.search( + [ + ( + "odoo_version_sequence", + ">=", + rec.odoo_version_id.next_id.sequence, + ), + ("odoo_model_id", "=", rec.odoo_model_id.id), + ], + order="odoo_version_sequence,global_dependency_level", + limit=1, + ) + # # Stop there if no renaming/relacement + # if not rec.timeline_ids: + # continue + # rec.next_odoo_version_module_branch_id = self.search( + # [ + # ("branch_sequence", ">=", rec.branch_id.next_id.sequence), + # ("module_id", "=", rec.timeline_ids.next_module_id.id), + # ], + # order="branch_sequence", + # limit=1, + # ) + + def open_next_odoo_version_model(self): + self.ensure_one() + xml_id = "odoo_repository_code.odoo_module_branch_model_action" + if not self.next_odoo_version_model_id: + return False + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Next version") + # action["domain"] = [("id", "=", self.next_odoo_version_model_id.id)] + del action["view_id"] # = (468, 'odoo.module.branch.model.tree'), + action["view_mode"] = "form" + action["views"] = [(False, "form")] + action["res_id"] = self.next_odoo_version_model_id.id + return action + + def open_parent_models(self): + self.ensure_one() + parent_models = self._get_parent_models(include_self=True) + xml_id = "odoo_repository_code.odoo_module_branch_model_action" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Parent Models") + action["domain"] = [("id", "in", parent_models.ids)] + action["context"] = {} + return action + + def _to_dict(self): + self.ensure_one() + return { + "name": self.odoo_model_name, + "inherit": self.inherit_ids.odoo_model_id.mapped("name"), + "inherits": self.inherits_ids.odoo_model_id.mapped("name"), + "type": self.model_type, + "order": self.order, + "fields": [rec._to_dict() for rec in self.field_ids], + "methods": [rec._to_dict() for rec in self.method_ids], + } diff --git a/odoo_repository_code/models/odoo_module_branch_model_field.py b/odoo_repository_code/models/odoo_module_branch_model_field.py new file mode 100644 index 0000000..8bb93cc --- /dev/null +++ b/odoo_repository_code/models/odoo_module_branch_model_field.py @@ -0,0 +1,157 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class OdooModuleBranchModelField(models.Model): + _name = "odoo.module.branch.model.field" + _inherit = "odoo.module.branch.model.resource.mixin" + _description = "Odoo field" + + field_type = fields.Char(string="Type", required=True, index=True) + data = fields.Serialized() + code = fields.Text(compute="_compute_code", store=True, index="trigram") + comodel_name = fields.Char(compute="_compute_comodel_name", store=True, index=True) + comodel_id = fields.Many2one( + comodel_name="odoo.model.version", + compute="_compute_comodel_id", + ) + is_relational = fields.Boolean(compute="_compute_is_relational", store=True) + inverse_method = fields.Char( + string="Inverse method name", + compute="_compute_methods", + store=True, + ) + inverse_method_id = fields.Many2one( + comodel_name="odoo.module.branch.model.method", + compute="_compute_methods_id", + ) + search_method = fields.Char( + string="Search method name", + compute="_compute_methods", + store=True, + ) + search_method_id = fields.Many2one( + comodel_name="odoo.module.branch.model.method", + compute="_compute_methods_id", + ) + root_id = fields.Many2one( + string="Origin", + comodel_name="odoo.module.branch.model.field", + compute="_compute_root_id", + ) + parent_ids = fields.One2many( + comodel_name="odoo.module.branch.model.field", + compute="_compute_parent_ids", + string="Parent Fields", + ) + + @api.depends("data") + def _compute_code(self): + for rec in self: + rec.code = rec.data.get("code", False) + + @api.depends("data") + def _compute_comodel_name(self): + for rec in self: + rec.comodel_name = rec.data.get("comodel_name") + + @api.depends("comodel_name", "odoo_model_id", "odoo_version_id") + def _compute_comodel_id(self): + model_model = self.env["odoo.model.version"] + for rec in self: + rec.comodel_id = False + if rec.comodel_name: + model = model_model.search( + [ + ("odoo_model_id.name", "=", rec.comodel_name), + ("odoo_version_id", "=", rec.odoo_version_id.id), + ] + ) + rec.comodel_id = model + + @api.depends("field_type") + def _compute_is_relational(self): + for rec in self: + rec.is_relational = ( + rec.field_type.startswith("One2") + or rec.field_type.startswith("Many2") + or rec.field_type == "Reference" + ) + + @api.depends("data") + def _compute_methods(self): + for rec in self: + kwargs = rec.data.get("kwargs", {}) + rec.inverse_method = kwargs.get("inverse") + rec.search_method = kwargs.get("search") + + @api.depends("inverse_method", "search_method") + def _compute_methods_id(self): + method_model = self.env["odoo.module.branch.model.method"] + for rec in self: + rec.inverse_method_id = rec.search_method_id = False + # Inverse method + if rec.inverse_method: + method = method_model.search( + [ + ("module_branch_model_id", "=", rec.module_branch_model_id.id), + ("name", "=", rec.inverse_method), + ], + limit=1, + ) + rec.inverse_method_id = method + # Search method + if rec.search_method: + method = method_model.search( + [ + ("module_branch_model_id", "=", rec.module_branch_model_id.id), + ("name", "=", rec.search_method), + ], + limit=1, + ) + rec.search_method_id = method + + @api.depends("module_branch_model_id.module_branch_id", "odoo_version_id", "name") + def _compute_root_id(self): + for rec in self: + parent_fields = rec._get_parent_fields() + root = fields.first(parent_fields) + rec.root_id = root if root != rec else False + + @api.depends("module_branch_model_id", "name") + def _compute_parent_ids(self): + for rec in self: + rec.parent_ids = rec._get_parent_fields() + + def _get_parent_fields(self, order="global_dependency_level"): + """Return all parent fields from dependencies (call stack).""" + self.ensure_one() + # Get all parent models + parent_models = self.module_branch_model_id._get_parent_models() + # Find fields with the same name in parent models + parent_fields = self.search( + [ + ("module_branch_model_id", "in", parent_models.ids), + ("name", "=", self.name), + ], + order=order, + ) + return parent_fields + + def open_parent_fields(self): + self.ensure_one() + xml_id = "odoo_repository_code.odoo_module_branch_model_field_action2" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Parent Fields") + action["domain"] = [("id", "in", self.parent_ids.ids)] + action["context"] = {} + return action + + def _to_dict(self): + self.ensure_one() + return { + "name": self.name, + "field_type": self.field_type, + } diff --git a/odoo_repository_code/models/odoo_module_branch_model_method.py b/odoo_repository_code/models/odoo_module_branch_model_method.py new file mode 100644 index 0000000..c49a634 --- /dev/null +++ b/odoo_repository_code/models/odoo_module_branch_model_method.py @@ -0,0 +1,90 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models + + +class OdooModuleBranchModelMethod(models.Model): + _name = "odoo.module.branch.model.method" + _inherit = "odoo.module.branch.model.resource.mixin" + _description = "Odoo method" + _order = "odoo_model_name, name" + + data = fields.Serialized() + signature = fields.Char(compute="_compute_signature", store=True) + code = fields.Text(compute="_compute_code", store=True) + global_dependency_level = fields.Integer( + related="module_branch_model_id.module_branch_id.global_dependency_level", + store=True, + ) + root_id = fields.Many2one( + string="Origin", + comodel_name="odoo.module.branch.model.method", + compute="_compute_root_id", + ) + parent_ids = fields.One2many( + comodel_name="odoo.module.branch.model.method", + compute="_compute_parent_ids", + string="Parent Methods", + ) + + @api.depends("data") + def _compute_signature(self): + for rec in self: + signature = ", ".join(rec.data.get("signature", [])) + rec.signature = f"{rec.name}({signature})" + + @api.depends("data") + def _compute_code(self): + for rec in self: + decorators = "\n @".join(rec.data.get("decorators", [])).strip() + if decorators: + decorators = f" @{decorators}\n" + code = rec.data.get("code", "") + rec.code = f"{decorators}{code}" + + @api.depends("module_branch_model_id.module_branch_id", "odoo_version_id", "name") + def _compute_root_id(self): + for rec in self: + parent_methods = rec._get_parent_methods() + root = fields.first(parent_methods) + rec.root_id = root if root != rec else False + + @api.depends("module_branch_model_id", "name") + def _compute_parent_ids(self): + for rec in self: + rec.parent_ids = rec._get_parent_methods() + + def _get_parent_methods(self, order="global_dependency_level"): + """Return all parent methods from dependencies (call stack).""" + self.ensure_one() + # Get all parent models + parent_models = self.module_branch_model_id._get_parent_models() + # Find methods with the same name in parent models + parent_methods = self.search( + [ + ("module_branch_model_id", "in", parent_models.ids), + ("name", "=", self.name), + ], + order=order, + ) + return parent_methods + + def open_parent_methods(self): + self.ensure_one() + xml_id = "odoo_repository_code.odoo_module_branch_model_method_action2" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Parent Methods") + action["domain"] = [("id", "in", self.parent_ids.ids)] + action["context"] = {} + return action + + def _to_dict(self, code=False): + self.ensure_one() + data = { + "name": self.name, + "signature": self.signature, + } + if code: + data["code"] = self.code + return data diff --git a/odoo_repository_code/models/odoo_module_branch_model_resource_mixin.py b/odoo_repository_code/models/odoo_module_branch_model_resource_mixin.py new file mode 100644 index 0000000..a22bca3 --- /dev/null +++ b/odoo_repository_code/models/odoo_module_branch_model_resource_mixin.py @@ -0,0 +1,79 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OdooModuleBranchModelResourceMixin(models.AbstractModel): + _name = "odoo.module.branch.model.resource.mixin" + _description = "Odoo Model Resource Mixin" + _order = "odoo_model_name, name" + + module_branch_model_id = fields.Many2one( + comodel_name="odoo.module.branch.model", + ondelete="cascade", + string="Model", + required=True, + index=True, + ) + module_branch_id = fields.Many2one( + related="module_branch_model_id.module_branch_id", + store=True, + index=True, + ) + module_name = fields.Char( + related="module_branch_model_id.module_name", + string="Technical module name", + required=True, + store=True, + precompute=True, + index=True, + ) + odoo_model_id = fields.Many2one( + related="module_branch_model_id.odoo_model_id", + string="Model ", + required=True, + store=True, + precompute=True, + index=True, + ) + odoo_model_name = fields.Char( + related="module_branch_model_id.odoo_model_name", + string="Model name", + required=True, + store=True, + precompute=True, + index=True, + ) + odoo_version_id = fields.Many2one( + related="module_branch_model_id.odoo_version_id", + string="Odoo Version", + required=True, + store=True, + precompute=True, + index=True, + ) + org_id = fields.Many2one(related="module_branch_id.org_id", store=True, index=True) + repository_id = fields.Many2one( + related="module_branch_id.repository_id", store=True, index=True + ) + global_dependency_level = fields.Integer( + string="Dep. Level", + related="module_branch_id.global_dependency_level", + store=True, + ) + display_name = fields.Char( + compute="_compute_display_name", + store=True, + index="trigram", + ) + name = fields.Char(required=True, index=True) + active = fields.Boolean(default=True) + + @api.depends("module_name", "odoo_model_name", "name") + def _compute_display_name(self): + for rec in self: + model_name = rec.odoo_model_name + rec.display_name = ( + f"<{model_name}>.{rec.name} in {rec.module_branch_id.display_name}" + ) diff --git a/odoo_repository_code/models/odoo_repository.py b/odoo_repository_code/models/odoo_repository.py new file mode 100644 index 0000000..fcdb2d4 --- /dev/null +++ b/odoo_repository_code/models/odoo_repository.py @@ -0,0 +1,13 @@ +# Copyright 2025 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import models + + +class OdooRepository(models.Model): + _inherit = "odoo.repository" + + def _prepare_scanner_parameters(self, version, branch): + params = super()._prepare_scanner_parameters(version, branch) + params["parse_code"] = True + return params diff --git a/odoo_repository_code/pyproject.toml b/odoo_repository_code/pyproject.toml new file mode 100644 index 0000000..4231d0c --- /dev/null +++ b/odoo_repository_code/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/odoo_repository_code/readme/CONTRIBUTORS.md b/odoo_repository_code/readme/CONTRIBUTORS.md new file mode 100644 index 0000000..09975b5 --- /dev/null +++ b/odoo_repository_code/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Sébastien Alix \ diff --git a/odoo_repository_code/readme/DESCRIPTION.md b/odoo_repository_code/readme/DESCRIPTION.md new file mode 100644 index 0000000..f0565f2 --- /dev/null +++ b/odoo_repository_code/readme/DESCRIPTION.md @@ -0,0 +1,6 @@ +This modules extends `odoo_repository` to also collect coding elements from Odoo modules. + +It will collect the following: +- Odoo models +- Fields +- Methods with their signature and body diff --git a/odoo_repository_code/security/ir.model.access.csv b/odoo_repository_code/security/ir.model.access.csv new file mode 100644 index 0000000..91922c6 --- /dev/null +++ b/odoo_repository_code/security/ir.model.access.csv @@ -0,0 +1,6 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_odoo_model_user,odoo_model_user,model_odoo_model,odoo_repository.group_odoo_repository_user,1,0,0,0 +access_odoo_model_version_user,odoo_model_version_user,model_odoo_model_version,odoo_repository.group_odoo_repository_user,1,0,0,0 +access_odoo_module_branch_model_user,odoo_module_branch_model_user,model_odoo_module_branch_model,odoo_repository.group_odoo_repository_user,1,0,0,0 +access_odoo_module_branch_model_field_user,odoo_module_branch_model_field_user,model_odoo_module_branch_model_field,odoo_repository.group_odoo_repository_user,1,0,0,0 +access_odoo_module_branch_model_method_user,odoo_module_branch_model_method_user,model_odoo_module_branch_model_method,odoo_repository.group_odoo_repository_user,1,0,0,0 diff --git a/odoo_repository_code/static/description/index.html b/odoo_repository_code/static/description/index.html new file mode 100644 index 0000000..3d4a7a9 --- /dev/null +++ b/odoo_repository_code/static/description/index.html @@ -0,0 +1,430 @@ + + + + + +Odoo Repository Code + + + +
+

Odoo Repository Code

+ + +

Beta License: AGPL-3 OCA/module-composition-analysis Translate me on Weblate Try me on Runboat

+

This modules extends odoo_repository to also collect coding elements +from Odoo modules.

+

It will collect the following:

+
    +
  • Odoo models
  • +
  • Fields
  • +
  • Methods with their signature and body
  • +
+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • sebalix
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/module-composition-analysis project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/odoo_repository_code/views/menu.xml b/odoo_repository_code/views/menu.xml new file mode 100644 index 0000000..6954cf1 --- /dev/null +++ b/odoo_repository_code/views/menu.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/odoo_repository_code/views/odoo_model_version.xml b/odoo_repository_code/views/odoo_model_version.xml new file mode 100644 index 0000000..9923eef --- /dev/null +++ b/odoo_repository_code/views/odoo_model_version.xml @@ -0,0 +1,37 @@ + + + + + odoo.model.version.form + odoo.model.version + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
+
diff --git a/odoo_repository_code/views/odoo_module_branch.xml b/odoo_repository_code/views/odoo_module_branch.xml new file mode 100644 index 0000000..86d5613 --- /dev/null +++ b/odoo_repository_code/views/odoo_module_branch.xml @@ -0,0 +1,34 @@ + + + + + odoo.module.branch.form.inherit + odoo.module.branch + + +
+ +
+
+
+ + + odoo.module.branch.search.inherit + odoo.module.branch + + + + + + + +
diff --git a/odoo_repository_code/views/odoo_module_branch_model.xml b/odoo_repository_code/views/odoo_module_branch_model.xml new file mode 100644 index 0000000..c0fb757 --- /dev/null +++ b/odoo_repository_code/views/odoo_module_branch_model.xml @@ -0,0 +1,189 @@ + + + + + odoo.module.branch.model.form + odoo.module.branch.model + +
+ +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + odoo.module.branch.model.list + odoo.module.branch.model + + + + + + + + + + + + + + + odoo.module.branch.model.tree2 + odoo.module.branch.model + + + + + + + + + + + + + + odoo.module.branch.model.search + odoo.module.branch.model + search + + + + + + + + + + + + + + + + + + + + + + + + + + + + Models + ir.actions.act_window + odoo.module.branch.model + + {'search_default_group_by_odoo_version_id': 1} + + + +
diff --git a/odoo_repository_code/views/odoo_module_branch_model_field.xml b/odoo_repository_code/views/odoo_module_branch_model_field.xml new file mode 100644 index 0000000..3db0571 --- /dev/null +++ b/odoo_repository_code/views/odoo_module_branch_model_field.xml @@ -0,0 +1,164 @@ + + + + + odoo.module.branch.model.field.form + odoo.module.branch.model.field + +
+ +
+ +
+ + + + + + + + + + + + + + + +
+
+
+
+ + + odoo.module.branch.model.field.list + odoo.module.branch.model.field + + + + + + + + + + + + + + + odoo.module.branch.model.field.list + odoo.module.branch.model.field + + + + + + + + + + + odoo.module.branch.model.field.search + odoo.module.branch.model.field + search + + + + + + + + + + + + + + + + + + + + + + + + Fields + ir.actions.act_window + odoo.module.branch.model.field + + {'search_default_group_by_odoo_version_id': 1, 'search_default_group_by_odoo_model_id': 2} + + + + FIelds + ir.actions.act_window + odoo.module.branch.model.field + + + + +
diff --git a/odoo_repository_code/views/odoo_module_branch_model_method.xml b/odoo_repository_code/views/odoo_module_branch_model_method.xml new file mode 100644 index 0000000..91fe6b7 --- /dev/null +++ b/odoo_repository_code/views/odoo_module_branch_model_method.xml @@ -0,0 +1,145 @@ + + + + + odoo.module.branch.model.method.form + odoo.module.branch.model.method + +
+ +
+ +
+ + + + + + + + +
+
+
+
+ + + odoo.module.branch.model.method.list + odoo.module.branch.model.method + + + + + + + + + + + + + + odoo.module.branch.model.method.list + odoo.module.branch.model.method + + + + + + + + + + + odoo.module.branch.model.method.search + odoo.module.branch.model.method + search + + + + + + + + + + + + + + + + + + + + + + + + Methods + ir.actions.act_window + odoo.module.branch.model.method + + {'search_default_group_by_odoo_version_id': 1, 'search_default_group_by_odoo_model_id': 2} + + + + Methods + ir.actions.act_window + odoo.module.branch.model.method + + + + +
From 65a2afa79ccb362b9268c7a21516aa2636ed607b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 3 Nov 2026 15:02:52 +0100 Subject: [PATCH 4/4] [IMP] odoo_repository_code: scan modules data (XML/CSV) --- odoo_repository/lib/scanner.py | 6 +- odoo_repository_code/README.rst | 1 + odoo_repository_code/__manifest__.py | 1 + odoo_repository_code/models/__init__.py | 1 + .../models/odoo_module_branch.py | 101 +++++++- .../models/odoo_module_branch_data.py | 95 +++++++ odoo_repository_code/readme/DESCRIPTION.md | 1 + .../security/ir.model.access.csv | 1 + .../static/description/index.html | 1 + odoo_repository_code/tests/__init__.py | 1 + .../tests/test_odoo_module_branch_data.py | 233 ++++++++++++++++++ .../views/odoo_module_branch.xml | 10 + .../views/odoo_module_branch_data.xml | 127 ++++++++++ 13 files changed, 577 insertions(+), 2 deletions(-) create mode 100644 odoo_repository_code/models/odoo_module_branch_data.py create mode 100644 odoo_repository_code/tests/__init__.py create mode 100644 odoo_repository_code/tests/test_odoo_module_branch_data.py create mode 100644 odoo_repository_code/views/odoo_module_branch_data.xml diff --git a/odoo_repository/lib/scanner.py b/odoo_repository/lib/scanner.py index 45761d1..9dac8ad 100644 --- a/odoo_repository/lib/scanner.py +++ b/odoo_repository/lib/scanner.py @@ -1134,7 +1134,11 @@ def _run_module_code_analysis( else: # Get current code analysis data full_module_path = self.path.joinpath(module_path) - parser = ModuleParser(full_module_path, scan_models=self.parse_code) + parser = ModuleParser( + full_module_path, + scan_models=self.parse_code, + scan_data=self.parse_code, + ) data = parser.to_dict() # Append the history of versions versions = self._read_module_versions( diff --git a/odoo_repository_code/README.rst b/odoo_repository_code/README.rst index f4480eb..dcc6f27 100644 --- a/odoo_repository_code/README.rst +++ b/odoo_repository_code/README.rst @@ -36,6 +36,7 @@ It will collect the following: - Odoo models - Fields - Methods with their signature and body +- Data records from XML and CSV files **Table of contents** diff --git a/odoo_repository_code/__manifest__.py b/odoo_repository_code/__manifest__.py index 2722d24..8b54122 100644 --- a/odoo_repository_code/__manifest__.py +++ b/odoo_repository_code/__manifest__.py @@ -14,6 +14,7 @@ "views/odoo_module_branch_model.xml", "views/odoo_module_branch_model_field.xml", "views/odoo_module_branch_model_method.xml", + "views/odoo_module_branch_data.xml", "views/odoo_module_branch.xml", ], "installable": True, diff --git a/odoo_repository_code/models/__init__.py b/odoo_repository_code/models/__init__.py index d0b7a52..819d43b 100644 --- a/odoo_repository_code/models/__init__.py +++ b/odoo_repository_code/models/__init__.py @@ -6,3 +6,4 @@ from . import odoo_module_branch_model_resource_mixin from . import odoo_module_branch_model_field from . import odoo_module_branch_model_method +from . import odoo_module_branch_data diff --git a/odoo_repository_code/models/odoo_module_branch.py b/odoo_repository_code/models/odoo_module_branch.py index c94b033..b8e0429 100644 --- a/odoo_repository_code/models/odoo_module_branch.py +++ b/odoo_repository_code/models/odoo_module_branch.py @@ -13,11 +13,22 @@ class OdooModuleBranch(models.Model): string="Models", ) models_count = fields.Integer(compute="_compute_models_count") + data_ids = fields.One2many( + comodel_name="odoo.module.branch.data", + inverse_name="module_branch_id", + string="Data", + ) + data_count = fields.Integer(compute="_compute_data_count") @api.depends("model_ids") def _compute_models_count(self): for rec in self: - rec.models_count = len(self.model_ids) + rec.models_count = len(rec.model_ids) + + @api.depends("data_ids") + def _compute_data_count(self): + for rec in self: + rec.data_count = len(rec.data_ids) def open_models(self): self.ensure_one() @@ -28,6 +39,15 @@ def open_models(self): action["context"] = {} return action + def open_data(self): + self.ensure_one() + xml_id = "odoo_repository_code.odoo_module_branch_data_action" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Data") + action["domain"] = [("id", "in", self.data_ids.ids)] + action["context"] = {} + return action + def push_scanned_data(self, repo_branch_id, module, data): module_branch = super().push_scanned_data(repo_branch_id, module, data) module_branch._import_code(data) @@ -40,6 +60,11 @@ def _import_code(self, data): self._import_code_odoo_models(data["models"]) else: self._cleanup_code_odoo_models() + # Import module data (XML/CSV records) only if the module is installable + if (data.get("data") or data.get("demo")) and self.installable: + self._import_code_odoo_data(data.get("data") or {}, data.get("demo") or {}) + else: + self._cleanup_code_odoo_data() def _import_code_odoo_models(self, data_models): self.ensure_one() @@ -99,6 +124,71 @@ def _archive_code_elements( ) models.active = fields.active = methods.active = False + # == data import == + + def _import_code_odoo_data(self, data_records, demo_records): + self.ensure_one() + # These are entries created/updated from scanned module + mb_data_ids = [] + for data_type, records in (("data", data_records), ("demo", demo_records)): + for model_name, model_records in records.items(): + odoo_model = self._get_or_create_odoo_model(model_name, None) + odoo_model_version = self._get_or_create_odoo_model_version(odoo_model) + for record in model_records: + vals = self._prepare_odoo_module_branch_data_values( + odoo_model_version, record, data_type + ) + data_ = self._create_or_update_odoo_module_branch_data(vals) + mb_data_ids.append(data_.id) + self._archive_code_data_elements(mb_data_ids) + + def _cleanup_code_odoo_data(self): + self.ensure_one() + self.data_ids.sudo().unlink() + + def _archive_code_data_elements(self, scanned_data_ids): + """Archive data elements that doesn't exist anymore.""" + data = ( + self.data_ids + - self.env["odoo.module.branch.data"].browse(scanned_data_ids).sudo() + ) + data.active = False + + def _prepare_odoo_module_branch_data_values( + self, odoo_model_version, record, data_type + ): + self.ensure_one() + return { + "module_branch_id": self.id, + "odoo_model_version_id": odoo_model_version.id, + "xml_id": record["id"], + "name": record.get("name") or record["id"], + "file_path": record.get("file_path"), + "data_type": data_type, + "loaded": record.get("loaded", False), + "noupdate": record.get("noupdate", False), + "data": record, + "active": True, + } + + def _create_or_update_odoo_module_branch_data(self, vals): + self.ensure_one() + model_ = self.env["odoo.module.branch.data"].with_context(active_test=False) + rec = model_.search( + [ + ("module_branch_id", "=", vals["module_branch_id"]), + ("odoo_model_version_id", "=", vals["odoo_model_version_id"]), + ("xml_id", "=", vals["xml_id"]), + ("data_type", "=", vals["data_type"]), + ], + limit=1, + ) + if rec: + rec.sudo().write(vals) + else: + rec = model_.sudo().create(vals) + return rec + def _prepare_odoo_module_branch_model_values(self, odoo_model_version, data): self.ensure_one() # Handle custom types @@ -218,4 +308,13 @@ def _create_or_update_odoo_module_branch_model_method(self, vals): def _to_dict(self): data = super()._to_dict() data["models"] = [model._to_dict() for model in self.model_ids] + # Group data records by type and model, as done by the scanner + for key, data_type in (("data", "data"), ("demo", "demo")): + data_records = self.data_ids.filtered( + lambda rec, data_type=data_type: rec.data_type == data_type + ) + if data_records: + data[key] = {} + for rec in data_records: + data[key].setdefault(rec.odoo_model_name, []).append(rec._to_dict()) return data diff --git a/odoo_repository_code/models/odoo_module_branch_data.py b/odoo_repository_code/models/odoo_module_branch_data.py new file mode 100644 index 0000000..857c6a5 --- /dev/null +++ b/odoo_repository_code/models/odoo_module_branch_data.py @@ -0,0 +1,95 @@ +# Copyright 2026 Sebastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import api, fields, models + + +class OdooModuleBranchData(models.Model): + _name = "odoo.module.branch.data" + _description = "Odoo Data" + _order = "odoo_model_name, name" + + module_branch_id = fields.Many2one( + comodel_name="odoo.module.branch", + ondelete="cascade", + string="Module", + index=True, + ) + module_id = fields.Many2one( + string="Module technical name", + related="module_branch_id.module_id", + store=True, + index=True, + ) + module_name = fields.Char( + string="Module technical name ", + related="module_branch_id.module_name", + store=True, + index="trigram", + ) + odoo_model_version_id = fields.Many2one( + comodel_name="odoo.model.version", + ondelete="restrict", + string="Model (for this Odoo version)", + required=True, + index=True, + ) + odoo_model_id = fields.Many2one( + related="odoo_model_version_id.odoo_model_id", + string="Model", + store=True, + index=True, + ) + odoo_model_name = fields.Char( + related="odoo_model_version_id.odoo_model_id.name", + string="Model name", + store=True, + index=True, + ) + odoo_version_id = fields.Many2one( + related="module_branch_id.branch_id", store=True, index=True + ) + org_id = fields.Many2one(related="module_branch_id.org_id", store=True, index=True) + repository_id = fields.Many2one( + related="module_branch_id.repository_id", store=True, index=True + ) + global_dependency_level = fields.Integer( + string="Dep. Level", + related="module_branch_id.global_dependency_level", + store=True, + ) + display_name = fields.Char( + compute="_compute_display_name", store=True, index="trigram" + ) + name = fields.Char(required=True, index=True) + xml_id = fields.Char(string="External ID", required=True, index="trigram") + file_path = fields.Char(string="File", index=True) + data_type = fields.Selection( + selection=[ + ("data", "Data"), + ("demo", "Demo"), + ], + string="Type", + required=True, + default="data", + index=True, + ) + loaded = fields.Boolean( + help="True if the data is loaded by the module " + "(declared in the 'data' or 'demo' keys of its manifest).", + ) + noupdate = fields.Boolean() + data = fields.Serialized() + active = fields.Boolean(default=True) + + @api.depends("odoo_model_name", "xml_id", "module_branch_id") + def _compute_display_name(self): + for rec in self: + rec.display_name = ( + f"<{rec.odoo_model_name}>.{rec.xml_id} " + f"in {rec.module_branch_id.display_name}" + ) + + def _to_dict(self): + self.ensure_one() + return dict(self.data) diff --git a/odoo_repository_code/readme/DESCRIPTION.md b/odoo_repository_code/readme/DESCRIPTION.md index f0565f2..a9a297e 100644 --- a/odoo_repository_code/readme/DESCRIPTION.md +++ b/odoo_repository_code/readme/DESCRIPTION.md @@ -4,3 +4,4 @@ It will collect the following: - Odoo models - Fields - Methods with their signature and body +- Data records from XML and CSV files diff --git a/odoo_repository_code/security/ir.model.access.csv b/odoo_repository_code/security/ir.model.access.csv index 91922c6..cee05a3 100644 --- a/odoo_repository_code/security/ir.model.access.csv +++ b/odoo_repository_code/security/ir.model.access.csv @@ -4,3 +4,4 @@ access_odoo_model_version_user,odoo_model_version_user,model_odoo_model_version, access_odoo_module_branch_model_user,odoo_module_branch_model_user,model_odoo_module_branch_model,odoo_repository.group_odoo_repository_user,1,0,0,0 access_odoo_module_branch_model_field_user,odoo_module_branch_model_field_user,model_odoo_module_branch_model_field,odoo_repository.group_odoo_repository_user,1,0,0,0 access_odoo_module_branch_model_method_user,odoo_module_branch_model_method_user,model_odoo_module_branch_model_method,odoo_repository.group_odoo_repository_user,1,0,0,0 +access_odoo_module_branch_data_user,odoo_module_branch_data_user,model_odoo_module_branch_data,odoo_repository.group_odoo_repository_user,1,0,0,0 diff --git a/odoo_repository_code/static/description/index.html b/odoo_repository_code/static/description/index.html index 3d4a7a9..b630618 100644 --- a/odoo_repository_code/static/description/index.html +++ b/odoo_repository_code/static/description/index.html @@ -377,6 +377,7 @@

Odoo Repository Code

  • Odoo models
  • Fields
  • Methods with their signature and body
  • +
  • Data records from XML and CSV files
  • Table of contents

    diff --git a/odoo_repository_code/tests/__init__.py b/odoo_repository_code/tests/__init__.py new file mode 100644 index 0000000..94c7e30 --- /dev/null +++ b/odoo_repository_code/tests/__init__.py @@ -0,0 +1 @@ +from . import test_odoo_module_branch_data diff --git a/odoo_repository_code/tests/test_odoo_module_branch_data.py b/odoo_repository_code/tests/test_odoo_module_branch_data.py new file mode 100644 index 0000000..72810da --- /dev/null +++ b/odoo_repository_code/tests/test_odoo_module_branch_data.py @@ -0,0 +1,233 @@ +# Copyright 2026 Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import shutil + +import git + +from odoo.addons.odoo_repository.tests.common import Common +from odoo.addons.odoo_repository.tests.odoo_repo_mixin import FIXTURE_MODULE_PATH + +DEMO_FILE_PATH = "demo/my_model.xml" +DEMO_MANIFEST_LINE = f' "{DEMO_FILE_PATH}",\n' +MANIFEST_INSTALLABLE_TRUE = ' "installable": True,\n' +MANIFEST_INSTALLABLE_FALSE = ' "installable": False,\n' + + +class TestOdooModuleBranchData(Common): + """Scan the git fixture repository and check the collected data elements.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Keep a reference on the original commit of the fixture module, to be + # able to restore its state once a test modified it + repo = git.Repo(cls.repo_upstream_path) + repo.git.checkout(cls.branch1_name) + cls.original_sha = repo.head.object.hexsha + + def setUp(self): + super().setUp() + self.addCleanup(self._restore_upstream_module) + + def _restore_upstream_module(self): + """Restore the fixture module to its original state.""" + repo = git.Repo(self.repo_upstream_path) + repo.git.checkout(self.branch1_name) + repo.git.reset("--hard", self.original_sha) + + def _get_module_branch(self): + module = self.env["odoo.module"].search([("name", "=", self.module_name)]) + return self.env["odoo.module.branch"].search( + [("module_id", "=", module.id), ("branch_id", "=", self.branch.id)] + ) + + def _get_data(self, module_branch, xml_id): + """Return the data record, archived records included.""" + return ( + self.env["odoo.module.branch.data"] + .with_context(active_test=False) + .search( + [ + ("module_branch_id", "=", module_branch.id), + ("xml_id", "=", xml_id), + ] + ) + ) + + def _commit_upstream_module(self, message): + """Commit the current changes of the fixture module.""" + repo = git.Repo(self.repo_upstream_path) + repo.git.checkout(self.branch1_name) + repo.git.add("-A", str(self.addon_path)) + repo.index.commit(message) + + def _remove_demo_file(self): + """Remove the demo file from the fixture module.""" + manifest_path = self.addon_path / "__manifest__.py" + content = manifest_path.read_text() + content = content.replace(DEMO_MANIFEST_LINE, "") + manifest_path.write_text(content) + demo_path = self.addon_path / DEMO_FILE_PATH + demo_path.unlink() + self._commit_upstream_module("[REM] Remove demo data") + + def _restore_demo_file(self): + """Restore the demo file removed by `_remove_demo_file`.""" + shutil.copy( + FIXTURE_MODULE_PATH / "__manifest__.py", + self.addon_path / "__manifest__.py", + ) + demo_path = self.addon_path / DEMO_FILE_PATH + demo_path.parent.mkdir(parents=True, exist_ok=True) + shutil.copy(FIXTURE_MODULE_PATH / DEMO_FILE_PATH, demo_path) + self._commit_upstream_module("[ADD] Restore demo data") + + def _set_installable(self, installable): + """Update the 'installable' flag of the fixture module.""" + manifest_path = self.addon_path / "__manifest__.py" + content = manifest_path.read_text() + if installable: + content = content.replace( + MANIFEST_INSTALLABLE_FALSE, MANIFEST_INSTALLABLE_TRUE + ) + else: + content = content.replace( + MANIFEST_INSTALLABLE_TRUE, MANIFEST_INSTALLABLE_FALSE + ) + manifest_path.write_text(content) + self._commit_upstream_module(f"[CHG] installable = {installable}") + + def test_scan_collects_code_and_data(self): + """A scan collects the models and the data elements of the module.""" + self._run_odoo_repository_action_scan(self.branch.id) + module_branch = self._get_module_branch() + self.assertTrue(module_branch.installable) + # Models + self.assertEqual(module_branch.models_count, 2) + my_model = module_branch.model_ids.filtered( + lambda rec: rec.odoo_model_name == "my_module.my_model" + ) + self.assertEqual(len(my_model.field_ids), 1) + self.assertEqual(len(my_model.method_ids), 1) + # Data elements (3 'data' + 1 'demo') + self.assertEqual(module_branch.data_count, 4) + # XML record (view) + view = self._get_data(module_branch, "my_model_view_form") + self.assertTrue(view.active) + self.assertEqual(view.odoo_model_name, "ir.ui.view") + self.assertEqual(view.name, "my_module.my_model.form") + self.assertEqual(view.file_path, "views/my_model.xml") + self.assertEqual(view.data_type, "data") + self.assertTrue(view.loaded) + self.assertFalse(view.noupdate) + self.assertEqual(view.data["target_model"], "my_module.my_model") + # XML record (menu) + menu = self._get_data(module_branch, "my_model_menu") + self.assertEqual(menu.odoo_model_name, "ir.ui.menu") + self.assertEqual(menu.name, "My Model") + # CSV record: no 'name' available, fallback on the XML id + access = self._get_data(module_branch, "access_my_module_my_model") + self.assertEqual(access.odoo_model_name, "ir.model.access") + self.assertEqual(access.name, "access_my_module_my_model") + self.assertEqual(access.file_path, "security/ir.model.access.csv") + # Demo record + demo = self._get_data(module_branch, "my_model_demo") + self.assertEqual(demo.data_type, "demo") + self.assertEqual(demo.odoo_model_name, "my_module.my_model") + self.assertEqual(demo.name, "Demo") + self.assertEqual(demo.file_path, "demo/my_model.xml") + self.assertTrue(demo.noupdate) + # The target models are registered for the current Odoo version + for model_name in ( + "my_module.my_model", + "res.partner", + "ir.ui.view", + "ir.ui.menu", + "ir.model.access", + ): + model_version = self.env["odoo.model.version"].search( + [ + ("odoo_model_id.name", "=", model_name), + ("odoo_version_id", "=", self.branch.id), + ] + ) + self.assertTrue(model_version) + + def test_scan_data_to_dict(self): + """Data are exported grouped by model, as done by the scanner.""" + self._run_odoo_repository_action_scan(self.branch.id) + module_branch = self._get_module_branch() + data = module_branch._to_dict() + self.assertEqual( + sorted(data["data"]), ["ir.model.access", "ir.ui.menu", "ir.ui.view"] + ) + self.assertEqual(sorted(data["demo"]), ["my_module.my_model"]) + self.assertEqual(data["data"]["ir.ui.view"][0]["id"], "my_model_view_form") + self.assertEqual(data["demo"]["my_module.my_model"][0]["id"], "my_model_demo") + + def test_open_data(self): + self._run_odoo_repository_action_scan(self.branch.id) + module_branch = self._get_module_branch() + action = module_branch.open_data() + self.assertEqual(action["domain"], [("id", "in", module_branch.data_ids.ids)]) + + def test_scan_data_removed_is_archived(self): + """Data removed from the module is archived on the next scan.""" + self._run_odoo_repository_action_scan(self.branch.id) + module_branch = self._get_module_branch() + self.assertEqual(module_branch.data_count, 4) + # Remove the demo file from the module and re-scan + self._remove_demo_file() + self._run_odoo_repository_action_scan(self.branch.id, force=True) + module_branch = self._get_module_branch() + # The demo record is archived, the other data elements are untouched + self.assertEqual(len(module_branch.data_ids), 3) + demo = self._get_data(module_branch, "my_model_demo") + self.assertEqual(len(demo), 1) + self.assertFalse(demo.active) + view = self._get_data(module_branch, "my_model_view_form") + self.assertTrue(view.active) + + def test_scan_data_readded_is_reactivated(self): + """Data re-added after removal reactivates the archived entry.""" + self._run_odoo_repository_action_scan(self.branch.id) + module_branch = self._get_module_branch() + demo = self._get_data(module_branch, "my_model_demo") + self.assertTrue(demo.active) + # Remove the demo file and re-scan: the record is archived + self._remove_demo_file() + self._run_odoo_repository_action_scan(self.branch.id, force=True) + module_branch = self._get_module_branch() + demo = self._get_data(module_branch, "my_model_demo") + self.assertFalse(demo.active) + # Restore the demo file and re-scan: the record is reactivated + self._restore_demo_file() + self._run_odoo_repository_action_scan(self.branch.id, force=True) + module_branch = self._get_module_branch() + # No duplicate record was created + demo = ( + self.env["odoo.module.branch.data"] + .with_context(active_test=False) + .search( + [ + ("module_branch_id", "=", module_branch.id), + ("xml_id", "=", "my_model_demo"), + ] + ) + ) + self.assertEqual(len(demo), 1) + self.assertTrue(demo.active) + + def test_scan_data_not_installable_is_cleaned(self): + """Data are unlinked when the module is not installable.""" + self._run_odoo_repository_action_scan(self.branch.id) + module_branch = self._get_module_branch() + self.assertEqual(module_branch.data_count, 4) + # Make the module not installable and re-scan + self._set_installable(False) + self._run_odoo_repository_action_scan(self.branch.id, force=True) + module_branch = self._get_module_branch() + self.assertFalse(module_branch.installable) + self.assertFalse(module_branch.data_ids) + self.assertFalse(module_branch.model_ids) diff --git a/odoo_repository_code/views/odoo_module_branch.xml b/odoo_repository_code/views/odoo_module_branch.xml index 86d5613..14f8d45 100644 --- a/odoo_repository_code/views/odoo_module_branch.xml +++ b/odoo_repository_code/views/odoo_module_branch.xml @@ -17,6 +17,15 @@ > +
    @@ -28,6 +37,7 @@ + diff --git a/odoo_repository_code/views/odoo_module_branch_data.xml b/odoo_repository_code/views/odoo_module_branch_data.xml new file mode 100644 index 0000000..320acad --- /dev/null +++ b/odoo_repository_code/views/odoo_module_branch_data.xml @@ -0,0 +1,127 @@ + + + + + odoo.module.branch.data.form + odoo.module.branch.data + +
    + + + + + + + + + + + + + + + + + + + + +
    +
    +
    + + + odoo.module.branch.data.list + odoo.module.branch.data + + + + + + + + + + + + + + + + + odoo.module.branch.data.search + odoo.module.branch.data + search + + + + + + + + + + + + + + + + + + + + + + + + + Data + ir.actions.act_window + odoo.module.branch.data + + {'search_default_group_by_odoo_version_id': 1, 'search_default_group_by_odoo_model_id': 2} + + + +