[ADD] analyse: offer to install the suggested modules that are ready

After the report, only the "available" ones are offered — an unknown
module is not in the addons path and an uninstallable one has a broken
dependency, so listing them would buy three failures. How many were
left out is stated, else the count would look like a bug.

This is the only write in the Analyse menu, so its header no longer
claims otherwise. Three guards: the checkout must match the database
version (an Odoo 18 run against a 12 rewrites it before failing), both
questions default to no, and a rejected token is always shown.

--- FR ---

Après le rapport, seuls les « available » sont proposés — un module
inconnu n'est pas dans le chemin des addons, un cassé a une dépendance
morte : les lister achèterait trois échecs. Le nombre d'écartés est dit,
sinon l'écart de comptage passerait pour un bogue.

C'est la seule écriture du menu Analyse, dont l'en-tête ne prétend donc
plus le contraire. Trois garde-fous : le checkout doit être sur la
version de la base (un Odoo 18 lancé sur une 12 la réécrit avant
d'échouer), les deux questions valent non par défaut, et un jeton
refusé est toujours montré.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-21 04:31:36 -04:00
parent 50366673c2
commit 4b092c04d2
4 changed files with 456 additions and 5 deletions

View file

@ -356,6 +356,54 @@ def missing(rapport):
)
def installable(rapport):
"""Les modules qu'on peut RÉELLEMENT installer, dans l'ordre affiché.
Seuls les « available » : la base les connaît et ils attendent. Un
« unknown » n'est pas dans le chemin des addons — l'installer échoue
avant de commencer ; un « uninstallable » a une dépendance cassée
qu'aucune installation ne contournera ; un « pending » est déjà en
route. Les proposer ferait une liste plus longue et trois échecs.
"""
return [
ligne["module"]
for ligne in missing(rapport)
if ligne["verdict"] == "available"
]
def parse_selection(answer, candidates):
"""(choisis, jetons refusés) d'après « 1 3 5 », « a », ou rien.
Les jetons refusés sont RENDUS, jamais avalés : demander cinq modules
et en recevoir quatre sans que rien ne le dise est la pire issue —
on croit l'installation complète. L'appelant doit pouvoir le montrer.
La virgule vaut l'espace : les listes affichées ailleurs par l'outil
sont séparées par des virgules, et refuser « 1,3 » ne protégerait de
rien tout en obligeant à retaper.
"""
reponse = (answer or "").strip()
if not reponse:
return [], []
if reponse.lower() in ("a", "all"):
return list(candidates), []
choisis, refuses, vus = [], [], set()
for jeton in reponse.replace(",", " ").split():
if not jeton.isdigit():
refuses.append(jeton)
continue
rang = int(jeton)
if not 1 <= rang <= len(candidates):
refuses.append(jeton)
continue
nom = candidates[rang - 1]
if nom not in vus:
vus.add(nom)
choisis.append(nom)
return choisis, refuses
def render(rapport, limit=0):
"""Le rapport, en clair. `limit` borne les listes longues (0 = tout)."""
if rapport.get("unavailable"):

View file

