Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/apps/api/idempotency.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import hashlib
import json
import logging

from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError, transaction
from rest_framework import status as http
from rest_framework.response import Response

from competitions.models import IdempotencyRecord

logger = logging.getLogger(__name__)

ENDPOINT_SUBMISSION_CREATE = "submissions.create"


def _fingerprint(data):
"""Stable sha256 over the request body."""
try:
canonical = json.dumps(data, sort_keys=True, default=str)
except TypeError:
canonical = repr(data)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def _json_safe(data):
"""Round-trip through DjangoJSONEncoder so UUID/datetime/Decimal survive."""
return json.loads(json.dumps(data, cls=DjangoJSONEncoder))


class IdempotentCreateMixin:
"""Replay-safe POST keyed by the `Idempotency-Key` header.

Behaviour:
* No header -> falls back to legacy create() (compat).
* Header + first call -> runs create(), stores response, returns it.
* Header + replay, same payload -> returns stored response (same id).
* Header + replay, diff payload -> 409 Conflict.
* Header + in-flight (race) -> 409 Conflict ("request already in flight").

The owner of the key is `request.user`; an anonymous user gets the legacy
path because there is no stable identity to scope the key.
"""
idempotency_endpoint = ENDPOINT_SUBMISSION_CREATE

def create(self, request, *args, **kwargs):
key = request.headers.get("Idempotency-Key")
if not key or not request.user.is_authenticated:
return super().create(request, *args, **kwargs)

fp = _fingerprint(request.data)
owner = request.user
endpoint = self.idempotency_endpoint

try:
with transaction.atomic():
rec = (
IdempotencyRecord.objects
.select_for_update()
.filter(owner=owner, endpoint=endpoint, key=key)
.first()
)
if rec is None:
rec = IdempotencyRecord.objects.create(
owner=owner,
endpoint=endpoint,
key=key,
request_fingerprint=fp,
)
is_new = True
else:
is_new = False
except IntegrityError:
rec = IdempotencyRecord.objects.get(owner=owner, endpoint=endpoint, key=key)
is_new = False

if not is_new:
if rec.request_fingerprint != fp:
return Response(
{"detail": "Idempotency-Key reused with a different payload"},
status=http.HTTP_409_CONFLICT,
)
if rec.response_status:
return Response(rec.response_body, status=rec.response_status)
return Response(
{"detail": "Request already in flight for this Idempotency-Key"},
status=http.HTTP_409_CONFLICT,
)

resp = super().create(request, *args, **kwargs)

body = resp.data if isinstance(resp.data, (dict, list)) else {}
rec.response_status = resp.status_code
rec.response_body = _json_safe(body)
if isinstance(resp.data, dict) and "id" in resp.data:
rec.submission_id = resp.data["id"]
rec.save(update_fields=["response_status", "response_body", "submission", "updated_when"])
return resp
31 changes: 29 additions & 2 deletions src/apps/api/views/submissions.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,15 @@
from api.pagination import DynamicChoicePagination
from tasks.models import Task
from api.serializers.submissions import SubmissionCreationSerializer, SubmissionSerializer, SubmissionFilesSerializer, SubmissionDetailSerializer
from competitions.models import Submission, SubmissionDetails, Phase, CompetitionParticipant
from api.idempotency import IdempotentCreateMixin, ENDPOINT_SUBMISSION_CREATE
from competitions.models import Submission, SubmissionDetails, Phase, CompetitionParticipant, IdempotencyRecord
from leaderboards.strategies import put_on_leaderboard_by_submission_rule
from leaderboards.models import SubmissionScore, Column, Leaderboard
import logging
logger = logging.getLogger(__name__)


class SubmissionViewSet(ModelViewSet):
class SubmissionViewSet(IdempotentCreateMixin, ModelViewSet):
queryset = Submission.objects.all().order_by('-pk')
permission_classes = []
filter_backends = (DjangoFilterBackend, SearchFilter)
Expand Down Expand Up @@ -198,6 +199,32 @@ def create(self, request, *args, **kwargs):
raise ValidationError('You do not have participant permissions for this group')
return super(SubmissionViewSet, self).create(request, *args, **kwargs)

