[ADD] migration: a read-only statistics screen, and a way out

The interface choice offered two ways to ACT on a migration and none to just
look at one. Entry [3] reads the progression file and the traces left under
private/, writes nothing, and can be opened as often as one likes — [0] there
returns to the interface choice, and a new [0] Cancel leaves the tool entirely
instead of forcing a migration to be started.

« stats » is deliberately not storable as a default: it does nothing, so
landing on it every time would only be in the way.

The dashboard answers what was asked — which modules were removed, and how far
between Odoo versions — plus what the recorded data already knew and nobody
was showing:

  · elapsed time since the migration started
  · module count per version, with the delta per bump — a jump losing 15
    modules does not mean the same thing as one losing none
  · modules reported missing or duplicated
  · which fix hooks exist and which have run
  · COW snapshots taken, with their view count over time
  · how many commands ran, and the annotated decisions among them

Then, on demand: the removed modules with their justification, the same list
comma-separated to paste, a diff between any two COW snapshots (through the
existing snapshot_cow_views.py --diff), the decisions, and the last commands.

Computation lives in migration_stats.py as pure functions fed by
resume_context() — the same context the resume screen and its TUI already
render, so the step and version-bump state can never be described two
different ways. read_uninstall is INJECTED rather than reimplemented: it
resolves private-then-global itself, so the screen shows exactly the list that
would be applied.

Verified on a realistic progression: 1 j 03 h elapsed, 312 -> 297 -> 282
modules (-15 each), 5 removals across two bumps with their reasons, 4 COW
snapshots ordered by time, the fix applied for 14.0 and pending for 15.0, and
the progression dict byte-identical afterwards. The menu was checked to map
'' /1/2/3/0/9 to tui/tui/cli/stats/None/tui, and [0] to leave without asking
anything else.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-02 03:16:50 -04:00
parent f2da91b0fe
commit 03ed3a6a87
3 changed files with 468 additions and 5 deletions

View file

@ -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/<base>/`.
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 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

View file

@ -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.",

View file

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