erplibre/script/odoo/migration/fix_cow_render.py

578 lines
19 KiB
Python
Raw Normal View History

#!/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())