There was no counterpart to install_addons_theme.sh, so themes were removed with a plain --uninstall. That takes out the module, not the theme: it skips _theme_remove(), whose first act is _reset_default_config() — the call that writes font-number and its three neighbours into user_values.scss. Measured on a real 12 -> 13 migration: web.assets_frontend stopped on « Undefined variable: $o-theme-font-number ». Odoo 12 defined it in option_font_body_*, dropped in 13.0; only the theme still redefined it, so removing the theme exposed a frozen 2020 customization. theme_leftover.py then reports what unloading does not take — 15 attachments on the measured database. It deletes nothing: their content may be the only trace of a customization. --- FR --- [ADD] addons : désinstaller un thème comme Odoo le retire install_addons_theme.sh n'avait pas de symétrique : on retirait donc les thèmes par un --uninstall nu. Cela enlève le module, pas le thème — cela saute _theme_remove(), dont le premier geste est _reset_default_config(), l'appel qui écrit font-number et ses trois voisines dans user_values.scss. Mesuré sur une vraie migration 12 → 13 : web.assets_frontend s'arrête sur « Undefined variable: $o-theme-font-number ». Odoo 12 la définissait dans option_font_body_*, supprimés en 13.0 ; seul le thème la redéfinissait, et le retirer a mis à nu un SCSS figé en 2020. theme_leftover.py signale ensuite ce que le déchargement ne prend pas — 15 pièces jointes sur la base mesurée. Il ne supprime rien : leur contenu peut être la seule trace d'une personnalisation. Assisted-by: Claude Opus 5 (cherry picked from commit 16a7b1e1333752e2ef603d66f47374a22590d665)
146 lines
5.5 KiB
Python
Executable file
146 lines
5.5 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Désinstaller un thème n'est pas désinstaller son module.
|
|
|
|
`--install-theme` appelle `button_choose_theme()`, qui fait deux choses :
|
|
copier les vues et ressources du thème dans chaque site, et écrire dans
|
|
`user_values.scss` une personnalisation qui DÉFINIT `$o-theme-font-number`
|
|
et ses trois voisines. Le chemin de retrait d'Odoo, `_theme_remove()`, défait
|
|
les deux — et son premier geste est `_reset_default_config()`, celui qui écrit
|
|
ces définitions.
|
|
|
|
Un `--uninstall` nu saute tout cela. Mesuré sur une migration réelle 12 → 13 :
|
|
le bundle `web.assets_frontend` s'arrête sur « Undefined variable:
|
|
$o-theme-font-number ». La variable venait des fichiers `option_font_body_*`
|
|
d'Odoo 12, supprimés en 13.0 ; seul le thème la redéfinissait encore, et le
|
|
retirer a mis à nu un SCSS personnalisé figé depuis 2020.
|
|
|
|
Ces tests portent sur ce que le script fait, pas sur son texte.
|
|
"""
|
|
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import unittest
|
|
|
|
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
SCRIPT = os.path.join(REPO, "script", "addons", "uninstall_addons_theme.sh")
|
|
|
|
sys.path.insert(0, os.path.join(REPO, "script", "addons"))
|
|
import theme_leftover # noqa: E402
|
|
|
|
|
|
class TestTheScriptShape(unittest.TestCase):
|
|
def source(self):
|
|
with open(SCRIPT) as handle:
|
|
return handle.read()
|
|
|
|
def test_it_exists_and_is_executable(self):
|
|
self.assertTrue(os.access(SCRIPT, os.X_OK))
|
|
|
|
def test_it_parses(self):
|
|
done = subprocess.run(
|
|
["bash", "-n", SCRIPT], capture_output=True, text=True
|
|
)
|
|
self.assertEqual(done.returncode, 0, done.stderr)
|
|
|
|
def test_it_goes_through_theme_remove(self):
|
|
# LE point : sans cet appel, le script ne serait qu'un --uninstall
|
|
# sous un autre nom, et laisserait la même panne derrière lui.
|
|
self.assertIn("_theme_remove(website)", self.source())
|
|
|
|
def test_it_walks_every_website(self):
|
|
# Un site par thème : n'en traiter qu'un laisserait les autres avec
|
|
# des copies dont le module est parti.
|
|
source = self.source()
|
|
self.assertIn('env["website"].search([])', source)
|
|
|
|
def test_it_still_uninstalls_the_module(self):
|
|
self.assertIn("--uninstall", self.source())
|
|
|
|
def test_it_mirrors_the_installer_checks(self):
|
|
# Même garde-fou que install_addons_theme.sh : un nom de module
|
|
# inexistant doit s'arrêter avant de toucher la base.
|
|
self.assertIn("check_addons_exist.py", self.source())
|
|
|
|
def test_a_missing_argument_stops_before_anything(self):
|
|
done = subprocess.run(
|
|
["bash", SCRIPT, "onlydb"],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=REPO,
|
|
)
|
|
self.assertEqual(done.returncode, 1)
|
|
self.assertIn("Usage", done.stdout + done.stderr)
|
|
|
|
|
|
class TestTheLeftoverReport(unittest.TestCase):
|
|
"""Ce que le déchargement ne prend pas, et qu'il faut au moins savoir."""
|
|
|
|
def setUp(self):
|
|
# PAS set_lang() : il persiste la langue dans env_var.sh, suivi par
|
|
# git. On épingle la mémoïsation — sans quoi ces tests liraient la
|
|
# langue du poste, et passeraient ou non selon la machine.
|
|
from script.todo import todo_i18n
|
|
|
|
self.addCleanup(
|
|
setattr, todo_i18n, "_current_lang", todo_i18n._current_lang
|
|
)
|
|
todo_i18n._current_lang = "en"
|
|
|
|
def test_nothing_left_is_said_plainly(self):
|
|
text = theme_leftover.render("theme_x", [], [])
|
|
self.assertIn("✅", text)
|
|
|
|
def test_attachments_are_listed_with_their_date(self):
|
|
rows = ["4457|/theme_x/static/a.scss|2021-03-04"]
|
|
text = theme_leftover.render("theme_x", rows, [])
|
|
self.assertIn("4457", text)
|
|
self.assertIn("2021-03-04", text)
|
|
|
|
def test_a_long_list_says_how_many_it_hid(self):
|
|
# Tronquer sans le dire se lit comme « c'est tout ».
|
|
rows = [f"{i}|/theme_x/a{i}.scss|2021-01-01" for i in range(30)]
|
|
text = theme_leftover.render("theme_x", rows, [])
|
|
self.assertIn("10", text)
|
|
|
|
def test_it_never_offers_to_delete(self):
|
|
# Le contenu d'une pièce jointe peut être la seule trace d'une
|
|
# personnalisation : c'est une décision, pas un ménage.
|
|
text = theme_leftover.render("theme_x", ["1|/theme_x/a|d"], [])
|
|
self.assertIn("Nothing was deleted", text)
|
|
|
|
def test_the_sql_escapes_a_quote_in_the_theme_name(self):
|
|
self.assertEqual(theme_leftover.quote_literal("a'b"), "'a''b'")
|
|
|
|
def test_the_query_is_read_only_on_the_server_side(self):
|
|
# Pas une promesse de l'outil : PostgreSQL refuse l'écriture.
|
|
with open(theme_leftover.__file__) as handle:
|
|
source = handle.read()
|
|
self.assertIn("default_transaction_read_only=on", source)
|
|
|
|
|
|
class TestExitCodes(unittest.TestCase):
|
|
"""0 rien, 1 des restes, 2 l'outil a échoué — comme les outils voisins."""
|
|
|
|
def test_a_dead_database_is_a_tool_failure(self):
|
|
done = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
os.path.join(REPO, "script", "addons", "theme_leftover.py"),
|
|
"-d",
|
|
"erplibre_no_such_database_zz",
|
|
"-t",
|
|
"theme_x",
|
|
],
|
|
capture_output=True,
|
|
text=True,
|
|
cwd=REPO,
|
|
)
|
|
self.assertEqual(done.returncode, 2, done.stdout)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|