Odoo refused to load a database: it validated a <search> arch with tree rules. The view is not a COW copy -- the COW tools were right to say they had nothing to reset -- its stored `type` simply lies. Nothing would ever have fixed it. In ir_ui_view.py, `type` is filled in `create`, and only when absent; `write` never recomputes it. A wrong value stays wrong, and `-u module` fails on the validation it causes before it could rewrite anything. Plain SQL, no ORM: the registry is what will not load, and a repair that needed Odoo to fix what stops Odoo would be useless. The rule is Odoo's own -- an inherited view takes its parent's type -- and it holds: zero disagreement on a pristine 18 install and on four migrated databases, exactly one on the database that refused to load. --- FR --- Odoo refusait de charger une base : il validait un arch <search> avec les règles d'un tree. La vue n'est pas une copie COW — les outils COW avaient raison de dire qu'ils n'avaient rien à réinitialiser — c'est son `type` stocké qui ment. Rien ne l'aurait jamais réparé. Dans ir_ui_view.py, `type` est rempli dans `create`, et seulement s'il est absent ; `write` ne le recalcule jamais. Une valeur fausse le reste, et `-u module` échoue sur la validation qu'elle provoque avant de pouvoir réécrire quoi que ce soit. En SQL, sans ORM : c'est le registre qui ne charge plus, et une réparation qui aurait besoin d'Odoo ne servirait à rien. La règle est celle d'Odoo — une vue héritée prend le type de son parent — et elle tient : zéro écart sur une 18 neuve et sur quatre bases migrées, exactement un sur celle qui refusait de charger. Assisted-by: Claude Opus 5
173 lines
6.5 KiB
Python
Executable file
173 lines
6.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)
|
|
|
|
"""Choisir une copie à réinitialiser dans une LISTE, pas de mémoire.
|
|
|
|
L'aide affichée après une erreur disait « --reset <key> --apply » et laissait
|
|
retrouver la clé dans un diff de mille lignes. On la recopie, on se trompe
|
|
d'un caractère, et la commande ne fait RIEN sans le dire — une clé qui ne
|
|
correspond à aucune copie n'est pas une erreur pour l'outil.
|
|
|
|
D'où l'option [3] : elle demande les clés à l'outil, les numérote, et offre
|
|
« toutes ». Ces tests portent sur ce que chaque réponse lance.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
|
from script.todo import todo_i18n # noqa: E402
|
|
from script.todo.todo_upgrade import TodoUpgrade # noqa: E402
|
|
|
|
|
|
class PromptCase(unittest.TestCase):
|
|
def setUp(self):
|
|
self.addCleanup(
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
)
|
|
todo_i18n._current_lang = "en"
|
|
|
|
def run_prompt(self, answer, lst_key=None):
|
|
"""(commandes lancées, texte affiché) après cette réponse."""
|
|
import contextlib
|
|
import io
|
|
|
|
if lst_key is None:
|
|
lst_key = ["website_sale.product", "web.layout", "website_blog.x"]
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
upgrade.dct_progression = {}
|
|
upgrade.lst_command_executed = []
|
|
upgrade.write_config = lambda: None
|
|
upgrade.stale_cow_keys = lambda db: lst_key
|
|
lst_cmd = []
|
|
upgrade.run_on_terminal = lambda cmd: lst_cmd.append(cmd) or 0
|
|
# Le doublon HONORE le défaut, comme le vrai `ask_gate` : sinon
|
|
# les tests de défaut ne testeraient que le doublon.
|
|
upgrade.ask_gate = lambda prompt, default="": answer or default
|
|
out = io.StringIO()
|
|
with contextlib.redirect_stdout(out):
|
|
upgrade.prompt_reset_stale_cow_views("db")
|
|
return lst_cmd, out.getvalue()
|
|
|
|
|
|
class TestTheMenu(PromptCase):
|
|
def test_every_key_is_numbered(self):
|
|
# C'est tout l'objet : on ne connaît pas les clés de tête.
|
|
_cmd, text = self.run_prompt("")
|
|
self.assertIn("[1] website_sale.product", text)
|
|
self.assertIn("[2] web.layout", text)
|
|
self.assertIn("[3] website_blog.x", text)
|
|
|
|
def test_all_is_offered(self):
|
|
_cmd, text = self.run_prompt("")
|
|
self.assertIn("[a]", text)
|
|
|
|
def test_nothing_drifted_asks_nothing(self):
|
|
lst_cmd, text = self.run_prompt("a", lst_key=[])
|
|
self.assertEqual(lst_cmd, [])
|
|
self.assertIn("No COW copy", text)
|
|
|
|
|
|
class TestWhatEachAnswerRuns(PromptCase):
|
|
def test_a_number_resets_that_key(self):
|
|
lst_cmd, _ = self.run_prompt("2")
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
self.assertIn("--reset web.layout", lst_cmd[0])
|
|
self.assertIn("--apply", lst_cmd[0])
|
|
self.assertNotIn("website_sale", lst_cmd[0])
|
|
|
|
def test_several_numbers_reset_several_keys(self):
|
|
lst_cmd, _ = self.run_prompt("1,3")
|
|
self.assertIn("--reset website_sale.product", lst_cmd[0])
|
|
self.assertIn("--reset website_blog.x", lst_cmd[0])
|
|
self.assertNotIn("web.layout", lst_cmd[0])
|
|
|
|
def test_a_resets_them_all(self):
|
|
lst_cmd, _ = self.run_prompt("a")
|
|
self.assertIn("--reset all", lst_cmd[0])
|
|
|
|
def test_enter_resets_them_all(self):
|
|
# Entrée les prend TOUTES : elles ne sont dans cette liste que
|
|
# parce qu'un enfant n'y trouve plus son ancrage, et l'outil sauve
|
|
# ce que chaque copie portait avant de la réinitialiser.
|
|
lst_cmd, _text = self.run_prompt("")
|
|
self.assertEqual(len(lst_cmd), 1)
|
|
self.assertIn("--reset all", lst_cmd[0])
|
|
|
|
def test_n_resets_nothing(self):
|
|
# Le défaut ne retire pas le choix : il ne fait qu'en proposer un.
|
|
lst_cmd, text = self.run_prompt("n")
|
|
self.assertEqual(lst_cmd, [])
|
|
self.assertIn("Kept", text)
|
|
|
|
def test_an_out_of_range_number_resets_nothing(self):
|
|
# Le piège serait de tomber en silence sur « all » ou sur la
|
|
# première clé : on ne devine pas ce qui n'a pas été demandé.
|
|
lst_cmd, text = self.run_prompt("9")
|
|
self.assertEqual(lst_cmd, [])
|
|
self.assertIn("Unknown choice", text)
|
|
|
|
def test_garbage_resets_nothing(self):
|
|
lst_cmd, text = self.run_prompt("zzz")
|
|
self.assertEqual(lst_cmd, [])
|
|
self.assertIn("Unknown choice", text)
|
|
|
|
|
|
class TestTheKeysComeFromTheTool(unittest.TestCase):
|
|
"""La liste est demandée à l'outil, pas devinée."""
|
|
|
|
def test_list_keys_prints_only_keys(self):
|
|
script = os.path.join(
|
|
REPO, "script", "odoo", "migration", "reset_stale_cow_views.py"
|
|
)
|
|
with open(script) as handle:
|
|
source = handle.read()
|
|
self.assertIn("--list-keys", source)
|
|
self.assertIn('print(cow_view["key"])', source)
|
|
|
|
def test_a_dead_database_yields_no_key(self):
|
|
# Sans ce garde-fou, un menu vide se lirait comme « rien n'a dérivé ».
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
upgrade.dct_progression = {}
|
|
upgrade.lst_command_executed = []
|
|
upgrade.write_config = lambda: None
|
|
upgrade.todo_upgrade_execute = lambda cmd, **kw: (2, cmd, [])
|
|
self.assertEqual(upgrade.stale_cow_keys("nope"), [])
|
|
|
|
def test_the_error_prompt_offers_it(self):
|
|
import inspect
|
|
|
|
# Le menu d'erreur vit dans `_prompt_on_error`, extrait de
|
|
# `todo_upgrade_execute` quand celui-ci a passé le seuil de
|
|
# complexité. Lire les deux : c'est le CHEMIN d'erreur qu'on
|
|
# éprouve, pas une méthode en particulier.
|
|
source = inspect.getsource(
|
|
TodoUpgrade.todo_upgrade_execute
|
|
) + inspect.getsource(TodoUpgrade._prompt_on_error)
|
|
self.assertIn("prompt_reset_stale_cow_views", source)
|
|
self.assertIn("Reset one of them onto its module view", source)
|
|
|
|
|
|
class TestTheToolAcceptsWhatWeSend(unittest.TestCase):
|
|
def test_reset_can_be_repeated_and_accepts_all(self):
|
|
done = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"./script/odoo/migration/reset_stale_cow_views.py",
|
|
"--help",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=REPO,
|
|
)
|
|
self.assertEqual(done.returncode, 0)
|
|
self.assertIn("--list-keys", done.stdout)
|
|
self.assertIn("--reset", done.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|