erplibre/script/analyse/check_migration_quality.py
Mathieu Benoit c95cf2e9d8 [ADD] qualité de migration : verdicts, sources, revue
Le rapport comparait les paliers sans dire si la migration avait réussi,
alors que les verdicts dorment déjà dans lst_event du journal de
progression : des contrôles en échec y restent sans remonter nulle part.

Trois sections s'ajoutent sous les paliers : les verdicts, rattachés au
palier ODOO et non au compteur du pilote, décalé d'un rang ; où vivent les
traces, car config.conf laisse logfile= vide et la sortie d'Odoo meurt avec
le terminal ; et la revue, six étapes lançables par « r ». Le contrôle de
résidus porte la même section sans toucher son code de sortie : un verdict
vient du fichier, pas de la base.

--- EN ---

The report compared the tiers without saying whether the migration had
succeeded, while the verdicts already sit in lst_event of the progression
file: failed checks stay there and surface nowhere.

Three sections are added below the tiers: the verdicts, tied to the ODOO
tier and not to the driver counter, which is off by one; where the traces
live, since config.conf leaves logfile= empty and Odoo's output dies with
the terminal; and the review, six steps runnable with "r". The residue
check carries the same section without touching its exit code: a verdict
comes from the file, not from the database.

Assisted-by: Claude Opus 5
(cherry picked from commit b05e0333c4b94d58eb794f09a2459e41d826c655)
2026-08-29 02:11:04 -04:00

