- Fix tag filtering (sa_text import shadowing caused UnboundLocalError) - Add TagBrowser component with per-section search - Multi-category selection (OR within categories, AND with tags) - AI image validation: has_figure field in extraction prompt - Skip known branding images by MD5 hash + dimension filters - Fix quiz timer auto-submit (wrong useEffect dependency) - Fix QuizResponse schema: section_id nullable - Fix Question.quiz_id → source_quiz_id attribute name - Fix SQL injection in quizzes.py vector search - Add PDF processing progress steps via Redis - Add delete user from admin panel - Admin page: no spinner flash on data refresh - Upload progress: axios 1.x e.progress, remove manual Content-Type - Duplicate model error: 409 with clear message - Backend startup: retry DDL migration on lock timeout - Replace all silent except:pass with warning logs - Comprehensive multi-page documentation (docs/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
298 lines
13 KiB
Python
298 lines
13 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_, text as sa_text
|
|
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.models.favorite import Favorite
|
|
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),
|
|
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
|
uncategorized: bool = Query(False),
|
|
favorites_only: bool = Query(False),
|
|
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
|
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.source_quiz_id == quiz_id)
|
|
if category_ids:
|
|
cat_id_list = [int(c.strip()) for c in category_ids.split(",") if c.strip().isdigit()]
|
|
if cat_id_list:
|
|
query = query.filter(Question.question_category_id.in_(cat_id_list))
|
|
elif 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 favorites_only:
|
|
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
|
|
fav_ids = [f[0] for f in favorite_ids]
|
|
if not fav_ids:
|
|
return []
|
|
query = query.filter(Question.id.in_(fav_ids))
|
|
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}%"),
|
|
)
|
|
)
|
|
# Tag filter: questions must have ALL specified tags
|
|
if tag_ids:
|
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
matching_ids = list(db.execute(sa_text("""
|
|
SELECT question_id FROM question_tag_links
|
|
WHERE tag_id = ANY(:tag_ids)
|
|
GROUP BY question_id
|
|
HAVING COUNT(DISTINCT tag_id) = :cnt
|
|
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
|
|
if matching_ids:
|
|
query = query.filter(Question.id.in_(matching_ids))
|
|
else:
|
|
return []
|
|
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),
|
|
category_ids: str | None = Query(None, description="Comma-separated category IDs (OR filter)"),
|
|
uncategorized: bool = Query(False),
|
|
favorites_only: bool = Query(False),
|
|
tag_ids: str | None = Query(None, description="Comma-separated tag IDs (AND filter)"),
|
|
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.source_quiz_id == quiz_id)
|
|
|
|
if category_ids:
|
|
cat_id_list = [int(c.strip()) for c in category_ids.split(",") if c.strip().isdigit()]
|
|
if cat_id_list:
|
|
query = query.filter(Question.question_category_id.in_(cat_id_list))
|
|
elif 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))
|
|
|
|
# Tag filter: questions must have ALL specified tags
|
|
if tag_ids:
|
|
import logging as _log
|
|
_log.getLogger(__name__).info(f"Tag filter: tag_ids={tag_ids!r}")
|
|
tag_id_list = [int(t.strip()) for t in tag_ids.split(",") if t.strip().isdigit()]
|
|
if tag_id_list:
|
|
matching_ids = list(db.execute(sa_text("""
|
|
SELECT question_id FROM question_tag_links
|
|
WHERE tag_id = ANY(:tag_ids)
|
|
GROUP BY question_id
|
|
HAVING COUNT(DISTINCT tag_id) = :cnt
|
|
"""), {"tag_ids": tag_id_list, "cnt": len(tag_id_list)}).scalars())
|
|
_log.getLogger(__name__).info(f"Tag filter matched {len(matching_ids)} questions for tags {tag_id_list}")
|
|
if matching_ids:
|
|
query = query.filter(Question.id.in_(matching_ids))
|
|
else:
|
|
return {"total": 0, "questions": []}
|
|
|
|
if favorites_only:
|
|
favorite_ids = db.query(Favorite.question_id).filter(Favorite.user_id == current_user.id).all()
|
|
fav_ids = [f[0] for f in favorite_ids]
|
|
if not fav_ids:
|
|
return {"total": 0, "questions": []}
|
|
query = query.filter(Question.id.in_(fav_ids))
|
|
|
|
# ── 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
|
|
emb = generate_embedding(q.strip())
|
|
if emb:
|
|
# Validate all values are finite floats before interpolating into SQL
|
|
emb_literal = "[" + ",".join(str(float(x)) for x in emb) + "]"
|
|
rows = db.execute(sa_text("""
|
|
SELECT id, 1 - (embedding <=> CAST(:vec AS vector)) AS sim
|
|
FROM questions
|
|
WHERE embedding IS NOT NULL
|
|
ORDER BY embedding <=> CAST(:vec AS vector)
|
|
LIMIT :lim
|
|
"""), {"vec": emb_literal, "lim": limit * 2}).fetchall()
|
|
semantic_ids_ordered = [r.id for r in rows if float(r.sim) >= 0.55]
|
|
|
|
# ── 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,
|
|
"image_path": qu.image_path,
|
|
})
|
|
|
|
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}
|