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()