1478 lines
54 KiB
Python
Executable file
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)
"""Ce qu'une migration gagne et ce qu'elle perd, palier par palier.
La question qu'on se pose après six paliers n'est pas « a-t-elle fini » —
le journal le dit — mais « qu'est-ce qui a changé en chemin ». Un module
désinstallé en 15 pour débloquer la mise à jour, une table vidée sans que
personne ne le voie, deux cents vues apparues : rien de tout cela
n'apparaît dans un journal qu'on lit ligne à ligne.
Ce que l'outil compare
----------------------
Une migration laisse une base PAR PALIER — `x`, `x_upgrade_13`, … — et
elles existent toutes encore. On les interroge donc côte à côte, en
lecture seule, plutôt que de rejouer quoi que ce soit.
Pourquoi pas en démarrant Odoo
------------------------------
Six démarrages coûteraient une heure, écriraient dans les bases et
demanderaient de basculer le checkout à chaque palier. Mesuré : la même
inspection en SQL prend moins d'une demi-seconde par base, et ne touche à
rien. Ce qu'on y perd — les modèles abstraits, les champs calculés — ne
se compare pas d'une version à l'autre de toute façon.
Ce qui compte le plus
---------------------
Les LIGNES perdues. Un module en moins se voit ; une table qui passe de
quatre mille lignes à zéro ne se voit nulle part. Un renommage de table
entre deux versions s'y lit comme une perte suivie d'un gain : le rapport
les rapproche quand le compte correspond, plutôt que de crier au loup.
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
"""
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
DEFAULT_PROGRESSION = ".venv.erplibre/odoo_database_migration_log.json"
FILESTORE = os.path.join(
os.path.expanduser("~"), ".local", "share", "Odoo", "filestore"
)
SEP = "\x1f"
def run_psql(database, sql):
"""Interroger la base en lecture seule, garantie par le SERVEUR.
`default_transaction_read_only` n'est pas une promesse de l'outil :
PostgreSQL refusera l'écriture même si le SQL en contenait une. On
inspecte des bases de migration, parfois la seule copie qui reste.
"""
env = os.environ.copy()
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return None
return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
def read_progression(path=DEFAULT_PROGRESSION):
try:
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
except (OSError, ValueError):
return {}
# Où la migration laisse une trace, et ce qu'on y trouve. Les DEUX
# chemins sont montrés à l'écran même quand le second n'existe pas : sans
# `logfile` dans config.conf, Odoo écrit sur le terminal et sa sortie est
# perdue à la fermeture. Ne rien dire laisserait chercher un fichier qui
# n'a jamais été écrit.
JOURNAL_ODOO = "log/odoo.log"
MOTIFS_ERREUR = ("CRITICAL", "ERROR", "Traceback")
def read_events(dct):
"""Les verdicts que la migration a laissés, dans l'ordre où ils sont venus.
`command_executed` dit ce qui a été LANCÉ ; `lst_event` dit ce que
cela a RENDU. C'est la seule trace persistante d'un échec : la sortie
d'Odoo, elle, part sur le terminal et disparaît avec lui.
"""
lst = []
for brut in dct.get("lst_event") or []:
if not isinstance(brut, dict):
continue
try:
statut = int(brut.get("status") or 0)
except (TypeError, ValueError):
statut = 0
lst.append(
{
"at": str(brut.get("at") or ""),
"step": str(brut.get("step") or ""),
"kind": str(brut.get("kind") or ""),
"name": str(brut.get("name") or ""),
"status": statut,
"detail": str(brut.get("detail") or ""),
}
)
return lst
def failures(events):
"""Ceux qui n'ont pas rendu zéro, et seulement ceux-là.
On garde `kind == "test"` : les entrées `command` à 1 appartiennent au
dialogue du pilote — « voulez-vous effacer le module manquant » — et
signaler un choix comme un échec ferait ignorer la liste entière.
"""
return [e for e in events if e["status"] and e["kind"] == "test"]
def event_database(event):
"""La base sur laquelle ce verdict portait, lue dans sa commande."""
for mot in event.get("detail", "").split():
if mot.startswith("test_") or "_upgrade_" in mot:
return mot
morceaux = event.get("detail", "").split("-d ")
if len(morceaux) > 1:
return morceaux[1].split()[0]
return ""
def event_step(event, database=""):
"""Le palier Odoo que ce verdict concernait — « 14 », « 18 ».
Le nom de la base le porte — « …_upgrade_14 » — et c'est plus sûr que
le champ `step`, dont la numérotation (« 4.1.I ») compte les ÉTAPES du
pilote et non les versions d'Odoo : elles sont décalées d'un rang.
"""
base = database or event_database(event)
if "_upgrade_" in base:
suffixe = base.rsplit("_upgrade_", 1)[1]
if suffixe.isdigit():
return suffixe
return (event.get("step") or "").split(" ")[0][:5]
def log_sources(path=DEFAULT_PROGRESSION, journal=JOURNAL_ODOO):
"""[(rôle, chemin, existe, ce qu'on y lit)] — la carte des traces.
Documenter les chemins EST la fonctionnalité : il a fallu une question
pour découvrir que les verdicts vivaient dans `lst_event`, et que la
sortie d'Odoo n'était écrite nulle part.
"""
return [
(
t("Migration progression"),
path,
os.path.isfile(path),
t("steps, commands and their verdicts (lst_event)"),
),
(
t("Odoo log"),
journal,
os.path.isfile(journal),
t("set logfile= in config.conf, else output is lost"),
),
]
def scan_log(path=JOURNAL_ODOO, limite=12):
"""{motif: compte} et les dernières lignes fautives d'un journal Odoo.
On lit la QUEUE : un journal de migration pèse des dizaines de
mégaoctets et ce qu'on cherche est ce qui a échoué en dernier.
"""
rapport = {
"path": path,
"exists": os.path.isfile(path),
"counts": {},
"lines": [],
}
if not rapport["exists"]:
return rapport
try:
with open(path, "rb") as handle:
handle.seek(0, os.SEEK_END)
debut = max(0, handle.tell() - 2_000_000)
handle.seek(debut)
queue = handle.read().decode("utf-8", "replace")
except OSError:
return rapport
for motif in MOTIFS_ERREUR:
rapport["counts"][motif] = queue.count(motif)
for ligne in queue.splitlines():
if any(motif in ligne for motif in MOTIFS_ERREUR):
rapport["lines"].append(ligne.strip()[:200])
rapport["lines"] = rapport["lines"][-limite:]
return rapport
# La revue : ce qu'on vérifie, dans l'ordre, et ce que chaque étape prouve.
# Elle est ÉCRITE ici plutôt que dans une documentation à côté, parce
# qu'une liste de contrôle qu'il faut aller chercher n'est pas suivie.
REVUE = (
(
"Did the migration reach the end?",
"lst_event, and the six state_4_*_lst flags at 6/6",
None,
),
(
"Does Odoo load the database?",
"./odoo_bin.sh shell -c ./config.conf -d {db} --no-http",
"shell",
),
(
"What did the migration leave behind?",
"script/analyse/check_migration_residue.py -d {db}",
"residue",
),
(
"Is this copy safe to open?",
"script/analyse/check_instance_state.py -d {db} --expect copy",
"state",
),
(
"Do the public pages still answer?",
"script/odoo/migration/smoke_public_url.py -d {db}",
"smoke",
),
(
"Is anything left that cleanup could not drop?",
"script/odoo/migration/database_cleanup.py -d {db}",
"cleanup",
),
)
def chain(dct):
"""[(version, base)] du départ à l'arrivée, dans l'ordre du parcours.
Le nom des bases de palier suit la convention du pilote —
« <base>_upgrade_<version> » — et la liste des paliers se déduit de la
cible et du nombre d'entrées `state_4_*_odoo_lst`. On ne devine donc
rien : on relit ce que la migration a écrit.
"""
base = dct.get("config_database_name")
if not base:
return []
try:
cible = int(float(dct.get("target_odoo_version") or 0))
except (TypeError, ValueError):
return []
total = max(
[
len(valeur)
for cle, valeur in dct.items()
if cle.startswith("state_4_")
and cle.endswith("_odoo_lst")
and isinstance(valeur, list)
]
or [0]
)
if not total or not cible:
return [(None, base)]
lst_version = list(range(cible - total + 1, cible + 1))
depart = lst_version[0] - 1
return [(depart, base)] + [
(version, f"{base}_upgrade_{version}") for version in lst_version
]
META_SQL = """
SELECT 'odoo', latest_version FROM ir_module_module WHERE name = 'base'
UNION ALL SELECT 'view', count(*)::text FROM ir_ui_view
UNION ALL SELECT 'view_cow', count(*)::text FROM ir_ui_view
WHERE website_id IS NOT NULL
UNION ALL SELECT 'menu', count(*)::text FROM ir_ui_menu
UNION ALL SELECT 'action', count(*)::text FROM ir_act_window
UNION ALL SELECT 'attachment', count(*)::text FROM ir_attachment
UNION ALL SELECT 'attachment_stored', count(*)::text FROM ir_attachment
WHERE store_fname IS NOT NULL
UNION ALL SELECT 'language', count(*)::text FROM res_lang WHERE active
"""
def table_counts(database):
"""{table: lignes} pour toutes les tables. Une seule requête.
Construite côté serveur puis exécutée d'un bloc : huit cents requêtes
séparées coûteraient huit cents allers-retours, là où celle-ci prend
quatre dixièmes de seconde — mesuré sur une base de 890 tables.
"""
fabrique = run_psql(
database,
"SELECT string_agg("
"format('SELECT %L t, count(*) n FROM %I', table_name, table_name),"
" ' UNION ALL ') FROM information_schema.tables"
" WHERE table_schema = 'public' AND table_type = 'BASE TABLE'",
)
if not fabrique or not fabrique[0][0]:
return {}
lignes = run_psql(database, fabrique[0][0])
if lignes is None:
return {}
return {
nom: int(nombre)
for nom, nombre in (ligne[:2] for ligne in lignes)
if nombre.isdigit()
}
def missing_files(database, lst_store_fname):
"""Les pièces jointes dont le FICHIER a disparu du filestore.
Une base peut référencer des milliers de pièces jointes dont le
contenu n'a pas suivi le clonage. Rien ne le signale : la page se
charge, l'image est vide.
"""
racine = os.path.join(FILESTORE, database)
if not os.path.isdir(racine):
return None
return [
nom
for nom in lst_store_fname
if nom and not os.path.isfile(os.path.join(racine, nom))
]
def inspect(database):
"""L'état d'une base, en lecture seule. `exists` False si absente."""
etat = {"database": database, "exists": False}
meta = run_psql(database, META_SQL)
if meta is None:
return etat
etat["exists"] = True
dct_meta = {ligne[0]: ligne[1] for ligne in meta if len(ligne) > 1}
etat["odoo"] = (dct_meta.get("odoo") or "?").rsplit(".", 2)[0]
for cle in (
"view",
"view_cow",
"menu",
"action",
"attachment",
"attachment_stored",
"language",
):
etat[cle] = int(dct_meta.get(cle) or 0)
modules = run_psql(
database, "SELECT name, state FROM ir_module_module ORDER BY name"
)
etat["module"] = {
nom: statut for nom, statut in (m[:2] for m in modules or [])
}
etat["installed"] = sorted(
nom for nom, statut in etat["module"].items() if statut == "installed"
)
modeles = run_psql(database, "SELECT model FROM ir_model ORDER BY model")
etat["model"] = sorted(m[0] for m in modeles or [])
etat["table"] = table_counts(database)
# Les CHAMPS : un champ perdu est une colonne de données perdue, et
# c'est plus fin qu'un modèle — un modèle qui survit peut avoir été
# vidé de la moitié de ses champs sans que rien ne le dise.
champs = run_psql(
database,
"SELECT model || '.' || name FROM ir_model_fields ORDER BY 1",
)
etat["field"] = sorted(ligne[0] for ligne in champs or [])
# À quel module appartient chaque champ. Sans cela, un champ d'un
# module OCA compterait comme « non déclaré par OpenUpgrade » —
# littéralement vrai, et trompeur : OpenUpgrade n'analyse que le cœur
# d'Odoo, il ne pouvait rien en dire.
origines = run_psql(
database,
"SELECT f.model || '.' || f.name, d.module FROM ir_model_fields f"
" JOIN ir_model_data d ON d.model = 'ir.model.fields'"
" AND d.res_id = f.id",
)
dct_origine = {}
for ligne in origines or []:
if len(ligne) >= 2:
dct_origine.setdefault(ligne[0], set()).add(ligne[1])
etat["field_module"] = {cle: sorted(v) for cle, v in dct_origine.items()}
# Lesquels PORTENT une donnée. Un champ `store=false` n'a jamais eu
# de colonne : sa disparition ne perd rien, et il pesait jusqu'à 90 %
# du seau « non déclarés par OpenUpgrade ». Mesuré au palier 16 → 17 :
# `__last_update` à lui seul comptait pour 397 des 565.
#
# `store` existe de la 12 à la 18 et n'est jamais NULL — vérifié sur
# les sept bases — donc le renseignement est fiable partout.
stockes = run_psql(
database,
"SELECT model || '.' || name FROM ir_model_fields WHERE store",
)
etat["field_stored"] = sorted(ligne[0] for ligne in stockes or [])
# Le CHAMP PORTEUR de chaque pièce jointe, et s'il existe encore.
#
# Une pièce jointe dont `res_field` ne nomme aucun champ vivant est
# DÉJÀ illisible : Odoo lève un KeyError en la contrôlant. Ce ne sont
# pas des données, ce sont des débris. Mesuré sur une chaîne 12 → 18 :
# la dette naît aux paliers 13 et 14, reste gelée pendant trois
# paliers, et la 18 ramasse 452 lignes d'un coup — qui se lisent
# alors comme 452 pertes.
#
# Ni le nom ni le mimetype ici : `run_psql` découpe par LIGNE et un
# nom de fichier peut en contenir une.
portees = run_psql(
database,
"SELECT a.id, coalesce(a.res_model, ''), coalesce(a.res_field, ''),"
" coalesce(a.res_id::text, ''),"
" CASE WHEN a.res_field IS NULL OR a.res_field = '' THEN '1'"
" WHEN EXISTS (SELECT 1 FROM ir_model_fields f"
" WHERE f.model = a.res_model AND f.name = a.res_field)"
" THEN '1' ELSE '0' END"
" FROM ir_attachment a",
)
etat["attachment_row"] = {
ligne[0]: (ligne[1], ligne[2], ligne[3], ligne[4] == "1")
for ligne in portees or []
if len(ligne) >= 5 and ligne[0].isdigit()
}
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
# c'est par elle qu'on les retrouve d'une version à l'autre.
copies = run_psql(
database,
"SELECT coalesce(key, 'id:' || id::text) FROM ir_ui_view"
" WHERE website_id IS NOT NULL ORDER BY 1",
)
etat["cow"] = sorted(ligne[0] for ligne in copies or [])
# Les modèles sans table : abstraits, mixins et vues SQL pour la
# plupart — mais leur NOMBRE qui bouge d'un palier à l'autre dit
# quelque chose, alors que la liste brute ne dit rien.
tables = set(etat["table"])
etat["model_without_table"] = [
m for m in etat["model"] if m.replace(".", "_") not in tables
]
stockees = run_psql(
database,
"SELECT DISTINCT store_fname FROM ir_attachment"
" WHERE store_fname IS NOT NULL",
)
absents = missing_files(database, [ligne[0] for ligne in stockees or []])
etat["attachment_missing"] = None if absents is None else len(absents)
# Les NOMS, pas seulement le compte : sans eux, « 254 fichiers
# absents » ne dit pas lesquels, et l'on ne peut ni juger de la
# gravité ni retrouver ce qui a disparu. Bornés, car une base peut en
# aligner des dizaines de milliers et l'écran n'en montrera jamais tant.
etat["attachment_missing_list"] = (absents or [])[:MAX_MISSING]
return etat
MAX_MISSING = 5000
def missing_detail(database, lst_store_fname, limit=400):
"""Les métadonnées des pièces jointes dont le fichier a disparu.
À la DEMANDE, jamais pendant l'inspection : une requête de plus par
base allongerait un parcours qui tient aujourd'hui en quatre secondes,
pour une information qu'on ne regarde qu'en la demandant.
`res_field` est le renseignement le plus utile du lot : il dit QUEL
champ a perdu son image — l'`image_1920` d'un pays n'a pas le même
poids qu'une pièce jointe de facture.
"""
if not lst_store_fname:
return []
lst = list(lst_store_fname)[:limit]
valeurs = ", ".join("'" + nom.replace("'", "''") + "'" for nom in lst)
lignes = run_psql(
database,
"SELECT store_fname, coalesce(res_model, '-'),"
" coalesce(res_field, '-'), coalesce(res_id::text, '-'),"
" coalesce(mimetype, '-'), coalesce(file_size::text, '0'),"
" coalesce(name, '-')"
f" FROM ir_attachment WHERE store_fname IN ({valeurs})"
" ORDER BY res_model, res_field, name",
)
return [
{
"store_fname": ligne[0],
"model": ligne[1],
"field": ligne[2],
"res_id": ligne[3],
"mimetype": ligne[4],
"size": int(ligne[5]) if ligne[5].isdigit() else 0,
"name": ligne[6],
}
for ligne in lignes or []
if len(ligne) >= 7
]
DETAILS = ("modules", "models", "fields", "cow", "tables")
def render_detail(diff, categorie, colour=False):
"""La liste ENTIÈRE d'une catégorie, sans troncature.
Le résumé coupe à huit entrées, et il a raison : personne ne lit
trois cent soixante-quinze noms de modèles en passant. Mais quand on
cherche si UN module précis a survécu, la liste tronquée ne répond
pas — et c'est justement là qu'on a besoin d'elle.
"""
from script.todo.migration_status import paint
if diff is None or diff.get("unavailable"):
return t("not comparable: a database is missing")
if categorie == "tables":
lignes = [f"── {t('table(s) lost rows')} ──"]
vers = {a: b for a, b, _n in diff["renamed"]}
for table, avant, apres, connu in diff["rows_lost"]:
note = ""
if connu and connu["into"]:
note = f" → {connu['into']}"
elif connu:
note = f" {t('retired from the database')}"
elif table in vers:
note = f" ↻ {vers[table]}"
teinte = "dim" if connu else "fail"
lignes.append(
f" {paint(f'{avant - apres:>8}', teinte, colour)}"
f" {table:<46} {avant} → {apres}{note}"
)
return "\n".join(lignes)
perdus = diff.get(f"{categorie}_lost") or []
gagnes = diff.get(f"{categorie}_gained") or []
lignes = []
# « − 28 copies COW » plutôt que « 28 copies COW perdus » : le signe
# porte déjà le sens, et l'accord d'un participe avec une catégorie
# dont le genre change d'une langue à l'autre ne se traduit pas.
for lst, symbole, teinte in (
(perdus, "−", "fail"),
(gagnes, "+", "ok"),
):
lignes.append(
f"── {paint(symbole, teinte, colour)} {len(lst)}"
f" {t(categorie)} ──"
)
lignes.extend(f" {nom}" for nom in lst)
lignes.append("")
return "\n".join(lignes)
def render_missing(etat, colour=False, limit=60):
"""Ce qui manque, groupé d'abord, détaillé ensuite.
Le groupement d'abord parce qu'il tranche : deux cent trente-trois
drapeaux de pays et treize logos de fournisseurs de paiement sont des
images livrées par les modules, qu'une mise à jour restaure. Huit
pièces jointes éparses, non. La liste brute mettait les deux sur le
même plan.
"""
from script.todo.migration_status import paint
absents = etat.get("attachment_missing") or 0
if not absents:
return f"✅ {t('every attachment file is present')}"
lignes = [
paint(
f"❌ {absents}"
f" {t('attachment files missing from the filestore')}",
"fail",
colour,
),
f" {etat['database']}",
"",
]
detail = missing_detail(
etat["database"], etat.get("attachment_missing_list") or []
)
if not detail:
lignes.append(t("Could not read their metadata."))
return "\n".join(lignes)
groupe = {}
for item in detail:
cle = (item["model"], item["field"], item["mimetype"])
groupe[cle] = groupe.get(cle, 0) + 1
lignes.append(f"── {t('by model and field')} ──")
for (modele, champ, mime), nombre in sorted(
groupe.items(), key=lambda x: -x[1]
):
lignes.append(
f" {nombre:>5} {paint(modele, 'step', colour):<34}"
f" {champ:<22} {mime}"
)
lignes.append("")
lignes.append(f"── {t('one by one')} ──")
for item in detail[:limit]:
lignes.append(
f" {item['model']}#{item['res_id']}"
f" {paint(item['field'], 'dim', colour)}"
f" {item['size']:>8} o {item['name'][:44]}"
)
lignes.append(f" {paint(item['store_fname'], 'cmd', colour)}")
if len(detail) > limit:
lignes.append(f" … {len(detail) - limit} {t('more')}")
return "\n".join(lignes)
# Ce qu'Odoo déplace ou retire de lui-même, d'une version à l'autre.
#
# Sans cette carte, l'outil annonçait « 81 tables ont perdu des lignes »
# et la plus grosse d'entre elles — ir_translation, 32 984 lignes — était
# une refonte voulue par l'éditeur. Les vraies questions se noyaient dans
# les fausses, ce qui est la façon la plus sûre de ne pas les voir.
#
# Chaque entrée a été VÉRIFIÉE sur une migration réelle 12 → 18, en
# comptant les deux côtés. Une carte écrite de mémoire vaudrait moins que
# pas de carte : elle expliquerait des pertes qui n'en sont pas.
SEMANTIC_MAP = (
# Fusions : les enregistrements continuent, ailleurs et autrement.
{
"since": 13,
"table": "account_invoice",
"into": "account_move",
"kind": "merged",
"why": "invoices became journal entries",
},
{
"since": 13,
"table": "account_invoice_line",
"into": "account_move_line",
"kind": "merged",
"why": "invoices became journal entries",
},
{
"since": 13,
"table": "account_invoice_tax",
"into": "account_move_line",
"kind": "merged",
"why": "invoices became journal entries",
},
# Renommages : les mêmes enregistrements, sous un autre nom.
{
"since": 17,
"table": "mail_channel",
"into": "discuss_channel",
"kind": "renamed",
"why": "Discuss was renamed",
},
{
"since": 17,
"table": "mail_channel_partner",
"into": "discuss_channel_member",
"kind": "renamed",
"why": "Discuss was renamed",
},
{
"since": 13,
"table": "website_redirect",
"into": "website_rewrite",
"kind": "renamed",
"why": "redirections were reworked",
},
{
"since": 13,
"table": "crm_lead_tag",
"into": "crm_tag",
"kind": "renamed",
"why": "tags became shared",
},
# Retraits : la fonction a quitté la base pour du code.
{
"since": 16,
"table": "ir_translation",
"into": None,
"kind": "retired",
"why": "translations moved into jsonb columns",
},
# Vérifiée palier par palier : la table ne se vide jamais, elle GROSSIT
# (211 en 12 → 457 en 17) puis disparaît d'un coup en 18. Les champs
# qu'elle portait sont des colonnes jsonb en 18 — vérifié sur
# res_partner.property_payment_term_id et
# product_template.property_account_income_id.
{
"since": 18,
"table": "ir_property",
"into": None,
"kind": "retired",
"why": "company-dependent values moved into jsonb columns",
},
{
"since": 17,
"table": "account_tax_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_account_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_chart_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_fiscal_position_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_fiscal_position_tax_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
{
"since": 17,
"table": "account_fiscal_position_account_template",
"into": None,
"kind": "retired",
"why": "chart templates left the database",
},
# Vérifié palier par palier : 1269 lignes en 12, 13 et 14, la table
# disparaît en 15 et `mail_notification` en compte exactement 1269.
# Pas une perdue.
{
"since": 15,
"table": "mail_message_res_partner_needaction_rel",
"into": "mail_notification",
"kind": "merged",
"why": "needaction became notifications",
},
{
"since": 15,
"table": "stock_inventory",
"into": "stock_quant",
"kind": "merged",
"why": "inventory adjustments became quants",
},
{
"since": 15,
"table": "stock_inventory_line",
"into": "stock_quant",
"kind": "merged",
"why": "inventory adjustments became quants",
},
# Élagages : la table demeure, ces lignes non. À distinguer d'une
# fusion — ici les enregistrements ne continuent NULLE PART.
#
# Vérifié : en 13 `field` est un varchar (le nom du champ), en 14 c'est
# une clé étrangère vers ir_model_fields. En 14 aucune ligne n'a de
# champ nul ni de clé cassée : ce qui ne se résolvait pas a été
# supprimé. Sur les 2336 disparues, 1528 portaient des champs du module
# agile retiré (priority_id 761, agile_enabled 711, story_points 54,
# resolution_id 2) ; 706 autres ont SURVÉCU sous un nouveau nom
# (account.move.type → move_type, 651).
{
"since": 14,
"table": "mail_tracking_value",
"into": None,
"kind": "pruned",
"why": "tracking of fields that ceased to exist",
},
)
def as_version(etat):
"""« 18.0 » -> 18. None si l'on ne sait pas."""
try:
return int(float((etat or {}).get("odoo") or 0)) or None
except (TypeError, ValueError):
return None
_CACHE_DECLARE = {}
def declared_index(version):
"""L'index OpenUpgrade du palier, mis en cache par version.
Lire 402 fichiers coûte 0,04 s — négligeable une fois, pas six fois
par rapport quand on compare tous les paliers d'affilée.
"""
if version not in _CACHE_DECLARE:
from script.analyse import openupgrade_analysis
_CACHE_DECLARE[version] = openupgrade_analysis.load(version)
return _CACHE_DECLARE[version]
def overlay_declared(
version,
modeles_perdus,
champs_perdus,
origine_champ=None,
champs_stockes=None,
):
"""Le THÉORIQUE posé sur le PRATIQUE : qui avait été annoncé ?
Une perte déclarée par OpenUpgrade n'est pas un incident, c'est le
palier qui fait son travail. Les séparer transforme « 300 champs
disparus » — un chiffre devant lequel on ne peut rien faire — en une
poignée de champs que personne n'a annoncés, et qu'il faut regarder.
Le cas qui justifie tout : « devenu calculé ». Le champ EXISTE
toujours ; il n'a plus de colonne. Ni gain ni perte : transformation.
"""
from script.analyse import openupgrade_analysis
if not version:
return {"available": False, "reason": "version"}
index = declared_index(version)
if not index["modules"]:
return {"available": False, "reason": "missing"}
modeles = {"obsolete": [], "renamed": [], "undeclared": []}
for nom in modeles_perdus:
change = openupgrade_analysis.model_change(nom, index)
if not change:
modeles["undeclared"].append(nom)
elif change[0] == "renamed":
modeles["renamed"].append((nom, change[1]))
else:
modeles["obsolete"].append(nom)
champs = {
"del": [],
"unstored": [],
"company_dependent": [],
"moved": [],
"model_gone": [],
"no_data": [],
"not_analysed": [],
"undeclared": [],
}
partis = tuple(f"{nom}." for nom in modeles_perdus)
analyses = openupgrade_analysis.analysed_modules(version)
for cle in champs_perdus:
change = openupgrade_analysis.field_change(cle, index)
if change:
if change[0] == "moved":
champs["moved"].append((cle, change[1]))
else:
champs[change[0]].append(cle)
continue
# Un champ dont le MODÈLE a disparu n'est pas une trouvaille de
# plus : c'est la même, comptée une fois par champ. Sur un palier
# réel cela triplait la liste et noyait le vrai signal.
if partis and cle.startswith(partis):
champs["model_gone"].append(cle)
continue
# AVANT « non analysé » : « sans donnée propre » est une raison
# plus forte que « hors du champ d'OpenUpgrade ». Mesuré au
# palier 16 → 17, le placement avant fait tomber `not_analysed`
# de 181 à 47 sans changer `undeclared` — le seau résiduel se
# réduit alors au risque réel : des champs qui AVAIENT des
# données, dans des modules dont OpenUpgrade ne peut rien dire.
#
# La garde `is not None` compte : un instantané sans
# `field_stored` — une base lue par une version antérieure de cet
# outil — verserait TOUT ici et n'annoncerait plus rien.
if champs_stockes is not None and (
cle.rsplit(".", 1)[-1] in SANS_DONNEE_PROPRE
or cle not in champs_stockes
):
champs["no_data"].append(cle)
continue
origines = set((origine_champ or {}).get(cle) or [])
if origines and not (origines & analyses):
champs["not_analysed"].append(cle)
continue
champs["undeclared"].append(cle)
return {
"available": True,
"modules": index["modules"],
"models": modeles,
"fields": champs,
}
def render_attachment_kind(connu, colour):
"""Le détail par CAUSE d'une perte de pièces jointes.
Le rapport ne doit jamais dire « pièce jointe perdue » sans dire CE
QUI est parti avec elle : un champ déjà mort ne fait perdre rien
qu'un utilisateur ait pu voir, et cela se range en teinte calme. Le
seul cas rouge est « le champ est toujours là ».
"""
from script.todo.migration_status import paint
lignes = []
for cle, libelle, teinte in ATTACHMENT_KIND:
combien = (connu.get("buckets") or {}).get(cle) or 0
if not combien:
continue
lignes.append(
f" {paint(str(combien).rjust(5), teinte, colour)}"
f" {t(libelle)}"
)
return lignes
def classify_attachments(avant, apres):
"""Pourquoi chaque pièce jointe partie est partie. None si on ne sait.
Python pur, sur ce que `inspect` a déjà lu : aucune requête de plus.
L'ORDRE des tests compte — éprouvé dans l'autre sens, les 452 du
palier 18 se rangeaient à tort en « champ retiré à ce palier ».
"""
lignes_avant = avant.get("attachment_row")
lignes_apres = apres.get("attachment_row")
if not lignes_avant or lignes_apres is None:
return None
modeles_apres = set(apres.get("model") or [])
seaux = {cle: [] for cle, _l, _t in ATTACHMENT_KIND}
for identifiant, (modele, champ, _res, vivant) in lignes_avant.items():
if identifiant in lignes_apres:
continue
apres_vivant = None
for autre in lignes_apres.values():
if autre[0] == modele and autre[1] == champ:
apres_vivant = autre[3]
break
# L'ORDRE porte le sens. « Le champ était déjà mort » d'abord :
# c'est la raison la plus forte, et celle des 452 lignes du
# palier 18. « Le modèle a quitté la base » ensuite — plus forte
# que « le champ n'apparaît plus après », qui ne se déduit que
# d'une absence et serait vraie de toute façon.
if not vivant:
seaux["field_debt"].append(identifiant)
elif modele and modele not in modeles_apres:
seaux["model_gone"].append(identifiant)
elif apres_vivant is False or (champ and apres_vivant is None):
seaux["field_dropped"].append(identifiant)
else:
seaux["undeclared"].append(identifiant)
return seaux
def explain_loss(table, version):
"""Ce qu'Odoo a fait de cette table à cette version, ou None.
`version` est celle d'ARRIVÉE : une refonte de la 17 n'explique rien
d'un palier 13 → 14, et l'accepter ferait taire une vraie perte sous
prétexte qu'elle porte le nom d'une table refondue plus tard.
"""
if not version:
return None
for entree in SEMANTIC_MAP:
if entree["table"] == table and entree["since"] <= version:
return entree
return None
def compare(avant, apres):
"""Ce qui a été gagné et ce qui a été perdu entre deux paliers."""
if not avant.get("exists") or not apres.get("exists"):
return {"unavailable": True}
inst_avant, inst_apres = set(avant["installed"]), set(apres["installed"])
mod_avant, mod_apres = set(avant["model"]), set(apres["model"])
tbl_avant, tbl_apres = avant["table"], apres["table"]
# TOUJOURS quatre éléments, le dernier étant l'explication ou None.
# Un tuple de taille variable obligerait chaque lecteur à s'en méfier.
lignes_perdues = []
for table, nombre in sorted(tbl_avant.items()):
reste = tbl_apres.get(table)
if reste is None and nombre:
lignes_perdues.append((table, nombre, 0, None))
elif reste is not None and reste < nombre:
lignes_perdues.append((table, nombre, reste, None))
lignes_gagnees = [
(table, tbl_avant.get(table, 0), nombre)
for table, nombre in sorted(tbl_apres.items())
if nombre > tbl_avant.get(table, 0)
]
version = as_version(apres)
for index, (table, debut, fin, _rien) in enumerate(lignes_perdues):
connu = explain_loss(table, version)
if not connu:
continue
# Une fusion qui n'a PAS grossi la table d'accueil n'explique
# rien : on garde l'explication et on dit qu'elle ne tient pas.
arrivee = connu["into"]
recue = (
apres["table"].get(arrivee, 0) - avant["table"].get(arrivee, 0)
if arrivee
else None
)
lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue})
# `ir_attachment` n'a pas d'entrée dans SEMANTIC_MAP et n'en aura
# pas : la cause n'est pas la table, ce sont ces lignes-là. On la
# DÉDUIT, dans le même vocabulaire, et l'on garde en rouge le seul
# cas qui compte — une pièce jointe dont le champ est toujours là.
seaux = classify_attachments(avant, apres)
if seaux:
for index, (table, debut, fin, connu) in enumerate(lignes_perdues):
if table != "ir_attachment" or connu:
continue
lignes_perdues[index] = (
table,
debut,
fin,
{
"into": None,
"kind": "pruned",
"why": "attachments of fields and records already gone",
"gained": None,
"buckets": {
cle: len(seaux.get(cle) or [])
for cle, _l, _t in ATTACHMENT_KIND
},
"residue": len(seaux.get("undeclared") or []),
},
)
champ_avant = set(avant.get("field") or [])
champ_apres = set(apres.get("field") or [])
cow_avant = set(avant.get("cow") or [])
cow_apres = set(apres.get("cow") or [])
# Les plus grosses pertes EN TÊTE : on attaque une liste de
# cinquante-sept par le haut, pas par ordre alphabétique.
lignes_perdues.sort(key=lambda item: -(item[1] - item[2]))
champs_perdus = sorted(champ_avant - champ_apres)
modeles_perdus = sorted(mod_avant - mod_apres)
return {
"declared": overlay_declared(
version,
modeles_perdus,
champs_perdus,
avant.get("field_module"),
set(avant.get("field_stored") or []) or None,
),
"fields_lost": champs_perdus,
"fields_gained": sorted(champ_apres - champ_avant),
"cow_lost": sorted(cow_avant - cow_apres),
"cow_gained": sorted(cow_apres - cow_avant),
"modules_lost": sorted(inst_avant - inst_apres),
"modules_gained": sorted(inst_apres - inst_avant),
"models_lost": modeles_perdus,
"models_gained": sorted(mod_apres - mod_avant),
"rows_lost": lignes_perdues,
"rows_gained": lignes_gagnees,
"renamed": probable_renames(lignes_perdues, lignes_gagnees),
"delta": {
cle: apres.get(cle, 0) - avant.get(cle, 0)
for cle in (
"view",
"view_cow",
"menu",
"action",
"attachment",
"language",
)
},
}
def probable_renames(perdues, gagnees):
"""Rapprocher une table disparue d'une table apparue au même compte.
Odoo renomme des tables entre versions — `mail_channel` est devenu
`discuss_channel` en 17. Sans ce rapprochement, chaque renommage se
lit comme une perte de données doublée d'une apparition, et l'on
cherche un dégât là où il n'y en a pas.
"""
disparues = {
table: avant
for table, avant, apres, _connu in perdues
if apres == 0 and avant
}
apparues = {
table: apres for table, avant, apres in gagnees if avant == 0 and apres
}
couples = []
for table, nombre in sorted(disparues.items()):
for autre, combien in sorted(apparues.items()):
if combien == nombre and looks_renamed(table, autre):
couples.append((table, autre, nombre))
del apparues[autre]
break
return couples
RENAME_RATIO = 0.75
def looks_renamed(un, deux):
"""Les deux noms se ressemblent-ils assez pour être le même sujet ?
Deux garde-fous ont été essayés et rejetés, mesurés sur une vraie
migration. Le seul nombre de lignes accouplait
`account_account_tag_account_tax_template_rel` à `dms_directory` : les
deux comptaient sept lignes. Un mot commun d'au moins cinq lettres
accouplait `cleanup_purge_wizard_menu` à
`cleanup_create_indexes_line` — « cleanup » ne dit rien.
La ressemblance d'ENSEMBLE tranche : `muk_dms_directory` et
`dms_directory` se ressemblent à 87 %, `website_redirect` et
`website_rewrite` à 83 %, tandis que les faux couples restent sous
50 %.
"""
import difflib
return difflib.SequenceMatcher(None, un, deux).ratio() >= RENAME_RATIO
def survey(dct, echo=None):
"""Inspecter toute la chaîne, du départ à l'arrivée."""
lst = []
for version, database in chain(dct):
if echo:
echo(f"{database} …")
etat = inspect(database)
etat["version"] = version
lst.append(etat)
return lst
def overall(lst_snapshot):
"""La comparaison du PREMIER au DERNIER palier.
Elle ne se déduit pas des comparaisons deux à deux : un module retiré
en 15 puis remis en 17 n'a rien perdu du tout, et l'addition des
étapes le compterait deux fois.
"""
presents = [x for x in lst_snapshot if x.get("exists")]
if len(presents) < 2:
return {"unavailable": True}
return compare(presents[0], presents[-1])
DECLARE_MODELES = (
("obsolete", "declared obsolete", "dim"),
("renamed", "renamed by Odoo", "dim"),
("undeclared", "NOT declared by OpenUpgrade", "warn"),
)
# L'ordre range du plus rassurant au plus inquiétant : ce qu'on doit
# regarder finit la liste, donc reste sous les yeux.
# `id` porte une donnée mais pas la SIENNE : elle appartient à la ligne.
# Il figure dans ir_model_fields de tout modèle, y compris abstrait, et
# Odoo 15 cesse de l'inscrire sur ceux-là — d'où 65 « pertes » d'un coup
# au palier 14 → 15, pour zéro octet.
SANS_DONNEE_PROPRE = ("id",)
# Pourquoi une pièce jointe a disparu — DÉDUIT, jamais déclaré.
#
# SEMANTIC_MAP ne peut pas porter ceci : elle nomme une TABLE, et la
# cause n'est pas la table, ce sont ces lignes-là. Mesuré sur une chaîne
# 12 → 18 : des 516 lignes parties au palier 18, 452 avaient perdu leur
# champ et 63 leur enregistrement. Une entrée « ir_attachment / pruned »
# aurait rangé les 516 sous « perte attendue » — et la 517e avec.
ATTACHMENT_KIND = (
("field_debt", "their field was already gone before this step", "dim"),
("field_dropped", "their field was removed at this step", "dim"),
("model_gone", "their model left the database", "dim"),
("undeclared", "the field is STILL there — look at these", "warn"),
)
DECLARE_CHAMPS = (
("del", "declared removed", "dim"),
("moved", "moved to another module", "dim"),
("company_dependent", "became a per-company jsonb column", "dim"),
(
"unstored",
"computed now — the field remains, the column does not",
"ok",
),
("no_data", "held no data of their own — nothing to lose", "dim"),
("model_gone", "their model went away too", "dim"),
("not_analysed", "in a module OpenUpgrade does not analyse", "dim"),
("undeclared", "NOT declared by OpenUpgrade", "warn"),
)
def group_by_field_name(cles):
"""[(nom, combien, exemple)] par nom de champ, les plus nombreux d'abord.
Un champ retiré d'un MIXIN disparaît de tous les modèles qui en
héritent : `__last_update` s'est ainsi compté 580 fois au palier 17.
C'est UN changement. Listé modèle par modèle, il remplissait l'écran
et cachait les vingt autres ; groupé, il tient sur une ligne.
"""
par_nom = {}
for cle in cles:
nom = cle.rsplit(".", 1)[-1] if "." in cle else cle
par_nom.setdefault(nom, []).append(cle)
return sorted(
((nom, len(lst), sorted(lst)[0]) for nom, lst in par_nom.items()),
key=lambda item: (-item[1], item[0]),
)
def render_declared(declare, colour, limit=8):
"""Le théorique posé sur le pratique. Rien si on ne peut pas le lire.
Se taire quand l'analyse manque plutôt que d'afficher des zéros :
« 0 déclaré » et « analyse introuvable » se ressemblent à l'œil et ne
veulent pas du tout dire la même chose.
"""
from script.todo.migration_status import paint
if not declare.get("available"):
if declare.get("reason") == "missing":
return [
f" {paint('⇄', 'dim', colour)}"
f" {t('No OpenUpgrade analysis for this step.')}"
]
return []
lignes = [
f" {paint('⇄', 'dim', colour)} {t('Against OpenUpgrade')}"
f" ({declare['modules']} {t('core module(s) analysed')})"
]
for titre, groupes, table in (
(t("model(s) lost"), declare["models"], DECLARE_MODELES),
(t("field(s) lost"), declare["fields"], DECLARE_CHAMPS),
):
total = sum(len(groupes.get(cle) or []) for cle, _l, _c in table)
if not total:
continue
lignes.append(f" {total} {titre} :")
for cle, libelle, teinte in table:
trouves = groupes.get(cle) or []
if not trouves:
continue
lignes.append(
f" {paint(str(len(trouves)).rjust(5), teinte, colour)}"
f" {t(libelle)}"
)
if cle not in ("undeclared", "unstored", "no_data"):
# Ces trois-là seuls méritent des noms : le premier
# parce qu'il faut aller voir, les deux autres parce
# qu'on croirait à une perte — et « __last_update ×
# 518 modèles » sur une ligne explique à lui seul le
# gros chiffre. Nommer le reste noierait le rapport.
continue
plats = [
element[0] if isinstance(element, tuple) else element
for element in trouves
]
if table is DECLARE_CHAMPS:
groupes_nom = group_by_field_name(plats)
for nom, combien, exemple in groupes_nom[:limit]:
suffixe = (
f" × {combien} {t('model(s)')}" if combien > 1 else ""
)
montre = exemple if combien == 1 else nom
lignes.append(f" {montre}{suffixe}")
if len(groupes_nom) > limit:
lignes.append(
f" … {len(groupes_nom) - limit}"
f" {t('other field name(s)')}"
)
else:
for nom in plats[:limit]:
lignes.append(f" {nom}")
if len(plats) > limit:
lignes.append(
f" … {len(plats) - limit} {t('more')}"
)
return lignes
def render_text(lst_snapshot, colour=None, limit=8):
"""Le rapport complet. C'est aussi le repli du plein écran."""
from script.todo.migration_status import paint, supports_colour
if colour is None:
colour = supports_colour()
lignes = [f"📐 {t('Migration quality, step by step')}"]
for etat in lst_snapshot:
if not etat.get("exists"):
lignes.append(
f" ⚠️ {etat['database']} : {t('database not found')}"
)
continue
lignes.append(
f" {paint(f'{etat['odoo']:<6}', 'step', colour)}"
f" {etat['database']:<34}"
f" {len(etat['installed']):>4} {t('modules')}"
f" · {len(etat['model']):>4} {t('models')}"
f" · {etat['view']:>5} {t('views')}"
f" · {etat['attachment']:>6} {t('attachments')}"
)
if etat.get("attachment_missing"):
lignes.append(
f" {paint('❌', 'fail', colour)}"
f" {etat['attachment_missing']}"
f" {t('attachment files missing from the filestore')}"
)
presents = [x for x in lst_snapshot if x.get("exists")]
for avant, apres in zip(presents, presents[1:]):
lignes.append(f"\n🔀 {avant['odoo']} → {apres['odoo']}")
lignes.extend(render_compare(compare(avant, apres), colour, limit))
lignes.append(
f"\n🏁 {t('From start to finish')} :"
f" {presents[0]['odoo'] if presents else '?'}"
f" → {presents[-1]['odoo'] if presents else '?'}"
)
lignes.extend(render_compare(overall(lst_snapshot), colour, limit))
return "\n".join(lignes)
def render_compare(diff, colour, limit=8):
"""Les gains et les pertes d'un palier, en quelques lignes."""
from script.todo.migration_status import paint
if diff.get("unavailable"):
return [f" {t('not comparable: a database is missing')}"]
lignes = []
for cle, symbole, teinte in (
("modules_lost", "−", "fail"),
("modules_gained", "+", "ok"),
):
lst = diff[cle]
if lst:
lignes.append(
f" {paint(symbole, teinte, colour)} {len(lst)}"
f" {t('modules')} : {', '.join(lst[:limit])}"
+ (" …" if len(lst) > limit else "")
)
for cle, symbole, teinte in (
("models_lost", "−", "fail"),
("models_gained", "+", "ok"),
):
lst = diff[cle]
if lst:
lignes.append(
f" {paint(symbole, teinte, colour)} {len(lst)}"
f" {t('models')} : {', '.join(lst[:limit])}"
+ (" …" if len(lst) > limit else "")
)
if diff["renamed"]:
lignes.append(
f" ↻ {len(diff['renamed'])} {t('probable table rename(s)')} :"
f" {', '.join(f'{a} → {b}' for a, b, _n in diff['renamed'][:4])}"
)
perdues = diff["rows_lost"]
if perdues:
# LE signal qui compte : un module en moins se voit, une table qui
# passe de quatre mille lignes à zéro ne se voit nulle part.
#
# Les pertes EXPLIQUÉES sont séparées des autres, jamais retirées.
# Sans cette séparation on lisait « 81 tables ont perdu des lignes »
# dont la plus grosse — ir_translation, 32 984 lignes — était une
# refonte voulue par Odoo : les vraies questions se noyaient dans
# les fausses, ce qui est la façon la plus sûre de ne pas les voir.
vers = {a: b for a, b, _n in diff["renamed"]}
ouvertes = [item for item in perdues if not item[3]]
connues = [item for item in perdues if item[3]]
if ouvertes:
lignes.append(
f" {paint('▼', 'fail', colour)} {len(ouvertes)}"
f" {t('table(s) lost rows, unexplained')} :"
)
for table, avant, apres, _rien in ouvertes[:limit]:
note = (
f" ↻ {t('probably renamed to')} {vers[table]}"
if table in vers
else ""
)
lignes.append(f" {table:<40} {avant:>8} → {apres}{note}")
if len(ouvertes) > limit:
lignes.append(f" … {len(ouvertes) - limit} {t('more')}")
if connues:
lignes.append(
f" {paint('▽', 'dim', colour)} {len(connues)}"
f" {t('table(s) explained by an Odoo change')} :"
)
for table, avant, apres, connu in connues[:limit]:
if connu["into"]:
recue = connu.get("gained")
# Une fusion dont la table d'accueil n'a PAS grossi
# n'explique rien : le dire, plutôt que de classer la
# perte comme attendue et passer à autre chose.
accueil = (
f"+{recue} {t('there')}"
if recue and recue > 0
else paint(t("but it gained nothing"), "fail", colour)
)
ou = f"→ {connu['into']} ({accueil})"
elif connu["kind"] == "pruned":
# La table est TOUJOURS là : dire « retirée » serait
# faux, et cette perte-ci est réelle — les lignes ne
# continuent nulle part. Le mot doit le laisser voir.
ou = paint(
t("rows dropped, the table remains"), "warn", colour
)
else:
ou = t("retired from the database")
lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}")
lignes.append(f" {t(connu['why'])}")
lignes.extend(render_attachment_kind(connu, colour))
if len(connues) > limit:
lignes.append(f" … {len(connues) - limit} {t('more')}")
lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))
delta = diff["delta"]
lignes.append(
" "
+ " ".join(
f"{cle} {valeur:+d}" for cle, valeur in delta.items() if valeur
)
or " ="
)
return lignes
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(
description=(
"Compare every database a migration left behind, step by step,"
" and report what was gained and what was lost."
)
)
parser.add_argument(
"-f",
"--file",
default=DEFAULT_PROGRESSION,
help="migration progression file",
)
parser.add_argument(
"--text",
action="store_true",
help="print the report instead of opening the full screen",
)
parser.add_argument("--limit", type=int, default=8)
config = parser.parse_args(argv)
dct = read_progression(config.file)
if not chain(dct):
print(f"ℹ️ {t('No migration in progress.')}")
return 0
lst = survey(dct, echo=lambda texte: print(f"⧖ {texte}", flush=True))
if not config.text:
try:
from script.analyse.check_migration_quality_tui import run_tui
except Exception:
run_tui = None
if run_tui and run_tui(lst):
return 0
print(render_text(lst, limit=config.limit))
manque = [x for x in lst if not x.get("exists")]
perdu = overall(lst)
trouvailles = bool(manque) or bool(
not perdu.get("unavailable")
and (perdu["modules_lost"] or perdu["rows_lost"])
)
return 1 if trouvailles else 0
if __name__ == "__main__":
sys.exit(main())