[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
2026-08-12 00:43:16 -04:00
|
|
|
#!/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})
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
[ADD] migration: go back a step from a prompt, and name the DB
Realising at a prompt that an earlier step deserved another answer had one
way out: Ctrl+C. That leaves the progression as it stands and makes you find
the resume screen again to rewind. « b » does it properly — it shows the
steps, rewinds the state, writes it, and says what to relaunch.
Cancelling that must not stop the migration, which is the trap: it returns to
the same prompt, exactly where you were. Removing that guard makes two tests
fail.
The COW warning printed « -d DB -t odooXX.0 » for commands meant to be pasted.
It knows both values, so it prints them, and offers --shape as well since that
is half the answer.
Checked on the four outcomes: a normal answer passes through, « b » then a
step rewinds and stops, cancelling and an unknown step both continue. 15 tests.
--- FR ---
S'apercevoir à une invite qu'une étape antérieure méritait un autre choix
n'avait qu'une issue : Ctrl+C. Cela laisse la progression telle quelle et
oblige à retrouver l'écran de reprise pour rembobiner. « b » le fait
proprement — il montre les étapes, rembobine l'état, l'écrit, et dit quoi
relancer.
Y renoncer ne doit pas arrêter la migration, et c'est le piège : on revient à
la même invite, exactement là où l'on était. Retirer ce garde-fou fait tomber
deux tests.
L'avertissement COW affichait « -d DB -t odooXX.0 » pour des commandes faites
pour être collées. Il connaît les deux valeurs, donc il les écrit, et propose
aussi --shape puisque c'est la moitié de la réponse.
Vérifié sur les quatre issues : une réponse normale passe, « b » puis une
étape rembobine et arrête, annuler et un choix inconnu continuent. 15 tests.
Assisted-by: Claude Opus 5
2026-08-12 01:47:44 -04:00
|
|
|
class TestBackGate(unittest.TestCase):
|
|
|
|
|
"""Revenir à une étape précédente depuis une invite en cours de route.
|
|
|
|
|
|
|
|
|
|
Ces invites ne demandaient qu'à continuer. S'apercevoir à ce moment-là
|
|
|
|
|
qu'une étape antérieure méritait un autre choix n'avait qu'une issue :
|
|
|
|
|
Ctrl+C, qui laisse la progression telle quelle et oblige à retrouver
|
|
|
|
|
l'écran de reprise. « b » fait le travail proprement.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
def setUp(self):
|
|
|
|
|
import os
|
|
|
|
|
import tempfile
|
|
|
|
|
|
|
|
|
|
self.addCleanup(os.chdir, os.getcwd())
|
|
|
|
|
os.chdir(tempfile.mkdtemp())
|
|
|
|
|
os.makedirs(".venv.erplibre", exist_ok=True)
|
|
|
|
|
self.addCleanup(
|
|
|
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
|
|
|
)
|
|
|
|
|
todo_i18n._current_lang = "en"
|
|
|
|
|
|
|
|
|
|
def gate(self, answers):
|
|
|
|
|
"""(a arrêté ?, valeur rendue, progression) après ces réponses."""
|
|
|
|
|
import builtins
|
|
|
|
|
import contextlib
|
|
|
|
|
import io
|
|
|
|
|
|
|
|
|
|
from script.todo.todo_upgrade import MigrationRewind
|
|
|
|
|
|
|
|
|
|
upgrade = TodoUpgrade.__new__(TodoUpgrade)
|
|
|
|
|
upgrade.dct_progression = {
|
|
|
|
|
"config_database_name": "db",
|
|
|
|
|
"state_0_install_odoo": True,
|
|
|
|
|
"state_1_restore_database": True,
|
|
|
|
|
"state_2_update_all": True,
|
|
|
|
|
"state_3_clean_database": True,
|
|
|
|
|
}
|
|
|
|
|
upgrade.lst_command_executed = []
|
|
|
|
|
seq = iter(answers)
|
|
|
|
|
original = builtins.input
|
|
|
|
|
builtins.input = lambda *a: next(seq)
|
|
|
|
|
self.addCleanup(setattr, builtins, "input", original)
|
|
|
|
|
stopped, returned = False, None
|
|
|
|
|
try:
|
|
|
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|
|
|
|
returned = upgrade.ask_gate("? ")
|
|
|
|
|
except MigrationRewind:
|
|
|
|
|
stopped = True
|
|
|
|
|
return stopped, returned, upgrade.dct_progression
|
|
|
|
|
|
|
|
|
|
def test_a_normal_answer_passes_straight_through(self):
|
|
|
|
|
stopped, returned, _ = self.gate(["y"])
|
|
|
|
|
self.assertFalse(stopped)
|
|
|
|
|
self.assertEqual(returned, "y")
|
|
|
|
|
|
|
|
|
|
def test_b_then_a_step_rewinds_and_stops(self):
|
|
|
|
|
stopped, _, progression = self.gate(["b", "2"])
|
|
|
|
|
self.assertTrue(stopped)
|
|
|
|
|
# L'étape choisie et les suivantes sont effacées, les précédentes non.
|
|
|
|
|
self.assertIn("state_1_restore_database", progression)
|
|
|
|
|
self.assertNotIn("state_2_update_all", progression)
|
|
|
|
|
self.assertNotIn("state_3_clean_database", progression)
|
|
|
|
|
|
|
|
|
|
def test_cancelling_the_rewind_does_not_stop_the_migration(self):
|
|
|
|
|
# LE piège : renoncer au retour en arrière arrêtait quand même tout.
|
|
|
|
|
# On revient à la même invite, exactement là où l'on était.
|
|
|
|
|
stopped, returned, progression = self.gate(["b", "", "y"])
|
|
|
|
|
self.assertFalse(stopped)
|
|
|
|
|
self.assertEqual(returned, "y")
|
|
|
|
|
self.assertIn("state_3_clean_database", progression)
|
|
|
|
|
|
|
|
|
|
def test_an_unknown_step_is_not_a_rewind(self):
|
|
|
|
|
stopped, returned, progression = self.gate(["b", "zzz", ""])
|
|
|
|
|
self.assertFalse(stopped)
|
|
|
|
|
self.assertEqual(returned, "")
|
|
|
|
|
self.assertIn("state_3_clean_database", progression)
|
|
|
|
|
|
|
|
|
|
def test_the_answer_is_case_insensitive(self):
|
|
|
|
|
stopped, _, _ = self.gate(["B", "1"])
|
|
|
|
|
self.assertTrue(stopped)
|
|
|
|
|
|
|
|
|
|
|
[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
2026-08-12 00:43:16 -04:00
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|