From 1831b234ea3e5b890369ce98e0a44249bce6783c Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 17 Aug 2026 09:05:03 -0400 Subject: [PATCH] [ADD] migration: run the OCA database cleanup before testing the pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/odoo/migration/database_cleanup.py | 352 ++++++++++++++++++ script/todo/todo_i18n.py | 108 ++++++ script/todo/todo_upgrade.py | 32 ++ test/test_database_cleanup.py | 428 ++++++++++++++++++++++ 4 files changed, 920 insertions(+) create mode 100755 script/odoo/migration/database_cleanup.py create mode 100755 test/test_database_cleanup.py diff --git a/script/odoo/migration/database_cleanup.py b/script/odoo/migration/database_cleanup.py new file mode 100755 index 0000000..4e6f7fd --- /dev/null +++ b/script/odoo/migration/database_cleanup.py @@ -0,0 +1,352 @@ +#!/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 +ORDER = %(order)r +MAX_ROUND = %(max_round)d +DRY_RUN = %(dry_run)s + +report = {"rounds": [], "missing": [], "failed": []} +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) + continue + ok = 0 + errors = [] + would = [] + try: + wizard = env[model].create({}) + lines = wizard.purge_line_ids + except Exception as exc: + report["failed"].append([label, "-", str(exc)[:200]]) + continue + for line in lines: + name = line.name or str(line.id) + if DRY_RUN: + would.append(name) + continue + try: + # Un point de reprise par ENTRÉE : un refus n'emporte que la + # sienne, et la passe continue. Sans cela, le premier échec + # ferait perdre tout ce que la passe avait réparé. + with env.cr.savepoint(): + line.purge() + ok += 1 + except Exception as exc: + errors.append([name, str(exc)[:160]]) + if not DRY_RUN and ok: + env.cr.commit() + 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 + +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 + + +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}") + 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 + + 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()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6529d4a..3284524 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5138,6 +5138,114 @@ TRANSLATIONS = { "fr": "Parcourir les différences en plein écran", "en": "Browse the differences full screen", }, + "The checkout is on Odoo": { + "fr": "Le checkout est sur Odoo", + "en": "The checkout is on Odoo", + }, + "and the database on": { + "fr": "et la base sur", + "en": "and the database on", + }, + "Opening it with the wrong version writes to it before": { + "fr": "L'ouvrir avec la mauvaise version y écrit avant", + "en": "Opening it with the wrong version writes to it before", + }, + "failing. Switch the checkout first.": { + "fr": "d'échouer. Basculer le checkout d'abord.", + "en": "failing. Switch the checkout first.", + }, + "Database cleanup on": { + "fr": "Nettoyage de la base", + "en": "Database cleanup on", + }, + "Cleaning": { + "fr": "Nettoyage de", + "en": "Cleaning", + }, + "pass": { + "fr": "passe", + "en": "pass", + }, + "purged": { + "fr": "purgés", + "en": "purged", + }, + "purged;": { + "fr": "purgés ;", + "en": "purged;", + }, + "could not be, and are left as they are": { + "fr": "n'ont pas pu l'être, et restent en l'état", + "en": "could not be, and are left as they are", + }, + "entries purged, nothing left.": { + "fr": "entrées purgées, rien ne reste.", + "en": "entries purged, nothing left.", + }, + "no such wizard in this version, skipped.": { + "fr": "pas d'assistant pour cela dans cette version, ignoré.", + "en": "no such wizard in this version, skipped.", + }, + "A database can carry leftovers nothing can remove.": { + "fr": "Une base peut porter des restes que rien ne peut retirer.", + "en": "A database can carry leftovers nothing can remove.", + }, + "This is a warning, not a failure.": { + "fr": "C'est un avertissement, pas un échec.", + "en": "This is a warning, not a failure.", + }, + "The cleanup produced no report.": { + "fr": "Le nettoyage n'a produit aucun rapport.", + "en": "The cleanup produced no report.", + }, + "Unreadable report": { + "fr": "Rapport illisible", + "en": "Unreadable report", + }, + "obsolete models": { + "fr": "modèles obsolètes", + "en": "obsolete models", + }, + "obsolete modules": { + "fr": "modules obsolètes", + "en": "obsolete modules", + }, + "obsolete columns": { + "fr": "colonnes obsolètes", + "en": "obsolete columns", + }, + "obsolete tables": { + "fr": "tables obsolètes", + "en": "obsolete tables", + }, + "obsolete data entries": { + "fr": "entrées de données obsolètes", + "en": "obsolete data entries", + }, + "obsolete menu entries": { + "fr": "entrées de menu obsolètes", + "en": "obsolete menu entries", + }, + "missing indexes": { + "fr": "index manquants", + "en": "missing indexes", + }, + "obsolete properties": { + "fr": "propriétés obsolètes", + "en": "obsolete properties", + }, + "entries would be purged": { + "fr": "entrées seraient purgées", + "en": "entries would be purged", + }, + "nothing was changed": { + "fr": "rien n'a été modifié", + "en": "nothing was changed", + }, + "Clean the database before testing the pages?": { + "fr": "Nettoyer la base avant de tester les pages ?", + "en": "Clean the database before testing the pages?", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 260c376..b1149d3 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1699,6 +1699,10 @@ class TodoUpgrade: # la migration se lit comme un dégât de la migration, et l'on cherche # des heures du côté du palier. Mesuré : deux URL cassaient avant même # de commencer. + # Le nettoyage AVANT la mesure : interroger les pages sur une base + # encombrée fait chercher des pannes dans des restes, et le nettoyage + # en répare une partie de lui-même. + self.prompt_database_cleanup(database_name) self.prompt_smoke_public_url(database_name, baseline=True) msg = "3 - Clean up database before data migration" @@ -2601,6 +2605,7 @@ class TodoUpgrade: # quand même un 500 sur une page que personne n'ouvre. Mesuré # ici : /blog//post/ et /contactus, alors que # le journal de migration n'avait rien signalé. + self.prompt_database_cleanup(database_name_upgrade) self.prompt_smoke_public_url(database_name_upgrade) print(f"[y] {t('Open the server with Selenium')}") @@ -2913,6 +2918,33 @@ class TodoUpgrade: f" -d {database_name} {args} --apply" ) + def prompt_database_cleanup(self, database_name): + """Proposer le nettoyage OCA avant d'interroger les pages. + + Une migration laisse derrière elle des modèles, colonnes, tables, + données et menus obsolètes. Les interroger ensuite fait chercher des + pannes dans des restes qui n'auraient pas dû survivre — et le + nettoyage lui-même en répare une partie. + + « non » par défaut : cela écrit en base, et c'est à vous d'en + décider. + """ + answer = ( + self.ask_gate( + f"💬 {t('Clean the database before testing the pages?')}" + f" (y/N, {t('(b = go back to a previous step)')}) : " + ) + .strip() + .lower() + ) + if answer != "y": + return + self.run_on_terminal( + f"{PYTHON_BIN}" + " ./script/odoo/migration/database_cleanup.py" + f" -d {database_name}" + ) + def prompt_smoke_public_url(self, database_name, baseline=False): """Proposer d'interroger toutes les pages publiques de la base. diff --git a/test/test_database_cleanup.py b/test/test_database_cleanup.py new file mode 100755 index 0000000..37d2587 --- /dev/null +++ b/test/test_database_cleanup.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le nettoyage OCA, en ordre, jusqu'à ce que plus rien ne bouge. + +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 seule passe ne suffit jamais, et l'ordre compte. + +Les erreurs sont ATTENDUES — une clé étrangère tient encore, un module +refuse. Purger la liste d'un bloc perdrait tout au premier refus : chaque +entrée passe donc dans son propre point de reprise. Ce que la passe n'a pas +réparé, la suivante le peut, une fois les voisines parties. + +Ces tests exécutent le script réellement poussé dans le shell, sur un `env` +simulé. C'est la seule façon de vérifier l'ordre, l'isolement des refus et +l'arrêt de la boucle sans lancer Odoo sur une vraie base. +""" + +import os +import sys +import unittest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "script", "odoo", "migration")) + +import database_cleanup as cleanup # noqa: E402 + + +class FakeLine: + def __init__(self, name, fails=0, journal=None): + self.name = name + self.id = abs(hash(name)) % 10000 + self.fails = fails # nombre de refus avant de céder + self.journal = journal if journal is not None else [] + + def purge(self): + self.journal.append(("purge", self.name)) + if self.fails > 0: + self.fails -= 1 + raise RuntimeError(f"refus sur {self.name}") + + +class FakeWizard: + def __init__(self, lines): + self.purge_line_ids = lines + + +class FakeModel: + def __init__(self, lines, raise_on_create=None): + self._lines = lines + self._raise = raise_on_create + + def create(self, values): + if self._raise: + raise RuntimeError(self._raise) + # Les lignes déjà purgées ne reviennent pas : find() les recalcule. + return FakeWizard([ln for ln in self._lines if ln.fails >= 0]) + + +class FakeCursor: + def __init__(self, journal): + self.journal = journal + + def savepoint(self): + journal = self.journal + + class Guard: + def __enter__(self_inner): + journal.append(("savepoint", "enter")) + return self_inner + + def __exit__(self_inner, exc_type, exc, tb): + journal.append( + ("savepoint", "rollback" if exc_type else "release") + ) + return False + + return Guard() + + def commit(self): + self.journal.append(("commit", None)) + + +class FakeEnv(dict): + def __init__(self, mapping, journal): + super().__init__(mapping) + self.cr = FakeCursor(journal) + + +def run_script(models, max_round=10, dry_run=False): + """Exécuter le script réellement poussé, et rendre (rapport, journal).""" + import json + + journal = [] + env = FakeEnv(models, journal) + namespace = {"env": env} + exec(cleanup.build_script(max_round, dry_run), namespace) # noqa: S102 + # Le script imprime le rapport entre deux sentinelles ; ici on le relit + # dans son espace de noms, ce qui teste la MÊME structure. + return json.loads(json.dumps(namespace["report"])), journal + + +class TestTheOrder(unittest.TestCase): + def test_the_requested_order_is_kept(self): + # Purger un modèle libère des colonnes : l'inverse ne marcherait pas. + self.assertEqual( + [kind for kind, _model in cleanup.ORDER], + [ + "models", + "modules", + "columns", + "tables", + "data", + "menus", + "indexes", + "properties", + ], + ) + + def test_indexes_come_after_the_purges(self): + kinds = [kind for kind, _ in cleanup.ORDER] + self.assertGreater(kinds.index("indexes"), kinds.index("tables")) + + def test_a_pass_visits_the_kinds_in_that_order(self): + models = { + model: FakeModel([FakeLine(f"{kind}-1")]) + for kind, model in cleanup.ORDER + } + report, _journal = run_script(models, max_round=1) + self.assertEqual( + [entry["kind"] for entry in report["rounds"][0]], + [kind for kind, _ in cleanup.ORDER], + ) + + +class TestOneEntryCannotSinkThePass(unittest.TestCase): + def test_each_entry_gets_its_own_savepoint(self): + # Sans cela, le premier refus emporterait tout ce que la passe avait + # déjà réparé. + journal = [] + lines = [ + FakeLine("a", journal=journal), + FakeLine("b", journal=journal), + ] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + _report, got = run_script(models, max_round=1) + self.assertEqual(got.count(("savepoint", "enter")), 2) + + def test_a_refusal_rolls_back_only_its_own(self): + journal = [] + lines = [ + FakeLine("ok1", journal=journal), + FakeLine("bad", fails=99, journal=journal), + FakeLine("ok2", journal=journal), + ] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + report, got = run_script(models, max_round=1) + entry = report["rounds"][0][0] + self.assertEqual(entry["purged"], 2) + self.assertEqual([name for name, _msg in entry["errors"]], ["bad"]) + self.assertEqual(got.count(("savepoint", "rollback")), 1) + self.assertEqual(got.count(("savepoint", "release")), 2) + + def test_a_wizard_that_cannot_even_be_created_is_recorded(self): + models = {cleanup.ORDER[0][1]: FakeModel([], raise_on_create="boom")} + report, _got = run_script(models, max_round=1) + self.assertEqual(report["failed"][0][0], "models") + self.assertIn("boom", report["failed"][0][2]) + + +class TestGoingRoundAgain(unittest.TestCase): + def test_what_one_pass_refused_the_next_may_take(self): + # LE point, et la raison même de boucler : une entrée refuse TANT QUE + # sa voisine est là. Une ligne qui guérirait toute seule n'existe pas + # — ce qui existe, c'est une dépendance qui tombe. + etat = {"table_partie": False} + + class Dependante(FakeLine): + def purge(self): + if not etat["table_partie"]: + raise RuntimeError("la table la retient encore") + self.fails = -1 + + class Liberatrice(FakeLine): + def purge(self): + etat["table_partie"] = True + self.fails = -1 + + modele_col, modele_tab = cleanup.ORDER[2][1], cleanup.ORDER[3][1] + models = { + modele_col: FakeModel([Dependante("colonne_liee")]), + modele_tab: FakeModel([Liberatrice("vieille_table")]), + } + report, _got = run_script(models, max_round=5) + # Passe 1 : la colonne refuse, la table part → du progrès, on continue. + self.assertEqual(report["rounds"][0][0]["purged"], 0) + self.assertEqual(report["rounds"][0][1]["purged"], 1) + # Passe 2 : la colonne cède, sa dépendance étant partie. + self.assertEqual(report["rounds"][1][0]["purged"], 1) + + def test_a_pass_that_repairs_nothing_ends_it(self): + # Rien n'a changé dans la base : une passe de plus rendrait le même + # refus. Boucler serait des minutes brûlées pour rien. + models = { + cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)]) + } + report, _got = run_script(models, max_round=8) + self.assertEqual(len(report["rounds"]), 1) + self.assertEqual(len(report["rounds"][0][0]["errors"]), 1) + + def test_the_loop_stops_when_a_pass_repairs_nothing(self): + # Ce qui résistait au tour d'avant résistera encore : boucler + # jusqu'à max_round brûlerait des minutes pour rien. + models = { + cleanup.ORDER[0][1]: FakeModel([FakeLine("never", fails=99)]) + } + report, _got = run_script(models, max_round=8) + self.assertEqual(len(report["rounds"]), 1) + + def test_nothing_to_do_is_one_pass(self): + models = {cleanup.ORDER[0][1]: FakeModel([])} + report, _got = run_script(models, max_round=8) + self.assertEqual(len(report["rounds"]), 1) + + +class TestWhatIsAbsentIsSkipped(unittest.TestCase): + def test_a_missing_wizard_is_noted_not_fatal(self): + # `property` n'existe plus en 18.0 : Odoo y a remplacé ir.property + # par une colonne jsonb. Échouer dessus arrêterait le nettoyage sur + # une version où il n'a plus lieu d'être. + models = {cleanup.ORDER[0][1]: FakeModel([])} + report, _got = run_script(models, max_round=1) + self.assertIn("properties", report["missing"]) + self.assertIn("indexes", report["missing"]) + + def test_a_kind_is_noted_once(self): + models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("x", fails=1)])} + report, _got = run_script(models, max_round=5) + self.assertEqual(report["missing"].count("properties"), 1) + + +class TestTheDryRun(unittest.TestCase): + def test_it_purges_nothing(self): + journal = [] + lines = [FakeLine("a", journal=journal)] + models = {cleanup.ORDER[0][1]: FakeModel(lines)} + report, got = run_script(models, max_round=5, dry_run=True) + self.assertEqual(journal, []) + self.assertNotIn(("commit", None), got) + self.assertEqual(report["rounds"][0][0]["would"], ["a"]) + + def test_it_does_a_single_pass(self): + # Rien ne change, donc rien ne se libère : boucler serait du vent. + models = {cleanup.ORDER[0][1]: FakeModel([FakeLine("a")])} + report, _got = run_script(models, max_round=9, dry_run=True) + self.assertEqual(len(report["rounds"]), 1) + + +class TestTheReport(unittest.TestCase): + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def test_leftovers_are_a_warning_not_a_failure(self): + report = { + "rounds": [ + [ + { + "kind": "models", + "purged": 3, + "errors": [["x", "held"]], + "would": [], + } + ] + ], + "missing": [], + "failed": [], + } + text = cleanup.render(report, "db") + self.assertIn("⚠️", text) + self.assertIn("not a failure", text) + + def test_all_clean_says_so(self): + report = { + "rounds": [ + [{"kind": "models", "purged": 3, "errors": [], "would": []}] + ], + "missing": [], + "failed": [], + } + self.assertIn("✅", cleanup.render(report, "db")) + + def test_a_dry_run_is_not_read_as_refusals(self): + # 586 candidats ne sont pas 586 refus : la première version les + # affichait comme des échecs. + report = { + "rounds": [ + [ + { + "kind": "columns", + "purged": 0, + "errors": [], + "would": ["a", "b"], + } + ] + ], + "missing": [], + "failed": [], + } + text = cleanup.render(report, "db") + self.assertIn("would be purged", text) + self.assertNotIn("⚠️", text) + + +class TestTheMigrationRunsItBeforeTheSmokeTest(unittest.TestCase): + """Interroger les pages sur une base encombrée fait chercher à côté.""" + + def source(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + return inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + + def test_it_runs_before_both_smoke_tests(self): + source = self.source() + self.assertEqual(source.count("prompt_database_cleanup"), 2) + for _ in range(2): + nettoyage = source.index("prompt_database_cleanup") + mesure = source.index("prompt_smoke_public_url") + self.assertLess(nettoyage, mesure) + source = source[mesure + 1 :] + + def test_it_gets_a_real_terminal(self): + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.prompt_database_cleanup) + self.assertIn("run_on_terminal", source) + self.assertNotIn("todo_upgrade_execute", source) + + def test_the_default_cleans_nothing(self): + # Cela ÉCRIT en base : ce n'est pas à la migration de le décider. + import contextlib + import io + + from script.todo import todo_i18n + from script.todo.todo_upgrade import TodoUpgrade + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt: "" + with contextlib.redirect_stdout(io.StringIO()): + upgrade.prompt_database_cleanup("db") + self.assertEqual(lst_cmd, []) + + def test_yes_runs_it_on_that_database(self): + from script.todo.todo_upgrade import TodoUpgrade + + upgrade = TodoUpgrade.__new__(TodoUpgrade) + upgrade.dct_progression = {} + upgrade.lst_command_executed = [] + upgrade.write_config = lambda: None + lst_cmd = [] + upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0 + upgrade.ask_gate = lambda prompt: "y" + upgrade.prompt_database_cleanup("db_upgrade_18") + self.assertEqual(len(lst_cmd), 1) + self.assertIn("database_cleanup.py", lst_cmd[0]) + self.assertIn("-d db_upgrade_18", lst_cmd[0]) + + +class TestItRefusesTheWrongOdooVersion(unittest.TestCase): + """Un Odoo plus ancien sur une base plus récente ÉCRIT avant d'échouer.""" + + def test_a_mismatch_is_refused(self): + original_checkout = cleanup.checkout_version + original_db = cleanup.database_version + cleanup.checkout_version = lambda: "14.0" + cleanup.database_version = lambda database: "17.0" + self.addCleanup( + setattr, cleanup, "checkout_version", original_checkout + ) + self.addCleanup(setattr, cleanup, "database_version", original_db) + message = cleanup.require_matching_version("db") + self.assertIsNotNone(message) + self.assertIn("14.0", message) + self.assertIn("17.0", message) + + def test_a_match_passes(self): + original_checkout = cleanup.checkout_version + original_db = cleanup.database_version + cleanup.checkout_version = lambda: "17.0" + cleanup.database_version = lambda database: "17.0" + self.addCleanup( + setattr, cleanup, "checkout_version", original_checkout + ) + self.addCleanup(setattr, cleanup, "database_version", original_db) + self.assertIsNone(cleanup.require_matching_version("db")) + + def test_not_knowing_does_not_block(self): + # Refuser sur une supposition empêcherait de nettoyer là où c'est + # possible. + original_checkout = cleanup.checkout_version + cleanup.checkout_version = lambda: None + self.addCleanup( + setattr, cleanup, "checkout_version", original_checkout + ) + self.assertIsNone(cleanup.require_matching_version("db")) + + +if __name__ == "__main__": + unittest.main()