""" 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, "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, 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", 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.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"), 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)}", }, )