erplibre/script/analyse/check_migration_quality_tui.py
Mathieu Benoit 4028421cb8 [ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.

It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.

The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.

Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.

--- FR ---

[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier

Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.

Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.

Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.

Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.

Assisted-by: Claude Opus 5
2026-08-22 07:23:59 -04:00

225 lines
7.3 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 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
perdu = (
0
if diff is None or diff.get("unavailable")
else len(diff["rows_lost"])
)
lst.append(
{
"kind": "step",
"label": f"{etat['odoo']:<6} {etat['database'][:24]}",
"detail": str(perdu) if perdu else "",
"data": etat,
"diff": diff,
}
)
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 pane_text(lst_snapshot, row, colour=False):
"""Le détail du palier choisi, ou le bilan d'ensemble."""
if row is None:
return t("Nothing to show yet.")
if row["kind"] == "missing":
return f"⚠️ {row['data']['database']} : {t('database not found')}"
lignes = []
etat = row["data"]
if etat:
lignes.append(
status.paint(f"{etat['odoo']} {etat['database']}", "step", colour)
)
lignes.append("")
for libelle, valeur in (
(t("modules"), len(etat["installed"])),
(t("models"), len(etat["model"])),
(t("views"), etat["view"]),
(" · COW", etat["view_cow"]),
(t("menus"), etat["menu"]),
(t("attachments"), etat["attachment"]),
):
lignes.append(f" {libelle:<28} {valeur:>7}")
if etat.get("attachment_missing"):
lignes.append(
f" {status.paint('❌ ' + t('attachment files missing from'
' the filestore'), 'fail', colour)}"
f" {etat['attachment_missing']}"
)
lignes.append("")
diff = row.get("diff")
if diff:
lignes.extend(quality.render_compare(diff, colour, limit=40))
return "\n".join(lignes)
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"))]
def __init__(self, lst_snapshot):
super().__init__()
self.lst_snapshot = lst_snapshot
self.lst_row = rows(lst_snapshot)
self.index = 0
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):
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))
)
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
app.run()
return True