From 045ccc1c85e5c84fbc8acd3dd24d3f7a0f70f6f7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 31 Jul 2026 23:28:54 -0400 Subject: [PATCH] [ADD] migration: predict the website COW views a version bump will break MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- script/odoo/migration/check_cow_views.py | 235 +++++++++++++++++++++++ script/todo/todo_upgrade.py | 25 ++- 2 files changed, 258 insertions(+), 2 deletions(-) create mode 100755 script/odoo/migration/check_cow_views.py diff --git a/script/odoo/migration/check_cow_views.py b/script/odoo/migration/check_cow_views.py new file mode 100755 index 0000000..6255482 --- /dev/null +++ b/script/odoo/migration/check_cow_views.py @@ -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 ```` root as an inheritance spec, +cannot find it in the parent, and the whole upgrade stops on:: + + ValueError: Element '' + 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 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: