[ADD] migration: offer to remove the themes before the first bump

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)
This commit is contained in:
Mathieu Benoit 2026-08-15 02:36:28 -04:00
parent aa2f78c404
commit 3465bfdd19
3 changed files with 172 additions and 0 deletions

View file

@ -4770,6 +4770,26 @@ TRANSLATIONS = {
"fr": "d'une personnalisation. Lire avant de retirer.", "fr": "d'une personnalisation. Lire avant de retirer.",
"en": "of a customization. Read before removing.", "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": { "Nothing to decide yet": {
"fr": "Rien à décider pour l'instant", "fr": "Rien à décider pour l'instant",
"en": "Nothing to decide yet", "en": "Nothing to decide yet",

View file

@ -1545,6 +1545,11 @@ class TodoUpgrade:
self.dct_progression["state_1_neutralize_database"] = True self.dct_progression["state_1_neutralize_database"] = True
self.write_config() 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 = self.dct_progression.get(
"config_state_1_uninstall_module" "config_state_1_uninstall_module"
) )
@ -2706,6 +2711,62 @@ class TodoUpgrade:
prefix, sep, label = msg.partition(" - ") prefix, sep, label = msg.partition(" - ")
print(f"🔷 {prefix}{sep}{t(label)}" if sep else f"🔷 {t(msg)}") 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"): def show_cow_drift(self, database_name, next_version, mode="diff"):
"""Montre les copies COW à risque. Ne touche à rien. """Montre les copies COW à risque. Ne touche à rien.

View file

@ -122,6 +122,97 @@ class TestTheLeftoverReport(unittest.TestCase):
self.assertIn("default_transaction_read_only=on", source) 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): class TestExitCodes(unittest.TestCase):
"""0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins.""" """0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins."""