erplibre/script/odoo/migration/dms_access_repair.py

250 lines
8.5 KiB
Python
Raw Normal View History

#!/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:
[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-21 05:25:23 -04:00
# 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):
[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-21 05:25:23 -04:00
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)))
[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-21 05:25:23 -04:00
if rapport.get("absent"):
return 0
avant = rapport.get("before") or {}
[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-21 05:25:23 -04:00
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())