571 categories, 21 uploaded documents, 14 articles, 8 card decks, 30 shared tests and 2 questions carried somebody's name — mostly daniel@danvics.com, which is not even the working administrator any more. So "who may edit this" partly depended on who happened to create it, and handing the site to somebody else would have meant rewriting every one of those rows. Migration q6a7b8c9d0e1 empties those owner columns and makes them nullable, because ownerless is now a legitimate state and a NOT NULL owner is exactly what forced a name onto every row. Nothing is deleted and nothing moves. What keeps its owner, deliberately: attempts, notes, favourites, collections, folders, study-plan progress, and the quizzes that are somebody's own sittings rather than shared bank tests. study_plans needed nothing — it never had an owner column. Then the code, so it cannot grow back. Authorship is no longer a way in anywhere: may_edit_question and can_edit_article ask the role and the grants and nothing else; the article draft, status and delete paths lost their "or you wrote it" arm; decks are the bank's, so an educator reaches any of them and a learner reaches the shared ones; documents are the corpus, so they are editors-only rather than "mine"; and every creation path writes user_id NULL. The bank listing's "mine" facet went with it — it counted nothing and could only ever count nothing. Verified against production as a real learner account: every bank write 403s, admin settings 403, documents empty. As an admin, everything opens. Also: a category grant no longer offers Editorial in the menu. It offers Questions and Images, which is what a grant covers; Editorial is the whole library's review queue and its route is moderator-only, so the entry was a door that answered "Not yours to open". Six tests changed rather than deleted — they asserted the old model, and each now asserts the new one: writing an article does not make it yours, writing a question does not make it yours, an answer image is not opened by authorship, the tutor is not opened by authorship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
210 lines
7.2 KiB
Python
210 lines
7.2 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()}_{os.path.basename(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(
|
|
# No owner. Bank content belongs to the admin role and to whoever
|
|
# holds a grant over it, never to whoever happened to create it —
|
|
# otherwise ownership grows back one upload at a time.
|
|
user_id=None,
|
|
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),
|
|
):
|
|
# An uploaded source belongs to the bank, not to whoever carried it in, so
|
|
# there is no "my documents" any more — only the corpus, and only for the
|
|
# people who work on it.
|
|
if not current_user.is_moderator:
|
|
return []
|
|
return db.query(PDFDocument).order_by(PDFDocument.uploaded_at.desc()).all()
|
|
|
|
|
|
def _get_doc_or_404(document_id: int, current_user: User, db) -> PDFDocument:
|
|
"""Fetch a document. The corpus is the bank's, so this is editors only."""
|
|
if not current_user.is_moderator:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
doc = db.query(PDFDocument).filter(PDFDocument.id == document_id).first()
|
|
if not doc:
|
|
raise HTTPException(status_code=404, detail="Document not found")
|
|
return doc
|
|
|
|
|
|
@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),
|
|
):
|
|
return _get_doc_or_404(document_id, current_user, db)
|
|
|
|
|
|
@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 = _get_doc_or_404(document_id, current_user, db)
|
|
return DocumentStatusResponse(
|
|
id=doc.id,
|
|
status=doc.status,
|
|
total_pages=doc.total_pages,
|
|
error_message=doc.error_message,
|
|
)
|
|
|
|
|
|
@router.get("/{document_id}/processing-steps")
|
|
def get_processing_steps(
|
|
document_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Poll PDF processing progress steps from Redis."""
|
|
_get_doc_or_404(document_id, current_user, db)
|
|
import json as _json
|
|
import redis
|
|
r = redis.from_url(settings.REDIS_URL, decode_responses=True)
|
|
raw = r.lrange(f"pdf:steps:{document_id}", 0, -1)
|
|
steps = [_json.loads(s) for s in raw] if raw else []
|
|
return {"steps": steps}
|
|
|
|
|
|
@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(require_moderator),
|
|
):
|
|
doc = _get_doc_or_404(document_id, current_user, db)
|
|
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 section_data.start_page < 1 or section_data.end_page < 1:
|
|
raise HTTPException(status_code=400, detail="Page numbers must be positive")
|
|
if section_data.start_page >= section_data.end_page:
|
|
raise HTTPException(status_code=400, detail="start_page must be less than end_page")
|
|
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}/sections/{section_id}", status_code=status.HTTP_204_NO_CONTENT)
|
|
def delete_section(
|
|
document_id: int,
|
|
section_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator),
|
|
):
|
|
doc = _get_doc_or_404(document_id, current_user, db)
|
|
section = db.query(Section).filter(Section.id == section_id, Section.document_id == document_id).first()
|
|
if not section:
|
|
raise HTTPException(status_code=404, detail="Section not found")
|
|
db.delete(section)
|
|
db.commit()
|
|
|
|
|
|
@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(require_moderator),
|
|
):
|
|
doc = _get_doc_or_404(document_id, current_user, db)
|
|
|
|
# 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()
|