erplibre/script/odoo/migration/snapshot_cow_views.py
Mathieu Benoit 1e4cc4f471 [ADD] migration: snapshot and diff the website COW views at each version bump
A version bump rewrites website views without announcing any of it, so "the
site looks wrong" after a migration is currently unanswerable.

snapshot_cow_views.py records every website_id view (key, mode, inherit_id,
active, arch and its md5) and diffs two snapshots. Rows come back as JSON
straight from Postgres because an arch holds newlines and pipes; the column
list is intersected with information_schema, since ir_ui_view does not expose
the same columns from 12.0 to 18.0. Snapshots hold customer template content,
so they go under private/ and stay out of git.

todo_upgrade.py takes one before and one after each OpenUpgrade run, then
prints the diff. Both are non-blocking: forensic material must never stop an
upgrade.

Measured on the real 12.0 -> 13.0 jump, the diff shows what no log reported:
71 -> 68 copies, 16 deleted and 13 created. portal.frontend_layout is not
converted but DELETED (id 2670) and RECREATED (id 3397), with its children
re-parented from one to the other. The four theme_technolibre copies,
muk_web_branding, project_agile and erplibre_website_snippets_basic_html are
dropped outright, and website_crm.contactus_thanks is renamed to
website_form.contactus_thanks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-01 00:58:54 -04:00

213 lines
6.7 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Snapshot the website COW views, and diff two snapshots.
Why
---
A version bump rewrites website views in ways nobody announces: OpenUpgrade
converts Bootstrap markup on every ``website_id IS NOT NULL`` view, modules
rewrite the copies they own, and some copies simply disappear. Without a
before/after record, "the site looks wrong" is unanswerable.
Taking a snapshot before and after each jump turns that into a diff: which copy
lost its arch, which changed mode, which was renamed, which vanished.
Snapshots hold customer template content, so they belong under ``private/``
and are never versioned.
Usage::
snapshot_cow_views.py -d <database> --label before_13
snapshot_cow_views.py -d <database> --label after_13
snapshot_cow_views.py --diff <before.json> <after.json>
"""
import argparse
import datetime
import hashlib
import json
import os
import subprocess
import sys
# Columns worth recording. ir_ui_view does not expose the same set across 12.0
# to 18.0, so the query keeps only those that actually exist.
WANTED_COLUMN = [
"id",
"key",
"name",
"type",
"mode",
"active",
"priority",
"website_id",
"inherit_id",
"arch_fs",
"arch_updated",
]
DEFAULT_DIR = os.path.join("private", "odoo", "migration")
def run_psql(database, sql):
"""Run a read-only query and return stdout."""
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 existing_columns(database):
"""Column names of ir_ui_view present in this database."""
output = run_psql(
database,
"SELECT column_name FROM information_schema.columns"
" WHERE table_name = 'ir_ui_view';",
)
return {line.strip() for line in output.splitlines() if line.strip()}
def collect(database):
"""Return the list of COW views as plain dicts, arch included.
The rows come back as JSON straight from Postgres: an arch holds newlines
and pipes, so no hand-made separator survives it.
"""
available = existing_columns(database)
lst_column = [name for name in WANTED_COLUMN if name in available]
select = ", ".join(lst_column) + ", arch_db::text AS arch_db"
output = run_psql(
database,
"SELECT COALESCE(json_agg(row_to_json(t)), '[]'::json) FROM ("
f" SELECT {select} FROM ir_ui_view"
" WHERE website_id IS NOT NULL ORDER BY id) t;",
)
lst_view = json.loads(output or "[]")
for view in lst_view:
arch = view.pop("arch_db", None) or ""
view["arch_md5"] = hashlib.md5(arch.encode("utf-8")).hexdigest()
view["arch_len"] = len(arch)
view["arch_db"] = arch
return lst_view
def save(database, label, output_dir):
"""Write a snapshot and return its path."""
directory = output_dir or os.path.join(
DEFAULT_DIR, database, "cow_snapshots"
)
os.makedirs(directory, exist_ok=True)
lst_view = collect(database)
payload = {
"database": database,
"label": label,
"taken_at": datetime.datetime.now().isoformat(timespec="seconds"),
"count": len(lst_view),
"views": lst_view,
}
file_path = os.path.join(directory, f"{label}.json")
with open(file_path, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
print(f"✅ -> {len(lst_view)} COW view(s) recorded in {file_path}")
return file_path
def load(file_path):
with open(file_path, "r", encoding="utf-8") as f:
return json.load(f)
def diff(path_before, path_after):
"""Print what changed between two snapshots."""
before = load(path_before)
after = load(path_after)
map_before = {view["id"]: view for view in before["views"]}
map_after = {view["id"]: view for view in after["views"]}
removed = sorted(set(map_before) - set(map_after))
added = sorted(set(map_after) - set(map_before))
common = sorted(set(map_before) & set(map_after))
print(
f"📊 {before.get('label')} ({before.get('count')} views)"
f" -> {after.get('label')} ({after.get('count')} views)"
)
if removed:
print(f"{len(removed)} COW view(s) disappeared:")
for view_id in removed:
view = map_before[view_id]
print(f" - id={view_id} {view.get('key')}")
if added:
print(f" {len(added)} COW view(s) appeared:")
for view_id in added:
view = map_after[view_id]
print(f" - id={view_id} {view.get('key')} ({view.get('mode')})")
lst_changed = []
for view_id in common:
old, new = map_before[view_id], map_after[view_id]
lst_field = []
for field in ("key", "mode", "inherit_id", "active", "arch_md5"):
if old.get(field) != new.get(field):
if field == "arch_md5":
lst_field.append(
f"arch rewritten ({old.get('arch_len')} ->"
f" {new.get('arch_len')} chars)"
)
else:
lst_field.append(
f"{field}: {old.get(field)} -> {new.get(field)}"
)
if lst_field:
lst_changed.append((view_id, new.get("key"), lst_field))
if lst_changed:
print(f"✏️ {len(lst_changed)} COW view(s) changed:")
for view_id, key, lst_field in lst_changed:
print(f" - id={view_id} {key}")
for change in lst_field:
print(f" {change}")
if not (removed or added or lst_changed):
print("✅ -> No change on the website COW views.")
return 0
def main():
parser = argparse.ArgumentParser(
description="Snapshot website COW views, or diff two snapshots."
)
parser.add_argument("-d", "--database", help="database to snapshot")
parser.add_argument(
"-l", "--label", help="snapshot name, e.g. before_13 or after_13"
)
parser.add_argument(
"-o", "--output_dir", help="where to write (default: private/...)"
)
parser.add_argument(
"--diff",
nargs=2,
metavar=("BEFORE", "AFTER"),
help="compare two snapshot files instead of taking one",
)
config = parser.parse_args()
if config.diff:
return diff(*config.diff)
if not config.database or not config.label:
parser.error("--database and --label are required to take a snapshot")
save(config.database, config.label, config.output_dir)
return 0
if __name__ == "__main__":
sys.exit(main())