[FIX] migration: updating the addons early left step 2 « not started »

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
This commit is contained in:
Mathieu Benoit 2026-08-12 00:43:16 -04:00
parent 4b6de55f30
commit 98be22cb77
3 changed files with 149 additions and 4 deletions

View file

@ -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",

View file

@ -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,

View file

@ -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()