@ -10166,13 +10166,18 @@ class TODO:
print(t("Command not found !"))
def prompt_execute_analyse(self):
"""Analyses en lecture seule d'une base Odoo.
"""Analyses d'une base Odoo, en lecture seule.
Aucune entrée de ce menu n'écrit : la connexion psql est ouverte avec
`default_transaction_read_only=on`, donc c'est le serveur qui refuse
toute écriture, pas une promesse du code.
Toute LECTURE passe par une connexion psql ouverte avec
`default_transaction_read_only=on` : c'est le serveur qui refuse
l'écriture, pas une promesse du code.
Une seule action écrit — installer les modules suggérés, à la fin
de l'analyse [5]. Elle ne part jamais seule : question explicite,
défaut à « non », liste à confirmer, et refus net si le checkout
n'est pas sur la version de la base.
"""
print(f"🤖 {t('Analyse a database, without ever writing to it!')}")
print(f"🤖 {t('Analyse a database. Reading never writes.')}")
choices = [
{"section": t("Structure")},
{"prompt_description": t("Tables and database size")},
@ -10234,6 +10239,7 @@ class TODO:
print(f"❌ {t('Cannot read the database: ')}{database}")
return
print("\n".join(modules.render(rapport, limit=8)))
self._analyse_offer_install(database, rapport)
def handler(rank):
if rank == 1:
@ -10255,6 +10261,80 @@ class TODO:
handler,
)
def _analyse_offer_install(self, database, rapport):
"""Proposer d'installer ce qui manque, quand c'est installable.
Seuls les modules « available » sont offerts. Le dire est
nécessaire : le rapport vient d'en annoncer onze, la liste n'en
montre qu'un, et sans un mot on croirait à un bogue.
C'est la seule écriture de tout le menu Analyse, d'où trois
garde-fous : la version du checkout doit être celle de la base —
un Odoo 18 lancé sur une base 12 la réécrit avant d'échouer —, la
question par défaut est « non », et la liste choisie est
confirmée avant que rien ne parte.
"""
from script.analyse import check_module_package as modules
from script.odoo.migration import database_cleanup
from script.todo import auto_ask
candidats = modules.installable(rapport)
if not candidats:
return
autres = len(modules.missing(rapport)) - len(candidats)
souci = database_cleanup.require_matching_version(database)
if souci:
print(f"\n⚠ {souci}")
print(f" {t('Cannot install from here.')}")
return
print()
detail = f" ({autres} {t('need repair first')})" if autres else ""
question = (
f"💬 {t('Install some of the')} {len(candidats)}"
f" {t('suggested module(s) waiting in this database?')}{detail}"
f" (y/N) : "
)
if auto_ask.ask(question, default="n").strip().lower() not in (
"y",
"yes",
"o",
):
return
print()
for rang, nom in enumerate(candidats, start=1):
print(f" [{rang}] {nom}")
print(f" [a] {t('every one of them')}")
print(f" {t('Enter = cancel')}")
choisis, refuses = modules.parse_selection(
auto_ask.ask(f"💬 {t('Numbers, space separated:')} ", default=""),
candidats,
)
# Un jeton refusé n'est JAMAIS avalé : en demander cinq et en
# recevoir quatre sans un mot ferait croire l'installation faite.
if refuses:
print(f"⚠ {t('Ignored, not in the list:')} {' '.join(refuses)}")
if not choisis:
print(f"ℹ️ {t('Nothing selected.')}")
return
print()
print(f" {t('About to install into')} {database} :")
print(f" {', '.join(choisis)}")
if auto_ask.ask(
f"💬 {t('Go ahead?')} (y/N) : ", default="n"
).strip().lower() not in ("y", "yes", "o"):
print(f"ℹ️ {t('Nothing selected.')}")
return
self.execute.exec_command_live(
f"./script/addons/install_addons.sh {database}"
f" {','.join(choisis)}",
source_erplibre=False,
single_source_erplibre=True,
)
def execute_analyse_migration_quality(self):
"""Ce qu'une migration a gagné et perdu, palier par palier.

View file

