[FIX] analyse: dire CE QUI est parti avec une pièce jointe

« 409 pièces jointes perdues » au palier 17 → 18 n'en recouvrait presque
aucune. Sur les 516 lignes parties, 452 avaient perdu leur CHAMP PORTEUR
aux paliers 13 et 14 : elles étaient déjà illisibles — Odoo lève un
KeyError en les contrôlant. Ce ne sont pas des données, ce sont des
débris, et la 18 les ramasse.

On ne DÉCLARE pas cela dans SEMANTIC_MAP. Cette carte nomme une TABLE, et
la cause n'est pas la table, ce sont ces lignes-là ; déclarée, elle
rangerait toute perte future de ir_attachment sous « changement d'Odoo »,
cinq mille factures comprises. On la DÉDUIT, ligne par ligne, dans le
même vocabulaire.

Reste 64 lignes en rouge au palier 18 : leur champ est toujours là. Elles
demandent un contrôle d'existence d'enregistrement que je n'ai pas
mesuré, donc je ne l'affirme pas.

--- EN ---

« 409 attachments lost » at the 17 → 18 step covered almost none. Of the
516 rows that went, 452 had lost their CARRYING FIELD at steps 13 and 14:
they were already unreadable — Odoo raises a KeyError checking them. Not
data, debris, and 18 sweeps them up.

We do NOT declare this in SEMANTIC_MAP. That map names a TABLE, and the
cause is not the table but those rows; declared, it would file every
future ir_attachment loss under « an Odoo change », five thousand
invoices included. We DEDUCE it, row by row, in the same vocabulary.

64 rows stay red at step 18: their field is still there. They need a
record-existence check I have not measured, so I do not claim it.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 06:08:17 -04:00
parent 2ba64132ca
commit 9af1e2b9d2
3 changed files with 241 additions and 1 deletions

View file

