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
547 lines
19 KiB
Python
Executable file
547 lines
19 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Ce qu'une base n'a pas, alors que l'installation par défaut l'aurait.
|
|
|
|
`conf/module_list_image_db_odoo.json` dit ce qu'ERPLibre embarque : le
|
|
package `odoo18.0_base` suggère quinze modules. Rien ne vérifiait qu'une
|
|
base DONNÉE les a. C'est pourtant la question qu'on se pose devant une
|
|
instance migrée depuis la 12 : elle a traversé six paliers, des modules
|
|
ont été désinstallés en chemin pour débloquer une mise à jour, et
|
|
personne ne sait plus ce qui manque par rapport à une installation neuve.
|
|
|
|
Pourquoi pas `image_db.py --check_addons_exist`
|
|
-----------------------------------------------
|
|
Il répond à une autre question : le module est-il sur le DISQUE. Un
|
|
module peut être présent dans le chemin des addons et absent de la base,
|
|
ou l'inverse — connu de la base parce qu'il y fut installé, mais son code
|
|
n'est plus là. Les deux outils sont complémentaires, aucun ne remplace
|
|
l'autre.
|
|
|
|
Cinq verdicts, pas un seul « manquant »
|
|
---------------------------------------
|
|
Les confondre rendrait le rapport inutile, car l'action diffère à chaque
|
|
fois : un module `available` s'installe d'un clic ; un module `unknown`
|
|
demande d'abord de réparer le chemin des addons ; un `uninstallable` a
|
|
une dépendance cassée qu'aucune installation ne contournera. Un rapport
|
|
qui dit « 7 modules manquants » sans distinguer ces cas oblige à tout
|
|
reprendre à la main.
|
|
|
|
Lecture seule, garantie par le serveur — on inspecte parfois des bases de
|
|
migration dont c'est la seule copie.
|
|
|
|
Codes de sortie : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
|
|
sys.path.append(
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
)
|
|
|
|
try:
|
|
from script.todo.todo_i18n import t
|
|
except Exception: # pragma: no cover - repli si i18n indisponible
|
|
|
|
def t(key: str) -> str:
|
|
return key
|
|
|
|
|
|
SEP = "\x1f"
|
|
|
|
# Résolu depuis CE fichier, pas depuis le répertoire courant : l'outil est
|
|
# lancé aussi bien depuis la racine du dépôt que depuis le menu TODO ou un
|
|
# /tmp, et un chemin relatif le faisait échouer en disant « fichier de
|
|
# packages illisible » — un diagnostic qui envoyait chercher au mauvais
|
|
# endroit.
|
|
REPO_ROOT = os.path.normpath(
|
|
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
|
|
)
|
|
PACKAGE_FILE = os.path.join(
|
|
REPO_ROOT, "conf", "module_list_image_db_odoo.json"
|
|
)
|
|
|
|
# L'ordre EST la gravité : c'est lui qui décide de la lecture du rapport.
|
|
VERDICTS = ("unknown", "uninstallable", "pending", "available", "installed")
|
|
|
|
# Les états d'`ir_module_module` qu'Odoo peut porter, rangés par verdict.
|
|
# Une base en cours de mise à jour reste dans « to install » ou « to
|
|
# remove » : ce n'est ni installé ni disponible, c'est inachevé, et le
|
|
# dire évite de proposer d'installer ce qui est déjà en route.
|
|
ETAT_VERS_VERDICT = {
|
|
"installed": "installed",
|
|
"to upgrade": "installed",
|
|
"to install": "pending",
|
|
"to remove": "pending",
|
|
"uninstalled": "available",
|
|
"uninstallable": "uninstallable",
|
|
}
|
|
|
|
ICONE = {
|
|
"installed": "✅",
|
|
"available": "○",
|
|
"pending": "⏳",
|
|
"uninstallable": "⛔",
|
|
"unknown": "❌",
|
|
}
|
|
|
|
|
|
def run_psql(database, sql):
|
|
"""Interroger la base en lecture seule, garantie par le SERVEUR.
|
|
|
|
`default_transaction_read_only` n'est pas une promesse de l'outil :
|
|
PostgreSQL refusera l'écriture même si le SQL en contenait une.
|
|
"""
|
|
env = os.environ.copy()
|
|
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
|
|
env["PSQLRC"] = ""
|
|
done = subprocess.run(
|
|
["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
|
|
capture_output=True,
|
|
text=True,
|
|
env=env,
|
|
)
|
|
if done.returncode:
|
|
return None
|
|
return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
|
|
|
|
|
|
def read_packages(path=PACKAGE_FILE):
|
|
"""Le fichier des packages, ou {} s'il est illisible."""
|
|
try:
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
dct = json.load(handle)
|
|
except (OSError, ValueError):
|
|
return {}
|
|
return dct if isinstance(dct, dict) else {}
|
|
|
|
|
|
def package_chain(name, packages):
|
|
"""De la racine à `name`, en suivant `base`.
|
|
|
|
Un package hérite du sien : `odoo12.0_website` est construit SUR
|
|
l'image `odoo12.0_base`, donc ses modules s'ajoutent à ceux-là. Ne
|
|
lire que le maillon nommé sous-estimerait ce qu'une installation
|
|
embarque, et l'outil déclarerait « rien ne manque » à tort.
|
|
|
|
Une boucle dans `base` ferait tourner l'outil indéfiniment : on
|
|
s'arrête au premier nom déjà vu plutôt que de faire confiance au
|
|
fichier.
|
|
"""
|
|
chaine = []
|
|
vus = set()
|
|
courant = name
|
|
# Borné par le NOMBRE de packages : une chaîne ne peut pas être plus
|
|
# longue que ce qui existe. La terminaison ne dépend donc pas de la
|
|
# garde `vus` — retirer l'une des deux donne un résultat FAUX, que le
|
|
# test attrape, au lieu d'un outil qui pend, que rien n'attrape.
|
|
for _ in range(len(packages) + 1):
|
|
if not courant or courant not in packages or courant in vus:
|
|
break
|
|
vus.add(courant)
|
|
chaine.append(courant)
|
|
courant = (packages[courant] or {}).get("base") or ""
|
|
chaine.reverse()
|
|
return chaine
|
|
|
|
|
|
def package_modules(name, packages):
|
|
"""{module: package qui le suggère}, héritage compris.
|
|
|
|
Le package d'ORIGINE est retenu, pas le dernier vu : savoir qu'un
|
|
module vient de `odoo16.0_base` et non de `odoo16.0_website` dit s'il
|
|
est fondamental ou accessoire.
|
|
"""
|
|
trouve = {}
|
|
for maillon in package_chain(name, packages):
|
|
for groupe in (packages[maillon] or {}).get("image_list") or []:
|
|
for module in (groupe or {}).get("module") or []:
|
|
trouve.setdefault(module, maillon)
|
|
return trouve
|
|
|
|
|
|
def db_version(database):
|
|
"""« 18.0 » d'après le module `base`, ou None.
|
|
|
|
C'est la version qu'Odoo lui-même inscrit, pas celle du checkout : une
|
|
base de palier 15 lue depuis un checkout 18 doit se comparer au
|
|
package 15, sans quoi le rapport nommerait des modules qui n'existaient
|
|
pas encore.
|
|
"""
|
|
lignes = run_psql(
|
|
database,
|
|
"SELECT latest_version FROM ir_module_module WHERE name = 'base'",
|
|
)
|
|
if not lignes or not lignes[0][0]:
|
|
return None
|
|
morceaux = lignes[0][0].split(".")
|
|
if len(morceaux) < 2:
|
|
return None
|
|
return f"{morceaux[0]}.{morceaux[1]}"
|
|
|
|
|
|
def default_package(version):
|
|
"""« 18.0 » -> « odoo18.0_base ». None si l'on ne sait pas."""
|
|
return f"odoo{version}_base" if version else None
|
|
|
|
|
|
def column_types(database, table):
|
|
"""{colonne: type} pour une table. {} si la base ne répond pas."""
|
|
lignes = run_psql(
|
|
database,
|
|
"SELECT column_name, data_type FROM information_schema.columns"
|
|
f" WHERE table_name = '{table}'",
|
|
)
|
|
return {ligne[0]: ligne[1] for ligne in lignes or [] if len(ligne) >= 2}
|
|
|
|
|
|
def as_text(colonne, types):
|
|
"""Lire une colonne en texte, qu'elle soit varchar ou jsonb.
|
|
|
|
Les champs traduisibles d'Odoo sont passés en jsonb à la 16 :
|
|
`shortdesc` est un varchar en 12-15 et un `{"en_US": "…"}` ensuite.
|
|
Cet outil vise les bases de PALIER d'une migration, donc les deux
|
|
formes se présentent dans la même session — supposer l'une fait
|
|
échouer la requête entière sur l'autre, et l'outil déclare alors la
|
|
base illisible alors qu'elle se porte bien.
|
|
"""
|
|
if types.get(colonne) == "jsonb":
|
|
return (
|
|
f"coalesce({colonne} ->> 'en_US', {colonne} ->> 'fr_FR',"
|
|
f" {colonne} ->> 'fr_CA', '')"
|
|
)
|
|
return f"coalesce({colonne}, '')"
|
|
|
|
|
|
def census(database):
|
|
"""{module: (état, résumé, application, auteur)} pour TOUTE la base.
|
|
|
|
None si la base ne répond pas — à distinguer d'une base vide, qui
|
|
rendrait un dictionnaire vide et ne veut pas dire la même chose.
|
|
"""
|
|
types = column_types(database, "ir_module_module")
|
|
if "name" not in types:
|
|
return None
|
|
lignes = run_psql(
|
|
database,
|
|
f"SELECT name, state, {as_text('shortdesc', types)},"
|
|
" case when application then '1' else '0' end,"
|
|
f" {as_text('author', types)} FROM ir_module_module",
|
|
)
|
|
if lignes is None:
|
|
return None
|
|
return {
|
|
ligne[0]: (ligne[1], ligne[2], ligne[3] == "1", ligne[4])
|
|
for ligne in lignes
|
|
if len(ligne) >= 5
|
|
}
|
|
|
|
|
|
def dependencies(database):
|
|
"""{module: [ce dont il dépend]}. {} si la table ne répond pas."""
|
|
lignes = run_psql(
|
|
database,
|
|
"SELECT m.name, d.name FROM ir_module_module_dependency d"
|
|
" JOIN ir_module_module m ON m.id = d.module_id",
|
|
)
|
|
if not lignes:
|
|
return {}
|
|
dct = {}
|
|
for ligne in lignes:
|
|
if len(ligne) >= 2:
|
|
dct.setdefault(ligne[0], []).append(ligne[1])
|
|
return dct
|
|
|
|
|
|
def verdict_of(module, connus):
|
|
"""Le verdict d'un module suggéré, d'après ce que la base en sait."""
|
|
if module not in connus:
|
|
return "unknown"
|
|
return ETAT_VERS_VERDICT.get(connus[module][0], "uninstallable")
|
|
|
|
|
|
def blocking_dependencies(module, connus, depend):
|
|
"""Ce qu'installer `module` réclamerait et que la base n'a pas.
|
|
|
|
Un module « disponible » ne l'est pas toujours vraiment : si trois de
|
|
ses dépendances sont absentes du chemin des addons, l'installer
|
|
échouera. Le dire ici évite de le découvrir en cliquant.
|
|
"""
|
|
manquantes = []
|
|
for nom in sorted(set(depend.get(module) or [])):
|
|
if verdict_of(nom, connus) in ("unknown", "uninstallable"):
|
|
manquantes.append(nom)
|
|
return manquantes
|
|
|
|
|
|
def audit(database, package=None, packages=None, path=PACKAGE_FILE):
|
|
"""Tout ce que le rapport a besoin de savoir, en une passe."""
|
|
packages = read_packages(path) if packages is None else packages
|
|
connus = census(database)
|
|
if connus is None:
|
|
return {"unavailable": True, "database": database}
|
|
version = db_version(database)
|
|
nom = package or default_package(version)
|
|
suggere = package_modules(nom, packages) if nom else {}
|
|
depend = dependencies(database)
|
|
|
|
lignes = []
|
|
for module in sorted(suggere):
|
|
verdict = verdict_of(module, connus)
|
|
etat = connus.get(module, ("", "", False, ""))
|
|
lignes.append(
|
|
{
|
|
"module": module,
|
|
"from": suggere[module],
|
|
"verdict": verdict,
|
|
"state": etat[0],
|
|
"shortdesc": etat[1],
|
|
"needs": (
|
|
blocking_dependencies(module, connus, depend)
|
|
if verdict == "available"
|
|
else []
|
|
),
|
|
}
|
|
)
|
|
par_etat = {}
|
|
for etat, _desc, _app, _auteur in connus.values():
|
|
par_etat[etat] = par_etat.get(etat, 0) + 1
|
|
installes = {
|
|
nom_mod
|
|
for nom_mod, valeur in connus.items()
|
|
if valeur[0] in ("installed", "to upgrade")
|
|
}
|
|
return {
|
|
"database": database,
|
|
"version": version,
|
|
"package": nom,
|
|
"package_known": bool(nom and nom in packages),
|
|
"chain": package_chain(nom, packages) if nom else [],
|
|
"lines": lignes,
|
|
"by_state": par_etat,
|
|
"total": len(connus),
|
|
"installed": sorted(installes),
|
|
"extra": sorted(installes - set(suggere)),
|
|
"authors": authors_of(connus, installes),
|
|
}
|
|
|
|
|
|
def authors_of(connus, installes):
|
|
"""[(auteur, combien)] parmi les modules installés, les gros d'abord.
|
|
|
|
« L'ensemble des modules » d'une base ne se lit pas en listant trois
|
|
cents noms : l'auteur dit d'où ils viennent — Odoo, OCA, ou la maison.
|
|
"""
|
|
compte = {}
|
|
for nom in installes:
|
|
auteur = (connus[nom][3] or t("unknown author")).strip()
|
|
compte[auteur] = compte.get(auteur, 0) + 1
|
|
return sorted(compte.items(), key=lambda item: (-item[1], item[0]))
|
|
|
|
|
|
def missing(rapport):
|
|
"""Les lignes qui réclament une action, les plus graves en tête."""
|
|
ordre = {verdict: rang for rang, verdict in enumerate(VERDICTS)}
|
|
return sorted(
|
|
(
|
|
ligne
|
|
for ligne in rapport.get("lines") or []
|
|
if ligne["verdict"] != "installed"
|
|
),
|
|
key=lambda ligne: (ordre[ligne["verdict"]], ligne["module"]),
|
|
)
|
|
|
|
|
|
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"):
|
|
return [
|
|
f"❌ {t('Cannot read the database: ')}{rapport['database']}",
|
|
]
|
|
lignes = [
|
|
f"📦 {t('Modules of')} {rapport['database']}"
|
|
f" ({t('Odoo')} {rapport['version'] or '?'})",
|
|
]
|
|
if not rapport["package_known"]:
|
|
# Ne pas se taire : un package inconnu rend TOUT module « manquant »,
|
|
# et l'on croirait à une base vide plutôt qu'à un nom mal choisi.
|
|
lignes.append(
|
|
f" ⚠ {t('No default package known for this version')}"
|
|
f" ({rapport['package'] or '?'})"
|
|
)
|
|
lignes.append(f" {t('Compared against nothing — census only.')}")
|
|
else:
|
|
lignes.append(
|
|
f" {t('Default package:')} {' → '.join(rapport['chain'])}"
|
|
f" ({len(rapport['lines'])} {t('suggested module(s)')})"
|
|
)
|
|
lignes.append("")
|
|
|
|
lignes.append(f" {t('Census')} : {rapport['total']} {t('known')}")
|
|
for etat, combien in sorted(
|
|
rapport["by_state"].items(), key=lambda item: -item[1]
|
|
):
|
|
lignes.append(f" {etat:<16} {combien:>5}")
|
|
if rapport["authors"]:
|
|
lignes.append("")
|
|
lignes.append(f" {t('Installed modules by author')} :")
|
|
for auteur, combien in rapport["authors"][: limit or None]:
|
|
lignes.append(f" {auteur[:52]:<54} {combien:>4}")
|
|
lignes.append("")
|
|
|
|
absents = missing(rapport)
|
|
if not absents:
|
|
if rapport["package_known"]:
|
|
lignes.append(f" ✅ {t('Every suggested module is installed.')}")
|
|
return lignes
|
|
|
|
lignes.append(
|
|
f" {len(absents)} {t('suggested module(s) not installed')} :"
|
|
)
|
|
for verdict in VERDICTS:
|
|
groupe = [ligne for ligne in absents if ligne["verdict"] == verdict]
|
|
if not groupe:
|
|
continue
|
|
lignes.append(
|
|
f" {ICONE[verdict]} {len(groupe)} {t(EXPLICATION[verdict])}"
|
|
)
|
|
for ligne in groupe[: limit or None]:
|
|
detail = (
|
|
f" — {ligne['shortdesc'][:44]}" if ligne["shortdesc"] else ""
|
|
)
|
|
lignes.append(f" {ligne['module']:<34}{detail}")
|
|
if ligne["needs"]:
|
|
lignes.append(
|
|
f" ↳ {t('also needs')} :"
|
|
f" {', '.join(ligne['needs'][:6])}"
|
|
)
|
|
if limit and len(groupe) > limit:
|
|
lignes.append(f" … {len(groupe) - limit} {t('more')}")
|
|
return lignes
|
|
|
|
|
|
# Ce qu'il faut FAIRE, pas seulement ce que c'est : un rapport qui nomme
|
|
# l'état sans nommer le geste laisse le travail entier à faire.
|
|
EXPLICATION = {
|
|
"unknown": "absent from the addons path — sync the repo first",
|
|
"uninstallable": "present but broken — fix the dependency",
|
|
"pending": "half-way — finish the pending update",
|
|
"available": "known to the database — install it",
|
|
"installed": "installed",
|
|
}
|
|
|
|
|
|
def main(argv=None):
|
|
import argparse
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description=(
|
|
"List every module of a database and report which ones the"
|
|
" default ERPLibre package suggests but the database lacks."
|
|
)
|
|
)
|
|
parser.add_argument("-d", "--database", help="database to inspect")
|
|
parser.add_argument(
|
|
"-p",
|
|
"--package",
|
|
help="package to compare against (default: odoo<version>_base)",
|
|
)
|
|
parser.add_argument(
|
|
"--file", default=PACKAGE_FILE, help="package definition file"
|
|
)
|
|
parser.add_argument(
|
|
"--list-packages",
|
|
action="store_true",
|
|
help="print every known package and exit",
|
|
)
|
|
parser.add_argument(
|
|
"--limit",
|
|
type=int,
|
|
default=0,
|
|
help="cap long lists (0 = no cap)",
|
|
)
|
|
parser.add_argument("--json", action="store_true", help="machine output")
|
|
config = parser.parse_args(argv)
|
|
|
|
packages = read_packages(config.file)
|
|
if not packages:
|
|
print(f"❌ {t('Cannot read the package file: ')}{config.file}")
|
|
return 2
|
|
if config.list_packages:
|
|
for nom in sorted(packages):
|
|
combien = len(package_modules(nom, packages))
|
|
marque = (
|
|
" (disabled)" if (packages[nom] or {}).get("disable") else ""
|
|
)
|
|
print(f"{nom:<46} {combien:>4} module(s){marque}")
|
|
return 0
|
|
if not config.database:
|
|
parser.error("--database is required (or use --list-packages)")
|
|
|
|
rapport = audit(config.database, package=config.package, packages=packages)
|
|
if rapport.get("unavailable"):
|
|
print(f"❌ {t('Cannot read the database: ')}{config.database}")
|
|
return 2
|
|
if config.json:
|
|
print(
|
|
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
|
|
)
|
|
else:
|
|
print("\n".join(render(rapport, limit=config.limit)))
|
|
return 1 if missing(rapport) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|