diff --git a/script/odoo/migration/dms_access_repair.py b/script/odoo/migration/dms_access_repair.py new file mode 100755 index 0000000..8982198 --- /dev/null +++ b/script/odoo/migration/dms_access_repair.py @@ -0,0 +1,228 @@ +#!/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: + 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): + 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))) + avant = rapport.get("before") or {} + return 1 if not avant.get("files") else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 2ac36e4..44f4597 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5791,6 +5791,142 @@ TRANSLATIONS = { "fr": "table(s) expliquées par un changement d'Odoo", "en": "table(s) explained by an Odoo change", }, + "Cannot read the database: ": { + "fr": "Lecture impossible de la base : ", + "en": "Cannot read the database: ", + }, + "Cannot read the package file: ": { + "fr": "Lecture impossible du fichier de packages : ", + "en": "Cannot read the package file: ", + }, + "Modules missing from the default package": { + "fr": "📦 Modules absents du package par défaut", + "en": "📦 Modules missing from the default package", + }, + "Show every entry": { + "fr": "Tout afficher", + "en": "Show every entry", + }, + "List the known packages": { + "fr": "Lister les packages connus", + "en": "List the known packages", + }, + "Already repaired: the group exists.": { + "fr": "Déjà réparé : le groupe existe.", + "en": "Already repaired: the group exists.", + }, + "DMS access groups:": { + "fr": "Groupes d'accès DMS :", + "en": "DMS access groups:", + }, + "DMS documents in the database": { + "fr": "Documents DMS dans la base", + "en": "DMS documents in the database", + }, + "No DMS user to test visibility with.": { + "fr": "Aucun utilisateur DMS pour éprouver la visibilité.", + "en": "No DMS user to test visibility with.", + }, + "Nothing written. Re-run with --apply.": { + "fr": "Rien d'écrit. Relancer avec --apply.", + "en": "Nothing written. Re-run with --apply.", + }, + "Now visible to": { + "fr": "Désormais visibles pour", + "en": "Now visible to", + }, + "Visible to": { + "fr": "Visibles pour", + "en": "Visible to", + }, + "Would create one access group over": { + "fr": "Créerait un groupe d'accès sur", + "en": "Would create one access group over", + }, + "file(s)": { + "fr": "fichier(s)", + "en": "file(s)", + }, + "folder(s)": { + "fr": "dossier(s)", + "en": "folder(s)", + }, + "root folder(s)": { + "fr": "dossier(s) racine", + "en": "root folder(s)", + }, + "Census": { + "fr": "Recensement", + "en": "Census", + }, + "Compared against nothing — census only.": { + "fr": "Comparé à rien — recensement seul.", + "en": "Compared against nothing — census only.", + }, + "Default package:": { + "fr": "Package par défaut :", + "en": "Default package:", + }, + "Every suggested module is installed.": { + "fr": "Tous les modules suggérés sont installés.", + "en": "Every suggested module is installed.", + }, + "Installed modules by author": { + "fr": "Modules installés, par auteur", + "en": "Installed modules by author", + }, + "Modules of": { + "fr": "Modules de", + "en": "Modules of", + }, + "No default package known for this version": { + "fr": "Aucun package par défaut connu pour cette version", + "en": "No default package known for this version", + }, + "Odoo": { + "fr": "Odoo", + "en": "Odoo", + }, + "absent from the addons path — sync the repo first": { + "fr": "hors du chemin des addons — synchroniser le dépôt d'abord", + "en": "absent from the addons path — sync the repo first", + }, + "also needs": { + "fr": "réclame aussi", + "en": "also needs", + }, + "half-way — finish the pending update": { + "fr": "à mi-chemin — finir la mise à jour en attente", + "en": "half-way — finish the pending update", + }, + "installed": { + "fr": "déjà en place", + "en": "installed", + }, + "known": { + "fr": "connus", + "en": "known", + }, + "known to the database — install it": { + "fr": "dans la base — à installer", + "en": "known to the database — install it", + }, + "present but broken — fix the dependency": { + "fr": "en panne de dépendance — à réparer", + "en": "present but broken — fix the dependency", + }, + "suggested module(s)": { + "fr": "module(s) suggéré(s)", + "en": "suggested module(s)", + }, + "suggested module(s) not installed": { + "fr": "module(s) suggéré(s) non installé(s)", + "en": "suggested module(s) not installed", + }, + "unknown author": { + "fr": "auteur inconnu", + "en": "unknown author", + }, "rows dropped, the table remains": { "fr": "lignes supprimées, la table demeure", "en": "rows dropped, the table remains", diff --git a/test/test_dms_access_repair.py b/test/test_dms_access_repair.py new file mode 100644 index 0000000..bfa9f96 --- /dev/null +++ b/test/test_dms_access_repair.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Ce qui doit tenir avant de toucher à une base de production. + +Deux propriétés portent tout le reste. La première : sans `--apply`, +RIEN ne s'écrit — un outil de diagnostic qui répare tout seul est un +piège. La seconde : le compte de visibilité doit être fait avec un vrai +utilisateur, jamais en sudo, car la règle globale d'OCA DMS ne s'applique +pas au super-utilisateur et un compte en sudo dirait « tout va bien » +alors que personne ne voit rien. +""" + +import ast +import io +import os +import sys +import unittest +from contextlib import redirect_stdout + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..")) +) + +from script.odoo.migration import dms_access_repair as repair # noqa: E402 +from script.todo import todo_i18n # noqa: E402 + + +class TestTheGeneratedScript(unittest.TestCase): + def test_the_dry_run_never_creates_anything(self): + # Le garde vit dans le script POUSSÉ : c'est lui qui décide, pas + # l'appelant. Un `DRY = True` qui n'entoure pas le `create` ne + # protège de rien. + code = repair.build_script(dry_run=True) + self.assertIn("DRY = True", code) + arbre = ast.parse(code) + creations = [ + n + for n in ast.walk(arbre) + if isinstance(n, ast.Call) + and isinstance(n.func, ast.Attribute) + and n.func.attr == "create" + ] + self.assertTrue(creations, "aucune création dans le script ?") + for noeud in creations: + self.assertTrue( + self._under_dry_guard(arbre, noeud), + "une création hors du garde `not DRY`", + ) + + @staticmethod + def _under_dry_guard(arbre, cible): + """La création est-elle sous un `if not DRY ...` ?""" + for noeud in ast.walk(arbre): + if not isinstance(noeud, ast.If): + continue + texte = ast.dump(noeud.test) + if "DRY" not in texte: + continue + for enfant in ast.walk(noeud): + if enfant is cible: + return True + return False + + def test_apply_lifts_the_guard(self): + self.assertIn("DRY = False", repair.build_script(dry_run=False)) + + def test_the_script_is_valid_python(self): + for dry in (True, False): + ast.parse(repair.build_script(dry_run=dry)) + + def test_visibility_is_measured_with_a_real_user(self): + # `with_user` est le point tout entier : en sudo, la règle globale + # ne s'applique pas et le rapport serait rassurant à tort. + code = repair.build_script(dry_run=True) + self.assertIn("with_user(temoin)", code) + self.assertIn("u.id != 1", code) + + def test_it_reuses_the_shared_sentinels(self): + from script.odoo.migration import database_cleanup + + self.assertEqual(repair.DEBUT, database_cleanup.START) + self.assertEqual(repair.FIN, database_cleanup.END) + + +class TestTheReport(unittest.TestCase): + PLEIN = { + "files": 69, + "directories": 16, + "access_groups_before": 0, + "witness": "marie@example.org", + "before": {"directories": 0, "files": 0}, + "roots": ["CNESST", "Contrat"], + "already_repaired": False, + } + + def test_it_shows_what_exists_and_what_is_seen(self): + texte = "\n".join(repair.render(self.PLEIN, dry_run=True)) + self.assertIn("69", texte) + self.assertIn("marie@example.org", texte) + + def test_a_dry_run_says_nothing_was_written(self): + texte = "\n".join(repair.render(self.PLEIN, dry_run=True)) + self.assertIn( + todo_i18n.t("Nothing written. Re-run with --apply."), texte + ) + + def test_an_applied_run_reports_the_new_visibility(self): + rapport = dict(self.PLEIN, after={"directories": 16, "files": 69}) + texte = "\n".join(repair.render(rapport, dry_run=False)) + self.assertIn(todo_i18n.t("Now visible to"), texte) + + def test_an_already_repaired_database_says_so_and_stops(self): + rapport = dict(self.PLEIN, already_repaired=True) + texte = "\n".join(repair.render(rapport, dry_run=True)) + self.assertIn( + todo_i18n.t("Already repaired: the group exists."), texte + ) + self.assertNotIn( + todo_i18n.t("Would create one access group over"), texte + ) + + def test_no_witness_is_flagged_not_silently_fine(self): + # Sans utilisateur témoin on ne SAIT pas : le taire ferait passer + # une base muette pour une base saine. + rapport = dict(self.PLEIN, before=None, witness=None) + texte = "\n".join(repair.render(rapport, dry_run=True)) + self.assertIn( + todo_i18n.t("No DMS user to test visibility with."), texte + ) + + def test_every_translation_key_exists(self): + with io.open( + repair.__file__.replace(".pyc", ".py"), encoding="utf-8" + ) as handle: + src = handle.read() + for node in ast.walk(ast.parse(src)): + if ( + isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == "t" + and node.args + and isinstance(node.args[0], ast.Constant) + ): + self.assertIn(node.args[0].value, todo_i18n.TRANSLATIONS) + + +class TestTheVersionGuard(unittest.TestCase): + def setUp(self): + from script.odoo.migration import database_cleanup + + self.cleanup = database_cleanup + self.vraie = database_cleanup.require_matching_version + self.vrai_shell = database_cleanup.run_shell + self.appels = [] + database_cleanup.run_shell = ( + lambda *a, **k: self.appels.append(a) or {} + ) + + def tearDown(self): + self.cleanup.require_matching_version = self.vraie + self.cleanup.run_shell = self.vrai_shell + + def test_a_mismatch_stops_before_opening_the_database(self): + # Un Odoo d'une autre version ÉCRIT avant d'échouer : le refus + # doit précéder toute ouverture, pas la suivre. + self.cleanup.require_matching_version = lambda base: "18.0 vs 12.0" + tampon = io.StringIO() + with redirect_stdout(tampon): + code = repair.main(["-d", "vieille_base"]) + self.assertEqual(code, 2) + self.assertEqual(self.appels, []) + self.assertIn("18.0 vs 12.0", tampon.getvalue()) + + +if __name__ == "__main__": + unittest.main()