Files
office_translator/routes/glossary_routes.py
sepehr c66252bed4
Some checks failed
Deploy to Production / Build and Deploy (push) Failing after 1m30s
feat: mark glossary templates as multilingual — support 11 target languages
Templates enriched by enrich_glossary_templates.py already contain
translations for de, es, it, pt, nl, ru, ja, ko, zh, ar, fa (including
Persian). But they were labeled FR→EN, causing incorrect filtering and
warnings when translating to other languages.

Changes:
- index.json: set target_lang='multi' for all 8 templates
- GlossarySelector: treat target_language='multi' as compatible with
  any target language (no false warnings, auto-select works)
- GlossarySelector: display '🌐 MULTILINGUE' badge instead of EN flag
- glossary_routes: default target_language to 'multi' instead of 'en'
- Migration: detect existing multilingual glossaries in DB (5+ keys in
  translations JSON) and set their target_language to 'multi'

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 22:32:27 +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 "en",
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_data.get("source_lang", "fr"),
target_language=template_data.get("target_lang", "en"),
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)}",
},
)