pdf-quiz-generator/backend/app/routers/questions.py
Daniel 287d3a72f4 Fix question bank: select-all, bulk category, page size, category delete
Bugs fixed:
- 'Select all' now fetches ALL matching IDs from server (GET /questions/bank/ids)
  not just the currently loaded page — works for 268 questions
- Bulk category assign: fixed 'Remove category' value ('0' → 'remove'),
  now shows error message on failure instead of silent fail
- Search mode (Keyword/Semantic/Hybrid) moved to a clearly labelled dropdown,
  visually separated from category filter chips — eliminates confusion
- Category chips now have a 'Filter:' label to distinguish from other buttons

New features:
- Page size selector: 50 / 100 / 200 / All — loads the requested count in one shot
- Bulk actions moved to its own clearly labelled bar (only shown for moderators)
- Category delete with questions: prompts user to choose target category before deleting
  (uses new move_to param on DELETE /question-categories/{id})
- GET /questions/bank/ids endpoint for server-side select-all

Backend:
- DELETE /question-categories/{id}?move_to={id}: move questions to another category
  before deletion instead of always leaving them uncategorized

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 02:55:25 +02:00

237 lines
9.4 KiB
Python

"""Question bank — view, search, categorise, and create quizzes from individual questions."""
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import cast, String, or_
from sqlalchemy.orm import Session
from app.database import get_db
from app.models.question import Question
from app.models.question_category import QuestionCategory
from app.models.quiz import Quiz
from app.models.user import User
from app.utils.auth import get_current_user, require_moderator
router = APIRouter()
@router.patch("/{question_id}/category")
def set_question_category(
question_id: int,
category_id: int | None = None,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Assign or remove a question category."""
question = db.query(Question).filter(Question.id == question_id).first()
if not question:
raise HTTPException(status_code=404, detail="Question not found")
if category_id is not None:
cat = db.query(QuestionCategory).filter(QuestionCategory.id == category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
question.question_category_id = category_id
db.commit()
return {"question_id": question_id, "question_category_id": category_id}
@router.get("/bank/ids")
def get_bank_ids(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
uncategorized: bool = Query(False),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Return just IDs for all matching questions (for server-side select-all)."""
query = db.query(Question.id)
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
if category_id is not None:
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
query = query.filter(Question.question_category_id.is_(None))
if q and q.strip():
phrase = q.strip()
query = query.filter(
or_(
Question.question_text.ilike(f"%{phrase}%"),
cast(Question.options, String).ilike(f"%{phrase}%"),
)
)
return [row[0] for row in query.all()]
@router.get("/bank")
def get_question_bank(
q: str | None = Query(None),
quiz_id: int | None = Query(None),
category_id: int | None = Query(None),
uncategorized: bool = Query(False),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""List all questions across all quizzes. Supports keyword filter and quiz filter."""
query = db.query(Question)
if quiz_id:
query = query.filter(Question.quiz_id == quiz_id)
if category_id is not None:
query = query.filter(Question.question_category_id == category_id)
if uncategorized:
query = query.filter(Question.question_category_id.is_(None))
# ── Semantic search (pgvector) ─────────────────────────────────
semantic_ids_ordered: list[int] = []
if q and q.strip() and search_mode in ("semantic", "hybrid"):
from app.services.embedding_service import generate_embedding
from sqlalchemy import text as sa_text
emb = generate_embedding(q.strip())
if emb:
emb_literal = "[" + ",".join(str(x) for x in emb) + "]"
rows = db.execute(sa_text(f"""
SELECT id, 1 - (embedding <=> '{emb_literal}'::vector) AS sim
FROM questions
WHERE embedding IS NOT NULL
ORDER BY embedding <=> '{emb_literal}'::vector
LIMIT 200
""")).fetchall()
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.30]
# ── Keyword filter ─────────────────────────────────────────────
if q and q.strip() and search_mode in ("keyword", "hybrid"):
phrase = q.strip()
query = query.filter(
or_(
Question.question_text.ilike(f"%{phrase}%"),
cast(Question.options, String).ilike(f"%{phrase}%"),
)
)
# Apply semantic ID filter if semantic-only mode
if q and q.strip() and search_mode == "semantic" and semantic_ids_ordered:
query = query.filter(Question.id.in_(semantic_ids_ordered))
total = query.count()
questions = query.order_by(Question.source_quiz_id, Question.id).offset(offset).limit(limit).all()
# If hybrid: merge semantic first then keyword remainder
if semantic_ids_ordered and search_mode == "hybrid":
sem_set = set(semantic_ids_ordered)
sem_qs = [qu for qu in questions if qu.id in sem_set]
kw_qs = [qu for qu in questions if qu.id not in sem_set]
# Sort semantic by original similarity order
sem_order = {qid: i for i, qid in enumerate(semantic_ids_ordered)}
sem_qs.sort(key=lambda qu: sem_order.get(qu.id, 999))
questions = sem_qs + kw_qs
quiz_cache: dict[int, str] = {}
cat_cache: dict[int, str] = {}
result = []
for qu in questions:
src_id = qu.source_quiz_id
if src_id not in quiz_cache:
quiz = db.query(Quiz).filter(Quiz.id == src_id).first() if src_id else None
quiz_cache[src_id] = quiz.title if quiz else (f"Quiz {src_id}" if src_id else "Unknown")
cat_name = None
if qu.question_category_id:
if qu.question_category_id not in cat_cache:
cat = db.query(QuestionCategory).filter(QuestionCategory.id == qu.question_category_id).first()
cat_cache[qu.question_category_id] = cat.name if cat else None
cat_name = cat_cache.get(qu.question_category_id)
result.append({
"id": qu.id,
"quiz_id": qu.source_quiz_id,
"quiz_title": quiz_cache[src_id],
"question_category_id": qu.question_category_id,
"question_category_name": cat_name,
"question_text": qu.question_text,
"question_type": qu.question_type,
"options": qu.options,
"correct_answer": qu.correct_answer,
"explanation": qu.explanation,
})
return {"total": total, "questions": result}
class CreateFromBankRequest(BaseModel):
title: str
question_ids: list[int]
mode: str = "timed"
time_limit_minutes: int | None = None
@router.post("/from-bank")
def create_quiz_from_bank(
data: CreateFromBankRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Create a new quiz referencing existing bank questions (no copying — edits propagate)."""
if not data.title.strip():
raise HTTPException(status_code=400, detail="Title is required")
if not data.question_ids:
raise HTTPException(status_code=400, detail="Select at least one question")
if data.mode not in ("timed", "learning"):
raise HTTPException(status_code=400, detail="Mode must be timed or learning")
source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all()
if not source_questions:
raise HTTPException(status_code=404, detail="No valid questions found")
# Preserve caller's requested order
id_order = {qid: i for i, qid in enumerate(data.question_ids)}
source_questions.sort(key=lambda q: id_order.get(q.id, len(data.question_ids)))
# Find a section_id from the source questions' origin quiz
first_src_quiz_id = source_questions[0].source_quiz_id
first_quiz = db.query(Quiz).filter(Quiz.id == first_src_quiz_id).first() if first_src_quiz_id else None
section_id = first_quiz.section_id if first_quiz else source_questions[0].source_quiz_id or 1
new_quiz = Quiz(
section_id=section_id,
user_id=current_user.id,
title=data.title.strip(),
questions_count=len(source_questions),
mode=data.mode,
time_limit_minutes=data.time_limit_minutes,
)
db.add(new_quiz)
db.flush()
# Reference existing questions via junction (no copies)
from app.models.quiz_question_link import QuizQuestionLink
for pos, sq in enumerate(source_questions):
db.add(QuizQuestionLink(quiz_id=new_quiz.id, question_id=sq.id, position=pos))
db.commit()
db.refresh(new_quiz)
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count}
class BulkCategoryRequest(BaseModel):
question_ids: list[int]
category_id: int | None = None
@router.post("/bulk-category")
def bulk_set_question_category(
data: BulkCategoryRequest,
db: Session = Depends(get_db),
current_user: User = Depends(require_moderator),
):
"""Assign or remove a category from multiple questions."""
if data.category_id is not None:
cat = db.query(QuestionCategory).filter(QuestionCategory.id == data.category_id).first()
if not cat:
raise HTTPException(status_code=404, detail="Category not found")
updated = db.query(Question).filter(Question.id.in_(data.question_ids)).update(
{"question_category_id": data.category_id}, synchronize_session=False
)
db.commit()
return {"updated": updated, "question_category_id": data.category_id}