#!/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, '')," " 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], "field": ligne[2], "res_id": ligne[3], "name": ligne[4], "size": int(ligne[5] or 0), "mimetype": ligne[6], "created": ligne[7][:10], } for ligne in lignes if len(ligne) >= 8 ] 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 = {}, {} if not os.path.isdir(racine): return ailleurs, niches 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": cible, marque = niches, base 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): cible.setdefault(f"{deux}/{nom}", marque) continue if base == sauf: continue for nom in os.listdir(sous): ailleurs.setdefault(f"{prefixe}/{nom}", base) return ailleurs, niches 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 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 piece["field"]: cle = f"{piece['model']}.{piece['field']}" if cle not in champs_vivants: return ("dead_field", cle) 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) 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 = 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() for piece in pieces: verdict = classify( piece, present, ailleurs, niches, sauvegardes, champs_vivants ) if not verdict: continue # 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])) return { "database": database, "attachments": len(pieces), "files_present": len(present), "missing": len(vus), "groups": groupes, "nested_total": len(niches), "root": racine, } 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_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]: 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']}" ) if len(groupe) > limit: lignes.append(f" … {len(groupe) - limit} {t('more')}") return lignes + render_nested(rapport) def render_nested(rapport): if not rapport.get("nested_total"): return [] return [ "", f" ↳ {rapport['nested_total']}" f" {t('file(s) sit in nested filestores Odoo never reads.')}", ] 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 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())