erplibre/script/odoo/migration/fix_cow_render.py
Mathieu Benoit 291fd67841 [ADD] migration: réparer les copies de site qui ne savent plus se rendre
`check_cow_views` cherche la rupture qui ARRÊTE la migration : la forme
que l'arch doit avoir change. Deux autres la laissent finir sans un mot.

Un ancrage manquant : une vue héritière fait un xpath sur `//t[@t-set=…]`
que le module a gagné en chemin ; la copie, page d'utilisateur, n'est
jamais réécrite. Un t-call pendant : le gabarit appelé n'existe plus dans
la cible. Mesuré sur une 12 → 18 réelle : /contact rendait 500 depuis le
palier 14 → 15, quatre paliers de silence, et le test de fumée le voyait
sans savoir le nommer.

Le premier se répare depuis la vue module, en gardant la page telle que
son auteur l'a écrite. Le second se retire, et l'humain décide.

--- EN ---

`check_cow_views` looks for the break that STOPS the migration: the shape
the arch must have changes. Two others let it finish without a word.

A missing anchor: a child view xpaths on `//t[@t-set=…]` that the module
gained along the way; the copy, a user page, is never rewritten. A
dangling t-call: the template called no longer exists in the target.
Measured on a real 12 → 18: /contact returned 500 since the 14 → 15 step,
four steps of silence, and the smoke test saw it without naming it.

The first is repaired from the module view, keeping the page as its
author wrote it. The second is removed, and the human decides.

Assisted-by: Claude Opus 5
2026-08-25 03:19:18 -04:00

