Integrate claude, improve selenium and support qemu #1

Open
mathben wants to merge 224 commits from develop into master
5 changed files with 360 additions and 25 deletions
Showing only changes of commit d40e6aa011 - Show all commits

View file

@ -0,0 +1,186 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Écran de reprise de migration Odoo, en TUI.
Pendant de `qemu_deploy_form` pour l'outil de migration. Deux interfaces
posent la MÊME première question — « où en est-on, et par où reprend-on ? » —
et renvoient les MÊMES chaînes de réponse (« c », « n », « r », « q »,
« 0 »..« 4 », « 4.<version> ») que `TodoUpgrade.apply_resume_answer` traduit
en progression. La décision est donc écrite une seule fois.
- run_resume_tui(ctx, run_app=True) : renvoie la réponse, ou None pour
retomber sur les invites en ligne.
`ctx` vient de `TodoUpgrade.resume_context()` : pure donnée, aucun accès à la
base ni au disque depuis l'affichage.
"""
from __future__ import annotations
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 version_line(versions):
"""« 13✓ 14✓ 15 16 17 18 » — l'avancement des montées de version."""
return " ".join(
f"{v['version']}{'✓' if v['done'] else ''}" for v in versions
)
def next_version(versions):
"""Première version pas encore migrée : celle que « continuer » reprend."""
for item in versions:
if not item["done"]:
return item["version"]
return None
def run_resume_tui(ctx, run_app: bool = True):
"""Écran de reprise. Renvoie la réponse choisie, ou None si annulé.
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.widgets import (
Button,
DataTable,
Footer,
Header,
OptionList,
Static,
)
from textual.widgets.option_list import Option
result = {"answer": None}
class Resume(App):
CSS = """
#head { height: auto; padding: 0 1; color: $text-muted; }
#steps { height: auto; max-height: 12; border: solid $accent; }
#bumps { height: auto; max-height: 10; border: solid $panel; }
.grouptitle { color: $accent; text-style: bold; padding: 1 1 0 1; }
#actions { height: auto; padding: 1 1 0 1; }
#hint { height: auto; color: $text-muted; padding: 0 1; }
"""
BINDINGS = [
("c", "cont", t("Continue where it stopped")),
("n", "new", t("New migration, erase everything")),
("r", "keep_zip", t("Keep the zip only")),
("q", "quit_nothing", t("Quit without doing anything")),
("escape", "quit_nothing", t("Quit without doing anything")),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static(
f" {t('File'):<9}: {ctx['file']}\n"
f" {t('Database'):<9}: {ctx['database']}"
f" · {t('Target')} : {ctx['target']}\n"
f" {t('Started'):<9}: {ctx['started']}",
id="head",
)
yield Static(f"{t('Steps')}", classes="grouptitle")
yield DataTable(id="steps")
if ctx["versions"]:
yield Static(
f"{t('Version bumps')} ({version_line(ctx['versions'])})",
classes="grouptitle",
)
yield OptionList(id="bumps")
with Vertical():
with Horizontal(id="actions"):
yield Button(
t("Continue where it stopped"),
variant="primary",
id="a_cont",
)
yield Button(t("New migration"), id="a_new")
yield Button(t("Keep the zip only"), id="a_keep")
yield Button(t("Quit"), id="a_quit")
yield Static(
f" {t('Enter on a step or a version = replay from there')}",
id="hint",
)
yield Footer()
def on_mount(self) -> None:
self.title = t("Migration in progress")
table = self.query_one("#steps", DataTable)
table.cursor_type = "row"
table.add_columns("", "", t("Step"), t("Detail"))
for item in ctx["steps"]:
table.add_row(
f"[{item['step']}]",
item["icon"],
item["label"],
item["detail"],
)
# Curseur sur la première étape inachevée : c'est là que ça a
# calé, donc là qu'on veut probablement rejouer.
for index, item in enumerate(ctx["steps"]):
if item["icon"] != "✅":
table.move_cursor(row=index)
break
if ctx["versions"]:
bumps = self.query_one("#bumps", OptionList)
for item in ctx["versions"]:
mark = "✓" if item["done"] else " "
bumps.add_option(
Option(
f" {mark} Odoo {item['version']}.0 — "
f"{t('rebuilds the intermediate database')}",
id=str(item["version"]),
)
)
upcoming = next_version(ctx["versions"])
if upcoming is not None:
bumps.highlighted = [
v["version"] for v in ctx["versions"]
].index(upcoming)
# -- choix ------------------------------------------------------ #
def _answer(self, value):
result["answer"] = value
self.exit()
def on_data_table_row_selected(self, event) -> None:
index = event.cursor_row
if 0 <= index < len(ctx["steps"]):
self._answer(str(ctx["steps"][index]["step"]))
def on_option_list_option_selected(self, event) -> None:
self._answer(f"4.{event.option.id}")
def on_button_pressed(self, event) -> None:
mapping = {
"a_cont": "c",
"a_new": "n",
"a_keep": "r",
"a_quit": "q",
}
value = mapping.get(event.button.id)
if value:
self._answer(value)
def action_cont(self) -> None:
self._answer("c")
def action_new(self) -> None:
self._answer("n")
def action_keep_zip(self) -> None:
self._answer("r")
def action_quit_nothing(self) -> None:
self._answer("q")
app = Resume()
app._result = result # lecture par les tests headless
if not run_app:
return app
app.run()
return result["answer"]

View file

@ -642,6 +642,14 @@ class TODO:
("tui", "TUI, collapsible blocks per VM"),
),
),
"migration_ui": (
"Odoo migration interface",
(
("ask", "Ask every time"),
("tui", "TUI form"),
("cli", "Classic questions (line by line)"),
),
),
}
def _pref_label(self, key):
@ -690,6 +698,12 @@ class TODO:
f"({self._pref_label('qemu_deploy_progress')})"
)
},
{
"prompt_description": (
f"{t('Odoo migration interface')} "
f"({self._pref_label('migration_ui')})"
)
},
{"section": t("Maintenance")},
{"prompt_description": t("Reset all preferences")},
]
@ -704,6 +718,8 @@ class TODO:
elif status == "3":
self._pref_edit("qemu_deploy_progress")
elif status == "4":
self._pref_edit("migration_ui")
elif status == "5":
n = todo_prefs.reset()
print(f"✅ {t('Preferences reset')} ({n})")
else:

View file

@ -1399,6 +1399,42 @@ TRANSLATIONS = {
"fr": "Choix (numéro, vide = garder) :",
"en": "Choice (number, blank = keep):",
},
"Odoo migration interface": {
"fr": "🚚 Interface de la migration Odoo",
"en": "🚚 Odoo migration interface",
},
"No migration in progress to resume.": {
"fr": "Aucune migration en cours à reprendre.",
"en": "No migration in progress to resume.",
},
"Quit without doing anything": {
"fr": "Quitter sans rien faire",
"en": "Quit without doing anything",
},
"Version bumps": {
"fr": "Montées de version",
"en": "Version bumps",
},
"Step": {
"fr": "Étape",
"en": "Step",
},
"Detail": {
"fr": "Détail",
"en": "Detail",
},
"Enter on a step or a version = replay from there": {
"fr": "Entrée sur une étape ou une version = rejouer depuis là",
"en": "Enter on a step or a version = replay from there",
},
"New migration": {
"fr": "Nouvelle migration",
"en": "New migration",
},
"Keep the zip only": {
"fr": "Garder seulement le zip",
"en": "Keep the zip only",
},
"Reset all preferences": {
"fr": "🧹 Réinitialiser toutes les préférences",
"en": "🧹 Reset all preferences",

View file

@ -28,6 +28,8 @@ DEFAULTS = {
# Affichage pendant le déploiement : "cli" (sortie texte, facile à copier
# depuis le terminal) ou "tui" (blocs repliables + copie OSC 52).
"qemu_deploy_progress": "cli",
# Interface de la migration Odoo : "ask" / "tui" / "cli".
"migration_ui": "ask",
}

View file

@ -156,54 +156,91 @@ class TodoUpgrade:
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.
def resume_context(self, old_dct_progression):
"""Everything the resume screen shows, as plain data.
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.
No I/O: the line-by-line prompt and the TUI both render THIS, so the
two can never describe the migration differently.
"""
migration_file = old_dct_progression.get("migration_file") or "?"
steps = []
for step, label in MIGRATION_STEP:
icon, detail = self.step_status(old_dct_progression, step)
steps.append(
{
"step": step,
"icon": icon,
"label": t(label),
"detail": detail,
}
)
lst_version = self.version_bumps(old_dct_progression)
done = old_dct_progression.get("state_4_upgrade_odoo_lst") or []
return {
"file": os.path.basename(migration_file),
"database": old_dct_progression.get("config_database_name") or "?",
"target": old_dct_progression.get("target_odoo_version") or "?",
"started": old_dct_progression.get("date_create") or "?",
"steps": steps,
"versions": [
{
"version": version,
"done": bool(i < len(done) and done[i]),
}
for i, version in enumerate(lst_version)
],
}
@staticmethod
def print_resume(ctx):
"""Render the resume screen on the terminal."""
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('File'):<9}: {ctx['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')}"
f" {t('Database'):<9}: {ctx['database']}"
f" · {t('Target')} : {ctx['target']}"
)
print(f" {t('Started'):<9}: {ctx['started']}")
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}")
for item in ctx["steps"]:
print(
f" [{item['step']}] {item['icon']} "
f"{item['label']:<44} {item['detail']}"
)
print()
lst_version = self.version_bumps(old_dct_progression)
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')})"
)
if lst_version:
if ctx["versions"]:
versions = "/".join(str(v["version"]) for v in ctx["versions"])
print(
f" [4.N] {t('Replay the upgrade from version N')}"
f" ({'/'.join(str(v) for v in lst_version)}) —"
f" ({versions}) —"
f" {t('rebuilds the intermediate database')}"
)
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()
print(f" [q] {t('Quit without doing anything')}")
def apply_resume_answer(self, old_dct_progression, answer, ctx):
"""Turn the answer into (progression, changed), or None to quit.
THE decision point, shared by both interfaces: the TUI returns the
same answer strings as the prompt, so this logic is written once.
"""
answer = (answer or "").strip().lower()
lst_version = [v["version"] for v in ctx["versions"]]
if answer in ("", "c"):
return old_dct_progression, False
if answer == "q":
return None
if answer == "n":
return {}, True
if answer == "r":
@ -231,6 +268,57 @@ class TodoUpgrade:
print(f"⚠️ {t('Unknown choice, continuing where it stopped')}.")
return old_dct_progression, False
def prompt_resume(self, old_dct_progression, use_tui=False):
"""Show where the migration stands and ask what to do next.
Returns (progression, changed), or None if the user quits. The old
menu exposed internal key names (« Reuse database without state_4 »),
which said nothing about what would happen; this shows the real state
of every step and lets the user replay from any of them.
"""
ctx = self.resume_context(old_dct_progression)
answer = None
if use_tui:
answer = self.resume_tui(ctx)
if answer is None:
self.print_resume(ctx)
answer = input(f"💬 {t('Your choice')} : ")
return self.apply_resume_answer(old_dct_progression, answer, ctx)
@staticmethod
def ask_ui():
"""Interface of the migration: TUI or line-by-line prompts.
The preference can settle it in advance (TODO > Configuration);
« ask » asks. Same contract as the QEMU deployment.
"""
try:
from script.todo import todo_prefs
pref = todo_prefs.get("migration_ui")
except Exception:
pref = "ask"
if pref in ("tui", "cli"):
return pref
print(f"\n{t('Interface:')}")
print(f" [1] {t('TUI form')} *")
print(f" [2] {t('Classic questions (line by line)')}")
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"
@staticmethod
def resume_tui(ctx):
"""Resume screen as a TUI. Returns the SAME answer strings as the
prompt, or None when textual is missing (fall back to the prompt)."""
try:
from script.todo.migration_form import run_resume_tui
return run_resume_tui(ctx)
except ImportError:
print(t("Install textual for the dashboard (pip)."))
return None
@staticmethod
def version_bumps(dct_progression):
"""Odoo versions the step 4 loop walks through, e.g. [13, 14, ..., 18].
@ -634,14 +722,21 @@ class TodoUpgrade:
self.dct_module_per_dct_version_path = {}
default_database_name = "test"
use_tui = self.ask_ui() == "tui"
if os.path.exists(UPGRADE_DATABASE_CONFIG_LOG):
old_dct_progression = self.read_progression()
if old_dct_progression:
self.dct_progression, changed = self.prompt_resume(
old_dct_progression
)
resumed = self.prompt_resume(old_dct_progression, use_tui)
if resumed is None:
return
self.dct_progression, changed = resumed
if changed:
self.write_config()
elif use_tui:
print(f"ℹ️ {t('No migration in progress to resume.')}")
elif use_tui:
print(f"ℹ️ {t('No migration in progress to resume.')}")
if "migration_file" in self.dct_progression:
self.file_path = self.dct_progression["migration_file"]