erplibre/script/odoo/migration/snapshot_cow_views.py
Mathieu Benoit d2c5d92d11 [FIX] migration: the COW tools speak the system language
They run as subprocesses and had no i18n, so a French migration alternated
languages from one line to the next. The database upgrade itself had the
same gap.

The driver read English sentences out of their output to decide whether to
ask its question. Translating them would have made it mute -- no error, no
trace. The link is now the exit code, which no language touches: 0
nothing, 1 copies concerned, 2 the tool failed.

87 strings, five tools. A test rejects any displayed sentence that skips
t(), and proves itself on an untranslated one.

--- FR ---

Ils tournent en sous-processus et n'avaient aucun i18n : une migration en
français alternait les deux langues d'une ligne à l'autre. La mise à
niveau de la base elle-même souffrait du même manque.

Le pilote lisait des phrases anglaises dans leur sortie pour décider de
poser sa question. Les traduire l'aurait rendu muet — sans erreur, sans
trace. Le lien est désormais le code de sortie, qu'aucune langue ne
touche : 0 rien, 1 copies concernées, 2 l'outil a échoué.

87 chaînes, cinq outils. Un test refuse toute phrase affichée qui saute
t(), et fait sa preuve sur une chaîne non traduite.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

225 lines
7.1 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
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# 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)} {t('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')} {t('views')})"
f" -> {after.get('label')} ({after.get('count')} {t('views')})"
)
if removed:
print(f"❌ {len(removed)} {t('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)} {t('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"{t('arch rewritten')} ({old.get('arch_len')} ->"
f" {new.get('arch_len')} {t('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)} {t('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(f"✅ -> {t('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())