Fifty-seven losses are read from the top, not in alphabetical order. They are now sorted by rows LOST, not by rows there before: a table of ten thousand that drops two matters less than one of a thousand that empties. « d » cycles the pane through the complete lists — modules, models, fields, COW copies, tables — and back to the summary. The summary cuts at eight entries and is right to; but when one is looking for whether ONE module survived, the truncated list does not answer, and that is exactly when it is needed. Two inventories were missing and both matter. FIELDS: a lost field is a lost column of data, finer than a model, which can survive emptied of half of its own. COW COPIES by key: it is the key one resets, and by the key one finds them again across versions. On a real 12 → 18 they read 11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared — customisations nothing was reporting until now. One mode replaces two flags: « show missing files » and « show the model list » cannot both be true, and two booleans let that impossible state be written. The mode is named in the sub-title, because a pane that changes without saying why reads as a broken screen. --- FR --- [ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d » Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le volume PERDU, non sur le volume présent avant : une table de dix mille qui en perd deux compte moins qu'une de mille qui se vide. « d » fait défiler le panneau à travers les listes entières — modules, modèles, champs, copies COW, tables — puis revient au résumé. Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18, 28 ont disparu — des personnalisations que rien ne signalait. Un seul mode remplace deux drapeaux, qui laissaient écrire un état impossible. Il est nommé dans le sous-titre. Assisted-by: Claude Opus 5
361 lines
12 KiB
Python
361 lines
12 KiB
Python
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
||
"""La qualité d'une migration, palier par palier, en plein écran.
|
||
|
||
Le rapport texte dit tout d'un coup : sur six paliers il fait plusieurs
|
||
centaines de lignes, et ce qu'on cherche — quel palier a perdu quoi — se
|
||
trouve quelque part au milieu. Un écran qui se parcourt règle cela.
|
||
|
||
Les données viennent de `check_migration_quality`, comme le rapport texte.
|
||
Deux assemblages sépareraient les deux vues, et l'on finirait par lire deux
|
||
états contradictoires de la même migration.
|
||
"""
|
||
|
||
import os
|
||
import sys
|
||
|
||
sys.path.append(
|
||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||
)
|
||
|
||
from script.analyse import check_migration_quality as quality # noqa: E402
|
||
from script.todo import migration_status as status # noqa: E402
|
||
|
||
try:
|
||
from script.todo.todo_i18n import t
|
||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||
|
||
def t(key: str) -> str:
|
||
return key
|
||
|
||
|
||
CSS = """
|
||
Screen { layout: vertical; }
|
||
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
|
||
#body { height: 1fr; }
|
||
#left { width: 40; border-right: solid $accent; }
|
||
#pane { width: 1fr; padding: 0 1; }
|
||
"""
|
||
|
||
|
||
def rows(lst_snapshot):
|
||
"""Un palier par ligne, puis le bilan d'ensemble en dernier.
|
||
|
||
Le bilan EN DERNIER et non en tête : on descend la liste comme on a
|
||
vécu la migration, et la question « qu'est-ce qu'il en reste » se pose
|
||
une fois qu'on a vu le chemin.
|
||
"""
|
||
lst = []
|
||
presents = [x for x in lst_snapshot if x.get("exists")]
|
||
for index, etat in enumerate(lst_snapshot):
|
||
if not etat.get("exists"):
|
||
lst.append(
|
||
{
|
||
"kind": "missing",
|
||
"label": f"⚠️ {etat['database'][:30]}",
|
||
"detail": "",
|
||
"data": etat,
|
||
}
|
||
)
|
||
continue
|
||
precedent = None
|
||
rang = presents.index(etat)
|
||
if rang:
|
||
precedent = presents[rang - 1]
|
||
diff = quality.compare(precedent, etat) if precedent else None
|
||
# Le précédent voyage avec la ligne : le panneau en a besoin pour
|
||
# écrire « 171 (−43) », et le recalculer là-bas ferait deux
|
||
# sources pour la même comparaison.
|
||
# La colonne compte les pertes INEXPLIQUÉES : afficher 81 quand
|
||
# 79 sont des refontes voulues par Odoo ferait fuir le lecteur du
|
||
# seul chiffre qui demande une réponse.
|
||
perdu = (
|
||
0
|
||
if diff is None or diff.get("unavailable")
|
||
else len([item for item in diff["rows_lost"] if not item[3]])
|
||
)
|
||
lst.append(
|
||
{
|
||
"kind": "step",
|
||
"label": f"{etat['odoo']:<6} {etat['database'][:24]}",
|
||
"detail": str(perdu) if perdu else "",
|
||
"data": etat,
|
||
"diff": diff,
|
||
"previous": precedent,
|
||
}
|
||
)
|
||
if len(presents) >= 2:
|
||
lst.append(
|
||
{
|
||
"kind": "overall",
|
||
"label": f"🏁 {presents[0]['odoo']} → {presents[-1]['odoo']}",
|
||
"detail": "",
|
||
"data": None,
|
||
"diff": quality.overall(lst_snapshot),
|
||
}
|
||
)
|
||
return lst
|
||
|
||
|
||
def head_text(lst_snapshot):
|
||
presents = [x for x in lst_snapshot if x.get("exists")]
|
||
manquants = len(lst_snapshot) - len(presents)
|
||
if not presents:
|
||
return t("No migration in progress.")
|
||
return (
|
||
f"{presents[0]['database']} · {len(presents)} {t('steps')}"
|
||
f" · {presents[0]['odoo']} → {presents[-1]['odoo']}"
|
||
+ (
|
||
f" · ⚠️ {manquants} {t('database not found')}"
|
||
if manquants
|
||
else ""
|
||
)
|
||
)
|
||
|
||
|
||
def statistics(etat, precedent):
|
||
"""[(libellé, valeur, écart)] pour un palier. L'écart est None au départ.
|
||
|
||
Un chiffre seul ne dit rien : 2283 vues est un nombre, « +61 » est une
|
||
information. Le premier palier n'a pas de précédent, et inventer un
|
||
écart de zéro y laisserait croire à une comparaison qui n'existe pas.
|
||
"""
|
||
lst = [
|
||
(
|
||
t("modules"),
|
||
len(etat["installed"]),
|
||
None if not precedent else len(precedent["installed"]),
|
||
),
|
||
(
|
||
t("models"),
|
||
len(etat["model"]),
|
||
None if not precedent else len(precedent["model"]),
|
||
),
|
||
(
|
||
t("views"),
|
||
etat["view"],
|
||
None if not precedent else precedent["view"],
|
||
),
|
||
(
|
||
" · COW",
|
||
etat["view_cow"],
|
||
None if not precedent else precedent["view_cow"],
|
||
),
|
||
(
|
||
t("menus"),
|
||
etat["menu"],
|
||
None if not precedent else precedent["menu"],
|
||
),
|
||
(
|
||
t("attachments"),
|
||
etat["attachment"],
|
||
None if not precedent else precedent["attachment"],
|
||
),
|
||
]
|
||
return [
|
||
(libelle, valeur, None if avant is None else valeur - avant)
|
||
for libelle, valeur, avant in lst
|
||
]
|
||
|
||
|
||
def pane_text(lst_snapshot, row, colour=False, mode=None):
|
||
"""Le détail du palier choisi, ou le bilan d'ensemble.
|
||
|
||
UN seul mode, et non deux drapeaux : « montre les fichiers absents »
|
||
et « montre la liste des modèles » ne peuvent pas être vrais en même
|
||
temps, et deux booléens laissaient écrire cet état impossible.
|
||
"""
|
||
if row is None:
|
||
return t("Nothing to show yet.")
|
||
if row["kind"] == "missing":
|
||
return f"⚠️ {row['data']['database']} : {t('database not found')}"
|
||
etat = row["data"]
|
||
if mode == "missing":
|
||
if not etat:
|
||
return t("Pick a step to see its missing files.")
|
||
return quality.render_missing(etat, colour)
|
||
if mode in quality.DETAILS:
|
||
return quality.render_detail(row.get("diff"), mode, colour)
|
||
lignes = []
|
||
if etat:
|
||
lignes.append(
|
||
status.paint(f"{etat['odoo']} {etat['database']}", "step", colour)
|
||
)
|
||
lignes.append("")
|
||
for libelle, valeur, ecart in statistics(etat, row.get("previous")):
|
||
if ecart is None:
|
||
marque = ""
|
||
else:
|
||
marque = status.paint(
|
||
f"{ecart:+d}", "ok" if ecart >= 0 else "warn", colour
|
||
)
|
||
lignes.append(f" {libelle:<28} {valeur:>7} {marque}")
|
||
if etat.get("attachment_missing"):
|
||
lignes.append(
|
||
" "
|
||
+ status.paint(
|
||
f"❌ {etat['attachment_missing']}"
|
||
f" {t('attachment files missing from the filestore')}",
|
||
"fail",
|
||
colour,
|
||
)
|
||
)
|
||
lignes.append(f" {t('press m to list them')}")
|
||
lignes.append("")
|
||
diff = row.get("diff")
|
||
if diff:
|
||
lignes.extend(quality.render_compare(diff, colour, limit=40))
|
||
return "\n".join(lignes)
|
||
|
||
|
||
def mode_label(mode):
|
||
"""Le nom du mode courant, pour le sous-titre.
|
||
|
||
Un panneau qui change de contenu sans dire pourquoi se lit comme un
|
||
écran cassé — et avec sept modes, on ne devine pas.
|
||
"""
|
||
if mode is None:
|
||
return t("summary")
|
||
if mode == "missing":
|
||
return t("Missing files")
|
||
return t(mode)
|
||
|
||
|
||
def build_app(lst_snapshot):
|
||
"""Textual est importé ICI : le module reste testable sans lui."""
|
||
from rich.text import Text
|
||
from textual.app import App, ComposeResult
|
||
from textual.containers import Horizontal, VerticalScroll
|
||
from textual.widgets import DataTable, Footer, Header, Static
|
||
|
||
class QualityApp(App):
|
||
CSS = globals()["CSS"]
|
||
BINDINGS = [
|
||
("q,escape", "quit", t("Quit")),
|
||
("m", "toggle_missing", t("Missing files")),
|
||
("d", "cycle_detail", t("Details")),
|
||
]
|
||
|
||
def __init__(self, lst_snapshot):
|
||
super().__init__()
|
||
self.lst_snapshot = lst_snapshot
|
||
self.lst_row = rows(lst_snapshot)
|
||
self.index = 0
|
||
self.mode = None
|
||
|
||
def compose(self) -> ComposeResult:
|
||
yield Header()
|
||
yield Static("", id="head")
|
||
with Horizontal(id="body"):
|
||
yield DataTable(id="left", cursor_type="row")
|
||
with VerticalScroll(id="pane"):
|
||
yield Static("", id="content")
|
||
yield Footer()
|
||
|
||
def on_mount(self):
|
||
self.title = t("Migration quality, step by step")
|
||
table = self.query_one("#left", DataTable)
|
||
table.add_columns(t("steps"), "▼")
|
||
for row in self.lst_row:
|
||
table.add_row(row["label"][:34], row["detail"])
|
||
self.query_one("#head", Static).update(
|
||
head_text(self.lst_snapshot)
|
||
)
|
||
self._show()
|
||
|
||
def _show(self):
|
||
self.sub_title = mode_label(self.mode)
|
||
row = (
|
||
self.lst_row[self.index]
|
||
if self.lst_row and self.index < len(self.lst_row)
|
||
else None
|
||
)
|
||
self.query_one("#content", Static).update(
|
||
Text.from_ansi(
|
||
pane_text(
|
||
self.lst_snapshot,
|
||
row,
|
||
colour=True,
|
||
mode=self.mode,
|
||
)
|
||
)
|
||
)
|
||
|
||
def action_toggle_missing(self):
|
||
"""Basculer entre les chiffres du palier et ses fichiers absents.
|
||
|
||
Les métadonnées ne sont lues qu'ICI : une requête de plus par
|
||
base allongerait un parcours qui tient en quatre secondes, pour
|
||
une information qu'on ne regarde qu'en la demandant.
|
||
"""
|
||
self.mode = None if self.mode == "missing" else "missing"
|
||
self._show()
|
||
|
||
def action_cycle_detail(self):
|
||
"""Parcourir les listes ENTIÈRES, une catégorie à la fois.
|
||
|
||
Le résumé coupe à huit entrées et il a raison ; mais quand on
|
||
cherche si UN module précis a survécu, la liste tronquée ne
|
||
répond pas. On enchaîne donc résumé → modules → modèles →
|
||
champs → copies COW → tables, et l'on revient.
|
||
"""
|
||
suite = (None,) + quality.DETAILS
|
||
courant = self.mode if self.mode in suite else None
|
||
self.mode = suite[(suite.index(courant) + 1) % len(suite)]
|
||
self._show()
|
||
|
||
def on_data_table_row_highlighted(self, event):
|
||
if event.data_table.id == "left" and self.lst_row:
|
||
self.index = event.cursor_row
|
||
self._show()
|
||
|
||
return QualityApp(lst_snapshot)
|
||
|
||
|
||
def run_tui(lst_snapshot, run_app=True):
|
||
"""Ouvrir l'écran. False si l'on n'a pas pu — et alors on DIT pourquoi."""
|
||
if not lst_snapshot:
|
||
return False
|
||
if not sys.stdout.isatty():
|
||
print(f"ℹ️ {t('Not a terminal: showing the text report instead.')}")
|
||
return False
|
||
try:
|
||
from script.todo import textual_setup
|
||
except Exception:
|
||
textual_setup = None
|
||
if textual_setup and not textual_setup.ensure():
|
||
return False
|
||
try:
|
||
app = build_app(lst_snapshot)
|
||
except ImportError:
|
||
print(
|
||
f"ℹ️ {t('Textual is missing from this interpreter:')}"
|
||
f" {sys.executable}"
|
||
)
|
||
return False
|
||
if not run_app:
|
||
return app
|
||
if in_event_loop():
|
||
# Filet de sécurité : `app.run()` appelle `asyncio.run()`, qui
|
||
# lève dans une boucle déjà en cours. Le dire vaut mieux que la
|
||
# trace de quarante lignes que cela produit — et l'appelant doit
|
||
# passer par un sous-processus.
|
||
print(
|
||
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
|
||
f" {t('process instead.')}"
|
||
)
|
||
return False
|
||
app.run()
|
||
return True
|
||
|
||
|
||
def in_event_loop():
|
||
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
|
||
import asyncio
|
||
|
||
try:
|
||
asyncio.get_running_loop()
|
||
except RuntimeError:
|
||
return False
|
||
return True
|