[IMP] migration: show the real state of each step and replay from any of them

The resume menu exposed internal key names: « Reuse database without state_4 »
tells nobody what will happen, and the most useful option -- keep the prepared
database, redo the version bumps -- was the least readable of the four.

It now prints where the migration actually stands: the zip, the database, the
target, and one line per step with its state. Step 4 reports how many version
bumps are migrated and names them, so « 0/6 · 13 14 15 16 17 18 » replaces a
list index nobody could interpret.

The four numbered options become:
  [c]   continue where it stopped        (was: press enter)
  [0-4] replay from that step            (new: rewind to any step)
  [n]   new migration, erase everything  (was: [1])
  [r]   keep the zip, ask everything     (was: [4])

Rewinding drops « state_* » from the chosen step onward and keeps the rest:
config_* answers, the zip and the target version are decisions, not progress,
so they are no longer re-asked. state_0_search_missing_module is still forced
back to False, as the previous code did: it fills an in-memory dict the later
steps rely on. Old [2] is now « replay from 0 », old [3] « replay from 4 ».

First use of t() in this file; the new strings are translated in todo_i18n.py.

Verified against the real progression of the 12.0 -> 18.0 migration and a
synthetic one: for every step, the kept keys are exactly those of the earlier
steps, config and target survive, and the module search is forced again.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mathieu Benoit 2026-08-01 02:53:17 -04:00
parent 96cc39431c
commit a9ef5a0e8e
2 changed files with 239 additions and 91 deletions

View file

@ -2457,6 +2457,76 @@ TRANSLATIONS = {
"fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)", "fr": "QEMU - Exemple dry-run (demo-vm, Ubuntu 24.04)",
"en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)", "en": "QEMU - Sample dry-run (demo-vm, Ubuntu 24.04)",
}, },
# Database migration - resume menu
"Migration in progress": {
"fr": "Migration en cours",
"en": "Migration in progress",
},
"File": {"fr": "Fichier", "en": "File"},
"Database": {"fr": "Base", "en": "Database"},
"Target": {"fr": "Cible", "en": "Target"},
"Started": {"fr": "Démarrée", "en": "Started"},
"Steps": {"fr": "Étapes", "en": "Steps"},
"Prepare the environment": {
"fr": "Préparer l'environnement",
"en": "Prepare the environment",
},
"Restore and neutralize the database": {
"fr": "Restaurer et neutraliser la base",
"en": "Restore and neutralize the database",
},
"Update all addons": {
"fr": "Mettre à jour tous les modules",
"en": "Update all addons",
},
"Clean up before data migration": {
"fr": "Nettoyer avant la migration des données",
"en": "Clean up before data migration",
},
"Upgrade version by version (OpenUpgrade)": {
"fr": "Monter de version en version (OpenUpgrade)",
"en": "Upgrade version by version (OpenUpgrade)",
},
"not started": {"fr": "non démarrée", "en": "not started"},
"done": {"fr": "terminée", "en": "done"},
"partially done": {"fr": "partielle", "en": "partially done"},
"version bumps migrated": {
"fr": "montées de version faites",
"en": "version bumps migrated",
},
"Continue where it stopped": {
"fr": "Continuer là où ça s'est arrêté",
"en": "Continue where it stopped",
},
"Replay from that step": {
"fr": "Reprendre à partir de cette étape",
"en": "Replay from that step",
},
"erases the progression of that step and the next ones": {
"fr": "efface la progression de cette étape et des suivantes",
"en": "erases the progression of that step and the next ones",
},
"New migration, erase everything": {
"fr": "Nouvelle migration, tout effacer",
"en": "New migration, erase everything",
},
"Keep the zip only, ask every question again": {
"fr": "Garder seulement le zip, reposer toutes les questions",
"en": "Keep the zip only, ask every question again",
},
"Your choice": {"fr": "Votre choix", "en": "Your choice"},
"Unknown choice, continuing where it stopped": {
"fr": "Choix inconnu, on continue là où ça s'est arrêté",
"en": "Unknown choice, continuing where it stopped",
},
"Replaying from step": {
"fr": "Reprise à partir de l'étape",
"en": "Replaying from step",
},
"The progression file is invalid, ignoring it": {
"fr": "Le fichier de progression est invalide, on l'ignore",
"en": "The progression file is invalid, ignoring it",
},
} }

View file

