Nuevos cambios en el backend

This commit is contained in:
Mireya Cueto Garrido
2026-05-19 10:21:34 +02:00
parent 02da44fb30
commit ba2507918b
20 changed files with 494 additions and 55 deletions
+17 -8
View File
@@ -1,17 +1,26 @@
Ahora mismo el flujo es backend/API, sin frontend: 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, 0.- El profesor se registra (POST /auth/register) o inicia sesión (POST /auth/login) y obtiene un token JWT.
tipos de preguntas, número de preguntas, puntuación, penalización y dificultad.
2.- Genera un prompt con POST /exam/prompts/{template_id} La API devuelve un prompt estructurado para 1.- Crea una plantilla con POST /exam/templates (requiere Authorization: Bearer <token>).
pedirle al LLM preguntas en JSON válido. 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.- 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.1.- Generación automática: POST /exam/generate.
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. 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.
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/xml/{template_id} para Moodle XML.
GET /exam/export/txt/{template_id} para texto plano. GET /exam/export/txt/{template_id} para texto plano.
@@ -19,4 +28,4 @@ GET /exam/export/json/{template_id} para JSON.
(El XML generado se importa manualmente en Moodle.) (El XML generado se importa manualmente en Moodle.)
En resumen: configurar plantilla → generar prompt o llamar al LLM → guardar preguntas → exportar Moodle XML. En resumen: registrarse → configurar plantilla → generar prompt o llamar al LLM → guardar preguntas → ver historial → exportar Moodle XML.
+43 -10
View File
@@ -38,26 +38,37 @@ El archivo de entorno debe estar en `backend/.env`.
Variables principales: 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. - `DATABASE_URL`: conexión PostgreSQL usada por el backend.
- `LLM_API_KEY`: clave del proveedor LLM. - `LLM_API_KEY`: clave del proveedor LLM.
- `LLM_BASE_URL`: endpoint compatible con OpenAI. - `LLM_BASE_URL`: endpoint compatible con OpenAI.
- `LLM_MODEL`: modelo usado para generar preguntas. - `LLM_MODEL`: modelo usado para generar preguntas.
- `ALLOWED_ORIGINS`: orígenes permitidos por CORS. - `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 ```http
X-API-Key: change-me-in-production Authorization: Bearer <access_token>
```
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 ## Flujo de Usuario
1. Crear una plantilla de examen. 1. Registrarse o iniciar sesión.
2. Generar un prompt guiado para el LLM. 2. Crear una plantilla de examen (queda asociada al usuario).
3. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT. 3. Generar un prompt guiado para el LLM.
4. Guardar las preguntas validadas en PostgreSQL. 4. Generar preguntas automáticamente con el LLM o parsear una salida externa en JSON/TXT.
5. Exportar el examen a Moodle XML, TXT o JSON. 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 ## Endpoints
@@ -65,13 +76,33 @@ X-API-Key: change-me-in-production
Comprueba que la API está levantada. 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` `POST /exam/templates`
Crea una plantilla con materia, nivel educativo, tipos de pregunta, puntuación, penalización y dificultad. Crea una plantilla con materia, nivel educativo, tipos de pregunta, puntuación, penalización y dificultad.
`GET /exam/templates` `GET /exam/templates`
Lista las plantillas creadas. Lista las plantillas del usuario autenticado.
`GET /exam/templates/{template_id}` `GET /exam/templates/{template_id}`
@@ -103,7 +134,9 @@ Exporta las preguntas en JSON.
## Seguridad ## 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. - Rate limiting por cliente.
- Límite de tamaño de petición. - Límite de tamaño de petición.
- Validación de entrada con Pydantic. - Validación de entrada con Pydantic.
+22 -3
View File
@@ -1,14 +1,33 @@
# --- Aplicación ---
APP_NAME=GenExamenes IA APP_NAME=GenExamenes IA
ENVIRONMENT=local 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 DATABASE_URL=postgresql+psycopg://genexamenes:genexamenes@db:5432/genexamenes
# --- CORS (orígenes del frontend, separados por coma) ---
ALLOWED_ORIGINS=http://localhost:3000 ALLOWED_ORIGINS=http://localhost:3000
# --- Rate limiting y tamaño de petición ---
RATE_LIMIT_REQUESTS=60 RATE_LIMIT_REQUESTS=60
RATE_LIMIT_WINDOW_SECONDS=60 RATE_LIMIT_WINDOW_SECONDS=60
MAX_REQUEST_BYTES=1048576 MAX_REQUEST_BYTES=1048576
# OpenAI-compatible chat completions endpoint. # --- JWT (login email/contraseña y sesión tras Google) ---
LLM_API_KEY= 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_BASE_URL=https://api.openai.com/v1
LLM_MODEL=gpt-4o-mini LLM_MODEL=gpt-4o-mini
LLM_TIMEOUT_SECONDS=60 LLM_TIMEOUT_SECONDS=60
+43
View File
@@ -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)
+8 -3
View File
@@ -4,7 +4,9 @@ from typing import Annotated
from fastapi import APIRouter, Depends, Response from fastapi import APIRouter, Depends, Response
from app.api.dependencies import get_exam_service 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.exam import ExportFormat
from app.models.user import User
from app.services.exam_service import ExamService from app.services.exam_service import ExamService
router = APIRouter(prefix="/export", tags=["exports"]) router = APIRouter(prefix="/export", tags=["exports"])
@@ -13,25 +15,28 @@ router = APIRouter(prefix="/export", tags=["exports"])
@router.get("/xml/{template_id}") @router.get("/xml/{template_id}")
def export_xml( def export_xml(
template_id: uuid.UUID, template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> Response: ) -> 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") return Response(content=export.content, media_type="application/xml")
@router.get("/txt/{template_id}") @router.get("/txt/{template_id}")
def export_txt( def export_txt(
template_id: uuid.UUID, template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> Response: ) -> 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") return Response(content=export.content, media_type="text/plain; charset=utf-8")
@router.get("/json/{template_id}") @router.get("/json/{template_id}")
def export_json( def export_json(
template_id: uuid.UUID, template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> Response: ) -> 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") return Response(content=export.content, media_type="application/json")
+13 -3
View File
@@ -4,6 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from app.api.dependencies import get_exam_service, get_llm_client 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 ( from app.schemas.exam import (
BuildPromptRequest, BuildPromptRequest,
GenerateExamRequest, GenerateExamRequest,
@@ -21,23 +23,31 @@ router = APIRouter(tags=["generation"])
def build_prompt( def build_prompt(
template_id: uuid.UUID, template_id: uuid.UUID,
payload: BuildPromptRequest, payload: BuildPromptRequest,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> PromptResponse: ) -> 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) @router.post("/generate", response_model=ParsedQuestionsResponse)
async def generate_exam( async def generate_exam(
payload: GenerateExamRequest, payload: GenerateExamRequest,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
llm_client: Annotated[LLMClient, Depends(get_llm_client)], llm_client: Annotated[LLMClient, Depends(get_llm_client)],
) -> ParsedQuestionsResponse: ) -> 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) @router.post("/parse", response_model=ParsedQuestionsResponse)
def parse_ai_output( def parse_ai_output(
payload: ParseRequest, payload: ParseRequest,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> ParsedQuestionsResponse: ) -> ParsedQuestionsResponse:
return service.parse_and_persist(payload) return service.parse_and_persist(current_user.id, payload)
+19
View File
@@ -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)
+11 -4
View File
@@ -4,6 +4,8 @@ from typing import Annotated
from fastapi import APIRouter, Depends, status from fastapi import APIRouter, Depends, status
from app.api.dependencies import get_exam_service 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.schemas.exam import ExamTemplateCreate, ExamTemplateRead
from app.services.exam_service import ExamService 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) @router.post("", response_model=ExamTemplateRead, status_code=status.HTTP_201_CREATED)
def create_template( def create_template(
payload: ExamTemplateCreate, payload: ExamTemplateCreate,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> ExamTemplateRead: ) -> ExamTemplateRead:
return service.create_template(payload) return service.create_template(current_user.id, payload)
@router.get("", response_model=list[ExamTemplateRead]) @router.get("", response_model=list[ExamTemplateRead])
def list_templates(service: Annotated[ExamService, Depends(get_exam_service)]) -> list[ExamTemplateRead]: def list_templates(
return service.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) @router.get("/{template_id}", response_model=ExamTemplateRead)
def get_template( def get_template(
template_id: uuid.UUID, template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)], service: Annotated[ExamService, Depends(get_exam_service)],
) -> ExamTemplateRead: ) -> ExamTemplateRead:
return service.get_template(template_id) return service.get_template(current_user.id, template_id)
+21
View File
@@ -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))
+4
View File
@@ -18,6 +18,10 @@ class Settings(BaseSettings):
llm_base_url: str = "https://api.openai.com/v1" llm_base_url: str = "https://api.openai.com/v1"
llm_model: str = "gpt-4o-mini" llm_model: str = "gpt-4o-mini"
llm_timeout_seconds: int = Field(default=60, ge=5) 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( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
+15
View File
@@ -26,6 +26,21 @@ class ParseError(AppError):
super().__init__(message=message, status_code=422, code="parse_error") 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]: def error_payload(code: str, message: str, details: object | None = None) -> dict[str, object]:
payload: dict[str, object] = {"error": {"code": code, "message": message}} payload: dict[str, object] = {"error": {"code": code, "message": message}}
if details is not None: if details is not None:
+1 -1
View File
@@ -1,6 +1,6 @@
from app.db.base import Base from app.db.base import Base
from app.db.session import engine 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: def init_db() -> None:
+6 -6
View File
@@ -4,11 +4,10 @@ from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware 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.config import get_settings
from app.core.errors import register_exception_handlers from app.core.errors import register_exception_handlers
from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware
from app.core.security import require_api_key
from app.db.init_db import init_db from app.db.init_db import init_db
@@ -35,10 +34,11 @@ def create_app() -> FastAPI:
register_exception_handlers(app) register_exception_handlers(app)
app.include_router(health.router) app.include_router(health.router)
protected = [Depends(require_api_key)] app.include_router(auth.router)
app.include_router(templates.router, prefix="/exam", dependencies=protected) app.include_router(templates.router, prefix="/exam")
app.include_router(generation.router, prefix="/exam", dependencies=protected) app.include_router(generation.router, prefix="/exam")
app.include_router(exports.router, prefix="/exam", dependencies=protected) app.include_router(exports.router, prefix="/exam")
app.include_router(history.router, prefix="/exam")
return app return app
+8
View File
@@ -39,6 +39,12 @@ class ExamTemplate(Base):
__tablename__ = "exam_templates" __tablename__ = "exam_templates"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) 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) title: Mapped[str] = mapped_column(String(200), nullable=False)
subject: 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) 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()) 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()) 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( questions: Mapped[list["Question"]] = relationship(
back_populates="template", back_populates="template",
cascade="all, delete-orphan", cascade="all, delete-orphan",
+25
View File
@@ -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,
)
+15
View File
@@ -125,3 +125,18 @@ class ExportResponse(BaseModel):
template_id: uuid.UUID template_id: uuid.UUID
format: ExportFormat format: ExportFormat
content: str 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)
+35
View File
@@ -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"
+132
View File
@@ -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)
+48 -14
View File
@@ -3,10 +3,11 @@ import uuid
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.orm import Session 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.core.security import clean_text
from app.models.exam import ExamTemplate, ExportFormat, ExportJob, ExportStatus, Question from app.models.exam import ExamTemplate, ExportFormat, ExportJob, ExportStatus, Question
from app.schemas.exam import ( from app.schemas.exam import (
ExamHistoryItem,
ExamTemplateCreate, ExamTemplateCreate,
ExamTemplateRead, ExamTemplateRead,
ExportResponse, ExportResponse,
@@ -35,8 +36,9 @@ class ExamService:
self.parser = parser or AIQuestionParser() self.parser = parser or AIQuestionParser()
self.exporter = exporter or MoodleXMLExporter() 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( template = ExamTemplate(
user_id=user_id,
title=clean_text(payload.title, max_length=200), title=clean_text(payload.title, max_length=200),
subject=clean_text(payload.subject, max_length=200), subject=clean_text(payload.subject, max_length=200),
educational_level=clean_text(payload.educational_level, max_length=120), educational_level=clean_text(payload.educational_level, max_length=120),
@@ -49,37 +51,67 @@ class ExamService:
self.db.refresh(template) self.db.refresh(template)
return self._template_read(template) return self._template_read(template)
def list_templates(self) -> list[ExamTemplateRead]: def list_templates(self, user_id: uuid.UUID) -> list[ExamTemplateRead]:
templates = self.db.scalars(select(ExamTemplate).order_by(ExamTemplate.created_at.desc())).all() 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] return [self._template_read(template) for template in templates]
def get_template(self, template_id: uuid.UUID) -> ExamTemplateRead: def list_history(self, user_id: uuid.UUID) -> list[ExamHistoryItem]:
return self._template_read(self._get_template_or_404(template_id)) 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: def get_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplateRead:
template = self._get_template_or_404(template_id) 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) prompt = self.prompt_builder.build_prompt(template, topic_prompt)
return PromptResponse(template_id=template.id, prompt=prompt) return PromptResponse(template_id=template.id, prompt=prompt)
async def generate_with_llm( async def generate_with_llm(
self, self,
user_id: uuid.UUID,
template_id: uuid.UUID, template_id: uuid.UUID,
topic_prompt: str, topic_prompt: str,
llm_client: LLMClient, llm_client: LLMClient,
) -> ParsedQuestionsResponse: ) -> 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) prompt = self.prompt_builder.build_prompt(template, topic_prompt)
raw_output = await llm_client.generate(prompt) raw_output = await llm_client.generate(prompt)
questions = self.parser.parse_json(raw_output) questions = self.parser.parse_json(raw_output)
return self._persist_questions(template.id, questions) return self._persist_questions(template.id, questions)
def parse_and_persist(self, payload: ParseRequest) -> ParsedQuestionsResponse: def parse_and_persist(self, user_id: uuid.UUID, payload: ParseRequest) -> ParsedQuestionsResponse:
self._get_template_or_404(payload.template_id) self._get_user_template_or_404(user_id, payload.template_id)
questions = self.parser.parse(payload.raw_output, payload.input_format) questions = self.parser.parse(payload.raw_output, payload.input_format)
return self._persist_questions(payload.template_id, questions) return self._persist_questions(payload.template_id, questions)
def export(self, template_id: uuid.UUID, export_format: ExportFormat) -> ExportResponse: def export(self, user_id: uuid.UUID, template_id: uuid.UUID, export_format: ExportFormat) -> ExportResponse:
template = self._get_template_or_404(template_id) template = self._get_user_template_or_404(user_id, template_id)
questions = list(template.questions) questions = list(template.questions)
if not questions: if not questions:
raise NotFoundError("Template does not contain questions to export") 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]) 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) template = self.db.get(ExamTemplate, template_id)
if template is None: if template is None:
raise NotFoundError("Exam template not found") 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 return template
def _template_read(self, template: ExamTemplate) -> ExamTemplateRead: def _template_read(self, template: ExamTemplate) -> ExamTemplateRead:
+5
View File
@@ -3,7 +3,12 @@ uvicorn[standard]
SQLAlchemy SQLAlchemy
psycopg[binary] psycopg[binary]
pydantic-settings pydantic-settings
pydantic[email]
python-dotenv python-dotenv
httpx httpx
orjson orjson
passlib[bcrypt]
python-jose[cryptography]
google-auth
requests
pytest pytest