Junction table for shared questions; verification blocking; semantic bank search; bug fixes

Question sharing (edits now propagate):
- quiz_question_links junction table: quizzes reference questions, not own copies
- Existing questions migrated to junction (idempotent ON CONFLICT DO NOTHING)
- from-bank and from-category create quiz by adding junction links (no copying)
- Editing a question via QuizEditPage or bank Edit modal updates the ONE record
  visible in all quizzes that reference it
- Delete from quiz: only deletes Question record if no other quiz references it
- quiz_service: fixed position counter (was using len() incorrectly, now enumerate)
- question.quiz_id renamed to source_quiz_id in Python (DB column still quiz_id)
- All attempts/quizzes/search code updated to use junction helper or source_quiz_id

Bug fixes:
- PostgreSQL en_US.utf8 collation B-tree index corruption on users.email
  Fixed by ALTERing column to COLLATE "C" in migration + immediate repair
- get_current_user now blocks unverified users on ALL endpoints (403 + X-Unverified header)
  api/client.js intercepts X-Unverified=true and auto-logs out to /login
- Search (quiz + bank): correct_answer and explanation now included in matching_questions
  so study modals work correctly
- Keyword search: order_by updated to use source_quiz_id
- Bulk category: changed from PATCH with body array to POST with Pydantic body
  (fixes null category_id removal)
- Question bank: edit button added, answer highlighting removed from list view
- QuestionBankPage: Keyword/Semantic/Hybrid search toggle
- Login: login error message stays visible (removed immediate reload)

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel 2026-04-01 00:55:13 +02:00
parent a7a5bdff62
commit f2d15c3c57
13 changed files with 371 additions and 119 deletions

View file

@ -121,8 +121,8 @@ def seed_default_models():
def setup_pgvector(): def setup_pgvector():
"""Enable pgvector, add new columns/tables, run schema migrations.""" """Enable pgvector, add new columns/tables, run schema migrations."""
from sqlalchemy import text from sqlalchemy import text
# Import new model so create_all picks it up # Import new models so create_all picks them up
from app.models import quiz_category # noqa from app.models import quiz_category, quiz_question_link, question_category # noqa
with engine.connect() as conn: with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector")) conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)")) conn.execute(text("ALTER TABLE questions ADD COLUMN IF NOT EXISTS embedding vector(1024)"))
@ -158,6 +158,30 @@ def setup_pgvector():
ADD COLUMN IF NOT EXISTS question_category_id INTEGER ADD COLUMN IF NOT EXISTS question_category_id INTEGER
REFERENCES question_categories(id) ON DELETE SET NULL REFERENCES question_categories(id) ON DELETE SET NULL
""")) """))
# Fix email collation to avoid en_US.utf8 B-tree index corruption
conn.execute(text('ALTER TABLE users ALTER COLUMN email TYPE varchar COLLATE "C"'))
# Junction table: quiz ↔ question many-to-many
conn.execute(text("""
CREATE TABLE IF NOT EXISTS quiz_question_links (
quiz_id INTEGER NOT NULL REFERENCES quizzes(id) ON DELETE CASCADE,
question_id INTEGER NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
position INTEGER DEFAULT 0,
PRIMARY KEY (quiz_id, question_id)
)
"""))
# Populate junction from existing questions (idempotent via ON CONFLICT DO NOTHING)
conn.execute(text("""
INSERT INTO quiz_question_links (quiz_id, question_id, position)
SELECT quiz_id, id,
ROW_NUMBER() OVER (PARTITION BY quiz_id ORDER BY id) - 1
FROM questions
WHERE quiz_id IS NOT NULL
ON CONFLICT DO NOTHING
"""))
# Make quiz_id on questions nullable (it becomes informational "source_quiz_id")
conn.execute(text("ALTER TABLE questions ALTER COLUMN quiz_id DROP NOT NULL"))
conn.commit()
conn.commit() conn.commit()

View file

@ -5,13 +5,16 @@ from sqlalchemy.orm import relationship
from app.config import settings from app.config import settings
from app.database import Base from app.database import Base
from app.models.question_category import QuestionCategory # noqa — ensures mapper resolves from app.models.question_category import QuestionCategory # noqa — ensures mapper resolves
from app.models.quiz_question_link import QuizQuestionLink # noqa
class Question(Base): class Question(Base):
__tablename__ = "questions" __tablename__ = "questions"
id = Column(Integer, primary_key=True, index=True) id = Column(Integer, primary_key=True, index=True)
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), nullable=False) # source_quiz_id: which quiz this question was originally extracted for (informational).
# Content membership is tracked via quiz_question_links junction table.
source_quiz_id = Column("quiz_id", Integer, ForeignKey("quizzes.id", ondelete="SET NULL"), nullable=True)
question_category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True) question_category_id = Column(Integer, ForeignKey("question_categories.id", ondelete="SET NULL"), nullable=True)
question_text = Column(Text, nullable=False) question_text = Column(Text, nullable=False)
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
@ -22,6 +25,5 @@ class Question(Base):
image_path = Column(String, nullable=True) image_path = Column(String, nullable=True)
embedding = Column(Vector(1024), nullable=True) # semantic search vector embedding = Column(Vector(1024), nullable=True) # semantic search vector
quiz = relationship("Quiz", back_populates="questions")
question_category = relationship("QuestionCategory", back_populates="questions", question_category = relationship("QuestionCategory", back_populates="questions",
foreign_keys=[question_category_id]) foreign_keys=[question_category_id])

