[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
#!/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
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
demanderaient de basculer le checkout à chaque palier. 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
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
|
import re
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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 {}
|
|
|
|
|
|
|
|
|
|
|
|
|
[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-26 07:46:32 -04:00
|
|
|
|
# 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"]
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
|
FICHIER_VERSION = ".odoo-version"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def checkout_version(racine=None):
|
|
|
|
|
|
"""La version d'Odoo sur laquelle le checkout est posé, ou None.
|
|
|
|
|
|
|
|
|
|
|
|
Un entier, pas « 18.0 » : c'est ce que porte le nom des bases de
|
|
|
|
|
|
palier et ce que la chaîne de migration rend, et deux formes du même
|
|
|
|
|
|
fait finissent toujours par diverger.
|
|
|
|
|
|
"""
|
|
|
|
|
|
base = racine or os.path.dirname(
|
|
|
|
|
|
os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
|
|
)
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(
|
|
|
|
|
|
os.path.join(base, FICHIER_VERSION), encoding="utf-8"
|
|
|
|
|
|
) as handle:
|
|
|
|
|
|
return int(float(handle.read().strip()))
|
|
|
|
|
|
except (OSError, ValueError):
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def verdicts(events):
|
|
|
|
|
|
"""TOUS les verdicts de test, réussis compris.
|
|
|
|
|
|
|
|
|
|
|
|
N'afficher que les échecs répondait à « qu'est-ce qui a raté » et à
|
|
|
|
|
|
rien d'autre. Or la question qu'on se pose devant une base migrée est
|
|
|
|
|
|
« qu'a-t-on vérifié », et un test réussi au palier 17 est une preuve
|
|
|
|
|
|
au même titre qu'un raté au 14 — c'est même la seule façon de voir
|
|
|
|
|
|
qu'un échec du 14 a été RATTRAPÉ ensuite.
|
|
|
|
|
|
"""
|
|
|
|
|
|
return [e for e in events if e["kind"] == "test"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def version_of(database, dct):
|
|
|
|
|
|
"""La version d'Odoo de cette base, lue dans la chaîne de la migration.
|
|
|
|
|
|
|
|
|
|
|
|
Plus sûr que le suffixe du nom : la base de DÉPART n'en porte pas —
|
|
|
|
|
|
« test_neutralize » ne dit pas 12.0 — et son palier retombait alors
|
|
|
|
|
|
sur le compteur du pilote, qui affichait « 2 ».
|
|
|
|
|
|
"""
|
|
|
|
|
|
for version, base in chain(dct):
|
|
|
|
|
|
if base == database:
|
|
|
|
|
|
return version
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# Ce que la migration écrit dans le journal d'une étape autour d'un test :
|
|
|
|
|
|
#
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# [<horodatage>] $ .venv…/python3 ./script/…/smoke.py -d …
|
|
|
|
|
|
# [<horodatage>] -> 1
|
|
|
|
|
|
# [<horodatage>] [test] smoke_public_url -> 1
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
|
#
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# Entre le « $ » et le « -> », RIEN : la sortie de l'outil n'est pas
|
|
|
|
|
|
# capturée. Ce qui explique l'échec
|
[ADD] verdicts : tous les paliers, leur journal, et la bascule
L'écran de qualité ne listait que les échecs, quand la question devant
une base migrée est « qu'a-t-on vérifié » : les quatorze verdicts
s'affichent, de la 12 à la 18, seule façon de voir qu'un échec a été
rattrapé à un palier plus haut. Le panneau ne portait que la commande ;
il montre le passage du journal d'étape qui l'entoure, garde par tee ce
qu'il lance lui-même et le relit sans relancer. La sortie de l'outil,
elle, part sur le terminal : un tube ferait renoncer les pleins écrans.
Relancer un test d'un autre palier ouvrait la base avec la mauvaise
version, qui y écrit avant d'échouer ; l'écran demande avant de basculer.
--- EN ---
The quality screen listed failures only, when the question in front of a
migrated database is "what did we check": all fourteen verdicts now show,
12 through 18, the only way to see that a failure at one tier was
recovered higher up. The panel carried only the command; it shows the
step-log passage around it, keeps by tee what it runs itself and re-reads
that without rerunning. The tool output goes to the terminal: a pipe
would make full-screen tools give up. Replaying a test from another tier
opened the database with the wrong version, which writes before it
fails; the screen asks before switching the checkout.
Assisted-by: Claude Opus 5
(cherry picked from commit 2d460b7c777d39a887dabfe7cf5405864c6c3f8c)
2026-08-27 04:36:03 -04:00
|
|
|
|
# est donc AVANT, dans ce qu'Odoo écrivait juste avant qu'on le teste —
|
|
|
|
|
|
# et c'est pour cela que l'extrait remonte, au lieu de descendre.
|
|
|
|
|
|
MARQUEUR_COMMANDE = "] $ "
|
|
|
|
|
|
MOTIF_HORODATAGE = re.compile(r"^\[(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _horodatage(ligne):
|
|
|
|
|
|
"""La seconde portée par cette ligne de journal, ou ""."""
|
|
|
|
|
|
found = MOTIF_HORODATAGE.match(ligne)
|
|
|
|
|
|
return found.group(1) if found else ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def event_tool(event):
|
|
|
|
|
|
"""Le script que ce verdict a lancé — « smoke_public_url.py »."""
|
|
|
|
|
|
for mot in (event.get("detail") or "").split():
|
|
|
|
|
|
if mot.endswith(".py"):
|
|
|
|
|
|
return os.path.basename(mot)
|
|
|
|
|
|
return ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def event_excerpt(lignes, event, avant=18):
|
|
|
|
|
|
"""(extrait, rang de la commande) — le passage qui entoure ce verdict.
|
|
|
|
|
|
|
|
|
|
|
|
Quand la migration a été rejouée, le même test apparaît plusieurs fois
|
|
|
|
|
|
dans le même fichier. On retient donc l'occurrence la plus PROCHE de
|
|
|
|
|
|
l'horodatage du verdict, et non la dernière : la dernière appartient
|
|
|
|
|
|
peut-être à une exécution qui n'est pas celle qu'on regarde.
|
|
|
|
|
|
"""
|
|
|
|
|
|
outil = event_tool(event)
|
|
|
|
|
|
if not outil or not lignes:
|
|
|
|
|
|
return [], None
|
|
|
|
|
|
base = event_database(event)
|
|
|
|
|
|
quand = (event.get("at") or "")[:19]
|
|
|
|
|
|
candidats = [
|
|
|
|
|
|
rang
|
|
|
|
|
|
for rang, ligne in enumerate(lignes)
|
|
|
|
|
|
if MARQUEUR_COMMANDE in ligne
|
|
|
|
|
|
and outil in ligne
|
|
|
|
|
|
and (not base or base in ligne)
|
|
|
|
|
|
]
|
|
|
|
|
|
if not candidats:
|
|
|
|
|
|
return [], None
|
|
|
|
|
|
avant_le_verdict = [
|
|
|
|
|
|
rang for rang in candidats if _horodatage(lignes[rang]) <= quand
|
|
|
|
|
|
]
|
|
|
|
|
|
rang = (avant_le_verdict or candidats)[-1]
|
|
|
|
|
|
fin = rang
|
|
|
|
|
|
for suivant in range(rang, min(rang + 400, len(lignes))):
|
|
|
|
|
|
if "[test] " in lignes[suivant] and event["name"] in lignes[suivant]:
|
|
|
|
|
|
fin = suivant
|
|
|
|
|
|
break
|
|
|
|
|
|
return lignes[max(0, rang - avant) : fin + 1], rang
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration : garder la sortie des tests, par pseudo-terminal
Le journal d'étape notait la commande et son code de retour, jamais ce
qu'elle avait écrit : l'écran d'analyse ne pouvait rien montrer d'un
échec. Un tube aurait capturé et changé le programme — smoke_public_url
appelle can_ask(), qui exige stdin ET stdout sur un terminal, et derrière
un tube il cesse en silence d'offrir la réparation des vues COW. Un
pseudo-terminal lève le dilemme : l'enfant voit un vrai terminal, la
réponse tapée lui parvient, le code de retour survit. Neuf exécutions y
passent, dont check_hidden_models, qui tournait sans verdict retenu ;
deux restent dehors, pty.spawn naît en 0×0 et un plein écran s'y perdrait.
--- EN ---
The step log recorded the command and its exit code, never what it
wrote: the analysis screen could show nothing of a failure. A pipe would
have captured and changed the program — smoke_public_url calls can_ask(),
which requires stdin AND stdout to be terminals, and behind a pipe it
silently stops offering the COW view repair. A pty settles it: the child
sees a real terminal, a typed answer reaches it, the exit code survives.
Nine runs go through it, including check_hidden_models, which ran with no
verdict recorded; two stay out, pty.spawn starts at 0×0 and a full-screen
app would lay out on nothing.
Assisted-by: Claude Opus 5
(cherry picked from commit 81e9227502a0d16e6409338cf958495a5f8e3827)
2026-08-27 05:54:24 -04:00
|
|
|
|
def excerpt_has_output(extrait, rang_relatif):
|
|
|
|
|
|
"""La sortie de l'outil est-elle DANS l'extrait, ou seulement le cadre ?
|
|
|
|
|
|
|
|
|
|
|
|
Avant que le pilote ne capture, le journal ne portait que « $ … »
|
|
|
|
|
|
suivi de « -> code », sans une ligne entre les deux. Le dire était
|
|
|
|
|
|
juste ; le dire encore quand la sortie est là serait un mensonge, et
|
|
|
|
|
|
le panneau enverrait chercher ailleurs ce qu'il a sous les yeux.
|
|
|
|
|
|
"""
|
|
|
|
|
|
for ligne in extrait[rang_relatif + 1 :]:
|
|
|
|
|
|
depouille = ligne.strip()
|
|
|
|
|
|
if not depouille:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if MARQUEUR_COMMANDE in ligne:
|
|
|
|
|
|
continue
|
|
|
|
|
|
if depouille.startswith("-> ") or depouille.startswith("[test] "):
|
|
|
|
|
|
continue
|
|
|
|
|
|
if "] -> " in ligne or "] [test] " in ligne:
|
|
|
|
|
|
continue
|
|
|
|
|
|
return True
|
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
|
|
|
[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-26 07:46:32 -04:00
|
|
|
|
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",
|
|
|
|
|
|
),
|
[ADD] manifestes : détecter un dépôt absent d'un palier de migration
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)
2026-08-27 02:15:45 -04:00
|
|
|
|
(
|
|
|
|
|
|
"Will every step find its addons?",
|
|
|
|
|
|
"script/analyse/check_manifest_gaps.py --upstream",
|
|
|
|
|
|
"manifest",
|
|
|
|
|
|
),
|
[ADD] migration : détecter le type de vue tree resté dans les sources
Odoo 18 a supprimé le type de vue tree, sans conversion : un module
resté dessus ne s'installe pas, ou casse au clic (« View types not
defined tree found in act_window »). Les autres outils de la revue
lisent la BASE, où un module jamais installé ne laisse rien.
Le mot reste un identifiant valide : sur 465 occurrences, 80 cassent — le
noyau 18 garde account.view_invoice_tree en <list>. D'où lxml et ast,
jamais de regex : seule la position du littéral décide. Trois portes
filtrent avant tout motif : version, module installable, fichier chargé.
Balayage : 80 constats sur 2654 modules, 26 touchés.
--- EN ---
Odoo 18 removed the tree view type with no shim: a module still on it
fails to install, or breaks on click ("View types not defined tree found
in act_window"). The other review tools read the DATABASE, where a
module that never installed leaves nothing.
The word is still a valid identifier: of 465 occurrences, 80 break — the
18 core keeps account.view_invoice_tree in <list>. Hence lxml and ast,
never regex: only the literal's position decides. Three gates filter
before any pattern: version, installable module, loaded file. Sweep: 80
findings over 2654 modules, 26 affected.
Assisted-by: Claude Opus 5
(cherry picked from commit 38a91039d111b3fa6de32edf315c5caae8553c11)
2026-08-27 02:36:58 -04:00
|
|
|
|
(
|
|
|
|
|
|
"Is any module still on a pre-18 view type?",
|
|
|
|
|
|
"script/analyse/check_view_type_tree.py",
|
|
|
|
|
|
"viewtype",
|
|
|
|
|
|
),
|
2026-08-28 03:37:57 -04:00
|
|
|
|
(
|
|
|
|
|
|
"Did the last step add to the accounting data?",
|
|
|
|
|
|
"script/analyse/check_chart_drift.py -d {db}",
|
|
|
|
|
|
"chartdrift",
|
|
|
|
|
|
),
|
[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-26 07:46:32 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
quatre dixièmes de seconde sur une base de 890 tables.
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
"""
|
|
|
|
|
|
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)
|
[ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.
« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.
Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.
One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.
--- FR ---
[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »
Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.
« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.
Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.
Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-20 03:47:02 -04:00
|
|
|
|
# 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 [])
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
# À 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()}
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
# 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 %
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# du seau « non déclarés par OpenUpgrade ». Au palier 16 → 17,
|
|
|
|
|
|
# `__last_update` compte à lui seul pour 397 des 565.
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
#
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# `store` existe de la 12 à la 18 et n'y est jamais NULL : le
|
|
|
|
|
|
# renseignement est donc fiable partout.
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
stockes = run_psql(
|
|
|
|
|
|
database,
|
|
|
|
|
|
"SELECT model || '.' || name FROM ir_model_fields WHERE store",
|
|
|
|
|
|
)
|
|
|
|
|
|
etat["field_stored"] = sorted(ligne[0] for ligne in stockes or [])
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
# 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
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# pas des données, ce sont des débris. Sur une chaîne 12 → 18, la
|
|
|
|
|
|
# dette naît aux paliers 13 et 14, reste gelée pendant trois
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
# 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()
|
|
|
|
|
|
}
|
[ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.
« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.
Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.
One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.
--- FR ---
[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »
Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.
« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.
Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.
Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-20 03:47:02 -04:00
|
|
|
|
# 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 [])
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
|
|
|
|
|
|
# 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)
|
[ADD] migration quality: name the missing files, and show every delta
« 254 attachment files missing » does not say which. « m » now lists
them, grouped by model AND FIELD — the field is the most useful thing in
the row, because it says which field lost its image. On a real migration
it splits into 234 res.country/image flags, 13 payment.provider logos,
and a handful of scattered attachments including one 255 kB event photo:
the first two are module data a reinstall restores, the last is not, and
the raw list put them on the same footing.
The metadata is read ONLY when asked. One more query per database would
lengthen a survey that runs in four seconds, for something one looks at
by asking for it.
And every figure of a step now carries its change against the previous
one. « 2283 views » is a number; « +61 » is information. The first step
gets none: inventing a zero there would claim a comparison that does not
exist.
--- FR ---
[ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts
« 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en
donne la liste, groupée par modèle ET PAR CHAMP — le champ est le
renseignement le plus utile de la ligne, car il dit lequel a perdu son
image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos
de fournisseurs de paiement, et une poignée de pièces jointes éparses dont
une photo d'événement de 255 ko. Les deux premiers groupes sont des
données de module qu'une réinstallation restaure, le dernier non.
Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par
base allongerait un parcours qui tient en quatre secondes.
Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283
vues » est un nombre, « +61 » est une information. Le premier palier n'en
a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas.
Assisted-by: Claude Opus 5
2026-08-20 01:52:35 -04:00
|
|
|
|
# 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]
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
return etat
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: name the missing files, and show every delta
« 254 attachment files missing » does not say which. « m » now lists
them, grouped by model AND FIELD — the field is the most useful thing in
the row, because it says which field lost its image. On a real migration
it splits into 234 res.country/image flags, 13 payment.provider logos,
and a handful of scattered attachments including one 255 kB event photo:
the first two are module data a reinstall restores, the last is not, and
the raw list put them on the same footing.
The metadata is read ONLY when asked. One more query per database would
lengthen a survey that runs in four seconds, for something one looks at
by asking for it.
And every figure of a step now carries its change against the previous
one. « 2283 views » is a number; « +61 » is information. The first step
gets none: inventing a zero there would claim a comparison that does not
exist.
--- FR ---
[ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts
« 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en
donne la liste, groupée par modèle ET PAR CHAMP — le champ est le
renseignement le plus utile de la ligne, car il dit lequel a perdu son
image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos
de fournisseurs de paiement, et une poignée de pièces jointes éparses dont
une photo d'événement de 255 ko. Les deux premiers groupes sont des
données de module qu'une réinstallation restaure, le dernier non.
Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par
base allongerait un parcours qui tient en quatre secondes.
Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283
vues » est un nombre, « +61 » est une information. Le premier palier n'en
a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas.
Assisted-by: Claude Opus 5
2026-08-20 01:52:35 -04:00
|
|
|
|
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
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.
« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.
Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.
One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.
--- FR ---
[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »
Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.
« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.
Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.
Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-20 03:47:02 -04:00
|
|
|
|
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(
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
f"── {paint(symbole, teinte, colour)} {len(lst)} {t(categorie)} ──"
|
[ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.
« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.
Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.
One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.
--- FR ---
[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »
Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.
« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.
Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.
Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-20 03:47:02 -04:00
|
|
|
|
)
|
|
|
|
|
|
lignes.extend(f" {nom}" for nom in lst)
|
|
|
|
|
|
lignes.append("")
|
|
|
|
|
|
return "\n".join(lignes)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: name the missing files, and show every delta
« 254 attachment files missing » does not say which. « m » now lists
them, grouped by model AND FIELD — the field is the most useful thing in
the row, because it says which field lost its image. On a real migration
it splits into 234 res.country/image flags, 13 payment.provider logos,
and a handful of scattered attachments including one 255 kB event photo:
the first two are module data a reinstall restores, the last is not, and
the raw list put them on the same footing.
The metadata is read ONLY when asked. One more query per database would
lengthen a survey that runs in four seconds, for something one looks at
by asking for it.
And every figure of a step now carries its change against the previous
one. « 2283 views » is a number; « +61 » is information. The first step
gets none: inventing a zero there would claim a comparison that does not
exist.
--- FR ---
[ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts
« 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en
donne la liste, groupée par modèle ET PAR CHAMP — le champ est le
renseignement le plus utile de la ligne, car il dit lequel a perdu son
image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos
de fournisseurs de paiement, et une poignée de pièces jointes éparses dont
une photo d'événement de 255 ko. Les deux premiers groupes sont des
données de module qu'une réinstallation restaure, le dernier non.
Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par
base allongerait un parcours qui tient en quatre secondes.
Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283
vues » est un nombre, « +61 » est une information. Le premier palier n'en
a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas.
Assisted-by: Claude Opus 5
2026-08-20 01:52:35 -04:00
|
|
|
|
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(
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
f"❌ {absents} {t('attachment files missing from the filestore')}",
|
[ADD] migration quality: name the missing files, and show every delta
« 254 attachment files missing » does not say which. « m » now lists
them, grouped by model AND FIELD — the field is the most useful thing in
the row, because it says which field lost its image. On a real migration
it splits into 234 res.country/image flags, 13 payment.provider logos,
and a handful of scattered attachments including one 255 kB event photo:
the first two are module data a reinstall restores, the last is not, and
the raw list put them on the same footing.
The metadata is read ONLY when asked. One more query per database would
lengthen a survey that runs in four seconds, for something one looks at
by asking for it.
And every figure of a step now carries its change against the previous
one. « 2283 views » is a number; « +61 » is information. The first step
gets none: inventing a zero there would claim a comparison that does not
exist.
--- FR ---
[ADD] qualité de migration : nommer les fichiers absents, et montrer les écarts
« 254 fichiers de pièces jointes absents » ne dit pas lesquels. « m » en
donne la liste, groupée par modèle ET PAR CHAMP — le champ est le
renseignement le plus utile de la ligne, car il dit lequel a perdu son
image. Sur une vraie migration : 234 drapeaux res.country/image, 13 logos
de fournisseurs de paiement, et une poignée de pièces jointes éparses dont
une photo d'événement de 255 ko. Les deux premiers groupes sont des
données de module qu'une réinstallation restaure, le dernier non.
Les métadonnées ne sont lues QU'À LA DEMANDE : une requête de plus par
base allongerait un parcours qui tient en quatre secondes.
Et chaque chiffre d'un palier porte son écart avec le précédent. « 2283
vues » est un nombre, « +61 » est une information. Le premier palier n'en
a pas : y inventer un zéro annoncerait une comparaison qui n'existe pas.
Assisted-by: Claude Opus 5
2026-08-20 01:52:35 -04:00
|
|
|
|
"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)
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
# 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",
|
|
|
|
|
|
},
|
[ADD] migration quality: explain ir_property and tracking values
ir_property never empties, it GROWS — 211 rows in 12, 457 in 17 — then
vanishes in 18. Its fields are jsonb columns there, verified on
res_partner.property_payment_term_id.
mail_tracking_value is a REAL loss, not a rename: `field` goes from
varchar to a foreign key in 14, and what would not resolve was deleted.
1528 of the 2336 belonged to the removed agile module; 706 more survived
under a new name. New kind "pruned" so the report cannot call a living
table retired.
--- FR ---
ir_property ne se vide jamais, elle GROSSIT — 211 lignes en 12, 457 en
17 — puis disparaît en 18. Ses champs y sont des colonnes jsonb, vérifié
sur res_partner.property_payment_term_id.
mail_tracking_value est une VRAIE perte, pas un renommage : `field`
passe de varchar à clé étrangère en 14, et ce qui ne se résolvait pas a
été supprimé. 1528 des 2336 venaient du module agile retiré ; 706 autres
ont survécu sous un autre nom. Nouveau genre « pruned » pour que le
rapport ne déclare pas retirée une table bien vivante.
Assisted-by: Claude Opus 5
2026-08-21 03:31:28 -04:00
|
|
|
|
# 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",
|
|
|
|
|
|
},
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
{
|
|
|
|
|
|
"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",
|
|
|
|
|
|
},
|
2026-08-21 03:03:04 -04:00
|
|
|
|
# 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",
|
|
|
|
|
|
},
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
{
|
|
|
|
|
|
"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",
|
|
|
|
|
|
},
|
[ADD] migration quality: explain ir_property and tracking values
ir_property never empties, it GROWS — 211 rows in 12, 457 in 17 — then
vanishes in 18. Its fields are jsonb columns there, verified on
res_partner.property_payment_term_id.
mail_tracking_value is a REAL loss, not a rename: `field` goes from
varchar to a foreign key in 14, and what would not resolve was deleted.
1528 of the 2336 belonged to the removed agile module; 706 more survived
under a new name. New kind "pruned" so the report cannot call a living
table retired.
--- FR ---
ir_property ne se vide jamais, elle GROSSIT — 211 lignes en 12, 457 en
17 — puis disparaît en 18. Ses champs y sont des colonnes jsonb, vérifié
sur res_partner.property_payment_term_id.
mail_tracking_value est une VRAIE perte, pas un renommage : `field`
passe de varchar à clé étrangère en 14, et ce qui ne se résolvait pas a
été supprimé. 1528 des 2336 venaient du module agile retiré ; 706 autres
ont survécu sous un autre nom. Nouveau genre « pruned » pour que le
rapport ne déclare pas retirée une table bien vivante.
Assisted-by: Claude Opus 5
2026-08-21 03:31:28 -04:00
|
|
|
|
# É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",
|
|
|
|
|
|
},
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
_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(
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
version,
|
|
|
|
|
|
modeles_perdus,
|
|
|
|
|
|
champs_perdus,
|
|
|
|
|
|
origine_champ=None,
|
|
|
|
|
|
champs_stockes=None,
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
):
|
|
|
|
|
|
"""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": [],
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
"no_data": [],
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
"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
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
# AVANT « non analysé » : « sans donnée propre » est une raison
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# plus forte que « hors du champ d'OpenUpgrade ». Au palier
|
|
|
|
|
|
# 16 → 17, le placement avant fait tomber `not_analysed`
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
# 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
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
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,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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"]
|
|
|
|
|
|
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
# TOUJOURS quatre éléments, le dernier étant l'explication ou None.
|
|
|
|
|
|
# Un tuple de taille variable obligerait chaque lecteur à s'en méfier.
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
lignes_perdues = []
|
|
|
|
|
|
for table, nombre in sorted(tbl_avant.items()):
|
|
|
|
|
|
reste = tbl_apres.get(table)
|
|
|
|
|
|
if reste is None and nombre:
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
lignes_perdues.append((table, nombre, 0, None))
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
elif reste is not None and reste < nombre:
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
lignes_perdues.append((table, nombre, reste, None))
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
lignes_gagnees = [
|
|
|
|
|
|
(table, tbl_avant.get(table, 0), nombre)
|
|
|
|
|
|
for table, nombre in sorted(tbl_apres.items())
|
|
|
|
|
|
if nombre > tbl_avant.get(table, 0)
|
|
|
|
|
|
]
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
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})
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
# `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 []),
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
[ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.
« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.
Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.
One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.
--- FR ---
[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »
Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.
« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.
Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.
Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-20 03:47:02 -04:00
|
|
|
|
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]))
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
champs_perdus = sorted(champ_avant - champ_apres)
|
|
|
|
|
|
modeles_perdus = sorted(mod_avant - mod_apres)
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
return {
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
"declared": overlay_declared(
|
|
|
|
|
|
version,
|
|
|
|
|
|
modeles_perdus,
|
|
|
|
|
|
champs_perdus,
|
|
|
|
|
|
avant.get("field_module"),
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
set(avant.get("field_stored") or []) or None,
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
),
|
|
|
|
|
|
"fields_lost": champs_perdus,
|
[ADD] migration quality: biggest losses first, and the full lists behind « d »
Fifty-seven losses are read from the top, not in alphabetical order. They
are now sorted by rows LOST, not by rows there before: a table of ten
thousand that drops two matters less than one of a thousand that empties.
« d » cycles the pane through the complete lists — modules, models,
fields, COW copies, tables — and back to the summary. The summary cuts at
eight entries and is right to; but when one is looking for whether ONE
module survived, the truncated list does not answer, and that is exactly
when it is needed.
Two inventories were missing and both matter. FIELDS: a lost field is a
lost column of data, finer than a model, which can survive emptied of
half of its own. COW COPIES by key: it is the key one resets, and by the
key one finds them again across versions. On a real 12 → 18 they read
11 036 → 15 069 fields and 64 → 131 copies, of which 28 disappeared —
customisations nothing was reporting until now.
One mode replaces two flags: « show missing files » and « show the model
list » cannot both be true, and two booleans let that impossible state be
written. The mode is named in the sub-title, because a pane that changes
without saying why reads as a broken screen.
--- FR ---
[ADD] qualité de migration : les plus grosses pertes d'abord, les listes derrière « d »
Cinquante-sept pertes se lisent par le haut. Elles sont triées sur le
volume PERDU, non sur le volume présent avant : une table de dix mille qui
en perd deux compte moins qu'une de mille qui se vide.
« d » fait défiler le panneau à travers les listes entières — modules,
modèles, champs, copies COW, tables — puis revient au résumé.
Deux inventaires manquaient. Les CHAMPS : un champ perdu est une colonne
de données perdue. Les COPIES COW par leur clé : sur une vraie 12 → 18,
28 ont disparu — des personnalisations que rien ne signalait.
Un seul mode remplace deux drapeaux, qui laissaient écrire un état
impossible. Il est nommé dans le sous-titre.
Assisted-by: Claude Opus 5
2026-08-20 03:47:02 -04:00
|
|
|
|
"fields_gained": sorted(champ_apres - champ_avant),
|
|
|
|
|
|
"cow_lost": sorted(cow_avant - cow_apres),
|
|
|
|
|
|
"cow_gained": sorted(cow_apres - cow_avant),
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
"modules_lost": sorted(inst_avant - inst_apres),
|
|
|
|
|
|
"modules_gained": sorted(inst_apres - inst_avant),
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
"models_lost": modeles_perdus,
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
"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 = {
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
table: avant
|
|
|
|
|
|
for table, avant, apres, _connu in perdues
|
|
|
|
|
|
if apres == 0 and avant
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
}
|
|
|
|
|
|
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 ?
|
|
|
|
|
|
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
Deux garde-fous plus simples ne tiennent pas. Le seul nombre de lignes
|
|
|
|
|
|
accouple `account_account_tag_account_tax_template_rel` à
|
|
|
|
|
|
`dms_directory` dès qu'ils en comptent autant. Un mot commun d'au moins
|
|
|
|
|
|
cinq lettres accouple `cleanup_purge_wizard_menu` à
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
`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])
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
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.
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
# `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",)
|
|
|
|
|
|
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
# 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
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
# cause n'est pas la table, ce sont ces lignes-là. Sur une chaîne 12 → 18,
|
|
|
|
|
|
# des 516 lignes parties au palier 18, 452 ont perdu leur champ et 63 leur
|
|
|
|
|
|
# enregistrement. Une entrée « ir_attachment / pruned »
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
# 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"),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
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",
|
|
|
|
|
|
),
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
("no_data", "held no data of their own — nothing to lose", "dim"),
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
("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)}"
|
|
|
|
|
|
)
|
[FIX] analyse: écarter les champs qui n'ont jamais porté de donnée
Le seau « NON déclarés par OpenUpgrade » comptait 565 champs au palier
16 → 17, dont 397 `__last_update` — un champ magique qu'Odoo 17 cesse
d'inscrire et qui n'a jamais eu de colonne. Un chiffre de tête qui fait
peur pour rien fait ignorer le rapport entier.
`store=false` le dit sans ambiguïté, et `id` s'y ajoute : il porte une
donnée, mais celle de la ligne, pas la sienne. Ils vont dans une
catégorie à part, en teinte calme, et NOMMÉE — « __last_update × 101
modèle(s) » explique à lui seul le gros chiffre.
Mesuré sur la chaîne 12 → 18 : le total passe de 1176 à 491, le pire
palier de 565 à 57. Les quatre vrais champs techniques perdus restent
visibles.
--- EN ---
The « NOT declared by OpenUpgrade » bucket held 565 fields at the 16 → 17
step, 397 of them `__last_update` — a magic field Odoo 17 stops
recording, which never had a column. A headline number that frightens
for nothing gets the whole report ignored.
`store=false` says so unambiguously, and `id` joins it: it carries data,
but the row's, not its own. They go to a separate bucket, in a quiet
tint, and NAMED — « __last_update × 101 model(s) » explains the big
number on its own.
Measured on the 12 → 18 chain: the total drops from 1176 to 491, the
worst step from 565 to 57. The four genuinely lost technical fields stay
visible.
Assisted-by: Claude Opus 5
2026-08-24 05:48:03 -04:00
|
|
|
|
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.
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
etape = paint(f"{etat['odoo']:<6}", "step", colour)
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
lignes.append(
|
[FIX] outillage : désimbriquer les f-strings que seul 3.12 sait lire
Cinq fichiers employaient des f-strings PEP 701 — guillemets imbriqués du même
type, backslash dans l'expression, expression sur plusieurs lignes — que
Python refuse avant 3.12. Or les hooks de script/git/hooks portent
« #!/usr/bin/env python3 » et tournent donc sur l'interpréteur du système,
qu'une distribution livre encore en 3.10 : la SyntaxError tombe au chargement,
sans qu'aucun garde puisse nommer la commande à taper. Les chaînes traduites
gardent leur texte à l'octet près, sans quoi leur clé se perdrait. Les
commentaires de ces fichiers passent du récit au présent, comme la règle le
demande de ce qu'on touche.
Vérifié : tout l'arbre Python parse sous 3.10, 3.11, 3.12 et 3.14.
--- EN ---
Five files used PEP 701 f-strings — quotes of the same kind nested, a backslash
in the expression, an expression spanning lines — which Python refuses before
3.12. The hooks in script/git/hooks carry "#!/usr/bin/env python3" and so run
on the system interpreter, which a distribution still ships as 3.10: the
SyntaxError lands at load, before any guard can name the command to type.
Translated strings keep their text byte for byte, else their key would be lost.
The comments of those files move from tale to present tense, as the rule asks
of what one touches.
Checked: the whole Python tree parses under 3.10, 3.11, 3.12 and 3.14.
Assisted-by: Claude Opus 5
2026-09-24 13:30:47 -04:00
|
|
|
|
f" {etape}"
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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.
|
|
|
|
|
|
#
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
# 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.
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
vers = {a: b for a, b, _n in diff["renamed"]}
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
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)}"
|
[ADD] migration quality: explain ir_property and tracking values
ir_property never empties, it GROWS — 211 rows in 12, 457 in 17 — then
vanishes in 18. Its fields are jsonb columns there, verified on
res_partner.property_payment_term_id.
mail_tracking_value is a REAL loss, not a rename: `field` goes from
varchar to a foreign key in 14, and what would not resolve was deleted.
1528 of the 2336 belonged to the removed agile module; 706 more survived
under a new name. New kind "pruned" so the report cannot call a living
table retired.
--- FR ---
ir_property ne se vide jamais, elle GROSSIT — 211 lignes en 12, 457 en
17 — puis disparaît en 18. Ses champs y sont des colonnes jsonb, vérifié
sur res_partner.property_payment_term_id.
mail_tracking_value est une VRAIE perte, pas un renommage : `field`
passe de varchar à clé étrangère en 14, et ce qui ne se résolvait pas a
été supprimé. 1528 des 2336 venaient du module agile retiré ; 706 autres
ont survécu sous un autre nom. Nouveau genre « pruned » pour que le
rapport ne déclare pas retirée une table bien vivante.
Assisted-by: Claude Opus 5
2026-08-21 03:31:28 -04:00
|
|
|
|
f" {t('table(s) explained by an Odoo change')} :"
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
)
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
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})"
|
[ADD] migration quality: explain ir_property and tracking values
ir_property never empties, it GROWS — 211 rows in 12, 457 in 17 — then
vanishes in 18. Its fields are jsonb columns there, verified on
res_partner.property_payment_term_id.
mail_tracking_value is a REAL loss, not a rename: `field` goes from
varchar to a foreign key in 14, and what would not resolve was deleted.
1528 of the 2336 belonged to the removed agile module; 706 more survived
under a new name. New kind "pruned" so the report cannot call a living
table retired.
--- FR ---
ir_property ne se vide jamais, elle GROSSIT — 211 lignes en 12, 457 en
17 — puis disparaît en 18. Ses champs y sont des colonnes jsonb, vérifié
sur res_partner.property_payment_term_id.
mail_tracking_value est une VRAIE perte, pas un renommage : `field`
passe de varchar à clé étrangère en 14, et ce qui ne se résolvait pas a
été supprimé. 1528 des 2336 venaient du module agile retiré ; 706 autres
ont survécu sous un autre nom. Nouveau genre « pruned » pour que le
rapport ne déclare pas retirée une table bien vivante.
Assisted-by: Claude Opus 5
2026-08-21 03:31:28 -04:00
|
|
|
|
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
|
|
|
|
|
|
)
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
else:
|
|
|
|
|
|
ou = t("retired from the database")
|
|
|
|
|
|
lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}")
|
[FIX] analyse: dire CE QUI est parti avec une pièce jointe
« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.
On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.
Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.
--- EN ---
« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.
We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.
64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.
Assisted-by: Claude Opus 5
2026-08-24 06:08:17 -04:00
|
|
|
|
lignes.append(f" {t(connu['why'])}")
|
|
|
|
|
|
lignes.extend(render_attachment_kind(connu, colour))
|
[ADD] migration quality: tell Odoo's own redesigns from real losses
« 81 tables lost rows » buried the questions worth asking. The largest of
them — ir_translation, 32 984 rows — is a redesign Odoo made in 16, when
translations moved into jsonb columns. Putting redesigns and real losses
on the same footing is the surest way not to see the second kind.
A map now names what Odoo moves or retires, and the report splits the
list in two: unexplained first, then what Odoo did on purpose. On a real
12 → 18 the split is 57 and 14, and the partition is exact — an explained
loss is annotated, never removed, because removing it was the one thing
that could hide a real one.
Every entry was VERIFIED on that migration by counting both sides, not
written from memory: crm_lead_tag 6 → crm_tag +6, mail_channel 26 →
discuss_channel +26, account_invoice 651 → account_move +441. A map
written from memory would explain losses that are not losses.
And a merge whose target gained nothing says so: the explanation does not
hold, and that is exactly what the map was meant to surface.
--- FR ---
[ADD] qualité de migration : distinguer les refontes d'Odoo des vraies pertes
« 81 tables ont perdu des lignes » enterrait les questions qui valaient la
peine. La plus grosse — ir_translation, 32 984 lignes — est une refonte
d'Odoo 16, quand les traductions sont passées en colonnes jsonb.
Une carte nomme désormais ce qu'Odoo déplace ou retire, et le rapport
sépare la liste en deux : l'inexpliqué d'abord, puis ce qu'Odoo a fait
exprès. Sur une vraie 12 → 18 : 57 et 14, et la partition est exacte —
une perte expliquée est annotée, jamais retirée.
Chaque entrée a été VÉRIFIÉE sur cette migration en comptant les deux
côtés : crm_lead_tag 6 → crm_tag +6, mail_channel 26 → discuss_channel
+26, account_invoice 651 → account_move +441.
Et une fusion dont la table d'accueil n'a rien reçu le dit : l'explication
ne tient pas, et c'est précisément ce que la carte devait révéler.
Assisted-by: Claude Opus 5
2026-08-20 02:42:18 -04:00
|
|
|
|
if len(connues) > limit:
|
|
|
|
|
|
lignes.append(f" … {len(connues) - limit} {t('more')}")
|
[ADD] migration quality: lay OpenUpgrade's declared changes over the real ones
The report counted + and -. A field that stops being stored is neither:
it loses its column and keeps its existence. Read as a loss it cries
wolf every step; ignored it would mask a real one. OpenUpgrade already
knows, in the upgrade_analysis.txt files the checkout carries -- the
same source the OCA coverage pages are generated from, read locally, at
the checkout's exact version.
Three categories keep the signal from drowning: a field whose model went
away is not a second finding (544 of them on one real step), a field of
a module OpenUpgrade does not analyse was never its business, and the
rest is grouped by field name -- __last_update counted itself 391 times.
--- FR ---
Le rapport comptait des + et des -. Un champ qui cesse d'être stocké
n'est ni l'un ni l'autre : il perd sa colonne et garde son existence. Lu
comme une perte, il alarme à chaque palier ; ignoré, il en masquerait
une vraie. OpenUpgrade le sait déjà, dans les upgrade_analysis.txt que
porte le checkout -- la source des pages de couverture de l'OCA, lue
localement, à la version exacte.
Trois catégories évitent que le signal se noie : un champ dont le modèle
a disparu n'est pas une trouvaille de plus (544 sur un palier réel), un
champ d'un module non analysé n'a jamais relevé d'OpenUpgrade, et le
reste est groupé par nom -- __last_update se comptait 391 fois.
Assisted-by: Claude Opus 5
2026-08-21 05:55:54 -04:00
|
|
|
|
lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))
|
[ADD] analyse: what a migration gained and lost, step by step
A migration leaves one database per step and they all still exist, so the
tool compares them side by side rather than replaying anything. Six Odoo
starts would cost an hour, write to the databases and need the checkout
switched at each step; the same inspection in SQL takes under half a
second per database and touches nothing. Measured on a real migration:
seven databases surveyed in 3.8 seconds.
It reports what each step gained and lost — modules, models, views, menus,
attachments — and ends on the comparison of the ENDS, which is not the sum
of the steps: a module dropped at 15 and put back at 17 lost nothing, and
adding the steps would count it twice.
The signal that matters is rows. A module removed is visible; a table
going from four thousand rows to zero is visible nowhere.
Two rename heuristics were tried and rejected on real data before the one
that holds. Row count alone paired an account tag table with dms_directory
— both had seven rows. A shared five-letter word paired two cleanup
tables. Overall name similarity separates them. And a renamed table stays
in the loss list, annotated: removing it was the real danger, since a
wrong pairing would have hidden a genuine loss.
--- FR ---
[ADD] analyse : ce qu'une migration a gagné et perdu, palier par palier
Une migration laisse une base par palier et elles existent toutes encore :
l'outil les compare côte à côte plutôt que de rejouer quoi que ce soit.
Six démarrages d'Odoo coûteraient une heure et écriraient dans les bases ;
la même inspection en SQL prend moins d'une demi-seconde par base. Mesuré
sur une vraie migration : sept bases inspectées en 3,8 secondes.
Le bilan final compare les EXTRÉMITÉS, ce qui n'est pas la somme des
paliers : un module retiré en 15 puis remis en 17 n'a rien perdu.
Le signal qui compte, ce sont les lignes. Un module en moins se voit ; une
table qui passe de quatre mille lignes à zéro ne se voit nulle part.
Deux rapprochements de renommage ont été essayés et rejetés sur des
données réelles. Et une table renommée reste dans la liste des pertes,
annotée : l'en retirer était le vrai danger.
Assisted-by: Claude Opus 5
2026-08-19 08:21:12 -04:00
|
|
|
|
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())
|