@ -245,6 +245,32 @@ def inspect(database):
"SELECT model || '.' || name FROM ir_model_fields WHERE store",
)
etat["field_stored"] = sorted(ligne[0] for ligne in stockes or [])
# Le CHAMP PORTEUR de chaque pièce jointe, et s'il existe encore.
#
# Une pièce jointe dont `res_field` ne nomme aucun champ vivant est
# DÉJÀ illisible : Odoo lève un KeyError en la contrôlant. Ce ne sont
# pas des données, ce sont des débris. Mesuré sur une chaîne 12 → 18 :
# la dette naît aux paliers 13 et 14, reste gelée pendant trois
# paliers, et la 18 ramasse 452 lignes d'un coup — qui se lisent
# alors comme 452 pertes.
#
# Ni le nom ni le mimetype ici : `run_psql` découpe par LIGNE et un
# nom de fichier peut en contenir une.
portees = run_psql(
database,
"SELECT a.id, coalesce(a.res_model, ''), coalesce(a.res_field, ''),"
" coalesce(a.res_id::text, ''),"
" CASE WHEN a.res_field IS NULL OR a.res_field = '' THEN '1'"
" WHEN EXISTS (SELECT 1 FROM ir_model_fields f"
" WHERE f.model = a.res_model AND f.name = a.res_field)"
" THEN '1' ELSE '0' END"
" FROM ir_attachment a",
)
etat["attachment_row"] = {
ligne[0]: (ligne[1], ligne[2], ligne[3], ligne[4] == "1")
for ligne in portees or []
if len(ligne) >= 5 and ligne[0].isdigit()
}
# Les copies COW par leur CLÉ : c'est elle qu'on réinitialise, et
# c'est par elle qu'on les retrouve d'une version à l'autre.
copies = run_psql(
@ -710,6 +736,65 @@ def overlay_declared(
}
def render_attachment_kind(connu, colour):
"""Le détail par CAUSE d'une perte de pièces jointes.
Le rapport ne doit jamais dire « pièce jointe perdue » sans dire CE
QUI est parti avec elle : un champ déjà mort ne fait perdre rien
qu'un utilisateur ait pu voir, et cela se range en teinte calme. Le
seul cas rouge est « le champ est toujours là ».
"""
from script.todo.migration_status import paint
lignes = []
for cle, libelle, teinte in ATTACHMENT_KIND:
combien = (connu.get("buckets") or {}).get(cle) or 0
if not combien:
continue
lignes.append(
f" {paint(str(combien).rjust(5), teinte, colour)}"
f" {t(libelle)}"
)
return lignes
def classify_attachments(avant, apres):
"""Pourquoi chaque pièce jointe partie est partie. None si on ne sait.
Python pur, sur ce que `inspect` a déjà lu : aucune requête de plus.
L'ORDRE des tests compte — éprouvé dans l'autre sens, les 452 du
palier 18 se rangeaient à tort en « champ retiré à ce palier ».
"""
lignes_avant = avant.get("attachment_row")
lignes_apres = apres.get("attachment_row")
if not lignes_avant or lignes_apres is None:
return None
modeles_apres = set(apres.get("model") or [])
seaux = {cle: [] for cle, _l, _t in ATTACHMENT_KIND}
for identifiant, (modele, champ, _res, vivant) in lignes_avant.items():
if identifiant in lignes_apres:
continue
apres_vivant = None
for autre in lignes_apres.values():
if autre[0] == modele and autre[1] == champ:
apres_vivant = autre[3]
break
# L'ORDRE porte le sens. « Le champ était déjà mort » d'abord :
# c'est la raison la plus forte, et celle des 452 lignes du
# palier 18. « Le modèle a quitté la base » ensuite — plus forte
# que « le champ n'apparaît plus après », qui ne se déduit que
# d'une absence et serait vraie de toute façon.
if not vivant:
seaux["field_debt"].append(identifiant)
elif modele and modele not in modeles_apres:
seaux["model_gone"].append(identifiant)
elif apres_vivant is False or (champ and apres_vivant is None):
seaux["field_dropped"].append(identifiant)
else:
seaux["undeclared"].append(identifiant)
return seaux
def explain_loss(table, version):
"""Ce qu'Odoo a fait de cette table à cette version, ou None.
@ -761,6 +846,31 @@ def compare(avant, apres):
else None
)
lignes_perdues[index] = (table, debut, fin, {**connu, "gained": recue})
# `ir_attachment` n'a pas d'entrée dans SEMANTIC_MAP et n'en aura
# pas : la cause n'est pas la table, ce sont ces lignes-là. On la
# DÉDUIT, dans le même vocabulaire, et l'on garde en rouge le seul
# cas qui compte — une pièce jointe dont le champ est toujours là.
seaux = classify_attachments(avant, apres)
if seaux:
for index, (table, debut, fin, connu) in enumerate(lignes_perdues):
if table != "ir_attachment" or connu:
continue
lignes_perdues[index] = (
table,
debut,
fin,
{
"into": None,
"kind": "pruned",
"why": "attachments of fields and records already gone",
"gained": None,
"buckets": {
cle: len(seaux.get(cle) or [])
for cle, _l, _t in ATTACHMENT_KIND
},
"residue": len(seaux.get("undeclared") or []),
},
)
champ_avant = set(avant.get("field") or [])
champ_apres = set(apres.get("field") or [])
cow_avant = set(avant.get("cow") or [])
@ -891,6 +1001,20 @@ DECLARE_MODELES = (
# au palier 14 → 15, pour zéro octet.
SANS_DONNEE_PROPRE = ("id",)
# Pourquoi une pièce jointe a disparu — DÉDUIT, jamais déclaré.
#
# SEMANTIC_MAP ne peut pas porter ceci : elle nomme une TABLE, et la
# cause n'est pas la table, ce sont ces lignes-là. Mesuré sur une chaîne
# 12 → 18 : des 516 lignes parties au palier 18, 452 avaient perdu leur
# champ et 63 leur enregistrement. Une entrée « ir_attachment / pruned »
# aurait rangé les 516 sous « perte attendue » — et la 517e avec.
ATTACHMENT_KIND = (
("field_debt", "their field was already gone before this step", "dim"),
("field_dropped", "their field was removed at this step", "dim"),
("model_gone", "their model left the database", "dim"),
("undeclared", "the field is STILL there — look at these", "warn"),
)
DECLARE_CHAMPS = (
("del", "declared removed", "dim"),
("moved", "moved to another module", "dim"),
@ -1125,7 +1249,8 @@ def render_compare(diff, colour, limit=8):
else:
ou = t("retired from the database")
lignes.append(f" {table:<40} {avant:>8} → {apres} {ou}")
lignes.append(f" {connu['why']}")
lignes.append(f" {t(connu['why'])}")
lignes.extend(render_attachment_kind(connu, colour))
if len(connues) > limit:
lignes.append(f" … {len(connues) - limit} {t('more')}")
lignes.extend(render_declared(diff.get("declared") or {}, colour, limit))

View file

@ -6789,6 +6789,26 @@ TRANSLATIONS = {
"fr": "Aucun index en double.",
"en": "No duplicate index.",
},
"attachments of fields and records already gone": {
"fr": "pièces jointes de champs et d'enregistrements déjà disparus",
"en": "attachments of fields and records already gone",
},
"their field was already gone before this step": {
"fr": "leur champ porteur avait déjà disparu avant ce palier",
"en": "their field was already gone before this step",
},
"their field was removed at this step": {
"fr": "leur champ porteur a été retiré à ce palier",
"en": "their field was removed at this step",
},
"their model left the database": {
"fr": "leur modèle a quitté la base",
"en": "their model left the database",
},
"the field is STILL there — look at these": {
"fr": "le champ est TOUJOURS là — à regarder",
"en": "the field is STILL there — look at these",
},
"out of": {
"fr": "sur",
"en": "out of",

View file

@ -1492,5 +1492,100 @@ class TestFieldsThatHeldNoData(TestTheOpenUpgradeOverlay):
self.assertTrue(quality.t(libelle))
class TestWhyAnAttachmentWentAway(Base):
"""« 409 pièces jointes perdues » n'en recouvrait presque aucune.
Mesuré sur une chaîne 12 → 18 : des 516 lignes parties au palier 18,
452 avaient perdu leur CHAMP PORTEUR aux paliers 13 et 14 — elles
étaient déjà illisibles, Odoo lève un KeyError en les contrôlant. Ce
ne sont pas des données, ce sont des débris, et la 18 les ramasse.
On ne DÉCLARE pas cette perte dans SEMANTIC_MAP : cette carte nomme
une TABLE, et la cause n'est pas la table, ce sont ces lignes-là.
Déclarée, elle rangerait toute perte future de ir_attachment sous
« changement d'Odoo » — cinq mille factures comprises.
"""
def etat(self, lignes, modeles=("res.partner",)):
return {
"exists": True,
"attachment_row": lignes,
"model": list(modeles),
}
def test_a_field_already_gone_is_not_a_loss(self):
avant = self.etat({"7": ("res.partner", "image", "1", False)})
seaux = quality.classify_attachments(avant, self.etat({}))
self.assertEqual(["7"], seaux["field_debt"])
self.assertEqual([], seaux["undeclared"])
def test_a_field_removed_at_this_step_is_not_a_loss_either(self):
avant = self.etat({"7": ("res.partner", "image", "1", True)})
apres = self.etat({"8": ("res.partner", "image", "2", False)})
seaux = quality.classify_attachments(avant, apres)
self.assertEqual(["7"], seaux["field_dropped"])
def test_a_model_that_left_takes_its_attachments_with_it(self):
avant = self.etat({"7": ("mail.channel", "image", "1", True)})
apres = self.etat({}, modeles=["res.partner"])
seaux = quality.classify_attachments(avant, apres)
self.assertEqual(["7"], seaux["model_gone"])
def test_a_live_field_losing_its_attachment_stays_red(self):
# LE cas qui compte : une donnée lisible a disparu.
avant = self.etat({"7": ("res.partner", "image_1920", "1", True)})
apres = self.etat({"8": ("res.partner", "image_1920", "2", True)})
seaux = quality.classify_attachments(avant, apres)
self.assertEqual(["7"], seaux["undeclared"])
def test_an_attachment_that_stayed_is_not_counted(self):
lignes = {"7": ("res.partner", "image_1920", "1", True)}
seaux = quality.classify_attachments(
self.etat(lignes), self.etat(lignes)
)
self.assertEqual([], seaux["undeclared"])
self.assertEqual([], seaux["field_debt"])
def test_without_the_information_it_says_it_does_not_know(self):
# Un instantané pris par une version antérieure n'a pas
# `attachment_row`. Rendre des seaux vides ferait croire à une
# explication complète.
self.assertIsNone(
quality.classify_attachments({"exists": True}, self.etat({}))
)
def test_the_red_bucket_is_the_only_one_that_warns(self):
table = {cle: teinte for cle, _l, teinte in quality.ATTACHMENT_KIND}
self.assertEqual("warn", table["undeclared"])
for cle in ("field_debt", "field_dropped", "model_gone"):
self.assertEqual("dim", table[cle], cle)
def test_the_report_names_each_cause(self):
connu = {
"buckets": {"field_debt": 452, "undeclared": 1},
"why": "attachments of fields and records already gone",
}
texte = "\n".join(quality.render_attachment_kind(connu, colour=False))
self.assertIn("452", texte)
self.assertIn("1", texte)
self.assertIn(
quality.t("their field was already gone before this step"), texte
)
def test_a_bucket_at_zero_is_not_printed(self):
connu = {"buckets": {"field_debt": 0, "undeclared": 3}}
texte = "\n".join(quality.render_attachment_kind(connu, colour=False))
self.assertNotIn(
quality.t("their field was already gone before this step"), texte
)
def test_semantic_map_still_says_nothing_about_attachments(self):
# Une entrée déclarée rendrait l'outil aveugle : `explain_loss`
# ne compare que le nom de la table, et `pruned` ne déclenche
# aucune contre-vérification.
for entree in quality.SEMANTIC_MAP:
self.assertNotEqual("ir_attachment", entree.get("table"))
if __name__ == "__main__":
unittest.main()