Une migration 12 → 18 traverse six paliers, et le checkout bascule de manifeste à chaque fois. Un dépôt d'addons absent du manifeste du 17 n'existe pas sur disque pendant l'étape 17 : Odoo déclare ses modules introuvables, le pilote propose de les effacer, on répond oui, et la fonctionnalité part sans qu'aucun échec ne soit signalé. Seul le trou compte — présent avant et après, absent au milieu — et la branche en amont le confirme : sur 35 trous, 19 sont de vraies omissions, quinze déclarées en 16 et 18 mais pas en 17 ; les 35 auraient fait 46 % de bruit. development.git est rétabli en 13 et en 15. --- EN --- A 12 → 18 migration crosses six steps, and the checkout switches manifest each time. An addons repository missing from the 17 manifest does not exist on disk during step 17: Odoo reports its modules as missing, the driver offers to delete them, you answer yes, and the feature is gone without a single failure being reported. Only the hole counts — present before and after, absent in between — and the upstream branch confirms it: of 35 holes, 19 are real omissions, fifteen declared in 16 and 18 but not in 17; all 35 would have been 46% noise. development.git is restored in 13 and in 15. Assisted-by: Claude Opus 5 (cherry picked from commit d7613ea930d45153e9fb7c08460690a95e3dca0e)
1483 lines
54 KiB
Python
Executable file
1483 lines
54 KiB
Python
Executable file
#!/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",
|
||
),
|
||
(
|
||
"Will every step find its addons?",
|
||
"script/analyse/check_manifest_gaps.py --upstream",
|
||
"manifest",
|
||
),
|
||
)
|
||
|
||
|
||
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())
|