[ADD] migration: find the COW copies that drifted from their module view
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 '<xpath expr="//head/script[@id='web.layout.odooscript']">'
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 <meta viewport>
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) <noreply@anthropic.com>
This commit is contained in:
parent
03ed3a6a87
commit
00eb0a3c7a
1 changed files with 365 additions and 0 deletions
365
script/odoo/migration/reset_stale_cow_views.py
Executable file
365
script/odoo/migration/reset_stale_cow_views.py
Executable file
|
|
@ -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 '<xpath expr="//head/script[@id='web.layout.odooscript']">'
|
||||
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": "<t …"}`` in the
|
||||
second case, so unwrap it — the structure is the same in every language,
|
||||
and the structure is all this tool looks at.
|
||||
"""
|
||||
if not isinstance(value, str):
|
||||
return "" if value is None else str(value)
|
||||
text = value.strip()
|
||||
if text.startswith("{") and '"' in text:
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except ValueError:
|
||||
return value
|
||||
if isinstance(data, dict) and data:
|
||||
for lang in ("en_US", *sorted(data)):
|
||||
if lang in data and isinstance(data[lang], str):
|
||||
return data[lang]
|
||||
return value
|
||||
|
||||
|
||||
def fetch_views(database):
|
||||
"""Every view that matters: COW copies, their module twin, their children.
|
||||
|
||||
One query rather than one per view — these databases hold thousands of
|
||||
views. The result comes back as JSON: an arch is multi-line XML, so any
|
||||
line-based format would need a record separator the data could forge.
|
||||
"""
|
||||
raw = run_psql(
|
||||
database,
|
||||
"SELECT COALESCE(json_agg(json_build_object("
|
||||
"'id', id, 'key', COALESCE(key, ''), 'inherit_id', inherit_id,"
|
||||
"'website_id', website_id, 'active', active,"
|
||||
"'arch', arch_db::text))::text, '[]')"
|
||||
" FROM ir_ui_view WHERE arch_db IS NOT NULL",
|
||||
)
|
||||
rows = {}
|
||||
for item in json.loads(raw.strip() or "[]"):
|
||||
item["arch"] = normalise_arch(item.get("arch"))
|
||||
rows[item["id"]] = item
|
||||
return rows
|
||||
|
||||
|
||||
XPATH_RE = re.compile(r"<xpath\b[^>]*\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/<db>/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 <key> --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())
|
||||
Loading…
Reference in a new issue