From 3465bfdd19356311d3868b47de7f247ed6f0c0bc Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Sat, 15 Aug 2026 02:36:28 -0400 Subject: [PATCH] [ADD] migration: offer to remove the themes before the first bump MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A theme carries view copies and SCSS through every version bump, and a bump can rename what they rely on. Removing it first drops a whole family of failures, and it can be put back afterwards. Asked in step 1, before OpenUpgrade runs, and only when a theme is actually installed. Default is no: removing a theme changes how a site looks, and a migration does not decide that for its owner. theme_default is not offered — it IS the absence of a theme. --- FR --- [ADD] migration : proposer de retirer les thèmes avant le premier palier Un thème traîne des copies de vues et des SCSS à travers chaque palier de version, et un palier peut renommer ce dont ils dépendent. Le retirer d'abord enlève une famille entière de pannes, et se refait ensuite. Posée à l'étape 1, avant OpenUpgrade, et seulement s'il y a vraiment un thème installé. Par défaut non : retirer un thème change l'apparence d'un site, et ce n'est pas à une migration de trancher cela à la place de son propriétaire. theme_default n'est pas proposé — il EST l'absence de thème. Assisted-by: Claude Opus 5 (cherry picked from commit dcbee87a6b920408c315f7354a096b0ecfabcf06) --- script/todo/todo_i18n.py | 20 +++++++ script/todo/todo_upgrade.py | 61 +++++++++++++++++++ test/test_uninstall_addons_theme.py | 91 +++++++++++++++++++++++++++++ 3 files changed, 172 insertions(+) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 82d8ad8..74d9734 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4770,6 +4770,26 @@ TRANSLATIONS = { "fr": "d'une personnalisation. Lire avant de retirer.", "en": "of a customization. Read before removing.", }, + "Installed theme(s) on": { + "fr": "Thème(s) installé(s) sur", + "en": "Installed theme(s) on", + }, + "A theme carries view copies and SCSS through every": { + "fr": "Un thème traîne des copies de vues et des SCSS à travers chaque", + "en": "A theme carries view copies and SCSS through every", + }, + "version bump, and a bump can rename what they rely on.": { + "fr": "palier de version, et un palier peut renommer ce dont ils dépendent.", + "en": "version bump, and a bump can rename what they rely on.", + }, + "Uninstall them properly before migrating?": { + "fr": "Les désinstaller proprement avant de migrer ?", + "en": "Uninstall them properly before migrating?", + }, + "Kept. Nothing was uninstalled.": { + "fr": "Conservés. Rien n'a été désinstallé.", + "en": "Kept. Nothing was uninstalled.", + }, "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 6b77a99..0072e92 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -1545,6 +1545,11 @@ class TodoUpgrade: self.dct_progression["state_1_neutralize_database"] = True self.write_config() + if not self.dct_progression.get("state_1_theme_uninstalled"): + self.prompt_uninstall_theme(database_name) + self.dct_progression["state_1_theme_uninstalled"] = True + self.write_config() + config_state_1_uninstall_module = self.dct_progression.get( "config_state_1_uninstall_module" ) @@ -2706,6 +2711,62 @@ class TodoUpgrade: prefix, sep, label = msg.partition(" - ") print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") + def installed_theme(self, database_name): + """Thèmes installés, hors theme_default qui EST l'absence de thème.""" + status, _cmd, output = self.todo_upgrade_execute( + f'psql -X -w -d {database_name} -tAc "SELECT name FROM' + " ir_module_module WHERE name LIKE 'theme%' AND" + " state = 'installed' AND name <> 'theme_default'" + ' ORDER BY name;"', + get_output=True, + wait_at_error=False, + quiet=True, + ) + if status: + return [] + return [line.strip() for line in (output or []) if line.strip()] + + def prompt_uninstall_theme(self, database_name): + """Proposer de retirer les thèmes AVANT de monter de version. + + Un thème installé traverse la migration : ses copies de vues et ses + SCSS suivent chaque palier, et chaque palier peut renommer ce dont + ils dépendent. Le retirer d'abord enlève d'un coup une famille + entière de pannes, et se refait après. + + « non » par défaut : retirer un thème change l'apparence du site, et + ce n'est pas à une migration de le décider à la place de quelqu'un. + La question n'est posée que s'il y a un thème à retirer. + """ + lst_theme = self.installed_theme(database_name) + if not lst_theme: + return + print( + f"\n✨ {t('Installed theme(s) on')} '{database_name}' :" + f" {', '.join(lst_theme)}" + ) + print( + f" {t('A theme carries view copies and SCSS through every')}" + f" {t('version bump, and a bump can rename what they rely on.')}" + ) + answer = ( + self.ask_gate( + f"💬 {t('Uninstall them properly before migrating?')}" + f" (y/N, {t('(b = go back to a previous step)')}) : " + ) + .strip() + .lower() + ) + if answer != "y": + print(f"ℹ -> {t('Kept. Nothing was uninstalled.')}") + return + for theme in lst_theme: + self.todo_upgrade_execute( + f"./script/addons/uninstall_addons_theme.sh" + f" {database_name} {theme}", + wait_at_error=False, + ) + def show_cow_drift(self, database_name, next_version, mode="diff"): """Montre les copies COW à risque. Ne touche à rien. diff --git a/test/test_uninstall_addons_theme.py b/test/test_uninstall_addons_theme.py index 7483e53..bf22e3e 100755 --- a/test/test_uninstall_addons_theme.py +++ b/test/test_uninstall_addons_theme.py @@ -122,6 +122,97 @@ class TestTheLeftoverReport(unittest.TestCase): self.assertIn("default_transaction_read_only=on", source) +class TestTheMigrationOffersIt(unittest.TestCase): + """La question doit venir AVANT le premier palier, et par défaut non. + + Un thème installé traverse la migration : ses copies de vues et ses SCSS + suivent chaque palier, et chaque palier peut renommer ce dont ils + dépendent. Le proposer tôt retire d'un coup une famille de pannes. + + Par défaut non : retirer un thème change l'apparence d'un site, et ce + n'est pas à une migration de trancher cela à la place de quelqu'un. + """ + + def setUp(self): + from script.todo import todo_i18n + + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + def upgrade(self, lst_theme, answer): + from script.todo.todo_upgrade import TodoUpgrade + + obj = TodoUpgrade.__new__(TodoUpgrade) + obj.dct_progression = {} + obj.lst_command_executed = [] + obj.write_config = lambda: None + obj.installed_theme = lambda db: lst_theme + self.lst_cmd = [] + obj.todo_upgrade_execute = lambda cmd, **kw: ( + self.lst_cmd.append(cmd), + (False, cmd, []), + )[1] + obj.ask_gate = lambda prompt: answer + return obj + + def run_prompt(self, lst_theme, answer): + import contextlib + import io + + obj = self.upgrade(lst_theme, answer) + out = io.StringIO() + with contextlib.redirect_stdout(out): + obj.prompt_uninstall_theme("db") + return self.lst_cmd, out.getvalue() + + def test_the_default_answer_uninstalls_nothing(self): + # « Entrée » ne doit RIEN faire : la migration ne décide pas de + # l'apparence d'un site à la place de son propriétaire. + lst_cmd, text = self.run_prompt(["theme_technolibre"], "") + self.assertEqual(lst_cmd, []) + self.assertIn("Kept", text) + + def test_no_theme_means_no_question(self): + lst_cmd, text = self.run_prompt([], "y") + self.assertEqual(lst_cmd, []) + self.assertEqual(text, "") + + def test_yes_runs_the_proper_uninstaller(self): + lst_cmd, _ = self.run_prompt(["theme_technolibre"], "y") + self.assertEqual(len(lst_cmd), 1) + self.assertIn( + "uninstall_addons_theme.sh db theme_technolibre", lst_cmd[0] + ) + + def test_every_theme_is_offered_together(self): + lst_cmd, _ = self.run_prompt(["theme_a", "theme_b"], "Y") + self.assertEqual(len(lst_cmd), 2) + + def test_it_is_asked_before_the_first_bump(self): + # L'ordre est le point : posée après le premier palier, la question + # arrive quand les copies ont déjà traversé un renommage. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.execute_odoo_upgrade) + appel = source.index("prompt_uninstall_theme") + palier = source.index("4 - Upgrade version with OpenUpgrade") + self.assertLess(appel, palier) + + def test_theme_default_is_not_a_theme_to_remove(self): + # theme_default EST l'absence de thème : le proposer au retrait + # ferait poser une question sans objet à chaque migration. + import inspect + + from script.todo.todo_upgrade import TodoUpgrade + + source = inspect.getsource(TodoUpgrade.installed_theme) + self.assertIn("name <> 'theme_default'", source) + + class TestExitCodes(unittest.TestCase): """0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins."""