diff --git a/backend/app/main.py b/backend/app/main.py index 73edc19..e14694b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -161,6 +161,10 @@ def setup_pgvector(): # 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"')) + # Soft delete + publish control for quizzes + conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMP")) + conn.execute(text("ALTER TABLE quizzes ADD COLUMN IF NOT EXISTS is_published INTEGER DEFAULT 1")) + # Junction table: quiz ↔ question many-to-many conn.execute(text(""" CREATE TABLE IF NOT EXISTS quiz_question_links ( diff --git a/backend/app/models/quiz.py b/backend/app/models/quiz.py index dcade4b..26943ee 100644 --- a/backend/app/models/quiz.py +++ b/backend/app/models/quiz.py @@ -21,6 +21,8 @@ class Quiz(Base): mode = Column(String, default="timed") # timed, learning skipped_questions = Column(Text, nullable=True) # JSON list of skipped question texts created_at = Column(DateTime, default=datetime.utcnow) + deleted_at = Column(DateTime, nullable=True) # soft delete — null = active + is_published = Column(Integer, default=1) # 1 = visible to users, 0 = hidden (admin only) section = relationship("Section", back_populates="quizzes") user = relationship("User", back_populates="quizzes") diff --git a/backend/app/routers/attempts.py b/backend/app/routers/attempts.py index d3beb99..7f09e18 100644 --- a/backend/app/routers/attempts.py +++ b/backend/app/routers/attempts.py @@ -1,6 +1,7 @@ from datetime import datetime from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import func @@ -156,6 +157,120 @@ def list_attempts( ] +class ProgressSave(BaseModel): + quiz_id: int + attempt_id: int + answers: dict # {question_id: answer} + current_idx: int + mode: str + + +@router.post("/progress") +def save_progress( + data: ProgressSave, + current_user: User = Depends(get_current_user), +): + """Save in-progress quiz answers to Redis (survives logout/browser change).""" + try: + import redis as redis_lib, json as _json + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + key = f"quiz_progress:{current_user.id}:{data.quiz_id}" + r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days + "attempt_id": data.attempt_id, + "answers": data.answers, + "current_idx": data.current_idx, + "mode": data.mode, + })) + except Exception: + pass # Redis unavailable — degrade gracefully + return {"saved": True} + + +@router.get("/progress") +def get_progress( + quiz_id: int, + current_user: User = Depends(get_current_user), +): + """Retrieve in-progress quiz answers from Redis.""" + try: + import redis as redis_lib, json as _json + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + key = f"quiz_progress:{current_user.id}:{quiz_id}" + data = r.get(key) + if data: + return _json.loads(data) + except Exception: + pass + return None + + +@router.delete("/progress/{quiz_id}", status_code=204) +def clear_progress( + quiz_id: int, + current_user: User = Depends(get_current_user), +): + """Clear saved progress when quiz is submitted.""" + try: + import redis as redis_lib + from app.config import settings + r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) + r.delete(f"quiz_progress:{current_user.id}:{quiz_id}") + except Exception: + pass + + +@router.delete("/{attempt_id}", status_code=204) +def delete_attempt( + attempt_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Delete own attempt + answers + reminders for that quiz.""" + attempt = db.query(QuizAttempt).filter( + QuizAttempt.id == attempt_id, + QuizAttempt.user_id == current_user.id, + ).first() + if not attempt: + raise HTTPException(status_code=404, detail="Attempt not found") + + quiz_id = attempt.quiz_id + # Delete all attempts for this quiz by this user (wipe history) + from app.models.reminder import ReminderSchedule + db.query(ReminderSchedule).filter( + ReminderSchedule.user_id == current_user.id, + ReminderSchedule.quiz_id == quiz_id, + ).delete() + db.delete(attempt) + db.commit() + + +@router.get("/{attempt_id}/in-progress") +def get_in_progress_attempt( + quiz_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Return the latest incomplete attempt for a quiz, or null.""" + attempt = db.query(QuizAttempt).filter( + QuizAttempt.quiz_id == quiz_id, + QuizAttempt.user_id == current_user.id, + QuizAttempt.completed_at.is_(None), + ).order_by(QuizAttempt.started_at.desc()).first() + if not attempt: + return None + return AttemptResponse( + id=attempt.id, + quiz_id=attempt.quiz_id, + score=0, + total_questions=attempt.total_questions, + percentage=0.0, + started_at=attempt.started_at, + completed_at=None, + ) + + @router.get("/history") def get_quiz_history( db: Session = Depends(get_db), diff --git a/backend/app/routers/quizzes.py b/backend/app/routers/quizzes.py index 2025288..4ee8fbe 100644 --- a/backend/app/routers/quizzes.py +++ b/backend/app/routers/quizzes.py @@ -1,4 +1,5 @@ import random +from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import cast, String, or_, and_, func from sqlalchemy.orm import Session @@ -248,12 +249,11 @@ def list_quizzes( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): - """List all available quizzes. Admins/mods see all, users see all published.""" - if current_user.is_moderator: - quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all() - else: - quizzes = db.query(Quiz).order_by(Quiz.created_at.desc()).all() - return quizzes + """List quizzes. Moderators see all; regular users only see published.""" + q = db.query(Quiz).filter(Quiz.deleted_at.is_(None)) + if not current_user.is_moderator: + q = q.filter(Quiz.is_published == 1) + return q.order_by(Quiz.created_at.desc()).all() @router.get("/{quiz_id}") @@ -264,7 +264,7 @@ def get_quiz( current_user: User = Depends(get_current_user), ): """Get quiz for taking. study=true forces answers/explanations to be included.""" - quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") @@ -441,17 +441,72 @@ def delete_quiz( db: Session = Depends(get_db), current_user: User = Depends(require_moderator), ): - """Delete quiz. Questions shared with other quizzes stay in the bank. - Questions only in this quiz are detached (source_quiz_id cleared) so they - remain in the bank instead of being deleted.""" + """Soft-delete quiz — moves to trash. Restore via PATCH /{quiz_id}/restore.""" + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() + if not quiz: + raise HTTPException(status_code=404, detail="Quiz not found") + quiz.deleted_at = datetime.utcnow() + db.commit() + + +@router.patch("/{quiz_id}/publish") +def set_quiz_published( + quiz_id: int, + published: bool = True, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Hide (published=false) or show (published=true) a quiz for regular users.""" + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.is_(None)).first() + if not quiz: + raise HTTPException(status_code=404, detail="Quiz not found") + quiz.is_published = 1 if published else 0 + db.commit() + return {"quiz_id": quiz_id, "is_published": quiz.is_published} + + +@router.get("/trash", response_model=list[QuizResponse]) +def list_trash( + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """List soft-deleted quizzes — admin/moderator only.""" + return db.query(Quiz).filter(Quiz.deleted_at.isnot(None)).order_by(Quiz.deleted_at.desc()).all() + + +@router.patch("/{quiz_id}/restore", response_model=QuizResponse) +def restore_quiz( + quiz_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Restore a soft-deleted quiz from trash.""" + quiz = db.query(Quiz).filter(Quiz.id == quiz_id, Quiz.deleted_at.isnot(None)).first() + if not quiz: + raise HTTPException(status_code=404, detail="Quiz not found in trash") + quiz.deleted_at = None + db.commit() + db.refresh(quiz) + return quiz + + +@router.delete("/{quiz_id}/permanent", status_code=204) +def permanently_delete_quiz( + quiz_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(require_moderator), +): + """Permanently delete a quiz that's already in trash. Questions stay in bank.""" from app.models.quiz_question_link import QuizQuestionLink + from datetime import datetime as dt quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") + if quiz.deleted_at is None: + raise HTTPException(status_code=400, detail="Move to trash first before permanently deleting") - # For each question linked to this quiz: if no other quiz uses it, - # detach it (clear source_quiz_id) so it stays as a bank-only question + # Detach exclusive questions to bank links = db.query(QuizQuestionLink).filter(QuizQuestionLink.quiz_id == quiz_id).all() for link in links: other = db.query(QuizQuestionLink).filter( @@ -459,10 +514,9 @@ def delete_quiz( QuizQuestionLink.quiz_id != quiz_id, ).count() if other == 0: - # Detach from this quiz but keep in bank db.query(QuestionModel).filter(QuestionModel.id == link.question_id).update( - {"quiz_id": None} # source_quiz_id → null + {QuestionModel.source_quiz_id: None}, synchronize_session=False ) - db.delete(quiz) # cascades quiz_question_links + db.delete(quiz) db.commit() diff --git a/backend/app/schemas/quiz.py b/backend/app/schemas/quiz.py index d52ba76..09519c1 100644 --- a/backend/app/schemas/quiz.py +++ b/backend/app/schemas/quiz.py @@ -40,6 +40,8 @@ class QuizResponse(BaseModel): skipped_questions: str | None = None category_id: int | None = None created_at: datetime + deleted_at: datetime | None = None + is_published: int = 1 class Config: from_attributes = True diff --git a/backend/app/tasks/quiz_tasks.py b/backend/app/tasks/quiz_tasks.py index 2d9002f..a4901b8 100644 --- a/backend/app/tasks/quiz_tasks.py +++ b/backend/app/tasks/quiz_tasks.py @@ -104,23 +104,41 @@ def extract_quiz( .replace("ltem", "Item") .replace("ltcm", "Item")) - # --- Detect separate answer key section (e.g. PREP 2013) --- - # Scan document in 10-page steps to find where "Preferred Response:" first appears + # --- Detect PDF format --- + # First check if document has INLINE correct answers (standard PREP 2012 format). + # If yes → always use standard extraction, even if "Preferred Response:" appears + # later in explanations. + first_50_content = vector_service.get_pages_text( + document_id=section.document_id, + start_page=section.start_page, + end_page=min(section.start_page + 49, section.end_page), + ) + has_inline_answers = bool(first_50_content and ( + "Correct Answer:" in first_50_content or + "correct answer:" in first_50_content.lower() + )) + + # Only look for end-of-document answer key if there are NO inline answers answer_section_start = None - scan_step = 10 - for scan_p in range(section.start_page, section.end_page, scan_step): - scan_end = min(scan_p + scan_step - 1, section.end_page) - raw_chunk = vector_service.get_pages_text( - document_id=section.document_id, start_page=scan_p, end_page=scan_end, - ) - if not raw_chunk: - continue - scan_chunk = _normalize_ocr(raw_chunk) - if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower(): - answer_section_start = max(section.start_page, scan_p - 5) - break + if not has_inline_answers: + scan_step = 10 + for scan_p in range(section.start_page, section.end_page, scan_step): + scan_end = min(scan_p + scan_step - 1, section.end_page) + raw_chunk = vector_service.get_pages_text( + document_id=section.document_id, start_page=scan_p, end_page=scan_end, + ) + if not raw_chunk: + continue + scan_chunk = _normalize_ocr(raw_chunk) + if "Preferred Response:" in scan_chunk or "preferred response:" in scan_chunk.lower(): + answer_section_start = max(section.start_page, scan_p - 5) + break has_end_answer_key = answer_section_start is not None + if has_inline_answers: + _push_step(r, job_id, "ai", "Detected inline answer format (Correct Answer: X). Using standard extraction.") + elif has_end_answer_key: + pass # logged below if has_end_answer_key: _push_step(r, job_id, "ai", f"Detected separate answer key section starting around page {answer_section_start}. Using two-phase extraction.") diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 451854c..01c5687 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -15,6 +15,7 @@ import AccountPage from './pages/AccountPage' import SettingsPage from './pages/SettingsPage' import QuestionBankPage from './pages/QuestionBankPage' import JobsPage from './pages/JobsPage' +import TrashPage from './pages/TrashPage' import QuizEditPage from './pages/QuizEditPage' import VerifyEmailPage from './pages/VerifyEmailPage' import ForgotPasswordPage from './pages/ForgotPasswordPage' @@ -64,6 +65,7 @@ function AppRoutes() { } /> } /> } /> + } />