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
225 lines
7.1 KiB
Python
Executable file
225 lines
7.1 KiB
Python
Executable file
#!/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())
|