From efad7ace02e80a7fd58fb76f249a020795598f58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 27 May 2026 14:25:46 +0200 Subject: [PATCH 1/8] [IMP] odoo_repository: add .previous_id field --- odoo_repository/README.rst | 12 +++----- odoo_repository/__manifest__.py | 4 +-- odoo_repository/models/odoo_branch.py | 13 +++++++-- odoo_repository/static/description/index.html | 24 ++++++---------- odoo_repository/tests/__init__.py | 1 + odoo_repository/tests/common.py | 12 ++++++++ odoo_repository/tests/test_odoo_branch.py | 28 +++++++++++++++++++ 7 files changed, 67 insertions(+), 27 deletions(-) create mode 100644 odoo_repository/tests/test_odoo_branch.py diff --git a/odoo_repository/README.rst b/odoo_repository/README.rst index bf3dd394..3a0696bb 100644 --- a/odoo_repository/README.rst +++ b/odoo_repository/README.rst @@ -1,10 +1,6 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - -====================== -Odoo Repositories Data -====================== +======== +Odoo MCA +======== .. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! @@ -17,7 +13,7 @@ Odoo Repositories Data .. |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/license-AGPL--3-blue.png +.. |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 diff --git a/odoo_repository/__manifest__.py b/odoo_repository/__manifest__.py index 921ac2e7..34e76314 100644 --- a/odoo_repository/__manifest__.py +++ b/odoo_repository/__manifest__.py @@ -2,9 +2,9 @@ # Copyright 2026 Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) { - "name": "Odoo Repositories Data", + "name": "Odoo MCA", "summary": "Base module to host data collected from Odoo repositories.", - "version": "18.0.1.1.4", + "version": "18.0.1.2.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository/models/odoo_branch.py b/odoo_repository/models/odoo_branch.py index 31af62cb..6bf6e4d2 100644 --- a/odoo_repository/models/odoo_branch.py +++ b/odoo_repository/models/odoo_branch.py @@ -31,7 +31,11 @@ class OdooBranch(models.Model): sequence = fields.Integer() next_id = fields.Many2one( comodel_name="odoo.branch", - compute="_compute_next_id", + compute="_compute_next_and_previous", + ) + previous_id = fields.Many2one( + comodel_name="odoo.branch", + compute="_compute_next_and_previous", ) _sql_constraints = [ @@ -47,13 +51,18 @@ def _constrains_name(self): raise ValidationError(_("Version must match the pattern 'x.y'.")) @api.depends("sequence") - def _compute_next_id(self): + def _compute_next_and_previous(self): for rec in self: rec.next_id = self.search( [("sequence", ">", rec.sequence)], order="sequence", limit=1, ) + rec.previous_id = self.search( + [("sequence", "<", rec.sequence)], + order="sequence DESC", + limit=1, + ) @api.model def _recompute_sequence(self): diff --git a/odoo_repository/static/description/index.html b/odoo_repository/static/description/index.html index 45e897e5..23cdc23d 100644 --- a/odoo_repository/static/description/index.html +++ b/odoo_repository/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Odoo MCA -
+
+

Odoo MCA

- - -Odoo Community Association - -
-

Odoo Repositories Data

-

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

+

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

Base module to host data collected from Odoo repositories.

It allows you to:

    @@ -397,7 +392,7 @@

    Odoo Repositories Data

-

Bug Tracker

+

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 @@ -405,15 +400,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -435,6 +430,5 @@

Maintainers

