fix(glossaries): dedup by (user_id, name) instead of (user_id, template_id)
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 3m1s

Le groupement par template_id etait faux sur la prod :
- Les doublons historiques ont template_id=NULL (crees avant la migration)
- Deux glossaires 'Finance - FR->Anglais' et 'Finance - FR->Multilingue'
  partagent le meme template_id mais DOIVENT etre conserves separement.

Changements :
- Groupement par (user_id, name) -> c'est ce que l'utilisateur voit dans l'UI
  et la definition reelle d'un doublon.
- Les glossaires multilingues ('-> Multilingue') ont un nom distinct des
  versions '-> Anglais' : ils ne sont jamais fusionnes (preserve par design).
- Fallback automatique si la colonne template_id est absente du schema
  (dev DB) : warning + requete sans la colonne, aucun crash.
- Suppression du flag --allow-missing-template-id devenu inutile.
- Nettoyage des imports ORM inutiles (text brut uniquement, plus rapide).
This commit is contained in:
Sepehr
2026-06-03 21:48:53 +02:00
parent cd32a42b1a
commit 8f55e3d9aa
3 changed files with 147 additions and 200 deletions

View File

@@ -3,11 +3,15 @@
Supprime les glossaires dupliqués en se basant sur un fichier JSON de backup
produit par `scripts/backup_duplicate_glossaries.py`.
⚠️ DESTRUCTIF. Par défaut, demande confirmation interactive avant chaque
⚠️ DESTRUCTIF. Par défaut, demande confirmation interactive avant la
suppression. Utiliser --yes pour les exécutions automatisées.
Pour chaque groupe dans le JSON, le glossaire listé dans "keep" est conservé,
ceux listés dans "duplicates" sont supprimés (ainsi que leurs termes via cascade).
Pour chaque groupe (user_id, name) dans le JSON, le glossaire listé dans "keep"
est conservé, ceux listés dans "duplicates" sont supprimés (ainsi que leurs
termes via cascade).
Les glossaires multilingues (« → Multilingue ») ont un nom distinct et ne
peuvent pas être inclus dans un groupe de doublons : ils sont préservés.
Usage:
# Dry-run (relecture) :
@@ -19,16 +23,18 @@ Usage:
# Sans confirmation (CI / cron) :
python scripts/delete_duplicate_glossaries.py backups/glossary_duplicates_xxx.json --yes
# Re-génère le backup à la volée (si --input absent) :
# Re-génère le backup à la volée (si BACKUP_JSON absent) :
DATABASE_URL=... python scripts/delete_duplicate_glossaries.py --user <USER_ID> --dry-run
# Back-up automatique avant suppression (recommandé) :
python scripts/delete_duplicate_glossaries.py --user <USER_ID> --yes
# Backup auto + suppression :
DATABASE_URL=... python scripts/delete_duplicate_glossaries.py --user <USER_ID> --yes
"""
import argparse
import json
import logging
import os
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
@@ -36,10 +42,9 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from sqlalchemy import inspect, text
from sqlalchemy import text
from database.connection import get_sync_session, sync_engine
from database.models import Glossary, User
from database.connection import get_sync_session
logging.basicConfig(
level=logging.INFO,
@@ -67,9 +72,6 @@ def validate_ids(session, backup: dict) -> tuple[list[dict], list[str], list[str
Returns (valid_groups, errors, warnings).
- errors: bloquants (mismatch owner / id introuvable)
- warnings: informatifs (user parent absent — données orphelines)
Utilise du SQL brut pour ne pas dépendre du mapping ORM (qui planterait
si la colonne `template_id` n'existe pas dans le schéma).
"""
errors: list[str] = []
warnings: list[str] = []
@@ -104,7 +106,7 @@ def validate_ids(session, backup: dict) -> tuple[list[dict], list[str], list[str
def print_preview(valid_groups: list[dict]) -> tuple[int, int]:
"""Print what would be deleted. Returns (total_dupes, total_user_groups)."""
"""Print what would be deleted. Returns (total_dupes, total_terms)."""
total_dupes = 0
total_terms = 0
@@ -112,50 +114,33 @@ def print_preview(valid_groups: list[dict]) -> tuple[int, int]:
logger.info("Aperçu de la suppression")
logger.info("=" * 78)
by_template: dict[str, int] = {}
for g in valid_groups:
tid = g.get("template_id") or "(no template)"
by_template[tid] = by_template.get(tid, 0) + g["duplicates_count"]
total_dupes += g["duplicates_count"]
total_terms += sum(len(d.get("terms", [])) for d in g["duplicates"])
for d in g["duplicates"]:
total_terms += d.get("terms_count", len(d.get("terms", [])))
logger.info("Groupes à traiter : %d", len(valid_groups))
logger.info("Glossaires à supprimer : %d", total_dupes)
logger.info("Termes concernés (estim.) : %d", total_terms)
logger.info("Groupes à traiter : %d", len(valid_groups))
logger.info("Glossaires à supprimer : %d", total_dupes)
logger.info("Termes concernés : %d", total_terms)
logger.info("")
logger.info("Détail par template :")
for tid, count in sorted(by_template.items()):
logger.info(" %-14s %d doublon(s) à supprimer", tid, count)
logger.info("Détail par nom :")
for g in valid_groups:
logger.info(" '%s' %d doublon(s)", g["name"], g["duplicates_count"])
logger.info("=" * 78)
return total_dupes, total_terms
def delete_group(session, group: dict) -> tuple[int, int]:
"""Delete the duplicates of a single group. Returns (glossaries_deleted, terms_deleted).
Utilise SQL brut pour ne pas dépendre du mapping ORM (qui planterait
si la colonne `template_id` n'existe pas dans le schéma).
"""
"""Delete the duplicates of a single group. Returns (glossaries_deleted, terms_deleted)."""
deleted = 0
terms_deleted = 0
for dup in group["duplicates"]:
# 1. Compter les termes (avant suppression, pour les logs).
term_count = session.execute(
text("SELECT COUNT(*) FROM glossary_terms WHERE glossary_id = :gid"),
{"gid": dup["id"]},
).scalar() or 0
# 2. Récupérer le template_id pour le log (best effort).
try:
tpl = session.execute(
text("SELECT template_id FROM glossaries WHERE id = :gid"),
{"gid": dup["id"]},
).scalar()
except Exception:
tpl = None
# 3. Supprimer d'abord les termes (FK), puis le glossaire.
try:
session.execute(
text("DELETE FROM glossary_terms WHERE glossary_id = :gid"),
@@ -172,10 +157,7 @@ def delete_group(session, group: dict) -> tuple[int, int]:
deleted += 1
terms_deleted += term_count
logger.info(
" 🗑️ Supprimé id=%s (template=%s, %d termes)",
dup["id"], tpl, term_count,
)
logger.info(" 🗑️ Supprimé id=%s (%d termes)", dup["id"], term_count)
return deleted, terms_deleted
@@ -206,13 +188,13 @@ def perform_deletion(backup: dict, dry_run: bool) -> int:
if total_dupes == 0:
return 0
# Commit par user pour limiter l'impact d'une erreur partielle (F6).
# Commit par groupe (user + name) pour limiter l'impact d'une erreur partielle.
grand_deleted = 0
grand_terms = 0
for group in valid_groups:
user_id = group["user_id"]
tid = group.get("template_id")
logger.info("👤 user=%s template=%s — suppression…", user_id, tid)
name = group["name"]
logger.info("👤 user=%s name=%s — suppression…", user_id, name)
try:
deleted, terms = delete_group(session, group)
session.commit()
@@ -220,7 +202,7 @@ def perform_deletion(backup: dict, dry_run: bool) -> int:
grand_terms += terms
except Exception as e:
session.rollback()
logger.error("❌ Échec pour user=%s template=%s : %s", user_id, tid, e)
logger.error("❌ Échec pour user=%s name=%s : %s", user_id, name, e)
logger.error(" Transaction annulée pour ce groupe, on continue.")
logger.info("=" * 78)
@@ -241,10 +223,8 @@ def confirm(prompt: str) -> bool:
return answer in ("oui", "o", "yes", "y")
def regenerate_backup(user_id: str | None, allow_missing_template_id: bool = False) -> Path:
def regenerate_backup(user_id: str | None) -> Path:
"""Run the backup script as a subprocess to get a fresh JSON."""
import subprocess
timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = ROOT / "backups" / f"glossary_duplicates_{timestamp}.json"
cmd = [
@@ -253,11 +233,9 @@ def regenerate_backup(user_id: str | None, allow_missing_template_id: bool = Fal
]
if user_id:
cmd += ["--user", user_id]
if allow_missing_template_id:
cmd += ["--allow-missing-template-id"]
cmd += ["--output", str(out_path)]
logger.info("🔄 Génération d'un backup frais : %s", " ".join(cmd))
res = subprocess.run(cmd, env=__import__("os").environ.copy())
res = subprocess.run(cmd, env=os.environ.copy())
if res.returncode != 0:
logger.error("❌ Échec de la génération du backup (code=%d).", res.returncode)
sys.exit(2)
@@ -279,11 +257,6 @@ def main() -> int:
metavar="USER_ID",
help="Génère un backup frais limité à cet utilisateur (utilisé si BACKUP_JSON absent).",
)
parser.add_argument(
"--allow-missing-template-id",
action="store_true",
help="Transmis au script de backup si le schéma DB n'a pas la colonne template_id.",
)
parser.add_argument(
"--dry-run",
action="store_true",
@@ -300,7 +273,7 @@ def main() -> int:
if args.input:
backup_path = Path(args.input)
elif args.user:
backup_path = regenerate_backup(args.user, args.allow_missing_template_id)
backup_path = regenerate_backup(args.user)
else:
parser.error("Fournissez un BACKUP_JSON ou bien --user USER_ID.")