@ -16,6 +16,14 @@ import todo_file_browser
from script.todo.version_manager import get_odoo_version from script.todo.version_manager import get_odoo_version
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - fallback when i18n is unavailable
def t(key: str) -> str:
return key
new_path = os.path.normpath( new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..") os.path.join(os.path.dirname(__file__), "..", "..")
) )
@ -50,6 +58,17 @@ LOCAL_MANIFEST = os.path.join(
# be dropped depends on the database, so that choice is never versioned. # be dropped depends on the database, so that choice is never versioned.
PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration") PATH_MIGRATION_GLOBAL = os.path.join("script", "odoo", "migration")
PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration") PATH_MIGRATION_PRIVATE = os.path.join("private", "odoo", "migration")
# Steps of the migration, in order. Each one owns the progression keys prefixed
# with « state_<index> ». Rewinding to a step drops its keys and every later
# one, so the run replays from there. Labels go through t(): the key IS the
# English string, as everywhere else in this project.
MIGRATION_STEP = [
(0, "Prepare the environment"),
(1, "Restore and neutralize the database"),
(2, "Update all addons"),
(3, "Clean up before data migration"),
(4, "Upgrade version by version (OpenUpgrade)"),
]
class TodoUpgrade: class TodoUpgrade:
@ -75,6 +94,149 @@ class TodoUpgrade:
with open(UPGRADE_DATABASE_CONFIG_LOG, "w") as f: with open(UPGRADE_DATABASE_CONFIG_LOG, "w") as f:
json.dump(self.dct_progression, f, indent=4) json.dump(self.dct_progression, f, indent=4)
@staticmethod
def read_progression():
"""Return the saved progression, or an empty dict if unreadable."""
try:
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
return json.load(f)
except (json.decoder.JSONDecodeError, OSError):
print(
f"⚠️ {t('The progression file is invalid, ignoring it')}:"
f" {UPGRADE_DATABASE_CONFIG_LOG}"
)
return {}
@staticmethod
def step_status(dct_progression, step):
"""Return (icon, detail) telling how far a migration step went.
Steps 0 to 3 are plain booleans. Step 4 keeps one list per version, so
its detail reports which version bumps are already migrated.
"""
prefix = f"state_{step}_"
dct_flag = {
key: value
for key, value in dct_progression.items()
if key.startswith(prefix)
}
if not dct_flag:
return "", t("not started")
if step == 4:
lst_done = dct_progression.get("state_4_upgrade_odoo_lst") or []
# The data migration list only appears once a bump succeeds, so the
# number of bumps comes from any per-version list (« *_odoo_lst »).
total = max(
[
len(value)
for key, value in dct_progression.items()
if key.startswith("state_4_")
and key.endswith("_odoo_lst")
and isinstance(value, list)
]
or [0]
)
done = sum(1 for item in lst_done if item)
detail = f"{done}/{total} " + t("version bumps migrated")
# Name the versions when the target is known: the list ends on the
# target, so the first bump is target - total + 1.
try:
last = int(float(dct_progression["target_odoo_version"]))
detail += " · " + " ".join(
f"{last - total + 1 + i}"
f"{'' if i < len(lst_done) and lst_done[i] else ''}"
for i in range(total)
)
except (KeyError, TypeError, ValueError):
pass
return ("" if total and done == total else ""), detail
if all(dct_flag.values()):
return "", t("done")
return "", t("partially done")
def prompt_resume(self, old_dct_progression):
"""Show where the migration stands and ask what to do next.
Returns (progression, changed). The previous menu exposed internal key
names (« Reuse database without state_4 »), which said nothing about
what would actually happen. This shows the real state of every step and
lets the user replay from any of them.
"""
migration_file = old_dct_progression.get("migration_file") or "?"
print()
print(f"📍 {t('Migration in progress')}")
# Pad in code, not in the translations: the labels differ in length
# between languages and a hardcoded padding misaligns the colons.
print(f" {t('File'):<9}: {os.path.basename(migration_file)}")
print(
f" {t('Database'):<9}: "
f"{old_dct_progression.get('config_database_name') or '?'}"
f" · {t('Target')} :"
f" {old_dct_progression.get('target_odoo_version') or '?'}"
)
print(
f" {t('Started'):<9}: "
f"{old_dct_progression.get('date_create')}"
)
print()
print(f" {t('Steps')} :")
for step, label in MIGRATION_STEP:
icon, detail = self.step_status(old_dct_progression, step)
print(f" [{step}] {icon} {t(label):<44} {detail}")
print()
print(f" [c] {t('Continue where it stopped')}")
print(
f" [0-4] {t('Replay from that step')}"
f" ({t('erases the progression of that step and the next ones')})"
)
print(f" [n] {t('New migration, erase everything')}")
print(f" [r] {t('Keep the zip only, ask every question again')}")
answer = input(f"💬 {t('Your choice')} : ").strip().lower()
if answer in ("", "c"):
return old_dct_progression, False
if answer == "n":
return {}, True
if answer == "r":
return {
"migration_file": old_dct_progression.get("migration_file"),
"date_create": old_dct_progression.get("date_create"),
}, True
if answer.isdigit() and 0 <= int(answer) <= MIGRATION_STEP[-1][0]:
return (
self.rewind_progression(old_dct_progression, int(answer)),
True,
)
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
return old_dct_progression, False
@staticmethod
def rewind_progression(old_dct_progression, step):
"""Drop the progression of `step` and of every later step.
Configuration answers (config_*), the zip and the target version are
kept: they are decisions, not progress. Only « state_* » is rewound.
"""
dct_kept = {}
for key, value in old_dct_progression.items():
if not key.startswith("state_"):
dct_kept[key] = value
continue
index = key[len("state_") :].split("_", 1)[0]
if index.isdigit() and int(index) < step:
dct_kept[key] = value
# The module search fills an in-memory dict the later steps rely on;
# it must run again even when step 0 itself is kept.
dct_kept["state_0_search_missing_module"] = False
print(
f"{t('Replaying from step')} {step}"
f" {t(dict(MIGRATION_STEP)[step])}"
)
return dct_kept
def on_file_selected(self, file_path): def on_file_selected(self, file_path):
self.file_path = file_path self.file_path = file_path
todo_file_browser.exit_program() todo_file_browser.exit_program()
@ -407,97 +569,13 @@ class TodoUpgrade:
default_database_name = "test" default_database_name = "test"
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG): if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f: old_dct_progression = self.read_progression()
try: if old_dct_progression:
old_dct_progression = json.load(f) self.dct_progression, changed = self.prompt_resume(
self.dct_progression = { old_dct_progression
"migration_file": old_dct_progression.get( )
"migration_file" if changed:
), self.write_config()
# More useful to ask this question each time
"target_odoo_version": old_dct_progression.get(
"target_odoo_version"
),
"date_create": old_dct_progression.get("date_create"),
}
except json.decoder.JSONDecodeError:
print(
f'⚠️ The config file "{UPGRADE_DATABASE_CONFIG_LOG}" is invalid, ignore it.'
)
print(
f"✨ Detected migration \"{self.dct_progression.get('migration_file')}\" "
f"to version \"{self.dct_progression.get('target_odoo_version')}\", "
f"please select an option."
)
print("[1] Erase progression for a new migration")
print("[2] Reuse database with new process")
print(
"[3] Reuse database without state_4, before looping on next version"
)
print("[4] Reuse database without configuration")
erase_progression_input = (
input("💬 Select an option or press to continue : ")
.strip()
.lower()
)
self.dct_progression = {}
if erase_progression_input in ["2", "3", "4"]:
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
try:
old_dct_progression = json.load(f)
self.dct_progression = {
"migration_file": old_dct_progression.get(
"migration_file"
),
# More useful to ask this question each time
# "target_odoo_version": old_dct_progression.get(
# "target_odoo_version"
# ),
"date_create": old_dct_progression.get(
"date_create"
),
}
for key, value in old_dct_progression.items():
if erase_progression_input == "3":
if (
key.startswith("state_0")
or key.startswith("state_1")
or key.startswith("state_2")
or key.startswith("state_3")
):
self.dct_progression[key] = value
if key.startswith(f"config_state") and not (
key.startswith(f"config_state_0")
or key.startswith(f"config_state_1")
or key.startswith(f"config_state_2")
or key.startswith(f"config_state_3")
):
continue
if (
key.startswith("config_")
and erase_progression_input != "4"
):
self.dct_progression[key] = value
# Force to search missing module to fill dict
self.dct_progression[
"state_0_search_missing_module"
] = False
except json.decoder.JSONDecodeError:
print(
f"⚠️ The config file '{UPGRADE_DATABASE_CONFIG_LOG}' is invalid, ignore it."
)
self.write_config()
elif erase_progression_input not in ["1"]:
with open(UPGRADE_DATABASE_CONFIG_LOG, "r") as f:
try:
self.dct_progression = json.load(f)
except json.decoder.JSONDecodeError:
print(
f"⚠️ The config file '{UPGRADE_DATABASE_CONFIG_LOG}' is invalid, ignore it."
)
if "migration_file" in self.dct_progression: if "migration_file" in self.dct_progression:
self.file_path = self.dct_progression["migration_file"] self.file_path = self.dct_progression["migration_file"]