diff --git a/script/odoo/migration/reset_stale_cow_views.py b/script/odoo/migration/reset_stale_cow_views.py index 8c7935e..01021ed 100755 --- a/script/odoo/migration/reset_stale_cow_views.py +++ b/script/odoo/migration/reset_stale_cow_views.py @@ -283,6 +283,11 @@ def main(): ) ) parser.add_argument("-d", "--database", required=True) + parser.add_argument( + "--list-keys", + action="store_true", + help="print one drifted key per line, and nothing else", + ) parser.add_argument( "--reset", metavar="KEY", @@ -304,6 +309,14 @@ def main(): config = parser.parse_args() findings = analyse(config.database) + # Une sortie sans décor, pour que l'appelant construise un menu : on ne + # connaît pas les clés de tête, et les recopier depuis un diff de mille + # lignes est le genre de recopie où l'on se trompe. + if config.list_keys: + for cow_view, _module_view, _broken in findings: + print(cow_view["key"]) + return 1 if findings else 0 + if not findings: print(f"✅ {t('No COW copy has drifted from its module view.')}") return 0 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index cf8e6d4..8d71d69 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4926,6 +4926,30 @@ TRANSLATIONS = { "fr": "et ne change rien.", "en": "and changes nothing.", }, + "Reset one of them onto its module view": { + "fr": "Réinitialiser l'une d'elles sur sa vue module", + "en": "Reset one of them onto its module view", + }, + "Drifted COW copies": { + "fr": "Copies COW ayant dérivé", + "en": "Drifted COW copies", + }, + "Which one(s) to reset onto the module view?": { + "fr": "Laquelle ou lesquelles réinitialiser sur la vue module ?", + "en": "Which one(s) to reset onto the module view?", + }, + "numbers separated by commas, a = all, empty =": { + "fr": "numéros séparés par des virgules, a = toutes, vide =", + "en": "numbers separated by commas, a = all, empty =", + }, + "Kept. Nothing was reset.": { + "fr": "Conservées. Rien n'a été réinitialisé.", + "en": "Kept. Nothing was reset.", + }, + "Unknown choice, nothing was reset.": { + "fr": "Choix inconnu, rien n'a été réinitialisé.", + "en": "Unknown choice, nothing was reset.", + }, "Nothing to decide yet": { "fr": "Rien à décider pour l'instant", "en": "Nothing to decide yet", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index b3b7294..d170eeb 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2792,6 +2792,69 @@ class TodoUpgrade: wait_at_error=False, ) + def stale_cow_keys(self, database_name): + """Les clés des copies COW en retard, sans décor.""" + script_path = os.path.join( + PATH_MIGRATION_GLOBAL, "reset_stale_cow_views.py" + ) + if not os.path.exists(script_path): + return [] + status, _cmd, output = self.todo_upgrade_execute( + f"{PYTHON_BIN} ./{script_path} -d {database_name} --list-keys", + get_output=True, + wait_at_error=False, + quiet=True, + ) + if status == 2: + return [] + return [line.strip() for line in (output or []) if line.strip()] + + def prompt_reset_stale_cow_views(self, database_name): + """Choisir quoi réinitialiser dans une LISTE, pas de mémoire. + + L'aide disait « --reset --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 à rien n'est pas une erreur pour l'outil. + """ + lst_key = self.stale_cow_keys(database_name) + if not lst_key: + print( + f"✅ -> {t('No COW copy has drifted from its module view.')}" + ) + return + print(f"\n✨ {t('Drifted COW copies')} :") + for index, key in enumerate(lst_key, start=1): + print(f" [{index}] {key}") + print(f" [a] {t('All of the list above')}") + answer = ( + self.ask_gate( + f"💬 {t('Which one(s) to reset onto the module view?')}" + f" ({t('numbers separated by commas, a = all, empty =')}" + f" {t('nothing')}) : " + ) + .strip() + .lower() + ) + if not answer: + print(f"ℹ -> {t('Kept. Nothing was reset.')}") + return + if answer == "a": + lst_chosen = ["all"] + else: + lst_chosen = [] + for part in answer.replace(" ", "").split(","): + if part.isdigit() and 1 <= int(part) <= len(lst_key): + lst_chosen.append(lst_key[int(part) - 1]) + if not lst_chosen: + print(f"⚠️ {t('Unknown choice, nothing was reset.')}") + return + args = " ".join(f"--reset {key}" for key in lst_chosen) + self.run_on_terminal( + f"{PYTHON_BIN} ./{os.path.join(PATH_MIGRATION_GLOBAL, 'reset_stale_cow_views.py')}" + f" -d {database_name} {args} --apply" + ) + def show_cow_drift(self, database_name, next_version, mode="diff"): """Montre les copies COW à risque. Ne touche à rien. @@ -3253,6 +3316,7 @@ class TodoUpgrade: f"[2] {t('Check the COW views that drifted')}" f" ({database_name})" ) + print(f"[3] {t('Reset one of them onto its module view')}") wait_status = ( input( f"💬 {t('Error detected, press enter to continue or')}" @@ -3272,6 +3336,9 @@ class TodoUpgrade: # place, puis on repose le choix pour rejouer. self.check_stale_cow_views(database_name) continue + if wait_status == "3" and database_name: + self.prompt_reset_stale_cow_views(database_name) + continue break if wait_status == "1": diff --git a/test/test_reset_stale_cow_prompt.py b/test/test_reset_stale_cow_prompt.py new file mode 100755 index 0000000..35550e1 --- /dev/null +++ b/test/test_reset_stale_cow_prompt.py @@ -0,0 +1,158 @@ +#!/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 --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 + upgrade.ask_gate = lambda prompt: answer + 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_empty_resets_nothing(self): + # Par défaut on ne touche à rien : une copie porte parfois une vraie + # personnalisation, et la réinitialiser la jette. + lst_cmd, text = self.run_prompt("") + 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 + + source = inspect.getsource(TodoUpgrade.todo_upgrade_execute) + 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()