pdf-quiz-generator/backend/app/routers/documents.py
ifedan-ed b876f13fac Initial commit: PDF Quiz Generator app
- FastAPI backend with JWT auth, roles (admin/moderator/user)
- PDF upload (up to 500MB) with streaming, PyMuPDF text extraction
- ChromaDB vectorization per page with metadata
- LiteLLM AI question extraction from PDF (not generation)
- Image extraction from PDF pages, graceful fallback
- Quiz modes: timed (countdown timer) + learning (answers shown inline)
- Page-by-page question navigation with dot navigator
- TTS endpoint using LiteLLM (Google Vertex / OpenAI voices)
- Admin dashboard: AI model management per task, user role management
- Moderator role: upload PDFs, create sections, generate quizzes
- Spaced repetition reminders via SMTP email (SM-2 intervals)
- APScheduler daily reminder jobs
- Celery + Redis for background PDF processing
- React frontend with all pages
- Docker Compose deployment (nginx + backend + celery + redis)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 20:04:53 +00:00

180 lines
5.7 KiB
Python

import os
import uuid
import shutil
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, status
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.pdf_document import PDFDocument
from app.models.section import Section
from app.models.user import User
from app.schemas.document import DocumentResponse, DocumentStatusResponse, SectionCreate, SectionResponse
from app.utils.auth import get_current_user, require_moderator
from app.services import vector_service
router = APIRouter()
@router.post("/upload", response_model=DocumentResponse)
def upload_document(
file: UploadFile = File(...),
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
if not file.filename or not file.filename.lower().endswith(".pdf"):
raise HTTPException(status_code=400, detail="Only PDF files are accepted")
# Save file to disk streaming (handles large files)
os.makedirs(settings.UPLOAD_DIR, exist_ok=True)
safe_name = f"{uuid.uuid4()}_{file.filename}"
file_path = os.path.join(settings.UPLOAD_DIR, safe_name)
with open(file_path, "wb") as buffer:
shutil.copyfileobj(file.file, buffer, length=1024 * 1024)
# Check file size
file_size = os.path.getsize(file_path)
if file_size > settings.MAX_UPLOAD_SIZE:
os.remove(file_path)
raise HTTPException(status_code=400, detail=f"File too large. Max size: {settings.MAX_UPLOAD_SIZE} bytes")
# Create DB record
doc = PDFDocument(
user_id=current_user.id,
filename=safe_name,
original_filename=file.filename,
status="processing",
)
db.add(doc)
db.commit()
db.refresh(doc)
# Dispatch background processing
try:
from app.tasks.pdf_tasks import process_pdf
process_pdf.delay(doc.id, file_path)
except Exception:
# If Celery/Redis not available, process synchronously
from app.services import pdf_service
try:
total_pages = pdf_service.get_page_count(file_path)
doc.total_pages = total_pages
pages = pdf_service.extract_text_by_page(file_path)
if pages:
vector_service.store_pages(doc.id, pages)
doc.status = "ready"
else:
doc.status = "error"
doc.error_message = "No text could be extracted"
db.commit()
except Exception as e:
doc.status = "error"
doc.error_message = str(e)[:500]
db.commit()
db.refresh(doc)
return doc
@router.get("/", response_model=list[DocumentResponse])
def list_documents(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
if current_user.is_moderator:
docs = db.query(PDFDocument).order_by(PDFDocument.uploaded_at.desc()).all()
else:
docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).order_by(PDFDocument.uploaded_at.desc()).all()
return docs
@router.get("/{document_id}", response_model=DocumentResponse)
def get_document(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return doc
@router.get("/{document_id}/status", response_model=DocumentStatusResponse)
def get_document_status(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
return DocumentStatusResponse(
id=doc.id,
status=doc.status,
total_pages=doc.total_pages,
error_message=doc.error_message,
)
@router.post("/{document_id}/sections", response_model=SectionResponse)
def create_section(
document_id: int,
section_data: SectionCreate,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
if doc.status != "ready":
raise HTTPException(status_code=400, detail="Document is not ready yet")
if doc.total_pages and section_data.end_page > doc.total_pages:
raise HTTPException(status_code=400, detail=f"end_page exceeds document length ({doc.total_pages} pages)")
section = Section(
document_id=document_id,
name=section_data.name,
start_page=section_data.start_page,
end_page=section_data.end_page,
)
db.add(section)
db.commit()
db.refresh(section)
return section
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_document(
document_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
doc = db.query(PDFDocument).filter(
PDFDocument.id == document_id,
PDFDocument.user_id == current_user.id,
).first()
if not doc:
raise HTTPException(status_code=404, detail="Document not found")
# Delete file
file_path = os.path.join(settings.UPLOAD_DIR, doc.filename)
if os.path.exists(file_path):
os.remove(file_path)
# Delete ChromaDB collection
vector_service.delete_collection(document_id)
db.delete(doc)
db.commit()