@action(detail=False, methods=['get'], url_path=r'receipt/(?P<key>[^/]+)')
def receipt(self, request, key=None):
"""Pollable receipt for a previous POST keyed by Idempotency-Key.

Returns 404 if no record exists, 202 if the request is in flight,
200 with the original response body once the create has finalised.
"""
if not request.user.is_authenticated:
raise PermissionDenied('Authentication required')
try:
rec = IdempotencyRecord.objects.get(
owner=request.user,
endpoint=ENDPOINT_SUBMISSION_CREATE,
key=key,
)
except IdempotencyRecord.DoesNotExist:
return Response({'state': 'unknown'}, status=status.HTTP_404_NOT_FOUND)
if not rec.response_status:
return Response({'state': 'pending'}, status=status.HTTP_202_ACCEPTED)
return Response({
'state': 'completed',
'status': rec.response_status,
'submission_id': rec.submission_id,
'body': rec.response_body,
})

def destroy(self, request, *args, **kwargs):
"""
- If user is neither owner nor admin, user cannot delete the submission
Expand Down
67 changes: 67 additions & 0 deletions src/apps/competitions/migrations/0062_idempotencyrecord.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# Generated by Django 5.2.14 on 2026-06-17 08:41

import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("competitions", "0061_competition_participant_groups"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

operations = [
migrations.CreateModel(
name="IdempotencyRecord",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("key", models.CharField(max_length=128)),
("endpoint", models.CharField(max_length=64)),
("request_fingerprint", models.CharField(max_length=64)),
("response_status", models.PositiveSmallIntegerField(default=0)),
("response_body", models.JSONField(blank=True, default=dict)),
("created_when", models.DateTimeField(auto_now_add=True)),
("updated_when", models.DateTimeField(auto_now=True)),
(
"owner",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
(
"submission",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="competitions.submission",
),
),
],
options={
"indexes": [
models.Index(
fields=["created_when"], name="competition_created_672c32_idx"
)
],
"constraints": [
models.UniqueConstraint(
fields=["owner", "endpoint", "key"],
name="unique_idempotency_owner_endpoint_key",
),
],
},
),
]
37 changes: 37 additions & 0 deletions src/apps/competitions/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,9 @@ class Submission(models.Model):
ingestion_worker_hostname = models.CharField(max_length=255, blank=True, null=True)
# Scoring hostname
scoring_worker_hostname = models.CharField(max_length=255, blank=True, null=True)
# Incremented every time a worker claims this submission from the broker.
# Used to detect redeliveries (M6) and provide an audit trail.
worker_attempt_count = models.PositiveIntegerField(default=0)
queue = models.ForeignKey('queues.Queue', on_delete=models.SET_NULL, null=True, blank=True,
related_name='submissions')
is_migrated = models.BooleanField(default=False)
Expand Down Expand Up @@ -809,3 +812,37 @@ class CompetitionWhiteListEmail(models.Model):

def __str__(self):
return f"{self.email} - Competition: {self.competition.title}"


class IdempotencyRecord(models.Model):
"""Tracks one client-attempted POST keyed by (owner, endpoint, key).

Used to make submission creation replay-safe: a client retrying the same
request with the same Idempotency-Key header receives the original response
instead of producing a duplicate row.
"""
PENDING = 0
key = models.CharField(max_length=128)
owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
endpoint = models.CharField(max_length=64)
request_fingerprint = models.CharField(max_length=64)
response_status = models.PositiveSmallIntegerField(default=PENDING)
response_body = models.JSONField(default=dict, blank=True)
submission = models.ForeignKey(
'Submission', null=True, blank=True,
on_delete=models.SET_NULL, related_name='+',
)
created_when = models.DateTimeField(auto_now_add=True)
updated_when = models.DateTimeField(auto_now=True)

class Meta:
constraints = [
models.UniqueConstraint(
fields=['owner', 'endpoint', 'key'],
name='unique_idempotency_owner_endpoint_key',
),
]
indexes = [models.Index(fields=['created_when'])]

def __str__(self):
return f"IdempotencyRecord({self.endpoint}, {self.key}, status={self.response_status})"