[ADD] addons: uninstall a theme the way Odoo removes one
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)
This commit is contained in:
parent
7e0985d339
commit
aa2f78c404
4 changed files with 399 additions and 0 deletions
141
script/addons/theme_leftover.py
Executable file
141
script/addons/theme_leftover.py
Executable file
|
|
@ -0,0 +1,141 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""Ce qu'un thème laisse derrière lui une fois désinstallé. Lecture seule.
|
||||
|
||||
Décharger un thème retire ses copies de vues et rend au site sa configuration
|
||||
par défaut. Restent des pièces jointes portant son chemin — SCSS compilé,
|
||||
images téléversées dans ses dossiers — et parfois des vues dont la clé le
|
||||
nomme encore.
|
||||
|
||||
Elles ne cassent rien tant que le module est parti : plus personne ne les
|
||||
inclut dans un bundle. Mais elles traversent toutes les migrations suivantes,
|
||||
et l'on finit par tomber sur un `/theme_x/static/...` dont le module n'existe
|
||||
plus nulle part, sans savoir si c'est grave.
|
||||
|
||||
Rien n'est supprimé ici. Le contenu d'une pièce jointe peut être la seule
|
||||
trace d'une personnalisation, et c'est une décision, pas un ménage.
|
||||
|
||||
Codes de sortie : 0 rien à signaler, 1 des restes, 2 l'outil a échoué.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.append(
|
||||
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
)
|
||||
|
||||
try:
|
||||
from script.todo.todo_i18n import t
|
||||
except Exception: # pragma: no cover - repli si i18n indisponible
|
||||
|
||||
def t(key: str) -> str:
|
||||
return key
|
||||
|
||||
|
||||
def run_psql(database, sql):
|
||||
"""Interroger la base en lecture seule, garantie côté serveur.
|
||||
|
||||
`default_transaction_read_only` est posé par le serveur pour toute la
|
||||
session : ce n'est pas une promesse de l'outil, c'est PostgreSQL qui
|
||||
refusera l'écriture même si le SQL en contenait une.
|
||||
"""
|
||||
env = os.environ.copy()
|
||||
env["PGOPTIONS"] = (
|
||||
"-c default_transaction_read_only=on -c statement_timeout=30000"
|
||||
)
|
||||
env["PSQLRC"] = ""
|
||||
done = subprocess.run(
|
||||
[
|
||||
"psql",
|
||||
"-X",
|
||||
"-w",
|
||||
"-v",
|
||||
"ON_ERROR_STOP=1",
|
||||
"-d",
|
||||
database,
|
||||
"-tAF",
|
||||
"|",
|
||||
"-c",
|
||||
sql,
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
env=env,
|
||||
)
|
||||
if done.returncode:
|
||||
raise RuntimeError(done.stderr.strip() or "psql failed")
|
||||
return [line for line in done.stdout.splitlines() if line]
|
||||
|
||||
|
||||
def quote_literal(value):
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
|
||||
|
||||
def collect(database, theme):
|
||||
"""Pièces jointes et vues qui nomment encore ce thème."""
|
||||
like = quote_literal(f"%/{theme}/%")
|
||||
key_like = quote_literal(f"{theme}.%")
|
||||
attachments = run_psql(
|
||||
database,
|
||||
"SELECT id, COALESCE(url, name), create_date::date FROM ir_attachment"
|
||||
f" WHERE url LIKE {like} ORDER BY id;",
|
||||
)
|
||||
views = run_psql(
|
||||
database,
|
||||
"SELECT id, key, website_id FROM ir_ui_view"
|
||||
f" WHERE key LIKE {key_like} ORDER BY id;",
|
||||
)
|
||||
return attachments, views
|
||||
|
||||
|
||||
def render(theme, attachments, views):
|
||||
if not attachments and not views:
|
||||
return f"✅ -> {t('No leftover for theme')} '{theme}'.\n"
|
||||
lines = []
|
||||
if attachments:
|
||||
lines.append(
|
||||
f"ℹ {len(attachments)} {t('attachment(s) still under')}"
|
||||
f" /{theme}/ :"
|
||||
)
|
||||
for row in attachments[:20]:
|
||||
lines.append(f" - {row}")
|
||||
if len(attachments) > 20:
|
||||
lines.append(f" … {len(attachments) - 20} {t('more')}")
|
||||
if views:
|
||||
lines.append(
|
||||
f"ℹ {len(views)} {t('view(s) whose key still names it')} :"
|
||||
)
|
||||
for row in views[:20]:
|
||||
lines.append(f" - {row}")
|
||||
if len(views) > 20:
|
||||
lines.append(f" … {len(views) - 20} {t('more')}")
|
||||
lines.append(
|
||||
f" {t('Nothing was deleted: their content may be the only trace')}"
|
||||
f" {t('of a customization. Read before removing.')}"
|
||||
)
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=("List what an uninstalled theme left behind (read-only).")
|
||||
)
|
||||
parser.add_argument("-d", "--database", required=True)
|
||||
parser.add_argument("-t", "--theme", required=True)
|
||||
config = parser.parse_args(argv)
|
||||
try:
|
||||
attachments, views = collect(config.database, config.theme)
|
||||
except RuntimeError as exc:
|
||||
print(f"❌ {exc}")
|
||||
return 2
|
||||
print(render(config.theme, attachments, views))
|
||||
return 1 if (attachments or views) else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
92
script/addons/uninstall_addons_theme.sh
Executable file
92
script/addons/uninstall_addons_theme.sh
Executable file
|
|
@ -0,0 +1,92 @@
|
|||
#!/usr/bin/env bash
|
||||
# Désinstaller un thème comme Odoo le fait lui-même.
|
||||
#
|
||||
# « ./run.sh --uninstall theme_x » retire le MODULE, pas le THÈME. Choisir un
|
||||
# thème (button_choose_theme, ce que pose --install-theme) fait DEUX choses :
|
||||
# copier ses vues et ressources 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.
|
||||
#
|
||||
# Le désinstaller sans lui laisse donc les copies, et surtout n'écrit jamais
|
||||
# ces définitions. 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
|
||||
# une personnalisation figée depuis des années.
|
||||
#
|
||||
# Usage : ./script/addons/uninstall_addons_theme.sh <base> <theme> [config]
|
||||
|
||||
Red='\033[0;31m' # Red
|
||||
Color_Off='\033[0m' # Text Reset
|
||||
|
||||
if [[ $# -lt 2 ]]; then
|
||||
echo "Usage: $0 <database> <theme_module> [config]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
DATABASE="$1"
|
||||
THEME="$2"
|
||||
CONFIG="${3:-./config.conf}"
|
||||
|
||||
if [[ $# -eq 3 ]]; then
|
||||
./script/addons/check_addons_exist.py -m "$THEME" -c "$3"
|
||||
else
|
||||
./script/addons/check_addons_exist.py -m "$THEME"
|
||||
fi
|
||||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
echo -e "${Red}Error${Color_Off} check_addons_exist.py into uninstall_addons_theme.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Unload theme '$THEME' from every website of BD '$DATABASE'"
|
||||
|
||||
# Le déchargement passe par le shell : _theme_remove() n'a pas d'option de
|
||||
# ligne de commande, et l'écrire ici évite d'ajouter une option au fork pour
|
||||
# chaque version d'Odoo. Les journaux se mêlent à la sortie, d'où la
|
||||
# sentinelle : on ne conclut que sur ce qui la suit.
|
||||
./odoo_bin.sh shell -c "$CONFIG" -d "$DATABASE" --no-http --log-level=warn <<PYTHON
|
||||
theme_name = "$THEME"
|
||||
Module = env["ir.module.module"]
|
||||
theme = Module.search([("name", "=", theme_name)], limit=1)
|
||||
if not theme:
|
||||
print("ERPLIBRE_THEME_UNLOAD: unknown %s" % theme_name)
|
||||
else:
|
||||
lst_website = env["website"].search([])
|
||||
for website in lst_website:
|
||||
# _theme_remove décharge le thème COURANT du site et, avant tout,
|
||||
# rappelle _reset_default_config() : c'est cet appel qui écrit
|
||||
# font-number & co. dans user_values.scss. Il vaut même quand le
|
||||
# site n'a plus de thème — c'est précisément le cas à réparer.
|
||||
theme.with_context(website_id=website.id)._theme_remove(website)
|
||||
env.cr.commit()
|
||||
print("ERPLIBRE_THEME_UNLOAD: done %s website(s)" % len(lst_website))
|
||||
PYTHON
|
||||
|
||||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
echo -e "${Red}Error${Color_Off} odoo_bin.sh shell into uninstall_addons_theme.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Uninstall theme module '$THEME' on BD '$DATABASE'"
|
||||
|
||||
if [[ $# -eq 3 ]]; then
|
||||
./run.sh --no-http --stop-after-init -d "$DATABASE" --uninstall "$THEME" -c "$3"
|
||||
else
|
||||
./run.sh --no-http --stop-after-init -d "$DATABASE" --uninstall "$THEME"
|
||||
fi
|
||||
|
||||
retVal=$?
|
||||
if [[ $retVal -ne 0 ]]; then
|
||||
echo -e "${Red}Error${Color_Off} run.sh into uninstall_addons_theme.sh"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ce que le déchargement ne prend pas : les pièces jointes que le thème a
|
||||
# laissées sous son propre chemin. Elles ne cassent rien tant que ses vues
|
||||
# sont parties, mais elles survivent à toutes les migrations suivantes et
|
||||
# personne ne sait plus d'où elles viennent. On les signale, on ne les
|
||||
# supprime pas : leur contenu peut être la seule trace d'une personnalisation.
|
||||
./script/addons/theme_leftover.py -d "$DATABASE" -t "$THEME"
|
||||
|
|
@ -4750,6 +4750,26 @@ TRANSLATIONS = {
|
|||
"fr": "Le lancer avec",
|
||||
"en": "Run it with",
|
||||
},
|
||||
"No leftover for theme": {
|
||||
"fr": "Aucun reste pour le thème",
|
||||
"en": "No leftover for theme",
|
||||
},
|
||||
"attachment(s) still under": {
|
||||
"fr": "pièce(s) jointe(s) encore sous",
|
||||
"en": "attachment(s) still under",
|
||||
},
|
||||
"view(s) whose key still names it": {
|
||||
"fr": "vue(s) dont la clé le nomme encore",
|
||||
"en": "view(s) whose key still names it",
|
||||
},
|
||||
"Nothing was deleted: their content may be the only trace": {
|
||||
"fr": "Rien n'a été supprimé : leur contenu peut être la seule trace",
|
||||
"en": "Nothing was deleted: their content may be the only trace",
|
||||
},
|
||||
"of a customization. Read before removing.": {
|
||||
"fr": "d'une personnalisation. Lire avant de retirer.",
|
||||
"en": "of a customization. Read before removing.",
|
||||
},
|
||||
"Nothing to decide yet": {
|
||||
"fr": "Rien à décider pour l'instant",
|
||||
"en": "Nothing to decide yet",
|
||||
|
|
|
|||
146
test/test_uninstall_addons_theme.py
Executable file
146
test/test_uninstall_addons_theme.py
Executable file
|
|
@ -0,0 +1,146 @@
|
|||
#!/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()
|
||||
Loading…
Reference in a new issue