From 137ecbb709bb965ea8d6079773aeb085875ed67c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Ra=C3=AFch?= Date: Thu, 16 Jul 2026 12:32:38 +0200 Subject: [PATCH 1/2] [FIX] upgrade_analysis: don't reset foreign-module fields on moved noupdate records When a noupdate record is moved to another module, its previous definition may set a field declared in a module the new one doesn't depend on (e.g. a module that depends on it). That field is still managed by its own module, so skip it instead of resetting it to its default in the new module's noupdate_changes.xml. --- upgrade_analysis/models/upgrade_analysis.py | 41 ++++++++++++++++++--- upgrade_analysis/tests/test_module.py | 1 + 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/upgrade_analysis/models/upgrade_analysis.py b/upgrade_analysis/models/upgrade_analysis.py index 23b42785911..c703825b5f1 100644 --- a/upgrade_analysis/models/upgrade_analysis.py +++ b/upgrade_analysis/models/upgrade_analysis.py @@ -314,9 +314,27 @@ def _get_node_value(element): return element.text return etree.tostring(element) + def _get_available_modules(self, module_name): + """Return the set of module names a given module may reference in its + data files: the module itself plus its (transitive) dependencies. + """ + module = self.env["ir.module.module"].search([("name", "=", module_name)]) + if not module: + return set() + upstream = module.upstream_dependencies( + exclude_states=("uninstalled", "uninstallable", "to remove") + ) + return {module_name} | set(upstream.mapped("name")) + def _get_xml_diff( - self, remote_update, remote_noupdate, local_update, local_noupdate + self, + remote_update, + remote_noupdate, + local_update, + local_noupdate, + local_module, ): + available_modules = self._get_available_modules(local_module) odoo = etree.Element("odoo") for xml_id in sorted(local_noupdate.keys()): local_record = local_noupdate[xml_id] @@ -355,12 +373,21 @@ def _get_xml_diff( # Does the field still exist? if record_remote_dict[key].tag == "field": field_name = remote_record.xpath(key)[0].attrib.get("name") + model_name = local_record.attrib["model"] if ( - local_record.attrib["model"] not in self.env - or field_name - not in self.env[local_record.attrib["model"]]._fields.keys() + model_name not in self.env + or field_name not in self.env[model_name]._fields ): continue + # When the record was moved to another module, its + # previous definition may set a field that is declared + # in a module the current one does not depend on (e.g. + # the field lives in a module that depends on this one). + # That field is still managed by its own module, so it + # must not be reset from here. + field = self.env[model_name]._fields[field_name] + if field._module and field._module not in available_modules: + continue # Overwrite an existing value with an empty one. attribs = deepcopy(record_remote_dict[key]).attrib for attr in ["eval", "ref"]: @@ -575,7 +602,11 @@ def generate_noupdate_changes( local_files = local_record_obj.get_xml_records(local_module) local_update, local_noupdate = self._parse_files(local_files, local_module) diff = self._get_xml_diff( - remote_update, remote_noupdate, local_update, local_noupdate + remote_update, + remote_noupdate, + local_update, + local_noupdate, + local_module, ) if diff: module = self.env["ir.module.module"].search( diff --git a/upgrade_analysis/tests/test_module.py b/upgrade_analysis/tests/test_module.py index f7cfd34cea3..0af122406c6 100644 --- a/upgrade_analysis/tests/test_module.py +++ b/upgrade_analysis/tests/test_module.py @@ -150,6 +150,7 @@ def test_xml_comparison(self): """ ), }, + "upgrade_analysis", ) self.assertIn('', diff) self.assertIn('', diff) From d3168b0fd52fd0259b9fefb7463eee72f8437051 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miquel=20Ra=C3=AFch?= Date: Thu, 16 Jul 2026 12:55:28 +0200 Subject: [PATCH 2/2] [FIX] upgrade_analysis: skip discarded-field reset when value is unchanged When a noupdate field set in the previous version is dropped in the current one, only write the reset to noupdate_changes.xml if the value a fresh install would give the field actually differs from the previous value. Otherwise the (noupdate) record already holds the right value and the entry is a no-op. --- upgrade_analysis/models/upgrade_analysis.py | 18 ++++++++++++++- upgrade_analysis/tests/test_module.py | 25 ++++++++++++++++++--- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/upgrade_analysis/models/upgrade_analysis.py b/upgrade_analysis/models/upgrade_analysis.py index c703825b5f1..a8b747b5b64 100644 --- a/upgrade_analysis/models/upgrade_analysis.py +++ b/upgrade_analysis/models/upgrade_analysis.py @@ -416,7 +416,23 @@ def _get_xml_diff( eval_constant = ast.unparse(ast.Constant(field.falsy_value)) if eval_constant != "''": attribs["eval"] = eval_constant - element.append(etree.Element(record_remote_dict[key].tag, attribs)) + new_element = etree.Element( + record_remote_dict[key].tag, attribs + ) + # Only emit the reset when the value a fresh install + # would give the field actually differs from what the + # previous version set. If they are equal, the + # (noupdate) record already holds the right value and + # needs no change. + if self._get_node_value(new_element) == self._get_node_value( + record_remote_dict[key] + ): + continue + element.append(new_element) + else: + element.append( + etree.Element(record_remote_dict[key].tag, attribs) + ) else: oldrepr = self._get_node_value(record_remote_dict[key]) newrepr = self._get_node_value(record_local_dict[key]) diff --git a/upgrade_analysis/tests/test_module.py b/upgrade_analysis/tests/test_module.py index 0af122406c6..4ba99e6b20e 100644 --- a/upgrade_analysis/tests/test_module.py +++ b/upgrade_analysis/tests/test_module.py @@ -158,7 +158,19 @@ def test_xml_comparison(self): def test_analyze(self): """ Test a full analysis run. - For the time being, only xmlid related functionality is tested + For the time being, only xmlid related functionality is tested. + + The record is detected as renamed from ``other_module`` to + ``upgrade_analysis`` and the generated ``noupdate_changes.xml`` + exercises the discarded-field handling: + + - ``name``: value changed, so it is written; + - ``port``: dropped, and the value a fresh install gives (its default) + differs from the previous one, so a reset is written; + - ``username``: dropped, but the default equals the previous value, so + nothing is written; + - ``database``: dropped, but its field is (patched to be) declared in a + module the current one does not depend on, so it is left untouched. """ analysis = self.env["upgrade.analysis"].create( { @@ -236,7 +248,9 @@ def get_xml_records(self, module): Noupdate xmlid from other_module - some_server + + + some_database """ @@ -254,7 +268,6 @@ def local_get_xml_records(module): Noupdate xmlid from upgrade_analysis - some_server """ @@ -265,6 +278,10 @@ def local_get_xml_records(module): def write_file(module_name, version, content, filename="upgrade_analysis.txt"): written_files[f"{module_name}-{version}-{filename}"] = content + # Pretend the "database" field is declared in a module that depends on + # (and is therefore not reachable from) upgrade_analysis. + database_field = self.env["upgrade.comparison.config"]._fields["database"] + with ( patch.object(analysis.config_id.__class__, "get_connection"), patch.object( @@ -274,6 +291,7 @@ def write_file(module_name, version, content, filename="upgrade_analysis.txt"): patch.object( self.env["upgrade.record"].__class__, "get_xml_records" ) as patched_get_xml_records, + patch.object(database_field, "_module", "some_dependent_module"), ): patched_get_remote_model.side_effect = lambda *args: RemoteUpgradeRecord() patched_get_xml_records.side_effect = local_get_xml_records @@ -284,6 +302,7 @@ def write_file(module_name, version, content, filename="upgrade_analysis.txt"): Noupdate xmlid from upgrade_analysis + """