From aa2f78c404e9c6d8af4bdc5de158c273f37dfb48 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 13 Aug 2026 05:56:06 -0400 Subject: [PATCH] [ADD] addons: uninstall a theme the way Odoo removes one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- script/addons/theme_leftover.py | 141 +++++++++++++++++++++++ script/addons/uninstall_addons_theme.sh | 92 +++++++++++++++ script/todo/todo_i18n.py | 20 ++++ test/test_uninstall_addons_theme.py | 146 ++++++++++++++++++++++++ 4 files changed, 399 insertions(+) create mode 100755 script/addons/theme_leftover.py create mode 100755 script/addons/uninstall_addons_theme.sh create mode 100755 test/test_uninstall_addons_theme.py diff --git a/script/addons/theme_leftover.py b/script/addons/theme_leftover.py new file mode 100755 index 0000000..38b60da --- /dev/null +++ b/script/addons/theme_leftover.py @@ -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()) diff --git a/script/addons/uninstall_addons_theme.sh b/script/addons/uninstall_addons_theme.sh new file mode 100755 index 0000000..5eafad4 --- /dev/null +++ b/script/addons/uninstall_addons_theme.sh @@ -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 [config] + +Red='\033[0;31m' # Red +Color_Off='\033[0m' # Text Reset + +if [[ $# -lt 2 ]]; then + echo "Usage: $0 [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 <