-
diff --git a/odoo_repository/tests/__init__.py b/odoo_repository/tests/__init__.py index ee060d5d..ac60db36 100644 --- a/odoo_repository/tests/__init__.py +++ b/odoo_repository/tests/__init__.py @@ -4,5 +4,6 @@ from . import test_odoo_repository_scan from . import test_sync_node from . import test_odoo_module_branch +from . import test_odoo_branch from . import test_oca_repository_synchronizer from . import test_odoo_module_branch_recursive_dependencies diff --git a/odoo_repository/tests/common.py b/odoo_repository/tests/common.py index 3fcf1272..4ecefc0d 100644 --- a/odoo_repository/tests/common.py +++ b/odoo_repository/tests/common.py @@ -71,6 +71,18 @@ def setUpClass(cls): cls.branch2.active = True # branch3 cls.branch3_name = cls.target2.split("/")[1] + cls.branch3 = ( + cls.env["odoo.branch"] + .with_context(active_test=False) + .search([("name", "=", cls.branch3_name)]) + ) + if not cls.branch3: + cls.branch3 = cls.env["odoo.branch"].create( + { + "name": cls.branch2_name, + } + ) + cls.branch3.active = True # technical module cls.module_name = cls.addon cls.module_branch_model = cls.env["odoo.module.branch"] diff --git a/odoo_repository/tests/test_odoo_branch.py b/odoo_repository/tests/test_odoo_branch.py new file mode 100644 index 00000000..671260f5 --- /dev/null +++ b/odoo_repository/tests/test_odoo_branch.py @@ -0,0 +1,28 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import Common + + +class TestOdooBranch(Common): + def test_next_id_previous_id(self): + """Test next_id and previous_id computed fields.""" + branch_model = self.env["odoo.branch"] + branches = branch_model.search([], order="sequence", limit=3) + first_branch, second_branch, third_branch = ( + branches[0], + branches[1], + branches[2], + ) + # Test middle branch + self.assertEqual(second_branch.previous_id, first_branch) + self.assertEqual(second_branch.next_id, third_branch) + # Test first branch + self.assertFalse(first_branch.previous_id) + self.assertEqual(first_branch.next_id, second_branch) + # Test last branch + branches = branch_model.search([], order="sequence DESC", limit=2) + last_branch, ante_branch = branches[0], branches[1] + self.assertEqual(last_branch.previous_id, ante_branch) + self.assertFalse(last_branch.next_id) From d7f37fa48af998c83d57d460d623def025f9e07b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 27 May 2026 14:31:08 +0200 Subject: [PATCH 2/8] [IMP] odoo_repository_migration: test 'next_odoo_version_module_branch_id' --- .../tests/test_odoo_module_branch.py | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/odoo_repository_migration/tests/test_odoo_module_branch.py b/odoo_repository_migration/tests/test_odoo_module_branch.py index 20db9569..cca1a9fa 100644 --- a/odoo_repository_migration/tests/test_odoo_module_branch.py +++ b/odoo_repository_migration/tests/test_odoo_module_branch.py @@ -6,6 +6,46 @@ class TestOdooModuleBranch(MigrationCommon): + def test_next_odoo_version_module_branch_id(self): + """Test next_odoo_version_module_branch_id computed field.""" + # Create next branch module + next_module_branch = self._create_odoo_module_branch( + self.module, + self.branch2, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha", + ) + # Test next_odoo_version_module_branch_id + self.assertEqual( + self.module_branch.next_odoo_version_module_branch_id, next_module_branch + ) + + def test_next_odoo_version_module_branch_id_with_renamed_module(self): + """Test next_odoo_version_module_branch_id with renamed module.""" + # Create a new module name for the renamed module + next_module = self.module.copy({"name": "next_module"}) + # Create next branch module with new name + next_module_branch = self._create_odoo_module_branch( + next_module, + self.branch2, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha", + ) + # Add timeline entry for renaming + self.module_branch.timeline_ids.create( + { + "module_branch_id": self.module_branch.id, + "state": "renamed", + "next_module_id": next_module.id, + } + ) + # Test next_odoo_version_module_branch_id follows renaming + self.assertEqual( + self.module_branch.next_odoo_version_module_branch_id, next_module_branch + ) + def test_migration_scan_removed(self): self.module_branch.removed = True self.assertFalse(self.module_branch.migration_scan) From b91c0735eb67ca01b5f540a434c00e5f945e6b0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 27 May 2026 11:27:02 +0200 Subject: [PATCH 3/8] [IMP] odoo_repository_migration: tests, improve '_simulate_migration_scan()' Set explicit source and target branches. --- .../tests/test_export_migration_report.py | 5 ++- .../tests/test_generate_migration_data.py | 5 ++- odoo_repository_migration/tests/common.py | 23 ++++++---- .../tests/test_odoo_module_branch.py | 44 +++++++++++++++---- 4 files changed, 59 insertions(+), 18 deletions(-) diff --git a/odoo_project_migration/tests/test_export_migration_report.py b/odoo_project_migration/tests/test_export_migration_report.py index 38940ee7..8aff148b 100644 --- a/odoo_project_migration/tests/test_export_migration_report.py +++ b/odoo_project_migration/tests/test_export_migration_report.py @@ -19,7 +19,10 @@ def setUpClass(cls): ) # Retrieve migration data for `cls.module_branch` cls._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=cls.branch, + target=cls.branch2, + report={"process": "migrate", "results": {}}, ) # And import/install this module in our project cls._run_import_modules(cls.project, cls.module_branch.module_name) diff --git a/odoo_project_migration/tests/test_generate_migration_data.py b/odoo_project_migration/tests/test_generate_migration_data.py index 26809fe0..0c390df6 100644 --- a/odoo_project_migration/tests/test_generate_migration_data.py +++ b/odoo_project_migration/tests/test_generate_migration_data.py @@ -16,7 +16,10 @@ def setUpClass(cls): ) # Retrieve migration data for `cls.module_branch` cls._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=cls.branch, + target=cls.branch2, + report={"process": "migrate", "results": {}}, ) # And import/install this module in our project cls._run_import_modules(cls.project, cls.module_branch.module_name) diff --git a/odoo_repository_migration/tests/common.py b/odoo_repository_migration/tests/common.py index 70824309..f17b19c9 100644 --- a/odoo_repository_migration/tests/common.py +++ b/odoo_repository_migration/tests/common.py @@ -44,20 +44,27 @@ def setUpClass(cls): cls.gen_repository.addons_path_ids = cls.odoo_repository.addons_path_ids @classmethod - def _simulate_migration_scan(cls, target_commit, report=None): + def _simulate_migration_scan(cls, target_commit, source, target, report=None): """Helper method that pushes scanned migration data.""" + module_branch = cls.env["odoo.module.branch"].search( + [ + ("module_id", "=", cls.module.id), + ("branch_id", "=", source.id), + ] + ) + assert module_branch data = { - "module": cls.module_branch.module_name, - "source_version": cls.branch.name, - "source_branch": cls.branch.name, - "target_version": cls.branch2.name, - "target_branch": cls.branch2.name, - "source_commit": cls.module_branch.last_scanned_commit, + "module": module_branch.module_name, + "source_version": source.name, + "source_branch": source.name, + "target_version": target.name, + "target_branch": target.name, + "source_commit": module_branch.last_scanned_commit, "target_commit": target_commit, } if report is not None: data["report"] = report return cls.env["odoo.module.branch.migration"].push_scanned_data( - cls.module_branch.id, + module_branch.id, data, ) diff --git a/odoo_repository_migration/tests/test_odoo_module_branch.py b/odoo_repository_migration/tests/test_odoo_module_branch.py index cca1a9fa..a96d9f14 100644 --- a/odoo_repository_migration/tests/test_odoo_module_branch.py +++ b/odoo_repository_migration/tests/test_odoo_module_branch.py @@ -84,7 +84,10 @@ def test_migration_scan_missing_migration_path(self): # Once we collected migration data for the expected branch+commit # the module doesn't require a migration scan anymore self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=self.branch2, + report={"process": "migrate", "results": {}}, ) self.assertTrue(self.module_branch.migration_ids) self.assertFalse(self.module_branch.migration_scan) @@ -113,7 +116,10 @@ def test_migration_scan_target_module_in_review_then_merged(self): self.assertFalse(self.module_branch.migration_ids) self.assertTrue(self.module_branch.migration_scan) self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=self.branch2, + report={"process": "migrate", "results": {}}, ) self.assertTrue(self.module_branch.migration_ids) self.assertFalse(self.module_branch.migration_ids.migration_scan) @@ -141,6 +147,8 @@ def test_migration_scan_target_module_in_review_then_merged(self): # The source module doesn't need a migration scan anymore. self._simulate_migration_scan( "target_commit1", + source=self.branch, + target=self.branch2, report={ "process": "migrate", "results": {"existing_pr": {"url": target_module_branch.pr_url}}, @@ -171,7 +179,12 @@ def test_migration_scan_target_module_in_review_then_merged(self): # Simulate the migration scan. # The source module is fully ported and doesn't need a migration # scan afterwards. - self._simulate_migration_scan("target_commit2", report={"results": {}}) + self._simulate_migration_scan( + "target_commit2", + source=self.branch, + target=self.branch2, + report={"results": {}}, + ) self.assertEqual(self.module_branch.migration_ids.state, "fully_ported") self.assertFalse(self.module_branch.migration_ids.migration_scan) self.assertFalse(self.module_branch.migration_scan) @@ -187,7 +200,10 @@ def test_migration_scan_target_module_moved_to_standard(self): } ) self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=self.branch2, + report={"process": "migrate", "results": {}}, ) self.assertTrue(self.module_branch.migration_ids) mig = self.module_branch.migration_ids @@ -224,7 +240,10 @@ def test_migration_scan_target_module_moved_to_oca(self): } ) self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=self.branch2, + report={"process": "migrate", "results": {}}, ) self.assertTrue(self.module_branch.migration_ids) mig = self.module_branch.migration_ids @@ -261,7 +280,10 @@ def test_migration_scan_target_module_moved_to_generic(self): } ) self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=self.branch2, + report={"process": "migrate", "results": {}}, ) self.assertTrue(self.module_branch.migration_ids) mig = self.module_branch.migration_ids @@ -310,7 +332,10 @@ def test_renamed_to_module_in_target_version(self): } ) self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=next_branch, + report={"process": "migrate", "results": {}}, ) # Module has been renamed starting from 16.0 self.module_branch.timeline_ids.create( @@ -365,7 +390,10 @@ def test_replaced_by_module_in_target_version(self): } ) self._simulate_migration_scan( - "target_commit1", report={"process": "migrate", "results": {}} + "target_commit1", + source=self.branch, + target=next_branch, + report={"process": "migrate", "results": {}}, ) # New module is replacing current one starting from 16.0 self.module_branch.timeline_ids.create( From 32ccfcb7f88e59afc8543fa7dc59f1892e615ce2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Sun, 31 May 2026 18:49:19 +0200 Subject: [PATCH 4/8] [FIX] odoo_repository_migration: ensure a migration record exists In `_scan_migration_module`, ensure a migration record exists for replaced modules. --- odoo_repository_migration/models/odoo_repository.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/odoo_repository_migration/models/odoo_repository.py b/odoo_repository_migration/models/odoo_repository.py index ef01706d..17f299e8 100644 --- a/odoo_repository_migration/models/odoo_repository.py +++ b/odoo_repository_migration/models/odoo_repository.py @@ -190,6 +190,15 @@ def _scan_migration_module( migration_path.target_branch_id ) if replaced_by_module: + # Ensure a migration record exists before returning + values = { + "module_branch_id": module_branch_id, + "migration_path_id": migration_path_id, + "last_source_scanned_commit": module.last_scanned_commit, + } + self.env["odoo.module.branch.migration"]._create_or_update( + module_branch_id, migration_path, values + ) return ( f"{module.name} is now replaced by " f"{replaced_by_module.name}, no need to collect " From ec3f1f2fa79925abd95bc94df622115c0555a947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Tue, 9 Jun 2026 18:57:59 +0200 Subject: [PATCH 5/8] [IMP] odoo_repository_migration: add helper to get all related timelines recursively --- .../models/odoo_module_branch.py | 17 ++++ .../models/odoo_module_branch_timeline.py | 45 +++++++++ odoo_repository_migration/tests/common.py | 10 ++ .../tests/test_odoo_module_branch_timeline.py | 99 +++++++++++++++++++ 4 files changed, 171 insertions(+) create mode 100644 odoo_repository_migration/tests/test_odoo_module_branch_timeline.py diff --git a/odoo_repository_migration/models/odoo_module_branch.py b/odoo_repository_migration/models/odoo_module_branch.py index 683572af..4b932425 100644 --- a/odoo_repository_migration/models/odoo_module_branch.py +++ b/odoo_repository_migration/models/odoo_module_branch.py @@ -208,3 +208,20 @@ def open_next_module_branches(self): action["name"] = _("Next versions") action["domain"] = [("id", "in", self._get_next_module_branches().ids)] return action + + def _get_related_timelines(self): + """Return timelines related to current module. + + A module A could be renamed to a module B in version X, then renamed + again to C in version X+1, etc. + When calling this method on any module, all related timelines are returned. + """ + self.ensure_one() + timeline = self.env["odoo.module.branch.timeline"].search( + [ + "|", + ("module_branch_id.module_id", "=", self.module_id.id), + ("next_module_id", "=", self.module_id.id), + ] + ) + return timeline._get_related_timelines() if timeline else timeline diff --git a/odoo_repository_migration/models/odoo_module_branch_timeline.py b/odoo_repository_migration/models/odoo_module_branch_timeline.py index b924a991..2258df0d 100644 --- a/odoo_repository_migration/models/odoo_module_branch_timeline.py +++ b/odoo_repository_migration/models/odoo_module_branch_timeline.py @@ -100,3 +100,48 @@ def _inverse_next_fields(self): migrations._compute_renamed_to_module_id() migrations._compute_replaced_by_module_id() migrations._compute_state() + + def _get_related_timelines(self): + """Return timelines related to current ones regarding impacted modules. + + A module A could be renamed to a module B in version X, then renamed + again to C in version X+1, etc. + When calling this method on any timeline, the others are returned. + """ + impacted_modules = self._get_all_impacted_modules() + return self.search( + [ + "|", + ("module_branch_id.module_id", "in", impacted_modules.ids), + ("next_module_id", "in", impacted_modules.ids), + ] + ) + + # TODO: add ormcache? + def _get_all_impacted_modules(self, visited=None): + """Recursively collect all modules involved in the chain of timelines.""" + if visited is None: + visited = self.env["odoo.module.branch.timeline"].browse() + impacted_modules = self.env["odoo.module"].browse() + for rec in self: + if rec in visited: + continue + visited |= rec + impacted_modules |= rec.module_branch_id.module_id + rec.next_module_id + # Find previous timelines (current module is next_module_id) + previous_timelines = self.search( + [ + ("next_module_id", "=", rec.module_branch_id.module_id.id), + ] + ) + for timeline in previous_timelines: + impacted_modules |= timeline._get_all_impacted_modules(visited) + # Find next timelines (next_module_id is the module_branch_id) + next_timelines = self.search( + [ + ("module_branch_id.module_id", "=", rec.next_module_id.id), + ] + ) + for timeline in next_timelines: + impacted_modules |= timeline._get_all_impacted_modules(visited) + return impacted_modules diff --git a/odoo_repository_migration/tests/common.py b/odoo_repository_migration/tests/common.py index f17b19c9..7819a6c7 100644 --- a/odoo_repository_migration/tests/common.py +++ b/odoo_repository_migration/tests/common.py @@ -68,3 +68,13 @@ def _simulate_migration_scan(cls, target_commit, source, target, report=None): module_branch.id, data, ) + + @classmethod + def _create_timeline(cls, module_branch, next_module, state): + return cls.env["odoo.module.branch.timeline"].create( + { + "module_branch_id": module_branch.id, + "state": state, + "next_module_id": next_module.id, + } + ) diff --git a/odoo_repository_migration/tests/test_odoo_module_branch_timeline.py b/odoo_repository_migration/tests/test_odoo_module_branch_timeline.py new file mode 100644 index 00000000..002e61aa --- /dev/null +++ b/odoo_repository_migration/tests/test_odoo_module_branch_timeline.py @@ -0,0 +1,99 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import MigrationCommon + + +class TestOdooModuleBranchTimeline(MigrationCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.branch4_name = str(float(cls.branch3.name) + 1) + cls.branch4 = ( + cls.env["odoo.branch"] + .with_context(active_test=False) + .search([("name", "=", cls.branch4_name)]) + ) + if not cls.branch4: + cls.branch4 = cls.env["odoo.branch"].create( + { + "name": cls.branch4_name, + } + ) + cls.repo_branch4 = cls._create_odoo_repository_branch( + cls.odoo_repository, cls.branch4 + ) + # Create modules A, B, C and D + cls.module_a = cls.module.copy({"name": "module_a"}) + cls.module_branch_a = cls._create_odoo_module_branch( + cls.module_a, + cls.branch, + specific=False, + repository_branch_id=cls.repo_branch.id, + last_scanned_commit="sha_a", + ) + cls.module_b = cls.module.copy({"name": "module_b"}) + cls.module_branch_b = cls._create_odoo_module_branch( + cls.module_b, + cls.branch2, + specific=False, + repository_branch_id=cls.repo_branch2.id, + last_scanned_commit="sha_b", + ) + cls.module_c = cls.module.copy({"name": "module_c"}) + cls.module_branch_c = cls._create_odoo_module_branch( + cls.module_c, + cls.branch3, + specific=False, + repository_branch_id=cls.repo_branch3.id, + last_scanned_commit="sha_c", + ) + cls.module_d = cls.module.copy({"name": "module_d"}) + cls.module_branch_d = cls._create_odoo_module_branch( + cls.module_d, + cls.branch4, + specific=False, + repository_branch_id=cls.repo_branch4.id, + last_scanned_commit="sha_d", + ) + + def test_get_related_timelines(self): + """Test _get_related_timelines returns all timelines in a chain. + + Data input: + - module A renamed/replaced to B in X+1 + - module B renamed/replaced to C in X+2 + - module C renamed/replaced to D in X+3 + + Expected output: + - Calling _get_related_timelines on any timeline should return all timelines + """ + # Create timelines + timeline_b = self._create_timeline( + self.module_branch_a, self.module_b, "renamed" + ) + timeline_c = self._create_timeline( + self.module_branch_b, self.module_c, "replaced" + ) + timeline_d = self._create_timeline( + self.module_branch_c, self.module_d, "renamed" + ) + # Test from first timeline (A -> B) + related_timelines = timeline_b._get_related_timelines() + self.assertIn(timeline_b, related_timelines) + self.assertIn(timeline_c, related_timelines) + self.assertIn(timeline_d, related_timelines) + self.assertEqual(len(related_timelines), 3) + # Test from second timeline (B -> C) + related_timelines = timeline_c._get_related_timelines() + self.assertIn(timeline_b, related_timelines) + self.assertIn(timeline_c, related_timelines) + self.assertIn(timeline_d, related_timelines) + self.assertEqual(len(related_timelines), 3) + # Test from third (and last) timeline (C -> D) + related_timelines = timeline_d._get_related_timelines() + self.assertIn(timeline_b, related_timelines) + self.assertIn(timeline_c, related_timelines) + self.assertIn(timeline_d, related_timelines) + self.assertEqual(len(related_timelines), 3) From f42c3b5961ab5b6bb786dd1947ef9652f53d6e45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Wed, 27 May 2026 14:36:18 +0200 Subject: [PATCH 6/8] [FIX] odoo_repository_migration: handle modules renamed/replaced multiple times A module can be renamed/replaced multiple time between two Odoo versions, and the migration data should show the last relevant module. Also, the computation of relevant migration data is automatically triggered when timelines are created/updated. A convenient "Scan" button has been added on timeline form to trigger a scan on relevant modules if needed, as well as a "Migrations" smart button to access related migration data records. --- odoo_repository_migration/README.rst | 6 +- odoo_repository_migration/__manifest__.py | 4 +- .../migrations/18.0.1.1.0/post-migrate.py | 23 ++ .../models/odoo_module_branch.py | 83 ++-- .../models/odoo_module_branch_migration.py | 38 +- .../models/odoo_module_branch_timeline.py | 107 ++++- .../static/description/index.html | 26 +- odoo_repository_migration/tests/common.py | 3 + .../tests/test_odoo_module_branch.py | 375 ++++++++++++++++-- .../views/odoo_module_branch_migration.xml | 1 + .../views/odoo_module_branch_timeline.xml | 57 ++- 11 files changed, 610 insertions(+), 113 deletions(-) create mode 100644 odoo_repository_migration/migrations/18.0.1.1.0/post-migrate.py diff --git a/odoo_repository_migration/README.rst b/odoo_repository_migration/README.rst index ef343be3..fe1f7235 100644 --- a/odoo_repository_migration/README.rst +++ b/odoo_repository_migration/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - ============================== Odoo Repository Migration Data ============================== @@ -17,7 +13,7 @@ Odoo Repository Migration Data .. |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/license-AGPL--3-blue.png +.. |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 diff --git a/odoo_repository_migration/__manifest__.py b/odoo_repository_migration/__manifest__.py index f9083731..8b6b5e61 100644 --- a/odoo_repository_migration/__manifest__.py +++ b/odoo_repository_migration/__manifest__.py @@ -1,9 +1,11 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) { "name": "Odoo Repository Migration Data", "summary": "Collect modules migration data for Odoo Repositories.", - "version": "18.0.1.0.1", + "version": "18.0.1.1.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", diff --git a/odoo_repository_migration/migrations/18.0.1.1.0/post-migrate.py b/odoo_repository_migration/migrations/18.0.1.1.0/post-migrate.py new file mode 100644 index 00000000..44fd4109 --- /dev/null +++ b/odoo_repository_migration/migrations/18.0.1.1.0/post-migrate.py @@ -0,0 +1,23 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + recompute_timeline_migration_data(env) + + +def recompute_timeline_migration_data(env): + """Recompute migration data related to timelines.""" + timelines = env["odoo.module.branch.timeline"].search([]) + _logger.info("Recompute migration data related to %s timelines...", len(timelines)) + timelines.migration_ids.force_update() diff --git a/odoo_repository_migration/models/odoo_module_branch.py b/odoo_repository_migration/models/odoo_module_branch.py index 4b932425..96ff6ced 100644 --- a/odoo_repository_migration/models/odoo_module_branch.py +++ b/odoo_repository_migration/models/odoo_module_branch.py @@ -1,4 +1,6 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import _, api, fields, models @@ -69,13 +71,18 @@ def _replaced_by_module_in_target_version(self, target_branch): We give the priority to last modules while checking them. """ self.ensure_one() - modules = self._get_next_versions(target_branch) - for module in modules.sorted( - key=lambda mod: mod.branch_id.sequence, reverse=True - ): - if module.timeline_ids.state == "replaced": - return module.timeline_ids.next_module_id - return self.env["odoo.module"] + replaced_by = self.env["odoo.module"] + next_ = self + while next_ and next_.branch_sequence < target_branch.sequence: + timeline = next_.timeline_ids + if timeline.state == "replaced": + replaced_by = timeline.next_module_id + next_ = next_.next_odoo_version_module_branch_id + # If a module has been renamed after been replaced, the module to + # return has to be the renamed one + if next_ and replaced_by: + replaced_by = next_.module_id + return replaced_by def _renamed_to_module_in_target_version(self, target_branch): """Return the new module technical name in last module versions. @@ -87,22 +94,42 @@ def _renamed_to_module_in_target_version(self, target_branch): We give the priority to last modules while checking them. """ self.ensure_one() - modules = self._get_next_versions(target_branch) - for module in modules.sorted( - key=lambda mod: mod.branch_id.sequence, reverse=True - ): - if module.timeline_ids.state == "renamed": - return module.timeline_ids.next_module_id - return self.env["odoo.module"] - - def _get_next_versions(self, target_branch): + renamed_to = self.env["odoo.module"] + next_ = self + while next_ and next_.branch_sequence < target_branch.sequence: + timeline = next_.timeline_ids + # As soon as the module has been replaced in the chain of versions + # it cannot be considered as renamed anymore + if timeline.state == "replaced": + renamed_to = self.env["odoo.module"] + next_ = False + continue + if timeline.state == "renamed": + renamed_to = timeline.next_module_id + next_ = next_.next_odoo_version_module_branch_id + return renamed_to + + def _get_next_versions( + self, target_branch=None, order="branch_sequence", limit=None + ): + """Return all available upcoming versions. + + It doesn't take into account module renaming or replacement. Only versions + for the current module name are returned. + """ self.ensure_one() + if not target_branch: + target_branch = self.env["odoo.branch"]._get_last_version() return self.env["odoo.module.branch"].search( [ ("module_id", "=", self.module_id.id), + ("repository_id", "!=", False), + ("installable", "=", True), ("branch_id.sequence", ">=", self.branch_id.sequence), ("branch_id.sequence", "<", target_branch.sequence), - ] + ], + order=order, + limit=limit, ) def _get_next_module_branches(self, target_branch=None): @@ -177,29 +204,31 @@ def _to_dict(self): @api.model_create_multi def create(self, vals_list): recs = super().create(vals_list) - recs._update_migration_target_module_id() + recs._update_migration_data() return recs def write(self, vals): res = super().write(vals) # When 'pr_url' is set or unset, this means the module has been found - # in a PR or has been merged upstream. We want to recompute the target - # module in migration data in such case. + # in a PR or has been merged upstream. We want to refresh relevant + # migration data in such case. if "pr_url" in vals: - self._update_migration_target_module_id() + self._update_migration_data() return res - def _update_migration_target_module_id(self): - """Update `target_module_id` field on relevant module migration records.""" + def _update_migration_data(self): + """Update relevant module migration records.""" for rec in self: + timelines = rec._get_related_timelines() + impacted_modules = rec.module_id | timelines._get_all_impacted_modules() migrations = self.env["odoo.module.branch.migration"].search( [ - ("module_id", "=", rec.module_id.id), - ("target_branch_id", "=", rec.branch_id.id), + ("module_id", "in", impacted_modules.ids), + ("source_branch_id.sequence", "<=", rec.branch_id.sequence), + ("target_branch_id.sequence", ">=", rec.branch_id.sequence), ] ) - # Recompute 'target_module_id' field - migrations._compute_target_module_branch_id() + migrations.force_update() def open_next_module_branches(self): self.ensure_one() diff --git a/odoo_repository_migration/models/odoo_module_branch_migration.py b/odoo_repository_migration/models/odoo_module_branch_migration.py index 24980f0e..084f6109 100644 --- a/odoo_repository_migration/models/odoo_module_branch_migration.py +++ b/odoo_repository_migration/models/odoo_module_branch_migration.py @@ -1,4 +1,6 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) import pprint @@ -205,11 +207,9 @@ def _compute_moved_to_generic(self): and not rec.target_module_branch_id.repository_id.specific ) - @api.depends( - "module_branch_id.timeline_ids.state", - "module_branch_id.timeline_ids.next_module_id", - "target_branch_id", - ) + # NOTE: _renamed_to_module_in_target_version relies also on timelines, and + # this computed field will be recomputed automatically on timeline updates. + @api.depends("target_branch_id") def _compute_renamed_to_module_id(self): for rec in self: rec.renamed_to_module_id = ( @@ -218,11 +218,9 @@ def _compute_renamed_to_module_id(self): ) ) - @api.depends( - "module_branch_id.timeline_ids.state", - "module_branch_id.timeline_ids.next_module_id", - "target_branch_id", - ) + # NOTE: _replaced_by_module_in_target_version relies also on timelines, and + # this computed field will be recomputed automatically on timeline updates. + @api.depends("target_branch_id") def _compute_replaced_by_module_id(self): for rec in self: rec.replaced_by_module_id = ( @@ -260,7 +258,14 @@ def _compute_state(self): # Specific module moved to a generic repository (public or private) rec.state = "moved_to_generic" continue - rec.state = rec.process or "fully_ported" + rec.state = rec.process or ( + "fully_ported" + if ( + rec.target_module_branch_id + and not rec.target_module_branch_id.pr_url + ) + else "migrate" + ) if rec.process == "migrate" and rec.pr_url: rec.state = "review_migration" @@ -313,6 +318,17 @@ def _compute_migration_scan(self): elif rec.target_module_branch_id.pr_url != rec.pr_url: rec.migration_scan = True + def force_update(self): + """Ensure the migration data is updated or will be updated on next scan.""" + self_sudo = self.sudo() + self_sudo.last_target_scanned_commit = False + self_sudo.results = self_sudo.process = False + self_sudo._compute_replaced_by_module_id() + self_sudo._compute_renamed_to_module_id() + self_sudo._compute_target_module_branch_id() + self_sudo._compute_state() + self_sudo._compute_migration_scan() + @api.model @api.returns("odoo.module.branch.migration") def push_scanned_data(self, module_branch_id, data): diff --git a/odoo_repository_migration/models/odoo_module_branch_timeline.py b/odoo_repository_migration/models/odoo_module_branch_timeline.py index 2258df0d..fae36ce6 100644 --- a/odoo_repository_migration/models/odoo_module_branch_timeline.py +++ b/odoo_repository_migration/models/odoo_module_branch_timeline.py @@ -1,4 +1,6 @@ # Copyright 2025 Camptocamp SA +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) from odoo import api, fields, models @@ -15,6 +17,12 @@ class OdooModuleBranchTimeline(models.Model): required=True, index=True, ) + last_known_module_branch_id = fields.Many2one( + string="Last Known Module", + comodel_name="odoo.module.branch", + compute="_compute_last_known_module_branch_id", + help="Technical field to check if current selected module is the last one.", + ) org_id = fields.Many2one( string="Organization", related="module_branch_id.org_id", @@ -42,7 +50,6 @@ class OdooModuleBranchTimeline(models.Model): ("renamed", "has been renamed to"), ("replaced", "has been replaced by"), ], - inverse="_inverse_next_fields", default="renamed", required=True, index=True, @@ -56,7 +63,6 @@ class OdooModuleBranchTimeline(models.Model): next_module_id = fields.Many2one( string="New module name", comodel_name="odoo.module", - inverse="_inverse_next_fields", ondelete="restrict", index=True, ) @@ -65,6 +71,16 @@ class OdooModuleBranchTimeline(models.Model): related="module_branch_id.next_odoo_version_module_branch_id", ) note = fields.Html() + migration_ids = fields.Many2many( + comodel_name="odoo.module.branch.migration", + string="Migrations", + compute="_compute_migration_ids", + ) + migration_scan = fields.Boolean( + compute="_compute_migration_scan", + help="Technical field telling if this timeline needs a migration scan.", + ) + active = fields.Boolean(default=True) @api.depends("odoo_version_id", "module_branch_id", "next_module_id") def _compute_display_name(self): @@ -74,32 +90,93 @@ def _compute_display_name(self): f"{rec.module_branch_id.module_id.name} > {rec.next_module_id.name}" ) - def _inverse_next_fields(self): - # When a module is renamed or replaced, we reset the - # last target scan commits on all impacted migration paths. - # E.g. - # if a module on 17.0 is set as renamed starting from 18.0, - # all migration paths of this module targetting versions >= 18.0 - # should re-trigger a migration scan. + @api.depends("module_branch_id") + def _compute_last_known_module_branch_id(self): + for rec in self: + rec.last_known_module_branch_id = self.env["odoo.module.branch"].search( + [ + ("module_id", "=", rec.module_branch_id.module_id.id), + ("repository_id", "!=", False), + ("installable", "=", True), + ( + "branch_id.sequence", + ">=", + rec.module_branch_id.branch_id.sequence, + ), + ], + order="branch_sequence DESC", + limit=1, + ) + + @api.depends("module_branch_id", "next_module_id", "odoo_version_id") + def _compute_migration_ids(self): for rec in self: - migrations = ( + current_module = rec.module_branch_id.module_id + next_module = rec.next_module_id + impacted_modules = current_module + next_module + rec.migration_ids = ( self.env["odoo.module.branch.migration"] .search( [ - ("module_id", "=", rec.module_branch_id.module_id.id), ( "target_branch_id.sequence", ">=", rec.odoo_version_id.sequence, ), + "|", + "|", + ("module_id", "=", current_module.id), + ("renamed_to_module_id", "in", impacted_modules.ids), + ("replaced_by_module_id", "in", impacted_modules.ids), ] ) .sudo() ) - migrations.last_target_scanned_commit = False - migrations._compute_renamed_to_module_id() - migrations._compute_replaced_by_module_id() - migrations._compute_state() + + def _compute_migration_scan(self): + for rec in self: + rec.migration_scan = any(rec.migration_ids.mapped("migration_scan")) + + # Update relevant migration records on timeline update. + # + # When a module is renamed or replaced, we refresh all impacted + # migration data records and reset their last scan commits to trigger + # a new migration scan. + # + # E.g. + # if a module on 17.0 is set as renamed starting from 18.0, existing + # migration data (17.0 -> 18.0, or 17.0 -> 19.0) will be aware of such + # change and all related modules will have to be scanned again. + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records.migration_ids.force_update() + return records + + @api.model + def write(self, vals): + migrations = self.migration_ids + res = super().write(vals) + (self.migration_ids | migrations).force_update() + return res + + def unlink(self): + migrations = self.migration_ids + res = super().unlink() + migrations.force_update() + return res + + def open_migrations(self): + self.ensure_one() + xml_id = "odoo_repository_migration.odoo_module_branch_migration_action" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["domain"] = [("id", "in", self.migration_ids.ids)] + return action + + def action_scan(self): + self.migration_ids.module_branch_id.repository_branch_id.action_scan() + return True def _get_related_timelines(self): """Return timelines related to current ones regarding impacted modules. diff --git a/odoo_repository_migration/static/description/index.html b/odoo_repository_migration/static/description/index.html index c172a5cd..cd5a519b 100644 --- a/odoo_repository_migration/static/description/index.html +++ b/odoo_repository_migration/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Odoo Repository Migration Data -
+
+

