Add materials, exam images, storage quota, and API guide

Upload documents for AI context, exam images for Moodle questions, per-template storage limits, embedded images in XML export, and GUIA_API_Y_FLUJO.md with full endpoint documentation.
This commit is contained in:
Mireya Cueto Garrido
2026-06-01 10:30:40 +02:00
parent ba2507918b
commit 7bc27da33a
29 changed files with 1892 additions and 59 deletions
+32 -2
View File
@@ -6,11 +6,41 @@ from sqlalchemy.orm import Session
from app.core.config import Settings, get_settings
from app.db.session import get_db
from app.services.exam_service import ExamService
from app.services.image_service import ImageService
from app.services.llm import LLMClient
from app.services.material_service import MaterialService
from app.services.storage_quota import StorageQuotaService
def get_exam_service(db: Annotated[Session, Depends(get_db)]) -> ExamService:
return ExamService(db)
def get_storage_quota_service(
db: Annotated[Session, Depends(get_db)],
settings: Annotated[Settings, Depends(get_settings)],
) -> StorageQuotaService:
return StorageQuotaService(db, settings)
def get_material_service(
db: Annotated[Session, Depends(get_db)],
settings: Annotated[Settings, Depends(get_settings)],
storage_quota: Annotated[StorageQuotaService, Depends(get_storage_quota_service)],
) -> MaterialService:
return MaterialService(db, settings, storage_quota)
def get_image_service(
db: Annotated[Session, Depends(get_db)],
settings: Annotated[Settings, Depends(get_settings)],
storage_quota: Annotated[StorageQuotaService, Depends(get_storage_quota_service)],
) -> ImageService:
return ImageService(db, settings, storage_quota)
def get_exam_service(
db: Annotated[Session, Depends(get_db)],
material_service: Annotated[MaterialService, Depends(get_material_service)],
image_service: Annotated[ImageService, Depends(get_image_service)],
) -> ExamService:
return ExamService(db, material_service=material_service, image_service=image_service)
def get_llm_client(settings: Annotated[Settings, Depends(get_settings)]) -> LLMClient:
+7 -1
View File
@@ -26,7 +26,12 @@ def build_prompt(
current_user: Annotated[User, Depends(get_current_user)],
service: Annotated[ExamService, Depends(get_exam_service)],
) -> PromptResponse:
return service.build_prompt(current_user.id, template_id, payload.topic_prompt)
return service.build_prompt(
current_user.id,
template_id,
payload.topic_prompt,
payload.material_ids,
)
@router.post("/generate", response_model=ParsedQuestionsResponse)
@@ -41,6 +46,7 @@ async def generate_exam(
payload.template_id,
payload.topic_prompt,
llm_client,
payload.material_ids,
)
+73
View File
@@ -0,0 +1,73 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
from fastapi.responses import FileResponse
from app.api.dependencies import get_exam_service, get_image_service
from app.core.auth import get_current_user
from app.models.user import User
from app.schemas.image import ExamImageRead, ExamImageUploadResponse
from app.services.exam_service import ExamService
from app.services.image_service import ImageService
router = APIRouter(tags=["images"])
@router.post(
"/templates/{template_id}/images",
response_model=ExamImageUploadResponse,
status_code=status.HTTP_201_CREATED,
)
def upload_exam_image(
template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
image_service: Annotated[ImageService, Depends(get_image_service)],
file: UploadFile = File(...),
caption: Annotated[str | None, Form()] = None,
) -> ExamImageUploadResponse:
template = exam_service.get_owned_template(current_user.id, template_id)
image = image_service.upload(template, file, caption=caption)
return ExamImageUploadResponse(
image=ExamImageRead.model_validate(image_service.to_read(image)),
message="Image uploaded successfully",
)
@router.get("/templates/{template_id}/images", response_model=list[ExamImageRead])
def list_exam_images(
template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
image_service: Annotated[ImageService, Depends(get_image_service)],
) -> list[ExamImageRead]:
exam_service.get_owned_template(current_user.id, template_id)
images = image_service.list_images(template_id)
return [ExamImageRead.model_validate(image_service.to_read(image)) for image in images]
@router.get("/images/{image_id}/content")
def get_exam_image_content(
image_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
image_service: Annotated[ImageService, Depends(get_image_service)],
) -> FileResponse:
image = image_service.get_image_for_user(current_user.id, image_id)
return FileResponse(
path=image.storage_path,
media_type=image.mime_type,
filename=image.original_filename,
)
@router.delete("/templates/{template_id}/images/{image_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_exam_image(
template_id: uuid.UUID,
image_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
image_service: Annotated[ImageService, Depends(get_image_service)],
) -> None:
template = exam_service.get_owned_template(current_user.id, template_id)
image_service.delete_image(template, image_id)
+55
View File
@@ -0,0 +1,55 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends, File, UploadFile, status
from app.api.dependencies import get_exam_service, get_material_service
from app.core.auth import get_current_user
from app.models.exam import MaterialStatus
from app.models.user import User
from app.schemas.material import ExamMaterialRead, ExamMaterialUploadResponse
from app.services.exam_service import ExamService
from app.services.material_service import MaterialService
router = APIRouter(prefix="/templates/{template_id}/materials", tags=["materials"])
@router.post("", response_model=ExamMaterialUploadResponse, status_code=status.HTTP_201_CREATED)
def upload_material(
template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
material_service: Annotated[MaterialService, Depends(get_material_service)],
file: UploadFile = File(...),
) -> ExamMaterialUploadResponse:
template = exam_service.get_owned_template(current_user.id, template_id)
material = material_service.upload(template, file)
message = (
"File uploaded and processed successfully"
if material.status == MaterialStatus.PROCESSED
else "File uploaded but text extraction failed"
)
return ExamMaterialUploadResponse(material=material, message=message)
@router.get("", response_model=list[ExamMaterialRead])
def list_materials(
template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
material_service: Annotated[MaterialService, Depends(get_material_service)],
) -> list[ExamMaterialRead]:
exam_service.get_owned_template(current_user.id, template_id)
return material_service.list_materials(template_id)
@router.delete("/{material_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_material(
template_id: uuid.UUID,
material_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
material_service: Annotated[MaterialService, Depends(get_material_service)],
) -> None:
template = exam_service.get_owned_template(current_user.id, template_id)
material_service.delete_material(template, material_id)
+27
View File
@@ -0,0 +1,27 @@
import uuid
from typing import Annotated
from fastapi import APIRouter, Depends
from app.api.dependencies import get_exam_service, get_image_service
from app.core.auth import get_current_user
from app.models.user import User
from app.schemas.exam import QuestionRead
from app.schemas.image import QuestionImageAttach
from app.services.exam_service import ExamService
from app.services.image_service import ImageService
router = APIRouter(prefix="/questions", tags=["questions"])
@router.patch("/{question_id}/image", response_model=QuestionRead)
def attach_image_to_question(
question_id: uuid.UUID,
payload: QuestionImageAttach,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
image_service: Annotated[ImageService, Depends(get_image_service)],
) -> QuestionRead:
question, template = exam_service.get_owned_question(current_user.id, question_id)
updated = image_service.attach_image_to_question(template, question, payload.image_id)
return exam_service.to_question_read(updated)
+14 -1
View File
@@ -3,11 +3,13 @@ from typing import Annotated
from fastapi import APIRouter, Depends, status
from app.api.dependencies import get_exam_service
from app.api.dependencies import get_exam_service, get_storage_quota_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.storage import TemplateStorageUsage
from app.services.exam_service import ExamService
from app.services.storage_quota import StorageQuotaService
router = APIRouter(prefix="/templates", tags=["templates"])
@@ -36,3 +38,14 @@ def get_template(
service: Annotated[ExamService, Depends(get_exam_service)],
) -> ExamTemplateRead:
return service.get_template(current_user.id, template_id)
@router.get("/{template_id}/storage", response_model=TemplateStorageUsage)
def get_template_storage_usage(
template_id: uuid.UUID,
current_user: Annotated[User, Depends(get_current_user)],
exam_service: Annotated[ExamService, Depends(get_exam_service)],
storage_quota: Annotated[StorageQuotaService, Depends(get_storage_quota_service)],
) -> TemplateStorageUsage:
exam_service.get_owned_template(current_user.id, template_id)
return TemplateStorageUsage.model_validate(storage_quota.get_usage_summary(template_id))
+11
View File
@@ -22,6 +22,17 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
jwt_expire_minutes: int = Field(default=60 * 24, ge=5)
google_client_id: str | None = None
upload_dir: str = "/app/uploads"
max_upload_bytes: int = Field(default=20_971_520, ge=1_024)
max_materials_per_template: int = Field(default=10, ge=1, le=50)
max_reference_chars: int = Field(default=12_000, ge=1_000, le=100_000)
max_image_bytes: int = Field(default=5_242_880, ge=1_024)
max_images_per_template: int = Field(default=20, ge=1, le=100)
max_storage_bytes_per_template: int = Field(
default=52_428_800,
ge=1_024,
description="Cupo total por examen (materiales + imágenes). Por defecto 50 MB.",
)
model_config = SettingsConfigDict(
env_file=".env",
+2 -2
View File
@@ -32,8 +32,8 @@ def clean_text(value: str, *, max_length: int = 8_000) -> str:
return cleaned
def sanitize_prompt_input(value: str) -> str:
cleaned = clean_text(value, max_length=4_000)
def sanitize_prompt_input(value: str, *, max_length: int = 4_000) -> str:
cleaned = clean_text(value, max_length=max_length)
return ROLE_INJECTION_HINTS.sub("[filtered instruction]", cleaned)
+5 -2
View File
@@ -4,7 +4,7 @@ from collections.abc import AsyncIterator
from fastapi import Depends, FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.api.routes import auth, exports, generation, health, history, templates
from app.api.routes import auth, exports, generation, health, history, images, materials, questions, templates
from app.core.config import get_settings
from app.core.errors import register_exception_handlers
from app.core.middleware import RateLimitMiddleware, RequestSizeLimitMiddleware
@@ -25,7 +25,7 @@ def create_app() -> FastAPI:
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "OPTIONS"],
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Authorization", "Content-Type", "X-API-Key"],
)
app.add_middleware(RequestSizeLimitMiddleware, settings=settings)
@@ -39,6 +39,9 @@ def create_app() -> FastAPI:
app.include_router(generation.router, prefix="/exam")
app.include_router(exports.router, prefix="/exam")
app.include_router(history.router, prefix="/exam")
app.include_router(materials.router, prefix="/exam")
app.include_router(images.router, prefix="/exam")
app.include_router(questions.router, prefix="/exam")
return app
+66
View File
@@ -35,6 +35,11 @@ class ExportFormat(str, enum.Enum):
JSON = "json"
class MaterialStatus(str, enum.Enum):
PROCESSED = "processed"
FAILED = "failed"
class ExamTemplate(Base):
__tablename__ = "exam_templates"
@@ -66,6 +71,16 @@ class ExamTemplate(Base):
cascade="all, delete-orphan",
passive_deletes=True,
)
materials: Mapped[list["ExamMaterial"]] = relationship(
back_populates="template",
cascade="all, delete-orphan",
passive_deletes=True,
)
images: Mapped[list["ExamImage"]] = relationship(
back_populates="template",
cascade="all, delete-orphan",
passive_deletes=True,
)
class Question(Base):
@@ -87,9 +102,16 @@ class Question(Base):
score: Mapped[float] = mapped_column(Float, nullable=False, default=1.0)
penalty: Mapped[float] = mapped_column(Float, nullable=False, default=0.0)
options: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
image_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True),
ForeignKey("exam_images.id", ondelete="SET NULL"),
nullable=True,
index=True,
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
template: Mapped[ExamTemplate] = relationship(back_populates="questions")
image: Mapped["ExamImage | None"] = relationship(back_populates="questions")
class ExportJob(Base):
@@ -108,3 +130,47 @@ class ExportJob(Base):
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
template: Mapped[ExamTemplate] = relationship(back_populates="export_jobs")
class ExamMaterial(Base):
__tablename__ = "exam_materials"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("exam_templates.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(120), nullable=False)
size_bytes: Mapped[int] = mapped_column(nullable=False)
storage_path: Mapped[str] = mapped_column(String(500), nullable=False)
extracted_text: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[MaterialStatus] = mapped_column(Enum(MaterialStatus), nullable=False)
error_message: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
template: Mapped[ExamTemplate] = relationship(back_populates="materials")
class ExamImage(Base):
__tablename__ = "exam_images"
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
template_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True),
ForeignKey("exam_templates.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
original_filename: Mapped[str] = mapped_column(String(255), nullable=False)
stored_filename: Mapped[str] = mapped_column(String(255), nullable=False)
mime_type: Mapped[str] = mapped_column(String(120), nullable=False)
size_bytes: Mapped[int] = mapped_column(nullable=False)
storage_path: Mapped[str] = mapped_column(String(500), nullable=False)
caption: Mapped[str | None] = mapped_column(String(500), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
template: Mapped[ExamTemplate] = relationship(back_populates="images")
questions: Mapped[list["Question"]] = relationship(back_populates="image")
+13
View File
@@ -65,6 +65,10 @@ class QuestionCreate(BaseModel):
correct_answers: list[str] = Field(min_length=1, max_length=20)
wrong_answers: list[str] = Field(default_factory=list, max_length=20)
matching_pairs: list[MatchingPair] = Field(default_factory=list, max_length=50)
image_id: uuid.UUID | None = Field(
default=None,
description="ID de imagen de la plantilla que debe mostrarse con la pregunta.",
)
difficulty: Difficulty = Difficulty.MEDIUM
score: float = Field(default=1.0, ge=0.0, le=100.0)
penalty: float = Field(default=0.0, ge=0.0, le=100.0)
@@ -91,6 +95,7 @@ class QuestionCreate(BaseModel):
class QuestionRead(QuestionCreate):
id: uuid.UUID
template_id: uuid.UUID
image_url: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
@@ -104,11 +109,19 @@ class PromptResponse(BaseModel):
class BuildPromptRequest(BaseModel):
topic_prompt: str = Field(min_length=5, max_length=4_000)
material_ids: list[uuid.UUID] | None = Field(
default=None,
description="IDs de materiales a incluir. Si no se indica, se usan todos los procesados.",
)
class GenerateExamRequest(BaseModel):
template_id: uuid.UUID
topic_prompt: str = Field(min_length=5, max_length=4_000)
material_ids: list[uuid.UUID] | None = Field(
default=None,
description="IDs de materiales a incluir. Si no se indica, se usan todos los procesados.",
)
class ParseRequest(BaseModel):
+30
View File
@@ -0,0 +1,30 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
class ExamImageRead(BaseModel):
id: uuid.UUID
template_id: uuid.UUID
original_filename: str
stored_filename: str
mime_type: str
size_bytes: int
caption: str | None
content_url: str
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ExamImageUploadResponse(BaseModel):
image: ExamImageRead
message: str = "Image uploaded successfully"
class QuestionImageAttach(BaseModel):
image_id: uuid.UUID | None = Field(
default=None,
description="ID de imagen de la plantilla. null para desvincular.",
)
+32
View File
@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field
from app.models.exam import MaterialStatus
class ExamMaterialRead(BaseModel):
id: uuid.UUID
template_id: uuid.UUID
original_filename: str
mime_type: str
size_bytes: int
status: MaterialStatus
error_message: str | None
text_preview: str | None = None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class ExamMaterialUploadResponse(BaseModel):
material: ExamMaterialRead
message: str = "File uploaded and processed successfully"
class MaterialIdsFilter(BaseModel):
material_ids: list[uuid.UUID] | None = Field(
default=None,
description="Si se indica, solo se usan estos materiales como contexto.",
)
+14
View File
@@ -0,0 +1,14 @@
import uuid
from pydantic import BaseModel
class TemplateStorageUsage(BaseModel):
template_id: uuid.UUID
used_bytes: int
limit_bytes: int
remaining_bytes: int
materials_bytes: int
images_bytes: int
used_mb: float
limit_mb: float
@@ -0,0 +1,74 @@
from pathlib import Path
from app.core.errors import AppError
SUPPORTED_EXTENSIONS = {
".pdf": "application/pdf",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".txt": "text/plain",
".md": "text/markdown",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
}
class DocumentExtractor:
def extract(self, file_path: Path, mime_type: str) -> str:
suffix = file_path.suffix.lower()
if mime_type == "application/pdf" or suffix == ".pdf":
return self._extract_pdf(file_path)
if (
mime_type == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
or suffix == ".docx"
):
return self._extract_docx(file_path)
if mime_type.startswith("text/") or suffix in {".txt", ".md"}:
return self._extract_text(file_path)
if mime_type.startswith("image/") or suffix in {".png", ".jpg", ".jpeg", ".webp"}:
return self._extract_image(file_path)
raise AppError(f"Unsupported file type: {mime_type}", status_code=415, code="unsupported_media")
def _extract_pdf(self, file_path: Path) -> str:
from pypdf import PdfReader
reader = PdfReader(str(file_path))
parts = [page.extract_text() or "" for page in reader.pages]
text = "\n".join(parts).strip()
if not text:
raise AppError("PDF does not contain extractable text", status_code=422, code="empty_extraction")
return text
def _extract_docx(self, file_path: Path) -> str:
from docx import Document
document = Document(str(file_path))
parts = [paragraph.text.strip() for paragraph in document.paragraphs if paragraph.text.strip()]
text = "\n".join(parts).strip()
if not text:
raise AppError("DOCX does not contain extractable text", status_code=422, code="empty_extraction")
return text
def _extract_text(self, file_path: Path) -> str:
text = file_path.read_text(encoding="utf-8", errors="ignore").strip()
if not text:
raise AppError("Text file is empty", status_code=422, code="empty_extraction")
return text
def _extract_image(self, file_path: Path) -> str:
try:
import pytesseract
from PIL import Image
except ImportError as exc:
raise AppError(
"Image OCR is not available on this server",
status_code=503,
code="ocr_unavailable",
) from exc
image = Image.open(file_path)
text = pytesseract.image_to_string(image, lang="spa+eng").strip()
if not text:
raise AppError("Image does not contain recognizable text", status_code=422, code="empty_extraction")
return text
+77 -5
View File
@@ -17,7 +17,9 @@ from app.schemas.exam import (
QuestionCreate,
QuestionRead,
)
from app.services.image_service import ImageService
from app.services.llm import LLMClient
from app.services.material_service import MaterialService
from app.services.moodle_exporter import MoodleXMLExporter
from app.services.parser import AIQuestionParser
from app.services.prompt_builder import PromptBuilder
@@ -30,11 +32,15 @@ class ExamService:
prompt_builder: PromptBuilder | None = None,
parser: AIQuestionParser | None = None,
exporter: MoodleXMLExporter | None = None,
material_service: MaterialService | None = None,
image_service: ImageService | None = None,
) -> None:
self.db = db
self.prompt_builder = prompt_builder or PromptBuilder()
self.parser = parser or AIQuestionParser()
self.exporter = exporter or MoodleXMLExporter()
self.material_service = material_service
self.image_service = image_service
def create_template(self, user_id: uuid.UUID, payload: ExamTemplateCreate) -> ExamTemplateRead:
template = ExamTemplate(
@@ -87,9 +93,25 @@ class ExamService:
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:
def get_owned_template(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate:
return 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,
material_ids: list[uuid.UUID] | None = None,
) -> PromptResponse:
template = self._get_user_template_or_404(user_id, template_id)
prompt = self.prompt_builder.build_prompt(template, topic_prompt)
reference_context = self._reference_context(template_id, material_ids)
images_catalog = self._images_catalog(template_id)
prompt = self.prompt_builder.build_prompt(
template,
topic_prompt,
reference_context,
images_catalog,
)
return PromptResponse(template_id=template.id, prompt=prompt)
async def generate_with_llm(
@@ -98,9 +120,17 @@ class ExamService:
template_id: uuid.UUID,
topic_prompt: str,
llm_client: LLMClient,
material_ids: list[uuid.UUID] | None = None,
) -> ParsedQuestionsResponse:
template = self._get_user_template_or_404(user_id, template_id)
prompt = self.prompt_builder.build_prompt(template, topic_prompt)
reference_context = self._reference_context(template_id, material_ids)
images_catalog = self._images_catalog(template_id)
prompt = self.prompt_builder.build_prompt(
template,
topic_prompt,
reference_context,
images_catalog,
)
raw_output = await llm_client.generate(prompt)
questions = self.parser.parse_json(raw_output)
return self._persist_questions(template.id, questions)
@@ -116,8 +146,9 @@ class ExamService:
if not questions:
raise NotFoundError("Template does not contain questions to export")
image_map = self._image_map(template.id)
if export_format == ExportFormat.XML:
content = self.exporter.export_xml(questions)
content = self.exporter.export_xml(questions, image_map)
elif export_format == ExportFormat.TXT:
content = self.exporter.export_txt(questions)
else:
@@ -134,9 +165,30 @@ class ExamService:
self.db.commit()
return ExportResponse(template_id=template.id, format=export_format, content=content)
def get_owned_question(self, user_id: uuid.UUID, question_id: uuid.UUID) -> tuple[Question, ExamTemplate]:
question = self.db.get(Question, question_id)
if question is None:
raise NotFoundError("Question not found")
template = self._get_user_template_or_404(user_id, question.template_id)
if question.template_id != template.id:
raise NotFoundError("Question not found")
return question, template
def to_question_read(self, question: Question) -> QuestionRead:
read = QuestionRead.model_validate(question)
if question.image_id:
return read.model_copy(update={"image_url": f"/exam/images/{question.image_id}/content"})
return read
def _persist_questions(self, template_id: uuid.UUID, questions: list[QuestionCreate]) -> ParsedQuestionsResponse:
persisted: list[Question] = []
for payload in questions:
image_id = payload.image_id
if image_id is not None:
if self.image_service is None:
raise NotFoundError("Image service is not available")
self.image_service.get_image_for_template(template_id, image_id)
question = Question(
template_id=template_id,
question_type=payload.question_type,
@@ -144,6 +196,7 @@ class ExamService:
correct_answers=[clean_text(answer, max_length=1_000) for answer in payload.correct_answers],
wrong_answers=[clean_text(answer, max_length=1_000) for answer in payload.wrong_answers],
matching_pairs=[pair.model_dump() for pair in payload.matching_pairs],
image_id=image_id,
difficulty=payload.difficulty,
score=payload.score,
penalty=payload.penalty,
@@ -156,7 +209,26 @@ class ExamService:
for question in persisted:
self.db.refresh(question)
return ParsedQuestionsResponse(questions=[QuestionRead.model_validate(question) for question in persisted])
return ParsedQuestionsResponse(questions=[self.to_question_read(question) for question in persisted])
def _reference_context(
self,
template_id: uuid.UUID,
material_ids: list[uuid.UUID] | None,
) -> str:
if self.material_service is None:
return ""
return self.material_service.build_reference_context(template_id, material_ids)
def _images_catalog(self, template_id: uuid.UUID) -> str:
if self.image_service is None:
return ""
return self.image_service.images_catalog(template_id)
def _image_map(self, template_id: uuid.UUID) -> dict[uuid.UUID, object]:
if self.image_service is None:
return {}
return self.image_service.build_image_map(template_id)
def _get_user_template_or_404(self, user_id: uuid.UUID, template_id: uuid.UUID) -> ExamTemplate:
template = self.db.get(ExamTemplate, template_id)
+206
View File
@@ -0,0 +1,206 @@
import uuid
from pathlib import Path
from fastapi import UploadFile
from PIL import Image, UnidentifiedImageError
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.core.errors import AppError, NotFoundError
from app.core.security import clean_text
from app.models.exam import ExamImage, ExamTemplate, Question
from app.services.storage_quota import StorageQuotaService
ALLOWED_IMAGE_EXTENSIONS = {".png", ".jpg", ".jpeg", ".webp", ".gif"}
ALLOWED_IMAGE_MIMES = {
"image/png",
"image/jpeg",
"image/webp",
"image/gif",
}
class ImageService:
def __init__(
self,
db: Session,
settings: Settings,
storage_quota: StorageQuotaService | None = None,
) -> None:
self.db = db
self.settings = settings
self.storage_quota = storage_quota or StorageQuotaService(db, settings)
self.image_root = Path(settings.upload_dir) / "exam_images"
self.image_root.mkdir(parents=True, exist_ok=True)
def upload(
self,
template: ExamTemplate,
upload_file: UploadFile,
caption: str | None = None,
) -> ExamImage:
self._validate_upload_count(template.id)
suffix, mime_type = self._validate_image_file(upload_file)
content = upload_file.file.read()
if len(content) > self.settings.max_image_bytes:
raise AppError(
f"Image exceeds maximum size of {self.settings.max_image_bytes} bytes",
status_code=413,
code="file_too_large",
)
self.storage_quota.ensure_template_has_space(template.id, len(content))
image_id = uuid.uuid4()
stored_filename = f"{image_id}{suffix}"
target_dir = self.image_root / str(template.user_id) / str(template.id)
target_dir.mkdir(parents=True, exist_ok=True)
storage_path = target_dir / stored_filename
storage_path.write_bytes(content)
self._verify_image_integrity(storage_path)
image = ExamImage(
id=image_id,
template_id=template.id,
original_filename=clean_text(upload_file.filename or stored_filename, max_length=255),
stored_filename=stored_filename,
mime_type=mime_type,
size_bytes=len(content),
storage_path=str(storage_path),
caption=clean_text(caption, max_length=500) if caption else None,
)
self.db.add(image)
self.db.commit()
self.db.refresh(image)
return image
def list_images(self, template_id: uuid.UUID) -> list[ExamImage]:
return list(
self.db.scalars(
select(ExamImage)
.where(ExamImage.template_id == template_id)
.order_by(ExamImage.created_at.desc())
).all()
)
def get_image_for_template(self, template_id: uuid.UUID, image_id: uuid.UUID) -> ExamImage:
image = self.db.get(ExamImage, image_id)
if image is None or image.template_id != template_id:
raise NotFoundError("Image not found for this template")
return image
def get_image_for_user(self, user_id: uuid.UUID, image_id: uuid.UUID) -> ExamImage:
image = self.db.get(ExamImage, image_id)
if image is None:
raise NotFoundError("Image not found")
template = image.template
if template.user_id != user_id:
raise NotFoundError("Image not found")
return image
def delete_image(self, template: ExamTemplate, image_id: uuid.UUID) -> None:
image = self.get_image_for_template(template.id, image_id)
for question in list(image.questions):
question.image_id = None
path = Path(image.storage_path)
if path.exists():
path.unlink()
self.db.delete(image)
self.db.commit()
def attach_image_to_question(
self,
template: ExamTemplate,
question: Question,
image_id: uuid.UUID | None,
) -> Question:
if question.template_id != template.id:
raise NotFoundError("Question not found for this template")
if image_id is not None:
self.get_image_for_template(template.id, image_id)
question.image_id = image_id
self.db.commit()
self.db.refresh(question)
return question
def images_catalog(self, template_id: uuid.UUID) -> str:
images = self.list_images(template_id)
if not images:
return ""
lines = [
"Imágenes disponibles para preguntas visuales (el enunciado debe referirse a la imagen; "
"asigna el campo image_id en cada pregunta que deba mostrarla):"
]
for image in images:
caption = image.caption or "sin descripción"
lines.append(
f"- image_id: {image.id} | archivo: {image.original_filename} | descripción: {caption}"
)
return "\n".join(lines)
def build_image_map(self, template_id: uuid.UUID) -> dict[uuid.UUID, ExamImage]:
images = self.list_images(template_id)
return {image.id: image for image in images}
def to_read(self, image: ExamImage) -> dict[str, object]:
return {
"id": image.id,
"template_id": image.template_id,
"original_filename": image.original_filename,
"stored_filename": image.stored_filename,
"mime_type": image.mime_type,
"size_bytes": image.size_bytes,
"caption": image.caption,
"content_url": f"/exam/images/{image.id}/content",
"created_at": image.created_at,
}
def _validate_upload_count(self, template_id: uuid.UUID) -> None:
count = self.db.scalar(
select(func.count()).select_from(ExamImage).where(ExamImage.template_id == template_id)
)
if count is not None and count >= self.settings.max_images_per_template:
raise AppError(
f"Maximum of {self.settings.max_images_per_template} images per template reached",
status_code=409,
code="too_many_images",
)
def _validate_image_file(self, upload_file: UploadFile) -> tuple[str, str]:
if not upload_file.filename:
raise AppError("Filename is required", status_code=400, code="invalid_file")
suffix = Path(upload_file.filename).suffix.lower()
if suffix not in ALLOWED_IMAGE_EXTENSIONS:
raise AppError(
f"Unsupported image type. Allowed: {', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))}",
status_code=415,
code="unsupported_media",
)
mime_type = upload_file.content_type or ""
if mime_type and mime_type not in ALLOWED_IMAGE_MIMES:
raise AppError("Unsupported image MIME type", status_code=415, code="unsupported_media")
mime_by_suffix = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
}
resolved_mime = mime_type if mime_type in ALLOWED_IMAGE_MIMES else mime_by_suffix[suffix]
return suffix, resolved_mime
def _verify_image_integrity(self, storage_path: Path) -> None:
try:
with Image.open(storage_path) as img:
img.verify()
except (UnidentifiedImageError, OSError) as exc:
storage_path.unlink(missing_ok=True)
raise AppError("Invalid or corrupted image file", status_code=422, code="invalid_image") from exc
+188
View File
@@ -0,0 +1,188 @@
import uuid
from pathlib import Path
from fastapi import UploadFile
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.core.errors import AppError, NotFoundError
from app.core.security import clean_text
from app.models.exam import ExamMaterial, ExamTemplate, MaterialStatus
from app.schemas.material import ExamMaterialRead
from app.services.document_extractor import SUPPORTED_EXTENSIONS, DocumentExtractor
from app.services.storage_quota import StorageQuotaService
class MaterialService:
def __init__(
self,
db: Session,
settings: Settings,
storage_quota: StorageQuotaService | None = None,
) -> None:
self.db = db
self.settings = settings
self.storage_quota = storage_quota or StorageQuotaService(db, settings)
self.extractor = DocumentExtractor()
self.upload_root = Path(settings.upload_dir)
self.upload_root.mkdir(parents=True, exist_ok=True)
def upload(
self,
template: ExamTemplate,
upload_file: UploadFile,
) -> ExamMaterialRead:
self._validate_upload(template.id, upload_file)
suffix = Path(upload_file.filename or "file").suffix.lower()
if suffix not in SUPPORTED_EXTENSIONS:
raise AppError(
f"Unsupported extension. Allowed: {', '.join(sorted(SUPPORTED_EXTENSIONS))}",
status_code=415,
code="unsupported_media",
)
content = upload_file.file.read()
if len(content) > self.settings.max_upload_bytes:
raise AppError(
f"File exceeds maximum size of {self.settings.max_upload_bytes} bytes",
status_code=413,
code="file_too_large",
)
if not content:
raise AppError("Uploaded file is empty", status_code=400, code="empty_file")
self.storage_quota.ensure_template_has_space(template.id, len(content))
material_id = uuid.uuid4()
safe_name = f"{material_id}{suffix}"
target_dir = self.upload_root / str(template.user_id) / str(template.id)
target_dir.mkdir(parents=True, exist_ok=True)
storage_path = target_dir / safe_name
storage_path.write_bytes(content)
mime_type = upload_file.content_type or SUPPORTED_EXTENSIONS[suffix]
material = ExamMaterial(
id=material_id,
template_id=template.id,
original_filename=clean_text(upload_file.filename or safe_name, max_length=255),
mime_type=mime_type,
size_bytes=len(content),
storage_path=str(storage_path),
status=MaterialStatus.PROCESSED,
)
try:
material.extracted_text = clean_text(
self.extractor.extract(storage_path, mime_type),
max_length=500_000,
)
except AppError as exc:
material.status = MaterialStatus.FAILED
material.error_message = clean_text(exc.message, max_length=500)
except Exception as exc:
material.status = MaterialStatus.FAILED
material.error_message = clean_text(str(exc), max_length=500)
self.db.add(material)
self.db.commit()
self.db.refresh(material)
return self._to_read(material)
def list_materials(self, template_id: uuid.UUID) -> list[ExamMaterialRead]:
materials = self.db.scalars(
select(ExamMaterial)
.where(ExamMaterial.template_id == template_id)
.order_by(ExamMaterial.created_at.desc())
).all()
return [self._to_read(material) for material in materials]
def delete_material(self, template: ExamTemplate, material_id: uuid.UUID) -> None:
material = self.db.get(ExamMaterial, material_id)
if material is None or material.template_id != template.id:
raise NotFoundError("Material not found")
path = Path(material.storage_path)
if path.exists():
path.unlink()
self.db.delete(material)
self.db.commit()
def build_reference_context(
self,
template_id: uuid.UUID,
material_ids: list[uuid.UUID] | None = None,
) -> str:
query = select(ExamMaterial).where(
ExamMaterial.template_id == template_id,
ExamMaterial.status == MaterialStatus.PROCESSED,
ExamMaterial.extracted_text.isnot(None),
)
if material_ids:
query = query.where(ExamMaterial.id.in_(material_ids))
materials = self.db.scalars(query.order_by(ExamMaterial.created_at.asc())).all()
if material_ids:
found_ids = {material.id for material in materials}
missing = [material_id for material_id in material_ids if material_id not in found_ids]
if missing:
raise NotFoundError("One or more material IDs were not found or are not processed")
if not materials:
return ""
sections: list[str] = []
for material in materials:
text = material.extracted_text or ""
if not text.strip():
continue
sections.append(
f"--- Archivo: {material.original_filename} ---\n{text.strip()}"
)
if not sections:
return ""
combined = "\n\n".join(sections)
max_chars = self.settings.max_reference_chars
if len(combined) <= max_chars:
return combined
truncated = combined[:max_chars].rsplit("\n", 1)[0]
return f"{truncated}\n\n[Material truncado por límite de contexto]"
def _validate_upload(self, template_id: uuid.UUID, upload_file: UploadFile) -> None:
if not upload_file.filename:
raise AppError("Filename is required", status_code=400, code="invalid_file")
count = self.db.scalar(
select(func.count())
.select_from(ExamMaterial)
.where(ExamMaterial.template_id == template_id)
)
if count is not None and count >= self.settings.max_materials_per_template:
raise AppError(
f"Maximum of {self.settings.max_materials_per_template} files per template reached",
status_code=409,
code="too_many_files",
)
def _to_read(self, material: ExamMaterial) -> ExamMaterialRead:
preview = None
if material.extracted_text:
preview = material.extracted_text[:300]
if len(material.extracted_text) > 300:
preview += "..."
return ExamMaterialRead(
id=material.id,
template_id=material.template_id,
original_filename=material.original_filename,
mime_type=material.mime_type,
size_bytes=material.size_bytes,
status=material.status,
error_message=material.error_message,
text_preview=preview,
created_at=material.created_at,
)
+74 -30
View File
@@ -1,15 +1,20 @@
import base64
import json
from html import escape as html_escape
from pathlib import Path
from typing import Any
from uuid import UUID
from xml.sax.saxutils import escape as xml_escape
from app.core.security import clean_text
class MoodleXMLExporter:
def export_xml(self, questions: list[Any]) -> str:
def export_xml(self, questions: list[Any], image_map: dict[UUID, Any] | None = None) -> str:
images = image_map or {}
parts = ['<?xml version="1.0" encoding="UTF-8"?>', "<quiz>"]
for index, question in enumerate(questions, start=1):
parts.append(self._export_question(question, index))
parts.append(self._export_question(question, index, images))
parts.append("</quiz>")
return "\n".join(parts)
@@ -17,6 +22,8 @@ class MoodleXMLExporter:
blocks: list[str] = []
for question in questions:
lines = [self._attr(question, "statement")]
if self._attr(question, "image_id"):
lines.append(f"[Imagen adjunta: {self._attr(question, 'image_id')}]")
lines.extend(self._attr(question, "correct_answers") or [])
lines.extend(self._attr(question, "wrong_answers") or [])
blocks.append("\n".join(clean_text(str(line)) for line in lines))
@@ -26,19 +33,19 @@ class MoodleXMLExporter:
payload = {"questions": [self._question_dict(question) for question in questions]}
return json.dumps(payload, ensure_ascii=False, indent=2, default=str)
def _export_question(self, question: Any, index: int) -> str:
def _export_question(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
question_type = self._enum_value(self._attr(question, "question_type"))
if question_type == "multichoice":
return self._multichoice(question, index)
return self._multichoice(question, index, image_map)
if question_type == "truefalse":
return self._truefalse(question, index)
return self._truefalse(question, index, image_map)
if question_type == "shortanswer":
return self._shortanswer(question, index)
return self._shortanswer(question, index, image_map)
if question_type == "matching":
return self._matching(question, index)
return self._matching(question, index, image_map)
raise ValueError(f"Unsupported Moodle question type: {question_type}")
def _multichoice(self, question: Any, index: int) -> str:
def _multichoice(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
correct_answers = self._attr(question, "correct_answers") or []
wrong_answers = self._attr(question, "wrong_answers") or []
options = self._attr(question, "options") or {}
@@ -53,7 +60,7 @@ class MoodleXMLExporter:
return "\n".join(
[
' <question type="multichoice">',
self._common_header(question, index),
*self._common_header(question, index, image_map),
f" <single>{str(not multiple_correct).lower()}</single>",
" <shuffleanswers>1</shuffleanswers>",
*answers,
@@ -61,32 +68,32 @@ class MoodleXMLExporter:
]
)
def _truefalse(self, question: Any, index: int) -> str:
def _truefalse(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
correct = (self._attr(question, "correct_answers") or ["true"])[0].lower()
is_true = correct in {"true", "verdadero"}
return "\n".join(
[
' <question type="truefalse">',
self._common_header(question, index),
*self._common_header(question, index, image_map),
self._answer_xml("true", 100 if is_true else 0),
self._answer_xml("false", 0 if is_true else 100),
" </question>",
]
)
def _shortanswer(self, question: Any, index: int) -> str:
def _shortanswer(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
answers = [self._answer_xml(answer, 100) for answer in self._attr(question, "correct_answers")]
return "\n".join(
[
' <question type="shortanswer">',
self._common_header(question, index),
*self._common_header(question, index, image_map),
" <usecase>0</usecase>",
*answers,
" </question>",
]
)
def _matching(self, question: Any, index: int) -> str:
def _matching(self, question: Any, index: int, image_map: dict[UUID, Any]) -> str:
subquestions = []
for pair in self._attr(question, "matching_pairs") or []:
prompt = pair.get("prompt") if isinstance(pair, dict) else pair.prompt
@@ -106,27 +113,63 @@ class MoodleXMLExporter:
return "\n".join(
[
' <question type="matching">',
self._common_header(question, index),
*self._common_header(question, index, image_map),
*subquestions,
" </question>",
]
)
def _common_header(self, question: Any, index: int) -> str:
def _common_header(self, question: Any, index: int, image_map: dict[UUID, Any]) -> list[str]:
statement = self._attr(question, "statement")
name = clean_text(statement, max_length=80) or f"Pregunta {index}"
return "\n".join(
[
" <name>",
f" <text>{self._xml(name)}</text>",
" </name>",
' <questiontext format="html">',
f" <text>{self._cdata(statement)}</text>",
" </questiontext>",
f" <defaultgrade>{float(self._attr(question, 'score') or 1.0):.2f}</defaultgrade>",
" <generalfeedback format=\"html\"><text></text></generalfeedback>",
]
)
return [
" <name>",
f" <text>{self._xml(name)}</text>",
" </name>",
' <questiontext format="html">',
f" <text>{self._question_html(question, image_map)}</text>",
" </questiontext>",
*self._embedded_files(question, image_map),
f" <defaultgrade>{float(self._attr(question, 'score') or 1.0):.2f}</defaultgrade>",
' <generalfeedback format="html"><text></text></generalfeedback>',
]
def _question_html(self, question: Any, image_map: dict[UUID, Any]) -> str:
statement = html_escape(clean_text(str(self._attr(question, "statement"))))
html_parts = [f"<p>{statement}</p>"]
image = self._resolve_image(question, image_map)
if image is not None:
alt = html_escape(clean_text(image.caption or image.original_filename, max_length=200))
html_parts.append(
f'<p><img src="@@PLUGINFILE@@/{image.stored_filename}" alt="{alt}" /></p>'
)
return self._cdata("".join(html_parts))
def _embedded_files(self, question: Any, image_map: dict[UUID, Any]) -> list[str]:
image = self._resolve_image(question, image_map)
if image is None:
return []
path = Path(image.storage_path)
if not path.exists():
return []
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
return [
f' <file name="{self._xml(image.stored_filename)}" path="/" encoding="base64">',
encoded,
" </file>",
]
def _resolve_image(self, question: Any, image_map: dict[UUID, Any]) -> Any | None:
image_id = self._attr(question, "image_id")
if image_id is None:
return None
if hasattr(question, "image") and question.image is not None:
return question.image
return image_map.get(image_id)
def _answer_xml(self, text: str, fraction: float) -> str:
fraction_text = f"{fraction:.6g}"
@@ -134,7 +177,7 @@ class MoodleXMLExporter:
[
f' <answer fraction="{fraction_text}" format="html">',
f" <text>{self._xml(text)}</text>",
" <feedback format=\"html\"><text></text></feedback>",
' <feedback format="html"><text></text></feedback>',
" </answer>",
]
)
@@ -144,6 +187,7 @@ class MoodleXMLExporter:
"id": str(self._attr(question, "id")) if self._attr(question, "id") else None,
"question_type": self._enum_value(self._attr(question, "question_type")),
"statement": self._attr(question, "statement"),
"image_id": str(self._attr(question, "image_id")) if self._attr(question, "image_id") else None,
"correct_answers": self._attr(question, "correct_answers") or [],
"wrong_answers": self._attr(question, "wrong_answers") or [],
"matching_pairs": self._attr(question, "matching_pairs") or [],
@@ -162,5 +206,5 @@ class MoodleXMLExporter:
return xml_escape(clean_text(str(value)), {'"': "&quot;", "'": "&apos;"})
def _cdata(self, value: Any) -> str:
text = clean_text(str(value)).replace("]]>", "]]]]><![CDATA[>")
text = str(value).replace("]]>", "]]]]><![CDATA[>")
return f"<![CDATA[{text}]]>"
+2
View File
@@ -72,12 +72,14 @@ class AIQuestionParser:
if isinstance(wrong, str):
wrong = [wrong]
image_id = item.get("image_id")
return {
"question_type": question_type,
"statement": item.get("statement", item.get("question", item.get("prompt", ""))),
"correct_answers": correct,
"wrong_answers": wrong,
"matching_pairs": item.get("matching_pairs", []),
"image_id": image_id,
"difficulty": item.get("difficulty", Difficulty.MEDIUM.value),
"score": item.get("score", 1.0),
"penalty": item.get("penalty", 0.0),
+24 -1
View File
@@ -5,7 +5,13 @@ from app.models.exam import ExamTemplate
class PromptBuilder:
def build_prompt(self, template: ExamTemplate, topic_prompt: str) -> str:
def build_prompt(
self,
template: ExamTemplate,
topic_prompt: str,
reference_context: str = "",
images_catalog: str = "",
) -> str:
settings = template.settings
difficulty_profile = template.difficulty_profile
safe_topic = sanitize_prompt_input(topic_prompt)
@@ -18,6 +24,7 @@ class PromptBuilder:
"correct_answers": ["respuesta correcta"],
"wrong_answers": ["distractor 1", "distractor 2"],
"matching_pairs": [{"prompt": "concepto", "answer": "definicion"}],
"image_id": "uuid-opcional-de-imagen-de-la-plantilla",
"difficulty": "easy | medium | hard | very_hard",
"score": 1.0,
"penalty": 0.0,
@@ -41,6 +48,20 @@ class PromptBuilder:
"Tema, conceptos y restricciones indicadas por el profesor:",
safe_topic,
"",
*(
[
"Material de referencia (usa SOLO esta información junto con el tema para crear preguntas):",
sanitize_prompt_input(reference_context, max_length=12_000) if reference_context else "",
"",
]
if reference_context.strip()
else []
),
*(
[images_catalog, ""]
if images_catalog.strip()
else []
),
"Contrato de salida obligatorio:",
json.dumps(contract, ensure_ascii=False, indent=2),
"",
@@ -51,5 +72,7 @@ class PromptBuilder:
"- En truefalse, usa una única respuesta correcta: true o false.",
"- En shortanswer, incluye respuestas exactas aceptadas.",
"- En matching, rellena matching_pairs y deja wrong_answers vacío.",
"- Si la pregunta debe mostrar una imagen al alumno, incluye image_id del catálogo de imágenes.",
"- El enunciado debe describir qué observar en la imagen vinculada (sin inventar image_id inexistentes).",
]
)
+81
View File
@@ -0,0 +1,81 @@
import uuid
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.config import Settings
from app.core.errors import AppError
from app.models.exam import ExamImage, ExamMaterial
class StorageQuotaService:
def __init__(self, db: Session, settings: Settings) -> None:
self.db = db
self.settings = settings
def get_template_usage_bytes(self, template_id: uuid.UUID) -> int:
materials_bytes = self.db.scalar(
select(func.coalesce(func.sum(ExamMaterial.size_bytes), 0)).where(
ExamMaterial.template_id == template_id
)
)
images_bytes = self.db.scalar(
select(func.coalesce(func.sum(ExamImage.size_bytes), 0)).where(
ExamImage.template_id == template_id
)
)
return int(materials_bytes or 0) + int(images_bytes or 0)
def ensure_template_has_space(self, template_id: uuid.UUID, incoming_bytes: int) -> None:
if incoming_bytes <= 0:
return
limit = self.settings.max_storage_bytes_per_template
used = self.get_template_usage_bytes(template_id)
projected = used + incoming_bytes
if projected > limit:
raise AppError(
message=(
f"Template storage quota exceeded. "
f"Limit: {self._format_mb(limit)}, "
f"used: {self._format_mb(used)}, "
f"file: {self._format_mb(incoming_bytes)}"
),
status_code=413,
code="template_storage_quota_exceeded",
)
def get_usage_summary(self, template_id: uuid.UUID) -> dict[str, int | float]:
materials_bytes = int(
self.db.scalar(
select(func.coalesce(func.sum(ExamMaterial.size_bytes), 0)).where(
ExamMaterial.template_id == template_id
)
)
or 0
)
images_bytes = int(
self.db.scalar(
select(func.coalesce(func.sum(ExamImage.size_bytes), 0)).where(
ExamImage.template_id == template_id
)
)
or 0
)
used = materials_bytes + images_bytes
limit = self.settings.max_storage_bytes_per_template
return {
"template_id": template_id,
"used_bytes": used,
"limit_bytes": limit,
"remaining_bytes": max(limit - used, 0),
"materials_bytes": materials_bytes,
"images_bytes": images_bytes,
"used_mb": round(used / (1024 * 1024), 2),
"limit_mb": round(limit / (1024 * 1024), 2),
}
@staticmethod
def _format_mb(value_bytes: int) -> str:
return f"{value_bytes / (1024 * 1024):.2f} MB"