[ADD] migration: restore DMS visibility lost with MuK's access model
Nothing was lost at the 13 bump: 69 files, 16 folders and 23 MB of content_binary are identical from 12 to 18. What changed is the security model. MuK filtered on company alone; OCA DMS adds a GLOBAL rule on permission_read, granted only through a dms.access.group or through storages saved as attachment. The migration created no group — MuK had none to convert — and the storages are database. Both doors shut, every user sees zero. Reports without writing unless --apply. The visibility count uses a real user: the global rule spares the superuser, so a sudo count would call a mute database healthy. --- FR --- Rien n'a été perdu au palier 13 : 69 fichiers, 16 dossiers et 23 Mo de content_binary sont identiques de la 12 à la 18. C'est le modèle de sécurité qui a changé. MuK ne filtrait que sur la société ; OCA DMS ajoute une règle GLOBALE sur permission_read, accordée par une dms.access.group ou par un stockage en attachment. La migration n'a créé aucun groupe — MuK n'en avait aucun à convertir — et les stockages sont en database. Les deux portes fermées, personne ne voit rien. Rapport sans écriture sauf --apply. Le comptage de visibilité passe par un vrai utilisateur : la règle globale épargne le super-utilisateur, donc un comptage en sudo déclarerait saine une base muette. Assisted-by: Claude Opus 5
This commit is contained in:
parent
d2f70f6535
commit
a9d9a08574
3 changed files with 542 additions and 0 deletions
228
script/odoo/migration/dms_access_repair.py
Executable file
228
script/odoo/migration/dms_access_repair.py
Executable file
|
|
@ -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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
178
test/test_dms_access_repair.py
Normal file
178
test/test_dms_access_repair.py
Normal file
|
|
@ -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()
|
||||
Loading…
Reference in a new issue