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():
"""Enable pgvector, add new columns/tables, run schema migrations."""
from sqlalchemy import text
# Import new model so create_all picks it up
from app.models import quiz_category # noqa
# Import new models so create_all picks them up
from app.models import quiz_category, quiz_question_link, question_category # noqa
with engine.connect() as conn:
conn.execute(text("CREATE EXTENSION IF NOT EXISTS vector"))
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
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()

View file

@ -5,13 +5,16 @@ from sqlalchemy.orm import relationship
from app.config import settings
from app.database import Base
from app.models.question_category import QuestionCategory # noqa — ensures mapper resolves
from app.models.quiz_question_link import QuizQuestionLink # noqa
class Question(Base):
__tablename__ = "questions"
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_text = Column(Text, nullable=False)
question_type = Column(String, nullable=False) # mcq, true_false, fill_blank
@ -22,6 +25,5 @@ class Question(Base):
image_path = Column(String, nullable=True)
embedding = Column(Vector(1024), nullable=True) # semantic search vector
quiz = relationship("Quiz", back_populates="questions")
question_category = relationship("QuestionCategory", back_populates="questions",
foreign_keys=[question_category_id])

View file

@ -5,6 +5,7 @@ from sqlalchemy.orm import relationship
from app.database import Base
from app.models.quiz_category import QuizCategory # noqa — ensures mapper resolves "QuizCategory"
from app.models.quiz_question_link import QuizQuestionLink # noqa
class Quiz(Base):
@ -24,6 +25,13 @@ class Quiz(Base):
section = relationship("Section", back_populates="quizzes")
user = relationship("User", back_populates="quizzes")
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")
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,
)
from app.utils.auth import get_current_user
from app.utils.quiz_questions import get_quiz_questions
router = APIRouter()
@ -68,10 +69,8 @@ def submit_attempt(
if attempt.completed_at:
raise HTTPException(status_code=400, detail="Attempt already submitted")
# Get all questions for this quiz
questions = {
q.id: q for q in db.query(Question).filter(Question.quiz_id == attempt.quiz_id).all()
}
# Get all questions for this quiz via junction table
questions = {q.id: q for q in get_quiz_questions(db, attempt.quiz_id)}
score = 0
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
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, "")
is_correct = bool(user_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower()
answer_details.append(AnswerDetail(
@ -251,7 +250,7 @@ def get_attempt(
# Show ALL questions in review, not just answered ones
submitted_map = {ans.question_id: ans for ans in attempt.answers}
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)
answer_details.append(AnswerDetail(
question_id=q.id,

View file

@ -119,11 +119,11 @@ def create_quiz_from_question_category(
if not source_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:
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(
section_id=first_quiz.section_id,
user_id=current_user.id,
@ -135,23 +135,9 @@ def create_quiz_from_question_category(
db.add(new_quiz)
db.flush()
for sq in source_questions:
new_q = Question(
quiz_id=new_quiz.id,
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
# Reference existing questions via junction — no copies, edits propagate
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)

View file

@ -34,31 +34,13 @@ def set_question_category(
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),
search_mode: str = Query("hybrid"), # "keyword" | "semantic" | "hybrid"
limit: int = Query(50, le=200),
offset: int = Query(0),
db: Session = Depends(get_db),
@ -76,7 +58,25 @@ def get_question_bank(
if uncategorized:
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()
query = query.filter(
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()
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] = {}
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}"
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:
@ -103,8 +118,8 @@ def get_question_bank(
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],
"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,
@ -130,7 +145,7 @@ def create_quiz_from_bank(
db: Session = Depends(get_db),
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():
raise HTTPException(status_code=400, detail="Title is required")
if not data.question_ids:
@ -138,19 +153,20 @@ def create_quiz_from_bank(
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")
# 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
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")
# 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
import json
new_quiz = Quiz(
section_id=first_quiz.section_id,
section_id=section_id,
user_id=current_user.id,
title=data.title.strip(),
questions_count=len(source_questions),
@ -160,25 +176,34 @@ def create_quiz_from_bank(
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
# 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}

View file

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

View file

@ -9,6 +9,7 @@ from app.models.pdf_document import PDFDocument
from app.models.quiz import Quiz
from app.models.question import Question
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__)
@ -91,7 +92,7 @@ def create_quiz_from_section(
db.flush()
# 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")
image_path = None
@ -103,7 +104,7 @@ def create_quiz_from_section(
del page_images[page_ref]
question = Question(
quiz_id=quiz.id,
source_quiz_id=quiz.id,
question_category_id=question_category_id,
question_text=q["question_text"],
question_type=q["question_type"],
@ -115,6 +116,9 @@ def create_quiz_from_section(
)
db.add(question)
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:
embedding_service.embed_question(question)
except Exception as e:

View file

@ -49,6 +49,17 @@ def get_current_user(
user = db.query(User).filter(User.email == email).first()
if user is None:
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

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')
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)
}
)

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() {
const [questions, setQuestions] = useState([])
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(false)
const [categories, setCategories] = useState([])
const [searchQuery, setSearchQuery] = useState('')
const [searchMode, setSearchMode] = useState('hybrid')
const [filterCatId, setFilterCatId] = useState('')
const [showUncategorized, setShowUncategorized] = useState(false)
const [offset, setOffset] = useState(0)
const [studyQuestion, setStudyQuestion] = useState(null)
const [editQuestion, setEditQuestion] = useState(null)
const [selectedIds, setSelectedIds] = useState(new Set())
const [showCreateQuiz, setShowCreateQuiz] = useState(false)
const [newCatName, setNewCatName] = useState('')
@ -142,10 +244,10 @@ export default function QuestionBankPage() {
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)
try {
const params = { limit: LIMIT, offset: off }
const params = { limit: LIMIT, offset: off, search_mode: mode }
if (query.trim()) params.q = query.trim()
if (catId) params.category_id = parseInt(catId)
if (uncatOnly) params.uncategorized = true
@ -158,9 +260,9 @@ export default function QuestionBankPage() {
useEffect(() => {
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)
}, [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 selectAll = () => setSelectedIds(new Set(questions.map(q => q.id)))
@ -170,7 +272,10 @@ export default function QuestionBankPage() {
if (!assignCatId && assignCatId !== '0') return
const catIdVal = assignCatId === '0' ? null : parseInt(assignCatId)
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)
? { ...q, question_category_id: catIdVal, question_category_name: categories.find(c => c.id === catIdVal)?.name ?? null }
: q))
@ -202,6 +307,9 @@ export default function QuestionBankPage() {
return (
<div>
{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={() => {}} />}
{/* Header */}
@ -253,12 +361,20 @@ export default function QuestionBankPage() {
)}
{/* 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' }}>
<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)}
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 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 && (
<>
<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) => (
<span key={i} style={{
fontSize: '0.72rem', padding: '2px 7px', borderRadius: 4,
background: opt === q.correct_answer ? 'var(--correct-bg)' : 'var(--bg)',
color: opt === q.correct_answer ? 'var(--correct-fg)' : 'var(--text-muted)',
border: `1px solid ${opt === q.correct_answer ? 'var(--correct-bd)' : 'var(--border)'}`,
background: 'var(--bg)', color: 'var(--text-muted)',
border: '1px solid 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>
))}
</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>
))}