diff --git a/script/todo/migration_stats.py b/script/todo/migration_stats.py new file mode 100644 index 0000000..715d7cc --- /dev/null +++ b/script/todo/migration_stats.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Statistiques d'une migration Odoo, en lecture seule. + +Ne touche NI la base NI le fichier de progression : tout se déduit du journal +de migration et des traces laissées sous `private/odoo/migration//`. +C'est ce qui permet de consulter l'état d'une migration en cours depuis une +autre session sans risquer de la perturber. + +`compute()` reçoit le contexte déjà construit par +`TodoUpgrade.resume_context()` — étapes et montées de version — pour ne pas +réimplémenter une seconde fois la lecture des clés « state_* », qui +divergerait de l'écran de reprise. +""" +from __future__ import annotations + +import datetime +import glob +import json +import os + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +def _as_int(value): + """Clé de version en entier. JSON transforme les clés numériques en + chaînes : « 13 » et 13 désignent la même version.""" + try: + return int(float(value)) + except (TypeError, ValueError): + return None + + +def fmt_delay(start, end): + """Écart lisible entre deux horodatages « str(datetime) ».""" + try: + a = datetime.datetime.fromisoformat(str(start)) + b = datetime.datetime.fromisoformat(str(end)) + except (TypeError, ValueError): + return "?" + secs = int(abs((b - a).total_seconds())) + days, rest = divmod(secs, 86400) + hours, rest = divmod(rest, 3600) + minutes = rest // 60 + if days: + return f"{days} j {hours:02d} h" + if hours: + return f"{hours} h {minutes:02d} min" + return f"{minutes} min" + + +def module_evolution(dct_progression): + """[(version, nb_modules, delta)] du plus ancien au plus récent. + + Montre où les modules disparaissent : un saut qui en perd 15 d'un coup + n'a pas le même sens qu'un saut qui n'en perd aucun. + """ + raw = dct_progression.get("dct_module_per_version") or {} + rows = [] + for key, value in raw.items(): + version = _as_int(key) + if version is not None and isinstance(value, list): + rows.append((version, len(value))) + rows.sort() + out = [] + previous = None + for version, count in rows: + out.append( + (version, count, None if previous is None else count - previous) + ) + previous = count + return out + + +def cow_snapshots(private_dir, database_name): + """Instantanés de vues COW enregistrés, du plus ancien au plus récent.""" + directory = os.path.join(private_dir, database_name, "cow_snapshots") + out = [] + for path in sorted(glob.glob(os.path.join(directory, "*.json"))): + try: + with open(path, encoding="utf-8") as fh: + data = json.load(fh) + except (OSError, ValueError): + continue + out.append( + { + "label": data.get("label") or os.path.basename(path)[:-5], + "count": data.get("count"), + "taken_at": data.get("taken_at") or "?", + "path": path, + } + ) + out.sort(key=lambda item: item["taken_at"]) + return out + + +def fix_hooks(ctx, global_dir): + """Correctifs de migration disponibles, et lesquels ont tourné.""" + applied = ctx.get("_fix_applied") or [] + out = [] + for index, item in enumerate(ctx.get("versions") or []): + target = item["version"] + stem = f"fix_migration_odoo{(target - 1) * 10}_to_odoo{target * 10}" + found = [ + os.path.basename(p) + for ext in (".sql", ".py") + for p in [os.path.join(global_dir, stem + ext)] + if os.path.exists(p) + ] + if not found: + continue + out.append( + { + "version": target, + "file": found[0], + "applied": bool(index < len(applied) and applied[index]), + } + ) + return out + + +def journal(dct_progression): + """Commandes exécutées et décisions annotées (les lignes « # »).""" + lst = dct_progression.get("command_executed") or [] + comments = [ + c[2:].strip() for c in lst if isinstance(c, str) and c.startswith("# ") + ] + commands = [ + c for c in lst if isinstance(c, str) and not c.startswith("# ") + ] + return {"commands": commands, "comments": comments} + + +def compute( + dct_progression, + ctx, + database_name, + read_uninstall, + private_dir, + global_dir, +): + """Rassemble toutes les statistiques. + + `read_uninstall(version, base)` est la lecture de liste de TodoUpgrade : + elle résout ELLE-MÊME ses chemins privé puis global, ce qui garantit + qu'on affiche exactement la liste qui serait appliquée. `private_dir` ne + sert donc qu'aux instantanés COW, et `global_dir` qu'aux correctifs.""" + versions = [item["version"] for item in (ctx.get("versions") or [])] + uninstall = {} + for target in versions: + try: + _lst, detail = read_uninstall(target - 1, database_name) + except Exception: + detail = [] + if detail: + uninstall[target] = detail + + evolution = module_evolution(dct_progression) + origin = dct_progression.get("lst_module_per_version_origin") or [] + return { + "delay": fmt_delay( + dct_progression.get("date_create"), + dct_progression.get("date_update"), + ), + "updated": dct_progression.get("date_update") or "?", + "evolution": evolution, + "origin_count": len(origin) if isinstance(origin, list) else 0, + "missing": dct_progression.get("lst_module_missing") or [], + "duplicate": dct_progression.get("lst_module_duplicate") or [], + "uninstall": uninstall, + "removed_total": sum(len(v) for v in uninstall.values()), + "cow": cow_snapshots(private_dir, database_name), + "fixes": fix_hooks( + dict( + ctx, + _fix_applied=dct_progression.get( + "state_4_fix_migration_odoo_lst" + ) + or [], + ), + global_dir, + ), + "journal": journal(dct_progression), + } + + +def flat_module_list(uninstall): + """Tous les modules supprimés, dédupliqués, prêts à copier-coller.""" + seen = [] + for detail in uninstall.values(): + for item in detail: + name = item[0] if isinstance(item, (list, tuple)) else item + if name not in seen: + seen.append(name) + return seen diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 11936aa..bdfc1c2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1561,6 +1561,110 @@ TRANSLATIONS = { "fr": "Rien de désinstallé.", "en": "Nothing uninstalled.", }, + "Migration statistics (read-only)": { + "fr": "📊 Statistiques de la migration (lecture seule)", + "en": "📊 Migration statistics (read-only)", + }, + "Choice (0-3, default 1): ": { + "fr": "Choix (0-3, défaut 1) : ", + "en": "Choice (0-3, default 1): ", + }, + "Migration statistics": { + "fr": "Statistiques de la migration", + "en": "Migration statistics", + }, + "elapsed": { + "fr": "écoulé", + "en": "elapsed", + }, + "Level reached": { + "fr": "Niveau atteint", + "en": "Level reached", + }, + "Modules": { + "fr": "Modules", + "en": "Modules", + }, + "modules": { + "fr": "modules", + "en": "modules", + }, + "At the start": { + "fr": "Au départ", + "en": "At the start", + }, + "Removed in total": { + "fr": "Supprimés au total", + "en": "Removed in total", + }, + "Reported missing": { + "fr": "Signalés manquants", + "en": "Reported missing", + }, + "Duplicated": { + "fr": "En double", + "en": "Duplicated", + }, + "Migration fixes": { + "fr": "Correctifs de migration", + "en": "Migration fixes", + }, + "COW views": { + "fr": "Vues COW", + "en": "COW views", + }, + "views": { + "fr": "vues", + "en": "views", + }, + "no snapshot": { + "fr": "aucun instantané", + "en": "no snapshot", + }, + "Journal": { + "fr": "Journal", + "en": "Journal", + }, + "commands": { + "fr": "commandes", + "en": "commands", + }, + "recorded decisions": { + "fr": "décisions annotées", + "en": "recorded decisions", + }, + "Removed modules, with their reason": { + "fr": "Modules supprimés, avec leur justification", + "en": "Removed modules, with their reason", + }, + "Removed modules, comma-separated (copy)": { + "fr": "Modules supprimés, séparés par des virgules (à copier)", + "en": "Removed modules, comma-separated (copy)", + }, + "COW views: snapshots and differences": { + "fr": "Vues COW : instantanés et différences", + "en": "COW views: snapshots and differences", + }, + "Recorded decisions (journal)": { + "fr": "Décisions annotées (journal)", + "en": "Recorded decisions (journal)", + }, + "Executed commands (last 30)": { + "fr": "Commandes exécutées (30 dernières)", + "en": "Executed commands (last 30)", + }, + "nothing recorded": { + "fr": "rien d'annoté", + "en": "nothing recorded", + }, + "Need two snapshots to diff.": { + "fr": "Il faut deux instantanés pour comparer.", + "en": "Need two snapshots to diff.", + }, + "Diff which two? (e.g. 1,2 — blank to skip)": { + "fr": "Comparer lesquels ? (ex. 1,2 — vide pour passer)", + "en": "Diff which two? (e.g. 1,2 — blank to skip)", + }, "No migration in progress to resume.": { "fr": "Aucune migration en cours à reprendre.", "en": "No migration in progress to resume.", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b44db34..3fd616a 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -287,10 +287,13 @@ class TodoUpgrade: @staticmethod def ask_ui(): - """Interface of the migration: TUI or line-by-line prompts. + """Interface of the migration: TUI, line-by-line prompts, or the + read-only statistics screen. Returns None to leave the tool. The preference can settle it in advance (TODO > Configuration); - « ask » asks. Same contract as the QEMU deployment. + « ask » asks. Same contract as the QEMU deployment — except for + « stats », which is never a stored default: it does nothing, so + landing there every time would only be in the way. """ try: from script.todo import todo_prefs @@ -303,9 +306,155 @@ class TodoUpgrade: print(f"\n{t('Interface:')}") print(f" [1] {t('TUI form')} *") print(f" [2] {t('Classic questions (line by line)')}") + print(f" [3] {t('Migration statistics (read-only)')}") + print(f" [0] {t('Cancel')}") print(f" {t('(change the default in TODO > Configuration)')}") - answer = input(t("Choice (1-2, default 1): ")).strip() - return "cli" if answer == "2" else "tui" + answer = input(t("Choice (0-3, default 1): ")).strip() + return {"0": None, "2": "cli", "3": "stats"}.get(answer, "tui") + + def show_stats(self): + """Écran de statistiques, en lecture seule : rien n'est écrit, ni + dans la base ni dans le journal de migration.""" + from script.todo import migration_stats as ms + + if not os.path.exists(UPGRADE_DATABASE_CONFIG_LOG): + print(f"\nℹ️ {t('No migration in progress to resume.')}") + return + dct = self.read_progression() + if not dct: + print(f"\nℹ️ {t('No migration in progress to resume.')}") + return + ctx = self.resume_context(dct) + database_name = dct.get("config_database_name") or "" + stats = ms.compute( + dct, + ctx, + database_name, + self.read_uninstall_module_list, + PATH_MIGRATION_PRIVATE, + PATH_MIGRATION_GLOBAL, + ) + + while True: + self.print_stats(ctx, stats) + print(f"\n [1] {t('Removed modules, with their reason')}") + print(f" [2] {t('Removed modules, comma-separated (copy)')}") + print(f" [3] {t('COW views: snapshots and differences')}") + print(f" [4] {t('Recorded decisions (journal)')}") + print(f" [5] {t('Executed commands (last 30)')}") + print(f" [0] {t('Back')}") + answer = input(f"💬 {t('Your choice')} : ").strip() + if answer in ("", "0"): + return + if answer == "1": + for version, detail in sorted(stats["uninstall"].items()): + print(f"\n── {version - 1}.0 → {version}.0 ──") + self.print_uninstall_reason(detail) + elif answer == "2": + flat = ms.flat_module_list(stats["uninstall"]) + print(f"\n{len(flat)} {t('modules')} :\n") + print(",".join(flat)) + elif answer == "3": + self.stats_cow(stats, database_name) + elif answer == "4": + for line in stats["journal"]["comments"]: + print(f" · {line}") + if not stats["journal"]["comments"]: + print(f" {t('nothing recorded')}") + elif answer == "5": + for line in stats["journal"]["commands"][-30:]: + print(f" $ {line}") + else: + print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.") + + @staticmethod + def print_stats(ctx, stats): + """Rend le tableau de bord de la migration.""" + print(f"\n📊 {t('Migration statistics')}") + print(f" {t('File'):<11}: {ctx['file']}") + print( + f" {t('Database'):<11}: {ctx['database']}" + f" · {t('Target')} : {ctx['target']}" + ) + print( + f" {t('Started'):<11}: {ctx['started']}" + f" · {t('elapsed')} {stats['delay']}" + ) + + print(f"\n── {t('Level reached')} ──") + done = sum(1 for v in ctx["versions"] if v["done"]) + total = len(ctx["versions"]) or 1 + line = " " + for item in ctx["versions"]: + mark = "✅" if item["done"] else "⬜" + line += f"{item['version'] - 1}.0→{item['version']}.0 {mark} " + print(line) + print( + f" {done}/{len(ctx['versions'])} " + f"{t('version bumps migrated')} ({done * 100 // total} %)" + ) + print( + " " + + " ".join(f"[{s['step']}]{s['icon']}" for s in ctx["steps"]) + ) + + print(f"\n── {t('Modules')} ──") + if stats["origin_count"]: + print(f" {t('At the start'):<24}: {stats['origin_count']}") + for version, count, delta in stats["evolution"]: + change = "" if delta is None else f" ({delta:+d})" + print(f" {f'{version}.0':<24}: {count}{change}") + print(f" {t('Removed in total'):<24}: {stats['removed_total']}") + if stats["missing"]: + print(f" {t('Reported missing'):<24}: {len(stats['missing'])}") + if stats["duplicate"]: + print(f" {t('Duplicated'):<24}: {len(stats['duplicate'])}") + + if stats["fixes"]: + print(f"\n── {t('Migration fixes')} ──") + for fix in stats["fixes"]: + mark = "✅" if fix["applied"] else "⬜" + print(f" {mark} {fix['version']}.0 {fix['file']}") + + print(f"\n── {t('COW views')} ──") + if stats["cow"]: + for snap in stats["cow"]: + print( + f" {snap['label']:<18} {str(snap['count']):>4} " + f"{t('views')} {snap['taken_at']}" + ) + else: + print(f" {t('no snapshot')}") + + print(f"\n── {t('Journal')} ──") + print( + f" {len(stats['journal']['commands'])} {t('commands')}, " + f"{len(stats['journal']['comments'])} {t('recorded decisions')}" + ) + + def stats_cow(self, stats, database_name): + """Instantanés COW, et différence entre deux d'entre eux.""" + snaps = stats["cow"] + if len(snaps) < 2: + print(f" {t('Need two snapshots to diff.')}") + return + for index, snap in enumerate(snaps, 1): + print( + f" [{index}] {snap['label']:<18} " + f"{str(snap['count']):>4} {t('views')} {snap['taken_at']}" + ) + raw = input( + f"💬 {t('Diff which two? (e.g. 1,2 — blank to skip)')} : " + ).strip() + parts = [p.strip() for p in raw.replace(",", " ").split()] + if len(parts) != 2 or not all(p.isdigit() for p in parts): + return + first, second = (int(p) - 1 for p in parts) + if not (0 <= first < len(snaps) and 0 <= second < len(snaps)): + return + self.diff_cow_views( + database_name, snaps[first]["label"], snaps[second]["label"] + ) @staticmethod def resume_tui(ctx): @@ -722,7 +871,16 @@ class TodoUpgrade: self.dct_module_per_dct_version_path = {} default_database_name = "test" - use_tui = self.ask_ui() == "tui" + # L'écran de statistiques ne fait rien : on y revient autant de fois + # qu'on veut, et on repose ensuite le choix d'interface. + while True: + ui = self.ask_ui() + if ui is None: + return + if ui != "stats": + break + self.show_stats() + use_tui = ui == "tui" if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG): old_dct_progression = self.read_progression()