From 00eb0a3c7a0b57cde195d783b7c7b69b36b63b04 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sun, 2 Aug 2026 03:57:26 -0400 Subject: [PATCH] [ADD] migration: find the COW copies that drifted from their module view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third COW incident of this migration, and the first two tools did not cover it. A copy freezes the module view it came from; version after version the MODULE view is modernised and the copy is not, until a child xpath lands on an anchor the copy never had: Element '' cannot be located in parent view On 14.0 -> 15.0 that was web.layout, copied in the 12.0 era: no id on the script tag, QWeb still saying t-raw. It had survived only because the 14.0 module xpath carried a fallback — //head/script[@id='…'] | //head/script [last()] — that 15.0 removed. The breakage was years old; 15.0 merely stopped hiding it. The test is DIFFERENTIAL, and it has to be. Resolving a child's xpath against its parent's own arch proves nothing: Odoo resolves against the COMBINED arch of the whole chain, so a child of website.layout legitimately targets //header coming from an ancestor. Measured on the real database, that naive rule reported 5 copies where only 1 had a problem. Resolving twice — against the module twin and against the copy — and keeping only what the twin satisfies and the copy cannot, isolates drift and nothing else. Two accuracy fixes the real data forced: · inactive children are skipped; Odoo never applies them · a missing lxml is now a LOUD failure. The first version fell back to « everything resolves », so the checker answered « all clean » while checking nothing — the worst possible outcome for a checker. Verified: the bare system python3 has no lxml, .venv.erplibre does. --reset copies the module arch over the copy, after saving the previous arch to a timestamped file and printing the diff, because a copy can hold a real customisation buried in the drift — on web.layout it was one among six differences. Timing matters: drift only exists once the module views carry the new version, so run this AFTER OpenUpgrade and BEFORE update_addons_all. Run on the 14.0 database it finds nothing about web.layout, and rightly so — there the module view says t-raw too. Verified end to end on a scratch database rebuilt from the real arches: detection of the exact reported failure, dry-run changing nothing, --apply resetting it, the backup holding the previous arch, and a clean re-check. Then across the four migration databases, where it flags two latent drifts (website_sale.product, website_blog.blog_post_complete) that no version bump has surfaced yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../odoo/migration/reset_stale_cow_views.py | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100755 script/odoo/migration/reset_stale_cow_views.py diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py new file mode 100755 index 0000000..13cd44e --- /dev/null +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -0,0 +1,365 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Find the website COW copies that have gone stale, and reset them. + +The problem +----------- +A COW copy freezes the module view it was copied from. Version after version, +the MODULE view is modernised while the copy is not — it is user data, nothing +rewrites it. The copy keeps working right up to the moment a CHILD view does an +``xpath`` onto something the module added, renamed or re-anchored:: + + Element '' + cannot be located in parent view + +Observed on ``web.layout`` during 14.0 -> 15.0: the copy dated from 12.0, where +the script tag had no ``id`` and QWeb still said ``t-raw``. It had survived +until then only because the 14.0 module xpath carried a fallback +(``//head/script[@id='…'] | //head/script[last()]``) that 15.0 removed. + +What this checks +---------------- +Not a heuristic on deprecated syntax, and not an absolute one either: the +DRIFT between a copy and its module twin. For every active child of a COW +copy, each ``xpath`` expression is resolved twice — against the module twin +and against the copy. An expression the twin can satisfy and the copy cannot +is an anchor the copy has lost. + +The comparison has to be differential. Odoo resolves an xpath against the +COMBINED arch of the whole inheritance chain, so « does not resolve in this +parent » proves nothing on its own — a child of ``website.layout`` targets +``//header``, which comes from an ancestor. + +When it bites +------------- +A reported copy is not necessarily failing right now. Odoo re-validates a COW +copy when its module twin is REWRITTEN — which is what a version bump does — +or when the page is rendered for that website. So a finding is a breakage +already present in the data, waiting for the next bump to surface it. That is +precisely when it is cheap to fix. + +Resetting +--------- +``--reset`` copies the MODULE arch over the stale copy. The previous arch is +always written to a backup file first, and the diff is printed, because a copy +can hold a genuine customisation — one line among fifty of drift. Read the +diff, reset, then re-apply what mattered as an INHERITING view rather than a +full copy, so the next version bump cannot make it stale again. + +Plain psql on purpose: this runs on databases whose Odoo registry does not +load, which is precisely when it is needed. +""" + +import argparse +import datetime +import difflib +import json +import os +import re +import subprocess +import sys + + +def run_psql(database, sql): + """Run a statement and return stdout, raising on failure.""" + result = subprocess.run( + ["psql", "-d", database, "-tAc", sql], + capture_output=True, + text=True, + ) + if result.returncode: + raise RuntimeError( + f"Query failed on '{database}': {result.stderr.strip()}" + ) + return result.stdout + + +def normalise_arch(value): + """The arch as a string, whatever the column type. + + Odoo stores ``arch_db`` as text up to 15.0 and as jsonb (one entry per + language) from 16.0. Casting to text gives ``{"en_US": "]*\bexpr=([\"'])(.*?)\1", re.S) + + +def child_xpaths(arch): + """The xpath expressions a view applies to its parent.""" + return [match.group(2) for match in XPATH_RE.finditer(arch)] + + +def require_lxml(): + """The XPath engine, or a loud failure. + + Never degrade to « everything resolves » when lxml is missing: a checker + that cannot check must not answer « all clean ». Odoo's own venvs all ship + lxml; the bare system python3 usually does not. + """ + try: + from lxml import etree + + return etree + except ImportError: + sys.exit( + "❌ lxml is required to resolve the xpath expressions.\n" + " Run this with an interpreter that has it, e.g.\n" + " ./.venv.erplibre/bin/python3 " + os.path.relpath(__file__) + ) + + +def resolve(etree, arch, expr): + """True if `expr` matches something in `arch`. + + An arch that does not parse, or an expression lxml cannot evaluate, counts + as resolvable: this tool reports views that will CERTAINLY break, and must + never invent a failure it cannot substantiate. + """ + try: + tree = etree.fromstring(arch.encode("utf-8")) + except etree.XMLSyntaxError: + return True + try: + return bool(tree.xpath(expr)) + except etree.XPathEvalError: + return True + + +def analyse(database): + """[(copy, module_twin, [(child_id, failing_expr), ...])] — the COW copies + that have DRIFTED away from their module twin. + + The test is differential, and it has to be. Odoo resolves an xpath against + the COMBINED arch of the whole inheritance chain, not against the parent's + own arch, so « does not resolve here » proves nothing on its own: a child + of ``website.layout`` legitimately targets ``//header``, which comes from + an ancestor. Comparing the copy with its module twin removes that whole + class of noise: + + resolves in the module twin, not in the copy -> the copy lost an + anchor: real drift + resolves in neither -> the anchor lives + further up the chain + resolves in both -> nothing to see + + Inactive children are skipped: Odoo never applies them, so they cannot + break a load. + """ + etree = require_lxml() + views = fetch_views(database) + module_by_key = { + v["key"]: v + for v in views.values() + if v["website_id"] is None and v["key"] + } + children = {} + for view in views.values(): + if view["inherit_id"]: + children.setdefault(view["inherit_id"], []).append(view) + + findings = [] + for view in sorted(views.values(), key=lambda v: v["id"]): + twin = module_by_key.get(view["key"]) + if view["website_id"] is None or twin is None: + continue + broken = [] + for child in children.get(view["id"], []): + if not child.get("active", True): + continue + for expr in child_xpaths(child["arch"]): + if resolve(etree, twin["arch"], expr) and not resolve( + etree, view["arch"], expr + ): + broken.append((child["id"], expr)) + if broken: + findings.append((view, twin, broken)) + return findings + + +def show_diff(module_view, cow_view): + """Module arch vs copy: what the copy would gain and lose on a reset.""" + diff = difflib.unified_diff( + module_view["arch"].splitlines(), + cow_view["arch"].splitlines(), + fromfile=f"module id={module_view['id']}", + tofile=f"cow id={cow_view['id']}", + lineterm="", + ) + for line in diff: + print(f" {line}") + + +def backup(database, cow_view, directory): + """Store the arch about to be replaced, and return the file path.""" + os.makedirs(directory, exist_ok=True) + stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + path = os.path.join( + directory, f"{cow_view['key']}_{cow_view['id']}_{stamp}.json" + ) + with open(path, "w", encoding="utf-8") as fh: + json.dump( + { + "database": database, + "id": cow_view["id"], + "key": cow_view["key"], + "website_id": cow_view["website_id"], + "saved_at": stamp, + "arch_db": cow_view["arch"], + }, + fh, + indent=2, + ensure_ascii=False, + ) + return path + + +def reset(database, cow_view, module_view): + """Copy the module arch over the stale copy. Returns rows updated.""" + sql = ( + "UPDATE ir_ui_view c SET arch_db = m.arch_db " + "FROM ir_ui_view m " + f"WHERE c.id = {cow_view['id']} AND m.id = {module_view['id']}" + ) + run_psql(database, sql) + return 1 + + +def main(): + parser = argparse.ArgumentParser( + description=( + "Report the website COW copies whose children can no longer be" + " applied, and optionally reset them onto the module view." + ) + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--reset", + metavar="KEY", + action="append", + default=[], + help="reset this key onto its module view ('all' for every finding)", + ) + parser.add_argument( + "--apply", + action="store_true", + help="really write; without it --reset only shows what it would do", + ) + parser.add_argument( + "--backup-dir", + default=None, + help="where the replaced arch is saved" + " (default private/odoo/migration//cow_reset)", + ) + config = parser.parse_args() + + findings = analyse(config.database) + if not findings: + print("✅ No COW copy has drifted from its module view.") + return 0 + + print( + f"⚠️ {len(findings)} COW copy(ies) drifted from their module view" + f" in {config.database}" + ) + print( + " Odoo surfaces this when the module view is rewritten (a version" + " bump) or when the page is rendered.\n" + ) + for cow_view, module_view, broken in findings: + twin = ( + f"module id={module_view['id']}" + if module_view + else "NO module view with this key" + ) + print( + f" id={cow_view['id']} key={cow_view['key']}" + f" website_id={cow_view['website_id']} [{twin}]" + ) + for child_id, expr in broken: + print(f" child {child_id} cannot apply: {expr}") + if module_view: + show_diff(module_view, cow_view) + print() + + if not config.reset: + print( + "Nothing changed. Re-run with --reset --apply to reset a" + " copy onto its module view." + ) + return 1 + + wanted = set(config.reset) + directory = config.backup_dir or os.path.join( + "private", "odoo", "migration", config.database, "cow_reset" + ) + done = 0 + for cow_view, module_view, _broken in findings: + if "all" not in wanted and cow_view["key"] not in wanted: + continue + if not module_view: + print( + f"⏭ {cow_view['key']}: no module view to reset onto," + " skipped." + ) + continue + if not config.apply: + print( + f"[dry-run] would reset id={cow_view['id']}" + f" ({cow_view['key']}) onto id={module_view['id']}" + ) + continue + path = backup(config.database, cow_view, directory) + reset(config.database, cow_view, module_view) + done += 1 + print(f"✅ reset id={cow_view['id']} ({cow_view['key']})") + print(f" previous arch saved to {path}") + if config.apply and done: + print( + f"\n{done} copy(ies) reset. Re-apply any real customisation as an" + " INHERITING view, not a copy, so it cannot go stale again." + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())