#!/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())