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():
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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])
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
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,
|
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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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')
|
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)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue