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
249 lines
8.5 KiB
Python
Executable file
249 lines
8.5 KiB
Python
Executable file
#!/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())
|