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)
141 lines
4.3 KiB
Python
Executable file
141 lines
4.3 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)
|
||
|
||
"""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())
|