626 lines
20 KiB
Python
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.",
|
|
},
|
|
)
|