578 lines
19 KiB
Python
578 lines
19 KiB
Python
|
|
#!/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())
|