Odoo Repository Migration Data

- - -Odoo Community Association - -
-

Odoo Repository Migration Data

-

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

+

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

This module collects and records modules migration data from Odoo repositories.

Thanks to oca-port and data @@ -457,7 +452,7 @@

Odoo Repository Migration Data

-

Usage

+

Usage

To enable this feature, the option Collect migration data should be enabled on repositories (opt-in).

To record what a module became starting from a given Odoo version, you @@ -468,7 +463,7 @@

Usage

Modules / Migrations menu.

-

Bug Tracker

+

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 @@ -476,15 +471,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -506,6 +501,5 @@

Maintainers

-
diff --git a/odoo_repository_migration/tests/common.py b/odoo_repository_migration/tests/common.py index 7819a6c7..ce18f620 100644 --- a/odoo_repository_migration/tests/common.py +++ b/odoo_repository_migration/tests/common.py @@ -16,6 +16,9 @@ def setUpClass(cls): cls.repo_branch2 = cls._create_odoo_repository_branch( cls.odoo_repository, cls.branch2 ) + cls.repo_branch3 = cls._create_odoo_repository_branch( + cls.odoo_repository, cls.branch3 + ) cls.module_branch = cls._create_odoo_module_branch( cls.module, cls.branch, diff --git a/odoo_repository_migration/tests/test_odoo_module_branch.py b/odoo_repository_migration/tests/test_odoo_module_branch.py index a96d9f14..322105f4 100644 --- a/odoo_repository_migration/tests/test_odoo_module_branch.py +++ b/odoo_repository_migration/tests/test_odoo_module_branch.py @@ -34,13 +34,7 @@ def test_next_odoo_version_module_branch_id_with_renamed_module(self): last_scanned_commit="sha", ) # Add timeline entry for renaming - self.module_branch.timeline_ids.create( - { - "module_branch_id": self.module_branch.id, - "state": "renamed", - "next_module_id": next_module.id, - } - ) + self._create_timeline(self.module_branch, next_module, "renamed") # Test next_odoo_version_module_branch_id follows renaming self.assertEqual( self.module_branch.next_odoo_version_module_branch_id, next_module_branch @@ -132,7 +126,7 @@ def test_migration_scan_target_module_in_review_then_merged(self): self.module, self.branch2, specific=False, - repository_branch_id=self.repo_branch.id, + repository_branch_id=self.repo_branch2.id, # Module available in a PR pr_url="https://my/pr", ) @@ -167,9 +161,6 @@ def test_migration_scan_target_module_in_review_then_merged(self): "pr_url": False, } ) - self.module_branch.migration_ids.last_target_scanned_commit = ( - target_module_branch.last_scanned_commit - ) self.assertEqual( self.module_branch.migration_ids.target_module_branch_id, target_module_branch, @@ -308,9 +299,19 @@ def test_migration_scan_target_module_moved_to_generic(self): self.assertEqual(mig.state, "moved_to_generic") self.assertFalse(mig.migration_scan) - def test_renamed_to_module_in_target_version(self): + def test_renamed_module(self): + """Test migration data of a module renamed. + + Data input: + - source version = X + - target version = X+1 + - module renamed in X+1 + + Expected ouput: + - 'renamed_to_module_id' should target module renamed in X+1 + """ self.odoo_repository.collect_migration_data = True - # Next version is 16.0 + # Next version is X+1 next_branch = self.env["odoo.branch"].search( [("sequence", "=", self.branch.sequence + 1)] ) @@ -321,10 +322,10 @@ def test_renamed_to_module_in_target_version(self): new_module, next_branch, specific=False, - repository_branch_id=self.repo_branch.id, + repository_branch_id=self.repo_branch2.id, last_scanned_commit="sha", ) - # Generate migration data records + # Generate migration data records from X to X+1 self.env["odoo.migration.path"].create( { "source_branch_id": self.branch.id, @@ -337,19 +338,13 @@ def test_renamed_to_module_in_target_version(self): target=next_branch, report={"process": "migrate", "results": {}}, ) - # Module has been renamed starting from 16.0 - self.module_branch.timeline_ids.create( - { - "module_branch_id": self.module_branch.id, - "state": "renamed", - "next_module_id": new_module.id, - } - ) + # Module has been renamed starting from X+1 + self._create_timeline(self.module_branch, new_module, "renamed") renamed_to_module = self.module_branch._renamed_to_module_in_target_version( next_branch ) self.assertEqual(renamed_to_module, new_module) - # We target 17.0 to check if intermediate data in 16.0 is found + # We target X+2 to check if intermediate data in X+1 is found target_branch = self.env["odoo.branch"].search( [("sequence", "=", self.branch.sequence + 2)] ) @@ -363,12 +358,97 @@ def test_renamed_to_module_in_target_version(self): self.assertFalse(mig.replaced_by_module_id) self.assertEqual(mig.target_module_branch_id, target_module_branch) self.assertFalse(mig.last_target_scanned_commit) - self.assertEqual(mig.state, "migrate") + self.assertEqual(mig.state, "fully_ported") self.assertTrue(mig.migration_scan) - def test_replaced_by_module_in_target_version(self): + def test_renamed_module_twice(self): + """Test migration data of a module renamed twice. + + Data input: + - source version = X + - target version = X+2 + - module renamed in X+1 + - module renamed again in X+2 + + Expected ouput: + - 'renamed_to_module_id' should target module renamed in X+2 + """ self.odoo_repository.collect_migration_data = True - # Next version is 16.0 + # Next version is X+1 + next_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 1)] + ) + self.assertEqual(self.branch.next_id, next_branch) + # Target version is X+2 + target_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 2)] + ) + self.assertEqual(self.branch.next_id.next_id, target_branch) + # Create the next module + next_module = self.module.copy({"name": "next_module"}) + next_module_branch = self._create_odoo_module_branch( + next_module, + next_branch, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha_next", + ) + # Create the target module + target_module = self.module.copy({"name": "target_module"}) + target_module_branch = self._create_odoo_module_branch( + target_module, + target_branch, + specific=False, + repository_branch_id=self.repo_branch3.id, + last_scanned_commit="sha_target", + ) + # Generate migration data records from X to X+2 (with a gap) + self.env["odoo.migration.path"].create( + { + "source_branch_id": self.branch.id, + "target_branch_id": target_branch.id, + } + ) + self._simulate_migration_scan( + "target_commit1", + source=self.branch, + target=target_branch, + report={"process": "migrate", "results": {}}, + ) + # Module has been renamed in X+1 + self._create_timeline(self.module_branch, next_module, "renamed") + renamed_to_module = self.module_branch._renamed_to_module_in_target_version( + next_branch + ) + self.assertEqual(renamed_to_module, next_module) + # Module has been renamed again in X+2 + self._create_timeline(next_module_branch, target_module, "renamed") + renamed_to_module = next_module_branch._renamed_to_module_in_target_version( + target_branch + ) + self.assertEqual(renamed_to_module, target_module) + # Check migration data + mig = self.module_branch.migration_ids + self.assertEqual(mig.renamed_to_module_id, target_module) + self.assertFalse(mig.replaced_by_module_id) + self.assertEqual(mig.target_module_branch_id, target_module_branch) + self.assertFalse(mig.last_target_scanned_commit) + self.assertEqual(mig.state, "fully_ported") + self.assertTrue(mig.migration_scan) + + def test_replaced_module(self): + """Test migration data of a module replaced. + + Data input: + - source version = X + - target version = X+1 + - module replaced in X+1 + + Expected ouput: + - 'replaced_by_module_id' should target module replaced in X+1 + """ + self.odoo_repository.collect_migration_data = True + # Next version is X+1 next_branch = self.env["odoo.branch"].search( [("sequence", "=", self.branch.sequence + 1)] ) @@ -395,19 +475,13 @@ def test_replaced_by_module_in_target_version(self): target=next_branch, report={"process": "migrate", "results": {}}, ) - # New module is replacing current one starting from 16.0 - self.module_branch.timeline_ids.create( - { - "module_branch_id": self.module_branch.id, - "state": "replaced", - "next_module_id": new_module.id, - } - ) + # New module is replacing current one starting from X+1 + self._create_timeline(self.module_branch, new_module, "replaced") replaced_by_module = self.module_branch._replaced_by_module_in_target_version( next_branch ) self.assertEqual(replaced_by_module, new_module) - # We target 17.0 to check if intermediate data in 16.0 is found + # We target X+2 to check if intermediate data in X+1 is found target_branch = self.env["odoo.branch"].search( [("sequence", "=", self.branch.sequence + 2)] ) @@ -422,4 +496,233 @@ def test_replaced_by_module_in_target_version(self): self.assertEqual(mig.target_module_branch_id, target_module_branch) self.assertFalse(mig.last_target_scanned_commit) self.assertEqual(mig.state, "replaced") + # No migration scan needed for replaced modules + self.assertFalse(mig.migration_scan) + + def test_replaced_module_twice(self): + """Test migration data of a module replaced twice. + + Data input: + - source version = X + - target version = X+2 + - module replaced in X+1 + - module replaced again in X+2 + + Expected ouput: + - 'replaced_by_module_id' should target module replaced in X+2 + """ + self.odoo_repository.collect_migration_data = True + # Next version is X+1 + next_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 1)] + ) + self.assertEqual(self.branch.next_id, next_branch) + # Target version is X+2 + target_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 2)] + ) + self.assertEqual(self.branch.next_id.next_id, target_branch) + # Create the next module + next_module = self.module.copy({"name": "next_module"}) + next_module_branch = self._create_odoo_module_branch( + next_module, + next_branch, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha_next", + ) + # Create the target module + target_module = self.module.copy({"name": "target_module"}) + target_module_branch = self._create_odoo_module_branch( + target_module, + target_branch, + specific=False, + repository_branch_id=self.repo_branch3.id, + last_scanned_commit="sha_target", + ) + # Generate migration data records from X to X+2 (with a gap) + self.env["odoo.migration.path"].create( + { + "source_branch_id": self.branch.id, + "target_branch_id": target_branch.id, + } + ) + self._simulate_migration_scan( + "target_commit1", + source=self.branch, + target=target_branch, + report={"process": "migrate", "results": {}}, + ) + # Module has been replaced in X+1 + self._create_timeline(self.module_branch, next_module, "replaced") + replaced_by_module = self.module_branch._replaced_by_module_in_target_version( + next_branch + ) + self.assertEqual(replaced_by_module, next_module) + # Module has been replaced again in X+2 + self._create_timeline(next_module_branch, target_module, "replaced") + replaced_by_module = next_module_branch._replaced_by_module_in_target_version( + target_branch + ) + self.assertEqual(replaced_by_module, target_module) + # Check migration data + mig = self.module_branch.migration_ids + self.assertEqual(mig.replaced_by_module_id, target_module) + self.assertFalse(mig.renamed_to_module_id) + self.assertEqual(mig.target_module_branch_id, target_module_branch) + self.assertFalse(mig.last_target_scanned_commit) + self.assertEqual(mig.state, "replaced") + # No migration scan needed for replaced modules + self.assertFalse(mig.migration_scan) + + def test_renamed_then_replaced_module(self): + """Test migration data of a module renamed then replaced. + + Data input: + - source version = X + - target version = X+2 + - module renamed in X+1 + - module replaced in X+2 + + Expected ouput: + - 'renamed_to_module_id' should be empty + - 'replaced_by_module_id' should target module replaced in X+2 + """ + self.odoo_repository.collect_migration_data = True + # Next version is X+1 + next_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 1)] + ) + self.assertEqual(self.branch.next_id, next_branch) + # Target version is X+2 + target_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 2)] + ) + self.assertEqual(self.branch.next_id.next_id, target_branch) + # Create the next module + next_module = self.module.copy({"name": "next_module"}) + next_module_branch = self._create_odoo_module_branch( + next_module, + next_branch, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha_next", + ) + # Create the target module + target_module = self.module.copy({"name": "target_module"}) + target_module_branch = self._create_odoo_module_branch( + target_module, + target_branch, + specific=False, + repository_branch_id=self.repo_branch3.id, + last_scanned_commit="sha_target", + ) + # Generate migration data records from X to X+2 (with a gap) + self.env["odoo.migration.path"].create( + { + "source_branch_id": self.branch.id, + "target_branch_id": target_branch.id, + } + ) + self._simulate_migration_scan( + "target_commit1", + source=self.branch, + target=target_branch, + report={"process": "migrate", "results": {}}, + ) + # Module has been renamed in X+1 + self._create_timeline(self.module_branch, next_module, "renamed") + renamed_to_module = self.module_branch._renamed_to_module_in_target_version( + next_branch + ) + self.assertEqual(renamed_to_module, next_module) + # Module has been replaced in X+2 + self._create_timeline(next_module_branch, target_module, "replaced") + replaced_by_module = next_module_branch._replaced_by_module_in_target_version( + target_branch + ) + self.assertEqual(replaced_by_module, target_module) + # Check migration data + mig = self.module_branch.migration_ids + self.assertFalse(mig.renamed_to_module_id) + self.assertEqual(mig.replaced_by_module_id, target_module) + self.assertEqual(mig.target_module_branch_id, target_module_branch) + self.assertFalse(mig.last_target_scanned_commit) + self.assertEqual(mig.state, "replaced") + self.assertFalse(mig.migration_scan) + + def test_replaced_then_renamed_module(self): + """Test migration data of a module replaced then renamed. + + Data input: + - source version = X + - target version = X+2 + - module replaced in X+1 + - module renamed in X+2 + + Expected ouput: + - 'renamed_to_module_id' should be empty + - 'replaced_by_module_id' should target module renamed in X+2 + """ + self.odoo_repository.collect_migration_data = True + # Next version is X+1 + next_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 1)] + ) + self.assertEqual(self.branch.next_id, next_branch) + # Target version is X+2 + target_branch = self.env["odoo.branch"].search( + [("sequence", "=", self.branch.sequence + 2)] + ) + self.assertEqual(self.branch.next_id.next_id, target_branch) + # Create the next module + next_module = self.module.copy({"name": "next_module"}) + next_module_branch = self._create_odoo_module_branch( + next_module, + next_branch, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha_next", + ) + # Create the target module + target_module = self.module.copy({"name": "target_module"}) + target_module_branch = self._create_odoo_module_branch( + target_module, + target_branch, + specific=False, + repository_branch_id=self.repo_branch3.id, + last_scanned_commit="sha_target", + ) + # Generate migration data records from X to X+2 (with a gap) + self.env["odoo.migration.path"].create( + { + "source_branch_id": self.branch.id, + "target_branch_id": target_branch.id, + } + ) + self._simulate_migration_scan( + "target_commit1", + source=self.branch, + target=target_branch, + report={"process": "migrate", "results": {}}, + ) + # Module has been replaced in X+1 + self._create_timeline(self.module_branch, next_module, "replaced") + replaced_by_module = self.module_branch._replaced_by_module_in_target_version( + next_branch + ) + self.assertEqual(replaced_by_module, next_module) + # Module has been renamed in X+2 + self._create_timeline(next_module_branch, target_module, "renamed") + renamed_to_module = next_module_branch._renamed_to_module_in_target_version( + target_branch + ) + self.assertEqual(renamed_to_module, target_module) + # Check migration data + mig = self.module_branch.migration_ids + self.assertFalse(mig.renamed_to_module_id) + self.assertEqual(mig.replaced_by_module_id, target_module) + self.assertEqual(mig.target_module_branch_id, target_module_branch) + self.assertFalse(mig.last_target_scanned_commit) + self.assertEqual(mig.state, "replaced") self.assertFalse(mig.migration_scan) diff --git a/odoo_repository_migration/views/odoo_module_branch_migration.xml b/odoo_repository_migration/views/odoo_module_branch_migration.xml index 8a5b311d..ff94afa1 100644 --- a/odoo_repository_migration/views/odoo_module_branch_migration.xml +++ b/odoo_repository_migration/views/odoo_module_branch_migration.xml @@ -8,6 +8,7 @@
+

