Mobile touch fix: - Added touch-action: manipulation and -webkit-tap-highlight-color: transparent to .question-card .option CSS — prevents mobile browsers from interfering with tap events (double-tap zoom, highlight flash, delayed clicks) Documents hidden for regular users: - Dashboard: stats card hides Documents count for non-moderators - Dashboard: document list section completely hidden for non-moderators - Documents API not called for non-moderators (saves a request) Timer improvements: - Custom timer input on ModeSelectScreen — set any number of minutes for exam mode (overrides the quiz default, or add time to untimed quizzes) - timeLeft saved to Redis progress every 1.5s (alongside answers, mode, voice) - On resume: timer restarts from the saved remaining time - Timer auto-submits when it reaches 0 (already worked, now also works on resume) - Progress bar shows remaining time relative to what was left at resume Nextcloud settings — server-side storage: - GET /auth/me/settings — load user settings from Redis - PUT /auth/me/settings — save settings to Redis (linked to user_id) - SettingsPage loads Nextcloud config from server on mount - Saves to BOTH server (cross-browser persistent) and localStorage (for UploadPage) - Clear function wipes both server and localStorage - Switching browsers and logging in restores Nextcloud settings Also fixed: saved.attemptId → saved.attempt_id in ModeSelectScreen resume check Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
465 lines
16 KiB
Python
465 lines
16 KiB
Python
from datetime import datetime
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
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")
|
|
|
|
# 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,
|
|
)
|
|
|
|
attempt = QuizAttempt(
|
|
quiz_id=quiz_id,
|
|
user_id=current_user.id,
|
|
total_questions=quiz.questions_count,
|
|
)
|
|
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,
|
|
))
|
|
|
|
# Build full review — include ALL questions, unanswered marked as incorrect
|
|
answer_details = []
|
|
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:
|
|
pass
|
|
|
|
percentage = (score / attempt.total_questions * 100) if attempt.total_questions > 0 else 0
|
|
|
|
# Update reminder schedule
|
|
try:
|
|
from app.services.reminder_service import update_reminder_schedule
|
|
update_reminder_schedule(db, current_user.id, attempt.quiz_id, percentage)
|
|
except Exception:
|
|
pass # Don't fail submission if reminder update fails
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
@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)
|
|
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
|
|
|
|
|
|
@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,
|
|
"voice": data.voice,
|
|
"time_left": data.time_left,
|
|
}))
|
|
except Exception:
|
|
pass # Redis unavailable — degrade gracefully
|
|
return {"saved": True}
|
|
|
|
|
|
@router.get("/progress")
|
|
def get_progress(
|
|
quiz_id: int,
|
|
db: Session = Depends(get_db),
|
|
current_user: User = Depends(get_current_user),
|
|
):
|
|
"""Retrieve in-progress quiz answers from Redis.
|
|
Clears stale data if the saved attempt has already been submitted."""
|
|
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 not data:
|
|
return None
|
|
saved = _json.loads(data)
|
|
attempt_id = saved.get("attempt_id")
|
|
if attempt_id:
|
|
attempt = db.query(QuizAttempt).filter(
|
|
QuizAttempt.id == attempt_id,
|
|
QuizAttempt.user_id == current_user.id,
|
|
).first()
|
|
# Stale: attempt was submitted or doesn't exist
|
|
if not attempt or attempt.completed_at is not None:
|
|
r.delete(key)
|
|
return None
|
|
return saved
|
|
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("/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."""
|
|
incomplete = db.query(QuizAttempt).filter(
|
|
QuizAttempt.user_id == current_user.id,
|
|
QuizAttempt.completed_at.is_(None),
|
|
).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."""
|
|
completed = db.query(QuizAttempt).filter(
|
|
QuizAttempt.user_id == current_user.id,
|
|
QuizAttempt.completed_at.isnot(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({
|
|
"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 (not created)
|
|
total_quizzes = db.query(QuizAttempt.quiz_id).filter(
|
|
QuizAttempt.user_id == current_user.id,
|
|
QuizAttempt.completed_at.isnot(None),
|
|
).distinct().count()
|
|
|
|
completed_attempts = db.query(QuizAttempt).filter(
|
|
QuizAttempt.user_id == current_user.id,
|
|
QuizAttempt.completed_at.isnot(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")
|
|
|
|
# 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 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,
|
|
)
|