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
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:
@@ -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.")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user