[ADD] migration: predict the website COW views a version bump will break

A copy-on-write view freezes the structure of the module view it was copied
from. When that module view changes mode between two versions, the copy keeps
an arch written for the old mode and the upgrade dies on
« Element ... cannot be located in parent view », hours into the migration.

Measured on a real 12.0 database: portal.frontend_layout is declared primary in
12.0 (full QWeb template, so a full-template arch is legitimate) and becomes an
extension in 13.0 (inherit_id + xpath). The 2021 COW copy follows the module,
turns into an extension, and keeps its 12.0 arch. The customization is genuine;
the breakage is produced by the migration.

check_cow_views.py compares the mode stored in database with the mode declared
in the target version sources and sorts the views in three buckets: at risk
(mode change), module absent from the target version, and pages made from the
editor (not at risk). Read-only, and called from step 2 so the list is known
before the long version loop rather than during it.

On the reference database it reports exactly the view that broke the upgrade,
plus 7 views whose module is gone in 13.0 (custom theme included).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-07-31 23:28:54 -04:00
parent 4536c5bc40
commit 045ccc1c85
2 changed files with 258 additions and 2 deletions

View file

@ -0,0 +1,235 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Predict which website COW views will break on the next version bump.
Background
----------
When a website view is customized, Odoo makes a copy-on-write (COW) copy tied
to a website_id. That copy freezes the arch AND the structure of the module
view it was copied from.
A module view can change structure between two Odoo versions. Example measured
on a real 12.0 database: ``portal.frontend_layout`` is declared ``primary`` in
12.0 (a full QWeb template) and becomes an ``extension`` in 13.0
(``inherit_id="web.frontend_layout"`` + xpath). During the upgrade the COW copy
follows the module and becomes an extension, but keeps its 12.0 full-template
arch. Odoo then applies the ``<t t-name=...>`` root as an inheritance spec,
cannot find it in the parent, and the whole upgrade stops on::
ValueError: Element '<t ... t-name="portal.frontend_layout">'
cannot be located in parent view
So the rule is:
a COW view breaks when its module counterpart changes ``mode``
between version N and version N+1.
That is predictable *before* starting a multi-hour migration: the current mode
is in the database, and the target mode is declared in the target version
sources. This script compares the two and reports the views at risk.
It only reads: no database write, no source modification.
"""
import argparse
import glob
import os
import subprocess
import sys
import xml.etree.ElementTree as ET
# A view whose module counterpart cannot be found is reported separately: it is
# usually a view of a module that does not exist in the target version.
MODE_UNKNOWN = "unknown"
def query_cow_views(database):
"""Return [(id, key, mode, website_id)] for every website COW view."""
sql = (
"SELECT id, COALESCE(key, ''), mode, website_id FROM ir_ui_view"
" WHERE website_id IS NOT NULL ORDER BY id;"
)
result = subprocess.run(
["psql", "-d", database, "-tAF", "|", "-c", sql],
capture_output=True,
text=True,
)
if result.returncode:
raise RuntimeError(
f"Cannot read views from '{database}': {result.stderr.strip()}"
)
lst_view = []
for line in result.stdout.splitlines():
if not line.strip():
continue
view_id, key, mode, website_id = line.split("|")
lst_view.append((int(view_id), key, mode, website_id))
return lst_view
def find_module_dir(odoo_version, module_name):
"""Locate a module directory inside an odoo<version> tree."""
lst_pattern = [
os.path.join(odoo_version, "odoo", "addons", module_name),
os.path.join(odoo_version, "odoo", "odoo", "addons", module_name),
os.path.join(odoo_version, "addons", "*", module_name),
]
for pattern in lst_pattern:
for path in sorted(glob.glob(pattern)):
if os.path.isdir(path):
return path
return None
def declared_mode(module_dir, template_id):
"""Return 'primary', 'extension' or None for a view declared in sources.
Handles both declaration styles: <template id="..."> and
<record model="ir.ui.view">. A view is an extension when it inherits and is
not explicitly flagged primary.
"""
pattern = os.path.join(module_dir, "**", "*.xml")
for file_path in sorted(glob.glob(pattern, recursive=True)):
try:
root = ET.parse(file_path).getroot()
except ET.ParseError:
continue
for element in root.iter():
if element.get("id") != template_id:
continue
if element.tag == "template":
if str(element.get("primary", "")).lower() in ("true", "1"):
return "primary"
return "extension" if element.get("inherit_id") else "primary"
if (
element.tag == "record"
and element.get("model") == "ir.ui.view"
):
mode = None
inherit = None
for field in element.findall("field"):
if field.get("name") == "mode":
mode = (field.text or "").strip()
elif field.get("name") == "inherit_id":
inherit = field
if mode:
return mode
return "extension" if inherit is not None else "primary"
return None
def analyse(database, target_version):
"""Sort COW views into three buckets by comparing with the target sources.
- at_risk : the module view changes mode -> the copy will break
- module_absent : the module itself is gone in the target version
- no_counterpart : no module view with that id, so it is a page or a record
created from the editor. Normal, and not at risk.
"""
lst_at_risk = []
lst_module_absent = []
lst_no_counterpart = []
cache_mode = {}
for view_id, key, mode, website_id in query_cow_views(database):
if not key or "." not in key:
continue
if key not in cache_mode:
module_name, _, template_id = key.partition(".")
module_dir = find_module_dir(target_version, module_name)
if module_dir is None:
cache_mode[key] = MODE_UNKNOWN
else:
cache_mode[key] = declared_mode(module_dir, template_id)
target_mode = cache_mode[key]
if target_mode == MODE_UNKNOWN:
lst_module_absent.append((view_id, key, mode, website_id))
elif target_mode is None:
lst_no_counterpart.append((view_id, key, mode, website_id))
elif target_mode != mode:
lst_at_risk.append((view_id, key, mode, target_mode, website_id))
return lst_at_risk, lst_module_absent, lst_no_counterpart
def main():
parser = argparse.ArgumentParser(
description=(
"Report website COW views that will break on the next Odoo"
" version bump (read-only)."
)
)
parser.add_argument(
"-d", "--database", required=True, help="database to inspect"
)
parser.add_argument(
"-t",
"--target_version",
required=True,
help="target Odoo source directory, e.g. odoo13.0",
)
parser.add_argument(
"-v",
"--verbose",
action="store_true",
help="also list the editor-made pages, which are not at risk",
)
config = parser.parse_args()
if not os.path.isdir(config.target_version):
print(
f"❌ Target version directory '{config.target_version}' not found."
)
return 1
lst_at_risk, lst_module_absent, lst_no_counterpart = analyse(
config.database, config.target_version
)
if not lst_at_risk:
print(
"✅ -> No website COW view changes mode in"
f" {config.target_version}."
)
else:
print(
f"⚠️ {len(lst_at_risk)} website COW view(s) will break when moving"
f" to {config.target_version}: the module view changes mode, but"
" the copy keeps an arch written for the old mode."
)
for view_id, key, mode, target_mode, website_id in lst_at_risk:
print(
f" - id={view_id} website={website_id} {key}"
f" : {mode} -> {target_mode}"
)
print(
" Arbitrate BEFORE launching the migration: reset the copy on the"
" module view, deactivate it (active=False, reversible), or rewrite"
" the customization for the target version."
)
if lst_module_absent:
print(
f" {len(lst_module_absent)} COW view(s) belong to a module absent"
f" from {config.target_version}:"
)
for view_id, key, mode, website_id in lst_module_absent:
print(f" - id={view_id} website={website_id} {key} ({mode})")
if lst_no_counterpart:
print(
f" {len(lst_no_counterpart)} COW view(s) are pages or records made"
" from the website editor (no module view of that name): not at"
" risk." + ("" if config.verbose else " Use -v to list them.")
)
if config.verbose:
for view_id, key, mode, website_id in lst_no_counterpart:
print(f" - id={view_id} website={website_id} {key} ({mode})")
# Informative only: never fail the migration on a warning.
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -845,7 +845,11 @@ class TodoUpgrade:
for name, reason, origin in lst_uninstall_reason:
print(
f" - {name}"
+ (f"{reason}" if reason else " — ⚠️ no reason given")
+ (
f"{reason}"
if reason
else " — ⚠️ no reason given"
)
+ f" [{origin}]"
)
@ -891,7 +895,10 @@ class TodoUpgrade:
print(f"🔷 {msg}")
self.add_comment_progression(msg)
if not self.dct_progression.get("state_2_update_all") and not already_update_state_1:
if (
not self.dct_progression.get("state_2_update_all")
and not already_update_state_1
):
status, cmd_executed = self.todo_upgrade_execute(
f"./script/addons/update_addons_all.sh {database_name}",
single_source_odoo=True,
@ -900,6 +907,20 @@ class TodoUpgrade:
self.dct_progression["state_2_update_all"] = True
self.write_config()
# Predict the website COW views that the NEXT version bump will break.
# A copy-on-write view freezes the structure of the module view it was
# copied from; when that module view changes mode between two versions,
# the copy keeps an arch written for the old mode and the upgrade dies
# on « Element ... cannot be located in parent view ». Reporting it here
# -- before hours of migration -- leaves time to arbitrate.
# Only the next bump can be predicted: the modes in database describe
# the current version.
self.todo_upgrade_execute(
f"{PYTHON_BIN} ./script/odoo/migration/check_cow_views.py"
f" -d {database_name} -t odoo{start_version + 1}.0",
wait_at_error=False,
)
msg = "3 - Clean up database before data migration"
print(f"🔷 {msg}")
self.add_comment_progression(msg)