diff --git a/odoo_repository_migration/views/odoo_module_branch_timeline.xml b/odoo_repository_migration/views/odoo_module_branch_timeline.xml index eb20d374..f361aa80 100644 --- a/odoo_repository_migration/views/odoo_module_branch_timeline.xml +++ b/odoo_repository_migration/views/odoo_module_branch_timeline.xml @@ -1,5 +1,7 @@ @@ -7,10 +9,55 @@ odoo.module.branch.timeline +
+
- - +
+
+ + + + + + + Date: Thu, 4 Jun 2026 10:02:38 +0200 Subject: [PATCH 7/8] [IMP] odoo_project_migration: list project migrations from migration records --- .../models/odoo_module_branch_migration.py | 17 ++++++++++++++++- .../views/odoo_module_branch_migration.xml | 12 ++++++++++++ .../views/odoo_project_module_migration.xml | 7 +++++++ 3 files changed, 35 insertions(+), 1 deletion(-) diff --git a/odoo_project_migration/models/odoo_module_branch_migration.py b/odoo_project_migration/models/odoo_module_branch_migration.py index e8492f4e..d3cebeef 100644 --- a/odoo_project_migration/models/odoo_module_branch_migration.py +++ b/odoo_project_migration/models/odoo_module_branch_migration.py @@ -1,10 +1,25 @@ # Copyright 2025 Camptocamp SA +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) -from odoo import fields, models +from odoo import _, fields, models class OdooModuleBranchMigration(models.Model): _inherit = "odoo.module.branch.migration" odoo_project_ids = fields.Many2many(related="module_branch_id.odoo_project_ids") + odoo_project_module_migration_ids = fields.One2many( + comodel_name="odoo.project.module.migration", + inverse_name="module_migration_id", + string="Project Migrations", + ) + + def open_project_migrations(self): + self.ensure_one() + xml_id = "odoo_project_migration.odoo_project_module_migration_action" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Project migrations") + action["domain"] = [("id", "in", self.odoo_project_module_migration_ids.ids)] + return action diff --git a/odoo_project_migration/views/odoo_module_branch_migration.xml b/odoo_project_migration/views/odoo_module_branch_migration.xml index 295e5cfa..7fc2bc2e 100644 --- a/odoo_project_migration/views/odoo_module_branch_migration.xml +++ b/odoo_project_migration/views/odoo_module_branch_migration.xml @@ -1,5 +1,7 @@ @@ -10,6 +12,16 @@ ref="odoo_repository_migration.odoo_module_branch_migration_view_form" /> +
+
diff --git a/odoo_project_migration/views/odoo_project_module_migration.xml b/odoo_project_migration/views/odoo_project_module_migration.xml index 75b4f202..8598d392 100644 --- a/odoo_project_migration/views/odoo_project_module_migration.xml +++ b/odoo_project_migration/views/odoo_project_module_migration.xml @@ -1,5 +1,7 @@ @@ -8,6 +10,11 @@ +
+

