pdf-quiz-generator/backend/app/routers/mobile.py

244 lines
8.3 KiB
Python

from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, EmailStr
from sqlalchemy.orm import Session
from app.config import settings
from app.database import get_db
from app.models.attempt import AttemptAnswer, QuizAttempt
from app.models.email_verification import EmailVerification
from app.models.quiz import Quiz
from app.models.user import User
from app.schemas.auth import Token
from app.utils.auth import create_access_token, get_current_user, verify_password
from app.utils.quiz_questions import get_quiz_questions
router = APIRouter()
class MobileLoginRequest(BaseModel):
email: EmailStr
password: str
class MobileAnswerSubmit(BaseModel):
question_id: int
user_answer: str
transcript: str | None = None
selected_letter: str | None = None
confidence: float | None = None
class MobileAttemptSubmit(BaseModel):
quiz_id: int
started_at: datetime | None = None
completed_at: datetime | None = None
selected_question_ids: list[int] | None = None
answers: list[MobileAnswerSubmit]
ANDROID_MODEL_MANIFEST = {
"version": 1,
"default_stack": {
"stt": "android-speech-offline",
"mapper": "deterministic-option-matcher",
"tts": "android-system-tts",
},
"models": [
{
"id": "android-speech-offline",
"role": "stt",
"runtime": "android.speech.SpeechRecognizer",
"label": "Android offline speech recognizer",
"download_url": None,
"required": False,
"notes": "Uses the device speech service with offline preference while bundled STT artifacts are added.",
},
{
"id": "deterministic-option-matcher",
"role": "mapper",
"runtime": "in-app",
"label": "Exact option/letter matcher",
"download_url": None,
"required": True,
"notes": "Grades locally against synced answer keys; no cloud model decides correctness.",
},
{
"id": "android-system-tts",
"role": "tts",
"runtime": "android.speech.tts.TextToSpeech",
"label": "Android system TTS",
"download_url": None,
"required": False,
"notes": "Fast local read-aloud fallback before packaged neural TTS is shipped.",
},
],
}
def _mobile_login_rate_limit(client_ip: str):
try:
import redis as redis_lib
r = redis_lib.from_url(settings.REDIS_URL, decode_responses=True, socket_connect_timeout=1)
key = f"mobile_login_attempts:{client_ip}"
count = r.incr(key)
if count == 1:
r.expire(key, 15 * 60)
if count > 20:
raise HTTPException(status_code=429, detail="Too many login attempts. Try again in 15 minutes.")
except HTTPException:
raise
except Exception:
# Mobile login should still work if Redis is briefly unavailable.
return
def _visible_quizzes_query(db: Session, current_user: User):
query = db.query(Quiz).filter(Quiz.deleted_at.is_(None))
if not current_user.is_moderator:
query = query.filter(Quiz.is_published == 1, Quiz.course_id.is_(None))
return query
def _question_payload(question):
return {
"id": question.id,
"question_text": question.question_text,
"question_type": question.question_type,
"options": question.options,
"correct_answer": question.correct_answer,
"explanation": question.explanation,
"page_reference": question.page_reference,
"image_path": question.image_path,
}
def _quiz_payload(db: Session, quiz: Quiz, include_questions: bool = True):
payload = {
"id": quiz.id,
"title": quiz.title,
"mode": quiz.mode,
"questions_count": quiz.questions_count,
"time_limit_minutes": quiz.time_limit_minutes,
"questions_per_attempt": quiz.questions_per_attempt,
"category_id": quiz.category_id,
"course_id": quiz.course_id,
"is_published": quiz.is_published,
"is_shared": quiz.is_shared,
"created_at": quiz.created_at.isoformat() if quiz.created_at else None,
}
if include_questions:
payload["questions"] = [_question_payload(question) for question in get_quiz_questions(db, quiz.id)]
return payload
@router.post("/auth/login")
def mobile_login(data: MobileLoginRequest, request: Request, db: Session = Depends(get_db)):
"""Password login for native apps. Uses rate limiting instead of browser Turnstile."""
client_ip = request.client.host if request and request.client else "unknown"
_mobile_login_rate_limit(client_ip)
email = data.email.lower().strip()
user = db.query(User).filter(User.email == email).first()
if not user or not verify_password(data.password, user.hashed_password):
raise HTTPException(status_code=401, detail="Invalid email or password")
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")
token = create_access_token(data={"sub": user.email})
return {
**Token(access_token=token).model_dump(),
"user": {"id": user.id, "email": user.email, "name": user.name, "role": user.role},
"server_time": datetime.utcnow().isoformat(),
}
@router.get("/sync")
def mobile_sync(
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Bulk sync all visible quizzes with answer keys for offline study."""
quizzes = _visible_quizzes_query(db, current_user).order_by(Quiz.created_at.desc()).all()
return {
"server_time": datetime.utcnow().isoformat(),
"user": {"id": current_user.id, "email": current_user.email, "name": current_user.name, "role": current_user.role},
"quizzes": [_quiz_payload(db, quiz) for quiz in quizzes],
"deleted_quiz_ids": [],
}
@router.get("/quizzes/{quiz_id}")
def mobile_quiz_detail(
quiz_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
return _quiz_payload(db, quiz)
@router.get("/models")
def mobile_models(current_user: User = Depends(get_current_user)):
_ = current_user
return ANDROID_MODEL_MANIFEST
@router.post("/attempts")
def upload_mobile_attempt(
data: MobileAttemptSubmit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
quiz = _visible_quizzes_query(db, current_user).filter(Quiz.id == data.quiz_id).first()
if not quiz:
raise HTTPException(status_code=404, detail="Quiz not found")
question_map = {question.id: question for question in get_quiz_questions(db, quiz.id)}
selected_ids = data.selected_question_ids or [answer.question_id for answer in data.answers]
total_questions = len(selected_ids) if selected_ids else len(question_map)
score = 0
attempt = QuizAttempt(
quiz_id=quiz.id,
user_id=current_user.id,
total_questions=total_questions,
started_at=data.started_at or datetime.utcnow(),
completed_at=data.completed_at or datetime.utcnow(),
selected_question_ids=selected_ids or None,
)
db.add(attempt)
db.flush()
for answer in data.answers:
question = question_map.get(answer.question_id)
if not question:
continue
is_correct = bool(answer.user_answer) and answer.user_answer.strip().lower() == question.correct_answer.strip().lower()
if is_correct:
score += 1
db.add(AttemptAnswer(
attempt_id=attempt.id,
question_id=question.id,
user_answer=answer.user_answer,
is_correct=is_correct,
))
attempt.score = score
db.commit()
percentage = round((score / total_questions * 100) if total_questions else 0, 1)
return {
"id": attempt.id,
"quiz_id": quiz.id,
"score": score,
"total_questions": total_questions,
"percentage": percentage,
"completed_at": attempt.completed_at.isoformat() if attempt.completed_at else None,
}