import logging from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, Request logger = logging.getLogger(__name__) from pydantic import BaseModel from sqlalchemy.orm import Session from sqlalchemy import func from app.database import get_db from app.models.quiz import Quiz from app.models.question import Question from app.models.attempt import QuizAttempt, AttemptAnswer from app.models.pdf_document import PDFDocument from app.models.user import User from app.schemas.attempt import ( AttemptSubmit, AttemptResponse, AttemptDetail, AnswerDetail, DashboardStats, QuizStats, ) from app.utils.auth import get_current_user from app.utils.quiz_questions import get_quiz_questions router = APIRouter() @router.post("/start", response_model=AttemptResponse) def start_attempt( quiz_id: int, fresh: bool = False, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): quiz = db.query(Quiz).filter(Quiz.id == quiz_id).first() if not quiz: raise HTTPException(status_code=404, detail="Quiz not found") # Enforce max_attempts if quiz.max_attempts: completed_count = db.query(QuizAttempt).filter( QuizAttempt.quiz_id == quiz_id, QuizAttempt.user_id == current_user.id, QuizAttempt.completed_at.isnot(None), ).count() if completed_count >= quiz.max_attempts: raise HTTPException(status_code=403, detail="Maximum attempts reached for this quiz") # Reuse the most recent incomplete attempt unless fresh=true if not fresh: existing = 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 existing: return AttemptResponse( id=existing.id, quiz_id=existing.quiz_id, score=0, total_questions=existing.total_questions, percentage=0.0, started_at=existing.started_at, completed_at=None, ) # Question pool: randomly select N questions if questions_per_attempt is set import random selected_ids = None total_q = quiz.questions_count if quiz.questions_per_attempt and quiz.questions_per_attempt < quiz.questions_count: all_q_ids = [q.id for q in quiz.questions] selected_ids = sorted(random.sample(all_q_ids, quiz.questions_per_attempt)) total_q = quiz.questions_per_attempt attempt = QuizAttempt( quiz_id=quiz_id, user_id=current_user.id, total_questions=total_q, selected_question_ids=selected_ids, ) db.add(attempt) db.commit() db.refresh(attempt) 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.post("/{attempt_id}/submit", response_model=AttemptDetail) def submit_attempt( attempt_id: int, submission: AttemptSubmit, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): 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") if attempt.completed_at: raise HTTPException(status_code=400, detail="Attempt already submitted") # 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} # Save submitted answers for ans in submission.answers: question = questions.get(ans.question_id) if not question: continue is_correct = bool(question.correct_answer) and ans.user_answer.strip().lower() == question.correct_answer.strip().lower() if is_correct: score += 1 db.add(AttemptAnswer( attempt_id=attempt_id, question_id=ans.question_id, user_answer=ans.user_answer, is_correct=is_correct, )) # Check if review is allowed (course quizzes may disallow) quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() is_course_quiz = quiz and quiz.course_id is not None review_allowed = not is_course_quiz or (quiz.allow_review == 1) # Build full review — include ALL questions, unanswered marked as incorrect answer_details = [] if review_allowed: for q in get_quiz_questions(db, attempt.quiz_id): user_answer = submitted.get(q.id, "") is_correct = bool(user_answer) and bool(q.correct_answer) and user_answer.strip().lower() == q.correct_answer.strip().lower() answer_details.append(AnswerDetail( question_id=q.id, question_text=q.question_text, question_type=q.question_type, options=q.options, user_answer=user_answer, correct_answer=q.correct_answer, is_correct=is_correct, explanation=q.explanation, )) attempt.score = score attempt.completed_at = datetime.utcnow() db.commit() # Clear saved progress from Redis when 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}:{attempt.quiz_id}") except Exception: logger.warning("Failed to clear quiz progress from Redis", exc_info=True) percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 # Update reminder schedule (skip course quizzes) if not is_course_quiz: try: from app.services.reminder_service import update_reminder_schedule update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage) except Exception: logger.warning("Failed to update reminder schedule for quiz %d", attempt.quiz_id, exc_info=True) return AttemptDetail( id=attempt.id, quiz_id=attempt.quiz_id, score=score, total_questions=attempt.total_questions, percentage=round(percentage, 1), started_at=attempt.started_at, completed_at=attempt.completed_at, answers=answer_details, course_id=quiz.course_id if quiz else None, allow_review=review_allowed, ) @router.get("/", response_model=list[AttemptResponse]) def list_attempts( quiz_id: int | None = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): query = db.query(QuizAttempt).filter( QuizAttempt.user_id == current_user.id, (QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)), ) if quiz_id: query = query.filter(QuizAttempt.quiz_id == quiz_id) attempts = query.order_by(QuizAttempt.started_at.desc()).all() return [ AttemptResponse( id=a.id, quiz_id=a.quiz_id, score=a.score, total_questions=a.total_questions, percentage=round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1), started_at=a.started_at, completed_at=a.completed_at, ) for a in attempts ] class ProgressSave(BaseModel): quiz_id: int attempt_id: int answers: dict # {question_id: answer} current_idx: int mode: str voice: str | None = None time_left: int | None = None # remaining seconds for timed mode (authoritative when suspended=True) started_at: str | None = None # ISO timestamp when quiz started (for timer calculation) total_time: int | None = None # original time limit in seconds suspended: bool = False # when True, timer is paused; time_left is preserved authoritatively @router.post("/progress") def save_progress( data: ProgressSave, request: Request = None, current_user: User = Depends(get_current_user), ): """Save in-progress quiz answers to Redis (survives logout/browser change). Each attempt gets its own saved progress (key includes attempt_id). Also refreshes the active session lock to prevent concurrent resume on another device.""" 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) # Session lock: identify this device by a session_id header session_id = request.headers.get("x-quiz-session", "") if request else "" lock_key = f"quiz_active:{current_user.id}:{data.attempt_id}" if session_id: r.setex(lock_key, 30, session_id) # 30s TTL, refreshed every save key = f"quiz_progress:{current_user.id}:{data.attempt_id}" r.setex(key, 7 * 24 * 3600, _json.dumps({ # 7 days "quiz_id": data.quiz_id, "attempt_id": data.attempt_id, "answers": data.answers, "current_idx": data.current_idx, "mode": data.mode, "voice": data.voice, "time_left": data.time_left, "started_at": data.started_at, "total_time": data.total_time, "suspended": data.suspended, })) except Exception: logger.warning("Redis unavailable for progress save", exc_info=True) return {"saved": True} @router.get("/progress") def get_progress( quiz_id: int, request: Request = None, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Retrieve in-progress quiz answers from Redis. Finds the latest incomplete attempt for this quiz, then checks Redis. Rejects if another device is actively using this attempt. Auto-submits timed quizzes if timer has expired.""" try: import redis as redis_lib, json as _json from app.config import settings from datetime import datetime, timezone r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True) # Find latest incomplete attempt for this quiz 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 # Check if another device is actively using this attempt session_id = request.headers.get("x-quiz-session", "") if request else "" lock_key = f"quiz_active:{current_user.id}:{attempt.id}" active_session = r.get(lock_key) if active_session and session_id and active_session != session_id: raise HTTPException( status_code=409, detail="This quiz is active on another device. It will become available when the other session ends or times out (~30 seconds)." ) key = f"quiz_progress:{current_user.id}:{attempt.id}" data = r.get(key) if not data: return None saved = _json.loads(data) # If the quiz was suspended, timer is paused — re-anchor on resume so # the held time_left becomes the new total_time starting now. if saved.get("suspended"): time_left = saved.get("time_left") if time_left is not None and time_left > 0: saved["started_at"] = datetime.now(timezone.utc).isoformat() saved["total_time"] = int(time_left) saved["suspended"] = False # Persist the re-anchored progress so timer continues correctly r.setex(key, 7 * 24 * 3600, _json.dumps(saved)) return saved # Check if timer expired for timed quiz (auto-submit) — only for unsuspended attempts total_time = saved.get("total_time") started_at_str = saved.get("started_at") if total_time is not None and started_at_str: try: started_at = datetime.fromisoformat(started_at_str.replace('Z', '+00:00')) elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() if elapsed >= total_time: # Timer expired — auto-submit questions = {q.id: q for q in get_quiz_questions(db, quiz_id)} score = 0 submitted_answers = saved.get("answers", {}) for qid_str, user_ans in submitted_answers.items(): qid = int(qid_str) question = questions.get(qid) if question: correct = (question.correct_answer or "").strip().lower() if (user_ans or "").strip().lower() == correct: score += 1 db.add(AttemptAnswer( attempt_id=attempt.id, question_id=qid, user_answer=user_ans, is_correct=(user_ans or "").strip().lower() == correct, )) attempt.score = score attempt.completed_at = datetime.utcnow() attempt.expired = 1 # mark as timer-expired; exclude from history db.commit() r.delete(key) return None # no progress to resume — already submitted except Exception: logger.warning("Failed to check quiz timer expiration", exc_info=True) return saved except Exception: logger.warning("Redis unavailable for progress retrieval", exc_info=True) return None @router.delete("/progress/{attempt_id}", status_code=204) def clear_progress( attempt_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}:{attempt_id}") except Exception: logger.warning("Redis unavailable for progress clear", exc_info=True) @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. Cannot delete course quiz attempts.""" 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") # Block deletion of course quiz attempts quiz = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() if quiz and quiz.course_id: raise HTTPException(status_code=403, detail="Cannot delete course quiz attempts") 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("/in-progress") def get_in_progress_attempts( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return all incomplete (not yet submitted) attempts for this user. Excludes course quizzes.""" incomplete = ( db.query(QuizAttempt) .join(Quiz, QuizAttempt.quiz_id == Quiz.id) .filter( QuizAttempt.user_id == current_user.id, QuizAttempt.completed_at.is_(None), Quiz.course_id.is_(None), # exclude course quizzes ) .order_by(QuizAttempt.started_at.desc()) .all() ) result = [] for a in incomplete: quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first() result.append({ "attempt_id": a.id, "quiz_id": a.quiz_id, "quiz_title": quiz.title if quiz else f"Quiz {a.quiz_id}", "total_questions": a.total_questions, "started_at": a.started_at.isoformat() if a.started_at else None, }) return result @router.get("/history") def get_quiz_history( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): """Return per-quiz attempt history for the performance line graph (excludes course quizzes and expired attempts).""" completed = ( db.query(QuizAttempt) .join(Quiz, QuizAttempt.quiz_id == Quiz.id) .filter( QuizAttempt.user_id == current_user.id, QuizAttempt.completed_at.isnot(None), Quiz.course_id.is_(None), (QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)), ) .order_by(QuizAttempt.completed_at) .all() ) # Group by quiz from collections import defaultdict by_quiz: dict = defaultdict(list) quiz_titles: dict = {} for a in completed: pct = round((a.score / a.total_questions * 100) if a.total_questions > 0 else 0, 1) by_quiz[a.quiz_id].append({ "attempt_id": a.id, "date": a.completed_at.isoformat(), "percentage": pct, "score": a.score, "total": a.total_questions, }) if a.quiz_id not in quiz_titles: quiz = db.query(Quiz).filter(Quiz.id == a.quiz_id).first() quiz_titles[a.quiz_id] = quiz.title if quiz else f"Quiz {a.quiz_id}" return [ {"quiz_id": qid, "title": quiz_titles[qid], "attempts": attempts} for qid, attempts in by_quiz.items() ] @router.get("/stats/dashboard", response_model=DashboardStats) def get_dashboard_stats( db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): total_docs = db.query(PDFDocument).filter(PDFDocument.user_id == current_user.id).count() # Count distinct quizzes the user has attempted (excludes course quizzes and expired attempts) total_quizzes = ( db.query(QuizAttempt.quiz_id) .join(Quiz, QuizAttempt.quiz_id == Quiz.id) .filter( QuizAttempt.user_id == current_user.id, QuizAttempt.completed_at.isnot(None), Quiz.course_id.is_(None), (QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)), ) .distinct().count() ) completed_attempts = ( db.query(QuizAttempt) .join(Quiz, QuizAttempt.quiz_id == Quiz.id) .filter( QuizAttempt.user_id == current_user.id, QuizAttempt.completed_at.isnot(None), Quiz.course_id.is_(None), (QuizAttempt.expired == 0) | (QuizAttempt.expired.is_(None)), ) .all() ) total_attempts = len(completed_attempts) avg_score = 0.0 if completed_attempts: scores = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in completed_attempts] avg_score = round(sum(scores) / len(scores), 1) # Per-quiz stats — based on quizzes the user has attempted, not created quiz_stats = [] attempted_quiz_ids = {a.quiz_id for a in completed_attempts} quizzes = db.query(Quiz).filter(Quiz.id.in_(attempted_quiz_ids)).all() if attempted_quiz_ids else [] for quiz in quizzes: quiz_attempts = [a for a in completed_attempts if a.quiz_id == quiz.id] if quiz_attempts: pcts = [(a.score / a.total_questions * 100) if a.total_questions > 0 else 0 for a in quiz_attempts] quiz_stats.append(QuizStats( quiz_id=quiz.id, quiz_title=quiz.title, attempts_count=len(quiz_attempts), best_score=round(max(pcts), 1), latest_score=round(pcts[-1], 1), average_score=round(sum(pcts) / len(pcts), 1), )) return DashboardStats( total_documents=total_docs, total_quizzes=total_quizzes, total_attempts=total_attempts, average_score=avg_score, quiz_stats=quiz_stats, ) @router.get("/{attempt_id}", response_model=AttemptDetail) def get_attempt( attempt_id: int, db: Session = Depends(get_db), current_user: User = Depends(get_current_user), ): 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 = db.query(Quiz).filter(Quiz.id == attempt.quiz_id).first() is_course_quiz = quiz and quiz.course_id is not None review_allowed = not is_course_quiz or (quiz.allow_review == 1) # Show ALL questions in review, not just answered ones submitted_map = {ans.question_id: ans for ans in attempt.answers} answer_details = [] if review_allowed: for q in get_quiz_questions(db, attempt.quiz_id): ans = submitted_map.get(q.id) answer_details.append(AnswerDetail( question_id=q.id, question_text=q.question_text, question_type=q.question_type, options=q.options, user_answer=ans.user_answer if ans else "", correct_answer=q.correct_answer, is_correct=ans.is_correct if ans else False, explanation=q.explanation, )) percentage = (attempt.score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0 return AttemptDetail( id=attempt.id, quiz_id=attempt.quiz_id, score=attempt.score, total_questions=attempt.total_questions, percentage=round(percentage, 1), started_at=attempt.started_at, completed_at=attempt.completed_at, answers=answer_details, course_id=quiz.course_id if quiz else None, allow_review=review_allowed, )