diff --git a/script/analyse/analyse_view_custom.py b/script/analyse/analyse_view_custom.py
index fe31310..7fdb22d 100755
--- a/script/analyse/analyse_view_custom.py
+++ b/script/analyse/analyse_view_custom.py
@@ -334,9 +334,114 @@ def add_reference_arch(database, lst_finding, config_path=None, timeout=600):
return "orm", None
+def attach_cow_twin_diff(lst_finding, dct_twin, dct_copy_arch):
+ """Comparer chaque copie COW à la vue de module qu'elle masque.
+
+ C'est LA comparaison qui compte pour une copie, et elle n'a besoin d'aucun
+ registre : les deux côtés sont dans la base, appariés par leur clé —
+ exactement l'appariement que fait Odoo, et la raison pour laquelle
+ renommer une clé suffit à désapparier une copie.
+
+ Elle marche donc là où la comparaison avec la source du module est
+ impossible : une base dont la version diffère du checkout, et une
+ sauvegarde .zip, qui portent l'une comme l'autre les deux arch.
+
+ Une copie sans jumelle est laissée telle quelle : c'est une page faite
+ dans l'éditeur web, il n'y a rien à quoi la comparer.
+
+ Fonction pure — les deux provenances lui passent leurs dictionnaires et
+ concluent donc la même chose des mêmes faits. Renvoie le nombre de copies
+ comparées.
+ """
+ n_compared = 0
+ for row in lst_finding:
+ if row["category"] != "website_cow_copy":
+ continue
+ twin = dct_twin.get(row.get("key"))
+ if not twin:
+ continue
+ twin_id, twin_arch = twin
+ copy_arch = dct_copy_arch.get(row["id"])
+ if copy_arch is None:
+ continue
+ differs, comparable = arch_differs(twin_arch, copy_arch)
+ # Mêmes noms de champs que la comparaison avec la source du module :
+ # l'écran de navigation et le rendu texte marchent alors sans savoir
+ # laquelle des deux a produit la donnée.
+ row["arch_ref"] = twin_arch
+ row["arch_db_text"] = copy_arch
+ row["twin_id"] = twin_id
+ row["comparable"] = comparable
+ row["differs"] = differs
+ if comparable:
+ row["diff_stats"] = diff_stats(side_by_side(twin_arch, copy_arch))
+ n_compared += 1
+ return n_compared
+
+
+def _cow_twin_arch(database, lst_key, **kwargs):
+ """{clé: (id, arch)} des vues de module masquées par ces copies."""
+ if not lst_key:
+ return {}
+ values = ", ".join(
+ "'" + k.replace("'", "''") + "'" for k in sorted(set(lst_key))
+ )
+ rows = json_query(
+ database,
+ f"""
+ SELECT DISTINCT ON (v.key)
+ v.key AS key,
+ v.id AS id,
+ v.arch_db::text AS arch
+ FROM ir_ui_view v
+ WHERE v.key IN ({values}) AND v.website_id IS NULL
+ ORDER BY v.key, v.id
+ """,
+ **kwargs,
+ )
+ return {r["key"]: (r["id"], normalise_arch(r["arch"])) for r in rows}
+
+
+def _cow_copy_arch(database, lst_id, **kwargs):
+ """{id: arch} des seules copies retenues.
+
+ Rapatrier l'arch de toutes les vues ferait une ligne de sortie de plusieurs
+ centaines de mégaoctets ; celle des seules copies COW en fait quelques-uns.
+ """
+ if not lst_id:
+ return {}
+ ids = ", ".join(str(int(i)) for i in lst_id)
+ rows = json_query(
+ database,
+ "SELECT id AS id, arch_db::text AS arch"
+ f" FROM ir_ui_view WHERE id IN ({ids})",
+ **kwargs,
+ )
+ return {r["id"]: normalise_arch(r["arch"]) for r in rows}
+
+
+def add_cow_twin_diff(database, lst_finding, **kwargs):
+ """Comparer les copies COW d'une BASE à leur jumelle. Deux requêtes."""
+ lst_copy = [
+ row
+ for row in lst_finding
+ if row["category"] == "website_cow_copy" and row.get("has_module_twin")
+ ]
+ if not lst_copy:
+ return 0
+ dct_twin = _cow_twin_arch(
+ database, [row["key"] for row in lst_copy if row.get("key")], **kwargs
+ )
+ dct_copy = _cow_copy_arch(
+ database, [row["id"] for row in lst_copy], **kwargs
+ )
+ return attach_cow_twin_diff(lst_finding, dct_twin, dct_copy)
+
+
def collect(
database,
with_diff=False,
+ with_cow_diff=False,
scope="flagged",
config_path=None,
timeout=120,
@@ -370,6 +475,12 @@ def collect(
if category in ACTIONABLE:
lst_finding.append(row)
+ n_cow_compared = 0
+ if with_cow_diff:
+ # Indépendant de la voie ORM : les deux arch sont en base, donc ceci
+ # marche même quand la version du checkout interdit l'autre.
+ n_cow_compared = add_cow_twin_diff(database, lst_finding, **kwargs)
+
arch_ref_source, arch_ref_error = "none", None
checkout = checkout_odoo_version()
if with_diff:
@@ -455,6 +566,7 @@ def collect(
"scope": scope,
"arch_ref_error": arch_ref_error,
"n_identical_after_canonical": n_identical,
+ "n_cow_compared": n_cow_compared,
"n_views": len(lst_view),
"counts": dct_count,
"findings": lst_finding,
@@ -528,11 +640,30 @@ def collect_from_backup(zip_path):
if category in ACTIONABLE:
lst_finding.append(row)
+ # Le dump porte les arch des deux côtés : la comparaison COW est donc
+ # possible depuis un zip, là où celle avec la source du module ne l'est
+ # pas faute de registre.
+ dct_twin = {}
+ for row in dct_rows["ir_ui_view"]:
+ if row.get("key") and row.get("website_id") in (None, ""):
+ dct_twin.setdefault(
+ row["key"],
+ (row.get("id"), normalise_arch(row.get("arch_db"))),
+ )
+ dct_copy = {
+ (int(r.get("id")) if (r.get("id") or "").isdigit() else r.get("id")): (
+ normalise_arch(r.get("arch_db"))
+ )
+ for r in dct_rows["ir_ui_view"]
+ }
+ n_cow_compared = attach_cow_twin_diff(lst_finding, dct_twin, dct_copy)
+
return {
"tool": "analyse_view_custom",
"version": 1,
"database": os.path.basename(zip_path),
"source": "backup",
+ "n_cow_compared": n_cow_compared,
"backup_path": zip_path,
"odoo_version": backup_version(dct_rows, manifest),
"checkout_version": checkout_odoo_version(),
@@ -792,6 +923,12 @@ def main(argv=None):
default="flagged",
help=t("which views to compare (default: flagged)"),
)
+ parser.add_argument(
+ "--cow-diff",
+ dest="cow_diff",
+ action="store_true",
+ help=t("compare each website copy with the module view it shadows"),
+ )
parser.add_argument(
"--strict",
action="store_true",
@@ -814,7 +951,8 @@ def main(argv=None):
else:
data = collect(
config.database,
- with_diff=config.diff or config.tui,
+ with_diff=config.diff,
+ with_cow_diff=config.cow_diff or config.tui,
scope=config.scope,
config_path=config.config,
)
diff --git a/script/analyse/check_filestore.py b/script/analyse/check_filestore.py
new file mode 100755
index 0000000..4c62870
--- /dev/null
+++ b/script/analyse/check_filestore.py
@@ -0,0 +1,693 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Des pièces jointes sans fichier : lesquelles peut-on encore récupérer ?
+
+« 266 fichiers absents du filestore » ne dit pas quoi faire. La question
+utile est ailleurs : combien sont PERDUS, et combien dorment quelque part
+sur la machine en attendant qu'on les remette ?
+
+Mesuré sur une migration réelle : sur 266 absents, 262 étaient des images
+engendrées par des modules — dont 235 drapeaux de pays dont le champ
+n'existe même plus en 18 — et QUATRE étaient de vrais documents. Ces
+quatre-là sont la réponse. Les 262 autres sont du bruit qu'il ne faut pas
+confondre avec eux.
+
+Où l'outil cherche
+------------------
+1. Les autres filestores de la machine. Une migration laisse une base par
+ palier ; un fichier perdu dans la 12 est souvent intact dans la 15,
+ régénéré en chemin par une mise à jour de module.
+2. Les `filestore/` NICHÉS. `shutil.move(src, dst)` d'Odoo renomme quand
+ la destination n'existe pas et IMBRIQUE quand elle existe : une base
+ restaurée deux fois sous le même nom se retrouve avec
+ `filestore//filestore/xx/sha`, qu'Odoo ne lira jamais. Mesuré :
+ 1168 fichiers, 133 Mo, recopiés à l'identique dans les sept bases de
+ la chaîne par le clone.
+3. Les sauvegardes `.zip`. Leur répertoire central se lit sans tout
+ décompresser.
+
+Ce qui n'est PAS récupérable
+----------------------------
+Un fichier introuvable partout. On le dit alors franchement, avec le
+modèle et l'enregistrement auxquels il se rattache, pour qu'on puisse
+juger de la perte — et non « 266 fichiers absents », devant quoi il n'y
+a rien à décider.
+
+Un cas mérite sa propre catégorie : la pièce jointe dont le CHAMP
+n'existe plus. `res.country.image` était un binaire en 12 ; en 18 c'est
+`image_url`, calculé. Les 235 lignes survivent en pointant vers un champ
+disparu : rien ne les lit, rien ne les régénérera, et il n'y a rien à
+récupérer. Les compter comme des pertes serait faux.
+
+Lecture seule de bout en bout.
+
+Codes de sortie : 0 rien d'irrécupérable, 1 des trouvailles, 2 échec.
+"""
+
+import os
+import subprocess
+import sys
+import zipfile
+
+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"
+REPO_ROOT = os.path.normpath(
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
+)
+
+# L'ordre EST la gravité : ce qu'on ne peut pas récupérer se lit en premier.
+VERDICTS = ("lost", "in_backup", "in_other_filestore", "nested", "dead_field")
+
+ICONE = {
+ "lost": "❌",
+ "in_backup": "📦",
+ "in_other_filestore": "🗂",
+ "nested": "↳",
+ "dead_field": "🕳",
+}
+
+EXPLICATION = {
+ "lost": "nowhere to be found — truly lost",
+ "in_backup": "still in a backup zip",
+ "in_other_filestore": "intact in another database's filestore",
+ "nested": "stranded in a nested filestore Odoo never reads",
+ "dead_field": "its field no longer exists — nothing reads it",
+}
+
+
+def run_psql(database, sql):
+ """Interroger la base en lecture seule, garantie par le SERVEUR."""
+ 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 data_dir(config_path=None):
+ """Le `data_dir` d'Odoo, lu dans la configuration.
+
+ Le deviner reviendrait à chercher au mauvais endroit et à déclarer
+ tout perdu — le pire diagnostic possible pour cet outil.
+ """
+ chemin = config_path or os.path.join(REPO_ROOT, "config.conf")
+ try:
+ with open(chemin, "r", encoding="utf-8") as handle:
+ for ligne in handle:
+ if ligne.strip().startswith("data_dir"):
+ return ligne.split("=", 1)[1].strip()
+ except OSError:
+ pass
+ return os.path.expanduser("~/.local/share/Odoo")
+
+
+def filestore_root(config_path=None):
+ return os.path.join(data_dir(config_path), "filestore")
+
+
+def attachments(database):
+ """Les pièces jointes stockées sur disque. None si la base se tait."""
+ lignes = run_psql(
+ database,
+ "SELECT a.store_fname, coalesce(a.res_model, ''),"
+ " a.id::text,"
+ " coalesce(a.res_field, ''), coalesce(a.res_id::text, ''),"
+ " coalesce(a.name, ''), coalesce(a.file_size::text, '0'),"
+ " coalesce(a.mimetype, ''), coalesce(a.create_date::text, '')"
+ " FROM ir_attachment a WHERE a.store_fname IS NOT NULL"
+ " ORDER BY a.file_size DESC",
+ )
+ if lignes is None:
+ return None
+ return [
+ {
+ "store_fname": ligne[0],
+ "model": ligne[1],
+ "id": ligne[2],
+ "field": ligne[3],
+ "res_id": ligne[4],
+ "name": ligne[5],
+ "size": int(ligne[6] or 0),
+ "mimetype": ligne[7],
+ "created": ligne[8][:10],
+ }
+ for ligne in lignes
+ if len(ligne) >= 9
+ ]
+
+
+def live_fields(database):
+ """Les champs qui EXISTENT encore, en « modele.champ ».
+
+ Sans eux, une pièce jointe orpheline d'un champ supprimé passerait
+ pour une perte alors qu'il n'y a rien à perdre.
+ """
+ lignes = run_psql(
+ database, "SELECT model || '.' || name FROM ir_model_fields"
+ )
+ return {ligne[0] for ligne in lignes or [] if ligne and ligne[0]}
+
+
+def scan_filestores(racine, sauf=None):
+ """{store_fname: base} pour toutes les bases, la nôtre exclue.
+
+ Les fichiers NICHÉS sont indexés sous leur nom logique — c'est ce
+ qu'on cherchera — mais notés à part : les remettre en place est un
+ déplacement, pas une copie depuis ailleurs.
+ """
+ ailleurs, niches, par_base = {}, {}, {}
+ if not os.path.isdir(racine):
+ return ailleurs, niches, par_base
+ for base in sorted(os.listdir(racine)):
+ chemin = os.path.join(racine, base)
+ if not os.path.isdir(chemin):
+ continue
+ for prefixe in sorted(os.listdir(chemin)):
+ sous = os.path.join(chemin, prefixe)
+ if not os.path.isdir(sous):
+ continue
+ if prefixe == "filestore":
+ # Un même fichier peut être niché dans PLUSIEURS bases —
+ # le clone les recopie toutes. L'index n'en retient qu'une
+ # (le premier `setdefault` gagne) : il sert à retrouver un
+ # fichier, pas à compter. D'où le décompte par base, sans
+ # lequel une base nichée s'entendait dire que le problème
+ # était chez les voisines.
+ for deux in sorted(os.listdir(sous)):
+ profond = os.path.join(sous, deux)
+ if not os.path.isdir(profond):
+ continue
+ for nom in os.listdir(profond):
+ niches.setdefault(f"{deux}/{nom}", base)
+ par_base[base] = par_base.get(base, 0) + 1
+ continue
+ if base == sauf:
+ continue
+ for nom in os.listdir(sous):
+ ailleurs.setdefault(f"{prefixe}/{nom}", base)
+ return ailleurs, niches, par_base
+
+
+def scan_backups(dossier):
+ """{store_fname: zip} en lisant le répertoire central, sans extraire."""
+ trouves = {}
+ if not os.path.isdir(dossier):
+ return trouves
+ for nom in sorted(os.listdir(dossier)):
+ if not nom.endswith(".zip"):
+ continue
+ chemin = os.path.join(dossier, nom)
+ try:
+ with zipfile.ZipFile(chemin) as archive:
+ for membre in archive.namelist():
+ if membre.startswith("filestore/") and not membre.endswith(
+ "/"
+ ):
+ trouves.setdefault(membre[len("filestore/") :], nom)
+ except (OSError, zipfile.BadZipFile):
+ continue
+ return trouves
+
+
+def resources_alive(database, pieces):
+ """{(modele, id): True/False} — l'enregistrement visé existe-t-il ?
+
+ Une image perdue dont la tâche a été supprimée n'est pas une perte :
+ personne ne la cherchera jamais. Une image perdue sur une tâche
+ VIVANTE en est une, et c'est la seule qu'il faille regretter.
+ Confondre les deux, c'est pleurer au hasard.
+
+ Une requête par modèle, sur les seules pièces jointes déjà classées
+ perdues — jamais sur les milliers d'autres.
+ """
+ par_modele = {}
+ for piece in pieces:
+ if not piece.get("model") or not (piece.get("res_id") or "").isdigit():
+ continue
+ par_modele.setdefault(piece["model"], set()).add(int(piece["res_id"]))
+ vivants = {}
+ for modele, ids in par_modele.items():
+ table = modele.replace(".", "_").replace("'", "")
+ # Un modèle abstrait ou transitoire n'a pas de table : interroger
+ # une table absente rendrait None, qu'on lirait « n'existe pas ».
+ # Ce serait un mensonge, et le pire sens du mensonge ici.
+ existe = run_psql(
+ database, f"SELECT to_regclass('{table}') IS NOT NULL"
+ )
+ if not existe or existe[0][0] != "t":
+ continue
+ liste = ", ".join(str(i) for i in sorted(ids))
+ lignes = run_psql(
+ database, f"SELECT id FROM {table} WHERE id IN ({liste})"
+ )
+ if lignes is None:
+ continue
+ trouves = {int(ligne[0]) for ligne in lignes if ligne and ligne[0]}
+ for identifiant in ids:
+ vivants[(modele, str(identifiant))] = identifiant in trouves
+ return vivants
+
+
+def files_on_disk(racine, database):
+ """(au bon niveau, nichés) pour une base. Deux ensembles de noms."""
+ base = os.path.join(racine, database)
+ bons, niches = set(), set()
+ if not os.path.isdir(base):
+ return bons, niches
+ for prefixe in os.listdir(base):
+ sous = os.path.join(base, prefixe)
+ if not os.path.isdir(sous):
+ continue
+ if prefixe == "filestore":
+ for deux in os.listdir(sous):
+ profond = os.path.join(sous, deux)
+ if not os.path.isdir(profond):
+ continue
+ for nom in os.listdir(profond):
+ if os.path.isfile(os.path.join(profond, nom)):
+ niches.add(f"{deux}/{nom}")
+ continue
+ for nom in os.listdir(sous):
+ if os.path.isfile(os.path.join(sous, nom)):
+ bons.add(f"{prefixe}/{nom}")
+ return bons, niches
+
+
+def verify_restore(database, zip_path, config_path=None):
+ """La sauvegarde a-t-elle bien atterri ? À vérifier UNE fois.
+
+ Après un clone, il n'y a rien à contrôler : `copytree` recopie la
+ source telle quelle, défauts compris — le contrôle appartient à la
+ restauration d'origine, pas au miroir.
+
+ Ce qu'on cherche est précis. `shutil.move` d'Odoo renomme quand la
+ destination n'existe pas et IMBRIQUE quand elle existe. Un dossier
+ `filestore//` laissé par une restauration précédente suffit
+ donc à envoyer toute la sauvegarde dans
+ `filestore//filestore/`, où Odoo ne regardera jamais. Mesuré :
+ 1168 fichiers, 133 Mo, recopiés ensuite dans les six bases de la
+ chaîne par le clone, sans que rien ne le signale.
+ """
+ attendus = set(scan_zip(zip_path))
+ racine = filestore_root(config_path)
+ bons, niches = files_on_disk(racine, database)
+ return {
+ "database": database,
+ "zip": os.path.basename(zip_path),
+ "expected": len(attendus),
+ "placed": len(attendus & bons),
+ "nested": len(attendus & niches),
+ "missing": len(attendus - bons - niches),
+ "root": os.path.join(racine, database),
+ }
+
+
+def scan_zip(chemin):
+ """Les noms de fichiers du `filestore/` d'une sauvegarde."""
+ try:
+ with zipfile.ZipFile(chemin) as archive:
+ return [
+ membre[len("filestore/") :]
+ for membre in archive.namelist()
+ if membre.startswith("filestore/") and not membre.endswith("/")
+ ]
+ except (OSError, zipfile.BadZipFile):
+ return []
+
+
+def render_verify(rapport):
+ """Se taire quand tout va bien : un contrôle bavard finit ignoré."""
+ if not rapport["expected"]:
+ return []
+ if not rapport["nested"] and not rapport["missing"]:
+ return [
+ f"✅ {t('Filestore restored:')} {rapport['placed']}"
+ f"/{rapport['expected']} {t('file(s) in place')}"
+ ]
+ lignes = [
+ f"⚠ {t('Filestore restore looks wrong for')} {rapport['database']}"
+ f" ({rapport['zip']}) :",
+ f" {rapport['placed']}/{rapport['expected']}"
+ f" {t('file(s) in place')}",
+ ]
+ if rapport["nested"]:
+ lignes.append(
+ f" {rapport['nested']}"
+ f" {t('landed in a nested filestore Odoo never reads')}"
+ )
+ lignes.append(
+ f" {t('To fix:')} rsync -a --remove-source-files"
+ f" {rapport['root']}/filestore/ {rapport['root']}/"
+ )
+ if rapport["missing"]:
+ lignes.append(f" {rapport['missing']} {t('never landed at all')}")
+ return lignes
+
+
+def is_dead_field(piece, champs_vivants):
+ """La pièce jointe porte-t-elle un champ qui n'existe plus ?
+
+ Sans `res_field` la question ne se pose pas : c'est un document
+ téléversé, pas la valeur d'un champ. Le juger sur un champ absent le
+ ferait disparaître du rapport.
+ """
+ if not piece.get("field"):
+ return False
+ return f"{piece['model']}.{piece['field']}" not in champs_vivants
+
+
+def classify(piece, present, ailleurs, niches, sauvegardes, champs_vivants):
+ """Le verdict d'une pièce jointe. None si son fichier est là.
+
+ L'ordre des questions est l'ordre de l'action à mener : d'abord
+ « y a-t-il seulement quelque chose à récupérer », puis « où ».
+ """
+ if piece["store_fname"] in present:
+ return None
+ # Un champ disparu n'a rien à récupérer : la ligne est une scorie.
+ # Le tester EN PREMIER évite de proposer une remise en place inutile.
+ if is_dead_field(piece, champs_vivants):
+ return ("dead_field", f"{piece['model']}.{piece['field']}")
+ if piece["store_fname"] in niches:
+ return ("nested", niches[piece["store_fname"]])
+ if piece["store_fname"] in ailleurs:
+ return ("in_other_filestore", ailleurs[piece["store_fname"]])
+ if piece["store_fname"] in sauvegardes:
+ return ("in_backup", sauvegardes[piece["store_fname"]])
+ return ("lost", None)
+
+
+def audit(database, config_path=None, backups=None):
+ """Tout ce qu'il faut savoir, en une passe. Lecture seule."""
+ pieces = attachments(database)
+ if pieces is None:
+ return {"unavailable": True, "database": database}
+ racine = filestore_root(config_path)
+ mien = os.path.join(racine, database)
+ # `root` désigne le dossier de CETTE base. Y mettre la racine de tous
+ # les filestores faisait chercher le dossier imbriqué à
+ # `/filestore/filestore` — inexistant — et l'outil
+ # répondait « rien à ranger » devant 1168 fichiers échoués.
+ present = set()
+ if os.path.isdir(mien):
+ for prefixe in os.listdir(mien):
+ sous = os.path.join(mien, prefixe)
+ if prefixe == "filestore" or not os.path.isdir(sous):
+ continue
+ for nom in os.listdir(sous):
+ if os.path.isfile(os.path.join(sous, nom)):
+ present.add(f"{prefixe}/{nom}")
+ ailleurs, niches, par_base = scan_filestores(racine, sauf=database)
+ sauvegardes = scan_backups(backups or os.path.join(REPO_ROOT, "image_db"))
+ champs_vivants = live_fields(database)
+
+ groupes = {verdict: [] for verdict in VERDICTS}
+ vus = set()
+ morts = []
+ gardees = []
+ for piece in pieces:
+ verdict = classify(
+ piece, present, ailleurs, niches, sauvegardes, champs_vivants
+ )
+ if not verdict:
+ # Fichier présent — mais son champ vit-il encore ? Un outil
+ # nommé « fichiers absents » ne regardait pas là, et laissait
+ # dormir 1860 lignes et 30 Mo que plus rien ne lit.
+ if is_dead_field(piece, champs_vivants):
+ gardees.append(piece)
+ if str(piece.get("id", "")).isdigit():
+ morts.append(int(piece["id"]))
+ continue
+ # La déduplication qui suit sert à compter des FICHIERS. Pour
+ # effacer des LIGNES il les faut toutes : vingt-deux lignes
+ # partageaient deux fichiers, et la purge n'en offrait qu'une à
+ # la fois — il fallait la relancer vingt-deux fois.
+ if verdict[0] == "dead_field" and str(piece.get("id", "")).isdigit():
+ morts.append(int(piece["id"]))
+ # Plusieurs pièces jointes partagent un fichier quand leur contenu
+ # est identique : le compter une fois par ligne gonflerait le
+ # rapport sans ajouter un seul fichier à récupérer.
+ if piece["store_fname"] in vus:
+ continue
+ vus.add(piece["store_fname"])
+ groupes[verdict[0]].append(dict(piece, where=verdict[1]))
+ vivants = resources_alive(database, groupes["lost"])
+ for piece in groupes["lost"]:
+ piece["alive"] = vivants.get((piece["model"], piece["res_id"]))
+ return {
+ "database": database,
+ "attachments": len(pieces),
+ "files_present": len(present),
+ "missing": len(vus),
+ "groups": groupes,
+ "nested_total": par_base.get(database, 0),
+ "nested_elsewhere": sum(
+ combien for base, combien in par_base.items() if base != database
+ ),
+ "root": mien,
+ "dead_ids": morts,
+ "dead_kept": gardees,
+ "dead_kept_size": sum(piece["size"] for piece in gardees),
+ }
+
+
+def render(rapport, limit=20):
+ if rapport.get("unavailable"):
+ return [f"❌ {t('Cannot read the database: ')}{rapport['database']}"]
+ lignes = [
+ f"🗄 {t('Filestore of')} {rapport['database']} :"
+ f" {rapport['attachments']} {t('stored attachment(s)')},"
+ f" {rapport['files_present']} {t('file(s) on disk')}"
+ ]
+ if not rapport["missing"]:
+ lignes.append(f" ✅ {t('every attachment file is present')}")
+ return lignes + render_dead_kept(rapport) + render_nested(rapport)
+ lignes.append(f" {rapport['missing']} {t('file(s) missing')} :")
+ for verdict in VERDICTS:
+ groupe = rapport["groups"][verdict]
+ if not groupe:
+ continue
+ poids = sum(piece["size"] for piece in groupe)
+ lignes.append(
+ f" {ICONE[verdict]} {len(groupe)} {t(EXPLICATION[verdict])}"
+ f" ({poids // 1024} ko)"
+ )
+ # On ne nomme QUE l'irrécupérable : c'est la seule liste devant
+ # laquelle il y a une décision à prendre. Nommer les autres
+ # ferait défiler trois cents lignes sans rien apprendre.
+ if verdict != "lost":
+ apercu = summarise(groupe)
+ for texte in apercu[:4]:
+ lignes.append(f" {texte}")
+ if len(apercu) > 4:
+ lignes.append(f" … {len(apercu) - 4} {t('more')}")
+ continue
+ for piece in groupe[: limit or None]:
+ ou = (
+ f"{piece['model']} #{piece['res_id']}"
+ if piece["model"]
+ else "—"
+ )
+ champ = f" [{piece['field']}]" if piece["field"] else ""
+ lignes.append(
+ f" {piece['name'][:44] or '(sans nom)':<46}"
+ f" {piece['size'] // 1024:>6} ko {ou}{champ}"
+ f" {piece['created']} {alive_mark(piece)}"
+ )
+ if limit and len(groupe) > limit:
+ lignes.append(f" … {len(groupe) - limit} {t('more')}")
+ return lignes + render_dead_kept(rapport) + render_nested(rapport)
+
+
+def render_dead_kept(rapport, limit=4):
+ """Les lignes mortes dont le FICHIER est toujours là.
+
+ Elles ne manquent à personne — c'est justement le problème : rien ne
+ les lit, et leur fichier occupe le disque tant que la ligne existe,
+ puisque le ramasse-miettes d'Odoo ne retire que ce qui n'est plus
+ référencé. Un outil nommé « fichiers absents » ne regardait pas là,
+ et laissait dormir 1860 lignes et 30 Mo.
+ """
+ gardees = rapport.get("dead_kept") or []
+ if not gardees:
+ return []
+ lignes = [
+ "",
+ f" 🕳 {len(gardees)}"
+ f" {t('row(s) whose field is gone still hold their file')}"
+ f" ({rapport.get('dead_kept_size', 0) // 1024} ko)",
+ ]
+ apercu = summarise(gardees)
+ for texte in apercu[: limit or None]:
+ lignes.append(f" {texte}")
+ if limit and len(apercu) > limit:
+ lignes.append(f" … {len(apercu) - limit} {t('more')}")
+ return lignes
+
+
+def render_nested(rapport):
+ ailleurs = rapport.get("nested_elsewhere") or 0
+ if not rapport.get("nested_total"):
+ # Rien ici, mais peut-être chez les voisines : le dire sans
+ # laisser croire que CETTE base est concernée.
+ if ailleurs:
+ return [
+ "",
+ f" ↳ {ailleurs}"
+ f" {t('such file(s) sit in OTHER databases filestores.')}",
+ ]
+ return []
+ lignes = [
+ "",
+ f" ↳ {rapport['nested_total']}"
+ f" {t('file(s) sit in a nested filestore Odoo never reads.')}",
+ ]
+ return lignes
+
+
+def alive_mark(piece):
+ """Dire si l'enregistrement visé vit encore, ou qu'on ne sait pas.
+
+ Trois états, pas deux : « on n'a pas pu vérifier » ne doit pas se
+ lire comme « il a disparu », sans quoi une vraie perte serait classée
+ en fausse alerte.
+ """
+ etat = piece.get("alive")
+ if etat is True:
+ return f"✓ {t('record still exists')}"
+ if etat is False:
+ return f"✗ {t('record is gone — nothing will miss it')}"
+ return ""
+
+
+def summarise(groupe):
+ """« modele / champ × N », pour dire beaucoup en peu de lignes."""
+ compte = {}
+ for piece in groupe:
+ cle = f"{piece['model'] or '—'} / {piece['field'] or '—'}"
+ compte[cle] = compte.get(cle, 0) + 1
+ return [
+ f"{cle} × {combien}" if combien > 1 else cle
+ for cle, combien in sorted(compte.items(), key=lambda x: -x[1])
+ ]
+
+
+def purge_dead_sql(rapport):
+ """Le SQL qui efface les lignes dont le champ n'existe plus, ou "".
+
+ On efface par IDENTIFIANT, pas par un domaine reconstruit : la liste
+ a été établie en confrontant `ir_model_fields` à ce que la base
+ porte, et rejouer ce raisonnement en SQL laisserait la porte ouverte
+ à effacer autre chose que ce qui a été montré.
+ """
+ ids = sorted(set(rapport.get("dead_ids") or []))
+ if not ids:
+ return ""
+ liste = ", ".join(str(i) for i in ids)
+ return f"DELETE FROM ir_attachment WHERE id IN ({liste})"
+
+
+def rows_deleted(sortie):
+ """Le nombre annoncé par « DELETE n », ou None si rien ne le dit.
+
+ None n'est pas zéro : « la commande n'a rien annoncé » et « elle n'a
+ rien supprimé » appellent des mots différents. Les confondre ferait
+ taire une panne ou inventer un succès.
+ """
+ lignes = sortie if isinstance(sortie, list) else str(sortie).splitlines()
+ for ligne in reversed([str(x).strip() for x in lignes]):
+ if ligne.startswith("DELETE "):
+ reste = ligne[len("DELETE ") :].strip()
+ if reste.isdigit():
+ return int(reste)
+ return None
+
+
+def nested_dir(rapport):
+ """Le dossier imbriqué de cette base, s'il en existe un."""
+ chemin = os.path.join(rapport["root"], "filestore")
+ return chemin if os.path.isdir(chemin) else ""
+
+
+def tidy_nested_plan(rapport):
+ """(à remonter, doublons) parmi les fichiers imbriqués.
+
+ Deux tas très différents : ce qui MANQUE au bon niveau doit y
+ remonter, ce qui y est déjà est un doublon pur. Les traiter d'un
+ bloc écraserait des fichiers présents par des copies — inutile, et
+ inquiétant sur une base de production.
+ """
+ dossier = nested_dir(rapport)
+ if not dossier:
+ return [], []
+ bons, _n = files_on_disk(
+ os.path.dirname(rapport["root"]), os.path.basename(rapport["root"])
+ )
+ remonter, doublons = [], []
+ for deux in sorted(os.listdir(dossier)):
+ profond = os.path.join(dossier, deux)
+ if not os.path.isdir(profond):
+ continue
+ for nom in sorted(os.listdir(profond)):
+ complet = os.path.join(profond, nom)
+ if not os.path.isfile(complet):
+ continue
+ (doublons if f"{deux}/{nom}" in bons else remonter).append(
+ (complet, os.path.join(rapport["root"], deux, nom))
+ )
+ return remonter, doublons
+
+
+def main(argv=None):
+ import argparse
+
+ parser = argparse.ArgumentParser(
+ description=(
+ "List attachments whose file is gone, and say which ones can"
+ " still be recovered and from where."
+ )
+ )
+ parser.add_argument("-d", "--database", required=True)
+ parser.add_argument("-c", "--config", help="odoo config file")
+ parser.add_argument("--backups", help="directory of backup .zip files")
+ parser.add_argument("--limit", type=int, default=20)
+ parser.add_argument("--json", action="store_true")
+ config = parser.parse_args(argv)
+
+ rapport = audit(config.database, config.config, config.backups)
+ if rapport.get("unavailable"):
+ print(f"❌ {t('Cannot read the database: ')}{config.database}")
+ return 2
+ if config.json:
+ import json
+
+ print(
+ json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
+ )
+ else:
+ print("\n".join(render(rapport, config.limit)))
+ return 1 if rapport["groups"]["lost"] else 0
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/script/analyse/check_module_package.py b/script/analyse/check_module_package.py
new file mode 100755
index 0000000..1cbb9e3
--- /dev/null
+++ b/script/analyse/check_module_package.py
@@ -0,0 +1,547 @@
+#!/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_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())
diff --git a/script/database/db_restore.py b/script/database/db_restore.py
index e377394..8cbac35 100755
--- a/script/database/db_restore.py
+++ b/script/database/db_restore.py
@@ -7,9 +7,15 @@ import configparser
import getpass
import logging
import os
+import shutil
import sys
+import subprocess
from subprocess import check_output
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
+)
+
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
_logger = logging.getLogger(__name__)
@@ -78,6 +84,71 @@ def get_master_password():
_logger.error("Password echoed, danger!")
+# Assez pour une faute de frappe répétée, pas assez pour qu'une boucle
+# oubliée tourne toute la nuit devant une invite que personne ne lit.
+MAX_ESSAIS_MOT_DE_PASSE = 10
+
+
+def password_refused(sortie):
+ """Odoo a-t-il refusé le mot de passe maître, ou autre chose ?
+
+ La distinction porte tout. Reposer la question sur n'importe quel
+ échec cacherait la vraie panne derrière dix invites, et l'on
+ chercherait un mot de passe alors que la base est cassée.
+
+ Odoo lève `AccessDenied` — la classe apparaît dans la trace, et son
+ message traduit peut varier. On reconnaît donc la CLASSE.
+ """
+ return "AccessDenied" in (sortie or "")
+
+
+def probe_master_password(arg_base):
+ """(accepté, sortie) — éprouver le mot de passe sur `--list`.
+
+ La commande la plus inoffensive : elle ne touche à rien et rend le
+ même refus qu'une restauration. Valider ici évite d'échouer à
+ mi-parcours, une fois la base déjà supprimée.
+ """
+ done = subprocess.run(
+ f"{arg_base} --list".split(" "),
+ capture_output=True,
+ text=True,
+ )
+ return done.returncode == 0, (done.stdout or "") + (done.stderr or "")
+
+
+def ask_master_password(arg_base, essais=MAX_ESSAIS_MOT_DE_PASSE):
+ """Le mot de passe maître, redemandé tant qu'Odoo le refuse.
+
+ None si l'on renonce — invite vide, essais épuisés, ou panne qui
+ n'a rien à voir avec le mot de passe.
+
+ Une faute de frappe arrêtait la migration net, sur une trace
+ `CalledProcessError` que rien n'attrapait. Après une heure de
+ paliers, c'est cher payé pour une lettre.
+ """
+ for tour in range(1, essais + 1):
+ mot = get_master_password()
+ if not mot:
+ return None
+ candidat = f"{arg_base} --master_password={mot}"
+ accepte, sortie = probe_master_password(candidat)
+ if accepte:
+ return mot
+ if not password_refused(sortie):
+ # Autre chose est cassé : le dire, et ne pas noyer la panne
+ # sous dix invites de mot de passe.
+ _logger.error(sortie.strip()[-1500:])
+ return None
+ restants = essais - tour
+ if restants:
+ _logger.warning(
+ f"Master password refused, {restants} attempt(s) left."
+ )
+ _logger.error("Master password refused too many times.")
+ return None
+
+
def get_list_db_cache(arg_base):
arg = f"{arg_base} --list"
out = check_output(arg.split(" ")).decode()
@@ -86,6 +157,109 @@ def get_list_db_cache(arg_base):
return lst_db, lst_db_cache
+def verify_filestore(database, image):
+ """Contrôler qu'une restauration a bien posé ses fichiers.
+
+ Une seule fois, à la restauration d'origine. Après un clone il n'y a
+ rien à vérifier : `copytree` recopie la source telle quelle, défauts
+ compris — le contrôle appartient à ce qui a créé le défaut, pas à ce
+ qui l'a dupliqué.
+
+ Le contrôle n'interrompt pas : la base est restaurée et utilisable,
+ c'est la DISPOSITION des fichiers qui est suspecte. Refuser ici
+ casserait des chaînes qui marchent, pour un défaut qui se répare
+ d'une commande.
+ """
+ chemin = os.path.join("image_db", f"{image}.zip")
+ if not os.path.isfile(chemin):
+ return
+ try:
+ from script.analyse import check_filestore
+ except Exception: # pragma: no cover - l'outil d'analyse est optionnel
+ return
+ rapport = check_filestore.verify_restore(database, chemin)
+ for ligne in check_filestore.render_verify(rapport):
+ print(ligne)
+ if rapport.get("nested"):
+ offer_tidy(check_filestore, rapport)
+
+
+def offer_tidy(check_filestore, rapport):
+ """Proposer de ranger TOUT DE SUITE, là où le défaut naît.
+
+ C'est le seul endroit qui vaille. Le nichage se produit une fois, à
+ la restauration, puis le clone le recopie tel quel : mesuré, les six
+ bases de la chaîne portaient les mêmes 1168 fichiers. Ranger ici,
+ c'est ranger une fois ; ranger plus tard, c'est six fois.
+
+ Rien ne se fait sans réponse humaine, et rien du tout hors d'un
+ terminal : ce script tourne aussi sans personne devant, et une
+ question posée à un `stdin` fermé arrêterait la migration.
+ """
+ if not sys.stdin.isatty():
+ return
+ remonter, doublons = check_filestore.tidy_nested_plan(rapport)
+ if not remonter and not doublons:
+ return
+ print(f" {len(remonter)} à remonter, {len(doublons)} doublons purs")
+ try:
+ reponse = input("💬 Ranger maintenant ? (y/N) : ").strip().lower()
+ except EOFError:
+ return
+ if reponse not in ("y", "yes", "o"):
+ return
+ for source, cible in remonter:
+ os.makedirs(os.path.dirname(cible), exist_ok=True)
+ shutil.move(source, cible)
+ for source, _cible in doublons:
+ os.remove(source)
+ shutil.rmtree(check_filestore.nested_dir(rapport), ignore_errors=True)
+ print(f"✅ {len(remonter)} remontés, {len(doublons)} doublons supprimés.")
+
+
+def restore_or_clone(config, arg_base, cache_database, lst_db_cache):
+ """Restaurer depuis l'image, ou cloner le cache déjà restauré.
+
+ Le contrôle du filestore ne suit QUE les vraies restaurations. Le
+ clone recopie sa source telle quelle : contrôler le miroir dirait
+ deux fois la même chose, et la seconde au mauvais endroit.
+ """
+ if cache_database not in lst_db_cache and not config.ignore_cache:
+ _logger.info(
+ f"## Create cache {cache_database} from image {config.image} ##"
+ )
+ arg = (
+ f"{arg_base} --restore"
+ f" --restore_image {config.image} --database {cache_database}"
+ )
+ print(check_output(arg.split(" ")).decode())
+ verify_filestore(cache_database, config.image)
+
+ if config.ignore_cache:
+ _logger.info(
+ f"## Restoring {config.image} to database {config.database} ##"
+ )
+ arg = (
+ f"{arg_base} --restore --restore_image"
+ f" {config.image} --database {config.database}"
+ )
+ else:
+ _logger.info(
+ f"## Clone cache {cache_database} to database"
+ f" {config.database} ##"
+ )
+ arg = (
+ f"{arg_base} --clone --from_database"
+ f" {cache_database} --database {config.database}"
+ )
+ if config.neutralize:
+ arg += " --neutralize"
+ print(arg)
+ print(check_output(arg.split(" ")).decode())
+ if config.ignore_cache:
+ verify_filestore(config.database, config.image)
+
+
def main():
config = get_config()
@@ -109,12 +283,11 @@ def main():
has_admin_password = config_parser.get("options", "admin_passwd")
if has_admin_password and has_admin_password != "admin":
- master_password = get_master_password()
+ master_password = ask_master_password(arg_base)
if not master_password:
_logger.error("Missing master password, cancel transaction.")
sys.exit(1)
- else:
- arg_base += f" --master_password={master_password}"
+ arg_base += f" --master_password={master_password}"
else:
_logger.info("No master password needed... Continue")
@@ -139,40 +312,7 @@ def main():
print(out)
if config.only_drop:
return
- # Check cache exist
- if cache_database not in lst_db_cache and not config.ignore_cache:
- _logger.info(
- f"## Create cache {cache_database} from image"
- f" {config.image} ##"
- )
- arg = (
- f"{arg_base} --restore"
- f" --restore_image {config.image} --database {cache_database}"
- )
- out = check_output(arg.split(" ")).decode()
- print(out)
- # Clone database
- if config.ignore_cache:
- _logger.info(
- f"## Restoring {config.image} to database {config.database} ##"
- )
- arg = (
- f"{arg_base} --restore --restore_image"
- f" {config.image} --database {config.database}"
- )
- else:
- _logger.info(
- f"## Clone cache {cache_database} to database {config.database} ##"
- )
- arg = (
- f"{arg_base} --clone --from_database"
- f" {cache_database} --database {config.database}"
- )
- if config.neutralize:
- arg += " --neutralize"
- print(arg)
- out = check_output(arg.split(" ")).decode()
- print(out)
+ restore_or_clone(config, arg_base, cache_database, lst_db_cache)
if not config.clean_cache and not config.database:
print("Nothing to do.")
diff --git a/script/todo/todo.py b/script/todo/todo.py
index 07ab2f4..d8ea5c6 100755
--- a/script/todo/todo.py
+++ b/script/todo/todo.py
@@ -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")},
@@ -10185,6 +10190,18 @@ class TODO:
{"prompt_description": t("Studio and hand-made x_ fields")},
{"section": t("Migration")},
{"prompt_description": t("Quality of a migration, step by step")},
+ {"section": t("Modules")},
+ {
+ "prompt_description": t(
+ "Modules missing from the default package"
+ )
+ },
+ {"section": t("Files")},
+ {
+ "prompt_description": t(
+ "Attachment files missing from the filestore"
+ )
+ },
]
help_info = self.fill_help_info(choices)
@@ -10201,9 +10218,288 @@ class TODO:
self.execute_analyse_custom_field()
elif status == "4":
self.execute_analyse_migration_quality()
+ elif status == "5":
+ self.execute_analyse_module_package()
+ elif status == "6":
+ self.execute_analyse_filestore()
else:
print(t("Command not found !"))
+ def execute_analyse_module_package(self):
+ """Ce que la base n'a pas, alors que l'installation par défaut l'a.
+
+ Pas de choix « sauvegarde .zip » ici, contrairement aux autres
+ analyses : l'outil interroge `ir_module_module`, qu'un zip
+ n'expose pas sans restauration. Proposer l'option pour la refuser
+ ensuite ferait perdre le temps de la choisir.
+ """
+ from script.analyse import check_module_package as modules
+
+ database = self._analyse_select_database()
+ if not database:
+ return
+ try:
+ rapport = modules.audit(database)
+ except Exception as exc:
+ print(f"❌ {t('Analysis failed: ')}{exc}")
+ return
+ if rapport.get("unavailable"):
+ 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:
+ print("\n".join(modules.render(rapport, limit=0)))
+ elif rank == 2:
+ for nom in sorted(modules.read_packages()):
+ print(f" {nom}")
+ else:
+ self._analyse_export_json(
+ rapport, os.path.basename(database), "module_package"
+ )
+
+ self._analyse_follow_up(
+ [
+ {"prompt_description": t("Show every entry")},
+ {"prompt_description": t("List the known packages")},
+ {"prompt_description": t("Export as JSON")},
+ ],
+ handler,
+ )
+
+ def execute_analyse_filestore(self):
+ """Ce qui manque au filestore, et ce qu'on peut encore récupérer.
+
+ Pas d'option « sauvegarde .zip » : l'outil compare une BASE à son
+ filestore, et un zip porte les deux ensemble par construction —
+ il n'y a rien à y trouver.
+ """
+ from script.analyse import check_filestore as filestore
+
+ database = self._analyse_select_database()
+ if not database:
+ return
+ print(f"⧖ {t('Scanning filestores and backups…')}")
+ try:
+ rapport = filestore.audit(database)
+ except Exception as exc:
+ print(f"❌ {t('Analysis failed: ')}{exc}")
+ return
+ if rapport.get("unavailable"):
+ print(f"❌ {t('Cannot read the database: ')}{database}")
+ return
+ etat = {"rapport": rapport}
+ print("\n".join(filestore.render(rapport, limit=20)))
+
+ def relire():
+ """Relire APRÈS une réparation.
+
+ Sans cela « Tout afficher » rejouait le rapport d'avant :
+ on purgeait, on relisait, et l'on voyait encore ce qui
+ venait de disparaître. Pire, on repurgeait des lignes déjà
+ effacées en croyant le travail inachevé.
+ """
+ print(f"⧖ {t('Scanning filestores and backups…')}")
+ etat["rapport"] = filestore.audit(database)
+
+ def handler(rank):
+ if rank == 1:
+ print("\n".join(filestore.render(etat["rapport"], limit=0)))
+ elif rank == 2:
+ if self._filestore_purge_dead(database, etat["rapport"]):
+ relire()
+ elif rank == 3:
+ if self._filestore_tidy_nested(etat["rapport"]):
+ relire()
+ else:
+ self._analyse_export_json(
+ etat["rapport"], os.path.basename(database), "filestore"
+ )
+
+ self._analyse_follow_up(
+ [
+ {"prompt_description": t("Show every entry")},
+ {
+ "prompt_description": t(
+ "🧹 Purge attachments whose field no longer exists"
+ )
+ },
+ {
+ "prompt_description": t(
+ "🧹 Tidy the nested filestore Odoo never reads"
+ )
+ },
+ {"prompt_description": t("Export as JSON")},
+ ],
+ handler,
+ )
+
+ def _filestore_purge_dead(self, database, rapport):
+ """Effacer les pièces jointes dont le champ a disparu.
+
+ La seule ÉCRITURE en base de tout le menu Analyse. Elle porte sur
+ des lignes que plus rien ne lit — `res.country.image` est devenu
+ `image_url`, calculé, en 13 — mais elle reste une suppression :
+ question explicite, défaut à « non », et le compte est relu avant
+ de partir.
+ """
+ from script.analyse import check_filestore as filestore
+ from script.todo import auto_ask
+
+ lignes = rapport["groups"]["dead_field"]
+ sql = filestore.purge_dead_sql(rapport)
+ if not sql:
+ print(f"ℹ️ {t('Nothing to purge.')}")
+ return False
+ print()
+ for texte in filestore.summarise(lignes):
+ print(f" {texte}")
+ question = (
+ f"💬 {t('Delete these')} {len(lignes)}"
+ f" {t('attachment row(s) for good?')} (y/N) : "
+ )
+ if auto_ask.ask(question, default="n").strip().lower() not in (
+ "y",
+ "yes",
+ "o",
+ ):
+ print(f"ℹ️ {t('Nothing was deleted.')}")
+ return False
+ status, sortie = self.execute.exec_command_live(
+ f'psql -d {database} -c "{sql}"',
+ source_erplibre=False,
+ single_source_erplibre=True,
+ return_status_and_output=True,
+ )
+ if status:
+ print(f"❌ {t('The purge failed.')}")
+ return False
+ # Le nombre ANNONCÉ par PostgreSQL, pas celui qu'on espérait :
+ # rejouer une purge déjà faite rendait « DELETE 0 » et l'outil
+ # se félicitait quand même d'avoir supprimé.
+ efface = filestore.rows_deleted(sortie)
+ if efface is None:
+ print(f"⚠ {t('The purge ran but said nothing.')}")
+ return True
+ print(f"✅ {efface} {t('attachment row(s) deleted.')}")
+ return True
+
+ def _filestore_tidy_nested(self, rapport):
+ """Remonter ce qui manque, effacer les doublons purs.
+
+ Deux tas, deux gestes. Écraser un fichier présent par une copie
+ identique ne gagnerait rien et brouillerait la trace ; c'est
+ pourquoi les doublons sont comptés à part et jamais déplacés.
+ """
+ from script.analyse import check_filestore as filestore
+ from script.todo import auto_ask
+
+ remonter, doublons = filestore.tidy_nested_plan(rapport)
+ if not remonter and not doublons:
+ print(f"ℹ️ {t('No nested filestore to tidy.')}")
+ return False
+ print()
+ print(f" {len(remonter)} {t('file(s) to move up')}")
+ print(f" {len(doublons)} {t('pure duplicate(s) to delete')}")
+ print(f" {t('Directory:')} {filestore.nested_dir(rapport)}")
+ if auto_ask.ask(
+ f"💬 {t('Go ahead?')} (y/N) : ", default="n"
+ ).strip().lower() not in ("y", "yes", "o"):
+ print(f"ℹ️ {t('Nothing was moved.')}")
+ return False
+ deplaces, effaces = 0, 0
+ for source, cible in remonter:
+ os.makedirs(os.path.dirname(cible), exist_ok=True)
+ shutil.move(source, cible)
+ deplaces += 1
+ for source, _cible in doublons:
+ os.remove(source)
+ effaces += 1
+ dossier = filestore.nested_dir(rapport)
+ if dossier:
+ shutil.rmtree(dossier, ignore_errors=True)
+ print(
+ f"✅ {deplaces} {t('moved up')}, {effaces}"
+ f" {t('duplicate(s) removed')}."
+ )
+ return True
+
+ 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.
@@ -10374,6 +10670,8 @@ class TODO:
def run(**kwargs):
try:
+ # Une sauvegarde compare déjà ses copies COW à la lecture :
+ # les deux arch sont dans le dump, il n'y a rien à demander.
state["data"] = (
analyse.collect_from_backup(database)
if is_backup
@@ -10430,6 +10728,19 @@ class TODO:
print(analyse.render(data, verbose=True, hints=False))
lst_choice = [{"prompt_description": t("Show every view")}]
+ # La comparaison des copies COW n'a besoin d'aucun registre : les deux
+ # arch sont dans la base, appariées par leur clé. Elle est donc offerte
+ # partout, y compris sur une sauvegarde et sur une base dont la version
+ # diffère du checkout — là où l'autre comparaison est refusée.
+ has_cow = bool(state["data"]["counts"].get("website_cow_copy"))
+ if has_cow:
+ lst_choice.append(
+ {
+ "prompt_description": t(
+ "Compare the website copies with the view they shadow"
+ )
+ }
+ )
if can_compare:
lst_choice += [
{
@@ -10446,19 +10757,35 @@ class TODO:
]
lst_choice.append({"prompt_description": t("Export as JSON")})
+ def compare_cow():
+ if not run(with_cow_diff=True):
+ return
+ state["tried"] = True
+ data = state["data"]
+ print(analyse.render(data, hints=False))
+ n = data.get("n_cow_compared") or 0
+ n_diff = len([r for r in data["findings"] if r.get("differs")])
+ print(
+ f" {n} {t('website copies compared with their module view,')}"
+ f" {n_diff} {t('differ.')}"
+ )
+
def handler(rank):
data = state["data"]
+ offset = 1 if has_cow else 0
if rank == 1:
print(analyse.render(data, verbose=True, hints=False))
- elif not can_compare or rank == len(lst_choice):
+ elif has_cow and rank == 2:
+ compare_cow()
+ elif rank == len(lst_choice):
self._analyse_export_json(
data, os.path.basename(database), "view_custom"
)
- elif rank == 2:
+ elif can_compare and rank == 2 + offset:
compare("flagged")
- elif rank == 3:
+ elif can_compare and rank == 3 + offset:
compare("all")
- elif rank == 4:
+ elif can_compare and rank == 4 + offset:
browse()
self._analyse_follow_up(lst_choice, handler)
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index daf155c..89918d7 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -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!",
@@ -6071,12 +6115,12 @@ TRANSLATIONS = {
"en": "📦 Modules missing from the default package",
},
"Show every entry": {
- "fr": "Tout afficher",
- "en": "Show every entry",
+ "fr": "📜 Tout afficher",
+ "en": "📜 Show every entry",
},
"List the known packages": {
- "fr": "Lister les packages connus",
- "en": "List the known packages",
+ "fr": "📚 Lister les packages connus",
+ "en": "📚 List the known packages",
},
"Already repaired: the group exists.": {
"fr": "Déjà réparé : le groupe existe.",
diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py
index 8dd444c..84793da 100755
--- a/script/todo/todo_upgrade.py
+++ b/script/todo/todo_upgrade.py
@@ -2825,6 +2825,7 @@ class TodoUpgrade:
f"✨ {t('Re-update i18n, purge the data and the tables')}"
f" ({t('except mail_test and mail_test_full')})"
)
+ self.prompt_purge_dead_attachments(database_name_upgrade)
# waiting_input = self.ask("💬print Press any keyboard key to continue...")
msg = "6 - Migration finished"
self.print_step(msg)
diff --git a/test/test_analyse_view_custom.py b/test/test_analyse_view_custom.py
index 3bc8bed..a85a275 100644
--- a/test/test_analyse_view_custom.py
+++ b/test/test_analyse_view_custom.py
@@ -268,3 +268,93 @@ class TestRender(unittest.TestCase):
if __name__ == "__main__":
unittest.main()
+
+
+class TestCowTwinDiff(unittest.TestCase):
+ """Comparer une copie de site web à la vue de module qu'elle masque.
+
+ C'est LA comparaison qui compte pour une copie, et elle n'a besoin d'aucun
+ registre : les deux arch sont dans la base, appariées par la clé. Elle
+ marche donc là où la comparaison avec la source du module est refusée —
+ une base dont la version diffère du checkout, et une sauvegarde .zip.
+ """
+
+ def finding(self, **override):
+ row = view(
+ id=10, key="website.homepage", website_id=1, has_module_twin=True
+ )
+ row["category"], row["reason"] = A.classify(row)
+ row.update(override)
+ return row
+
+ def test_a_copy_that_differs_is_measured(self):
+ row = self.finding()
+ n = A.attach_cow_twin_diff(
+ [row],
+ {"website.homepage": (5, "")},
+ {10: ""},
+ )
+ self.assertEqual(n, 1)
+ self.assertTrue(row["differs"])
+ self.assertTrue(row["comparable"])
+ self.assertEqual(row["twin_id"], 5)
+ self.assertEqual(
+ row["diff_stats"]["added"] + row["diff_stats"]["changed"], 1
+ )
+
+ def test_a_copy_identical_to_its_twin(self):
+ # 35 des 62 copies d'une vraie base sont dans ce cas : elles ne
+ # portent aucune personnalisation, et le dire change la décision.
+ row = self.finding()
+ A.attach_cow_twin_diff(
+ [row], {"website.homepage": (5, "")}, {10: ""}
+ )
+ self.assertFalse(row["differs"])
+ self.assertTrue(row["comparable"])
+
+ def test_indentation_alone_is_not_a_difference(self):
+ row = self.finding()
+ A.attach_cow_twin_diff(
+ [row],
+ {"website.homepage": (5, "")},
+ {10: "\n \n"},
+ )
+ self.assertFalse(row["differs"])
+
+ def test_a_copy_without_a_twin_is_left_alone(self):
+ # Une page faite dans l'éditeur web n'a rien à quoi se comparer.
+ row = self.finding(has_module_twin=False)
+ n = A.attach_cow_twin_diff([row], {}, {10: ""})
+ self.assertEqual(n, 0)
+ self.assertNotIn("arch_ref", row)
+ self.assertNotIn("differs", row)
+
+ def test_a_view_that_is_not_a_copy_is_left_alone(self):
+ row = view(id=11, key="sale.order_form", arch_fs="x.xml")
+ row["category"], row["reason"] = A.classify(row)
+ n = A.attach_cow_twin_diff(
+ [row], {"sale.order_form": (1, "")}, {11: ""}
+ )
+ self.assertEqual(n, 0)
+ self.assertNotIn("differs", row)
+
+ def test_it_uses_the_same_field_names_as_the_module_comparison(self):
+ # Le nom des champs EST le contrat : l'écran de navigation et le rendu
+ # texte marchent alors sans savoir laquelle des deux comparaisons a
+ # produit la donnée.
+ row = self.finding()
+ A.attach_cow_twin_diff(
+ [row], {"website.homepage": (5, "")}, {10: ""}
+ )
+ for field in ("arch_ref", "arch_db_text", "differs", "comparable"):
+ self.assertIn(field, row, field)
+
+ def test_a_missing_arch_is_not_a_false_verdict(self):
+ # Sans l'arch de la copie, il n'y a pas eu de comparaison : ne rien
+ # conclure vaut mieux que conclure « identique ».
+ row = self.finding()
+ n = A.attach_cow_twin_diff(
+ [row], {"website.homepage": (5, "")}, {}
+ )
+ self.assertEqual(n, 0)
+ self.assertNotIn("differs", row)
diff --git a/test/test_check_filestore.py b/test/test_check_filestore.py
new file mode 100644
index 0000000..b86ac72
--- /dev/null
+++ b/test/test_check_filestore.py
@@ -0,0 +1,1243 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Séparer ce qui est perdu de ce qui dort quelque part.
+
+Deux propriétés portent l'outil. La première : un fichier retrouvé
+ailleurs n'est PAS une perte, et le dire évite de pleurer sur 266
+fichiers quand trois seulement ont disparu. La seconde : une pièce jointe
+dont le champ n'existe plus n'a rien à récupérer — la ranger avec les
+pertes serait faux dans l'autre sens.
+"""
+
+import io
+import os
+import shutil
+import sys
+import tempfile
+import unittest
+import zipfile
+from contextlib import redirect_stdout
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.analyse import check_filestore as fs # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+
+def piece(store, model="", field="", res_id="1", name="x", size=1024, pid="7"):
+ return {
+ "store_fname": store,
+ "model": model,
+ "id": pid,
+ "field": field,
+ "res_id": res_id,
+ "name": name,
+ "size": size,
+ "mimetype": "image/png",
+ "created": "2019-11-30",
+ }
+
+
+class TestClassify(unittest.TestCase):
+ VIVANTS = {"res.partner.image_1920"}
+
+ def juge(self, p, present=(), ailleurs=None, niches=None, zips=None):
+ return fs.classify(
+ p,
+ set(present),
+ ailleurs or {},
+ niches or {},
+ zips or {},
+ self.VIVANTS,
+ )
+
+ def test_a_present_file_is_not_a_finding(self):
+ self.assertIsNone(self.juge(piece("aa/bb"), present=["aa/bb"]))
+
+ def test_a_file_found_nowhere_is_lost(self):
+ self.assertEqual(self.juge(piece("aa/bb")), ("lost", None))
+
+ def test_a_file_in_another_filestore_is_recoverable(self):
+ self.assertEqual(
+ self.juge(piece("aa/bb"), ailleurs={"aa/bb": "autre_base"}),
+ ("in_other_filestore", "autre_base"),
+ )
+
+ def test_a_file_in_a_backup_is_recoverable(self):
+ self.assertEqual(
+ self.juge(piece("aa/bb"), zips={"aa/bb": "sauvegarde.zip"}),
+ ("in_backup", "sauvegarde.zip"),
+ )
+
+ def test_a_nested_file_is_named_as_such(self):
+ # Le remettre en place est un DÉPLACEMENT, pas une copie depuis
+ # une autre base : le geste diffère, la catégorie aussi.
+ self.assertEqual(
+ self.juge(piece("aa/bb"), niches={"aa/bb": "ma_base"}),
+ ("nested", "ma_base"),
+ )
+
+ def test_a_dead_field_is_judged_BEFORE_looking_anywhere(self):
+ # Rien ne lit cette ligne : proposer de la récupérer ferait
+ # travailler pour rien. Testé d'abord, donc, même si le fichier
+ # traîne dans une sauvegarde.
+ verdict = self.juge(
+ piece("aa/bb", model="res.country", field="image"),
+ zips={"aa/bb": "sauvegarde.zip"},
+ )
+ self.assertEqual(verdict[0], "dead_field")
+
+ def test_a_living_field_is_not_mistaken_for_a_dead_one(self):
+ verdict = self.juge(
+ piece("aa/bb", model="res.partner", field="image_1920")
+ )
+ self.assertEqual(verdict, ("lost", None))
+
+ def test_an_attachment_without_a_field_is_never_dead(self):
+ # Un document téléversé n'a pas de `res_field` : le juger sur un
+ # champ absent le ferait disparaître du rapport.
+ self.assertEqual(
+ self.juge(piece("aa/bb", model="project.task"))[0], "lost"
+ )
+
+ def test_every_verdict_has_an_icon_and_a_wording(self):
+ for verdict in fs.VERDICTS:
+ self.assertIn(verdict, fs.ICONE)
+ self.assertIn(verdict, fs.EXPLICATION)
+
+ def test_the_lost_come_first_in_the_order(self):
+ # L'ordre EST la gravité : ce qu'on ne peut pas récupérer se lit
+ # d'abord.
+ self.assertEqual(fs.VERDICTS[0], "lost")
+
+
+class TestTheDataDir(unittest.TestCase):
+ def test_it_is_read_from_the_config(self):
+ with tempfile.NamedTemporaryFile(
+ "w", suffix=".conf", delete=False, encoding="utf-8"
+ ) as handle:
+ handle.write("[options]\ndata_dir = /chemin/a/moi\n")
+ chemin = handle.name
+ try:
+ self.assertEqual(fs.data_dir(chemin), "/chemin/a/moi")
+ finally:
+ os.unlink(chemin)
+
+ def test_a_missing_config_falls_back_instead_of_crashing(self):
+ # Deviner mal ferait déclarer TOUT perdu : le pire diagnostic.
+ self.assertTrue(fs.data_dir("/nulle/part.conf"))
+
+
+class TestScanning(unittest.TestCase):
+ def setUp(self):
+ self.racine = tempfile.mkdtemp()
+ for base, chemin in (
+ ("ma_base", "aa/present"),
+ ("autre", "bb/ailleurs"),
+ ("ma_base", "filestore/cc/niche"),
+ ):
+ complet = os.path.join(self.racine, base, chemin)
+ os.makedirs(os.path.dirname(complet), exist_ok=True)
+ with open(complet, "w", encoding="utf-8") as handle:
+ handle.write("x")
+
+ def tearDown(self):
+ shutil.rmtree(self.racine)
+
+ def test_other_filestores_are_indexed_and_mine_is_skipped(self):
+ ailleurs, _n, _p = fs.scan_filestores(self.racine, sauf="ma_base")
+ self.assertEqual(ailleurs.get("bb/ailleurs"), "autre")
+ self.assertNotIn("aa/present", ailleurs)
+
+ def test_nested_files_are_indexed_under_their_logical_name(self):
+ # C'est sous « cc/niche » qu'on les cherchera, pas sous
+ # « filestore/cc/niche ».
+ _a, niches, _p = fs.scan_filestores(self.racine, sauf=None)
+ self.assertEqual(niches.get("cc/niche"), "ma_base")
+
+ def test_the_same_nested_file_in_two_databases_counts_in_BOTH(self):
+ # Le clone recopie le nichage : un même fichier dort dans
+ # plusieurs bases. L'index n'en retient qu'une — le premier
+ # `setdefault` gagne, et l'ordre est alphabétique — donc la 17
+ # s'entendait dire que le problème était chez les voisines.
+ for base in ("aaa_base", "zzz_base"):
+ complet = os.path.join(self.racine, base, "filestore", "ee", "x")
+ os.makedirs(os.path.dirname(complet), exist_ok=True)
+ with open(complet, "w", encoding="utf-8") as handle:
+ handle.write("x")
+ _a, _n, par_base = fs.scan_filestores(self.racine, sauf=None)
+ self.assertEqual(par_base.get("aaa_base"), 1)
+ self.assertEqual(par_base.get("zzz_base"), 1)
+
+ def test_a_missing_root_is_empty_not_a_crash(self):
+ self.assertEqual(fs.scan_filestores("/nulle/part"), ({}, {}, {}))
+
+ def test_backups_are_read_from_the_central_directory(self):
+ dossier = tempfile.mkdtemp()
+ try:
+ chemin = os.path.join(dossier, "sauv.zip")
+ with zipfile.ZipFile(chemin, "w") as archive:
+ archive.writestr("filestore/dd/dedans", "x")
+ archive.writestr("dump.sql", "x")
+ trouves = fs.scan_backups(dossier)
+ self.assertEqual(trouves.get("dd/dedans"), "sauv.zip")
+ self.assertNotIn("dump.sql", trouves)
+ finally:
+ shutil.rmtree(dossier)
+
+ def test_a_corrupt_zip_is_skipped_not_fatal(self):
+ dossier = tempfile.mkdtemp()
+ try:
+ with open(os.path.join(dossier, "casse.zip"), "w") as handle:
+ handle.write("pas un zip")
+ self.assertEqual(fs.scan_backups(dossier), {})
+ finally:
+ shutil.rmtree(dossier)
+
+
+class TestTheAudit(unittest.TestCase):
+ """`audit` sans PostgreSQL ni disque : on éprouve la LOGIQUE."""
+
+ def setUp(self):
+ self.vrais = (
+ fs.attachments,
+ fs.live_fields,
+ fs.scan_filestores,
+ fs.scan_backups,
+ fs.filestore_root,
+ )
+ fs.live_fields = lambda base: set()
+ fs.scan_filestores = lambda racine, sauf=None: ({}, {}, {})
+ fs.scan_backups = lambda dossier: {}
+ fs.filestore_root = lambda config=None: "/nulle/part"
+
+ def tearDown(self):
+ (
+ fs.attachments,
+ fs.live_fields,
+ fs.scan_filestores,
+ fs.scan_backups,
+ fs.filestore_root,
+ ) = self.vrais
+
+ def test_two_attachments_sharing_a_file_count_once(self):
+ # Odoo déduplique par empreinte : deux pièces jointes au contenu
+ # identique partagent un `store_fname`. Les compter deux fois
+ # gonflerait « fichiers absents » sans qu'un seul fichier de plus
+ # soit à retrouver.
+ fs.attachments = lambda base: [
+ piece("aa/bb", "project.task", res_id="1"),
+ piece("aa/bb", "project.task", res_id="2"),
+ ]
+ rapport = fs.audit("db")
+ self.assertEqual(rapport["missing"], 1)
+ self.assertEqual(len(rapport["groups"]["lost"]), 1)
+
+ def test_distinct_files_are_counted_apart(self):
+ fs.attachments = lambda base: [piece("aa/bb"), piece("cc/dd")]
+ self.assertEqual(fs.audit("db")["missing"], 2)
+
+ def test_a_silent_database_is_unavailable(self):
+ fs.attachments = lambda base: None
+ self.assertTrue(fs.audit("db")["unavailable"])
+
+ def test_every_row_sharing_a_dead_file_is_listed_for_deletion(self):
+ # Vingt-deux lignes partageaient deux fichiers : la purge n'en
+ # offrait qu'une à la fois, et il fallait la relancer vingt-deux
+ # fois. Compter des FICHIERS et effacer des LIGNES ne sont pas la
+ # même opération.
+ fs.attachments = lambda base: [
+ piece("aa/bb", "res.country", "image", res_id="1", pid="10"),
+ piece("aa/bb", "res.country", "image", res_id="2", pid="11"),
+ piece("cc/dd", "res.country", "image", res_id="3", pid="12"),
+ ]
+ rapport = fs.audit("db")
+ self.assertEqual(rapport["missing"], 2)
+ self.assertEqual(len(rapport["groups"]["dead_field"]), 2)
+ self.assertEqual(sorted(rapport["dead_ids"]), [10, 11, 12])
+ self.assertIn("(10, 11, 12)", fs.purge_dead_sql(rapport))
+
+ def test_a_live_row_sharing_a_file_is_never_swept_along(self):
+ # Deux lignes, un seul fichier, mais un seul champ mort : effacer
+ # les deux emporterait une pièce jointe bien vivante.
+ fs.live_fields = lambda base: {"project.task.attachment"}
+ fs.attachments = lambda base: [
+ piece("aa/bb", "res.country", "image", pid="10"),
+ piece("aa/bb", "project.task", "attachment", pid="11"),
+ ]
+ rapport = fs.audit("db")
+ self.assertEqual(rapport["dead_ids"], [10])
+
+ def test_the_root_points_at_this_database_directory(self):
+ # Y mettre la racine de tous les filestores faisait chercher le
+ # dossier imbriqué à `/filestore/filestore` : l'outil
+ # répondait « rien à ranger » devant 1168 fichiers échoués.
+ fs.attachments = lambda base: []
+ self.assertEqual(fs.audit("ma_base")["root"], "/nulle/part/ma_base")
+
+ def test_a_dead_field_never_lands_in_lost(self):
+ # Le garde du champ disparu doit VRAIMENT couper : sans lui, ces
+ # lignes gonfleraient les pertes réelles.
+ fs.attachments = lambda base: [piece("aa/bb", "res.country", "image")]
+ rapport = fs.audit("db")
+ self.assertEqual(rapport["groups"]["lost"], [])
+ self.assertEqual(len(rapport["groups"]["dead_field"]), 1)
+
+
+class TestTheReport(unittest.TestCase):
+ def rapport(self, **extra):
+ base = {
+ "database": "db",
+ "attachments": 10,
+ "files_present": 8,
+ "missing": 0,
+ "nested_total": 0,
+ "root": "/x",
+ "groups": {v: [] for v in fs.VERDICTS},
+ }
+ base.update(extra)
+ return base
+
+ def test_a_clean_filestore_says_so(self):
+ texte = "\n".join(fs.render(self.rapport()))
+ self.assertIn(todo_i18n.t("every attachment file is present"), texte)
+
+ def test_only_the_lost_are_named_one_by_one(self):
+ groupes = {v: [] for v in fs.VERDICTS}
+ groupes["lost"] = [piece("a/1", "project.task", name="perdu.png")]
+ groupes["in_backup"] = [
+ piece("b/1", "res.country", "image", name="recuperable.png")
+ ]
+ texte = "\n".join(fs.render(self.rapport(missing=2, groups=groupes)))
+ self.assertIn("perdu.png", texte)
+ # Le récupérable est RÉSUMÉ, pas listé : trois cents lignes de
+ # noms qu'on n'a pas à lire cacheraient les trois qui comptent.
+ self.assertNotIn("recuperable.png", texte)
+ self.assertIn("res.country / image", texte)
+
+ def test_limit_zero_shows_EVERYTHING(self):
+ # « Tout afficher » passe limit=0. Une tranche [:0] est vide :
+ # l'écran annonçait « … 3 de plus » et ne montrait rien du tout.
+ groupes = {v: [] for v in fs.VERDICTS}
+ groupes["lost"] = [
+ piece(f"a/{i}", "project.task", name=f"perdu{i}.png")
+ for i in range(3)
+ ]
+ texte = "\n".join(
+ fs.render(self.rapport(missing=3, groups=groupes), limit=0)
+ )
+ for i in range(3):
+ self.assertIn(f"perdu{i}.png", texte)
+ self.assertNotIn(todo_i18n.t("more"), texte)
+
+ def test_a_limit_still_caps_and_says_how_many_were_hidden(self):
+ groupes = {v: [] for v in fs.VERDICTS}
+ groupes["lost"] = [
+ piece(f"a/{i}", "project.task", name=f"perdu{i}.png")
+ for i in range(5)
+ ]
+ texte = "\n".join(
+ fs.render(self.rapport(missing=5, groups=groupes), limit=2)
+ )
+ self.assertIn("perdu0.png", texte)
+ self.assertNotIn("perdu4.png", texte)
+ self.assertIn(f"3 {todo_i18n.t('more')}", texte)
+
+ def test_the_nested_pile_is_reported(self):
+ texte = "\n".join(fs.render(self.rapport(nested_total=1168)))
+ self.assertIn("1168", texte)
+
+ def test_an_unreadable_database_renders_without_crashing(self):
+ texte = "\n".join(fs.render({"unavailable": True, "database": "x"}))
+ self.assertIn("x", texte)
+
+ def test_summarise_groups_by_model_and_field(self):
+ resume = fs.summarise(
+ [
+ piece("a/1", "res.country", "image"),
+ piece("a/2", "res.country", "image"),
+ piece("a/3", "project.task"),
+ ]
+ )
+ self.assertIn("res.country / image × 2", resume)
+
+
+class TestTheLivingRecord(unittest.TestCase):
+ """Une image perdue sur un enregistrement supprimé n'est pas perdue."""
+
+ def setUp(self):
+ self.vrai = fs.run_psql
+ self.demandes = []
+
+ def tearDown(self):
+ fs.run_psql = self.vrai
+
+ def branche(self, tables, ids):
+ def faux(base, sql):
+ self.demandes.append(sql)
+ if "to_regclass" in sql:
+ return [["t" if any(x in sql for x in tables) else "f"]]
+ return [[str(i)] for i in ids]
+
+ fs.run_psql = faux
+
+ def test_a_living_record_is_reported_as_such(self):
+ self.branche(["project_task"], [15])
+ vivants = fs.resources_alive(
+ "db", [piece("a/1", "project.task", res_id="15")]
+ )
+ self.assertIs(vivants[("project.task", "15")], True)
+
+ def test_a_deleted_record_is_reported_as_gone(self):
+ self.branche(["project_task"], [])
+ vivants = fs.resources_alive(
+ "db", [piece("a/1", "project.task", res_id="15")]
+ )
+ self.assertIs(vivants[("project.task", "15")], False)
+
+ def test_a_model_without_a_table_is_left_UNKNOWN(self):
+ # « on n'a pas pu vérifier » ne doit pas se lire « il a disparu » :
+ # une vraie perte serait classée en fausse alerte.
+ self.branche([], [])
+ vivants = fs.resources_alive(
+ "db", [piece("a/1", "un.abstrait", res_id="1")]
+ )
+ self.assertEqual(vivants, {})
+
+ def test_an_attachment_without_a_record_is_not_queried(self):
+ self.branche([], [])
+ fs.resources_alive("db", [piece("a/1")])
+ self.assertEqual(self.demandes, [])
+
+ def test_the_mark_says_all_three_states(self):
+ self.assertIn(
+ todo_i18n.t("record still exists"), fs.alive_mark({"alive": True})
+ )
+ self.assertIn(
+ todo_i18n.t("record is gone — nothing will miss it"),
+ fs.alive_mark({"alive": False}),
+ )
+ self.assertEqual(fs.alive_mark({"alive": None}), "")
+
+
+class TestTheRepairs(unittest.TestCase):
+ def rapport(self, ids=(), racine="/fs/db"):
+ base = {v: [] for v in fs.VERDICTS}
+ return {"root": racine, "groups": base, "dead_ids": list(ids)}
+
+ def test_the_purge_deletes_by_id_only(self):
+ # Par IDENTIFIANT, jamais par un domaine reconstruit : rejouer le
+ # raisonnement en SQL ouvrirait la porte à effacer autre chose
+ # que ce qui a été montré.
+ sql = fs.purge_dead_sql(self.rapport([9, 3]))
+ self.assertIn("WHERE id IN (3, 9)", sql)
+ self.assertNotIn("res_model", sql)
+
+ def test_nothing_dead_gives_no_sql(self):
+ self.assertEqual(fs.purge_dead_sql(self.rapport()), "")
+
+ def test_a_report_without_the_key_gives_no_sql(self):
+ self.assertEqual(fs.purge_dead_sql({"groups": {}}), "")
+
+
+class TestReadingWhatPostgresSaid(unittest.TestCase):
+ def test_it_reads_the_count(self):
+ self.assertEqual(fs.rows_deleted(["DELETE 250"]), 250)
+
+ def test_zero_is_zero_not_success(self):
+ # « DELETE 0 » se félicitait d'avoir supprimé : rejouer une purge
+ # déjà faite annonçait un travail qui n'avait pas eu lieu.
+ self.assertEqual(fs.rows_deleted(["DELETE 0"]), 0)
+
+ def test_it_takes_the_LAST_word(self):
+ self.assertEqual(fs.rows_deleted(["DELETE 5", "bruit", "DELETE 7"]), 7)
+
+ def test_silence_is_not_zero(self):
+ # « rien annoncé » et « rien supprimé » appellent des mots
+ # différents : les confondre tait une panne.
+ self.assertIsNone(fs.rows_deleted(["ERROR: boom"]))
+ self.assertIsNone(fs.rows_deleted([]))
+
+ def test_a_plain_string_works_too(self):
+ self.assertEqual(fs.rows_deleted("DELETE 3\n"), 3)
+
+
+class TestTidyingTheNested(unittest.TestCase):
+ def setUp(self):
+ self.racine = tempfile.mkdtemp()
+ self.base = os.path.join(self.racine, "ma_base")
+ for chemin in ("aa/deja", "filestore/aa/deja", "filestore/bb/absent"):
+ complet = os.path.join(self.base, chemin)
+ os.makedirs(os.path.dirname(complet), exist_ok=True)
+ with open(complet, "w", encoding="utf-8") as handle:
+ handle.write("x")
+
+ def tearDown(self):
+ shutil.rmtree(self.racine)
+
+ def test_it_separates_what_to_move_from_pure_duplicates(self):
+ # Écraser un fichier présent par une copie identique ne gagne
+ # rien et brouille la trace : les deux tas restent distincts.
+ remonter, doublons = fs.tidy_nested_plan({"root": self.base})
+ self.assertEqual(
+ [os.path.basename(a) for a, _b in remonter], ["absent"]
+ )
+ self.assertEqual([os.path.basename(a) for a, _b in doublons], ["deja"])
+
+ def test_the_destination_is_the_right_level(self):
+ remonter, _d = fs.tidy_nested_plan({"root": self.base})
+ self.assertEqual(
+ remonter[0][1], os.path.join(self.base, "bb", "absent")
+ )
+
+ def test_no_nested_directory_gives_an_empty_plan(self):
+ shutil.rmtree(os.path.join(self.base, "filestore"))
+ self.assertEqual(fs.tidy_nested_plan({"root": self.base}), ([], []))
+ self.assertEqual(fs.nested_dir({"root": self.base}), "")
+
+
+class TestTheRepairMenu(unittest.TestCase):
+ """Les réparations ÉCRIVENT. On éprouve ce qui part, pas les appels.
+
+ Un test qui constate qu'une fonction a été appelée n'aurait pas vu
+ que `exec_command` n'existe pas — seul `exec_command_live` est
+ offert. C'est arrivé ici : le code aurait planté au premier usage.
+ """
+
+ def setUp(self):
+ from script.todo import auto_ask
+ from script.todo import todo as todo_module
+
+ self.auto_ask = auto_ask
+ self.vrai_ask = auto_ask.ask
+ self.lancees = []
+ self.obj = todo_module.TODO.__new__(todo_module.TODO)
+
+ def faux_exec(_self, cmd, **kw):
+ self.lancees.append(cmd)
+ if kw.get("return_status_and_output"):
+ return 0, ["DELETE 1"]
+ return 0
+
+ self.obj.execute = type("E", (), {"exec_command_live": faux_exec})()
+
+ def tearDown(self):
+ self.auto_ask.ask = self.vrai_ask
+
+ def repond(self, *reponses):
+ file = list(reponses)
+
+ def faux(prompt, default="", seconds=None):
+ reponse = file.pop(0) if file else ""
+ return reponse or default
+
+ self.auto_ask.ask = faux
+
+ def rapport(self, morts=(), ids=()):
+ groupes = {v: [] for v in fs.VERDICTS}
+ groupes["dead_field"] = list(morts)
+ return {"root": "/fs/db", "groups": groupes, "dead_ids": list(ids)}
+
+ def test_saying_no_deletes_nothing(self):
+ self.repond("n")
+ with redirect_stdout(io.StringIO()):
+ self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.assertEqual(self.lancees, [])
+
+ def test_pressing_enter_deletes_nothing(self):
+ # Le défaut d'une SUPPRESSION doit être de ne rien faire.
+ self.repond("")
+ with redirect_stdout(io.StringIO()):
+ self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.assertEqual(self.lancees, [])
+
+ def test_accepting_runs_a_command_that_actually_exists(self):
+ self.repond("y")
+ with redirect_stdout(io.StringIO()):
+ self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.assertEqual(len(self.lancees), 1)
+ self.assertIn("psql -d db", self.lancees[0])
+ self.assertIn("WHERE id IN (3)", self.lancees[0])
+
+ def test_nothing_dead_asks_nothing(self):
+ demandes = []
+ self.auto_ask.ask = lambda p, default="", seconds=None: (
+ demandes.append(p) or "y"
+ )
+ with redirect_stdout(io.StringIO()):
+ self.obj._filestore_purge_dead("db", self.rapport())
+ self.assertEqual(demandes, [])
+ self.assertEqual(self.lancees, [])
+
+ def test_the_menu_offers_both_repairs(self):
+ racine = os.path.normpath(
+ os.path.join(os.path.dirname(__file__), "..")
+ )
+ with io.open(
+ os.path.join(racine, "script", "todo", "todo.py"), encoding="utf-8"
+ ) as handle:
+ src = handle.read()
+ self.assertIn("_filestore_purge_dead", src)
+ self.assertIn("_filestore_tidy_nested", src)
+ # Le garde du défaut doit rester collé à la question : c'est lui
+ # qui empêche Entrée de supprimer.
+ self.assertIn('auto_ask.ask(question, default="n")', src)
+
+ def test_a_delete_that_changed_nothing_is_NOT_a_success(self):
+ # « DELETE 0 » se félicitait d'avoir supprimé. Rejouer une purge
+ # déjà faite annonçait donc un travail qui n'avait pas eu lieu.
+ def rien(_self, cmd, **kw):
+ self.lancees.append(cmd)
+ return (
+ (0, ["DELETE 0"]) if kw.get("return_status_and_output") else 0
+ )
+
+ self.obj.execute = type("E", (), {"exec_command_live": rien})()
+ self.repond("y")
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.assertIn("0 ", tampon.getvalue())
+ self.assertNotIn("✅ 1 ", tampon.getvalue())
+
+ def test_a_silent_command_is_flagged_not_counted(self):
+ def muet(_self, cmd, **kw):
+ self.lancees.append(cmd)
+ return (
+ (0, ["rien du tout"])
+ if kw.get("return_status_and_output")
+ else 0
+ )
+
+ self.obj.execute = type("E", (), {"exec_command_live": muet})()
+ self.repond("y")
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.assertIn(
+ todo_i18n.t("The purge ran but said nothing."), tampon.getvalue()
+ )
+
+ def test_a_repair_reports_whether_it_did_something(self):
+ # C'est ce booléen qui déclenche la relecture du rapport : sans
+ # lui, « Tout afficher » rejouerait l'état d'avant la purge.
+ self.repond("n")
+ with redirect_stdout(io.StringIO()):
+ refus = self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.repond("y")
+ with redirect_stdout(io.StringIO()):
+ fait = self.obj._filestore_purge_dead(
+ "db", self.rapport([piece("a/1")], [3])
+ )
+ self.assertFalse(refus)
+ self.assertTrue(fait)
+
+
+class TestTheReportIsRereadAfterARepair(unittest.TestCase):
+ """« Tout afficher » doit montrer l'APRÈS, pas l'avant.
+
+ Sans relecture on purgeait, on relisait, et l'on voyait encore ce
+ qui venait de disparaître — puis on repurgeait des lignes déjà
+ effacées en croyant le travail inachevé. C'est arrivé en vrai.
+ """
+
+ def setUp(self):
+ from script.todo import auto_ask
+ from script.todo import todo as todo_module
+
+ self.auto_ask = auto_ask
+ self.vrai_ask = auto_ask.ask
+ self.vrai_audit = fs.audit
+ self.appels = []
+ self.obj = todo_module.TODO.__new__(todo_module.TODO)
+ self.obj._analyse_select_database = lambda: "db"
+ self.obj._filestore_purge_dead = lambda base, rapport: True
+ self.obj._filestore_tidy_nested = lambda rapport: True
+
+ def audit(base, *a, **k):
+ self.appels.append(base)
+ return {
+ "database": base,
+ "attachments": 1,
+ "files_present": 1,
+ "missing": 0,
+ "nested_total": 0,
+ "root": "/fs/db",
+ "groups": {v: [] for v in fs.VERDICTS},
+ "dead_ids": [],
+ }
+
+ fs.audit = audit
+ self.auto_ask.ask = lambda p, default="", seconds=None: "n"
+
+ def tearDown(self):
+ self.auto_ask.ask = self.vrai_ask
+ fs.audit = self.vrai_audit
+
+ def joue(self, rang):
+ self.obj._analyse_follow_up = lambda choix, handler: handler(rang)
+ with redirect_stdout(io.StringIO()):
+ self.obj.execute_analyse_filestore()
+
+ def test_a_purge_triggers_a_fresh_read(self):
+ self.joue(2)
+ self.assertEqual(len(self.appels), 2)
+
+ def test_a_tidy_triggers_a_fresh_read(self):
+ self.joue(3)
+ self.assertEqual(len(self.appels), 2)
+
+ def test_merely_displaying_does_not(self):
+ # Relire pour afficher coûterait un balayage complet des
+ # filestores et des zips à chaque coup d'œil.
+ self.joue(1)
+ self.assertEqual(len(self.appels), 1)
+
+ def test_a_refused_repair_does_not_reread(self):
+ self.obj._filestore_purge_dead = lambda base, rapport: False
+ self.joue(2)
+ self.assertEqual(len(self.appels), 1)
+
+
+class TestTidyingForReal(unittest.TestCase):
+ def setUp(self):
+ from script.todo import auto_ask
+ from script.todo import todo as todo_module
+
+ self.auto_ask = auto_ask
+ self.vrai_ask = auto_ask.ask
+ self.racine = tempfile.mkdtemp()
+ self.base = os.path.join(self.racine, "ma_base")
+ for chemin, contenu in (
+ ("aa/deja", "bon"),
+ ("filestore/aa/deja", "copie"),
+ ("filestore/bb/absent", "utile"),
+ ):
+ complet = os.path.join(self.base, chemin)
+ os.makedirs(os.path.dirname(complet), exist_ok=True)
+ with open(complet, "w", encoding="utf-8") as handle:
+ handle.write(contenu)
+ self.obj = todo_module.TODO.__new__(todo_module.TODO)
+
+ def tearDown(self):
+ self.auto_ask.ask = self.vrai_ask
+ shutil.rmtree(self.racine, ignore_errors=True)
+
+ def test_refusing_leaves_every_file_where_it_was(self):
+ self.auto_ask.ask = lambda p, default="", seconds=None: "n"
+ with redirect_stdout(io.StringIO()):
+ self.obj._filestore_tidy_nested({"root": self.base})
+ self.assertTrue(
+ os.path.isfile(
+ os.path.join(self.base, "filestore", "bb", "absent")
+ )
+ )
+
+ def test_accepting_moves_up_and_removes_the_nest(self):
+ self.auto_ask.ask = lambda p, default="", seconds=None: "y"
+ with redirect_stdout(io.StringIO()):
+ self.obj._filestore_tidy_nested({"root": self.base})
+ self.assertTrue(
+ os.path.isfile(os.path.join(self.base, "bb", "absent"))
+ )
+ self.assertFalse(os.path.isdir(os.path.join(self.base, "filestore")))
+ # Le fichier déjà présent n'a pas été ÉCRASÉ par sa copie :
+ # vérifier sa seule existence laisserait passer l'écrasement.
+ with io.open(
+ os.path.join(self.base, "aa", "deja"), encoding="utf-8"
+ ) as handle:
+ self.assertEqual(handle.read(), "bon")
+
+
+class TestTheDeadRowsThatKeptTheirFile(unittest.TestCase):
+ """1860 lignes, 30 Mo, que l'outil ne voyait pas.
+
+ Il s'appelle « fichiers absents » et ne regardait donc que les
+ fichiers absents. Or une ligne dont le champ a disparu retient son
+ fichier tant qu'elle existe : le ramasse-miettes d'Odoo ne retire
+ que ce qui n'est plus référencé.
+ """
+
+ def setUp(self):
+ self.vrais = (
+ fs.attachments,
+ fs.live_fields,
+ fs.scan_filestores,
+ fs.scan_backups,
+ fs.filestore_root,
+ )
+ fs.live_fields = lambda base: {"res.partner.image_1920"}
+ fs.scan_filestores = lambda racine, sauf=None: ({}, {}, {})
+ fs.scan_backups = lambda dossier: {}
+ fs.filestore_root = lambda config=None: "/nulle/part"
+
+ def tearDown(self):
+ (
+ fs.attachments,
+ fs.live_fields,
+ fs.scan_filestores,
+ fs.scan_backups,
+ fs.filestore_root,
+ ) = self.vrais
+
+ def pose_fichier(self, chemin):
+ """Un VRAI fichier : c'est la présence qui distingue les deux cas."""
+ complet = os.path.join(self.racine, "db", chemin)
+ os.makedirs(os.path.dirname(complet), exist_ok=True)
+ with open(complet, "w", encoding="utf-8") as handle:
+ handle.write("x")
+
+ def test_a_dead_row_WITH_its_file_lands_in_dead_kept(self):
+ self.racine = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.racine, True)
+ fs.filestore_root = lambda config=None: self.racine
+ self.pose_fichier("aa/bb")
+ fs.attachments = lambda base: [
+ piece("aa/bb", "res.partner", "image", size=4096, pid="5")
+ ]
+ rapport = fs.audit("db")
+ # Fichier présent : ce n'est PAS un fichier manquant…
+ self.assertEqual(rapport["missing"], 0)
+ # …mais la ligne est morte, et son fichier occupe le disque.
+ self.assertEqual(len(rapport["dead_kept"]), 1)
+ self.assertEqual(rapport["dead_kept_size"], 4096)
+ self.assertEqual(rapport["dead_ids"], [5])
+
+ def test_a_living_row_with_its_file_is_left_alone(self):
+ self.racine = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.racine, True)
+ fs.filestore_root = lambda config=None: self.racine
+ self.pose_fichier("aa/bb")
+ fs.attachments = lambda base: [
+ piece("aa/bb", "res.partner", "image_1920", size=4096, pid="5")
+ ]
+ rapport = fs.audit("db")
+ self.assertEqual(rapport["dead_kept"], [])
+ self.assertEqual(rapport["dead_ids"], [])
+
+ def test_the_size_is_summed_by_the_audit_itself(self):
+ self.racine = tempfile.mkdtemp()
+ self.addCleanup(shutil.rmtree, self.racine, True)
+ fs.filestore_root = lambda config=None: self.racine
+ for chemin in ("aa/bb", "cc/dd"):
+ self.pose_fichier(chemin)
+ fs.attachments = lambda base: [
+ piece("aa/bb", "res.partner", "image", size=1024, pid="5"),
+ piece("cc/dd", "res.partner", "image", size=1024, pid="6"),
+ ]
+ self.assertEqual(fs.audit("db")["dead_kept_size"], 2048)
+
+ def test_the_report_shows_the_weight(self):
+ gardees = [piece("a/1", "res.partner", "image", size=2048)]
+ texte = "\n".join(
+ fs.render_dead_kept({"dead_kept": gardees, "dead_kept_size": 2048})
+ )
+ self.assertIn("2 ko", texte)
+ self.assertIn("res.partner / image", texte)
+
+ def test_nothing_kept_says_nothing(self):
+ self.assertEqual(fs.render_dead_kept({"dead_kept": []}), [])
+
+ def test_a_living_field_is_never_counted(self):
+ self.assertFalse(
+ fs.is_dead_field(
+ piece("a/1", "res.partner", "image_1920"),
+ {"res.partner.image_1920"},
+ )
+ )
+
+ def test_an_uploaded_document_has_no_field_so_is_never_dead(self):
+ self.assertFalse(fs.is_dead_field(piece("a/1", "project.task"), set()))
+
+
+class TestTheNestedCountIsPerDatabase(unittest.TestCase):
+ """« 1168 fichiers échoués » devant une base qu'on vient de ranger.
+
+ Le compte agrégeait tous les filestores de la machine : on rangeait,
+ le rapport affichait le même chiffre, et l'on rangeait à nouveau.
+ """
+
+ def setUp(self):
+ self.vrais = (
+ fs.attachments,
+ fs.live_fields,
+ fs.scan_filestores,
+ fs.scan_backups,
+ fs.filestore_root,
+ )
+ fs.attachments = lambda base: []
+ fs.live_fields = lambda base: set()
+ fs.scan_backups = lambda dossier: {}
+ fs.filestore_root = lambda config=None: "/nulle/part"
+ fs.scan_filestores = lambda racine, sauf=None: (
+ {},
+ {"a/1": "ma_base", "b/2": "voisine", "c/3": "voisine"},
+ {"ma_base": 1, "voisine": 2},
+ )
+
+ def tearDown(self):
+ (
+ fs.attachments,
+ fs.live_fields,
+ fs.scan_filestores,
+ fs.scan_backups,
+ fs.filestore_root,
+ ) = self.vrais
+
+ def test_only_this_database_counts_as_nested(self):
+ rapport = fs.audit("ma_base")
+ self.assertEqual(rapport["nested_total"], 1)
+ self.assertEqual(rapport["nested_elsewhere"], 2)
+
+ def test_a_tidy_database_is_not_told_it_has_work(self):
+ fs.scan_filestores = lambda racine, sauf=None: (
+ {},
+ {"b/2": "voisine"},
+ {"voisine": 1},
+ )
+ rapport = fs.audit("ma_base")
+ texte = "\n".join(fs.render_nested(rapport))
+ self.assertNotIn(
+ todo_i18n.t("file(s) sit in a nested filestore Odoo never reads."),
+ texte,
+ )
+ self.assertIn(
+ todo_i18n.t("such file(s) sit in OTHER databases filestores."),
+ texte,
+ )
+
+
+class TestTheMigrationWiring(unittest.TestCase):
+ RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+
+ def source(self, chemin):
+ with io.open(
+ os.path.join(self.RACINE, chemin), encoding="utf-8"
+ ) as handle:
+ return handle.read()
+
+ def test_the_purge_runs_ONCE_at_the_end_not_between_bumps(self):
+ # Entre deux paliers, deux à onze champs disparaissent puis
+ # REVIENNENT : purger là trancherait sur du transitoire.
+ src = self.source("script/todo/todo_upgrade.py")
+ self.assertIn("prompt_purge_dead_attachments", src)
+ appel = "self.prompt_purge_dead_attachments(database_name_upgrade)"
+ self.assertEqual(src.count(appel), 1)
+ boucle = src.index("for index, next_version in enumerate(")
+ self.assertGreater(
+ src.index(appel), boucle, "l'appel doit suivre la boucle"
+ )
+ etape = src.index('"5 - Cleaning up database after upgrade"')
+ self.assertGreater(src.index(appel), etape)
+
+ def test_the_purge_runs_BEFORE_the_final_backup(self):
+ # Qui veut garder l'état d'avant refuse la purge ; la sauvegarde
+ # qui suit doit capturer l'état nettoyé.
+ src = self.source("script/todo/todo_upgrade.py")
+ appel = "self.prompt_purge_dead_attachments(database_name_upgrade)"
+ self.assertLess(src.index(appel), src.index("cmd_backup_template"))
+
+ def test_the_restore_offers_to_tidy_where_the_fault_is_born(self):
+ # L'APPEL, pas le nom : `pass` à sa place laisse la fonction
+ # définie et le test passerait sur du code mort.
+ src = self.source("script/database/db_restore.py")
+ self.assertIn("tidy_nested_plan", src)
+ self.assertIn(
+ 'if rapport.get("nested"):\n offer_tidy(',
+ src,
+ "le rangement n'est plus proposé après la vérification",
+ )
+
+ def test_the_restore_never_asks_without_a_terminal(self):
+ # Ce script tourne aussi sans personne devant : une question
+ # posée à un stdin fermé arrêterait la migration.
+ src = self.source("script/database/db_restore.py")
+ debut = src.index("def offer_tidy")
+ fin = src.index("input(", debut)
+ self.assertIn("sys.stdin.isatty()", src[debut:fin])
+
+ def test_the_clone_path_still_offers_nothing(self):
+ src = self.source("script/database/db_restore.py")
+ debut = src.index("--clone --from_database")
+ fin = src.index("verify_filestore(config.database", debut)
+ self.assertNotIn("offer_tidy", src[debut:fin])
+
+
+class TestVerifyingARestore(unittest.TestCase):
+ """Le contrôle d'après-restauration, celui qui aurait vu le nichage."""
+
+ def setUp(self):
+ self.racine = tempfile.mkdtemp()
+ self.zip = os.path.join(self.racine, "sauv.zip")
+ with zipfile.ZipFile(self.zip, "w") as archive:
+ for nom in ("aa/un", "bb/deux", "cc/trois"):
+ archive.writestr(f"filestore/{nom}", "x")
+ archive.writestr("dump.sql", "x")
+ self.fs = os.path.join(self.racine, "fs")
+ self.vrai = fs.filestore_root
+ fs.filestore_root = lambda config=None: self.fs
+
+ def tearDown(self):
+ fs.filestore_root = self.vrai
+ shutil.rmtree(self.racine)
+
+ def pose(self, chemins):
+ for chemin in chemins:
+ complet = os.path.join(self.fs, "ma_base", chemin)
+ os.makedirs(os.path.dirname(complet), exist_ok=True)
+ with open(complet, "w", encoding="utf-8") as handle:
+ handle.write("x")
+
+ def test_a_clean_restore_reports_everything_in_place(self):
+ self.pose(["aa/un", "bb/deux", "cc/trois"])
+ r = fs.verify_restore("ma_base", self.zip)
+ self.assertEqual((r["expected"], r["placed"]), (3, 3))
+ self.assertEqual((r["nested"], r["missing"]), (0, 0))
+
+ def test_a_nested_restore_is_caught(self):
+ # Le défaut exact qui a coûté 133 Mo par base, sept fois.
+ self.pose(
+ ["filestore/aa/un", "filestore/bb/deux", "filestore/cc/trois"]
+ )
+ r = fs.verify_restore("ma_base", self.zip)
+ self.assertEqual(r["nested"], 3)
+ self.assertEqual(r["placed"], 0)
+
+ def test_a_half_nested_restore_counts_both_sides(self):
+ self.pose(["aa/un", "filestore/bb/deux"])
+ r = fs.verify_restore("ma_base", self.zip)
+ self.assertEqual((r["placed"], r["nested"], r["missing"]), (1, 1, 1))
+
+ def test_files_that_never_landed_are_counted(self):
+ self.pose(["aa/un"])
+ self.assertEqual(fs.verify_restore("ma_base", self.zip)["missing"], 2)
+
+ def test_a_zip_without_a_filestore_says_nothing(self):
+ # Se taire quand il n'y a rien à contrôler : un contrôle bavard
+ # à chaque restauration finit par ne plus être lu.
+ vide = os.path.join(self.racine, "vide.zip")
+ with zipfile.ZipFile(vide, "w") as archive:
+ archive.writestr("dump.sql", "x")
+ r = fs.verify_restore("ma_base", vide)
+ self.assertEqual(r["expected"], 0)
+ self.assertEqual(fs.render_verify(r), [])
+
+ def test_the_fix_command_names_the_real_directory(self):
+ self.pose(["filestore/aa/un"])
+ texte = "\n".join(
+ fs.render_verify(fs.verify_restore("ma_base", self.zip))
+ )
+ self.assertIn(os.path.join(self.fs, "ma_base"), texte)
+ self.assertIn(todo_i18n.t("To fix:"), texte)
+
+ def test_a_clean_restore_still_says_so_briefly(self):
+ self.pose(["aa/un", "bb/deux", "cc/trois"])
+ texte = "\n".join(
+ fs.render_verify(fs.verify_restore("ma_base", self.zip))
+ )
+ self.assertIn(todo_i18n.t("Filestore restored:"), texte)
+ self.assertNotIn(todo_i18n.t("To fix:"), texte)
+
+
+class TestTheRestoreWiring(unittest.TestCase):
+ RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+
+ def source(self, chemin):
+ with io.open(
+ os.path.join(self.RACINE, chemin), encoding="utf-8"
+ ) as handle:
+ return handle.read()
+
+ def test_db_restore_checks_after_a_real_restore(self):
+ src = self.source("script/database/db_restore.py")
+ self.assertEqual(src.count("verify_filestore("), 3)
+ self.assertIn(
+ "if config.ignore_cache:\n verify_filestore("
+ "config.database",
+ src,
+ "le contrôle d'après-restauration directe n'est plus gardé"
+ " par ignore_cache",
+ )
+ self.assertIn(
+ "not config.ignore_cache:",
+ src,
+ "la création du cache n'est plus conditionnée",
+ )
+
+ def test_the_clone_path_is_NOT_checked(self):
+ # Le miroir recopie sa source, défauts compris : contrôler là
+ # dirait deux fois la même chose, et au mauvais endroit.
+ src = self.source("script/database/db_restore.py")
+ self.assertIn("--clone --from_database", src)
+ debut = src.index("--clone --from_database")
+ marque = "verify_filestore(config.database"
+ self.assertIn(
+ marque, src, "le contrôle d'après-restauration a disparu"
+ )
+ fin = src.index(marque, debut)
+ self.assertNotIn("verify_filestore", src[debut:fin])
+
+ def test_the_analyse_menu_offers_the_tool(self):
+ src = self.source("script/todo/todo.py")
+ self.assertIn("Attachment files missing from the filestore", src)
+ self.assertIn("self.execute_analyse_filestore()", src)
+ self.assertIn("def execute_analyse_filestore", src)
+
+ def test_the_menu_has_as_many_entries_as_branches(self):
+ src = self.source("script/todo/todo.py")
+ debut = src.index("def prompt_execute_analyse")
+ fin = src.index('print(t("Command not found !"))', debut)
+ bloc = src[debut:fin]
+ entrees = bloc.count('"prompt_description"')
+ branches = sum(
+ f'status == "{n}"' in bloc for n in range(1, entrees + 2)
+ )
+ self.assertEqual(entrees, branches)
+
+
+class TestTheExitCodes(unittest.TestCase):
+ def setUp(self):
+ self.vrai = fs.audit
+
+ def tearDown(self):
+ fs.audit = self.vrai
+
+ def lance(self, rapport):
+ fs.audit = lambda *a, **k: rapport
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ code = fs.main(["-d", "db"])
+ return code, tampon.getvalue()
+
+ def propre(self, **extra):
+ base = {
+ "database": "db",
+ "attachments": 1,
+ "files_present": 1,
+ "missing": 0,
+ "nested_total": 0,
+ "root": "/x",
+ "groups": {v: [] for v in fs.VERDICTS},
+ }
+ base.update(extra)
+ return base
+
+ def test_nothing_lost_exits_zero(self):
+ code, _ = self.lance(self.propre())
+ self.assertEqual(code, 0)
+
+ def test_recoverable_only_still_exits_zero(self):
+ # Rien à décider : tout se récupère. Sortir 1 ferait échouer une
+ # chaîne make pour une situation saine.
+ groupes = {v: [] for v in fs.VERDICTS}
+ groupes["in_backup"] = [piece("a/1")]
+ code, _ = self.lance(self.propre(missing=1, groups=groupes))
+ self.assertEqual(code, 0)
+
+ def test_a_real_loss_exits_one(self):
+ groupes = {v: [] for v in fs.VERDICTS}
+ groupes["lost"] = [piece("a/1")]
+ code, _ = self.lance(self.propre(missing=1, groups=groupes))
+ self.assertEqual(code, 1)
+
+ def test_an_unreadable_database_exits_two(self):
+ code, _ = self.lance({"unavailable": True, "database": "db"})
+ self.assertEqual(code, 2)
+
+
+class TestTheFollowUpIcons(unittest.TestCase):
+ """Chaque entrée « Aller plus loin » porte une icône, ou aucune.
+
+ Une seule entrée nue au milieu d'entrées ornées se lit comme un
+ oubli — et c'en est un. Le garde vaut mieux qu'une relecture : la
+ prochaine entrée ajoutée sans icône tombera ici, pas dans l'œil de
+ quelqu'un six mois plus tard.
+ """
+
+ RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+
+ def libelles(self):
+ """Les clés passées en `prompt_description` à `_analyse_follow_up`."""
+ import ast
+
+ with io.open(
+ os.path.join(self.RACINE, "script", "todo", "todo.py"),
+ encoding="utf-8",
+ ) as handle:
+ arbre = ast.parse(handle.read())
+ trouves = []
+ for node in ast.walk(arbre):
+ if not (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Attribute)
+ and node.func.attr == "_analyse_follow_up"
+ and node.args
+ ):
+ continue
+ for element in ast.walk(node.args[0]):
+ if (
+ isinstance(element, ast.Call)
+ and isinstance(element.func, ast.Name)
+ and element.func.id == "t"
+ and element.args
+ and isinstance(element.args[0], ast.Constant)
+ ):
+ trouves.append(element.args[0].value)
+ return trouves
+
+ def test_there_are_follow_up_entries_to_check(self):
+ # Sans cette borne, un jour où l'extraction ne trouve plus rien,
+ # le test suivant passerait en ne vérifiant rien du tout.
+ self.assertGreater(len(self.libelles()), 5)
+
+ def test_every_follow_up_entry_carries_an_icon(self):
+ for cle in self.libelles():
+ for langue in ("fr", "en"):
+ texte = todo_i18n.TRANSLATIONS.get(cle, {}).get(langue, cle)
+ self.assertGreaterEqual(
+ ord(texte[0]),
+ 0x1F300,
+ f"entrée sans icône [{langue}] : {texte!r}",
+ )
+
+
+class TestTranslations(unittest.TestCase):
+ def test_every_key_exists(self):
+ import ast
+
+ with io.open(fs.__file__, encoding="utf-8") as handle:
+ arbre = ast.parse(handle.read())
+ cles = set(fs.EXPLICATION.values())
+ for node in ast.walk(arbre):
+ if (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "t"
+ and node.args
+ and isinstance(node.args[0], ast.Constant)
+ ):
+ cles.add(node.args[0].value)
+ for cle in cles:
+ self.assertTrue(
+ cle in todo_i18n.TRANSLATIONS, f"clé sans traduction : {cle!r}"
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_check_module_package.py b/test/test_check_module_package.py
new file mode 100644
index 0000000..8c61cf9
--- /dev/null
+++ b/test/test_check_module_package.py
@@ -0,0 +1,875 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Ce que l'outil doit garantir avant qu'on lui fasse confiance.
+
+Deux propriétés valent tous les autres tests. La première : un module
+absent n'est pas UNE catégorie — dire « manquant » là où il fallait dire
+« absent du chemin des addons » envoie installer ce qui ne peut pas
+l'être. La seconde : l'outil vise des bases 12 à 18 dans la même session,
+et `shortdesc` y est tantôt varchar tantôt jsonb ; se tromper fait
+échouer la requête entière et l'outil déclare la base illisible alors
+qu'elle se porte bien.
+"""
+
+import ast
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.analyse import check_module_package as modules # noqa: E402
+from script.todo import todo_i18n # noqa: E402
+
+PACKAGES = {
+ "odoo18.0_base": {
+ "base": "",
+ "image_list": [{"module": ["queue_job", "web_dark_mode"]}],
+ },
+ "odoo18.0_website": {
+ "base": "odoo18.0_base",
+ "image_list": [{"module": ["website_extra", "queue_job"]}],
+ },
+ "boucle_a": {"base": "boucle_b", "image_list": [{"module": ["a"]}]},
+ "boucle_b": {"base": "boucle_a", "image_list": [{"module": ["b"]}]},
+}
+
+
+class TestThePackageFile(unittest.TestCase):
+ def test_the_path_is_absolute(self):
+ # Un chemin relatif ne marchait que depuis la racine du dépôt :
+ # lancé du menu TODO ou d'ailleurs, l'outil disait « fichier de
+ # packages illisible » et envoyait chercher au mauvais endroit.
+ self.assertTrue(os.path.isabs(modules.PACKAGE_FILE))
+
+ def test_the_real_file_parses(self):
+ reel = modules.read_packages()
+ self.assertIn("odoo18.0_base", reel)
+
+ def test_an_unreadable_file_is_empty_not_a_crash(self):
+ self.assertEqual(modules.read_packages("/nowhere/at/all.json"), {})
+
+ def test_a_file_that_is_not_a_dict_is_refused(self):
+ chemin = os.path.join(os.path.dirname(__file__), "..", "README.md")
+ self.assertEqual(modules.read_packages(chemin), {})
+
+
+class TestTheChain(unittest.TestCase):
+ def test_it_runs_root_first(self):
+ self.assertEqual(
+ modules.package_chain("odoo18.0_website", PACKAGES),
+ ["odoo18.0_base", "odoo18.0_website"],
+ )
+
+ def test_a_root_is_alone(self):
+ self.assertEqual(
+ modules.package_chain("odoo18.0_base", PACKAGES), ["odoo18.0_base"]
+ )
+
+ def test_an_unknown_name_gives_nothing(self):
+ self.assertEqual(modules.package_chain("pas_moi", PACKAGES), [])
+
+ def test_a_cycle_stops_instead_of_spinning(self):
+ # Sans garde, `base` circulaire ferait tourner l'outil sans fin.
+ chaine = modules.package_chain("boucle_a", PACKAGES)
+ self.assertEqual(sorted(chaine), ["boucle_a", "boucle_b"])
+
+
+class TestWhatAPackageSuggests(unittest.TestCase):
+ def test_it_inherits_from_its_base(self):
+ trouve = modules.package_modules("odoo18.0_website", PACKAGES)
+ self.assertEqual(
+ sorted(trouve), ["queue_job", "web_dark_mode", "website_extra"]
+ )
+
+ def test_it_keeps_the_package_of_ORIGIN(self):
+ # Savoir qu'un module vient de la base et non de l'extension dit
+ # s'il est fondamental ou accessoire ; garder le dernier vu
+ # attribuerait tout à la feuille.
+ trouve = modules.package_modules("odoo18.0_website", PACKAGES)
+ self.assertEqual(trouve["queue_job"], "odoo18.0_base")
+ self.assertEqual(trouve["website_extra"], "odoo18.0_website")
+
+ def test_the_real_18_package_has_its_fifteen(self):
+ reel = modules.read_packages()
+ self.assertEqual(
+ len(modules.package_modules("odoo18.0_base", reel)), 15
+ )
+
+
+class TestTheVersion(unittest.TestCase):
+ def test_it_keeps_two_components(self):
+ self.assertEqual(modules.default_package("18.0"), "odoo18.0_base")
+
+ def test_no_version_gives_no_package(self):
+ self.assertIsNone(modules.default_package(None))
+
+
+class TestReadingAColumn(unittest.TestCase):
+ """`shortdesc` est varchar jusqu'en 15, jsonb ensuite."""
+
+ def test_a_plain_column_is_read_plainly(self):
+ sql = modules.as_text("shortdesc", {"shortdesc": "character varying"})
+ self.assertNotIn("->>", sql)
+ self.assertIn("shortdesc", sql)
+
+ def test_a_jsonb_column_is_extracted(self):
+ sql = modules.as_text("shortdesc", {"shortdesc": "jsonb"})
+ self.assertIn("->>", sql)
+ self.assertIn("en_US", sql)
+
+ def test_an_unknown_column_is_treated_as_plain(self):
+ self.assertNotIn("->>", modules.as_text("shortdesc", {}))
+
+
+class TestTheVerdicts(unittest.TestCase):
+ CONNUS = {
+ "installe": ("installed", "", False, "Odoo"),
+ "a_maj": ("to upgrade", "", False, "Odoo"),
+ "dispo": ("uninstalled", "", False, "OCA"),
+ "casse": ("uninstallable", "", False, "OCA"),
+ "en_cours": ("to install", "", False, "OCA"),
+ }
+
+ def test_each_state_maps_to_its_verdict(self):
+ for nom, attendu in (
+ ("installe", "installed"),
+ ("a_maj", "installed"),
+ ("dispo", "available"),
+ ("casse", "uninstallable"),
+ ("en_cours", "pending"),
+ ):
+ self.assertEqual(
+ modules.verdict_of(nom, self.CONNUS), attendu, nom
+ )
+
+ def test_a_module_the_database_never_heard_of_is_unknown(self):
+ # Le distinguer de « disponible » est tout l'intérêt : il n'y a
+ # rien à installer tant que le chemin des addons est incomplet.
+ self.assertEqual(
+ modules.verdict_of("jamais_vu", self.CONNUS), "unknown"
+ )
+
+ def test_an_unexpected_state_is_not_silently_installed(self):
+ # Un état qu'Odoo ajouterait demain ne doit pas passer pour
+ # installé : mieux vaut le signaler que de le taire.
+ etrange = {"x": ("something_new", "", False, "")}
+ self.assertNotEqual(modules.verdict_of("x", etrange), "installed")
+
+
+class TestBlockingDependencies(unittest.TestCase):
+ CONNUS = {
+ "moi": ("uninstalled", "", False, ""),
+ "ok": ("installed", "", False, ""),
+ "dispo": ("uninstalled", "", False, ""),
+ "casse": ("uninstallable", "", False, ""),
+ }
+
+ def test_only_what_truly_blocks_is_listed(self):
+ depend = {"moi": ["ok", "dispo", "casse", "absent"]}
+ self.assertEqual(
+ modules.blocking_dependencies("moi", self.CONNUS, depend),
+ ["absent", "casse"],
+ )
+
+ def test_no_dependency_is_no_problem(self):
+ self.assertEqual(
+ modules.blocking_dependencies("moi", self.CONNUS, {}), []
+ )
+
+
+class FakeBase:
+ """Une base qui répond, sans PostgreSQL.
+
+ On aiguille sur le SQL plutôt que sur l'ordre des appels : un test qui
+ compte les appels casse dès qu'on réordonne le code sans rien changer
+ au comportement.
+ """
+
+ def __init__(
+ self,
+ connus,
+ depend=None,
+ jsonb=False,
+ muette=False,
+ sans_colonnes=False,
+ sans_recensement=False,
+ ):
+ self.connus = connus
+ self.depend = depend or {}
+ self.jsonb = jsonb
+ self.muette = muette
+ self.sans_colonnes = sans_colonnes
+ self.sans_recensement = sans_recensement
+ self.vues = []
+
+ def __call__(self, database, sql):
+ self.vues.append(sql)
+ if self.muette:
+ return None
+ if "information_schema.columns" in sql:
+ if self.sans_colonnes:
+ return []
+ desc = "jsonb" if self.jsonb else "character varying"
+ return [
+ ["name", "character varying"],
+ ["shortdesc", desc],
+ ["author", "character varying"],
+ ["state", "character varying"],
+ ]
+ if "latest_version" in sql:
+ return [["18.0.1.3"]]
+ if "ir_module_module_dependency" in sql:
+ return [
+ [mod, dep] for mod, lst in self.depend.items() for dep in lst
+ ]
+ if "FROM ir_module_module" in sql:
+ if self.sans_recensement:
+ return None
+ return [
+ [nom, etat, desc, "1" if app else "0", auteur]
+ for nom, (etat, desc, app, auteur) in self.connus.items()
+ ]
+ return []
+
+
+CONNUS = {
+ "queue_job": ("uninstalled", "Job Queue", False, "OCA"),
+ "web_dark_mode": ("installed", "Dark Mode", False, "OCA"),
+ "base": ("installed", "Base", False, "Odoo S.A."),
+ "casse": ("uninstallable", "Cassé", False, "OCA"),
+}
+
+
+class TestTheAudit(unittest.TestCase):
+ def setUp(self):
+ self.vrai = modules.run_psql
+
+ def tearDown(self):
+ modules.run_psql = self.vrai
+
+ def audite(self, connus=None, depend=None, jsonb=False, package=None):
+ modules.run_psql = FakeBase(
+ CONNUS if connus is None else connus, depend, jsonb
+ )
+ return modules.audit("db", package=package, packages=PACKAGES)
+
+ def test_it_finds_the_package_from_the_database_version(self):
+ rapport = self.audite()
+ self.assertEqual(rapport["version"], "18.0")
+ self.assertEqual(rapport["package"], "odoo18.0_base")
+ self.assertTrue(rapport["package_known"])
+
+ def test_it_classifies_each_suggested_module(self):
+ rapport = self.audite()
+ verdicts = {
+ ligne["module"]: ligne["verdict"] for ligne in rapport["lines"]
+ }
+ self.assertEqual(
+ verdicts, {"queue_job": "available", "web_dark_mode": "installed"}
+ )
+
+ def test_a_module_absent_from_the_database_is_unknown(self):
+ rapport = self.audite(
+ connus={"base": ("installed", "", False, "Odoo")}
+ )
+ verdicts = {
+ ligne["module"]: ligne["verdict"] for ligne in rapport["lines"]
+ }
+ self.assertEqual(verdicts["queue_job"], "unknown")
+
+ def test_it_reports_what_installing_would_still_need(self):
+ rapport = self.audite(
+ depend={"queue_job": ["casse", "base", "fantome"]}
+ )
+ ligne = [x for x in rapport["lines"] if x["module"] == "queue_job"][0]
+ self.assertEqual(ligne["needs"], ["casse", "fantome"])
+
+ def test_a_jsonb_database_is_read_not_refused(self):
+ # Sur une base 16+, `shortdesc` est jsonb. Une requête écrite pour
+ # du varchar y échoue ENTIÈREMENT et l'outil déclarerait la base
+ # illisible.
+ rapport = self.audite(jsonb=True)
+ self.assertFalse(rapport.get("unavailable"))
+ recensement = [
+ s
+ for s in modules.run_psql.vues
+ if "FROM ir_module_module" in s
+ and "dependency" not in s
+ and "latest_version" not in s
+ ]
+ self.assertTrue(any("->>" in s for s in recensement), recensement)
+
+ def test_a_silent_database_is_unavailable_not_empty(self):
+ # Rendre un rapport vide ferait croire à une base sans modules.
+ modules.run_psql = FakeBase({}, muette=True)
+ self.assertTrue(modules.audit("db", packages=PACKAGES)["unavailable"])
+
+ def test_a_database_without_the_module_table_is_unavailable(self):
+ # Pas de table `ir_module_module` : ce n'est pas une base Odoo.
+ modules.run_psql = FakeBase(CONNUS, sans_colonnes=True)
+ self.assertTrue(modules.audit("db", packages=PACKAGES)["unavailable"])
+
+ def test_a_census_query_that_fails_is_unavailable(self):
+ # Les colonnes répondent, le recensement non — l'autre garde. Les
+ # tester ensemble laissait chacune masquer la panne de l'autre.
+ modules.run_psql = FakeBase(CONNUS, sans_recensement=True)
+ self.assertTrue(modules.audit("db", packages=PACKAGES)["unavailable"])
+
+ def test_the_census_counts_every_module_not_only_the_suggested(self):
+ rapport = self.audite()
+ self.assertEqual(rapport["total"], len(CONNUS))
+ self.assertEqual(rapport["by_state"]["installed"], 2)
+
+ def test_extras_are_what_is_installed_beyond_the_package(self):
+ rapport = self.audite()
+ self.assertIn("base", rapport["extra"])
+ self.assertNotIn("web_dark_mode", rapport["extra"])
+
+ def test_an_unknown_package_is_flagged_not_silently_empty(self):
+ rapport = self.audite(package="jamais_defini")
+ self.assertFalse(rapport["package_known"])
+ self.assertEqual(rapport["lines"], [])
+
+
+class TestTheOrderOfTheReport(unittest.TestCase):
+ def ligne(self, module, verdict):
+ return {
+ "module": module,
+ "verdict": verdict,
+ "from": "p",
+ "state": "",
+ "shortdesc": "",
+ "needs": [],
+ }
+
+ def test_the_worst_comes_first(self):
+ # « unknown » demande de réparer le chemin des addons avant tout
+ # le reste : le lire en dernier ferait installer dans le vide.
+ rapport = {
+ "lines": [
+ self.ligne("aaa", "available"),
+ self.ligne("zzz", "unknown"),
+ self.ligne("mmm", "uninstallable"),
+ self.ligne("bbb", "installed"),
+ ]
+ }
+ self.assertEqual(
+ [x["module"] for x in modules.missing(rapport)],
+ ["zzz", "mmm", "aaa"],
+ )
+
+ def test_installed_modules_never_appear(self):
+ rapport = {"lines": [self.ligne("z", "installed")]}
+ self.assertEqual(modules.missing(rapport), [])
+
+ def test_every_verdict_has_a_rank_and_an_icon_and_a_wording(self):
+ # Un verdict sans rang ferait planter le tri ; sans libellé, le
+ # rapport nommerait l'état sans nommer le geste.
+ for verdict in modules.VERDICTS:
+ self.assertIn(verdict, modules.ICONE)
+ self.assertIn(verdict, modules.EXPLICATION)
+ for verdict in modules.ETAT_VERS_VERDICT.values():
+ self.assertIn(verdict, modules.VERDICTS)
+
+
+class TestTheRendering(unittest.TestCase):
+ def rapport(self, **extra):
+ base = {
+ "database": "db",
+ "version": "18.0",
+ "package": "odoo18.0_base",
+ "package_known": True,
+ "chain": ["odoo18.0_base"],
+ "lines": [],
+ "by_state": {"installed": 2},
+ "total": 2,
+ "installed": ["base"],
+ "extra": [],
+ "authors": [("Odoo S.A.", 2)],
+ }
+ base.update(extra)
+ return base
+
+ def test_a_clean_database_says_so(self):
+ texte = "\n".join(modules.render(self.rapport()))
+ self.assertIn(
+ todo_i18n.t("Every suggested module is installed."), texte
+ )
+
+ def test_an_unknown_package_never_claims_success(self):
+ # Comparé à rien, TOUT semble installé : le dire serait un
+ # mensonge tranquille, le pire des rapports.
+ texte = "\n".join(
+ modules.render(self.rapport(package_known=False, package="?"))
+ )
+ self.assertNotIn(
+ todo_i18n.t("Every suggested module is installed."), texte
+ )
+ self.assertIn(
+ todo_i18n.t("No default package known for this version"), texte
+ )
+
+ def test_a_missing_module_names_the_action(self):
+ ligne = {
+ "module": "queue_job",
+ "verdict": "unknown",
+ "from": "p",
+ "state": "",
+ "shortdesc": "Job Queue",
+ "needs": [],
+ }
+ texte = "\n".join(modules.render(self.rapport(lines=[ligne])))
+ self.assertIn("queue_job", texte)
+ self.assertIn(
+ todo_i18n.t("absent from the addons path — sync the repo first"),
+ texte,
+ )
+
+ def test_an_unreadable_database_renders_without_crashing(self):
+ texte = "\n".join(
+ modules.render({"unavailable": True, "database": "x"})
+ )
+ self.assertIn("x", texte)
+
+ def test_the_limit_caps_the_list_and_says_how_many_were_hidden(self):
+ lignes = [
+ {
+ "module": f"m{i}",
+ "verdict": "available",
+ "from": "p",
+ "state": "",
+ "shortdesc": "",
+ "needs": [],
+ }
+ for i in range(10)
+ ]
+ texte = "\n".join(modules.render(self.rapport(lines=lignes), limit=3))
+ self.assertIn("m0", texte)
+ self.assertNotIn("m9", texte)
+ self.assertIn(f"7 {todo_i18n.t('more')}", texte)
+
+
+class TestTheCommandLine(unittest.TestCase):
+ def setUp(self):
+ self.vrai = modules.run_psql
+
+ def tearDown(self):
+ modules.run_psql = self.vrai
+
+ @classmethod
+ def setUpClass(cls):
+ import json
+ import tempfile
+
+ cls.dossier = tempfile.TemporaryDirectory()
+ cls.fichier = os.path.join(cls.dossier.name, "packages.json")
+ with io.open(cls.fichier, "w", encoding="utf-8") as handle:
+ json.dump(PACKAGES, handle)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.dossier.cleanup()
+
+ def lance(self, argv, connus=CONNUS, muette=False):
+ modules.run_psql = FakeBase(connus, muette=muette)
+ argv = list(argv) + ["--file", self.fichier]
+ tampon = io.StringIO()
+ with redirect_stdout(tampon):
+ code = modules.main(argv)
+ return code, tampon.getvalue()
+
+ def test_nothing_missing_exits_zero(self):
+ code, _ = self.lance(
+ ["-d", "db", "-p", "odoo18.0_base"],
+ connus={
+ "web_dark_mode": ("installed", "", False, ""),
+ "queue_job": ("installed", "", False, ""),
+ },
+ )
+ self.assertEqual(code, 0)
+
+ def test_a_finding_exits_one(self):
+ code, sortie = self.lance(["-d", "db"])
+ self.assertEqual(code, 1)
+ self.assertIn("queue_job", sortie)
+
+ def test_an_unreadable_database_exits_two(self):
+ # 2 dit « l'outil a échoué », pas « rien trouvé » : un script qui
+ # confond les deux conclurait que tout va bien.
+ code, _ = self.lance(["-d", "db"], muette=True)
+ self.assertEqual(code, 2)
+
+ def test_listing_packages_needs_no_database(self):
+ code, sortie = self.lance(["--list-packages"])
+ self.assertEqual(code, 0)
+ self.assertIn("odoo18.0_base", sortie)
+
+ def test_json_output_is_parseable(self):
+ import json
+
+ code, sortie = self.lance(["-d", "db", "--json"])
+ self.assertEqual(code, 1)
+ self.assertEqual(json.loads(sortie)["package"], "odoo18.0_base")
+
+
+class TestTheWiring(unittest.TestCase):
+ RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+
+ def source(self, chemin):
+ with io.open(os.path.join(self.RACINE, chemin), encoding="utf-8") as f:
+ return f.read()
+
+ def test_every_translation_key_exists(self):
+ # Une clé absente s'affiche en anglais au milieu du français, et
+ # rien ne le signale à l'exécution.
+ src = self.source("script/analyse/check_module_package.py")
+ arbre = ast.parse(src)
+ cles = set()
+ for node in ast.walk(arbre):
+ if (
+ isinstance(node, ast.Call)
+ and isinstance(node.func, ast.Name)
+ and node.func.id == "t"
+ and node.args
+ and isinstance(node.args[0], ast.Constant)
+ ):
+ cles.add(node.args[0].value)
+ cles.update(modules.EXPLICATION.values())
+ manquantes = [c for c in cles if c not in todo_i18n.TRANSLATIONS]
+ self.assertEqual(manquantes, [])
+
+ def test_the_menu_offers_the_entry_and_dispatches_it(self):
+ src = self.source("script/todo/todo.py")
+ self.assertIn("Modules missing from the default package", src)
+ self.assertIn("self.execute_analyse_module_package()", src)
+ self.assertIn("def execute_analyse_module_package", src)
+
+ def test_the_menu_has_as_many_entries_as_branches(self):
+ # Ajouter une entrée sans son aiguillage donne « Command not
+ # found » sur un choix que le menu vient d'afficher.
+ src = self.source("script/todo/todo.py")
+ debut = src.index("def prompt_execute_analyse")
+ fin = src.index("def execute_analyse_module_package")
+ self.assertLess(debut, fin)
+ bloc = src[debut:fin]
+ entrees = bloc.count('"prompt_description"')
+ branches = sum(
+ f'status == "{n}"' in bloc for n in range(1, entrees + 2)
+ )
+ 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.
+
+ Un outil livré sans le bit d'exécution ne se lance qu'en le préfixant
+ de `python3`, et rien ne le signale avant l'essai. Un module de
+ bibliothèque marqué exécutable invite à le lancer alors qu'il n'a pas
+ de `main` — l'inverse est tout aussi trompeur.
+ """
+
+ DOSSIER = os.path.normpath(
+ os.path.join(os.path.dirname(__file__), "..", "script", "analyse")
+ )
+
+ def test_shebang_and_executable_bit_agree(self):
+ import glob
+ import stat
+
+ for chemin in sorted(glob.glob(os.path.join(self.DOSSIER, "*.py"))):
+ with io.open(chemin, encoding="utf-8") as handle:
+ shebang = handle.readline().startswith("#!")
+ executable = bool(
+ stat.S_IMODE(os.stat(chemin).st_mode) & stat.S_IXUSR
+ )
+ 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()
diff --git a/test/test_master_password_retry.py b/test/test_master_password_retry.py
new file mode 100644
index 0000000..9efec85
--- /dev/null
+++ b/test/test_master_password_retry.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+
+"""Une faute de frappe ne doit pas coûter une migration.
+
+Le mot de passe maître était demandé UNE fois. Faux ? Odoo lève
+`AccessDenied`, `check_output` lève `CalledProcessError`, rien ne
+l'attrape, et la migration meurt sur une trace. Après une heure de
+paliers, c'est cher payé pour une lettre.
+
+La propriété qui porte tout : on ne redemande QUE sur un refus de mot de
+passe. Reposer la question sur n'importe quel échec cacherait la vraie
+panne derrière dix invites, et l'on chercherait un mot de passe alors
+que la base est cassée.
+"""
+
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stderr, redirect_stdout
+
+sys.path.append(
+ os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
+)
+
+from script.database import db_restore # noqa: E402
+
+
+class TestRecognisingTheRefusal(unittest.TestCase):
+ def test_an_access_denied_is_a_refusal(self):
+ self.assertTrue(
+ db_restore.password_refused(
+ "Traceback...\nodoo.exceptions.AccessDenied: Access Denied"
+ )
+ )
+
+ def test_the_class_is_what_we_match_not_the_message(self):
+ # Le message est traduit : « Accès refusé » en français. La
+ # CLASSE, elle, ne bouge pas.
+ self.assertTrue(
+ db_restore.password_refused("odoo.exceptions.AccessDenied")
+ )
+ self.assertFalse(db_restore.password_refused("Accès refusé"))
+
+ def test_anything_else_is_NOT_a_refusal(self):
+ # C'est la protection : dix invites de mot de passe devant une
+ # base cassée, et l'on cherche du mauvais côté.
+ for sortie in (
+ "psycopg2.OperationalError: could not connect",
+ "FileNotFoundError: ./odoo_bin.sh",
+ "",
+ None,
+ ):
+ self.assertFalse(db_restore.password_refused(sortie), sortie)
+
+
+class TestTheRetryLoop(unittest.TestCase):
+ def setUp(self):
+ self.vrais = (
+ db_restore.get_master_password,
+ db_restore.probe_master_password,
+ )
+ self.demandes = 0
+ self.sondes = []
+
+ def tearDown(self):
+ (
+ db_restore.get_master_password,
+ db_restore.probe_master_password,
+ ) = self.vrais
+
+ def branche(self, mots, reponses):
+ suite = iter(mots)
+ rep = iter(reponses)
+
+ def demander():
+ self.demandes += 1
+ return next(suite, "")
+
+ def sonder(arg_base):
+ self.sondes.append(arg_base)
+ return next(rep, (False, "AccessDenied"))
+
+ db_restore.get_master_password = demander
+ db_restore.probe_master_password = sonder
+
+ def lance(self, essais=10):
+ tampon = io.StringIO()
+ with redirect_stdout(tampon), redirect_stderr(tampon):
+ return db_restore.ask_master_password("./odoo_bin.sh db", essais)
+
+ def test_a_good_password_is_returned_at_once(self):
+ self.branche(["bon"], [(True, "db1\ndb2")])
+ self.assertEqual(self.lance(), "bon")
+ self.assertEqual(self.demandes, 1)
+
+ def test_a_typo_is_asked_again(self):
+ self.branche(
+ ["faux", "bon"],
+ [(False, "odoo.exceptions.AccessDenied"), (True, "db1")],
+ )
+ self.assertEqual(self.lance(), "bon")
+ self.assertEqual(self.demandes, 2)
+
+ def test_it_stops_after_the_allowed_attempts(self):
+ # Sans borne, une invite non lue tournerait toute la nuit.
+ self.branche(["faux"] * 20, [(False, "AccessDenied")] * 20)
+ self.assertIsNone(self.lance(essais=10))
+ self.assertEqual(self.demandes, 10)
+
+ def test_an_empty_prompt_gives_up_immediately(self):
+ # Ctrl-D ou Entrée : on ne veut pas neuf invites de plus.
+ self.branche([""], [])
+ self.assertIsNone(self.lance())
+ self.assertEqual(self.demandes, 1)
+ self.assertEqual(self.sondes, [])
+
+ def test_an_unrelated_failure_stops_instead_of_asking_again(self):
+ # LA propriété. Une base injoignable n'est pas un mot de passe
+ # faux, et redemander dix fois cacherait la vraie panne.
+ self.branche(["bon"], [(False, "psycopg2.OperationalError: refused")])
+ self.assertIsNone(self.lance())
+ self.assertEqual(self.demandes, 1)
+
+ def test_the_unrelated_failure_is_shown(self):
+ self.branche(["bon"], [(False, "psycopg2.OperationalError: refused")])
+ with self.assertLogs(db_restore._logger, level="ERROR") as journal:
+ db_restore.ask_master_password("./odoo_bin.sh db")
+ self.assertIn("OperationalError", "\n".join(journal.output))
+
+ def test_the_probe_carries_the_password_and_touches_nothing(self):
+ # `--list` ne modifie rien : valider ici évite d'échouer à
+ # mi-parcours, une fois la base déjà supprimée.
+ self.branche(["bon"], [(True, "db1")])
+ self.lance()
+ self.assertEqual(len(self.sondes), 1)
+ self.assertIn("--master_password=bon", self.sondes[0])
+
+ def test_each_attempt_probes_with_ITS_password(self):
+ self.branche(
+ ["un", "deux"],
+ [(False, "AccessDenied"), (True, "db1")],
+ )
+ self.lance()
+ self.assertIn("--master_password=un", self.sondes[0])
+ self.assertIn("--master_password=deux", self.sondes[1])
+
+
+class TestTheWiring(unittest.TestCase):
+ def source(self):
+ with io.open(db_restore.__file__, encoding="utf-8") as handle:
+ return handle.read()
+
+ def test_the_flow_uses_the_retrying_version(self):
+ src = self.source()
+ self.assertIn("master_password = ask_master_password(arg_base)", src)
+
+ def test_the_probe_uses_list_which_changes_nothing(self):
+ src = self.source()
+ debut = src.index("def probe_master_password")
+ fin = src.index("def ask_master_password")
+ bloc = src[debut:fin]
+ self.assertIn("--list", bloc)
+ for danger in ("--drop", "--restore", "--clone"):
+ self.assertNotIn(danger, bloc)
+
+ def test_the_bound_is_ten(self):
+ self.assertEqual(db_restore.MAX_ESSAIS_MOT_DE_PASSE, 10)
+
+
+if __name__ == "__main__":
+ unittest.main()