diff --git a/backend/app/main.py b/backend/app/main.py index cf4070f..73edc19 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/question.py b/backend/app/models/question.py index 20fa056..9d6b1c8 100644 --- a/backend/app/models/question.py +++ b/backend/app/models/question.py @@ -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]) diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py index 8c1f706..dcade4b 100644 --- a/backend/app/models/quiz.py +++ b/backend/app/models/quiz.py @@ -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") diff --git a/backend/app/models/quiz_question_link.py b/backend/app/models/quiz_question_link.py new file mode 100644 index 0000000..4dcb90e --- /dev/null +++ b/backend/app/models/quiz_question_link.py @@ -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) diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index efcf640..8100269 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -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, diff --git a/backend/app/routers/question_categories.py b/backend/app/routers/question_categories.py index 19df85c..d6653cd 100644 --- a/backend/app/routers/question_categories.py +++ b/backend/app/routers/question_categories.py @@ -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) diff --git a/backend/app/routers/questions.py b/backend/app/routers/questions.py index 71578a7..e729a19 100644 --- a/backend/app/routers/questions.py +++ b/backend/app/routers/questions.py @@ -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} diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index dfa2772..4787703 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -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() diff --git a/backend/app/services/quiz_service.py b/backend/app/services/quiz_service.py index 86ec7d7..7ba5f8a 100644 --- a/backend/app/services/quiz_service.py +++ b/backend/app/services/quiz_service.py @@ -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: diff --git a/backend/app/utils/auth.py b/backend/app/utils/auth.py index 8bf63d3..c09a16d 100644 --- a/backend/app/utils/auth.py +++ b/backend/app/utils/auth.py @@ -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 diff --git a/backend/app/utils/quiz_questions.py b/backend/app/utils/quiz_questions.py new file mode 100644 index 0000000..e0fc9e7 --- /dev/null +++ b/backend/app/utils/quiz_questions.py @@ -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() diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 367ffe8..2e5178a 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -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) } ) diff --git a/frontend/src/pages/QuestionBankPage.jsx b/frontend/src/pages/QuestionBankPage.jsx index 23d63f3..e43255d 100644 --- a/frontend/src/pages/QuestionBankPage.jsx +++ b/frontend/src/pages/QuestionBankPage.jsx @@ -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 ( +
+ This question is shared — changes apply to all quizzes using it. +
+ {error &&