[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -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)
|
|
|
|
|
|
|
|
|
|
|
|
"""Run the OCA « Database cleanup » purges, in order, until nothing moves.
|
|
|
|
|
|
|
|
|
|
|
|
Why a tool rather than the eight screens
|
|
|
|
|
|
----------------------------------------
|
|
|
|
|
|
A migration leaves obsolete models, columns, tables, data and menus behind.
|
|
|
|
|
|
The `database_cleanup` module offers one wizard per kind, and they are not
|
|
|
|
|
|
independent: purging a model frees the columns that referenced it, purging a
|
|
|
|
|
|
table frees the data rows pointing at it. One pass is never enough — the
|
|
|
|
|
|
order matters, and so does going round again.
|
|
|
|
|
|
|
|
|
|
|
|
Errors are expected, and are not a reason to stop
|
|
|
|
|
|
-------------------------------------------------
|
|
|
|
|
|
Some entries cannot be purged: a foreign key still holds, a record is
|
|
|
|
|
|
protected, a module refuses. Purging the whole list at once loses everything
|
|
|
|
|
|
to the first failure, so each entry is purged INSIDE ITS OWN SAVEPOINT: a
|
|
|
|
|
|
refusal rolls back that entry alone and the rest of the pass continues. What
|
|
|
|
|
|
one pass could not repair, the next may — once its neighbours are gone.
|
|
|
|
|
|
|
|
|
|
|
|
The loop stops when a full pass repairs nothing new. Whatever remains is
|
|
|
|
|
|
reported as a warning, not as a failure: a database can carry leftovers that
|
|
|
|
|
|
nothing can remove, and refusing to move on would help no one.
|
|
|
|
|
|
|
|
|
|
|
|
Exit codes: 0 nothing left, 1 leftovers remain, 2 the tool failed.
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import argparse
|
|
|
|
|
|
import json
|
|
|
|
|
|
import os
|
|
|
|
|
|
import subprocess
|
|
|
|
|
|
import sys
|
|
|
|
|
|
|
|
|
|
|
|
sys.path.append(
|
|
|
|
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from script.todo.todo_i18n import t
|
|
|
|
|
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
|
|
|
|
|
|
|
|
|
|
|
def t(key: str) -> str:
|
|
|
|
|
|
return key
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# L'ordre demandé, et il compte : purger un modèle libère les colonnes qui le
|
|
|
|
|
|
# référençaient, purger une table libère les données qui la visaient. Les
|
|
|
|
|
|
# index viennent après les purges — inutile d'indexer ce qu'on va retirer.
|
|
|
|
|
|
#
|
|
|
|
|
|
# `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property par une
|
|
|
|
|
|
# colonne jsonb. On le garde dans la liste et on le saute quand il manque,
|
|
|
|
|
|
# plutôt que d'échouer sur une version où il n'a plus lieu d'être.
|
|
|
|
|
|
ORDER = [
|
|
|
|
|
|
("models", "cleanup.purge.wizard.model"),
|
|
|
|
|
|
("modules", "cleanup.purge.wizard.module"),
|
|
|
|
|
|
("columns", "cleanup.purge.wizard.column"),
|
|
|
|
|
|
("tables", "cleanup.purge.wizard.table"),
|
|
|
|
|
|
("data", "cleanup.purge.wizard.data"),
|
|
|
|
|
|
("menus", "cleanup.purge.wizard.menu"),
|
|
|
|
|
|
("indexes", "cleanup.create_indexes.wizard"),
|
|
|
|
|
|
("properties", "cleanup.purge.wizard.property"),
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
LABEL = {
|
|
|
|
|
|
"models": "obsolete models",
|
|
|
|
|
|
"modules": "obsolete modules",
|
|
|
|
|
|
"columns": "obsolete columns",
|
|
|
|
|
|
"tables": "obsolete tables",
|
|
|
|
|
|
"data": "obsolete data entries",
|
|
|
|
|
|
"menus": "obsolete menu entries",
|
|
|
|
|
|
"indexes": "missing indexes",
|
|
|
|
|
|
"properties": "obsolete properties",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
START = "ERPLIBRE_CLEANUP_START"
|
|
|
|
|
|
END = "ERPLIBRE_CLEANUP_END"
|
|
|
|
|
|
|
|
|
|
|
|
SHELL_SCRIPT = """
|
|
|
|
|
|
import json
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
|
from odoo.exceptions import UserError
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
class UserError(Exception):
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
ORDER = %(order)r
|
|
|
|
|
|
MAX_ROUND = %(max_round)d
|
|
|
|
|
|
DRY_RUN = %(dry_run)s
|
|
|
|
|
|
|
|
|
|
|
|
report = {"rounds": [], "missing": [], "failed": []}
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def note(label, name, exc):
|
|
|
|
|
|
report["failed"].append([label, name, str(exc)[:200]])
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
def recover():
|
|
|
|
|
|
# Rendre la transaction utilisable, quoi qu'il vienne de se passer.
|
|
|
|
|
|
#
|
|
|
|
|
|
# PAS de point de reprise ici, et c'est mesure : purge_modules.find()
|
|
|
|
|
|
# purge lui-meme (purge_modules.py:91), et purge_columns appelle
|
|
|
|
|
|
# cr.commit() (purge_columns.py:57). Or un COMMIT DETRUIT tous les
|
|
|
|
|
|
# points de reprise : le notre disparaissait sous nos pieds, d'ou
|
|
|
|
|
|
# « savepoint ... does not exist », puis tout ce qui suivait mourait
|
|
|
|
|
|
# sur une transaction avortee. Un rollback franc remet les compteurs
|
|
|
|
|
|
# a zero ; ce qu'on perd est borne, puisqu'on valide apres chaque
|
|
|
|
|
|
# entree reussie.
|
|
|
|
|
|
try:
|
|
|
|
|
|
env.cr.rollback()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
try:
|
|
|
|
|
|
for index in range(MAX_ROUND):
|
|
|
|
|
|
this_round = []
|
|
|
|
|
|
purged_this_round = 0
|
|
|
|
|
|
for label, model in ORDER:
|
|
|
|
|
|
if model not in env:
|
|
|
|
|
|
if label not in report["missing"]:
|
|
|
|
|
|
report["missing"].append(label)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
continue
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
ok = 0
|
|
|
|
|
|
errors = []
|
|
|
|
|
|
would = []
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
try:
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
wizard = env[model].create({})
|
|
|
|
|
|
# Les noms sont matérialisés TOUT DE SUITE : les relire plus
|
|
|
|
|
|
# tard relancerait une requête, et c'est là que le script
|
|
|
|
|
|
# mourait quand la transaction avait été avortée entre-temps.
|
|
|
|
|
|
todo = [
|
|
|
|
|
|
(line, line.name or str(line.id))
|
|
|
|
|
|
for line in wizard.purge_line_ids
|
|
|
|
|
|
]
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
except UserError:
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
# « No orphaned models found » : le module signale le VIDE
|
|
|
|
|
|
# en levant. Le compter comme un échec faisait passer une
|
|
|
|
|
|
# base saine pour cassée.
|
|
|
|
|
|
recover()
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
this_round.append({"kind": label, "purged": 0,
|
|
|
|
|
|
"errors": [], "would": []})
|
|
|
|
|
|
continue
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
except Exception as exc:
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
recover()
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
note(label, "-", exc)
|
|
|
|
|
|
continue
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
# find() peut avoir purgé de lui-même : on garde ce qu'il a fait.
|
|
|
|
|
|
if not DRY_RUN:
|
|
|
|
|
|
try:
|
|
|
|
|
|
env.cr.commit()
|
|
|
|
|
|
except Exception:
|
|
|
|
|
|
recover()
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
for line, name in todo:
|
|
|
|
|
|
if DRY_RUN:
|
|
|
|
|
|
would.append(name)
|
|
|
|
|
|
continue
|
|
|
|
|
|
try:
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
line.purge()
|
|
|
|
|
|
# Valider ENTRÉE PAR ENTRÉE : un refus plus loin ne doit
|
|
|
|
|
|
# pas emporter ce qui vient d'être réparé.
|
|
|
|
|
|
env.cr.commit()
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
ok += 1
|
|
|
|
|
|
except Exception as exc:
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
recover()
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
errors.append([name, str(exc)[:160]])
|
|
|
|
|
|
purged_this_round += ok
|
|
|
|
|
|
this_round.append({"kind": label, "purged": ok,
|
|
|
|
|
|
"errors": errors, "would": would})
|
|
|
|
|
|
report["rounds"].append(this_round)
|
|
|
|
|
|
# On s'arrête quand une passe ENTIÈRE n'a plus rien réparé : ce qui
|
|
|
|
|
|
# résistait au tour d'avant résistera encore. En simulation, une
|
|
|
|
|
|
# seule passe suffit — rien ne change, donc rien ne se libère.
|
|
|
|
|
|
if purged_this_round == 0 or DRY_RUN:
|
|
|
|
|
|
break
|
|
|
|
|
|
except Exception as exc:
|
|
|
|
|
|
# Le rapport de CE QUI A ÉTÉ FAIT vaut plus que la trace de ce qui a
|
|
|
|
|
|
# cassé : sans lui, on ne sait même pas si la base a été touchée.
|
|
|
|
|
|
note("*", "fatal", exc)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
|
[FIX] database_cleanup: recover the transaction, not the savepoint
One refusal killed seven categories: modules died on « savepoint ...
does not exist », then columns, tables, data, menus, indexes and
properties all reported « current transaction is aborted ». Six
victims that never got to try.
The savepoints were mine and they could not work. The OCA module
commits on its own — purge_columns.py:57 calls cr.commit(), and
purge_modules.find() purges at line 91 before returning. A COMMIT
destroys every savepoint, so ours vanished under our feet and nothing
put the transaction back on its feet.
Each entry is committed as it succeeds, and any failure rolls back.
The dry run rolls back too: find() was writing for real.
--- FR ---
[FIX] database_cleanup : rattraper la transaction, pas le point de reprise
Un seul refus en tuait sept : modules mourait sur « savepoint ... does
not exist », puis colonnes, tables, données, menus, index et
propriétés signalaient tous « current transaction is aborted ». Six
victimes qui n'ont jamais eu leur tour.
Les points de reprise étaient les miens et ne pouvaient pas tenir. Le
module OCA valide de lui-même — purge_columns.py:57 appelle cr.commit(),
et purge_modules.find() purge dès la ligne 91. Or un COMMIT détruit
tout point de reprise : le nôtre disparaissait sous nos pieds, et rien
ne remettait la transaction d'aplomb.
Chaque entrée est validée dès qu'elle réussit, tout échec défait la
sienne. La simulation défait aussi : find() écrivait pour de bon.
Assisted-by: Claude Opus 5
2026-08-17 19:38:56 -04:00
|
|
|
|
if DRY_RUN:
|
|
|
|
|
|
# Une simulation qui écrit n'est pas une simulation. Et celle-ci
|
|
|
|
|
|
# écrivait : purge_modules.find() purge de lui-meme, avant meme
|
|
|
|
|
|
# qu'on ait rien decide. On defait tout ce que la lecture a
|
|
|
|
|
|
# provoque — c'est possible ici, justement parce qu'on n'a valide
|
|
|
|
|
|
# aucune entree.
|
|
|
|
|
|
recover()
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
print("%(start)s")
|
|
|
|
|
|
print(json.dumps(report))
|
|
|
|
|
|
print("%(end)s")
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def build_script(max_round, dry_run):
|
|
|
|
|
|
return SHELL_SCRIPT % {
|
|
|
|
|
|
"order": ORDER,
|
|
|
|
|
|
"max_round": max_round,
|
|
|
|
|
|
"dry_run": "True" if dry_run else "False",
|
|
|
|
|
|
"start": START,
|
|
|
|
|
|
"end": END,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def checkout_version():
|
|
|
|
|
|
"""La version d'Odoo que le checkout servira, d'après .odoo-version."""
|
|
|
|
|
|
try:
|
|
|
|
|
|
with open(".odoo-version", "r", encoding="utf-8") as handle:
|
|
|
|
|
|
return handle.read().strip()
|
|
|
|
|
|
except OSError:
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def database_version(database):
|
|
|
|
|
|
"""La version que la BASE dit être la sienne, via le module `base`."""
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
|
|
|
|
|
|
env["PSQLRC"] = ""
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"psql",
|
|
|
|
|
|
"-X",
|
|
|
|
|
|
"-w",
|
|
|
|
|
|
"-d",
|
|
|
|
|
|
database,
|
|
|
|
|
|
"-tAc",
|
|
|
|
|
|
"SELECT latest_version FROM ir_module_module WHERE name='base';",
|
|
|
|
|
|
],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=env,
|
|
|
|
|
|
)
|
|
|
|
|
|
if done.returncode:
|
|
|
|
|
|
return None
|
|
|
|
|
|
parts = done.stdout.strip().split(".")
|
|
|
|
|
|
return ".".join(parts[:2]) if len(parts) >= 2 else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def require_matching_version(database):
|
|
|
|
|
|
"""Refuser d'ouvrir une base avec un Odoo d'une autre version.
|
|
|
|
|
|
|
|
|
|
|
|
Un Odoo plus ancien qui charge une base plus récente ne se contente pas
|
|
|
|
|
|
d'échouer : il ÉCRIT en chemin — mesuré, un shell en 14.0 lancé sur une
|
|
|
|
|
|
base 17.0 est parti réécrire ir_model avant de mourir sur un jsonb qu'il
|
|
|
|
|
|
ne connaissait pas. Le checkout suit la migration, et rien ne garantit
|
|
|
|
|
|
qu'il soit resté sur la version de la base qu'on veut nettoyer.
|
|
|
|
|
|
"""
|
|
|
|
|
|
checkout = checkout_version()
|
|
|
|
|
|
database_side = database_version(database)
|
|
|
|
|
|
if not checkout or not database_side:
|
|
|
|
|
|
return None # Rien pour trancher : ne pas bloquer sur une supposition.
|
|
|
|
|
|
if checkout != database_side:
|
|
|
|
|
|
return (
|
|
|
|
|
|
f"{t('The checkout is on Odoo')} {checkout}"
|
|
|
|
|
|
f" {t('and the database on')} {database_side}."
|
|
|
|
|
|
f"\n {t('Opening it with the wrong version writes to it before')}"
|
|
|
|
|
|
f" {t('failing. Switch the checkout first.')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
def module_state(database, module="database_cleanup"):
|
|
|
|
|
|
"""L'état du module dans cette base, ou None si on ne peut pas lire."""
|
|
|
|
|
|
env = os.environ.copy()
|
|
|
|
|
|
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
|
|
|
|
|
|
env["PSQLRC"] = ""
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"psql",
|
|
|
|
|
|
"-X",
|
|
|
|
|
|
"-w",
|
|
|
|
|
|
"-d",
|
|
|
|
|
|
database,
|
|
|
|
|
|
"-tAc",
|
|
|
|
|
|
f"SELECT state FROM ir_module_module WHERE name='{module}';",
|
|
|
|
|
|
],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
env=env,
|
|
|
|
|
|
)
|
|
|
|
|
|
if done.returncode:
|
|
|
|
|
|
return None
|
|
|
|
|
|
return done.stdout.strip() or None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def install_module(database, module="database_cleanup", timeout=1800):
|
|
|
|
|
|
"""Poser le module avant de s'en servir.
|
|
|
|
|
|
|
|
|
|
|
|
Sans lui, aucun assistant n'existe et l'outil rend « rien à faire » sur
|
|
|
|
|
|
une base qui en aurait eu besoin — un silence qu'on prend pour un
|
|
|
|
|
|
succès. La migration l'installait plus tard, à l'étape 3 ; l'attendre
|
|
|
|
|
|
revenait à nettoyer trop tard.
|
|
|
|
|
|
"""
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
|
["./script/addons/install_addons.sh", database, module],
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
return done.returncode, done.stdout + done.stderr
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
def run_shell(database, config_path, script, timeout=3600):
|
|
|
|
|
|
"""Pousser le script dans « odoo-bin shell » et rendre son rapport.
|
|
|
|
|
|
|
|
|
|
|
|
Les journaux d'Odoo se mêlent à la sortie, d'où les sentinelles : on ne
|
|
|
|
|
|
lit que ce qui est entre elles. Leur absence est une erreur franche, pas
|
|
|
|
|
|
un rapport vide qu'on prendrait pour « rien à faire ».
|
|
|
|
|
|
"""
|
|
|
|
|
|
done = subprocess.run(
|
|
|
|
|
|
[
|
|
|
|
|
|
"./odoo_bin.sh",
|
|
|
|
|
|
"shell",
|
|
|
|
|
|
"-c",
|
|
|
|
|
|
config_path,
|
|
|
|
|
|
"-d",
|
|
|
|
|
|
database,
|
|
|
|
|
|
"--log-level=warn",
|
|
|
|
|
|
],
|
|
|
|
|
|
input=script,
|
|
|
|
|
|
capture_output=True,
|
|
|
|
|
|
text=True,
|
|
|
|
|
|
timeout=timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
output = done.stdout + done.stderr
|
|
|
|
|
|
if START not in output or END not in output:
|
|
|
|
|
|
raise RuntimeError(
|
|
|
|
|
|
f"{t('The cleanup produced no report.')}\n{output.strip()[-1500:]}"
|
|
|
|
|
|
)
|
|
|
|
|
|
body = output.split(START, 1)[1].split(END, 1)[0].strip()
|
|
|
|
|
|
try:
|
|
|
|
|
|
return json.loads(body)
|
|
|
|
|
|
except ValueError as exc:
|
|
|
|
|
|
raise RuntimeError(f"{t('Unreadable report')} : {exc}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def leftovers(report):
|
|
|
|
|
|
"""[(kind, name, message)] de ce que la DERNIÈRE passe n'a pas pu purger."""
|
|
|
|
|
|
if not report.get("rounds"):
|
|
|
|
|
|
return []
|
|
|
|
|
|
lst = []
|
|
|
|
|
|
for entry in report["rounds"][-1]:
|
|
|
|
|
|
for name, message in entry.get("errors", []):
|
|
|
|
|
|
lst.append((entry["kind"], name, message))
|
|
|
|
|
|
for kind, name, message in report.get("failed", []):
|
|
|
|
|
|
lst.append((kind, name, message))
|
|
|
|
|
|
return lst
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render(report, database):
|
|
|
|
|
|
lines = [f"🧹 {t('Database cleanup on')} '{database}'"]
|
|
|
|
|
|
# La simulation ne répare rien : la présenter comme un échec ferait
|
|
|
|
|
|
# croire à 585 refus là où il n'y a que 585 candidats.
|
|
|
|
|
|
lst_would = [
|
|
|
|
|
|
(entry["kind"], name)
|
|
|
|
|
|
for this_round in report.get("rounds", [])
|
|
|
|
|
|
for entry in this_round
|
|
|
|
|
|
for name in entry.get("would", [])
|
|
|
|
|
|
]
|
|
|
|
|
|
if lst_would:
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f"ℹ {len(lst_would)} {t('entries would be purged')}"
|
|
|
|
|
|
f" ({t('nothing was changed')}) :"
|
|
|
|
|
|
)
|
|
|
|
|
|
by_kind = {}
|
|
|
|
|
|
for kind, _name in lst_would:
|
|
|
|
|
|
by_kind[kind] = by_kind.get(kind, 0) + 1
|
|
|
|
|
|
for kind, count in by_kind.items():
|
|
|
|
|
|
lines.append(f" - {t(LABEL.get(kind, kind))} : {count}")
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
# Les échecs comptent AUSSI en simulation : une catégorie qui ne
|
|
|
|
|
|
# s'ouvre même pas est une information, pas un silence.
|
|
|
|
|
|
for kind, name, message in report.get("failed", []):
|
|
|
|
|
|
lines.append(f" ⚠ [{kind}] {name} : {message[:90]}")
|
|
|
|
|
|
for kind in report.get("missing", []):
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" ℹ {t(LABEL.get(kind, kind))} :"
|
|
|
|
|
|
f" {t('no such wizard in this version, skipped.')}"
|
|
|
|
|
|
)
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
total = 0
|
|
|
|
|
|
for index, this_round in enumerate(report.get("rounds", []), start=1):
|
|
|
|
|
|
purged = sum(entry["purged"] for entry in this_round)
|
|
|
|
|
|
total += purged
|
|
|
|
|
|
detail = ", ".join(
|
|
|
|
|
|
f"{t(LABEL[entry['kind']])} {entry['purged']}"
|
|
|
|
|
|
for entry in this_round
|
|
|
|
|
|
if entry["purged"]
|
|
|
|
|
|
)
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" {t('pass')} {index} : {purged} {t('purged')}"
|
|
|
|
|
|
+ (f" — {detail}" if detail else "")
|
|
|
|
|
|
)
|
|
|
|
|
|
for kind in report.get("missing", []):
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" ℹ {t(LABEL.get(kind, kind))} :"
|
|
|
|
|
|
f" {t('no such wizard in this version, skipped.')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
lst_left = leftovers(report)
|
|
|
|
|
|
if not lst_left:
|
|
|
|
|
|
lines.append(f"✅ -> {total} {t('entries purged, nothing left.')}")
|
|
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f"⚠️ {total} {t('purged;')} {len(lst_left)}"
|
|
|
|
|
|
f" {t('could not be, and are left as they are')} :"
|
|
|
|
|
|
)
|
|
|
|
|
|
for kind, name, message in lst_left[:20]:
|
|
|
|
|
|
lines.append(f" - [{kind}] {name} : {message[:90]}")
|
|
|
|
|
|
if len(lst_left) > 20:
|
|
|
|
|
|
lines.append(f" … {len(lst_left) - 20} {t('more')}")
|
|
|
|
|
|
lines.append(
|
|
|
|
|
|
f" {t('A database can carry leftovers nothing can remove.')}"
|
|
|
|
|
|
f" {t('This is a warning, not a failure.')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def main(argv=None):
|
|
|
|
|
|
parser = argparse.ArgumentParser(
|
|
|
|
|
|
description=(
|
|
|
|
|
|
"Run the OCA database_cleanup purges in order, repeating until"
|
|
|
|
|
|
" a full pass repairs nothing new."
|
|
|
|
|
|
)
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument("-d", "--database", required=True)
|
|
|
|
|
|
parser.add_argument("-c", "--config", default="./config.conf")
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--max-round",
|
|
|
|
|
|
type=int,
|
|
|
|
|
|
default=10,
|
|
|
|
|
|
help="how many full passes at most (default 10)",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
|
"--dry-run",
|
|
|
|
|
|
action="store_true",
|
|
|
|
|
|
help="list what would be purged, purge nothing",
|
|
|
|
|
|
)
|
|
|
|
|
|
parser.add_argument("--timeout", type=int, default=3600)
|
|
|
|
|
|
config = parser.parse_args(argv)
|
|
|
|
|
|
|
|
|
|
|
|
mismatch = require_matching_version(config.database)
|
|
|
|
|
|
if mismatch:
|
|
|
|
|
|
print(f"⛔ {mismatch}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
[FIX] migration: the cleanup installs its own module, and survives a refusal
Three defects, all mine, all found on a real run.
Creating a wizard could fail and leave the transaction ABORTED. The next
name read died on it, outside any guard, and the whole script stopped
with no report at all — only a traceback. Creating and reading the names
now happen inside the savepoint, and a report is printed whatever
happens: knowing what was done matters more than the trace of what
broke.
« No orphaned models found » is a UserError: the module signals the
EMPTY by raising. Counting it as a failure made a healthy database look
broken, four warnings out of five kinds.
The module was installed at step 3, after being used at step 2, so the
first run had no wizard at all and answered « nothing to do ». It is now
installed by the tool itself, and step 3 no longer asks to redo by hand
what has just been done automatically.
--- FR ---
[FIX] migration : le nettoyage pose son module, et survit à un refus
Trois défauts, tous à moi, tous trouvés sur une vraie exécution.
Créer un assistant pouvait échouer en laissant la transaction AVORTÉE.
La lecture de nom suivante mourait dessus, hors de tout garde, et le
script s'arrêtait sans aucun rapport — juste une trace. La création et
la lecture des noms sont désormais dans le point de reprise, et un
rapport est imprimé quoi qu'il arrive : savoir ce qui a été fait vaut
mieux que la trace de ce qui a cassé.
« No orphaned models found » est une UserError : le module signale le
VIDE en levant. Le compter comme un échec faisait passer une base saine
pour cassée, quatre avertissements sur cinq catégories.
Le module était installé à l'étape 3, après avoir servi à l'étape 2 : le
premier passage n'avait donc aucun assistant et répondait « rien à
faire ». L'outil le pose lui-même, et l'étape 3 ne demande plus de
refaire à la main ce qui vient d'être fait.
Assisted-by: Claude Opus 5
2026-08-17 17:45:04 -04:00
|
|
|
|
state = module_state(config.database)
|
|
|
|
|
|
if state != "installed":
|
|
|
|
|
|
print(
|
|
|
|
|
|
f"⧖ {t('database_cleanup is not installed on this base;')}"
|
|
|
|
|
|
f" {t('installing it first.')}"
|
|
|
|
|
|
)
|
|
|
|
|
|
code, output = install_module(config.database)
|
|
|
|
|
|
if code:
|
|
|
|
|
|
print(output.strip()[-1500:])
|
|
|
|
|
|
print(f"❌ {t('Could not install database_cleanup.')}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
|
[ADD] migration: run the OCA database cleanup before testing the pages
The eight purges are not independent: purging a model frees the columns
that referenced it, purging a table frees the data pointing at it. One
pass is never enough, so the requested order runs again until a full
pass repairs nothing new.
Refusals are expected — a foreign key still holds, a module says no.
Purging a list at once loses everything to the first one, so each entry
purges inside its own savepoint: a refusal rolls back that entry alone.
What one pass could not take, the next may, once its neighbours are
gone. Leftovers are reported as a warning: a database can carry some
that nothing removes, and stopping there would help no one.
It refuses a version mismatch. Measured while building it: a shell on
Odoo 14 opened against a 17.0 database went rewriting ir_model before
dying on a jsonb it did not know. An older Odoo does not merely fail on
a newer database — it writes on the way.
--- FR ---
[ADD] migration : lancer le nettoyage OCA avant de tester les pages
Les huit purges ne sont pas indépendantes : purger un modèle libère les
colonnes qui le référençaient, purger une table libère les données qui
la visaient. Une passe ne suffit jamais, l'ordre demandé est donc rejoué
jusqu'à ce qu'une passe entière ne répare plus rien.
Les refus sont attendus — une clé étrangère tient, un module dit non.
Purger d'un bloc perdrait tout au premier : chaque entrée passe dans son
propre point de reprise, un refus n'emporte que la sienne. Ce qu'une
passe n'a pu prendre, la suivante le peut, une fois les voisines
parties. Les restes sont un avertissement : une base peut en porter que
rien ne retire, et s'arrêter là n'aiderait personne.
Il refuse une version qui ne correspond pas. Mesuré en le construisant :
un shell en Odoo 14 ouvert sur une base 17.0 est parti réécrire ir_model
avant de mourir sur un jsonb inconnu de lui. Un Odoo plus ancien
n'échoue pas simplement sur une base plus récente — il écrit en chemin.
Assisted-by: Claude Opus 5
2026-08-17 09:05:03 -04:00
|
|
|
|
print(f"⧖ {t('Cleaning')} '{config.database}'…")
|
|
|
|
|
|
try:
|
|
|
|
|
|
report = run_shell(
|
|
|
|
|
|
config.database,
|
|
|
|
|
|
config.config,
|
|
|
|
|
|
build_script(config.max_round, config.dry_run),
|
|
|
|
|
|
timeout=config.timeout,
|
|
|
|
|
|
)
|
|
|
|
|
|
except (RuntimeError, subprocess.SubprocessError, OSError) as exc:
|
|
|
|
|
|
print(f"❌ {exc}")
|
|
|
|
|
|
return 2
|
|
|
|
|
|
print(render(report, config.database))
|
|
|
|
|
|
return 1 if leftovers(report) else 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
sys.exit(main())
|