Nuevos cambios en el backend
This commit is contained in:
+17
-8
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,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
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user