Files
training-software/quizzes/views.py
Paperclip CTO 6d7cc5ed16
Some checks failed
CI / Tests (Python 3.12) (push) Has been cancelled
CI / Lint (push) Has been cancelled
CI / OpenAPI Schema (push) Has been cancelled
feat(TRA-240): attempt policy enforcement — limits, timers, auto-expiry
- 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>
2026-05-07 09:17:25 +02:00

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)