@ -4091,6 +4091,50 @@ TRANSLATIONS = {
"en": "🔬 Analyse - Odoo database analysis",
},
"Analyse": {"fr": "Analyse", "en": "Analysis"},
"Analyse a database. Reading never writes.": {
"fr": "Analyser une base ; la lecture n'écrit jamais.",
"en": "Analyse a database. Reading never writes.",
},
"About to install into": {
"fr": "Sur le point d'installer dans",
"en": "About to install into",
},
"Cannot install from here.": {
"fr": "Installation impossible d'ici.",
"en": "Cannot install from here.",
},
"Enter = cancel": {
"fr": "Entrée = annuler",
"en": "Enter = cancel",
},
"Go ahead?": {
"fr": "On y va ?",
"en": "Go ahead?",
},
"Ignored, not in the list:": {
"fr": "Ignorés, absents de la liste :",
"en": "Ignored, not in the list:",
},
"Install some of the": {
"fr": "Installer une partie des",
"en": "Install some of the",
},
"Numbers, space separated:": {
"fr": "Numéros, séparés par des espaces :",
"en": "Numbers, space separated:",
},
"every one of them": {
"fr": "tous",
"en": "every one of them",
},
"need repair first": {
"fr": "à réparer d'abord",
"en": "need repair first",
},
"suggested module(s) waiting in this database?": {
"fr": "module(s) suggéré(s) qui attendent dans cette base ?",
"en": "suggested module(s) waiting in this database?",
},
"Analyse a database, without ever writing to it!": {
"fr": "Analyser une base, sans jamais y écrire !",
"en": "Analyse a database, without ever writing to it!",

View file

@ -566,6 +566,83 @@ class TestTheWiring(unittest.TestCase):
self.assertEqual(entrees, branches)
class TestWhatCanBeInstalled(unittest.TestCase):
def rapport(self, *couples):
return {
"lines": [
{
"module": nom,
"verdict": v,
"from": "p",
"state": "",
"shortdesc": "",
"needs": [],
}
for nom, v in couples
]
}
def test_only_available_modules_are_offered(self):
# Proposer un « unknown » ferait une liste plus longue et un
# échec : il n'est pas dans le chemin des addons.
r = self.rapport(
("a", "available"),
("b", "unknown"),
("c", "uninstallable"),
("d", "pending"),
("e", "installed"),
)
self.assertEqual(modules.installable(r), ["a"])
def test_nothing_available_offers_nothing(self):
self.assertEqual(
modules.installable(self.rapport(("b", "unknown"))), []
)
class TestTheSelection(unittest.TestCase):
CANDIDATS = ["alpha", "beta", "gamma"]
def choisit(self, reponse):
return modules.parse_selection(reponse, self.CANDIDATS)
def test_nothing_typed_selects_nothing(self):
self.assertEqual(self.choisit(""), ([], []))
self.assertEqual(self.choisit(" "), ([], []))
def test_a_takes_every_one(self):
for mot in ("a", "A", "all", " a "):
self.assertEqual(self.choisit(mot), (self.CANDIDATS, []), mot)
def test_numbers_are_one_based_and_space_separated(self):
self.assertEqual(self.choisit("1 3"), (["alpha", "gamma"], []))
def test_the_typed_order_is_kept(self):
self.assertEqual(self.choisit("3 1"), (["gamma", "alpha"], []))
def test_a_repeat_is_installed_once(self):
self.assertEqual(self.choisit("2 2"), (["beta"], []))
def test_commas_work_too(self):
# Les listes que l'outil affiche ailleurs sont en virgules ;
# refuser « 1,3 » n'aurait protégé de rien.
self.assertEqual(self.choisit("1,3"), (["alpha", "gamma"], []))
def test_an_out_of_range_number_is_REPORTED_not_dropped(self):
# En demander deux et en recevoir un sans un mot ferait croire
# l'installation complète. C'est la pire issue possible.
self.assertEqual(self.choisit("1 9"), (["alpha"], ["9"]))
def test_zero_is_out_of_range(self):
self.assertEqual(self.choisit("0"), ([], ["0"]))
def test_a_word_is_reported_not_ignored(self):
self.assertEqual(self.choisit("1 pouet"), (["alpha"], ["pouet"]))
def test_only_rubbish_selects_nothing_and_says_so(self):
self.assertEqual(self.choisit("x y"), ([], ["x", "y"]))
class TestThePermissions(unittest.TestCase):
"""Shebang et exécutable vont ensemble, dans les deux sens.
@ -592,5 +669,207 @@ class TestThePermissions(unittest.TestCase):
self.assertEqual(shebang, executable, os.path.basename(chemin))
class TestTheInstallOffer(unittest.TestCase):
"""L'invite qui ÉCRIT. On vérifie la commande, pas les appels.
Un test qui se contente de constater qu'une fonction a été appelée
laisse passer une commande mal formée ou lancée quand il ne fallait
pas. Ici on retient la ligne de commande exacte, et surtout on exige
qu'il n'en parte AUCUNE sur les chemins de refus.
"""
RAPPORT = {
"lines": [
{
"module": "queue_job",
"verdict": "available",
"from": "p",
"state": "",
"shortdesc": "",
"needs": [],
},
{
"module": "web_dark_mode",
"verdict": "available",
"from": "p",
"state": "",
"shortdesc": "",
"needs": [],
},
{
"module": "absent",
"verdict": "unknown",
"from": "p",
"state": "",
"shortdesc": "",
"needs": [],
},
]
}
def setUp(self):
from script.odoo.migration import database_cleanup
from script.todo import auto_ask
from script.todo import todo as todo_module
self.cleanup = database_cleanup
self.auto_ask = auto_ask
self.vraie_garde = database_cleanup.require_matching_version
self.vrai_ask = auto_ask.ask
database_cleanup.require_matching_version = lambda base: None
self.lancees = []
self.obj = todo_module.TODO.__new__(todo_module.TODO)
self.obj.execute = type(
"E",
(),
{
"exec_command_live": lambda _s, cmd, **k: self.lancees.append(
cmd
)
},
)()
def tearDown(self):
self.cleanup.require_matching_version = self.vraie_garde
self.auto_ask.ask = self.vrai_ask
def joue(self, *reponses):
"""Rendre TOUT ce que l'utilisateur voit : sortie ET invites.
Le texte d'une question ne passe pas par stdout — il est l'argument
de `ask`, qu'un vrai `input()` affiche. Ne regarder que stdout
laisserait une invite muette passer pour correcte.
"""
file = list(reponses)
self.demandes = []
def faux_ask(prompt, default="", seconds=None):
# Le VRAI `ask` rend le défaut quand la réponse est vide. Un
# faux qui rend la chaîne vide telle quelle ne teste pas le
# défaut du tout : basculer celui de « n » à « y » passait
# alors inaperçu, et Entrée aurait installé.
self.demandes.append(prompt)
reponse = file.pop(0) if file else ""
return reponse or default
self.auto_ask.ask = faux_ask
tampon = io.StringIO()
with redirect_stdout(tampon):
self.obj._analyse_offer_install("ma_base", self.RAPPORT)
return tampon.getvalue() + "\n".join(self.demandes)
def test_saying_no_runs_nothing(self):
self.joue("n")
self.assertEqual(self.lancees, [])
def test_pressing_enter_runs_nothing(self):
# Le défaut d'une action qui écrit doit être de ne rien faire.
# On fournit de quoi aller AU BOUT si le garde-fou cédait : sans
# cela, la suite s'arrêtait faute de réponses et le test passait
# même avec un défaut à « y ». Basculer le défaut installerait.
self.joue("", "a", "y")
self.assertEqual(self.lancees, [])
def test_pressing_enter_at_the_final_confirmation_runs_nothing(self):
# Même piège sur le second garde-fou : il faut que « tout est
# prêt » soit vrai au moment où l'on appuie sur Entrée.
self.joue("y", "a", "")
self.assertEqual(self.lancees, [])
def test_choosing_one_installs_exactly_that_one(self):
self.joue("y", "1", "y")
self.assertEqual(
self.lancees,
["./script/addons/install_addons.sh ma_base queue_job"],
)
def test_choosing_a_installs_every_available_one(self):
self.joue("y", "a", "y")
self.assertEqual(
self.lancees,
[
"./script/addons/install_addons.sh ma_base"
" queue_job,web_dark_mode"
],
)
def test_the_unknown_module_is_never_offered(self):
sortie = self.joue("y", "a", "y")
self.assertNotIn("absent", self.lancees[0])
self.assertIn("[1]", sortie)
self.assertIn("[2]", sortie)
self.assertNotIn("[3]", sortie)
def test_refusing_the_final_confirmation_runs_nothing(self):
# Deuxième filet : on a choisi, on relit, on renonce.
self.joue("y", "a", "n")
self.assertEqual(self.lancees, [])
def test_selecting_nothing_runs_nothing(self):
# On confirme APRÈS n'avoir rien choisi : sans le garde, la
# commande partirait avec une liste de modules vide.
self.joue("y", "", "y")
self.assertEqual(self.lancees, [])
def test_selecting_only_rubbish_runs_nothing(self):
# Même chemin, mais l'utilisateur a bien tapé quelque chose : ce
# qu'il a tapé ne désigne aucun module.
sortie = self.joue("y", "pouet 99", "y")
self.assertEqual(self.lancees, [])
self.assertIn("pouet", sortie)
def test_a_bad_token_is_shown_and_the_rest_still_installs(self):
sortie = self.joue("y", "1 99", "y")
self.assertIn("99", sortie)
self.assertEqual(
self.lancees,
["./script/addons/install_addons.sh ma_base queue_job"],
)
def test_a_version_mismatch_refuses_before_asking_anything(self):
# Un Odoo 18 lancé sur une base 12 la RÉÉCRIT avant d'échouer :
# ce refus doit précéder la moindre question.
self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0"
demandes = []
self.auto_ask.ask = lambda p, default="", seconds=None: (
demandes.append(p) or "y"
)
tampon = io.StringIO()
with redirect_stdout(tampon):
self.obj._analyse_offer_install("ma_base", self.RAPPORT)
self.assertEqual(self.lancees, [])
self.assertEqual(demandes, [])
self.assertIn("18.0 vs 12.0", tampon.getvalue())
def test_nothing_installable_asks_nothing_at_all(self):
demandes = []
self.auto_ask.ask = lambda p, default="", seconds=None: (
demandes.append(p) or "y"
)
rien = {
"lines": [
{
"module": "x",
"verdict": "unknown",
"from": "p",
"state": "",
"shortdesc": "",
"needs": [],
}
]
}
with redirect_stdout(io.StringIO()):
self.obj._analyse_offer_install("ma_base", rien)
self.assertEqual(demandes, [])
self.assertEqual(self.lancees, [])
def test_it_says_how_many_it_could_not_offer(self):
# Le rapport vient d'en annoncer trois, la liste en montre deux :
# sans un mot, on croirait à un bogue.
sortie = self.joue("n")
self.assertIn(todo_i18n.t("need repair first"), sortie)
if __name__ == "__main__":
unittest.main()