feat: revue de code, doc CODE_REVIEW, forfaits 2026, traduction LLM, providers avec modèle
Made-with: Cursor
This commit is contained in:
625
routes/glossary_routes.py
Normal file
625
routes/glossary_routes.py
Normal file
@@ -0,0 +1,625 @@
|
||||
"""
|
||||
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.",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user