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:
parent
a7a5bdff62
commit
f2d15c3c57
13 changed files with 371 additions and 119 deletions
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
10
backend/app/models/quiz_question_link.py
Normal file
10
backend/app/models/quiz_question_link.py
Normal 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)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
47
backend/app/utils/quiz_questions.py
Normal file
47
backend/app/utils/quiz_questions.py
Normal 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()
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue