[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
#!/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, ''),"
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
" a.id::text,"
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
" 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],
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
"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],
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
}
|
|
|
|
|
|
for ligne in lignes
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
if len(ligne) >= 9
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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.
|
|
|
|
|
|
"""
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
ailleurs, niches, par_base = {}, {}, {}
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
if not os.path.isdir(racine):
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
return ailleurs, niches, par_base
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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":
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
# 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.
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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):
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
niches.setdefault(f"{deux}/{nom}", base)
|
|
|
|
|
|
par_base[base] = par_base.get(base, 0) + 1
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
continue
|
|
|
|
|
|
if base == sauf:
|
|
|
|
|
|
continue
|
|
|
|
|
|
for nom in os.listdir(sous):
|
|
|
|
|
|
ailleurs.setdefault(f"{prefixe}/{nom}", base)
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
return ailleurs, niches, par_base
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] db_restore: check the filestore landed, and open the tool from the menu
Odoo's shutil.move renames when the destination is absent and NESTS when
it exists, so a leftover filestore/<db>/ sends a whole backup into
filestore/<db>/filestore/, where Odoo never looks. That happened once
here and the clone copied it into all seven databases of the chain --
1168 files, 133 MB each, and nothing said a word.
The check runs after a real restore only. A clone copies its source as
it stands, faults included: checking the mirror would say the same thing
twice, and in the wrong place. It warns and names the fix rather than
aborting -- the database is restored and usable, it is the layout that
is wrong.
--- FR ---
Le shutil.move d'Odoo renomme quand la destination est absente et
IMBRIQUE quand elle existe : un filestore/<base>/ resté là envoie toute
une sauvegarde dans filestore/<base>/filestore/, où Odoo ne regarde
jamais. C'est arrivé une fois ici et le clone l'a recopié dans les sept
bases de la chaîne -- 1168 fichiers, 133 Mo chacune, sans un mot.
Le contrôle ne suit qu'une vraie restauration. Un clone recopie sa
source telle quelle, défauts compris : contrôler le miroir dirait deux
fois la même chose, au mauvais endroit. Il avertit et nomme la
correction plutôt que d'interrompre -- la base est restaurée et
utilisable, c'est la disposition qui cloche.
Assisted-by: Claude Opus 5
2026-08-21 22:14:48 -04:00
|
|
|
|
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/<base>/` laissé par une restauration précédente suffit
|
|
|
|
|
|
donc à envoyer toute la sauvegarde dans
|
|
|
|
|
|
`filestore/<base>/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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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.
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
if is_dead_field(piece, champs_vivants):
|
|
|
|
|
|
return ("dead_field", f"{piece['model']}.{piece['field']}")
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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)
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
# `root` désigne le dossier de CETTE base. Y mettre la racine de tous
|
|
|
|
|
|
# les filestores faisait chercher le dossier imbriqué à
|
|
|
|
|
|
# `<data_dir>/filestore/filestore` — inexistant — et l'outil
|
|
|
|
|
|
# répondait « rien à ranger » devant 1168 fichiers échoués.
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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}")
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
ailleurs, niches, par_base = scan_filestores(racine, sauf=database)
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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()
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
morts = []
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
gardees = []
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
for piece in pieces:
|
|
|
|
|
|
verdict = classify(
|
|
|
|
|
|
piece, present, ailleurs, niches, sauvegardes, champs_vivants
|
|
|
|
|
|
)
|
|
|
|
|
|
if not verdict:
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
# 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"]))
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
continue
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
# 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"]))
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
# 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]))
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
vivants = resources_alive(database, groupes["lost"])
|
|
|
|
|
|
for piece in groupes["lost"]:
|
|
|
|
|
|
piece["alive"] = vivants.get((piece["model"], piece["res_id"]))
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
return {
|
|
|
|
|
|
"database": database,
|
|
|
|
|
|
"attachments": len(pieces),
|
|
|
|
|
|
"files_present": len(present),
|
|
|
|
|
|
"missing": len(vus),
|
|
|
|
|
|
"groups": groupes,
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
"nested_total": par_base.get(database, 0),
|
|
|
|
|
|
"nested_elsewhere": sum(
|
|
|
|
|
|
combien for base, combien in par_base.items() if base != database
|
|
|
|
|
|
),
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
"root": mien,
|
|
|
|
|
|
"dead_ids": morts,
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
"dead_kept": gardees,
|
|
|
|
|
|
"dead_kept_size": sum(piece["size"] for piece in gardees),
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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')}")
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
return lignes + render_dead_kept(rapport) + render_nested(rapport)
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
for piece in groupe[: limit or None]:
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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}"
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
f" {piece['created']} {alive_mark(piece)}"
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
)
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
if limit and len(groupe) > limit:
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
lignes.append(f" … {len(groupe) - limit} {t('more')}")
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
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
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def render_nested(rapport):
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
ailleurs = rapport.get("nested_elsewhere") or 0
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
if not rapport.get("nested_total"):
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
# 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.')}",
|
|
|
|
|
|
]
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
return []
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
lignes = [
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
"",
|
|
|
|
|
|
f" ↳ {rapport['nested_total']}"
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
f" {t('file(s) sit in a nested filestore Odoo never reads.')}",
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
]
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
|
return lignes
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
|
|
|
|
|
|
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
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 ""
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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])
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
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é.
|
|
|
|
|
|
"""
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
ids = sorted(set(rapport.get("dead_ids") or []))
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
if not ids:
|
|
|
|
|
|
return ""
|
|
|
|
|
|
liste = ", ".join(str(i) for i in ids)
|
|
|
|
|
|
return f"DELETE FROM ir_attachment WHERE id IN ({liste})"
|
|
|
|
|
|
|
|
|
|
|
|
|
[FIX] filestore: five defects a real repair session brought out
Deduplicating by file was right for COUNTING and wrong for DELETING:
twenty-two rows shared two files, so the purge only ever offered one at
a time and had to be replayed. It now gathers every row whose own field
is dead, and never a live row sharing the same file.
"root" held the root of all filestores instead of this database's
directory, so tidying looked for a nested folder at
<data_dir>/filestore/filestore and answered "nothing to tidy" in front
of 1168 stranded files. It now finds 112 to move up and 1056 duplicates.
limit=0 means "no cap" everywhere else, but the slice [:0] is empty:
"Show every entry" printed "… 3 more" and showed none of them.
The report was captured once, so after a purge it replayed the state
from before -- one then purged rows already gone, believing the work
unfinished. A repair now says whether it did something, and the report
is re-read when it did.
"DELETE 0" was announced as a success. The count now comes from what
PostgreSQL said, and saying nothing is not the same as deleting nothing.
--- FR ---
Dédupliquer par fichier était juste pour COMPTER et faux pour EFFACER :
vingt-deux lignes partageaient deux fichiers, la purge n'en offrait
qu'une à la fois. Elle prend maintenant toutes les lignes dont le champ
est mort, et jamais une ligne vivante partageant le même fichier.
« root » portait la racine de tous les filestores au lieu du dossier de
la base : le rangement cherchait à <data_dir>/filestore/filestore et
répondait « rien à ranger » devant 1168 fichiers échoués. Il en trouve
112 à remonter et 1056 doublons.
limit=0 veut dire « tout » partout ailleurs, mais [:0] est vide : « Tout
afficher » annonçait « … 3 de plus » sans en montrer un seul.
Le rapport n'était lu qu'une fois : après une purge il rejouait l'état
d'avant, et l'on repurgeait des lignes déjà effacées. Une réparation dit
maintenant si elle a fait quelque chose, et le rapport est relu alors.
« DELETE 0 » passait pour un succès. Le compte vient de ce que
PostgreSQL a annoncé, et ne rien dire n'est pas ne rien supprimer.
Assisted-by: Claude Opus 5
2026-08-21 23:20:08 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] filestore: say if the record still exists, and offer the cleanups
A lost image on a deleted task is not a loss -- nobody will ever look
for it. On a LIVING task it is one, and it is the only one worth
regretting. The report now says which: project.task #15 and
calendar.event #1 both still exist, so those two really are gone.
Three states, not two: "could not check" must not read as "it is gone",
or a real loss gets filed as a false alarm.
Two repairs sit in the follow-up menu. Purging rows whose field no
longer exists deletes by ID, never by a rebuilt domain -- replaying the
reasoning in SQL would open the door to deleting more than was shown.
Tidying the nested filestore moves up what is missing and deletes pure
duplicates, never overwriting a file already in place.
--- FR ---
Une image perdue sur une tâche supprimée n'est pas une perte : personne
ne la cherchera. Sur une tâche VIVANTE, c'en est une, et la seule à
regretter. Le rapport le dit : project.task #15 et calendar.event #1
existent encore, ces deux-là sont bien perdues.
Trois états, pas deux : « pas pu vérifier » ne doit pas se lire « a
disparu », sans quoi une vraie perte passe pour une fausse alerte.
Deux réparations dans le menu de suite. La purge efface par IDENTIFIANT,
jamais par un domaine reconstruit -- rejouer le raisonnement en SQL
ouvrirait la porte à effacer plus que ce qui a été montré. Le rangement
remonte ce qui manque et supprime les doublons purs, sans jamais
écraser un fichier déjà en place.
Assisted-by: Claude Opus 5
2026-08-21 22:39:48 -04:00
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
[ADD] analyse: say which missing attachment files are truly unrecoverable
"254 attachment files missing" leaves nothing to decide. The useful
split is how many are gone and how many are lying around. Measured on
the migrated 18: three are lost, one waits in a backup zip, and 250
point at a field that no longer exists -- res.country.image is
image_url, computed, since 13, so nothing reads them and there is
nothing to recover.
The tool searches the other databases' filestores, the nested ones Odoo
never reads, and the backup zips' central directory. Only the truly lost
are listed one by one; naming the rest would bury them.
--- FR ---
« 254 fichiers absents » ne laisse rien à décider. Le partage utile est
entre ce qui est perdu et ce qui traîne quelque part. Mesuré sur la 18
migrée : trois sont perdus, un attend dans une sauvegarde, et 250
pointent vers un champ disparu -- res.country.image est image_url,
calculé, depuis la 13 : rien ne les lit, rien à récupérer.
L'outil cherche dans les filestores des autres bases, dans les
filestores imbriqués qu'Odoo ne lit jamais, et dans le répertoire
central des sauvegardes. Seuls les vrais perdus sont listés un à un ;
nommer les autres les enterrerait.
Assisted-by: Claude Opus 5
2026-08-21 21:57:02 -04:00
|
|
|
|
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())
|