[UPD] todo : nommer la case d'installation par ce qu'elle commande

« Installer ERPLibre » commandait TOUTE installation, l'hyperviseur Proxmox
VE compris : décochée, une VM Proxmox restait une Debian nue sans que rien
ne l'explique. Elle devient « Installer un logiciel dans la VM », passe sous
le type de VM — juste avant les sections qu'elle commande — et le suivi la
quitte, puisqu'il regarde la VM arriver même quand rien ne s'installe.

Ce qu'elle rend sans effet se grise, titre compris. Trois états et non deux :
sans installation mais AVEC un bureau, le magasin d'applications et les
outils de la phase « avant » servent encore ; seuls ceux qui vivent dans le
dépôt s'éteignent. Griser en bloc aurait menti autant que de tout laisser.

--- EN ---

"Install ERPLibre" commanded EVERY install, the Proxmox VE hypervisor
included: unticked, a Proxmox VM stayed a bare Debian with nothing to explain
it. It becomes "Install software in the VM", moves under the VM type — right
before the sections it commands — and the dashboard leaves it, since that
watches the VM arrive even when nothing is installed.

What it makes ineffective now greys out, title included. Three states, not
two: with no install but WITH a desktop, the application store and the
"before" tools still act; only those living in the repository go dark.
Greying the lot would have lied as much as greying nothing.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 02:32:09 -04:00
parent 009ee6e2f6
commit 677b0e6529
6 changed files with 425 additions and 43 deletions

View file

@ -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,

View file

@ -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:

View file

@ -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()
},

View file

@ -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

View file

@ -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",

View file

@ -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)