# retoor <retoor@molodetz.nl>
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse, JSONResponse
from devplacepy.config import QUIZ_ANSWER_MAX_CHARS
from devplacepy.database import get_users_by_uids
from devplacepy.dependencies import json_or_form
from devplacepy.models import QuizAnswerForm
from devplacepy.responses import action_result, respond, wants_json
from devplacepy.schemas import (
QuizAnswerResultOut,
QuizAttemptPageOut,
QuizResultOut,
)
from devplacepy.services.audit import record as audit
from devplacepy.services.quiz import QuizError, store
from devplacepy.utils import (
award_rewards,
create_notification,
is_admin,
not_found,
require_user,
track_action,
XP_QUIZ_COMPLETE,
)
from ._shared import action_error, load_quiz, notify_quiz, quiz_seo, quiz_url
router = APIRouter()
def _load_attempt(quiz: dict, attempt_uid: str, user: dict, allow_admin: bool = False):
attempt = store.get_attempt(attempt_uid)
if not attempt or attempt.get("quiz_uid") != quiz["uid"]:
raise not_found("Attempt not found")
if attempt.get("user_uid") != user["uid"] and not (allow_admin and is_admin(user)):
raise not_found("Attempt not found")
return attempt
def _author(quiz: dict):
return get_users_by_uids([quiz["user_uid"]]).get(quiz["user_uid"])
@router.post("/{slug}/attempts")
async def start_attempt(request: Request, slug: str):
user = require_user(request)
quiz = load_quiz(slug, user)
if quiz.get("status") != "published":
return action_error(request, "This quiz is not published yet", quiz_url(quiz))
try:
attempt = store.start_attempt(user, quiz)
except QuizError as exc:
return action_error(request, str(exc), quiz_url(quiz))
audit.record(
request,
"quiz.attempt.start",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=f"{user['username']} started an attempt on quiz {quiz.get('title')}",
metadata={"attempt_uid": attempt["uid"]},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
url = f"{quiz_url(quiz)}/attempts/{attempt['uid']}"
return action_result(
request, url, data={"uid": attempt["uid"], "url": url, "status": attempt["status"]}
)
@router.get("/{slug}/attempts/{attempt_uid}", response_class=HTMLResponse)
async def attempt_page(request: Request, slug: str, attempt_uid: str):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user)
payload = store.serialize_attempt(quiz, attempt, user)
seo_ctx = quiz_seo(
request,
quiz.get("title") or "Quiz",
"Play this quiz on DevPlace.",
robots="noindex,follow",
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
)
return respond(
request,
"quiz_play.html",
{
**seo_ctx,
"request": request,
"user": user,
"quiz": store.serialize_quiz(quiz, user, _author(quiz)),
"attempt": payload,
"answer_max_chars": QUIZ_ANSWER_MAX_CHARS,
"quiz_error": request.query_params.get("error", ""),
},
model=QuizAttemptPageOut,
)
@router.post("/{slug}/attempts/{attempt_uid}/answer")
async def submit_answer(
request: Request,
slug: str,
attempt_uid: str,
data: Annotated[QuizAnswerForm, Depends(json_or_form(QuizAnswerForm))],
):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user)
attempt_url = f"{quiz_url(quiz)}/attempts/{attempt_uid}"
try:
answer, updated, result = await store.answer(
user,
quiz,
attempt,
data.question_uid,
data.submission(),
)
except QuizError as exc:
return action_error(request, str(exc), attempt_url)
if result.graded_by == "fallback":
audit.record_system(
"quiz.grade.failed",
result="failure",
target_type="quiz",
target_uid=quiz["uid"],
summary=(
f"AI grading unavailable for question {data.question_uid}, "
"deterministic fallback used"
),
metadata={"attempt_uid": attempt_uid, "question_uid": data.question_uid},
)
audit.record(
request,
"quiz.attempt.answer",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=(
f"{user['username']} answered a question on quiz {quiz.get('title')} "
f"for {answer.get('awarded_points')} points"
),
metadata={
"attempt_uid": attempt_uid,
"question_uid": data.question_uid,
"graded_by": result.graded_by,
},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
if not wants_json(request):
return action_result(request, attempt_url)
return JSONResponse(
QuizAnswerResultOut(
answer=store.serialize_answer(answer),
attempt=store.serialize_attempt(quiz, updated, user),
).model_dump(mode="json")
)
@router.post("/{slug}/attempts/{attempt_uid}/finish")
async def finish_attempt(request: Request, slug: str, attempt_uid: str):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user)
finished, won = store.finish(quiz, attempt)
results_url = f"{quiz_url(quiz)}/attempts/{attempt_uid}/results"
if won:
store.clear_cache()
award_rewards(user["uid"], XP_QUIZ_COMPLETE, "Quiz Taker")
track_action(user["uid"], "quiz_complete")
if float(finished.get("score_percent") or 0.0) >= 100.0:
track_action(user["uid"], "quiz_perfect")
if quiz["user_uid"] != user["uid"]:
create_notification(
quiz["user_uid"],
"quiz_attempt",
f"{user['username']} completed your quiz {quiz.get('title')}",
user["uid"],
quiz_url(quiz),
)
audit.record(
request,
"quiz.attempt.finish",
user=user,
target_type="quiz",
target_uid=quiz["uid"],
target_label=quiz.get("title") or quiz["uid"],
summary=(
f"{user['username']} finished quiz {quiz.get('title')} with "
f"{finished.get('score_percent')}%"
),
metadata={
"attempt_uid": attempt_uid,
"score_percent": finished.get("score_percent"),
"passed": finished.get("passed"),
},
links=[audit.target("quiz", quiz["uid"], quiz.get("title") or "")],
)
await notify_quiz(quiz["uid"])
if not wants_json(request):
return action_result(request, results_url)
result = store.serialize_result(quiz, finished, user)
return JSONResponse(
QuizResultOut(
quiz=store.serialize_quiz(quiz, user, _author(quiz)),
attempt=result,
review=result["review"],
fallback_count=result["fallback_count"],
).model_dump(mode="json")
)
@router.get("/{slug}/attempts/{attempt_uid}/results", response_class=HTMLResponse)
async def attempt_results(request: Request, slug: str, attempt_uid: str):
user = require_user(request)
quiz = load_quiz(slug, user)
attempt = _load_attempt(quiz, attempt_uid, user, allow_admin=True)
result = store.serialize_result(quiz, attempt, user)
seo_ctx = quiz_seo(
request,
f"{quiz.get('title') or 'Quiz'} results",
"Your quiz result on DevPlace.",
robots="noindex,follow",
breadcrumbs=[{"name": quiz.get("title") or "Quiz", "url": quiz_url(quiz)}],
)
return respond(
request,
"quiz_results.html",
{
**seo_ctx,
"request": request,
"user": user,
"quiz": store.serialize_quiz(quiz, user, _author(quiz)),
"attempt": result,
"review": result["review"],
"fallback_count": result["fallback_count"],
},
model=QuizResultOut,
)