- max_attempts enforcement in start_attempt: raises AttemptLimitError when quota of finalized attempts is exhausted (in_progress not counted) - Timer enforcement: start_attempt sets expired_at = now + time_limit_seconds; check_attempt_not_expired auto-finalizes and raises AttemptExpiredError on deadline breach - finalize_timed_out_attempt: grades partial responses, sets TIMED_OUT status - expire_timed_out_attempts Celery task: idempotent sweep of overdue in-progress attempts - Views: 409 on limit breach (start), 410 on expired response submit; timed_out result on submit - 14 unit + integration tests covering policy boundary conditions and API responses Co-Authored-By: Paperclip <noreply@paperclip.ing>
110 lines
3.8 KiB
Python
110 lines
3.8 KiB
Python
from django.shortcuts import get_object_or_404
|
|
from rest_framework import status
|
|
from rest_framework.permissions import IsAuthenticated
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from .models import AttemptStatus, Question, Quiz, QuizAttempt, QuestionResponse
|
|
from .serializers import (
|
|
AttemptResultSerializer,
|
|
AttemptSerializer,
|
|
QuestionResponseSerializer,
|
|
QuizSerializer,
|
|
SubmitResponseSerializer,
|
|
)
|
|
from .services import (
|
|
AttemptExpiredError,
|
|
AttemptLimitError,
|
|
check_attempt_not_expired,
|
|
start_attempt,
|
|
submit_attempt,
|
|
)
|
|
|
|
|
|
class QuizDetailView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request, quiz_id):
|
|
quiz = get_object_or_404(Quiz, pk=quiz_id)
|
|
attempt_id = request.query_params.get("attempt_id")
|
|
seed = 0
|
|
if attempt_id:
|
|
try:
|
|
att = QuizAttempt.objects.get(pk=attempt_id, user=request.user, quiz=quiz)
|
|
seed = att.seed
|
|
except QuizAttempt.DoesNotExist:
|
|
pass
|
|
serializer = QuizSerializer(quiz, context={"seed": seed})
|
|
return Response(serializer.data)
|
|
|
|
|
|
class StartAttemptView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request, quiz_id):
|
|
quiz = get_object_or_404(Quiz, pk=quiz_id)
|
|
try:
|
|
attempt = start_attempt(quiz, request.user)
|
|
except AttemptLimitError as exc:
|
|
return Response({"detail": str(exc)}, status=status.HTTP_409_CONFLICT)
|
|
return Response(AttemptSerializer(attempt).data, status=status.HTTP_201_CREATED)
|
|
|
|
|
|
class AttemptDetailView(APIView):
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def get(self, request, attempt_id):
|
|
attempt = get_object_or_404(QuizAttempt, pk=attempt_id, user=request.user)
|
|
serializer = AttemptResultSerializer(attempt)
|
|
return Response(serializer.data)
|
|
|
|
|
|
class SubmitResponseView(APIView):
|
|
"""Record or update a question response within an in-progress attempt."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request, attempt_id):
|
|
attempt = get_object_or_404(
|
|
QuizAttempt, pk=attempt_id, user=request.user, status=AttemptStatus.IN_PROGRESS
|
|
)
|
|
try:
|
|
check_attempt_not_expired(attempt)
|
|
except AttemptExpiredError as exc:
|
|
return Response({"detail": str(exc)}, status=status.HTTP_410_GONE)
|
|
|
|
serializer = SubmitResponseSerializer(data=request.data)
|
|
serializer.is_valid(raise_exception=True)
|
|
|
|
question_id = serializer.validated_data["question_id"]
|
|
question = get_object_or_404(Question, pk=question_id, quiz=attempt.quiz)
|
|
|
|
selected_ids = [str(uid) for uid in serializer.validated_data["selected_choice_ids"]]
|
|
text = serializer.validated_data["text_response"]
|
|
|
|
response_obj, _ = QuestionResponse.objects.update_or_create(
|
|
attempt=attempt,
|
|
question=question,
|
|
defaults={"selected_choice_ids": selected_ids, "text_response": text},
|
|
)
|
|
return Response(QuestionResponseSerializer(response_obj).data)
|
|
|
|
|
|
class SubmitAttemptView(APIView):
|
|
"""Finalize an attempt: grade all responses and compute score."""
|
|
|
|
permission_classes = [IsAuthenticated]
|
|
|
|
def post(self, request, attempt_id):
|
|
attempt = get_object_or_404(
|
|
QuizAttempt, pk=attempt_id, user=request.user, status=AttemptStatus.IN_PROGRESS
|
|
)
|
|
try:
|
|
check_attempt_not_expired(attempt)
|
|
except AttemptExpiredError:
|
|
# Attempt was already auto-finalized as timed_out; reload and return
|
|
attempt.refresh_from_db()
|
|
return Response(AttemptResultSerializer(attempt).data, status=status.HTTP_200_OK)
|
|
attempt = submit_attempt(attempt)
|
|
return Response(AttemptResultSerializer(attempt).data)
|