From ba2507918b4156f17d504fa5e3f6fe00d0979a39 Mon Sep 17 00:00:00 2001 From: Mireya Cueto Garrido Date: Tue, 19 May 2026 10:21:34 +0200 Subject: [PATCH] Nuevos cambios en el backend --- FlujoDeUsuario.txt | 31 ++++--- README.md | 53 +++++++++-- backend/.env.example | 25 ++++- backend/app/api/routes/auth.py | 43 +++++++++ backend/app/api/routes/exports.py | 11 ++- backend/app/api/routes/generation.py | 16 +++- backend/app/api/routes/history.py | 19 ++++ backend/app/api/routes/templates.py | 15 ++- backend/app/core/auth.py | 21 +++++ backend/app/core/config.py | 4 + backend/app/core/errors.py | 15 +++ backend/app/db/init_db.py | 2 +- backend/app/main.py | 12 +-- backend/app/models/exam.py | 8 ++ backend/app/models/user.py | 25 +++++ backend/app/schemas/exam.py | 15 +++ backend/app/schemas/user.py | 35 +++++++ backend/app/services/auth_service.py | 132 +++++++++++++++++++++++++++ backend/app/services/exam_service.py | 62 ++++++++++--- backend/requirements.txt | 5 + 20 files changed, 494 insertions(+), 55 deletions(-) create mode 100644 backend/app/api/routes/auth.py create mode 100644 backend/app/api/routes/history.py create mode 100644 backend/app/core/auth.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/schemas/user.py create mode 100644 backend/app/services/auth_service.py diff --git a/FlujoDeUsuario.txt b/FlujoDeUsuario.txt index 8f34a92..da16758 100644 --- a/FlujoDeUsuario.txt +++ b/FlujoDeUsuario.txt @@ -1,22 +1,31 @@ Ahora mismo el flujo es backend/API, sin frontend: -1.- El profesor crea una plantilla con POST /exam/templates Define título, materia, nivel educativo, -tipos de preguntas, número de preguntas, puntuación, penalización y dificultad. +0.- El profesor se registra (POST /auth/register) o inicia sesión (POST /auth/login) y obtiene un token JWT. -2.- Genera un prompt con POST /exam/prompts/{template_id} La API devuelve un prompt estructurado para -pedirle al LLM preguntas en JSON válido. +1.- Crea una plantilla con POST /exam/templates (requiere Authorization: Bearer ). + Define título, materia, nivel educativo, tipos de preguntas, puntuación, penalización y dificultad. + La plantilla queda guardada en base de datos asociada a su usuario. + +2.- Genera un prompt con POST /exam/prompts/{template_id}. + La API devuelve un prompt estructurado para pedirle al LLM preguntas en JSON válido. 3.- Hay dos caminos posibles: - 3.1.- Generación automática: POST /exam/generate La API llama al LLM configurado, parsea la respuesta y guarda las preguntas. - 3.2.- Carga manual: POST /exam/parse El profesor pega una salida de IA en json o txt, y la API la valida y guarda. + 3.1.- Generación automática: POST /exam/generate. + La API llama al LLM configurado, parsea la respuesta y guarda las preguntas. -4.- El profesor exporta el examen: + 3.2.- Carga manual: POST /exam/parse. + El profesor pega una salida de IA en json o txt, y la API la valida y guarda. -GET /exam/export/xml/{template_id} para Moodle XML. -GET /exam/export/txt/{template_id} para texto plano. -GET /exam/export/json/{template_id} para JSON. +4.- Consulta su historial con GET /exam/history. + Ve todos los exámenes que ha creado, cuántas preguntas tienen y cuándo exportó por última vez. + +5.- Exporta el examen: + + GET /exam/export/xml/{template_id} para Moodle XML. + GET /exam/export/txt/{template_id} para texto plano. + GET /exam/export/json/{template_id} para JSON. (El XML generado se importa manualmente en Moodle.) -En resumen: configurar plantilla → generar prompt o llamar al LLM → guardar preguntas → exportar Moodle XML. \ No newline at end of file +En resumen: registrarse → configurar plantilla → generar prompt o llamar al LLM → guardar preguntas → ver historial → exportar Moodle XML. diff --git a/README.md b/README.md index 9ac9cb4..498acec 100644 --- a/README.md +++ b/README.md @@ -38,26 +38,37 @@ El archivo de entorno debe estar en `backend/.env`. Variables principales: -- `API_KEY`: clave obligatoria para consumir las rutas protegidas. +- `JWT_SECRET_KEY`: secreto para firmar tokens JWT (mínimo 32 caracteres). +- `JWT_EXPIRE_MINUTES`: duración del token de acceso. +- `GOOGLE_CLIENT_ID`: Client ID de OAuth 2.0 en Google Cloud Console (para `/auth/google`). - `DATABASE_URL`: conexión PostgreSQL usada por el backend. - `LLM_API_KEY`: clave del proveedor LLM. - `LLM_BASE_URL`: endpoint compatible con OpenAI. - `LLM_MODEL`: modelo usado para generar preguntas. - `ALLOWED_ORIGINS`: orígenes permitidos por CORS. -Todas las rutas bajo `/exam` requieren la cabecera: +Todas las rutas bajo `/exam` requieren autenticación de usuario con: ```http -X-API-Key: change-me-in-production +Authorization: Bearer +``` + +Si ya tenías una base de datos creada antes de añadir usuarios, recrea el volumen: + +```bash +docker compose down -v +docker compose up --build ``` ## Flujo de Usuario -1. Crear una plantilla de examen. -2. Generar un prompt guiado para el LLM. -3. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT. -4. Guardar las preguntas validadas en PostgreSQL. -5. Exportar el examen a Moodle XML, TXT o JSON. +1. Registrarse o iniciar sesión. +2. Crear una plantilla de examen (queda asociada al usuario). +3. Generar un prompt guiado para el LLM. +4. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT. +5. Guardar las preguntas validadas en PostgreSQL. +6. Consultar el historial de exámenes creados. +7. Exportar el examen a Moodle XML, TXT o JSON. ## Endpoints @@ -65,13 +76,33 @@ X-API-Key: change-me-in-production Comprueba que la API está levantada. +`POST /auth/register` + +Registra un usuario con email y contraseña. + +`POST /auth/login` + +Devuelve un token JWT para usar en las rutas protegidas. + +`POST /auth/google` + +Recibe el `id_token` de Google (Sign in with Google en el frontend), verifica la cuenta y devuelve el mismo JWT de la API. + +`GET /auth/me` + +Devuelve los datos del usuario autenticado. + +`GET /exam/history` + +Lista el historial de exámenes del usuario (plantillas, preguntas y exportaciones). + `POST /exam/templates` Crea una plantilla con materia, nivel educativo, tipos de pregunta, puntuación, penalización y dificultad. `GET /exam/templates` -Lista las plantillas creadas. +Lista las plantillas del usuario autenticado. `GET /exam/templates/{template_id}` @@ -103,7 +134,9 @@ Exporta las preguntas en JSON. ## Seguridad -- Autenticación por API key. +- Registro e inicio de sesión con contraseña hasheada (bcrypt). +- Autenticación JWT por usuario. +- Cada examen pertenece a un único usuario; no se puede acceder al de otro. - Rate limiting por cliente. - Límite de tamaño de petición. - Validación de entrada con Pydantic. diff --git a/backend/.env.example b/backend/.env.example index 173588e..8df96cc 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,14 +1,33 @@ +# --- Aplicación --- APP_NAME=GenExamenes IA ENVIRONMENT=local -API_KEY=change-me-in-production + +# Clave legacy (reservada; las rutas /exam usan JWT de usuario). +API_KEY=change-me-in-production-min-16-chars + +# --- Base de datos (Docker: host "db") --- DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes + +# --- CORS (orígenes del frontend, separados por coma) --- ALLOWED_ORIGINS=http://localhost:3000 + +# --- Rate limiting y tamaño de petición --- RATE_LIMIT_REQUESTS=60 RATE_LIMIT_WINDOW_SECONDS=60 MAX_REQUEST_BYTES=1048576 -# OpenAI-compatible chat completions endpoint. -LLM_API_KEY= +# --- JWT (login email/contraseña y sesión tras Google) --- +JWT_SECRET_KEY=change-me-use-a-long-random-secret-key-at-least-32-chars +JWT_ALGORITHM=HS256 +JWT_EXPIRE_MINUTES=1440 + +# --- Google Sign-In --- +# Client ID de OAuth 2.0 (tipo "Aplicación web") en Google Cloud Console. +# El frontend obtiene un id_token con Google Identity Services y lo envía a POST /auth/google. +GOOGLE_CLIENT_ID=123456789012-abcdefghijklmnopqrstuvwxyz123456.apps.googleusercontent.com + +# --- LLM (OpenAI o compatible) --- +LLM_API_KEY=sk-your-openai-api-key LLM_BASE_URL=https://api.openai.com/v1 LLM_MODEL=gpt-4o-mini LLM_TIMEOUT_SECONDS=60 diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py new file mode 100644 index 0000000..079bc68 --- /dev/null +++ b/backend/app/api/routes/auth.py @@ -0,0 +1,43 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends, status + +from app.core.auth import get_current_user +from app.models.user import User +from app.schemas.user import GoogleLoginRequest, TokenResponse, UserLogin, UserRead, UserRegister +from app.services.auth_service import AuthService, get_auth_service + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED) +def register( + payload: UserRegister, + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> UserRead: + return auth_service.register(payload) + + +@router.post("/login", response_model=TokenResponse) +def login( + payload: UserLogin, + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> TokenResponse: + user = auth_service.authenticate(payload) + token = auth_service.create_access_token(user.id) + return TokenResponse(access_token=token) + + +@router.post("/google", response_model=TokenResponse) +def login_with_google( + payload: GoogleLoginRequest, + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> TokenResponse: + user = auth_service.login_with_google(payload.id_token) + token = auth_service.create_access_token(user.id) + return TokenResponse(access_token=token) + + +@router.get("/me", response_model=UserRead) +def get_me(current_user: Annotated[User, Depends(get_current_user)]) -> UserRead: + return UserRead.model_validate(current_user) diff --git a/backend/app/api/routes/exports.py b/backend/app/api/routes/exports.py index 7b6ca68..a098001 100644 --- a/backend/app/api/routes/exports.py +++ b/backend/app/api/routes/exports.py @@ -4,7 +4,9 @@ from typing import Annotated from fastapi import APIRouter, Depends, Response from app.api.dependencies import get_exam_service +from app.core.auth import get_current_user from app.models.exam import ExportFormat +from app.models.user import User from app.services.exam_service import ExamService router = APIRouter(prefix="/export", tags=["exports"]) @@ -13,25 +15,28 @@ router = APIRouter(prefix="/export", tags=["exports"]) @router.get("/xml/{template_id}") def export_xml( template_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> Response: - export = service.export(template_id, ExportFormat.XML) + export = service.export(current_user.id, template_id, ExportFormat.XML) return Response(content=export.content, media_type="application/xml") @router.get("/txt/{template_id}") def export_txt( template_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> Response: - export = service.export(template_id, ExportFormat.TXT) + export = service.export(current_user.id, template_id, ExportFormat.TXT) return Response(content=export.content, media_type="text/plain; charset=utf-8") @router.get("/json/{template_id}") def export_json( template_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> Response: - export = service.export(template_id, ExportFormat.JSON) + export = service.export(current_user.id, template_id, ExportFormat.JSON) return Response(content=export.content, media_type="application/json") diff --git a/backend/app/api/routes/generation.py b/backend/app/api/routes/generation.py index 64cf87d..81dab5a 100644 --- a/backend/app/api/routes/generation.py +++ b/backend/app/api/routes/generation.py @@ -4,6 +4,8 @@ from typing import Annotated from fastapi import APIRouter, Depends from app.api.dependencies import get_exam_service, get_llm_client +from app.core.auth import get_current_user +from app.models.user import User from app.schemas.exam import ( BuildPromptRequest, GenerateExamRequest, @@ -21,23 +23,31 @@ router = APIRouter(tags=["generation"]) def build_prompt( template_id: uuid.UUID, payload: BuildPromptRequest, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> PromptResponse: - return service.build_prompt(template_id, payload.topic_prompt) + return service.build_prompt(current_user.id, template_id, payload.topic_prompt) @router.post("/generate", response_model=ParsedQuestionsResponse) async def generate_exam( payload: GenerateExamRequest, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], llm_client: Annotated[LLMClient, Depends(get_llm_client)], ) -> ParsedQuestionsResponse: - return await service.generate_with_llm(payload.template_id, payload.topic_prompt, llm_client) + return await service.generate_with_llm( + current_user.id, + payload.template_id, + payload.topic_prompt, + llm_client, + ) @router.post("/parse", response_model=ParsedQuestionsResponse) def parse_ai_output( payload: ParseRequest, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> ParsedQuestionsResponse: - return service.parse_and_persist(payload) + return service.parse_and_persist(current_user.id, payload) diff --git a/backend/app/api/routes/history.py b/backend/app/api/routes/history.py new file mode 100644 index 0000000..3a78169 --- /dev/null +++ b/backend/app/api/routes/history.py @@ -0,0 +1,19 @@ +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.core.auth import get_current_user +from app.models.user import User +from app.schemas.exam import ExamHistoryItem +from app.services.exam_service import ExamService +from app.api.dependencies import get_exam_service + +router = APIRouter(prefix="/history", tags=["history"]) + + +@router.get("", response_model=list[ExamHistoryItem]) +def list_exam_history( + current_user: Annotated[User, Depends(get_current_user)], + service: Annotated[ExamService, Depends(get_exam_service)], +) -> list[ExamHistoryItem]: + return service.list_history(current_user.id) diff --git a/backend/app/api/routes/templates.py b/backend/app/api/routes/templates.py index 2d1dd72..af152d2 100644 --- a/backend/app/api/routes/templates.py +++ b/backend/app/api/routes/templates.py @@ -4,6 +4,8 @@ from typing import Annotated from fastapi import APIRouter, Depends, status from app.api.dependencies import get_exam_service +from app.core.auth import get_current_user +from app.models.user import User from app.schemas.exam import ExamTemplateCreate, ExamTemplateRead from app.services.exam_service import ExamService @@ -13,19 +15,24 @@ router = APIRouter(prefix="/templates", tags=["templates"]) @router.post("", response_model=ExamTemplateRead, status_code=status.HTTP_201_CREATED) def create_template( payload: ExamTemplateCreate, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> ExamTemplateRead: - return service.create_template(payload) + return service.create_template(current_user.id, payload) @router.get("", response_model=list[ExamTemplateRead]) -def list_templates(service: Annotated[ExamService, Depends(get_exam_service)]) -> list[ExamTemplateRead]: - return service.list_templates() +def list_templates( + current_user: Annotated[User, Depends(get_current_user)], + service: Annotated[ExamService, Depends(get_exam_service)], +) -> list[ExamTemplateRead]: + return service.list_templates(current_user.id) @router.get("/{template_id}", response_model=ExamTemplateRead) def get_template( template_id: uuid.UUID, + current_user: Annotated[User, Depends(get_current_user)], service: Annotated[ExamService, Depends(get_exam_service)], ) -> ExamTemplateRead: - return service.get_template(template_id) + return service.get_template(current_user.id, template_id) diff --git a/backend/app/core/auth.py b/backend/app/core/auth.py new file mode 100644 index 0000000..e7ab3ca --- /dev/null +++ b/backend/app/core/auth.py @@ -0,0 +1,21 @@ +from typing import Annotated + +from fastapi import Depends +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from sqlalchemy.orm import Session + +from app.core.errors import UnauthorizedError +from app.db.session import get_db +from app.models.user import User +from app.services.auth_service import AuthService, get_auth_service + +bearer_scheme = HTTPBearer(auto_error=False) + + +def get_current_user( + credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(bearer_scheme)], + auth_service: Annotated[AuthService, Depends(get_auth_service)], +) -> User: + if credentials is None or credentials.scheme.lower() != "bearer": + raise UnauthorizedError("Missing or invalid authorization token") + return auth_service.get_user_by_id(auth_service.decode_user_id(credentials.credentials)) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 348b46e..a6693d4 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -18,6 +18,10 @@ class Settings(BaseSettings): llm_base_url: str = "https://api.openai.com/v1" llm_model: str = "gpt-4o-mini" llm_timeout_seconds: int = Field(default=60, ge=5) + jwt_secret_key: str = Field(min_length=32) + jwt_algorithm: str = "HS256" + jwt_expire_minutes: int = Field(default=60 * 24, ge=5) + google_client_id: str | None = None model_config = SettingsConfigDict( env_file=".env", diff --git a/backend/app/core/errors.py b/backend/app/core/errors.py index a4f902b..6afce1a 100644 --- a/backend/app/core/errors.py +++ b/backend/app/core/errors.py @@ -26,6 +26,21 @@ class ParseError(AppError): super().__init__(message=message, status_code=422, code="parse_error") +class ConflictError(AppError): + def __init__(self, message: str = "Resource already exists") -> None: + super().__init__(message=message, status_code=409, code="conflict") + + +class ForbiddenError(AppError): + def __init__(self, message: str = "Access denied") -> None: + super().__init__(message=message, status_code=403, code="forbidden") + + +class UnauthorizedError(AppError): + def __init__(self, message: str = "Unauthorized") -> None: + super().__init__(message=message, status_code=401, code="unauthorized") + + def error_payload(code: str, message: str, details: object | None = None) -> dict[str, object]: payload: dict[str, object] = {"error": {"code": code, "message": message}} if details is not None: diff --git a/backend/app/db/init_db.py b/backend/app/db/init_db.py index 94d6ead..da37a46 100644 --- a/backend/app/db/init_db.py +++ b/backend/app/db/init_db.py @@ -1,6 +1,6 @@ from app.db.base import Base from app.db.session import engine -from app.models import exam # noqa: F401 +from app.models import exam, user # noqa: F401 def init_db() -> None: diff --git a/backend/app/main.py b/backend/app/main.py index 1b2bfd7..bc6a8dd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -4,11 +4,10 @@ from collections.abc import AsyncIterator from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware -from app.api.routes import exports, generation, health, templates +from app.api.routes import auth, exports, generation, health, history, templates from app.core.config import get_settings from app.core.errors import register_exception_handlers from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware -from app.core.security import require_api_key from app.db.init_db import init_db @@ -35,10 +34,11 @@ def create_app() -> FastAPI: register_exception_handlers(app) app.include_router(health.router) - protected = [Depends(require_api_key)] - app.include_router(templates.router, prefix="/exam", dependencies=protected) - app.include_router(generation.router, prefix="/exam", dependencies=protected) - app.include_router(exports.router, prefix="/exam", dependencies=protected) + app.include_router(auth.router) + app.include_router(templates.router, prefix="/exam") + app.include_router(generation.router, prefix="/exam") + app.include_router(exports.router, prefix="/exam") + app.include_router(history.router, prefix="/exam") return app diff --git a/backend/app/models/exam.py b/backend/app/models/exam.py index ad987fc..a6717c9 100644 --- a/backend/app/models/exam.py +++ b/backend/app/models/exam.py @@ -39,6 +39,12 @@ class ExamTemplate(Base): __tablename__ = "exam_templates" id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + ) title: Mapped[str] = mapped_column(String(200), nullable=False) subject: Mapped[str] = mapped_column(String(200), nullable=False) educational_level: Mapped[str] = mapped_column(String(120), nullable=False) @@ -48,6 +54,8 @@ class ExamTemplate(Base): created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + owner: Mapped["User"] = relationship(back_populates="exam_templates") + questions: Mapped[list["Question"]] = relationship( back_populates="template", cascade="all, delete-orphan", diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..36240c0 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,25 @@ +import uuid +from datetime import datetime + +from sqlalchemy import DateTime, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.db.base import Base + + +class User(Base): + __tablename__ = "users" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False, index=True) + password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True) + google_sub: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True) + full_name: Mapped[str | None] = mapped_column(String(200), nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now()) + + exam_templates: Mapped[list["ExamTemplate"]] = relationship( + back_populates="owner", + cascade="all, delete-orphan", + passive_deletes=True, + ) diff --git a/backend/app/schemas/exam.py b/backend/app/schemas/exam.py index d476cf6..ef2ad15 100644 --- a/backend/app/schemas/exam.py +++ b/backend/app/schemas/exam.py @@ -125,3 +125,18 @@ class ExportResponse(BaseModel): template_id: uuid.UUID format: ExportFormat content: str + + +class ExamHistoryItem(BaseModel): + id: uuid.UUID + title: str + subject: str + educational_level: str + language: str + question_count: int + export_count: int + last_export_at: datetime | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..7d35545 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,35 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, EmailStr, Field + + +class UserRegister(BaseModel): + email: EmailStr + password: str = Field(min_length=8, max_length=128) + full_name: str | None = Field(default=None, max_length=200) + + +class UserLogin(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=128) + + +class UserRead(BaseModel): + id: uuid.UUID + email: EmailStr + full_name: str | None + created_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class GoogleLoginRequest(BaseModel): + """ID token obtenido en el frontend con Google Identity Services (Sign in with Google).""" + + id_token: str = Field(min_length=10, max_length=8_000) + + +class TokenResponse(BaseModel): + access_token: str + token_type: str = "bearer" diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..0f509b5 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,132 @@ +import uuid +from datetime import UTC, datetime, timedelta +from typing import Annotated + +from fastapi import Depends +from jose import JWTError, jwt +from passlib.context import CryptContext +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.core.config import Settings, get_settings +from google.auth.transport import requests as google_requests +from google.oauth2 import id_token as google_id_token + +from app.core.errors import AppError, ConflictError, NotFoundError, UnauthorizedError +from app.core.security import clean_text +from app.db.session import get_db +from app.models.user import User +from app.schemas.user import UserLogin, UserRead, UserRegister + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +class AuthService: + def __init__(self, db: Session, settings: Settings) -> None: + self.db = db + self.settings = settings + + def register(self, payload: UserRegister) -> UserRead: + email = payload.email.lower().strip() + existing = self.db.scalar(select(User).where(User.email == email)) + if existing is not None: + raise ConflictError("Email is already registered") + + user = User( + email=email, + password_hash=pwd_context.hash(payload.password), + full_name=clean_text(payload.full_name, max_length=200) if payload.full_name else None, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return UserRead.model_validate(user) + + def authenticate(self, payload: UserLogin) -> User: + email = payload.email.lower().strip() + user = self.db.scalar(select(User).where(User.email == email)) + if user is None or user.password_hash is None: + raise UnauthorizedError("Invalid email or password") + if not pwd_context.verify(payload.password, user.password_hash): + raise UnauthorizedError("Invalid email or password") + return user + + def login_with_google(self, id_token_value: str) -> User: + if not self.settings.google_client_id: + raise AppError( + message="Google login is not configured", + status_code=503, + code="google_not_configured", + ) + + try: + idinfo = google_id_token.verify_oauth2_token( + id_token_value, + google_requests.Request(), + self.settings.google_client_id, + ) + except ValueError as exc: + raise UnauthorizedError("Invalid Google ID token") from exc + + google_sub = idinfo.get("sub") + email = (idinfo.get("email") or "").lower().strip() + if not google_sub or not email: + raise UnauthorizedError("Google token does not include required user information") + if not idinfo.get("email_verified", False): + raise UnauthorizedError("Google email is not verified") + + user = self.db.scalar(select(User).where(User.google_sub == google_sub)) + if user is not None: + return user + + user = self.db.scalar(select(User).where(User.email == email)) + if user is not None: + if user.google_sub is not None and user.google_sub != google_sub: + raise ConflictError("Email is linked to another Google account") + user.google_sub = google_sub + if not user.full_name and idinfo.get("name"): + user.full_name = clean_text(idinfo["name"], max_length=200) + self.db.commit() + self.db.refresh(user) + return user + + user = User( + email=email, + password_hash=None, + google_sub=google_sub, + full_name=clean_text(idinfo["name"], max_length=200) if idinfo.get("name") else None, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + return user + + def get_user_by_id(self, user_id: uuid.UUID) -> User: + user = self.db.get(User, user_id) + if user is None: + raise NotFoundError("User not found") + return user + + def create_access_token(self, user_id: uuid.UUID) -> str: + expire = datetime.now(UTC) + timedelta(minutes=self.settings.jwt_expire_minutes) + payload = {"sub": str(user_id), "exp": expire} + return jwt.encode(payload, self.settings.jwt_secret_key, algorithm=self.settings.jwt_algorithm) + + def decode_user_id(self, token: str) -> uuid.UUID: + try: + payload = jwt.decode( + token, + self.settings.jwt_secret_key, + algorithms=[self.settings.jwt_algorithm], + ) + user_id = uuid.UUID(payload["sub"]) + except (JWTError, KeyError, ValueError) as exc: + raise UnauthorizedError("Invalid or expired token") from exc + return user_id + + +def get_auth_service( + db: Annotated[Session, Depends(get_db)], + settings: Annotated[Settings, Depends(get_settings)], +) -> AuthService: + return AuthService(db, settings) diff --git a/backend/app/services/exam_service.py b/backend/app/services/exam_service.py index 49a74de..42d6114 100644 --- a/backend/app/services/exam_service.py +++ b/backend/app/services/exam_service.py @@ -3,10 +3,11 @@ import uuid from sqlalchemy import select from sqlalchemy.orm import Session -from app.core.errors import NotFoundError +from app.core.errors import ForbiddenError, NotFoundError from app.core.security import clean_text from app.models.exam import ExamTemplate, ExportFormat, ExportJob, ExportStatus, Question from app.schemas.exam import ( + ExamHistoryItem, ExamTemplateCreate, ExamTemplateRead, ExportResponse, @@ -35,8 +36,9 @@ class ExamService: self.parser = parser or AIQuestionParser() self.exporter = exporter or MoodleXMLExporter() - def create_template(self, payload: ExamTemplateCreate) -> ExamTemplateRead: + def create_template(self, user_id: uuid.UUID, payload: ExamTemplateCreate) -> ExamTemplateRead: template = ExamTemplate( + user_id=user_id, title=clean_text(payload.title, max_length=200), subject=clean_text(payload.subject, max_length=200), educational_level=clean_text(payload.educational_level, max_length=120), @@ -49,37 +51,67 @@ class ExamService: self.db.refresh(template) return self._template_read(template) - def list_templates(self) -> list[ExamTemplateRead]: - templates = self.db.scalars(select(ExamTemplate).order_by(ExamTemplate.created_at.desc())).all() + def list_templates(self, user_id: uuid.UUID) -> list[ExamTemplateRead]: + templates = self.db.scalars( + select(ExamTemplate) + .where(ExamTemplate.user_id == user_id) + .order_by(ExamTemplate.created_at.desc()) + ).all() return [self._template_read(template) for template in templates] - def get_template(self, template_id: uuid.UUID) -> ExamTemplateRead: - return self._template_read(self._get_template_or_404(template_id)) + def list_history(self, user_id: uuid.UUID) -> list[ExamHistoryItem]: + templates = self.db.scalars( + select(ExamTemplate) + .where(ExamTemplate.user_id == user_id) + .order_by(ExamTemplate.updated_at.desc()) + ).all() + history: list[ExamHistoryItem] = [] + for template in templates: + export_jobs = sorted(template.export_jobs, key=lambda job: job.created_at, reverse=True) + history.append( + ExamHistoryItem( + id=template.id, + title=template.title, + subject=template.subject, + educational_level=template.educational_level, + language=template.language, + question_count=len(template.questions), + export_count=len(export_jobs), + last_export_at=export_jobs[0].created_at if export_jobs else None, + created_at=template.created_at, + updated_at=template.updated_at, + ) + ) + return history - def build_prompt(self, template_id: uuid.UUID, topic_prompt: str) -> PromptResponse: - template = self._get_template_or_404(template_id) + def get_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplateRead: + return self._template_read(self._get_user_template_or_404(user_id, template_id)) + + def build_prompt(self, user_id: uuid.UUID, template_id: uuid.UUID, topic_prompt: str) -> PromptResponse: + template = self._get_user_template_or_404(user_id, template_id) prompt = self.prompt_builder.build_prompt(template, topic_prompt) return PromptResponse(template_id=template.id, prompt=prompt) async def generate_with_llm( self, + user_id: uuid.UUID, template_id: uuid.UUID, topic_prompt: str, llm_client: LLMClient, ) -> ParsedQuestionsResponse: - template = self._get_template_or_404(template_id) + template = self._get_user_template_or_404(user_id, template_id) prompt = self.prompt_builder.build_prompt(template, topic_prompt) raw_output = await llm_client.generate(prompt) questions = self.parser.parse_json(raw_output) return self._persist_questions(template.id, questions) - def parse_and_persist(self, payload: ParseRequest) -> ParsedQuestionsResponse: - self._get_template_or_404(payload.template_id) + def parse_and_persist(self, user_id: uuid.UUID, payload: ParseRequest) -> ParsedQuestionsResponse: + self._get_user_template_or_404(user_id, payload.template_id) questions = self.parser.parse(payload.raw_output, payload.input_format) return self._persist_questions(payload.template_id, questions) - def export(self, template_id: uuid.UUID, export_format: ExportFormat) -> ExportResponse: - template = self._get_template_or_404(template_id) + def export(self, user_id: uuid.UUID, template_id: uuid.UUID, export_format: ExportFormat) -> ExportResponse: + template = self._get_user_template_or_404(user_id, template_id) questions = list(template.questions) if not questions: raise NotFoundError("Template does not contain questions to export") @@ -126,10 +158,12 @@ class ExamService: return ParsedQuestionsResponse(questions=[QuestionRead.model_validate(question) for question in persisted]) - def _get_template_or_404(self, template_id: uuid.UUID) -> ExamTemplate: + def _get_user_template_or_404(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate: template = self.db.get(ExamTemplate, template_id) if template is None: raise NotFoundError("Exam template not found") + if template.user_id != user_id: + raise ForbiddenError("You do not have access to this exam template") return template def _template_read(self, template: ExamTemplate) -> ExamTemplateRead: diff --git a/backend/requirements.txt b/backend/requirements.txt index 6ad34b4..3076625 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,7 +3,12 @@ uvicorn[standard] SQLAlchemy psycopg[binary] pydantic-settings +pydantic[email] python-dotenv httpx orjson +passlib[bcrypt] +python-jose[cryptography] +google-auth +requests pytest