Architecture:
- Questions are primary objects in a bank, tagged with question categories
- QuestionCategory is a separate taxonomy from QuizCategory (different concepts)
- Extraction → questions added to bank, optionally tagged to a question category
- Quizzes can be created from: individual question selection, question category, or PDF extraction
Backend:
- QuestionCategory model + question_categories table
- question_category_id column on questions table (nullable, SET NULL on delete)
- GET/POST/PATCH/DELETE /api/question-categories/
- POST /api/question-categories/{id}/create-quiz — create quiz from all questions in a category
- PATCH /api/questions/{id}/category — assign single question to category
- PATCH /api/questions/bulk-category — assign multiple questions at once
- GET /api/questions/bank?category_id=&uncategorized= — filter by category
- QuizCreate schema now accepts question_category_id for extraction
- quiz_service.create_quiz_from_section accepts question_category_id param
Frontend:
- DocumentDetailPage: Add to Bank Category dropdown in Quiz Settings (optional)
Labels extracted questions with the selected category on creation
- QuestionBankPage: full rewrite
- Category chips for filtering (All / Uncategorized / named categories)
- Create category button inline
- Checkbox multi-select with bulk category assignment
- Create Quiz modal: choose from selected questions OR all from a category
- Each question shows its category badge and quiz source
- Study modal with instant answer feedback
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
184 lines
6.6 KiB
Python
184 lines
6.6 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.patch("/bulk-category")
|
|
def bulk_set_question_category(
|
|
question_ids: list[int],
|
|
category_id: int | None = None,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(require_moderator),
|
|
):
|
|
"""Assign a category to multiple questions at once."""
|
|
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")
|
|
updated = db.query(Question).filter(Question.id.in_(question_ids)).update(
|
|
{"question_category_id": category_id}, synchronize_session=False
|
|
)
|
|
db.commit()
|
|
return {"updated": updated, "question_category_id": category_id}
|
|
|
|
|
|
@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),
|
|
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))
|
|
|
|
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}%"),
|
|
)
|
|
)
|
|
|
|
total = query.count()
|
|
questions = query.order_by(Question.quiz_id, Question.id).offset(offset).limit(limit).all()
|
|
|
|
quiz_cache: dict[int, str] = {}
|
|
cat_cache: dict[int, str] = {}
|
|
result = []
|
|
for qu in questions:
|
|
if qu.quiz_id not in quiz_cache:
|
|
quiz = db.query(Quiz).filter(Quiz.id == qu.quiz_id).first()
|
|
quiz_cache[qu.quiz_id] = quiz.title if quiz else f"Quiz {qu.quiz_id}"
|
|
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.quiz_id,
|
|
"quiz_title": quiz_cache[qu.quiz_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 by copying selected questions from the question bank."""
|
|
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")
|
|
|
|
# Fetch source questions
|
|
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")
|
|
|
|
# Use the section_id from the first source question's quiz
|
|
first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first()
|
|
if not first_quiz:
|
|
raise HTTPException(status_code=400, detail="Source quiz not found")
|
|
|
|
import json
|
|
new_quiz = Quiz(
|
|
section_id=first_quiz.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()
|
|
|
|
# Copy questions (independent copies — original questions untouched)
|
|
from app.services import embedding_service
|
|
for sq in source_questions:
|
|
new_q = Question(
|
|
quiz_id=new_quiz.id,
|
|
question_text=sq.question_text,
|
|
question_type=sq.question_type,
|
|
options=sq.options,
|
|
correct_answer=sq.correct_answer,
|
|
explanation=sq.explanation,
|
|
page_reference=sq.page_reference,
|
|
)
|
|
db.add(new_q)
|
|
db.flush()
|
|
try:
|
|
embedding_service.embed_question(new_q)
|
|
except Exception:
|
|
pass
|
|
|
|
db.commit()
|
|
db.refresh(new_quiz)
|
|
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count}
|