Deleting the leftovers failed on « the database search does not have the
ids (('4457',)) and has extra ids ((4457,)) ». psql returns text; the ids
went into browse() as strings, Odoo compared them against integers, found
nothing and refused the whole batch.
Nothing was lost: the backup runs before the deletion, and the tool said
plainly that nothing was removed. Measured after the fact — 15 files
saved, 15 attachments still in database.
--- FR ---
[FIX] addons : parcourir les restes par entier, pas par la chaîne de psql
L'effacement échouait sur « la recherche en base n'a pas les identifiants
(('4457',)) et a des identifiants supplémentaires ((4457,)) ». psql rend
du texte ; les identifiants partaient dans browse() en chaînes, Odoo les
comparait à des entiers, ne trouvait rien et refusait tout le lot.
Rien n'a été perdu : la sauvegarde précède l'effacement, et l'outil a dit
franchement que rien n'avait été retiré. Vérifié après coup — 15 fichiers
sauvegardés, 15 pièces jointes toujours en base.
Assisted-by: Claude Opus 5
291 lines
9.5 KiB
Python
Executable file
291 lines
9.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)
|
||
|
||
"""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 can_ask():
|
||
"""Peut-on poser une question ICI ?
|
||
|
||
Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
|
||
terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
|
||
stdin laisse poser une invite qui part dans un tube : elle reste en
|
||
tampon, invisible, pendant que le processus attend — on croit à un
|
||
blocage et l'on tape Entrée à l'aveugle.
|
||
"""
|
||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||
|
||
|
||
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 backup_attachments(database, theme, lst_row, filestore=None):
|
||
"""Écrire le contenu des pièces jointes AVANT de les supprimer.
|
||
|
||
C'est la condition pour pouvoir répondre « efface ». Sans elle, on
|
||
détruirait ce dont on vient d'écrire qu'il peut être la seule trace
|
||
d'une personnalisation.
|
||
"""
|
||
base = filestore or os.path.join(
|
||
os.path.expanduser("~"),
|
||
".local",
|
||
"share",
|
||
"Odoo",
|
||
"filestore",
|
||
database,
|
||
)
|
||
directory = os.path.join(
|
||
"private", "odoo", "migration", database, "theme_backup", theme
|
||
)
|
||
os.makedirs(directory, exist_ok=True)
|
||
lst_saved = []
|
||
for row in lst_row:
|
||
att_id = row.split("|")[0]
|
||
rows = run_psql(
|
||
database,
|
||
"SELECT COALESCE(store_fname, '') FROM ir_attachment"
|
||
f" WHERE id = {int(att_id)};",
|
||
)
|
||
store_fname = rows[0].strip() if rows else ""
|
||
if not store_fname:
|
||
continue
|
||
source = os.path.join(base, store_fname)
|
||
if not os.path.isfile(source):
|
||
continue
|
||
target = os.path.join(
|
||
directory, f"{att_id}_" + os.path.basename(row.split("|")[1])
|
||
)
|
||
with open(source, "rb") as src, open(target, "wb") as dst:
|
||
dst.write(src.read())
|
||
lst_saved.append(target)
|
||
return lst_saved
|
||
|
||
|
||
def delete_attachments(database, lst_row, config_path="./config.conf"):
|
||
"""Supprimer par le shell d'Odoo, pour qu'il gère aussi le filestore.
|
||
|
||
Un DELETE en SQL laisserait les fichiers orphelins et les caches
|
||
incohérents ; unlink() fait le ménage complet, dans toutes les versions.
|
||
"""
|
||
# int(), pas la chaîne du psql : browse(['4457']) fait échouer Odoo sur
|
||
# « n'a pas les identifiants (('4457',)) et a des identifiants
|
||
# supplémentaires ((4457,)) » — il compare des chaînes à des entiers.
|
||
# Mesuré sur une vraie base, l'effacement n'a rien retiré.
|
||
lst_id = [int(row.split("|")[0]) for row in lst_row]
|
||
script = (
|
||
f"env['ir.attachment'].browse({lst_id!r}).unlink()\n"
|
||
"env.cr.commit()\n"
|
||
)
|
||
done = subprocess.run(
|
||
["./odoo_bin.sh", "shell", "-c", config_path, "-d", database],
|
||
input=script,
|
||
capture_output=True,
|
||
text=True,
|
||
)
|
||
return done.returncode, done.stdout + done.stderr
|
||
|
||
|
||
def prompt(database, theme, attachments, views, config_path, ask=input):
|
||
"""Garder ou effacer. « Garder » par défaut, et la sauvegarde d'abord."""
|
||
if not attachments:
|
||
return False
|
||
answer = (
|
||
ask(
|
||
f"💬 {t('Delete these leftovers, or keep them?')}"
|
||
f" ({t('Enter = keep')}, d = {t('delete, after saving them')}) : "
|
||
)
|
||
.strip()
|
||
.lower()
|
||
)
|
||
if answer != "d":
|
||
print(f"ℹ -> {t('Kept. Nothing was deleted.')}")
|
||
return False
|
||
lst_saved = backup_attachments(database, theme, attachments)
|
||
print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
|
||
if lst_saved:
|
||
print(f" {os.path.dirname(lst_saved[0])}")
|
||
status, output = delete_attachments(database, attachments, config_path)
|
||
print(output.strip()[-1500:])
|
||
if status:
|
||
print(f"❌ {t('Deletion failed, nothing was removed.')}")
|
||
return False
|
||
print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
|
||
return True
|
||
|
||
|
||
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)
|
||
parser.add_argument(
|
||
"-c",
|
||
"--config",
|
||
default="./config.conf",
|
||
help="Odoo config used by the shell for --delete",
|
||
)
|
||
parser.add_argument(
|
||
"--delete",
|
||
action="store_true",
|
||
help="delete them (WRITES; saves their content first)",
|
||
)
|
||
parser.add_argument(
|
||
"--report-only",
|
||
action="store_true",
|
||
help="never ask anything, even in front of a terminal",
|
||
)
|
||
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))
|
||
if not attachments and not views:
|
||
return 0
|
||
if config.delete:
|
||
lst_saved = backup_attachments(
|
||
config.database, config.theme, attachments
|
||
)
|
||
print(f"📦 {t('Saved before deleting')} : {len(lst_saved)}")
|
||
status, output = delete_attachments(
|
||
config.database, attachments, config.config
|
||
)
|
||
print(output.strip()[-1500:])
|
||
if status:
|
||
print(f"❌ {t('Deletion failed, nothing was removed.')}")
|
||
return 2
|
||
print(f"✅ -> {len(attachments)} {t('attachment(s) deleted.')}")
|
||
return 0
|
||
# Voir ET pouvoir répondre : une invite dont la sortie part dans un
|
||
# tube reste invisible — Python bufferise par blocs — pendant que le
|
||
# processus attend. Mesuré : l'utilisateur tape Entrée à l'aveugle.
|
||
if not config.report_only and can_ask():
|
||
if prompt(
|
||
config.database,
|
||
config.theme,
|
||
attachments,
|
||
views,
|
||
config.config,
|
||
):
|
||
return 0
|
||
return 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|