577 lines
19 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)
"""Les copies de site web qui ne savent plus se rendre après une montée.
`check_cow_views` prédit UN mode de rupture : la FORME que l'arch doit
avoir change entre deux versions — template autonome d'un côté, spec
d'héritage de l'autre. C'est celui qui arrête la migration elle-même, et
c'est pour cela qu'on le cherche AVANT de partir.
Deux autres modes la laissent finir sans un mot et n'apparaissent qu'à
l'ouverture de la page. Mesurés sur une migration 12 → 18 réelle :
ancrage manquant une vue héritière cherche `//t[@t-set='x']` dans la
copie. Le module a gagné cet ancrage en chemin — ici
au palier 14 → 15 — et sa propre vue héritière a
suivi ; la copie, elle, n'est jamais réécrite,
c'est une page d'utilisateur. Quatre paliers plus
tard /contact rendait 500, et rien ne l'avait dit.
t-call pendant la copie appelle un gabarit que la version cible ne
livre plus. `website.company_description` a disparu
en 18 : les deux copies qui l'appelaient rendaient
500, l'une d'elles pour CETTE raison seulement une
fois son ancrage remis.
Le premier se répare : l'ancrage existe dans la vue module de même clé,
on le remet là où le module le place. Le second ne se répare pas — un
gabarit disparu ne se réinvente pas — on retire l'appel, et la personne
décide si le bloc doit revenir autrement.
Lecture seule par défaut. `--apply` écrit, puis RELIT pour vérifier :
annoncer « corrigé » sans regarder ferait rouvrir la même page cassée.
"""
import argparse
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
def run_psql(database, sql, read_only=True):
"""Interroger la base. None si elle ne répond pas.
Lecture seule garantie par le SERVEUR et non par la politesse du
code : une réparation qui se trompe de base doit être refusée par
PostgreSQL, pas par une relecture attentive.
"""
# PGOPTIONS et non un « SET » en tête : celui-ci ÉCRIT une ligne
# « SET » dans la sortie, que le json.loads d'à côté prend en pleine
# figure. Même forme que fix_view_type.py, pour la même raison.
env = os.environ.copy()
if read_only:
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
result = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tA", "-c", sql],
capture_output=True,
text=True,
env=env,
)
if result.returncode:
return None
return result.stdout
def arch_is_jsonb(database):
"""La 16 stocke l'arch en varchar, la 17 et la 18 en jsonb."""
sortie = run_psql(
database,
"SELECT data_type FROM information_schema.columns"
" WHERE table_name='ir_ui_view' AND column_name='arch_db'",
)
return bool(sortie) and "jsonb" in sortie
# `arch_db` est un jsonb d'UNE ENTRÉE PAR LANGUE depuis la 17. N'en lire
# qu'une — et surtout n'en écrire qu'une — laisse la page cassée dans
# toutes les autres : mesuré sur /contact, réparé en en_US et toujours en
# 500 parce que le site rend en fr_CA.
def arch_expr(jsonb):
return "arch_db" if jsonb else "json_build_object('', arch_db)"
def langs_of(arch):
"""{langue: texte}. La clé vide quand la version ne traduit pas l'arch."""
if isinstance(arch, dict):
return {k: v for k, v in arch.items() if isinstance(v, str)}
return {"": arch} if isinstance(arch, str) else {}
def fetch(database, sql):
"""Rendre une liste de dictionnaires, ou None si la base se tait.
Un seul aller-retour et du JSON plutôt qu'un découpage par lignes :
un arch contient des sauts de ligne, et toute lecture ligne à ligne
finirait par couper une vue en deux sans le dire.
"""
sortie = run_psql(
database, f"SELECT coalesce(json_agg(x), '[]') FROM ({sql}) x"
)
if sortie is None:
return None
try:
return json.loads(sortie.strip() or "[]")
except ValueError:
return None
def cow_copies(database, jsonb):
"""Les copies faites par le site : website_id posé, aucun fichier.
`arch_fs` vide est le discriminant : une copie qui garde un fichier
est réécrite par la mise à jour du module, celle qui n'en a pas est
du contenu d'utilisateur et ne l'est jamais.
"""
return fetch(
database,
f"SELECT id, key, {arch_expr(jsonb)} AS arch FROM ir_ui_view"
" WHERE website_id IS NOT NULL AND coalesce(arch_fs, '') = ''"
" AND active",
)
def children_of(database, jsonb, lst_id):
"""{parent: [enfants]} — les vues qui héritent d'une de ces copies."""
if not lst_id:
return {}
ids = ",".join(str(i) for i in lst_id)
lignes = fetch(
database,
f"SELECT id, key, inherit_id, {arch_expr(jsonb)} AS arch"
f" FROM ir_ui_view WHERE inherit_id IN ({ids}) AND active",
)
dct = {}
for ligne in lignes or []:
dct.setdefault(ligne["inherit_id"], []).append(ligne)
return dct
def module_twin(database, jsonb, cle):
"""L'arch de la vue MODULE de même clé, celle dont la copie est née."""
lignes = fetch(
database,
f"SELECT {arch_expr(jsonb)} AS arch FROM ir_ui_view"
f" WHERE key = '{cle}' AND website_id IS NULL"
" AND coalesce(arch_fs, '') <> '' LIMIT 1",
)
return (lignes or [{}])[0].get("arch")
def known_keys(database):
sortie = run_psql(
database, "SELECT key FROM ir_ui_view WHERE key IS NOT NULL"
)
if sortie is None:
return None
return {ligne.strip() for ligne in sortie.splitlines() if ligne.strip()}
def parse(arch):
"""L'arbre, ou None si l'arch ne s'analyse pas."""
from lxml import etree
if not arch:
return None
try:
return etree.fromstring(arch.encode("utf-8"))
except Exception:
return None
def anchors_wanted(arch_enfant):
"""Les expressions xpath qu'une vue héritière cherche dans son parent."""
arbre = parse(arch_enfant)
if arbre is None:
return []
return [
noeud.get("expr") for noeud in arbre.iter("xpath") if noeud.get("expr")
]
def locates(arch, expr):
"""L'expression trouve-t-elle quelque chose dans cet arch ?
Une expression illisible compte comme TROUVÉE : elle ne relève pas de
cet outil, et la signaler comme un ancrage manquant enverrait
réparer une vue qui n'a rien.
"""
arbre = parse(arch)
if arbre is None:
return True
try:
return bool(arbre.xpath(expr))
except Exception:
return True
def dangling_calls(arch, connus):
"""Les `t-call` de cette arch vers un gabarit que la base n'a pas.
Seuls les appels LITTÉRAUX : un `t-call` calculé se résout à
l'exécution et l'on ne peut rien en dire ici.
"""
arbre = parse(arch)
if arbre is None:
return []
manquants = []
for noeud in arbre.iter():
nom = noeud.get("t-call")
if not nom or "{" in nom or nom.startswith("#"):
continue
if nom not in connus and nom not in manquants:
manquants.append(nom)
return manquants
def signature(noeud):
"""De quoi reconnaître un élément d'un arch dans l'autre."""
for attribut in ("t-call", "id", "t-name"):
if noeud.get(attribut):
return (noeud.tag, attribut, noeud.get(attribut))
return (noeud.tag, None, None)
def find_by_signature(arbre, sig):
tag, attribut, valeur = sig
for noeud in arbre.iter(tag):
if attribut is None:
return noeud
if noeud.get(attribut) == valeur:
return noeud
return None
def copie_de(noeud):
from lxml import etree
return etree.fromstring(etree.tostring(noeud))
def place_before(parent, ancre, accueil):
"""À quel rang poser l'ancrage dans `accueil`.
On cherche le premier frère SUIVANT de l'ancrage que la copie possède
aussi, et l'on se place juste devant. Sans aucun repère, en tête :
un `t-set` en tête est toujours valide, jamais en retard.
"""
suivants = list(parent)[list(parent).index(ancre) + 1 :]
for frere in suivants:
jumeau = find_by_signature(accueil, signature(frere))
if jumeau is not None and jumeau.getparent() is accueil:
return list(accueil).index(jumeau)
return 0
def repair_anchor(arch_copie, arch_module, expr):
"""Remettre l'ancrage là où le module le place. None si impossible.
On ne devine pas l'endroit : on lit dans le module quel élément
CONTIENT l'ancrage, on retrouve le même dans la copie par sa
signature, et l'on y insère en tête. Sans correspondance, on rend
None — mieux vaut une réparation refusée qu'un bloc posé au hasard
au milieu de la page de quelqu'un.
"""
from lxml import etree
module = parse(arch_module)
copie = parse(arch_copie)
if module is None or copie is None:
return None
try:
cibles = module.xpath(expr)
except Exception:
return None
if not cibles:
return None
ancre = cibles[0]
parent = ancre.getparent()
if parent is None:
return None
accueil = find_by_signature(copie, signature(parent))
if accueil is None:
return None
# AVANT le même frère suivant, et non au même rang : la copie n'a
# pas forcément les frères qui précèdent l'ancrage dans le module, et
# « rang 1 » y désignerait alors une tout autre place. Ce qui doit
# être respecté, c'est l'ORDRE — QWeb évalue dans l'ordre du
# document, donc un `t-set` posé après le contenu qui s'en sert n'est
# pas vu, et l'enfant qui s'insère « after » l'ancrage le suit.
accueil.insert(place_before(parent, ancre, accueil), copie_de(ancre))
return etree.tostring(copie, encoding="unicode")
def repair_call(arch_copie, nom):
"""Retirer l'appel au gabarit disparu. None si rien à retirer."""
from lxml import etree
copie = parse(arch_copie)
if copie is None:
return None
retires = 0
for noeud in list(copie.iter()):
if noeud.get("t-call") != nom:
continue
parent = noeud.getparent()
if parent is None:
continue
parent.remove(noeud)
retires += 1
if not retires:
return None
return etree.tostring(copie, encoding="unicode")
def any_lang(arch):
"""N'importe laquelle : les xpath d'une vue ne sont pas traduits."""
langues = langs_of(arch)
return langues.get("en_US") or (list(langues.values()) or [""])[0]
def audit(database):
"""Ce que chaque copie a de cassé. None si la base ne répond pas."""
jsonb = arch_is_jsonb(database)
copies = cow_copies(database, jsonb)
connus = known_keys(database)
if copies is None or connus is None:
return None
enfants = children_of(database, jsonb, [c["id"] for c in copies])
rapport = []
for copie in copies:
manque = []
pendants = []
for langue, texte in sorted(langs_of(copie["arch"]).items()):
for enfant in enfants.get(copie["id"], []):
# L'enfant a lui aussi ses langues ; une seule suffit à
# connaître ses xpath, ils ne sont pas traduits.
for expr in anchors_wanted(any_lang(enfant["arch"])):
if not locates(texte, expr):
manque.append(
{
"enfant": enfant["id"],
"expr": expr,
"langue": langue,
}
)
for nom in dangling_calls(texte, connus):
if nom not in pendants:
pendants.append(nom)
if manque or pendants:
rapport.append(
{
"id": copie["id"],
"key": copie["key"],
"anchors": manque,
"calls": pendants,
}
)
return {"database": database, "jsonb": jsonb, "views": rapport}
def write_arch_sql(vue_id, langues, jsonb):
"""La mise à jour, TOUTES les langues d'un coup.
Réécrire une seule entrée du jsonb laisse la page cassée dans les
autres : mesuré sur /contact, réparé en en_US et toujours en 500
parce que le site rend en fr_CA.
Dollar-quoting, parce qu'un arch porte une apostrophe à presque
chaque attribut. Le marqueur est assez long pour qu'aucun gabarit ne
le contienne par accident.
"""
if not jsonb:
texte = langues.get("") or next(iter(langues.values()), "")
return (
f"UPDATE ir_ui_view SET arch_db = $elcow${texte}$elcow$"
f" WHERE id = {vue_id};"
)
paires = ", ".join(
f"$elcow${langue}$elcow$, $elcow${texte}$elcow$"
for langue, texte in sorted(langues.items())
)
return (
f"UPDATE ir_ui_view SET arch_db = jsonb_build_object({paires})"
f" WHERE id = {vue_id};"
)
def plan(database, rapport):
"""[(id, clé, geste, quoi, sql)] — ce que `--apply` ferait, rien de plus.
Les réparations s'enchaînent sur le MÊME texte, langue par langue, et
une seule écriture les porte toutes : deux UPDATE sur la même vue et
le second effacerait le premier.
"""
jsonb = rapport["jsonb"]
gestes = []
for vue in rapport["views"]:
ligne = cow_copies_one(database, jsonb, vue["id"])
if not ligne:
continue
langues = langs_of(ligne["arch"])
module = module_twin(database, jsonb, vue["key"])
arch_module = any_lang(module) if module else None
touche = False
for langue in sorted(langues):
texte = langues[langue]
for manque in vue["anchors"]:
if locates(texte, manque["expr"]):
continue
neuf_texte = (
repair_anchor(texte, arch_module, manque["expr"])
if arch_module
else None
)
if neuf_texte is None:
gestes.append(
(
vue["id"],
vue["key"],
"anchor-impossible",
f"{manque['expr']} [{langue or '—'}]",
None,
)
)
continue
texte = neuf_texte
touche = True
gestes.append(
(
vue["id"],
vue["key"],
"anchor",
f"{manque['expr']} [{langue or '—'}]",
None,
)
)
for nom in vue["calls"]:
neuf_texte = repair_call(texte, nom)
if neuf_texte is None:
continue
texte = neuf_texte
touche = True
gestes.append(
(
vue["id"],
vue["key"],
"t-call",
f"{nom} [{langue or '—'}]",
None,
)
)
langues[langue] = texte
if touche:
gestes.append(
(
vue["id"],
vue["key"],
"write",
f"{len(langues)} langue(s)",
write_arch_sql(vue["id"], langues, jsonb),
)
)
return gestes
def cow_copies_one(database, jsonb, vue_id):
lignes = fetch(
database,
f"SELECT id, key, {arch_expr(jsonb)} AS arch FROM ir_ui_view"
f" WHERE id = {vue_id}",
)
return (lignes or [None])[0]
def render(rapport, gestes):
if not rapport["views"]:
return [f"✅ {t('Every website copy still renders.')}"]
lignes = [
f"🖼 {t('Website copies that can no longer render')}"
f" — {rapport['database']}",
"",
]
for vue in rapport["views"]:
lignes.append(f" [{vue['id']}] {vue['key']}")
for manque in vue["anchors"]:
lignes.append(
f" ⚓ {t('anchor missing for view')} {manque['enfant']} :"
f" {manque['expr']}"
)
for nom in vue["calls"]:
lignes.append(
f" ☎ {t('calls a template that is gone:')} {nom}"
)
impossibles = [g for g in gestes if g[2] == "anchor-impossible"]
faisables = [g for g in gestes if g[2] in ("anchor", "t-call")]
lignes.append("")
lignes.append(
f" {len(faisables)} {t('repair(s) available with --apply')}"
)
if impossibles:
lignes.append(
f" ⚠ {len(impossibles)} {t('cannot be repaired automatically')}"
)
if any(g[2] == "t-call" for g in faisables):
lignes.append(
f" {t('Removing a call removes its block from the page.')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Report website COW copies that can no longer render after a"
" version bump, and optionally repair them."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument(
"--apply",
action="store_true",
help="actually repair (default: report only)",
)
parser.add_argument("--json", action="store_true", help="machine output")
config = parser.parse_args(argv)
rapport = audit(config.database)
if rapport is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
gestes = plan(config.database, rapport)
if config.json:
print(
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
)
return 1 if rapport["views"] else 0
if not rapport["views"]:
print("\n".join(render(rapport, gestes)))
return 0
if not config.apply:
print("\n".join(render(rapport, gestes)))
return 1
for _id, _cle, genre, _quoi, sql in gestes:
if sql and run_psql(config.database, sql, read_only=False) is None:
print(f"❌ {t('The correction failed.')}")
return 2
# RELIRE : annoncer « corrigé » sans regarder ferait rouvrir la même
# page cassée en croyant le problème derrière soi.
apres = audit(config.database)
if apres is None:
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if apres["views"]:
print("\n".join(render(apres, plan(config.database, apres))))
print(f"⚠️ {t('Some copies still cannot render.')}")
return 1
print(f"✅ {t('Every website copy renders again.')}")
return 0
if __name__ == "__main__":
sys.exit(main())