+ +

+
From a30d480dd9921d5244d19f0c373647bb559b9e9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Alix?= Date: Mon, 8 Jun 2026 11:54:06 +0200 Subject: [PATCH 8/8] [IMP] odoo_project_migration: compute migration status even w/o migration record available --- odoo_project_migration/README.rst | 6 +- odoo_project_migration/__manifest__.py | 5 +- .../migrations/18.0.1.1.0/post-migrate.py | 26 ++++ odoo_project_migration/models/__init__.py | 2 + .../models/odoo_module_branch.py | 33 +++++ .../models/odoo_module_branch_timeline.py | 77 ++++++++++ .../models/odoo_project_module_migration.py | 69 +++++++-- .../static/description/index.html | 24 ++- odoo_project_migration/tests/__init__.py | 1 + .../test_odoo_project_module_migration.py | 138 ++++++++++++++++++ .../views/odoo_module_branch_timeline.xml | 26 ++++ .../views/odoo_project_module_migration.xml | 8 + 12 files changed, 385 insertions(+), 30 deletions(-) create mode 100644 odoo_project_migration/migrations/18.0.1.1.0/post-migrate.py create mode 100644 odoo_project_migration/models/odoo_module_branch.py create mode 100644 odoo_project_migration/models/odoo_module_branch_timeline.py create mode 100644 odoo_project_migration/tests/test_odoo_project_module_migration.py create mode 100644 odoo_project_migration/views/odoo_module_branch_timeline.xml diff --git a/odoo_project_migration/README.rst b/odoo_project_migration/README.rst index 32a823a2..9bc01ed9 100644 --- a/odoo_project_migration/README.rst +++ b/odoo_project_migration/README.rst @@ -1,7 +1,3 @@ -.. image:: https://odoo-community.org/readme-banner-image - :target: https://odoo-community.org/get-involved?utm_source=readme - :alt: Odoo Community Association - =========================== Odoo Project Migration Data =========================== @@ -17,7 +13,7 @@ Odoo Project Migration Data .. |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/license-AGPL--3-blue.png +.. |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 diff --git a/odoo_project_migration/__manifest__.py b/odoo_project_migration/__manifest__.py index 373b1e70..c362249b 100644 --- a/odoo_project_migration/__manifest__.py +++ b/odoo_project_migration/__manifest__.py @@ -1,14 +1,17 @@ # Copyright 2023 Camptocamp SA +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) { "name": "Odoo Project Migration Data", "summary": "Analyze your Odoo project migrations.", - "version": "18.0.1.0.0", + "version": "18.0.1.1.0", "category": "Tools", "author": "Camptocamp, Odoo Community Association (OCA)", "website": "https://github.com/OCA/module-composition-analysis", "data": [ "security/ir.model.access.csv", + "views/odoo_module_branch_timeline.xml", "views/odoo_module_branch_migration.xml", "views/odoo_project.xml", "views/odoo_project_module_migration.xml", diff --git a/odoo_project_migration/migrations/18.0.1.1.0/post-migrate.py b/odoo_project_migration/migrations/18.0.1.1.0/post-migrate.py new file mode 100644 index 00000000..7acffa0f --- /dev/null +++ b/odoo_project_migration/migrations/18.0.1.1.0/post-migrate.py @@ -0,0 +1,26 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging + +from odoo import SUPERUSER_ID, api + +_logger = logging.getLogger(__name__) + + +def migrate(cr, version): + if not version: + return + env = api.Environment(cr, SUPERUSER_ID, {}) + recompute_timeline_project_migration_data(env) + + +def recompute_timeline_project_migration_data(env): + """Recompute project migration data related to timelines.""" + timelines = env["odoo.module.branch.timeline"].search([]) + _logger.info( + "Recompute project migration data related to %s timelines...", len(timelines) + ) + timelines.migration_ids.force_update() + timelines.project_migration_ids.force_update() diff --git a/odoo_project_migration/models/__init__.py b/odoo_project_migration/models/__init__.py index def3f96b..2752f8c8 100644 --- a/odoo_project_migration/models/__init__.py +++ b/odoo_project_migration/models/__init__.py @@ -1,3 +1,5 @@ +from . import odoo_module_branch +from . import odoo_module_branch_timeline from . import odoo_module_branch_migration from . import odoo_project_module from . import odoo_project_module_migration diff --git a/odoo_project_migration/models/odoo_module_branch.py b/odoo_project_migration/models/odoo_module_branch.py new file mode 100644 index 00000000..e98e0a9f --- /dev/null +++ b/odoo_project_migration/models/odoo_module_branch.py @@ -0,0 +1,33 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models + + +class OdooModuleBranch(models.Model): + _inherit = "odoo.module.branch" + + def _update_migration_data(self): + # Override to refresh project migration data as well + res = super()._update_migration_data() + for rec in self: + timelines = rec._get_related_timelines() + impacted_modules = rec.module_id | timelines._get_all_impacted_modules() + migrations = self.env["odoo.project.module.migration"].search( + [ + ("module_id", "in", impacted_modules.ids), + ( + "migration_path_id.source_branch_id.sequence", + "<=", + rec.branch_id.sequence, + ), + ( + "migration_path_id.target_branch_id.sequence", + ">=", + rec.branch_id.sequence, + ), + ] + ) + migrations.force_update() + return res diff --git a/odoo_project_migration/models/odoo_module_branch_timeline.py b/odoo_project_migration/models/odoo_module_branch_timeline.py new file mode 100644 index 00000000..f15f7759 --- /dev/null +++ b/odoo_project_migration/models/odoo_module_branch_timeline.py @@ -0,0 +1,77 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import _, api, fields, models + + +class OdooModuleBranchTimeline(models.Model): + _inherit = "odoo.module.branch.timeline" + + project_migration_ids = fields.Many2many( + comodel_name="odoo.project.module.migration", + string="Project Migrations", + compute="_compute_project_migration_ids", + ) + + @api.depends("module_branch_id", "next_module_id", "odoo_version_id") + def _compute_project_migration_ids(self): + for rec in self: + impacted_modules = rec._get_all_impacted_modules() + rec.project_migration_ids = ( + self.env["odoo.project.module.migration"] + .search( + [ + ( + "migration_path_id.target_branch_id.sequence", + ">=", + rec.odoo_version_id.sequence, + ), + "|", + "|", + ("module_id", "in", impacted_modules.ids), + ("renamed_to_module_id", "in", impacted_modules.ids), + ("replaced_by_module_id", "in", impacted_modules.ids), + ] + ) + .sudo() + ) + + # Update relevant project migration records on timeline update. + # + # When a module is renamed or replaced, we refresh all impacted project + # migration data records as done in 'odoo_repository_migration' module for + # 'odoo.module.branch.migration' records. + # + # E.g. + # if a module on 17.0 is set as renamed starting from 18.0, existing + # project migration data (17.0 -> 18.0, or 17.0 -> 19.0) will be aware + # of such change even if migration data are not collected on source + # repository. + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + records.project_migration_ids.force_update() + return records + + @api.model + def write(self, vals): + project_migrations = self.project_migration_ids + res = super().write(vals) + (self.project_migration_ids | project_migrations).force_update() + return res + + def unlink(self): + project_migrations = self.project_migration_ids + res = super().unlink() + project_migrations.force_update() + return res + + def open_project_migrations(self): + self.ensure_one() + xml_id = "odoo_project_migration.odoo_project_module_migration_action" + action = self.env["ir.actions.actions"]._for_xml_id(xml_id) + action["name"] = _("Project Migrations") + action["domain"] = [("id", "in", self.project_migration_ids.ids)] + return action diff --git a/odoo_project_migration/models/odoo_project_module_migration.py b/odoo_project_migration/models/odoo_project_module_migration.py index 13d8bbfb..fa08c63a 100644 --- a/odoo_project_migration/models/odoo_project_module_migration.py +++ b/odoo_project_migration/models/odoo_project_module_migration.py @@ -84,6 +84,20 @@ class OdooProjectModuleMigration(models.Model): ), compute="_compute_migration_script_ids", ) + renamed_to_module_id = fields.Many2one( + comodel_name="odoo.module", + compute="_compute_renamed_to_module_id", + string="Renamed to", + store=True, + index=True, + ) + replaced_by_module_id = fields.Many2one( + comodel_name="odoo.module", + compute="_compute_replaced_by_module_id", + string="Replaced by", + store=True, + index=True, + ) state = fields.Selection( # Same as in 'odoo.module.branch.migration' but set a state even for # modules with no migration data, could be Odoo S.A. std modules @@ -124,16 +138,16 @@ def _compute_project_module_id(self): @api.depends( "source_module_branch_id", "migration_path_id", - "module_migration_id.replaced_by_module_id", - "module_migration_id.renamed_to_module_id", + "replaced_by_module_id", + "renamed_to_module_id", ) def _compute_target_module_branch_id(self): module_branch_model = self.env["odoo.module.branch"] for rec in self: # Look for the right module technical name module = ( - rec.module_migration_id.replaced_by_module_id - or rec.module_migration_id.renamed_to_module_id + rec.replaced_by_module_id + or rec.renamed_to_module_id or rec.source_module_branch_id.module_id ) rec.target_module_branch_id = module_branch_model._find( @@ -145,9 +159,6 @@ def _compute_target_module_branch_id(self): # NOTE: 'migration_scan' is here to re-trigger the computation # each time the source module has its state updated regarding migration. - # FIXME: this could trigger too much computations on irrelevant records - # (one not related to the updated migration path), we should switch to - # component events to handle such cases. @api.depends("migration_path_id", "source_module_branch_id.migration_scan") def _compute_module_migration_id(self): migration_model = self.env["odoo.module.branch.migration"] @@ -186,10 +197,41 @@ def _compute_migration_script_ids(self): ).sorted(key=lambda v: (v.branch_sequence, v.sequence)) rec.migration_script_ids = current_release_versions | new_release_versions - @api.depends("module_migration_id.state") + # NOTE: _renamed_to_module_in_target_version relies also on timelines, and + # this computed field will be recomputed automatically on timeline updates. + @api.depends("migration_path_id") + def _compute_renamed_to_module_id(self): + for rec in self: + rec.renamed_to_module_id = ( + rec.source_module_branch_id._renamed_to_module_in_target_version( + rec.migration_path_id.target_branch_id + ) + ) + + # NOTE: _replaced_by_module_in_target_version relies also on timelines, and + # this computed field will be recomputed automatically on timeline updates. + @api.depends("migration_path_id") + def _compute_replaced_by_module_id(self): + for rec in self: + rec.replaced_by_module_id = ( + rec.source_module_branch_id._replaced_by_module_in_target_version( + rec.migration_path_id.target_branch_id + ) + ) + + @api.depends( + "module_migration_id.state", + "replaced_by_module_id", + "source_module_branch_id.is_standard", + "target_module_branch_id.repository_branch_id", + ) def _compute_state(self): for rec in self: rec.state = rec.module_migration_id.state + if rec.replaced_by_module_id: + # Module replaced by another one + rec.state = "replaced" + continue if not rec.module_migration_id: # Default state (used by project specific modules) rec.state = "migrate" @@ -198,6 +240,15 @@ def _compute_state(self): rec.state = ( "available" if rec.target_module_branch_id else "removed" ) - elif rec.target_module_branch_id: + elif rec.target_module_branch_id.repository_branch_id: # repo with collect_migration_data = False rec.state = "available" + + def force_update(self): + """Ensure the project migration data are updated.""" + self_sudo = self.sudo() + self_sudo._compute_module_migration_id() + self_sudo._compute_replaced_by_module_id() + self_sudo._compute_renamed_to_module_id() + self_sudo._compute_target_module_branch_id() + self_sudo._compute_state() diff --git a/odoo_project_migration/static/description/index.html b/odoo_project_migration/static/description/index.html index af4b5126..c7d9443b 100644 --- a/odoo_project_migration/static/description/index.html +++ b/odoo_project_migration/static/description/index.html @@ -3,7 +3,7 @@ -README.rst +Odoo Project Migration Data -
+
+

