diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py index 1937215..94cafff 100644 --- a/script/todo/deploy_form_lib.py +++ b/script/todo/deploy_form_lib.py @@ -395,6 +395,9 @@ CSS_BASE = """ } #totals { height: auto; color: $text-muted; padding: 0 1; } .grouptitle { color: $accent; text-style: bold; padding: 1 0 0 0; } +/* Une section que le choix d'installation rend inerte : le titre pâlit avec +ses champs, pour qu'elle se lise inactive d'un coup d'œil. */ +.grouptitle.off { color: $text-muted; text-style: none; } .freeval { display: none; width: 9; } /* « width: auto » sur la CARTE, pas seulement sur la rangée. Un conteneur Textual vaut « width: 1fr » par défaut : la carte se bornait donc au panneau, diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 3815d21..dd227c1 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -277,9 +277,19 @@ def run_proxmox_form(ctx, run_app: bool = True): value=True, id="f_sshcfg", ) - yield Static("ERPLibre", classes="grouptitle") + # La case commande TOUTE installation — ERPLibre, Odoo, + # mais aussi l'hyperviseur Proxmox VE d'une VM imbriquée. + # Nommée « ERPLibre », elle laissait croire qu'un système + # Proxmox s'installerait quand même. + yield Static( + t("Installation"), + id="t_install", + classes="grouptitle", + ) yield Checkbox( - t("Install ERPLibre"), value=True, id="f_install" + t("Install software in the VM"), + value=True, + id="f_install", ) yield Select( [(lbl, i) for i, (lbl, _c) in enumerate(profiles)], @@ -293,12 +303,19 @@ def run_proxmox_form(ctx, run_app: bool = True): allow_blank=False, id="f_branch", ) + # Hors de la section « Installation » : le suivi regarde la + # VM ARRIVER, même quand rien ne s'installe. Rangé dedans, + # il se serait grisé avec elle. + yield Static( + t("Monitoring and parallelism"), + classes="grouptitle", + ) yield Checkbox( t("Follow the installation (dashboard)"), value=True, id="f_monitor", ) - yield Static(t("Parallelism"), classes="grouptitle") + yield Static(f" {t('Parallelism')}") yield Select( [(str(n), n) for n in (1, 2, 3, 4)], value=1, @@ -316,6 +333,7 @@ def run_proxmox_form(ctx, run_app: bool = True): def on_mount(self) -> None: self._reload_catalog() + self._sync_install_deps() # ---------------------------------------------------------------- # # Le plan @@ -539,9 +557,26 @@ def run_proxmox_form(ctx, run_app: bool = True): self._clear_overrides(tuple(RES_FIELDS)) self._refresh_after(remonter=True) + def _sync_install_deps(self) -> None: + """Grise ce que la case rend sans effet : la branche et le profil. + + Le suivi n'en fait pas partie — il regarde la VM arriver même + quand rien ne s'installe.""" + installe = self.query_one("#f_install", Checkbox).value + for cible in ("#f_profile_install", "#f_branch"): + try: + self.query_one(cible).disabled = not installe + except Exception: + pass + try: + self.query_one("#t_install").set_class(not installe, "off") + except Exception: + pass + def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": # Le disque d'ERPLibre entre — ou sort — du total. + self._sync_install_deps() self._refresh_after() def on_input_changed(self, event) -> None: diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 399961d..270d0b3 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -866,11 +866,11 @@ class QemuDeployMixin: br_txt = used_br.pop() if len(used_br) == 1 else varies lb_txt = used_lb.pop() if len(used_lb) == 1 else varies print( - f" {t('ERPLibre install:')} {t('branch')} {br_txt}, " + f" {t('Install:')} {t('branch')} {br_txt}, " f"{t('profile')} {lb_txt}, {env}" ) else: - print(f" {t('ERPLibre install:')} {t('no')}") + print(f" {t('Install:')} {t('no')}") flavour = spec.get("desktop") if flavour: label = (self._QEMU_DESKTOP.get(flavour) or {}).get( @@ -1288,6 +1288,13 @@ class QemuDeployMixin: "vm_tool_desktops": { k: v["desktops"] for k, v in self._QEMU_VM_TOOLS.items() }, + # « after » = l'outil vit DANS le dépôt ERPLibre (compilation + # mobile, AVD, script Forgejo) : sans installation, il n'existe + # pas, et la commande distante le saute en le nommant. + "vm_tool_phases": { + k: v.get("phase", "before") + for k, v in self._QEMU_VM_TOOLS.items() + }, "vm_tool_needs_desktop": { k: v["needs_desktop"] for k, v in self._QEMU_VM_TOOLS.items() }, diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index 4fe3b15..71b9e1d 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -134,6 +134,9 @@ def run_deploy_form(ctx, run_app: bool = True): # que les afficher et rendre les cases cochées. vm_tools = list(ctx.get("vm_tools") or []) tool_disk = dict(ctx.get("vm_tool_disk") or {}) + # « after » = l'outil vit DANS le dépôt ERPLibre : sans installation, il + # n'a rien où s'installer, bureau ou pas. + tool_phases = dict(ctx.get("vm_tool_phases") or {}) tool_arches = dict(ctx.get("vm_tool_arches") or {}) tool_desktops = dict(ctx.get("vm_tool_desktops") or {}) tool_needs_desktop = dict(ctx.get("vm_tool_needs_desktop") or {}) @@ -311,31 +314,19 @@ def run_deploy_form(ctx, run_app: bool = True): f"{t('Graphical (server + desktop):')} {label}", value=defaults.get("desktop", "") == key, ) - if app_stores: - yield Static( - t("Application store:"), classes="grouptitle" - ) - with RadioSet(id="f_store"): - for i, (_k, label) in enumerate(app_stores): - yield RadioButton(label, value=i == 0) - yield Static("", id="storewarn") - if vm_tools: - # Une case par outil, et non une liste déroulante : ils - # sont indépendants, et chacun se prend ou se laisse. - yield Static( - t("Development tools:"), classes="grouptitle" - ) - for key, label, hint in vm_tools: - gb = tool_disk.get(key, 0) - yield Checkbox( - f"{label} +{gb} Go — {hint}", - value=key in (defaults.get("tools") or ()), - id=f"f_tool_{key}", - ) - yield Static("", id="toolwarn") - yield Static("ERPLibre", classes="grouptitle") + # La case commande TOUTE installation — ERPLibre, Odoo, mais + # aussi l'hyperviseur Proxmox VE. Nommée « ERPLibre », elle + # laissait croire qu'un système Proxmox s'installerait + # quand même : rapporté, une VM Proxmox est restée une + # Debian nue. Placée SOUS le type de VM, juste avant les + # sections qu'elle commande. + yield Static( + t("Installation"), + id="t_install", + classes="grouptitle", + ) yield Checkbox( - t("Install ERPLibre"), + t("Install software in the VM"), value=defaults.get("install", True), id="f_install", ) @@ -358,11 +349,32 @@ def run_deploy_form(ctx, run_app: bool = True): value=defaults.get("prod", False), id="f_prod", ) - yield Checkbox( - t("Monitoring dashboard"), - value=defaults.get("monitor", True), - id="f_monitor", - ) + if app_stores: + yield Static( + t("Application store:"), + id="t_store", + classes="grouptitle", + ) + with RadioSet(id="f_store"): + for i, (_k, label) in enumerate(app_stores): + yield RadioButton(label, value=i == 0) + yield Static("", id="storewarn") + if vm_tools: + # Une case par outil, et non une liste déroulante : ils + # sont indépendants, et chacun se prend ou se laisse. + yield Static( + t("Development tools:"), + id="t_tools", + classes="grouptitle", + ) + for key, label, hint in vm_tools: + gb = tool_disk.get(key, 0) + yield Checkbox( + f"{label} +{gb} Go — {hint}", + value=key in (defaults.get("tools") or ()), + id=f"f_tool_{key}", + ) + yield Static("", id="toolwarn") yield Static(t("Timezone"), classes="grouptitle") # Une liste plutôt qu'une saisie : un nom IANA mal # orthographié n'est pas refusé par cloud-init, il est @@ -397,7 +409,9 @@ def run_deploy_form(ctx, run_app: bool = True): # Grisé quand AUCUNE des VM retenues n'est sur une # architecture que mise sert. yield Static( - t("Python interpreter:"), classes="grouptitle" + t("Python interpreter:"), + id="t_python", + classes="grouptitle", ) with RadioSet(id="f_python"): yield RadioButton( @@ -405,7 +419,25 @@ def run_deploy_form(ctx, run_app: bool = True): ) yield RadioButton(t("pyenv (compiles from source)")) yield Static("", id="miswarn") - yield Static(t("Parallelism"), classes="grouptitle") + # Hors de la section « Installation » : le suivi + # regarde la VM ARRIVER, même quand rien ne s'installe. + # Rangé dans cette section, il se serait grisé avec elle — + # et décocher ERPLibre avait déjà fait disparaître le + # tableau de bord une fois. + yield Static( + t("Monitoring and parallelism"), + id="t_deploy", + classes="grouptitle", + ) + yield Checkbox( + t("Monitoring dashboard"), + value=defaults.get("monitor", True), + id="f_monitor", + ) + # Le parallélisme reste dans « Déploiement » : c'est le + # nombre de VM menées de front, pas une option + # d'installation. + yield Static(f" {t('Parallelism')}") # Cochée, la case donne une exécution PAR installation : # le plafond du nombre de CPU ne s'applique plus. Décochée, # le nombre reprend la main, et son défaut suit l'hôte — @@ -433,6 +465,7 @@ def run_deploy_form(ctx, run_app: bool = True): def on_mount(self) -> None: self.title = t("Deploy ERPLibre VM(s)!") self._reload_catalog(first_load=True) + self._sync_install_deps() # -- catalogue et recalcul ------------------------------------- # def _entries(self): @@ -629,10 +662,16 @@ def run_deploy_form(ctx, run_app: bool = True): évite de le découvrir dans le journal d'installation.""" if not vm_tools: return + installe, quelque_chose = self._install_state() for key, _label, _hint in vm_tools: usable = any(self._tools_for_vm(vm, (key,)) for vm in self.vms) - self.query_one(f"#f_tool_{key}", Checkbox).disabled = ( - not usable + offert = ( + installe + if tool_phases.get(key) == "after" + else quelque_chose + ) + self.query_one(f"#f_tool_{key}", Checkbox).disabled = not ( + usable and offert ) picked = self._vm_tools() skipped = sorted( @@ -654,7 +693,10 @@ def run_deploy_form(ctx, run_app: bool = True): """Grise le choix quand aucune VM retenue n'est servie par mise, et nomme les architectures qui retomberont sur pyenv.""" usable = self._mise_usable() - self.query_one("#f_python", RadioSet).disabled = not usable + installe, _quelque_chose = self._install_state() + self.query_one("#f_python", RadioSet).disabled = not ( + usable and installe + ) skipped = sorted( { vm["arch"] @@ -707,13 +749,25 @@ def run_deploy_form(ctx, run_app: bool = True): if not app_stores: return needed = self._app_store_needed() - self.query_one("#f_store", RadioSet).disabled = not needed + _installe, quelque_chose = self._install_state() + self.query_one("#f_store", RadioSet).disabled = not ( + needed and quelque_chose + ) self.query_one("#storewarn", Static).update( "" if needed else f" {t('No graphical VM on a snap-based distro.')}" ) + def _install_state(self): + """(une installation ?, quelque chose à installer ?). + + Deux réponses et non une : sans installation mais avec un bureau, + il se pose encore des paquets — le magasin d'applications et les + outils de la phase « avant » gardent un effet.""" + installe = self.query_one("#f_install", Checkbox).value + return installe, bool(installe or self._default_desktop()) + def _mise_usable(self): return any(vm["arch"] in mise_arches for vm in self.vms) @@ -893,6 +947,7 @@ def run_deploy_form(ctx, run_app: bool = True): self.call_after_refresh(self._after_mount_rows) def _after_mount_rows(self) -> None: + self._sync_install_deps() self._sync_free_inputs() self._syncing = False @@ -941,6 +996,52 @@ def run_deploy_form(ctx, run_app: bool = True): pass self._sync_free_inputs() + def _sync_install_deps(self) -> None: + """Grise ce que le choix d'installation rend SANS EFFET. + + Trois états et non deux, parce que la commande distante en a + trois : rien du tout, un bureau seul, ou une installation + complète. Sans installation MAIS avec un bureau, le magasin + d'applications et les outils de la phase « avant » servent encore + — les griser mentirait autant que de laisser actif ce qui ne fait + rien. La branche, le profil et l'interpréteur Python, eux, ne + servent qu'à l'installation. + + Le type de VM et le suivi ne sont jamais grisés : le premier est + l'autre moitié de la décision, le second regarde la VM arriver + même quand rien ne s'installe.""" + installe, quelque_chose = self._install_state() + for cible, actif in ( + ("#f_branch", installe), + ("#f_profile_install", installe), + ("#f_prod", quelque_chose), + ): + try: + self.query_one(cible).disabled = not actif + except Exception: + pass + # Le magasin, les outils et l'interpréteur Python ont leur PROPRE + # raison de se griser (architecture, bureau, distribution) : ils + # composent les deux dans « _render_* », qui a le dernier mot. + # Le titre suit ses champs : une section entière se lit inactive + # d'un coup d'œil, au lieu de se déduire de trois widgets ternes. + for cible, actif in ( + ("#t_store", quelque_chose), + ("#t_tools", quelque_chose), + ("#t_python", installe), + ): + try: + self.query_one(cible).set_class(not actif, "off") + except Exception: + pass + # Les rangées portent les mêmes choix, par VM. + for i in range(len(self.rows)): + for cible in (f"#v{i}_branch", f"#v{i}_prof"): + try: + self.query_one(cible).disabled = not installe + except Exception: + pass + def _render_plan(self): # Le JEU de VM a-t-il changé ? Si oui on remonte les widgets, sinon # on se contente des titres : remonter à chaque frappe volerait le @@ -1007,6 +1108,9 @@ def run_deploy_form(ctx, run_app: bool = True): self._reload_catalog() elif event.radio_set.id == "f_type": self._clear_overrides(("desktop",)) + # Le type est l'autre moitié de la décision : un bureau seul + # garde le magasin d'applications et les outils utiles. + self._sync_install_deps() # Recalcul : le disque annonce inclut le bureau, et la # colonne Statut affiche le type de VM. self._recompute() @@ -1205,6 +1309,7 @@ def run_deploy_form(ctx, run_app: bool = True): def on_checkbox_changed(self, event) -> None: if event.checkbox.id == "f_install": + self._sync_install_deps() self._recompute() # le disque annoncé inclut le +5 G ERPLibre elif event.checkbox.id == "f_par_all": self.query_one("#f_par", Select).disabled = event.value diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0acd13e..63a1a1f 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4118,9 +4118,23 @@ TRANSLATIONS = { "fr": "Existantes, laissées intactes :", "en": "Existing, left untouched:", }, - "ERPLibre install:": { - "fr": "Installation ERPLibre :", - "en": "ERPLibre install:", + # Renommée : la case commande TOUTE installation — ERPLibre, Odoo, mais + # aussi l'hyperviseur Proxmox VE, qui n'installe ni l'un ni l'autre. + "Install:": { + "fr": "Installation :", + "en": "Install:", + }, + "Install software in the VM": { + "fr": "Installer un logiciel dans la VM", + "en": "Install software in the VM", + }, + "Installation": { + "fr": "Installation", + "en": "Installation", + }, + "Monitoring and parallelism": { + "fr": "Suivi et parallélisme", + "en": "Monitoring and parallelism", }, "profile": { "fr": "profil", diff --git a/test/test_qemu_deploy_form_install.py b/test/test_qemu_deploy_form_install.py new file mode 100644 index 0000000..6f78063 --- /dev/null +++ b/test/test_qemu_deploy_form_install.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""La case d'installation : son nom, sa place, et ce qu'elle grise. + +Elle s'appelait « Installer ERPLibre » alors qu'elle commande TOUTE +installation — l'hyperviseur Proxmox VE compris. Rapporté après coup : une VM +Proxmox déployée avec la case décochée est restée une Debian nue, et rien ne +disait que la case l'expliquait. + +Elle est donc renommée, placée juste sous le type de VM (les sections qu'elle +commande viennent après), et ce qu'elle rend sans effet se grise. Trois états +et non deux : sans installation mais avec un bureau, il se pose encore des +paquets — griser le magasin d'applications mentirait autant que de laisser +actif ce qui ne fait rien. +""" + +import asyncio +import re +import sys +import unittest +from pathlib import Path + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +FORM = ( + Path(__file__).resolve().parent.parent / "script/todo/qemu_deploy_form.py" +) + +try: + import textual # noqa: F401 + + TEXTUAL = True +except Exception: # pragma: no cover - dépend de l'environnement + TEXTUAL = False + + +class TestSaPlace(unittest.TestCase): + """Lu dans la source : l'ordre du panneau est une décision, pas un hasard.""" + + @classmethod + def setUpClass(cls): + cls.src = FORM.read_text(encoding="utf-8") + + def _rang(self, motif): + m = re.search(re.escape(motif), self.src) + self.assertIsNotNone(m, motif) + return m.start() + + def test_the_install_section_sits_under_the_vm_type(self): + self.assertLess( + self._rang('with RadioSet(id="f_type")'), + self._rang('id="t_install"'), + ) + + def test_and_before_the_sections_it_commands(self): + # Magasin d'applications, outils : ils dépendent d'elle, donc ils + # viennent après. + for apres in ('id="t_store"', 'id="t_tools"'): + self.assertLess(self._rang('id="t_install"'), self._rang(apres)) + + def test_the_checkbox_no_longer_claims_to_be_about_erplibre(self): + self.assertIn('t("Install software in the VM")', self.src) + self.assertNotIn('t("Install ERPLibre")', self.src) + + def test_the_monitor_left_the_install_section(self): + # Rangé dedans, il se serait grisé avec elle — et décocher ERPLibre + # avait déjà fait disparaître le tableau de bord une fois. + self.assertLess( + self._rang('id="t_deploy"'), self._rang('id="f_monitor"') + ) + + +def contexte(): + todo = TODO.__new__(TODO) + mod = todo._qemu_import_module() + todo._qemu_list_domains = lambda: [] + todo._qemu_branch_list = lambda: ["develop", "master"] + return todo._qemu_form_context(mod) + + +@unittest.skipUnless(TEXTUAL, "Textual absent") +class TestCeQuElleGrise(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.ctx = contexte() + + def _etats(self): + """Relève l'état des champs dans les trois situations.""" + from script.todo.qemu_deploy_form import run_deploy_form + + vu = {} + + async def scenario(): + from textual.widgets import Checkbox, SelectionList + + app = run_deploy_form(self.ctx, run_app=False) + async with app.run_test(size=(200, 60)) as pilote: + await pilote.pause() + liste = app.query_one(SelectionList) + liste.select(liste.get_option_at_index(0).value) + await pilote.pause() + await pilote.pause() + + def relever(): + etat = {} + for cible in ( + "f_branch", + "f_profile_install", + "f_python", + "f_prod", + "f_store", + "f_type", + "f_monitor", + "v0_branch", + "v0_prof", + ): + try: + etat[cible] = app.query_one(f"#{cible}").disabled + except Exception: + etat[cible] = None + etat["outils"] = { + w.id[7:]: w.disabled + for w in app.query(Checkbox) + if str(w.id or "").startswith("f_tool_") + } + etat["titres"] = { + w.id + for w in app.query("#fields Static") + if "off" in w.classes + } + return etat + + vu["installe"] = relever() + app.query_one("#f_install", Checkbox).value = False + await pilote.pause() + await pilote.pause() + vu["rien"] = relever() + list(app.query("#f_type RadioButton"))[1].value = True + await pilote.pause() + await pilote.pause() + vu["bureau_seul"] = relever() + # Retour au SERVEUR avant de recocher : le magasin + # d'applications a sa propre raison de se griser (aucune VM + # graphique sur une distribution à snap), et comparer deux + # états de type différent ne dirait rien de la case. + list(app.query("#f_type RadioButton"))[0].value = True + app.query_one("#f_install", Checkbox).value = True + await pilote.pause() + await pilote.pause() + vu["recoche"] = relever() + + asyncio.run(scenario()) + return vu + + @classmethod + def etats(cls): + if not hasattr(cls, "_vu"): + cls._vu = cls()._etats() + return cls._vu + + def test_the_branch_and_the_profile_only_serve_an_install(self): + vu = self.etats() + for champ in ("f_branch", "f_profile_install", "v0_branch", "v0_prof"): + self.assertFalse(vu["installe"][champ], champ) + self.assertTrue(vu["rien"][champ], champ) + # Même avec un bureau : ils ne servent QU'à ERPLibre. + self.assertTrue(vu["bureau_seul"][champ], champ) + + def test_a_desktop_only_install_keeps_what_it_really_uses(self): + # Le magasin d'applications et « production » servent encore : la + # commande distante pose des paquets et coupe les mises à jour. + vu = self.etats() + self.assertTrue(vu["rien"]["f_store"]) + self.assertFalse(vu["bureau_seul"]["f_store"]) + self.assertTrue(vu["rien"]["f_prod"]) + self.assertFalse(vu["bureau_seul"]["f_prod"]) + + def test_the_tools_that_live_in_the_repository_need_the_install(self): + # « after » = dans le dépôt ERPLibre. Sans installation, ils n'ont + # rien où s'installer, bureau ou pas — la commande distante les saute. + vu = self.etats() + phases = self.ctx["vm_tool_phases"] + apres = [k for k, v in phases.items() if v == "after"] + avant = [k for k, v in phases.items() if v != "after"] + self.assertTrue(apres and avant, phases) + for k in apres: + self.assertTrue(vu["bureau_seul"]["outils"][k], k) + for k in avant: + self.assertFalse(vu["bureau_seul"]["outils"][k], k) + for k in phases: + self.assertTrue(vu["rien"]["outils"][k], k) + + def test_the_vm_type_and_the_dashboard_are_never_greyed(self): + # Le type est l'AUTRE moitié de la décision ; le suivi regarde la VM + # arriver même quand rien ne s'installe. + vu = self.etats() + for cas in ("installe", "rien", "bureau_seul"): + self.assertFalse(vu[cas]["f_type"], cas) + self.assertFalse(vu[cas]["f_monitor"], cas) + + def test_a_greyed_section_reads_as_inactive(self): + vu = self.etats() + self.assertEqual(vu["installe"]["titres"], set()) + self.assertIn("t_store", vu["rien"]["titres"]) + self.assertIn("t_tools", vu["rien"]["titres"]) + + def test_ticking_it_back_restores_everything(self): + vu = self.etats() + for champ, valeur in vu["recoche"].items(): + if champ in ("outils", "titres"): + continue + self.assertEqual(valeur, vu["installe"][champ], champ) + + +if __name__ == "__main__": + unittest.main(verbosity=2)