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
201 lines
6.5 KiB
Python
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
|