erplibre/script/addons/theme_leftover.py
Mathieu Benoit aa2f78c404 [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)
2026-08-22 07:23:59 -04:00

141 lines
4.3 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())