Skip to content
Merged
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min
DRF_THROTTLE_TOKEN_OBTAIN=10/min
DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min
DRF_THROTTLE_APPLICATION_CREATE=10/min
DRF_THROTTLE_SUBMISSION_CREATE=10/min

SENTRY_DSN=

Expand Down
6 changes: 6 additions & 0 deletions partner_programs/application_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,16 @@
ApplicationSubmitView,
ApplicationWithdrawView,
)
from partner_programs.submission_views import ApplicationSubmissionListCreateView

app_name = "applications"

urlpatterns = [
path(
"<int:application_id>/submissions/",
ApplicationSubmissionListCreateView.as_view(),
name="submission-list-create",
),
path("<int:application_id>/", ApplicationDetailView.as_view(), name="detail"),
path(
"<int:application_id>/submit/",
Expand Down
3 changes: 3 additions & 0 deletions partner_programs/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,9 @@ def clean(self):
if (
self.submitted_by_id
and self.submitted_by_id not in allowed_submitter_ids
and not (
self.submitted_by.is_staff or self.submitted_by.is_superuser
)
):
errors["submitted_by"] = (
"Submitted by must match the application user or creator."
Expand Down
2 changes: 2 additions & 0 deletions partner_programs/serializers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
ProgramProjectFilterRequestSerializer,
UserProgramsSerializer,
)
from .submissions import SubmissionSerializer

__all__ = [
"ApplicationSerializer",
Expand All @@ -33,5 +34,6 @@
"PartnerProgramUserSerializer",
"ProgramProjectCreateSerializer",
"ProgramProjectFilterRequestSerializer",
"SubmissionSerializer",
"UserProgramsSerializer",
]
78 changes: 78 additions & 0 deletions partner_programs/serializers/submissions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
from rest_framework import serializers

from partner_programs.models import Submission


class SubmissionSerializer(serializers.ModelSerializer):
create_fields = frozenset(
{
"title",
"description",
"form_data",
"links",
"stage_key",
"version",
}
)
update_fields = frozenset(
{
"title",
"description",
"form_data",
"links",
}
)

class Meta:
model = Submission
fields = (
"id",
"application",
"program",
"submitted_by",
"title",
"description",
"form_data",
"links",
"status",
"stage_key",
"version",
"submitted_at",
"created_at",
"updated_at",
)
read_only_fields = (
"id",
"application",
"program",
"submitted_by",
"status",
"submitted_at",
"created_at",
"updated_at",
)
extra_kwargs = {
"description": {"required": False},
"form_data": {"required": False},
"links": {"required": False},
"stage_key": {"required": False},
"version": {"required": False, "min_value": 1},
}

def validate(self, attrs):
allowed_fields = self.update_fields if self.instance else self.create_fields
unsupported_fields = set(self.initial_data).difference(allowed_fields)
if unsupported_fields:
raise serializers.ValidationError(
{
field: "This field is read-only."
for field in sorted(unsupported_fields)
}
)

if self.instance and not self.instance.can_edit:
raise serializers.ValidationError(
{"status": "Only draft or returned submissions can be updated."}
)

return attrs
23 changes: 23 additions & 0 deletions partner_programs/submission_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from django.urls import path

from partner_programs.submission_views import (
SubmissionCancelView,
SubmissionDetailView,
SubmissionSubmitView,
)

app_name = "submissions"

urlpatterns = [
path("<int:submission_id>/", SubmissionDetailView.as_view(), name="detail"),
path(
"<int:submission_id>/submit/",
SubmissionSubmitView.as_view(),
name="submit",
),
path(
"<int:submission_id>/cancel/",
SubmissionCancelView.as_view(),
name="cancel",
),
]
215 changes: 215 additions & 0 deletions partner_programs/submission_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import IntegrityError, transaction
from django.db.models import Max
from django.shortcuts import get_object_or_404
from django.utils import timezone
from rest_framework import status
from rest_framework.exceptions import ValidationError
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView

from core.throttling import PostOnlyScopedRateThrottle
from partner_programs.models import Application, Submission
from partner_programs.serializers import SubmissionSerializer


def _application_queryset_for(user):
queryset = Application.objects.select_related("program", "user", "created_by")
if user.is_staff or user.is_superuser:
return queryset
return queryset.filter(user=user)


def _submission_queryset_for(user):
queryset = Submission.objects.select_related(
"application",
"application__user",
"application__created_by",
"program",
"submitted_by",
)
if user.is_staff or user.is_superuser:
return queryset
return queryset.filter(application__user=user)


def _submission_response(submission, response_status=status.HTTP_200_OK):
return Response(
SubmissionSerializer(submission).data,
status=response_status,
)


class ApplicationSubmissionListCreateView(APIView):
permission_classes = [IsAuthenticated]
throttle_classes = [PostOnlyScopedRateThrottle]
throttle_scope = "submission_create"

def get_application(self, request, application_id):
return get_object_or_404(
_application_queryset_for(request.user),
pk=application_id,
)

def get(self, request, application_id):
application = self.get_application(request, application_id)
submissions = (
Submission.objects.select_related("application", "program", "submitted_by")
.filter(application=application)
.order_by("-created_at", "-id")
)
return Response(SubmissionSerializer(submissions, many=True).data)

def post(self, request, application_id):
application = self.get_application(request, application_id)
if application.status not in (
Application.STATUS_SUBMITTED,
Application.STATUS_APPROVED,
):
raise ValidationError(
{
"application": (
"Submissions require a submitted or approved application."
)
}
)

serializer = SubmissionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
submission_data = dict(serializer.validated_data)
stage_key = submission_data.pop("stage_key", "main")
validated_version = submission_data.pop("version", None)
requested_version = (
validated_version if "version" in request.data else None
)
version = requested_version

try:
with transaction.atomic():
locked_application = get_object_or_404(
_application_queryset_for(request.user).select_for_update(),
pk=application.pk,
)
if locked_application.status not in (
Application.STATUS_SUBMITTED,
Application.STATUS_APPROVED,
):
raise ValidationError(
{
"application": (
"Submissions require a submitted or approved application."
)
}
)
if version is None:
latest_version = (
Submission.objects.filter(
application=locked_application,
stage_key=stage_key,
).aggregate(max_version=Max("version"))["max_version"]
or 0
)
version = latest_version + 1

submission = Submission.objects.create(
application=locked_application,
program=locked_application.program,
submitted_by=request.user,
stage_key=stage_key,
version=version,
**submission_data,
)
except (DjangoValidationError, IntegrityError) as exc:
if Submission.objects.filter(
application=application,
stage_key=stage_key,
version=version,
).exists():
raise ValidationError(
{
"version": (
"A submission with this stage and version already exists."
)
}
) from exc
if isinstance(exc, DjangoValidationError):
raise ValidationError(exc.message_dict) from exc
raise

return _submission_response(submission, status.HTTP_201_CREATED)


class SubmissionDetailView(APIView):
permission_classes = [IsAuthenticated]

def get_object(self, request, submission_id):
return get_object_or_404(
_submission_queryset_for(request.user),
pk=submission_id,
)

def get(self, request, submission_id):
return _submission_response(self.get_object(request, submission_id))

def patch(self, request, submission_id):
submission = self.get_object(request, submission_id)
if not submission.can_edit:
raise ValidationError(
{"status": "Only draft or returned submissions can be updated."}
)

serializer = SubmissionSerializer(
submission,
data=request.data,
partial=True,
)
serializer.is_valid(raise_exception=True)
try:
serializer.save()
except DjangoValidationError as exc:
raise ValidationError(exc.message_dict) from exc
return Response(serializer.data)


class SubmissionSubmitView(APIView):
permission_classes = [IsAuthenticated]

def post(self, request, submission_id):
with transaction.atomic():
submission = get_object_or_404(
_submission_queryset_for(request.user).select_for_update(),
pk=submission_id,
)
if submission.status == Submission.STATUS_SUBMITTED:
return _submission_response(submission)
if not submission.can_submit:
raise ValidationError(
{"status": "Only draft or returned submissions can be submitted."}
)

submission.status = Submission.STATUS_SUBMITTED
submission.submitted_at = timezone.now()
submission.save(update_fields=["status", "submitted_at", "updated_at"])
return _submission_response(submission)


class SubmissionCancelView(APIView):
permission_classes = [IsAuthenticated]

def post(self, request, submission_id):
with transaction.atomic():
submission = get_object_or_404(
_submission_queryset_for(request.user).select_for_update(),
pk=submission_id,
)
if submission.status == Submission.STATUS_CANCELLED:
return _submission_response(submission)
if not submission.can_edit:
raise ValidationError(
{"status": "Only draft or returned submissions can be cancelled."}
)

submission.status = Submission.STATUS_CANCELLED
submission.save(update_fields=["status", "updated_at"])
return _submission_response(submission)
Loading
Loading