View file

@ -5,6 +5,7 @@ from sqlalchemy.orm import relationship
from app.database import Base from app.database import Base
from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory" from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory"
from app.models.quiz_question_link import QuizQuestionLink # noqa
class Quiz(Base): class Quiz(Base):
@ -24,6 +25,13 @@ class Quiz(Base):
section = relationship("Section", back_populates="quizzes") section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes") user = relationship("User", back_populates="quizzes")
category = relationship("QuizCategory", back_populates="quizzes", foreign_keys=[category_id]) category = relationship("QuizCategory", back_populates="quizzes", foreign_keys=[category_id])
questions = relationship("Question", back_populates="quiz", cascade="all, delete-orphan") questions = relationship(
"Question",
secondary="quiz_question_links",
primaryjoin="Quiz.id == foreign(QuizQuestionLink.quiz_id)",
secondaryjoin="Question.id == foreign(QuizQuestionLink.question_id)",
order_by="QuizQuestionLink.position",
viewonly=True, # mutations handled explicitly via QuizQuestionLink
)
attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan") attempts = relationship("QuizAttempt", back_populates="quiz", cascade="all, delete-orphan")
reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id") reminders = relationship("ReminderSchedule", cascade="all, delete-orphan", foreign_keys="ReminderSchedule.quiz_id")

View file

@ -0,0 +1,10 @@
from sqlalchemy import Column, Integer, ForeignKey
from app.database import Base
class QuizQuestionLink(Base):
__tablename__ = "quiz_question_links"
quiz_id = Column(Integer, ForeignKey("quizzes.id", ondelete="CASCADE"), primary_key=True)
question_id = Column(Integer, ForeignKey("questions.id", ondelete="CASCADE"), primary_key=True)
position = Column(Integer, default=0)

View file