Odoo Project Migration Data

- - -Odoo Community Association - -
-

Odoo Project Migration Data

-

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

+

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

This module integrates the migration data collected by odoo_repository_migration module in your Odoo projects. It allows to generate migration reports, giving some hints about the effort to @@ -392,7 +387,7 @@

Odoo Project Migration Data

-

Bug Tracker

+

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 @@ -400,15 +395,15 @@

Bug Tracker

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

-

Credits

+

Credits

-

Authors

+

Authors

  • Camptocamp
-

Contributors

+

Contributors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association @@ -430,6 +425,5 @@

Maintainers

-
diff --git a/odoo_project_migration/tests/__init__.py b/odoo_project_migration/tests/__init__.py index 917bc32a..33716650 100644 --- a/odoo_project_migration/tests/__init__.py +++ b/odoo_project_migration/tests/__init__.py @@ -1,2 +1,3 @@ from . import test_generate_migration_data from . import test_export_migration_report +from . import test_odoo_project_module_migration diff --git a/odoo_project_migration/tests/test_odoo_project_module_migration.py b/odoo_project_migration/tests/test_odoo_project_module_migration.py new file mode 100644 index 00000000..84dbe281 --- /dev/null +++ b/odoo_project_migration/tests/test_odoo_project_module_migration.py @@ -0,0 +1,138 @@ +# Copyright 2026 Akretion (https://www.akretion.com). +# @author Sébastien Alix +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from .common import ProjectMigrationCommon + + +class TestOdooProjectModuleMigration(ProjectMigrationCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + # Import/install a module in our project + cls._run_import_modules(cls.project, cls.module_branch.module_name) + # Get current/next/target branches (N, N+1 and N+2) + cls.current_branch = cls.project.odoo_version_id + cls.next_branch = cls.current_branch.next_id + cls.target_branch = cls.next_branch.next_id + # Generate migration path from X to X+1 + cls.migration_path = cls.env["odoo.migration.path"].create( + { + "source_branch_id": cls.current_branch.id, + "target_branch_id": cls.target_branch.id, + } + ) + + def test_renamed_module_wo_migration_data(self): + """Test project migration data of a module renamed, w/o collected mig data. + + Project migration data should show relevant data even if + 'collect_migration_data' option is disabled on scanned repositories. + Such data could be updated thanks to timelines for instance, even if + there is no underlying migration data record. + + Data input: + - source version = X + - target version = X+2 + - module renamed in X+1 + + Expected ouput: + - 'renamed_to_module_id' should target module renamed in X+1 + - migration 'state' should be 'available' + """ + # Create the next module + # /!\ We do not create the target module branch on X+2 on purpose, + # so the project migration record cannot find a matching module. + next_module = self.module.copy({"name": "next_module"}) + self._create_odoo_module_branch( + next_module, + self.next_branch, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha1", + ) + # Generate project_migration data records from X to X+2 (with a gap) + self._generate_migration_data(self.project, self.migration_path) + self.assertTrue(self.project.module_migration_ids) + # Module has been renamed starting from X+1 + self._create_timeline(self.module_branch, next_module, "renamed") + # Check migration data + mig = self.project.module_migration_ids + self.assertEqual(mig.renamed_to_module_id, next_module) + self.assertFalse(mig.replaced_by_module_id) + self.assertFalse(mig.target_module_branch_id) + self.assertEqual(mig.state, "migrate") + # Now we create the target module branch on X+2, the project migration + # data should consider it + target_module_branch = self._create_odoo_module_branch( + next_module, + self.target_branch, + specific=False, + repository_branch_id=self.repo_branch3.id, + last_scanned_commit="sha2", + ) + self.assertEqual(mig.target_module_branch_id, target_module_branch) + self.assertEqual(mig.state, "available") + + def test_renamed_module_twice_wo_migration_data(self): + """Test project mig data of a module renamed twice, w/o collected mig data. + + Project migration data should show relevant data even if + 'collect_migration_data' option is disabled on scanned repositories. + Such data could be updated thanks to timelines for instance, even if + there is no underlying migration data record. + + Data input: + - source version = X + - target version = X+2 + - module renamed in X+1 + - module renamed again in X+2 + + Expected ouput: + - 'renamed_to_module_id' should target module renamed in X+2 + - migration 'state' should be 'available' + """ + # Create the next module + next_module = self.module.copy({"name": "next_module"}) + next_module_branch = self._create_odoo_module_branch( + next_module, + self.next_branch, + specific=False, + repository_branch_id=self.repo_branch2.id, + last_scanned_commit="sha_next", + ) + # Create the target module + # /!\ We do not create the target module branch on X+2 on purpose, + # so the project migration record cannot find a matching module. + target_module = self.module.copy({"name": "target_module"}) + # Generate project_migration data records from X to X+2 (with a gap) + self._generate_migration_data(self.project, self.migration_path) + # Module has been renamed in X+1 + self._create_timeline(self.module_branch, next_module, "renamed") + renamed_to_module = self.module_branch._renamed_to_module_in_target_version( + self.next_branch + ) + self.assertEqual(renamed_to_module, next_module) + # Module has been renamed again in X+2 + self._create_timeline(next_module_branch, target_module, "renamed") + renamed_to_module = self.module_branch._renamed_to_module_in_target_version( + self.target_branch + ) + self.assertEqual(renamed_to_module, target_module) + # Check migration data + mig = self.project.module_migration_ids + self.assertEqual(mig.renamed_to_module_id, target_module) + self.assertFalse(mig.replaced_by_module_id) + self.assertFalse(mig.target_module_branch_id) + self.assertEqual(mig.state, "migrate") + # Now we create the target module branch on X+2, the project migration + # data should consider it + target_module_branch = self._create_odoo_module_branch( + target_module, + self.target_branch, + specific=False, + repository_branch_id=self.repo_branch3.id, + last_scanned_commit="sha_target", + ) + self.assertEqual(mig.target_module_branch_id, target_module_branch) + self.assertEqual(mig.state, "available") diff --git a/odoo_project_migration/views/odoo_module_branch_timeline.xml b/odoo_project_migration/views/odoo_module_branch_timeline.xml new file mode 100644 index 00000000..035f008d --- /dev/null +++ b/odoo_project_migration/views/odoo_module_branch_timeline.xml @@ -0,0 +1,26 @@ + + + + + odoo.module.branch.timeline.form.inherit + odoo.module.branch.timeline + + +
+
+
+
+
diff --git a/odoo_project_migration/views/odoo_project_module_migration.xml b/odoo_project_migration/views/odoo_project_module_migration.xml index 8598d392..36e44160 100644 --- a/odoo_project_migration/views/odoo_project_module_migration.xml +++ b/odoo_project_migration/views/odoo_project_module_migration.xml @@ -24,6 +24,14 @@ /> + +