From 98be22cb77b7d3630cf46ec20824870372577c25 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 12 Aug 2026 00:43:16 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20migration:=20updating=20the=20addons=20?= =?UTF-8?q?early=20left=20step=202=20=C2=AB=20not=20started=20=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A database coming from an old version is updated before the neutralization — the tool offers it at the very start. That work IS step 2, done earlier, but it was only recorded as state_1_update_all while the screen reads state_2_*. So the step stayed « not started » right after running. The costly half was invisible. Step 2 skipped the work through a local variable, which is False again on a resume: with only the old flag written, resuming a migration re-ran update_addons_all on an already-updated database. Hours, for nothing. The check now reads the written trace, not the session. The screen says when it happened rather than just « done », since the moment is exactly what raised the doubt. Logs written before this fix read correctly without being touched — the early flag alone is recognised. Checked on the real log from the VM: step 2 goes from « not started » to « done early, before the neutralization », the four other steps unchanged. 10 tests; removing the fix makes the resume one fail. --- FR --- Une base venant d'une vieille version se met à jour avant la neutralisation — l'outil le propose au tout début. Ce travail EST l'étape 2, faite plus tôt, mais il n'était enregistré que sous state_1_update_all quand l'écran lit state_2_*. L'étape restait donc « non démarrée » juste après avoir tourné. La moitié coûteuse était invisible. L'étape 2 sautait le travail grâce à une variable locale, remise à False à la reprise : avec seulement l'ancien drapeau écrit, reprendre une migration relançait update_addons_all sur une base déjà à jour. Des heures, pour rien. Le contrôle lit maintenant la trace écrite, pas la session. L'écran dit quand cela a eu lieu plutôt que « terminée », le moment étant justement la source du doute. Les journaux écrits avant ce correctif se lisent correctement sans être modifiés — le drapeau précoce seul est reconnu. Vérifié sur le vrai journal de la VM : l'étape 2 passe de « non démarrée » à « faite plus tôt, avant la neutralisation », les quatre autres inchangées. 10 tests ; retirer le correctif fait tomber celui de la reprise. Assisted-by: Claude Opus 5 --- script/todo/todo_i18n.py | 4 ++ script/todo/todo_upgrade.py | 45 ++++++++++++-- test/test_todo_upgrade_steps.py | 104 ++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 test/test_todo_upgrade_steps.py diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0447c9d..ef08606 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3868,6 +3868,10 @@ TRANSLATIONS = { "fr": "Journal de la migration précédente conservé dans", "en": "Previous migration log kept in", }, + "done early, before the neutralization": { + "fr": "faite plus tôt, avant la neutralisation", + "en": "done early, before the neutralization", + }, # --- script/analyse : vues personnalisées --- "Customised views": { "fr": "Vues personnalisées", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 5e4fab2..c75a592 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -108,6 +108,26 @@ class TodoUpgrade: ) return {} + @staticmethod + def needs_update_all(dct_progression, already_done_early=False): + """Faut-il encore mettre à jour tous les modules ? + + Trois façons de l'avoir déjà fait, et il faut les trois. Le drapeau de + l'étape ; celui de la mise à jour précoce, posée avant la + neutralisation quand la base vient d'une vieille version ; et la + variable de la session en cours. + + Le second est ce qui manquait. Il n'est vrai que dans la session qui + l'a posé, alors qu'à la reprise seule la trace écrite subsiste : sans + le lire ici, reprendre une migration relançait `update_addons_all` sur + une base déjà à jour — des heures, pour rien. + """ + return not ( + dct_progression.get("state_2_update_all") + or dct_progression.get("state_1_update_all") + or already_done_early + ) + @staticmethod def step_status(dct_progression, step): """Return (icon, detail) telling how far a migration step went. @@ -121,6 +141,15 @@ class TodoUpgrade: for key, value in dct_progression.items() if key.startswith(prefix) } + # Un journal écrit avant que l'étape 2 n'enregistre son propre drapeau + # ne porte que state_1_update_all. Le travail a bien eu lieu ; le lire + # ici évite de dire « non démarrée » d'une étape terminée. + if ( + step == 2 + and not dct_flag + and dct_progression.get("state_1_update_all") + ): + return "✅", t("done early, before the neutralization") if not dct_flag: return "⬜", t("not started") @@ -154,6 +183,10 @@ class TodoUpgrade: return ("✅" if total and done == total else "⏳"), detail if all(dct_flag.values()): + if step == 2 and dct_progression.get("state_2_done_early"): + # Le doute vient du moment, pas du résultat : le dire évite de + # relancer une mise à jour qui a déjà eu lieu. + return "✅", t("done early, before the neutralization") return "✅", t("done") return "⏳", t("partially done") @@ -1313,6 +1346,13 @@ class TodoUpgrade: if not status: already_update_state_1 = True self.dct_progression["state_1_update_all"] = True + # La mise à jour de tous les modules EST l'étape 2, même + # faite plus tôt. Ne l'enregistrer que sous state_1 la + # laissait « non démarrée » à l'écran alors qu'elle venait + # de tourner, et faisait la refaire entièrement à la + # reprise suivante — sur une grosse base, des heures. + self.dct_progression["state_2_update_all"] = True + self.dct_progression["state_2_done_early"] = True self.write_config() print("✅ -> Update database before neutralize by module") @@ -1400,10 +1440,7 @@ class TodoUpgrade: print(f"🔷 {msg}") self.add_comment_progression(msg) - if ( - not self.dct_progression.get("state_2_update_all") - and not already_update_state_1 - ): + if self.needs_update_all(self.dct_progression, already_update_state_1): status, cmd_executed = self.todo_upgrade_execute( f"./script/addons/update_addons_all.sh {database_name}", single_source_odoo=True, diff --git a/test/test_todo_upgrade_steps.py b/test/test_todo_upgrade_steps.py new file mode 100644 index 0000000..5e5d68d --- /dev/null +++ b/test/test_todo_upgrade_steps.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""L'écran de reprise doit dire ce qui a eu lieu, pas ce qu'il croit. + +Une base venant d'une vieille version se met à jour AVANT la neutralisation : +l'outil le propose au tout début. Ce travail est celui de l'étape 2, fait plus +tôt — mais il n'était enregistré que sous `state_1_update_all`, et l'écran +cherchait `state_2_*`. + +Deux conséquences, l'une visible et l'autre chère : l'étape restait « non +démarrée » alors qu'elle venait de tourner, et la reprise suivante relançait +`update_addons_all` sur une base déjà à jour. +""" + +import unittest + +from script.todo import todo_i18n +from script.todo.todo_upgrade import MIGRATION_STEP, TodoUpgrade + + +class StatusCase(unittest.TestCase): + def setUp(self): + # PAS set_lang() : il persiste la langue dans env_var.sh, suivi par + # git. On écrit la mémoïsation, et on la rend. + self.addCleanup( + setattr, todo_i18n, "_current_lang", todo_i18n._current_lang + ) + todo_i18n._current_lang = "en" + + +class TestUpdateAllStatus(StatusCase): + def status(self, dct): + return TodoUpgrade.step_status(dct, 2) + + def test_an_old_log_knows_only_the_early_flag(self): + # Le cas signalé : un journal écrit avant le correctif. L'étape a bien + # eu lieu, il ne faut pas la dire « non démarrée ». + icon, detail = self.status({"state_1_update_all": True}) + self.assertEqual(icon, "✅") + self.assertIn("early", detail) + + def test_a_new_log_says_when_it_happened(self): + icon, detail = self.status( + { + "state_1_update_all": True, + "state_2_update_all": True, + "state_2_done_early": True, + } + ) + self.assertEqual(icon, "✅") + self.assertIn("early", detail) + + def test_done_at_its_normal_place_says_only_done(self): + icon, detail = self.status({"state_2_update_all": True}) + self.assertEqual(icon, "✅") + self.assertNotIn("early", detail) + + def test_really_not_started(self): + icon, detail = self.status({"state_0_install_odoo": True}) + self.assertEqual(icon, "⬜") + self.assertIn("not started", detail) + + def test_the_other_steps_are_untouched(self): + # Le correctif ne vaut que pour l'étape 2 : les autres gardent leur + # règle, sinon un drapeau d'une étape en éclairerait une autre. + dct = {"state_1_update_all": True} + for step, _ in MIGRATION_STEP: + if step in (1, 2): + continue + icon, _ = TodoUpgrade.step_status(dct, step) + self.assertEqual(icon, "⬜", f"étape {step}") + + +class TestNeedsUpdateAll(unittest.TestCase): + """Ce qui décide de relancer, ou non, une mise à jour de plusieurs heures.""" + + def test_nothing_done_yet(self): + self.assertTrue(TodoUpgrade.needs_update_all({})) + + def test_its_own_flag_is_enough(self): + self.assertFalse( + TodoUpgrade.needs_update_all({"state_2_update_all": True}) + ) + + def test_the_early_flag_survives_a_resume(self): + # LE défaut : la variable de session repart à False à la reprise, et + # seule la trace écrite subsiste. Sans la lire, on relançait tout. + self.assertFalse( + TodoUpgrade.needs_update_all({"state_1_update_all": True}) + ) + + def test_the_session_variable_still_counts(self): + self.assertFalse(TodoUpgrade.needs_update_all({}, True)) + + def test_an_unrelated_flag_does_not_count(self): + self.assertTrue( + TodoUpgrade.needs_update_all({"state_1_restore_database": True}) + ) + + +if __name__ == "__main__": + unittest.main()