@ -19,6 +19,7 @@ from app.schemas.attempt import (
QuizStats, QuizStats,
) )
from app.utils.auth import get_current_user from app.utils.auth import get_current_user
from app.utils.quiz_questions import get_quiz_questions
router = APIRouter() router = APIRouter()
@ -68,10 +69,8 @@ def submit_attempt(
if attempt.completed_at: if attempt.completed_at:
raise HTTPException(status_code=400, detail="Attempt already submitted") raise HTTPException(status_code=400, detail="Attempt already submitted")
# Get all questions for this quiz # Get all questions for this quiz via junction table
questions = { questions = {q.id: q for q in get_quiz_questions(db, attempt.quiz_id)}
q.id: q for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).all()
}
score = 0 score = 0
submitted = {ans.question_id: ans.user_answer for ans in submission.answers} submitted = {ans.question_id: ans.user_answer for ans in submission.answers}
@ -93,7 +92,7 @@ def submit_attempt(
# Build full review — include ALL questions, unanswered marked as incorrect # Build full review — include ALL questions, unanswered marked as incorrect
answer_details = [] answer_details = []
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all(): for q in get_quiz_questions(db, attempt.quiz_id):
user_answer = submitted.get(q.id, "") user_answer = submitted.get(q.id, "")
is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower() is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
answer_details.append(AnswerDetail( answer_details.append(AnswerDetail(
@ -251,7 +250,7 @@ def get_attempt(
# Show ALL questions in review, not just answered ones # Show ALL questions in review, not just answered ones
submitted_map = {ans.question_id: ans for ans in attempt.answers} submitted_map = {ans.question_id: ans for ans in attempt.answers}
answer_details = [] answer_details = []
for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).order_by(Question.id).all(): for q in get_quiz_questions(db, attempt.quiz_id):
ans = submitted_map.get(q.id) ans = submitted_map.get(q.id)
answer_details.append(AnswerDetail( answer_details.append(AnswerDetail(
question_id=q.id, question_id=q.id,

View file

@ -119,11 +119,11 @@ def create_quiz_from_question_category(
if not source_questions: if not source_questions:
raise HTTPException(status_code=400, detail="This category has no questions") raise HTTPException(status_code=400, detail="This category has no questions")
first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first() first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].source_quiz_id).first() if source_questions[0].source_quiz_id else None
if not first_quiz: if not first_quiz:
raise HTTPException(status_code=400, detail="Cannot determine source section") raise HTTPException(status_code=400, detail="Cannot determine source section — questions must have an origin quiz")
from app.services import embedding_service from app.models.quiz_question_link import QuizQuestionLink
new_quiz = Quiz( new_quiz = Quiz(
section_id=first_quiz.section_id, section_id=first_quiz.section_id,
user_id=current_user.id, user_id=current_user.id,
@ -135,23 +135,9 @@ def create_quiz_from_question_category(
db.add(new_quiz) db.add(new_quiz)
db.flush() db.flush()
for sq in source_questions: # Reference existing questions via junction — no copies, edits propagate
new_q = Question( for pos, sq in enumerate(source_questions):
quiz_id=new_quiz.id, db.add(QuizQuestionLink(quiz_id=new_quiz.id, question_id=sq.id, position=pos))
question_category_id=sq.question_category_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.commit()
db.refresh(new_quiz) db.refresh(new_quiz)

View file

@ -34,31 +34,13 @@ def set_question_category(
return {"question_id": question_id, "question_category_id": category_id} 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") @router.get("/bank")
def get_question_bank( def get_question_bank(
q: str | None = Query(None), q: str | None = Query(None),
quiz_id: int | None = Query(None), quiz_id: int | None = Query(None),
category_id: int | None = Query(None), category_id: int | None = Query(None),
uncategorized: bool = Query(False), uncategorized: bool = Query(False),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200), limit: int = Query(50, le=200),
offset: int = Query(0), offset: int = Query(0),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -76,7 +58,25 @@ def get_question_bank(
if uncategorized: if uncategorized:
query = query.filter(Question.question_category_id.is_(None)) query = query.filter(Question.question_category_id.is_(None))
if q and q.strip(): # ── 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() phrase = q.strip()
query = query.filter( query = query.filter(
or_( or_(
@ -85,16 +85,31 @@ def get_question_bank(
) )
) )
# 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() total = query.count()
questions = query.order_by(Question.quiz_id, Question.id).offset(offset).limit(limit).all() 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] = {} quiz_cache: dict[int, str] = {}
cat_cache: dict[int, str] = {} cat_cache: dict[int, str] = {}
result = [] result = []
for qu in questions: for qu in questions:
if qu.quiz_id not in quiz_cache: src_id = qu.source_quiz_id
quiz = db.query(Quiz).filter(Quiz.id == qu.quiz_id).first() if src_id not in quiz_cache:
quiz_cache[qu.quiz_id] = quiz.title if quiz else f"Quiz {qu.quiz_id}" 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 cat_name = None
if qu.question_category_id: if qu.question_category_id:
if qu.question_category_id not in cat_cache: if qu.question_category_id not in cat_cache:
@ -103,8 +118,8 @@ def get_question_bank(
cat_name = cat_cache.get(qu.question_category_id) cat_name = cat_cache.get(qu.question_category_id)
result.append({ result.append({
"id": qu.id, "id": qu.id,
"quiz_id": qu.quiz_id, "quiz_id": qu.source_quiz_id,
"quiz_title": quiz_cache[qu.quiz_id], "quiz_title": quiz_cache[src_id],
"question_category_id": qu.question_category_id, "question_category_id": qu.question_category_id,
"question_category_name": cat_name, "question_category_name": cat_name,
"question_text": qu.question_text, "question_text": qu.question_text,
@ -130,7 +145,7 @@ def create_quiz_from_bank(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_moderator), current_user: User = Depends(require_moderator),
): ):
"""Create a new quiz by copying selected questions from the question bank.""" """Create a new quiz referencing existing bank questions (no copying — edits propagate)."""
if not data.title.strip(): if not data.title.strip():
raise HTTPException(status_code=400, detail="Title is required") raise HTTPException(status_code=400, detail="Title is required")
if not data.question_ids: if not data.question_ids:
@ -138,19 +153,20 @@ def create_quiz_from_bank(
if data.mode not in ("timed", "learning"): if data.mode not in ("timed", "learning"):
raise HTTPException(status_code=400, detail="Mode must be timed or 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() source_questions = db.query(Question).filter(Question.id.in_(data.question_ids)).all()
if not source_questions: if not source_questions:
raise HTTPException(status_code=404, detail="No valid questions found") 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)))
# Use the section_id from the first source question's quiz # Find a section_id from the source questions' origin quiz
first_quiz = db.query(Quiz).filter(Quiz.id == source_questions[0].quiz_id).first() first_src_quiz_id = source_questions[0].source_quiz_id
if not first_quiz: first_quiz = db.query(Quiz).filter(Quiz.id == first_src_quiz_id).first() if first_src_quiz_id else None
raise HTTPException(status_code=400, detail="Source quiz not found") section_id = first_quiz.section_id if first_quiz else source_questions[0].source_quiz_id or 1
import json
new_quiz = Quiz( new_quiz = Quiz(
section_id=first_quiz.section_id, section_id=section_id,
user_id=current_user.id, user_id=current_user.id,
title=data.title.strip(), title=data.title.strip(),
questions_count=len(source_questions), questions_count=len(source_questions),
@ -160,25 +176,34 @@ def create_quiz_from_bank(
db.add(new_quiz) db.add(new_quiz)
db.flush() db.flush()
# Copy questions (independent copies — original questions untouched) # Reference existing questions via junction (no copies)
from app.services import embedding_service from app.models.quiz_question_link import QuizQuestionLink
for sq in source_questions: for pos, sq in enumerate(source_questions):
new_q = Question( db.add(QuizQuestionLink(quiz_id=new_quiz.id, question_id=sq.id, position=pos))
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.commit()
db.refresh(new_quiz) db.refresh(new_quiz)
return {"id": new_quiz.id, "title": new_quiz.title, "questions_count": new_quiz.questions_count} 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}

View file

@ -12,6 +12,7 @@ from app.models.user import User
from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview from app.schemas.quiz import QuizCreate, QuizResponse, QuizDetail, QuizLearningDetail, QuizReview
from app.services import quiz_service from app.services import quiz_service
from app.utils.auth import get_current_user, require_moderator from app.utils.auth import get_current_user, require_moderator
from app.utils.quiz_questions import get_quiz_questions, question_in_quiz, remove_question_from_quiz
router = APIRouter() router = APIRouter()
@ -99,6 +100,7 @@ def search_quizzes(
emb_literal = "[" + ",".join(str(x) for x in query_emb) + "]" emb_literal = "[" + ",".join(str(x) for x in query_emb) + "]"
rows = db.execute(sa_text(f""" rows = db.execute(sa_text(f"""
SELECT q.id, q.quiz_id, q.question_text, q.options, SELECT q.id, q.quiz_id, q.question_text, q.options,
q.correct_answer, q.explanation,
1 - (q.embedding <=> '{emb_literal}'::vector) AS similarity 1 - (q.embedding <=> '{emb_literal}'::vector) AS similarity
FROM questions q FROM questions q
WHERE q.embedding IS NOT NULL WHERE q.embedding IS NOT NULL
@ -110,12 +112,15 @@ def search_quizzes(
similarity = float(row.similarity) similarity = float(row.similarity)
if similarity < 0.30: if similarity < 0.30:
continue continue
if _ensure_quiz(row.quiz_id, "questions"): quiz_id_val = row.quiz_id # DB column still named quiz_id
if quiz_id_val and _ensure_quiz(quiz_id_val, "questions"):
seen_question_ids.add(row.id) seen_question_ids.add(row.id)
results[row.quiz_id]["matching_questions"].append({ results[quiz_id_val]["matching_questions"].append({
"id": row.id, "id": row.id,
"question_text": row.question_text, "question_text": row.question_text,
"options": row.options, "options": row.options,
"correct_answer": row.correct_answer,
"explanation": row.explanation,
"similarity": round(similarity, 3), "similarity": round(similarity, 3),
"match_source": "semantic", "match_source": "semantic",
}) })
@ -129,18 +134,21 @@ def search_quizzes(
keyword_rows = ( keyword_rows = (
db.query(QuestionModel) db.query(QuestionModel)
.filter(q_filter) .filter(q_filter)
.order_by(QuestionModel.quiz_id, QuestionModel.id) .order_by(QuestionModel.source_quiz_id, QuestionModel.id)
.limit(200) .limit(200)
.all() .all()
) )
for question in keyword_rows: for question in keyword_rows:
if _ensure_quiz(question.quiz_id, "questions"): src_qid = question.source_quiz_id
if src_qid and _ensure_quiz(src_qid, "questions"):
if question.id not in seen_question_ids: if question.id not in seen_question_ids:
seen_question_ids.add(question.id) seen_question_ids.add(question.id)
results[question.quiz_id]["matching_questions"].append({ results[src_qid]["matching_questions"].append({
"id": question.id, "id": question.id,
"question_text": question.question_text, "question_text": question.question_text,
"options": question.options, "options": question.options,
"correct_answer": question.correct_answer,
"explanation": question.explanation,
"similarity": None, "similarity": None,
"match_source": "keyword", "match_source": "keyword",
}) })
@ -208,7 +216,7 @@ def shuffle_quiz(
if not quiz: if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found") raise HTTPException(status_code=404, detail="Quiz not found")
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).all() questions = get_quiz_questions(db, quiz_id)
shuffled_questions = questions.copy() shuffled_questions = questions.copy()
random.shuffle(shuffled_questions) random.shuffle(shuffled_questions)
@ -267,11 +275,10 @@ def get_quiz_questions_for_edit(
current_user: User = Depends(require_moderator), current_user: User = Depends(require_moderator),
): ):
"""Get all questions with answers for editing — moderator/admin only.""" """Get all questions with answers for editing — moderator/admin only."""
from app.models.question import Question as QuestionModel
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if not quiz: if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found") raise HTTPException(status_code=404, detail="Quiz not found")
questions = db.query(QuestionModel).filter(QuestionModel.quiz_id == quiz_id).order_by(QuestionModel.id).all() questions = get_quiz_questions(db, quiz_id)
return [ return [
{ {
"id": q.id, "id": q.id,
@ -293,12 +300,10 @@ def update_question(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_moderator), current_user: User = Depends(require_moderator),
): ):
"""Update a quiz question — moderator/admin only.""" """Update a quiz question — change is shared: affects all quizzes using this question."""
from app.models.question import Question as QuestionModel if not question_in_quiz(db, quiz_id, question_id):
question = db.query(QuestionModel).filter( raise HTTPException(status_code=404, detail="Question not found in this quiz")
QuestionModel.id == question_id, question = db.query(QuestionModel).filter(QuestionModel.id == question_id).first()
QuestionModel.quiz_id == quiz_id,
).first()
if not question: if not question:
raise HTTPException(status_code=404, detail="Question not found") raise HTTPException(status_code=404, detail="Question not found")
@ -333,20 +338,28 @@ def delete_question(
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: User = Depends(require_moderator), current_user: User = Depends(require_moderator),
): ):
"""Delete a single question from a quiz — moderator/admin only.""" """Remove a question from this quiz. If it's shared, only removes the link (question stays in bank).
from app.models.question import Question as QuestionModel If it's only in this quiz, deletes it from the bank too."""
question = db.query(QuestionModel).filter( if not question_in_quiz(db, quiz_id, question_id):
QuestionModel.id == question_id, raise HTTPException(status_code=404, detail="Question not found in this quiz")
QuestionModel.quiz_id == quiz_id,
).first() from app.models.quiz_question_link import QuizQuestionLink
if not question: # Check if used by any OTHER quiz
raise HTTPException(status_code=404, detail="Question not found") other_uses = db.query(QuizQuestionLink).filter(
QuizQuestionLink.question_id == question_id,
QuizQuestionLink.quiz_id != quiz_id,
).count()
remove_question_from_quiz(db, quiz_id, question_id)
quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first()
if quiz and quiz.questions_count > 0: if quiz and quiz.questions_count > 0:
quiz.questions_count -= 1 quiz.questions_count -= 1
db.delete(question) # Only delete the question record if no other quiz references it
if other_uses == 0:
db.query(QuestionModel).filter(QuestionModel.id == question_id).delete()
db.commit() db.commit()

View file

@ -9,6 +9,7 @@ from app.models.pdf_document import PDFDocument
from app.models.quiz import Quiz from app.models.quiz import Quiz
from app.models.question import Question from app.models.question import Question
from app.services import ai_service, vector_service, pdf_service, embedding_service from app.services import ai_service, vector_service, pdf_service, embedding_service
from app.utils.quiz_questions import add_questions_to_quiz
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -91,7 +92,7 @@ def create_quiz_from_section(
db.flush() db.flush()
# Create question records, associating images where possible # Create question records, associating images where possible
for q in valid_questions: for pos, q in enumerate(valid_questions):
page_ref = q.get("page_reference") page_ref = q.get("page_reference")
image_path = None image_path = None
@ -103,7 +104,7 @@ def create_quiz_from_section(
del page_images[page_ref] del page_images[page_ref]
question = Question( question = Question(
quiz_id=quiz.id, source_quiz_id=quiz.id,
question_category_id=question_category_id, question_category_id=question_category_id,
question_text=q["question_text"], question_text=q["question_text"],
question_type=q["question_type"], question_type=q["question_type"],
@ -115,6 +116,9 @@ def create_quiz_from_section(
) )
db.add(question) db.add(question)
db.flush() db.flush()
# Register in junction table
from app.models.quiz_question_link import QuizQuestionLink
db.add(QuizQuestionLink(quiz_id=quiz.id, question_id=question.id, position=pos))
try: try:
embedding_service.embed_question(question) embedding_service.embed_question(question)
except Exception as e: except Exception as e:

View file

@ -49,6 +49,17 @@ def get_current_user(
user = db.query(User).filter(User.email == email).first() user = db.query(User).filter(User.email == email).first()
if user is None: if user is None:
raise credentials_exception raise credentials_exception
# Block unverified users from all protected endpoints
from app.models.email_verification import EmailVerification
verification = db.query(EmailVerification).filter(EmailVerification.user_id == user.id).first()
if verification and verification.verified_at is None:
raise HTTPException(
status_code=403,
detail="Email not verified. Please verify your email before using the app.",
headers={"X-Unverified": "true"},
)
return user return user

View file

@ -0,0 +1,47 @@
"""Helpers for managing the quiz ↔ question junction table."""
from sqlalchemy.orm import Session
from app.models.question import Question
from app.models.quiz_question_link import QuizQuestionLink
def get_quiz_questions(db: Session, quiz_id: int) -> list[Question]:
"""Fetch questions for a quiz in position order via junction table."""
links = (
db.query(QuizQuestionLink)
.filter(QuizQuestionLink.quiz_id == quiz_id)
.order_by(QuizQuestionLink.position)
.all()
)
if not links:
return []
q_map = {
q.id: q
for q in db.query(Question).filter(Question.id.in_([l.question_id for l in links])).all()
}
return [q_map[l.question_id] for l in links if l.question_id in q_map]
def add_questions_to_quiz(db: Session, quiz_id: int, question_ids: list[int], start_pos: int = 0):
"""Add question links to a quiz (skips duplicates)."""
existing = {
l.question_id
for l in db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all()
}
for i, qid in enumerate(question_ids):
if qid not in existing:
db.add(QuizQuestionLink(quiz_id=quiz_id, question_id=qid, position=start_pos + i))
def question_in_quiz(db: Session, quiz_id: int, question_id: int) -> bool:
return db.query(QuizQuestionLink).filter(
QuizQuestionLink.quiz_id == quiz_id,
QuizQuestionLink.question_id == question_id,
).first() is not None
def remove_question_from_quiz(db: Session, quiz_id: int, question_id: int):
db.query(QuizQuestionLink).filter(
QuizQuestionLink.quiz_id == quiz_id,
QuizQuestionLink.question_id == question_id,
).delete()

View file

@ -19,6 +19,11 @@ api.interceptors.response.use(
localStorage.removeItem('token') localStorage.removeItem('token')
window.location.href = '/login' window.location.href = '/login'
} }
// Unverified email — clear token and redirect to login (will show resend option)
if (error.response?.status === 403 && error.response?.headers?.['x-unverified'] === 'true') {
localStorage.removeItem('token')
window.location.href = '/login'
}
return Promise.reject(error) return Promise.reject(error)
} }
) )

View file

@ -118,16 +118,118 @@ function CreateQuizModal({ selectedIds, categories, onClose, onCreated }) {
) )
} }
const LETTERS = ['A','B','C','D','E','F']
function QuestionEditModal({ question, categories, onSaved, onClose }) {
const [form, setForm] = useState({
question_text: question.question_text,
question_type: question.question_type,
options: question.options ? [...question.options] : [],
correct_answer: question.correct_answer,
explanation: question.explanation || '',
question_category_id: question.question_category_id || '',
})
const [saving, setSaving] = useState(false)
const [error, setError] = useState('')
const setOption = (i, val) => {
const updated = [...form.options]
const wasCorrect = form.options[i] === form.correct_answer
updated[i] = val
setForm(f => ({ ...f, options: updated, correct_answer: wasCorrect ? val : f.correct_answer }))
}
const save = async () => {
if (!form.question_text.trim()) return setError('Question text is required')
if (form.options.length > 0 && !form.options.includes(form.correct_answer))
return setError('Correct answer must match one of the options')
setSaving(true); setError('')
try {
const payload = { ...form, question_category_id: form.question_category_id ? parseInt(form.question_category_id) : null }
// Update via quiz edit endpoint (uses source quiz for routing)
const res = await api.patch(`/quizzes/${question.quiz_id}/questions/${question.id}`, payload)
// Also update category if changed
if (form.question_category_id !== (question.question_category_id || '')) {
await api.patch(`/questions/${question.id}/category`, null, {
params: { category_id: form.question_category_id ? parseInt(form.question_category_id) : '' }
})
}
onSaved({ ...question, ...res.data, question_category_id: payload.question_category_id,
question_category_name: categories.find(c => c.id === payload.question_category_id)?.name || null })
onClose()
} catch (err) { setError(err.response?.data?.detail || 'Save failed') }
finally { setSaving(false) }
}
return (
<div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.55)', zIndex: 1000, display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 16 }}
onClick={e => e.target === e.currentTarget && onClose()}>
<div style={{ background: 'var(--card-bg)', borderRadius: 12, padding: 24, maxWidth: 640, width: '100%', maxHeight: '90vh', overflowY: 'auto', boxShadow: '0 20px 60px rgba(0,0,0,0.3)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
<h2 style={{ fontSize: '1.1rem' }}>Edit Question</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: '1.2rem' }}></button>
</div>
<p style={{ fontSize: '0.78rem', color: 'var(--text-muted)', marginBottom: 16 }}>
This question is shared changes apply to all quizzes using it.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div className="form-group">
<label>Question Text</label>
<textarea rows={4} value={form.question_text} onChange={e => setForm(f => ({ ...f, question_text: e.target.value }))}
style={{ fontFamily: 'inherit' }} />
</div>
{form.options.length > 0 && (
<div className="form-group">
<label>Options select the correct one</label>
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{form.options.map((opt, i) => (
<div key={i} style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<input type="radio" name="correct" checked={form.correct_answer === opt}
onChange={() => setForm(f => ({ ...f, correct_answer: opt }))}
style={{ width: 'auto', accentColor: 'var(--primary)' }} />
<span style={{ display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: 24, height: 24, borderRadius: '50%', flexShrink: 0, fontSize: '0.72rem', fontWeight: 700,
background: form.correct_answer === opt ? 'var(--correct-fg)' : 'var(--border)',
color: form.correct_answer === opt ? 'white' : 'var(--text-muted)' }}>{LETTERS[i]}</span>
<input type="text" value={opt} onChange={e => setOption(i, e.target.value)}
style={{ flex: 1, padding: '7px 12px', border: `1.5px solid ${form.correct_answer === opt ? 'var(--correct-bd)' : 'var(--border)'}`, borderRadius: 6, fontSize: '0.875rem', background: form.correct_answer === opt ? 'var(--correct-bg)' : 'var(--input-bg)', color: 'var(--text)', fontFamily: 'inherit' }} />
</div>
))}
</div>
</div>
)}
<div className="form-group">
<label>Question Category</label>
<select value={form.question_category_id} onChange={e => setForm(f => ({ ...f, question_category_id: e.target.value }))}>
<option value=""> Uncategorized </option>
{categories.map(c => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
</div>
<div className="form-group">
<label>Explanation</label>
<textarea rows={6} value={form.explanation} onChange={e => setForm(f => ({ ...f, explanation: e.target.value }))}
style={{ fontFamily: 'inherit' }} />
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn btn-primary" onClick={save} disabled={saving}>{saving ? 'Saving…' : 'Save Changes'}</button>
<button className="btn btn-secondary" onClick={onClose}>Cancel</button>
</div>
</div>
</div>
)
}
export default function QuestionBankPage() { export default function QuestionBankPage() {
const [questions, setQuestions] = useState([]) const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [categories, setCategories] = useState([]) const [categories, setCategories] = useState([])
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('hybrid')
const [filterCatId, setFilterCatId] = useState('') const [filterCatId, setFilterCatId] = useState('')
const [showUncategorized, setShowUncategorized] = useState(false) const [showUncategorized, setShowUncategorized] = useState(false)
const [offset, setOffset] = useState(0) const [offset, setOffset] = useState(0)
const [studyQuestion, setStudyQuestion] = useState(null) const [studyQuestion, setStudyQuestion] = useState(null)
const [editQuestion, setEditQuestion] = useState(null)
const [selectedIds, setSelectedIds] = useState(new Set()) const [selectedIds, setSelectedIds] = useState(new Set())
const [showCreateQuiz, setShowCreateQuiz] = useState(false) const [showCreateQuiz, setShowCreateQuiz] = useState(false)
const [newCatName, setNewCatName] = useState('') const [newCatName, setNewCatName] = useState('')
@ -142,10 +244,10 @@ export default function QuestionBankPage() {
api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {}) api.get('/question-categories/').then(res => setCategories(res.data)).catch(() => {})
}, []) }, [])
const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized) => { const loadQuestions = async (query = searchQuery, off = 0, catId = filterCatId, uncatOnly = showUncategorized, mode = searchMode) => {
setLoading(true) setLoading(true)
try { try {
const params = { limit: LIMIT, offset: off } const params = { limit: LIMIT, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim() if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId) if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true if (uncatOnly) params.uncategorized = true
@ -158,9 +260,9 @@ export default function QuestionBankPage() {
useEffect(() => { useEffect(() => {
clearTimeout(debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized), 300) debounceRef.current = setTimeout(() => loadQuestions(searchQuery, 0, filterCatId, showUncategorized, searchMode), 300)
return () => clearTimeout(debounceRef.current) return () => clearTimeout(debounceRef.current)
}, [searchQuery, filterCatId, showUncategorized]) }, [searchQuery, filterCatId, showUncategorized, searchMode])
const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n }) const toggleSelect = (id) => setSelectedIds(prev => { const n = new Set(prev); n.has(id) ? n.delete(id) : n.add(id); return n })
const selectAll = () => setSelectedIds(new Set(questions.map(q => q.id))) const selectAll = () => setSelectedIds(new Set(questions.map(q => q.id)))
@ -170,7 +272,10 @@ export default function QuestionBankPage() {
if (!assignCatId && assignCatId !== '0') return if (!assignCatId && assignCatId !== '0') return
const catIdVal = assignCatId === '0' ? null : parseInt(assignCatId) const catIdVal = assignCatId === '0' ? null : parseInt(assignCatId)
try { try {
await api.patch('/questions/bulk-category', [...selectedIds], { params: { category_id: catIdVal ?? '' } }) await api.post('/questions/bulk-category', {
question_ids: [...selectedIds],
category_id: catIdVal,
})
setQuestions(prev => prev.map(q => selectedIds.has(q.id) setQuestions(prev => prev.map(q => selectedIds.has(q.id)
? { ...q, question_category_id: catIdVal, question_category_name: categories.find(c => c.id === catIdVal)?.name ?? null } ? { ...q, question_category_id: catIdVal, question_category_name: categories.find(c => c.id === catIdVal)?.name ?? null }
: q)) : q))
@ -202,6 +307,9 @@ export default function QuestionBankPage() {
return ( return (
<div> <div>
{studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />} {studyQuestion && <QuestionStudyModal question={studyQuestion} onClose={() => setStudyQuestion(null)} />}
{editQuestion && <QuestionEditModal question={editQuestion} categories={categories}
onSaved={updated => setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q))}
onClose={() => setEditQuestion(null)} />}
{showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />} {showCreateQuiz && <CreateQuizModal selectedIds={selectedIds} categories={categories} onClose={() => setShowCreateQuiz(false)} onCreated={() => {}} />}
{/* Header */} {/* Header */}
@ -253,12 +361,20 @@ export default function QuestionBankPage() {
)} )}
{/* Search + bulk actions */} {/* Search + bulk actions */}
<div style={{ display: 'flex', gap: 8, marginBottom: 12, flexWrap: 'wrap', alignItems: 'center' }}> <div style={{ display: 'flex', gap: 8, marginBottom: 8, flexWrap: 'wrap', alignItems: 'center' }}>
<div style={{ flex: 1, minWidth: 200, position: 'relative' }}> <div style={{ flex: 1, minWidth: 200, position: 'relative' }}>
<span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8' }}>🔍</span> <span style={{ position: 'absolute', left: 12, top: '50%', transform: 'translateY(-50%)', color: '#94a3b8' }}>🔍</span>
<input type="text" placeholder="Search questions..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)} <input type="text" placeholder="Search questions..." value={searchQuery} onChange={e => setSearchQuery(e.target.value)}
style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} /> style={{ width: '100%', padding: '9px 13px 9px 36px', border: '1px solid var(--border)', borderRadius: 8, fontSize: '0.9rem', background: 'var(--input-bg)', color: 'var(--text)' }} />
</div> </div>
<div style={{ display: 'flex', gap: 4 }}>
{[['keyword','Keyword'],['semantic','Semantic'],['hybrid','Hybrid']].map(([val, label]) => (
<button key={val} className={`btn btn-sm ${searchMode === val ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setSearchMode(val)} title={val === 'keyword' ? 'Exact word match' : val === 'semantic' ? 'Meaning-based (AI)' : 'Both combined'}>
{label}
</button>
))}
</div>
{isModerator && ( {isModerator && (
<> <>
<button className="btn btn-sm btn-secondary" onClick={selectAll} disabled={questions.length === 0}>Select all</button> <button className="btn btn-sm btn-secondary" onClick={selectAll} disabled={questions.length === 0}>Select all</button>
@ -309,17 +425,19 @@ export default function QuestionBankPage() {
{q.options.map((opt, i) => ( {q.options.map((opt, i) => (
<span key={i} style={{ <span key={i} style={{
fontSize: '0.72rem', padding: '2px 7px', borderRadius: 4, fontSize: '0.72rem', padding: '2px 7px', borderRadius: 4,
background: opt === q.correct_answer ? 'var(--correct-bg)' : 'var(--bg)', background: 'var(--bg)', color: 'var(--text-muted)',
color: opt === q.correct_answer ? 'var(--correct-fg)' : 'var(--text-muted)', border: '1px solid var(--border)',
border: `1px solid ${opt === q.correct_answer ? 'var(--correct-bd)' : 'var(--border)'}`,
}}> }}>
{String.fromCharCode(65 + i)}. {opt.slice(0, 45)}{opt.length > 45 ? '…' : ''} {String.fromCharCode(65 + i)}. {opt.slice(0, 50)}{opt.length > 50 ? '…' : ''}
</span> </span>
))} ))}
</div> </div>
)} )}
</div> </div>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)} style={{ flexShrink: 0 }}>Study</button> <div style={{ display: 'flex', flexDirection: 'column', gap: 4, flexShrink: 0 }}>
<button className="btn btn-sm btn-secondary" onClick={() => setStudyQuestion(q)}>Study</button>
{isModerator && <button className="btn btn-sm btn-secondary" onClick={() => setEditQuestion(q)}>Edit</button>}
</div>
</div> </div>
))} ))}