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: