Files
office_translator/routes/glossary_routes.py
sepehr a79ce0fc9b
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 2m27s
fix: comprehensive glossary system audit — 6 critical fixes
Full audit found 18 issues across backend, frontend, and data files.
Root cause: target_language had no single source of truth — 'en' was
hardcoded as default in 6+ places while templates are actually multilingual.

Fixes applied:
- routes/glossary_routes.py: list_glossaries() fallback 'en' → 'multi'
- routes/glossary_routes.py: import reads target_lang from index.json
  (source of truth) instead of template file
- data/glossaries/*.json: all 8 template files target_lang 'en' → 'multi'
- services/glossary_service.py: get_glossary_terms() now returns
  target_language field (was missing entirely)
- schemas/glossary_schemas.py: all defaults 'en' → 'multi'
  (GlossaryCreate, GlossaryResponse, GlossaryListItem)
- useTranslationConfig.ts: only reset glossary on sourceLang change,
  not targetLang (multilingual glossaries work with any target)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 23:14:41 +02:00

643 lines
21 KiB
Python

"""
Glossary CRUD routes for Pro users
Story 3.9: Glossaires - Endpoint CRUD
"""
import json
import logging
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import JSONResponse
from sqlalchemy.orm import joinedload
from routes.deps import require_pro_user, ProUser
from services.auth_service import verify_token, get_user_by_id
from database.connection import get_sync_session
from database.models import Glossary, GlossaryTerm
from schemas.glossary_schemas import (
GlossaryCreate,
GlossaryUpdate,
GlossaryResponse,
GlossaryListItem,
GlossaryListResponse,
GlossaryDetailResponse,
)
logger = logging.getLogger(__name__)
GLOSSARIES_DIR = Path(__file__).parent.parent / "data/glossaries"
router = APIRouter(prefix="/api/v1/glossaries", tags=["Glossaries v1"])
# Maximum number of terms per glossary
MAX_TERMS_PER_GLOSSARY = 500
# Default pagination
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 100
def _format_term(term: GlossaryTerm) -> dict:
"""Format a GlossaryTerm for JSON response."""
return {
"id": term.id,
"source": term.source,
"target": term.target,
"translations": term.translations or {},
"created_at": term.created_at.isoformat() if term.created_at else None,
}
def _format_glossary(glossary: Glossary) -> dict:
"""Format a Glossary for JSON response."""
return {
"id": glossary.id,
"name": glossary.name,
"source_language": glossary.source_language,
"target_language": getattr(glossary, "target_language", "multi") or "multi",
"terms": [_format_term(t) for t in glossary.terms] if glossary.terms else [],
"created_at": glossary.created_at.isoformat() if glossary.created_at else None,
"updated_at": glossary.updated_at.isoformat() if glossary.updated_at else None,
}
@router.post(
"",
response_model=GlossaryDetailResponse,
status_code=201,
summary="Créer un glossaire",
description="""
Crée un nouveau glossaire avec une liste de termes source→cible.
**Restriction:** Uniquement disponible pour les utilisateurs Pro.
**Exemple de requête:**
```json
{
"name": "Glossaire Technique FR-EN",
"terms": [
{"source": "serveur", "target": "server"},
{"source": "base de données", "target": "database"}
]
}
```
""",
)
async def create_glossary(
body: GlossaryCreate,
user: ProUser = Depends(require_pro_user),
):
"""Create a new glossary for the authenticated Pro user."""
# Validate max terms
if len(body.terms) > MAX_TERMS_PER_GLOSSARY:
return JSONResponse(
status_code=400,
content={
"error": "TERMS_LIMIT_EXCEEDED",
"message": f"Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary allowed.",
},
)
try:
with get_sync_session() as session:
glossary = Glossary(
user_id=user.id,
name=body.name,
source_language=body.source_language,
target_language=body.target_language,
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
for term_data in body.terms:
term = GlossaryTerm(
glossary=glossary,
source=term_data.source,
target=term_data.target,
translations=term_data.translations or {},
created_at=datetime.now(timezone.utc),
)
session.add(term)
session.add(glossary)
session.commit()
session.refresh(glossary)
logger.info(
f"Glossary created: id={glossary.id}, user_id={user.id}, "
f"name={glossary.name}, terms_count={len(body.terms)}"
)
return JSONResponse(
status_code=201,
content={
"data": _format_glossary(glossary),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to create glossary for user {user.id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "An error occurred while creating the glossary.",
},
)
@router.get(
"",
response_model=GlossaryListResponse,
summary="Lister les glossaires",
description="Retourne la liste paginée des glossaires de l'utilisateur.",
)
async def list_glossaries(
page: int = Query(1, ge=1, description="Page number"),
per_page: int = Query(
DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE, description="Items per page"
),
user: ProUser = Depends(require_pro_user),
):
"""List all glossaries for the authenticated Pro user with pagination."""
try:
with get_sync_session() as session:
# Get total count
total_count = (
session.query(Glossary).filter(Glossary.user_id == user.id).count()
)
# Get paginated results with eager loading of terms (fixes N+1)
offset = (page - 1) * per_page
glossaries = (
session.query(Glossary)
.options(joinedload(Glossary.terms))
.filter(Glossary.user_id == user.id)
.order_by(Glossary.created_at.desc())
.offset(offset)
.limit(per_page)
.all()
)
items = [
GlossaryListItem(
id=g.id,
name=g.name,
source_language=g.source_language or "fr",
target_language=getattr(g, "target_language", None) or "multi",
terms_count=len(g.terms) if g.terms else 0,
created_at=g.created_at,
)
for g in glossaries
]
total_pages = (total_count + per_page - 1) // per_page
return JSONResponse(
status_code=200,
content={
"data": [item.model_dump(mode="json") for item in items],
"meta": {
"total": total_count,
"page": page,
"per_page": per_page,
"total_pages": total_pages,
},
},
)
except Exception as e:
logger.error(f"Failed to list glossaries for user {user.id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "An error occurred while retrieving glossaries.",
},
)
@router.get(
"/{glossary_id}",
response_model=GlossaryDetailResponse,
summary="Détail d'un glossaire",
description="Retourne les détails d'un glossaire avec tous ses termes.",
)
async def get_glossary(
glossary_id: str,
user: ProUser = Depends(require_pro_user),
):
"""Get a specific glossary by ID."""
# Validate UUID format
try:
import uuid
uuid.UUID(glossary_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_GLOSSARY_ID",
"message": "Invalid glossary ID format.",
},
)
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.options(joinedload(Glossary.terms))
.filter(Glossary.id == glossary_id, Glossary.user_id == user.id)
.first()
)
if not glossary:
return JSONResponse(
status_code=404,
content={
"error": "GLOSSARY_NOT_FOUND",
"message": "Glossary not found or you do not have access to this resource.",
},
)
return JSONResponse(
status_code=200,
content={
"data": _format_glossary(glossary),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to get glossary {glossary_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "An error occurred.",
},
)
@router.patch(
"/{glossary_id}",
response_model=GlossaryDetailResponse,
summary="Mettre à jour un glossaire",
description="Met à jour le nom et/ou les termes d'un glossaire existant.",
)
async def update_glossary(
glossary_id: str,
body: GlossaryUpdate,
user: ProUser = Depends(require_pro_user),
):
"""Update a glossary's name and/or terms."""
# Validate UUID format
try:
import uuid
uuid.UUID(glossary_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_GLOSSARY_ID",
"message": "Invalid glossary ID format.",
},
)
# Validate max terms if provided
if body.terms is not None and len(body.terms) > MAX_TERMS_PER_GLOSSARY:
return JSONResponse(
status_code=400,
content={
"error": "TERMS_LIMIT_EXCEEDED",
"message": f"Maximum {MAX_TERMS_PER_GLOSSARY} terms per glossary allowed.",
},
)
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.options(joinedload(Glossary.terms))
.filter(Glossary.id == glossary_id, Glossary.user_id == user.id)
.first()
)
if not glossary:
return JSONResponse(
status_code=404,
content={
"error": "GLOSSARY_NOT_FOUND",
"message": "Glossary not found or you do not have access to this resource.",
},
)
old_name = glossary.name
if body.name is not None:
glossary.name = body.name
if body.source_language is not None:
glossary.source_language = body.source_language
if body.target_language is not None:
glossary.target_language = body.target_language
if body.terms is not None:
# Delete existing terms
session.query(GlossaryTerm).filter(
GlossaryTerm.glossary_id == glossary.id
).delete()
# Add new terms
for term_data in body.terms:
term = GlossaryTerm(
glossary_id=glossary.id,
source=term_data.source,
target=term_data.target,
translations=term_data.translations or {},
created_at=datetime.now(timezone.utc),
)
session.add(term)
glossary.updated_at = datetime.now(timezone.utc)
session.commit()
session.refresh(glossary)
logger.info(
f"Glossary updated: id={glossary.id}, user_id={user.id}, "
f"old_name={old_name}, new_name={glossary.name}"
)
return JSONResponse(
status_code=200,
content={
"data": _format_glossary(glossary),
"meta": {},
},
)
except Exception as e:
logger.error(f"Failed to update glossary {glossary_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "An error occurred while updating.",
},
)
@router.delete(
"/{glossary_id}",
status_code=204,
summary="Supprimer un glossaire",
description="Supprime un glossaire et tous ses termes associés.",
)
async def delete_glossary(
glossary_id: str,
user: ProUser = Depends(require_pro_user),
):
"""Delete a glossary and all its terms."""
# Validate UUID format
try:
import uuid
uuid.UUID(glossary_id)
except ValueError:
return JSONResponse(
status_code=400,
content={
"error": "INVALID_GLOSSARY_ID",
"message": "Invalid glossary ID format.",
},
)
try:
with get_sync_session() as session:
glossary = (
session.query(Glossary)
.filter(Glossary.id == glossary_id, Glossary.user_id == user.id)
.first()
)
if not glossary:
return JSONResponse(
status_code=404,
content={
"error": "GLOSSARY_NOT_FOUND",
"message": "Glossary not found or you do not have access to this resource.",
},
)
glossary_name = glossary.name
session.delete(glossary)
session.commit()
logger.info(
f"Glossary deleted: id={glossary_id}, user_id={user.id}, "
f"name={glossary_name}"
)
return JSONResponse(
status_code=204,
content=None,
)
except Exception as e:
logger.error(f"Failed to delete glossary {glossary_id}: {e}")
return JSONResponse(
status_code=500,
content={
"error": "DATABASE_ERROR",
"message": "An error occurred while deleting.",
},
)
@router.get(
"/templates/list",
summary="Lister les templates de glossaires",
description="""
Retourne la liste des glossaires pré-définis disponibles (templates).
Ces templates couvrent différents domaines : juridique, technologie, finance, médical, marketing, RH, scientifique, e-commerce.
Utilisez ensuite `POST /glossaries/import` pour importer un template dans votre compte.
""",
)
async def list_glossary_templates():
"""List all available glossary templates."""
try:
index_path = GLOSSARIES_DIR / "index.json"
if not index_path.exists():
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATES_NOT_FOUND",
"message": "Glossary templates are not available.",
},
)
with open(index_path, "r", encoding="utf-8") as f:
index_data = json.load(f)
templates = []
for category_id, category_data in index_data.get("categories", {}).items():
templates.append({
"id": category_id,
"name": category_data.get("name", category_id),
"description": category_data.get("description", ""),
"source_lang": category_data.get("source_lang", "fr"),
"target_lang": category_data.get("target_lang", "en"),
"terms_count": category_data.get("terms_count", 0),
"file": category_data.get("file", f"{category_id}_fr_en.json"),
})
return JSONResponse(
status_code=200,
content={
"data": templates,
"meta": {
"total": len(templates),
},
},
)
except Exception as e:
logger.error(f"Failed to list glossary templates: {e}")
return JSONResponse(
status_code=500,
content={
"error": "TEMPLATES_ERROR",
"message": "An error occurred while retrieving templates.",
},
)
@router.post(
"/import",
response_model=GlossaryDetailResponse,
status_code=201,
summary="Importer un template de glossaire",
description="""
Importe un glossaire pré-défini dans votre compte.
**Paramètres:**
- `template_id`: L'identifiant du template (ex: "legal", "tech", "finance", "medical", "marketing", "hr", "scientific", "ecommerce")
- `name` (optionnel): Nom personnalisé pour le glossaire. Si non fourni, le nom du template sera utilisé.
**Exemple:**
```json
{
"template_id": "legal",
"name": "Mon glossaire juridique"
}
```
""",
)
async def import_glossary_template(
template_id: str = Query(..., description="ID du template à importer"),
name: Optional[str] = Query(None, description="Nom personnalisé pour le glossaire"),
user: ProUser = Depends(require_pro_user),
):
"""Import a pre-built glossary template into the user's account."""
try:
index_path = GLOSSARIES_DIR / "index.json"
if not index_path.exists():
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATES_NOT_FOUND",
"message": "Glossary templates are not available.",
},
)
with open(index_path, "r", encoding="utf-8") as f:
index_data = json.load(f)
categories = index_data.get("categories", {})
if template_id not in categories:
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATE_NOT_FOUND",
"message": f"Template '{template_id}' not found. Available templates: {', '.join(categories.keys())}",
},
)
template_info = categories[template_id]
template_file = template_info.get("file", f"{template_id}_fr_en.json")
template_path = GLOSSARIES_DIR / template_file
if not template_path.exists():
return JSONResponse(
status_code=404,
content={
"error": "TEMPLATE_FILE_NOT_FOUND",
"message": f"Template file '{template_file}' not found.",
},
)
with open(template_path, "r", encoding="utf-8") as f:
template_data = json.load(f)
terms = template_data.get("terms", [])
if len(terms) > MAX_TERMS_PER_GLOSSARY:
return JSONResponse(
status_code=400,
content={
"error": "TERMS_LIMIT_EXCEEDED",
"message": f"The template contains {len(terms)} terms, exceeding the limit of {MAX_TERMS_PER_GLOSSARY}.",
},
)
glossary_name = name or template_data.get("name", template_info.get("name", template_id))
with get_sync_session() as session:
glossary = Glossary(
user_id=user.id,
name=glossary_name,
source_language=template_info.get("source_lang", template_data.get("source_lang", "fr")),
target_language=template_info.get("target_lang", template_data.get("target_lang", "multi")),
created_at=datetime.now(timezone.utc),
updated_at=datetime.now(timezone.utc),
)
for term_data in terms:
term = GlossaryTerm(
glossary=glossary,
source=term_data.get("source", ""),
target=term_data.get("target", ""),
translations=term_data.get("translations") or None,
created_at=datetime.now(timezone.utc),
)
session.add(term)
session.add(glossary)
session.commit()
session.refresh(glossary)
logger.info(
f"Glossary template imported: id={glossary.id}, user_id={user.id}, "
f"template={template_id}, name={glossary_name}, terms_count={len(terms)}"
)
return JSONResponse(
status_code=201,
content={
"data": _format_glossary(glossary),
"meta": {
"template_id": template_id,
"imported_terms": len(terms),
},
},
)
except Exception as e:
logger.error(f"Failed to import glossary template {template_id}: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"error": "IMPORT_ERROR",
"message": f"Import failed: {str(e)}",
},
)