erplibre/script/odoo/migration/dms_access_repair.py
Mathieu Benoit 8c7ff096b3 [ADD] migration: repair DMS at the 13 bump, and catch the whole class
The repair now runs by itself where the breakage happens: MuK becomes
OCA DMS at 13 and nowhere else, so the hook is gated on that bump. It
stays quiet on databases without DMS and on ones already repaired, and
a successful --apply now exits 0 instead of 1.

The wider hole stays open otherwise: data PRESENT that a global rule
hides entirely. Row counts said everything was there — true. The smoke
test served every page — true. Nobody could reach the records. The new
check runs at every bump and names any model with rows that no active
internal user can see a single one of. Proven both ways on a copy: 85
models clean after the repair, dms.file and dms.directory named before
it.

--- FR ---

La réparation s'exécute désormais là où la casse a lieu : MuK devient
OCA DMS au palier 13 et nulle part ailleurs, d'où le garde. Elle se tait
sur une base sans DMS et sur une base déjà réparée, et un --apply réussi
rend 0 au lieu de 1.

Le trou plus large restait ouvert : des données PRÉSENTES qu'une règle
globale masque entièrement. Les comptages disaient « tout est là » —
vrai. Le test de fumée servait les pages — vrai. Personne n'atteignait
les données. Le nouveau test tourne à chaque palier et nomme tout modèle
dont aucun utilisateur interne actif ne voit une seule ligne. Éprouvé
dans les deux sens sur une copie : 85 modèles sains après réparation,
dms.file et dms.directory nommés avant.

Assisted-by: Claude Opus 5
2026-08-22 07:24:00 -04:00

