Files
office_translator/routes/glossary_routes.py
2026-03-07 11:42:58 +01:00

626 lines
20 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,
"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,
"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,
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,
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": "Une erreur est survenue lors de la création du glossaire.",
},
)
@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,
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": "Une erreur est survenue lors de la récupération des glossaires.",
},
)
@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": "Format d'identifiant de glossaire invalide.",
},
)
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": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
},
)
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": "Une erreur est survenue.",
},
)
@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": "Format d'identifiant de glossaire invalide.",
},
)
# 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": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
},
)
old_name = glossary.name
if body.name is not None:
glossary.name = body.name
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,
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": "Une erreur est survenue lors de la mise à jour.",
},
)
@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": "Format d'identifiant de glossaire invalide.",
},
)
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": "Glossaire introuvable ou vous n'avez pas accès à cette ressource.",
},
)
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": "Une erreur est survenue lors de la suppression.",
},
)
@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": "Les templates de glossaires ne sont pas disponibles.",
},
)
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": "Une erreur est survenue lors de la récupération des 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": "Les templates de glossaires ne sont pas disponibles.",
},
)
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}' introuvable. Templates disponibles: {', '.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"Le fichier de template '{template_file}' est introuvable.",
},
)
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"Le template contient {len(terms)} termes, ce qui dépasse la limite de {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,
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", ""),
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}")
return JSONResponse(
status_code=500,
content={
"error": "IMPORT_ERROR",
"message": "Une erreur est survenue lors de l'import du template.",
},
)