erplibre/script/todo/migration_stats.py
Mathieu Benoit b28b734338 [ADD] migration: see and repair the website COW views
A copy-on-write view freezes the module view it came from; the module moves
on and the upgrade dies hours later on a missing anchor. These tools
predict, snapshot, diff, neutralize and reset them. The migration screen
gains the real state of each step, replay from any of them, and statistics.

--- FR ---

Une vue copy-on-write fige la vue de module dont elle vient ; le module
évolue et la mise à niveau meurt des heures plus tard sur un point
d'ancrage absent. Ces outils les prévoient, photographient, comparent,
neutralisent et réinitialisent. L'écran de migration gagne l'état réel de
chaque étape, la reprise depuis n'importe laquelle, et des statistiques.

Assisted-by: Claude Opus 5
2026-08-10 03:10:50 -04:00

201 lines
6.5 KiB
Python

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