396 lines
14 KiB
Python
396 lines
14 KiB
Python
|
|
#!/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/<base>/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())
|