249 lines
8.5 KiB
Python
Executable file
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Rendre visibles les documents DMS qu'une migration a rendus muets.
Le symptôme : après le palier 13, les documents DMS « ont disparu ». Ils
n'ont pas disparu. Mesuré sur une base migrée : 69 fichiers, 16 dossiers,
23 Mo de `content_binary`, présents à l'identique de la 12 à la 18. Rien
n'a été perdu.
Ce qui a changé, c'est le MODÈLE DE SÉCURITÉ. En 12, MuK DMS ne filtrait
que sur la société :
['|', ('company','=',False), ('company','child_of',[...])]
OCA DMS, à partir de la 13, ajoute des règles GLOBALES sur une permission
calculée :
[('permission_read', '=', user.id)]
`_search_permission_read` n'accorde l'accès que par deux portes :
1. Une `dms.access.group` dont l'utilisateur est membre, reliée au
dossier — la migration n'en a créé AUCUNE, parce que MuK n'en avait
aucune à convertir.
2. L'héritage depuis l'enregistrement lié, qui exige
`storage.save_type = 'attachment'` — or les stockages migrés sont en
`database`.
Les deux portes fermées, la règle globale filtre TOUT. Pas un dossier,
pas un fichier, pour personne — l'administrateur compris, car `admin`
n'est pas le super-utilisateur au sens d'Odoo.
Ce que fait la réparation
-------------------------
Une seule `dms.access.group`, adossée au groupe Odoo `dms.group_dms_user`
plutôt qu'à une liste d'utilisateurs — ainsi l'appartenance continue de
suivre le groupe, comme avant la migration —, reliée aux dossiers
RACINES. Les enfants héritent : ils portent tous
`inherit_group_ids = true`.
Ce qu'elle ne fait pas : inventer une granularité que MuK n'avait pas.
L'ancien modèle ne connaissait pas d'ACL par dossier ; en fabriquer une
ici serait décider à la place de l'utilisateur, sur des données qu'il
n'a jamais saisies.
Codes de sortie : 0 rien à faire, 1 il y a à faire (ou c'est fait), 2 échec.
"""
import argparse
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
)
from script.odoo.migration import database_cleanup # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
# Les sentinelles de `run_shell`, pas les nôtres : il les impose, et
# elles ne servent qu'à isoler le rapport des journaux d'Odoo. En
# inventer une seconde paire aurait créé un contrat parallèle à tenir.
DEBUT = database_cleanup.START
FIN = database_cleanup.END
NOM_GROUPE = "Migration MuK → DMS"
# Le script poussé dans `odoo-bin shell`. Il compte AVANT et APRÈS avec un
# vrai utilisateur : c'est la seule preuve qui vaille — la règle globale
# ne s'applique pas au super-utilisateur, donc un compte fait en sudo
# dirait « tout va bien » alors que personne ne voit rien.
SCRIPT = """
import json
DRY = {dry}
NOM = {nom!r}
rapport = {{"dry_run": DRY}}
try:
# Sans DMS, il n'y a rien à réparer et ce n'est pas une erreur : cet
# outil tourne à chaque migration vers la 13, et la plupart des bases
# n'ont jamais eu de DMS. Lever ici les ferait toutes échouer.
if "dms.file" not in env:
rapport["absent"] = True
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
raise SystemExit(0)
Dossier = env["dms.directory"].sudo()
Fichier = env["dms.file"].sudo()
Groupe = env["dms.access.group"].sudo()
rapport["files"] = Fichier.search_count([])
rapport["directories"] = Dossier.search_count([])
rapport["access_groups_before"] = Groupe.search_count([])
# Un vrai utilisateur, pas le super-utilisateur : la règle globale ne
# s'applique qu'à lui, et c'est justement ce qu'on mesure.
membres = env.ref("dms.group_dms_user").users.filtered(
lambda u: u.id != 1 and u.active
)
temoin = membres[:1]
rapport["witness"] = temoin.login if temoin else None
def visible():
if not temoin:
return None
return {{
"directories": Dossier.with_user(temoin).search_count([]),
"files": Fichier.with_user(temoin).search_count([]),
}}
rapport["before"] = visible()
racines = Dossier.search([("is_root_directory", "=", True)])
rapport["roots"] = racines.mapped("name")
deja = Groupe.search([("name", "=", NOM)])
rapport["already_repaired"] = bool(deja)
if not DRY and not deja and racines:
Groupe.create({{
"name": NOM,
"perm_create": True,
"perm_write": True,
"perm_unlink": True,
"group_ids": [(6, 0, [env.ref("dms.group_dms_user").id])],
"directory_ids": [(6, 0, racines.ids)],
}})
env.cr.commit()
env.registry.clear_cache()
rapport["created"] = True
rapport["after"] = visible()
rapport["access_groups_after"] = Groupe.search_count([])
except Exception as exc:
rapport["error"] = "%s: %s" % (type(exc).__name__, exc)
print({debut!r})
print(json.dumps(rapport))
print({fin!r})
"""
def build_script(dry_run):
return SCRIPT.format(
dry="True" if dry_run else "False",
nom=NOM_GROUPE,
debut=DEBUT,
fin=FIN,
)
def render(rapport, dry_run):
if rapport.get("absent"):
return [f"ℹ️ {t('No DMS in this database, nothing to repair.')}"]
lignes = [
f"📁 {t('DMS documents in the database')} :"
f" {rapport.get('files', '?')} {t('file(s)')},"
f" {rapport.get('directories', '?')} {t('folder(s)')}"
]
avant = rapport.get("before")
temoin = rapport.get("witness")
if avant is None:
lignes.append(f" ⚠ {t('No DMS user to test visibility with.')}")
else:
lignes.append(
f" {t('Visible to')} {temoin} :"
f" {avant['directories']} {t('folder(s)')},"
f" {avant['files']} {t('file(s)')}"
)
lignes.append(
f" {t('DMS access groups:')} {rapport.get('access_groups_before', '?')}"
)
if rapport.get("already_repaired"):
lignes.append(f" ℹ️ {t('Already repaired: the group exists.')}")
return lignes
if dry_run:
lignes.append(
f" → {t('Would create one access group over')}"
f" {len(rapport.get('roots') or [])} {t('root folder(s)')} :"
f" {', '.join(rapport.get('roots') or [])}"
)
lignes.append(f" {t('Nothing written. Re-run with --apply.')}")
return lignes
apres = rapport.get("after")
if apres:
lignes.append(
f" ✅ {t('Now visible to')} {temoin} :"
f" {apres['directories']} {t('folder(s)')},"
f" {apres['files']} {t('file(s)')}"
)
return lignes
def main(argv=None):
parser = argparse.ArgumentParser(
description=(
"Restore visibility of DMS documents after a MuK DMS to OCA DMS"
" migration. Reports without writing unless --apply is given."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", default="config.conf")
parser.add_argument(
"--apply",
action="store_true",
help="actually create the access group (default: report only)",
)
config = parser.parse_args(argv)
# Le même garde-fou que partout ailleurs : un Odoo d'une autre version
# ÉCRIT dans la base avant d'échouer.
souci = database_cleanup.require_matching_version(config.database)
if souci:
print(f"❌ {souci}")
return 2
try:
rapport = database_cleanup.run_shell(
config.database,
config.config,
build_script(not config.apply),
echo=lambda texte: print(f"⧖ {texte}", flush=True),
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
if rapport.get("error"):
print(f"❌ {rapport['error']}")
return 2
print("\n".join(render(rapport, not config.apply)))
if rapport.get("absent"):
return 0
avant = rapport.get("before") or {}
if config.apply:
# Une réparation RÉUSSIE doit rendre 0. Rendre « il y a des
# trouvailles » après avoir tout remis en place ferait échouer
# n'importe quelle chaîne make qui appelle l'outil.
apres = rapport.get("after") or {}
if rapport.get("already_repaired"):
return 0
return 0 if apres.get("files") else 2
return 1 if not avant.get("files") else 0
if __name__ == "__main__":
sys.exit(main())