Skip to content

Commit fb4658a

Browse files
authored
Merge pull request #656 from PROCOLLAB-github/feature/submission-api
Add submission API
2 parents 00f6b78 + 6ba74bb commit fb4658a

10 files changed

Lines changed: 750 additions & 0 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min
1414
DRF_THROTTLE_TOKEN_OBTAIN=10/min
1515
DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min
1616
DRF_THROTTLE_APPLICATION_CREATE=10/min
17+
DRF_THROTTLE_SUBMISSION_CREATE=10/min
1718

1819
SENTRY_DSN=
1920

partner_programs/application_urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,16 @@
55
ApplicationSubmitView,
66
ApplicationWithdrawView,
77
)
8+
from partner_programs.submission_views import ApplicationSubmissionListCreateView
89

910
app_name = "applications"
1011

1112
urlpatterns = [
13+
path(
14+
"<int:application_id>/submissions/",
15+
ApplicationSubmissionListCreateView.as_view(),
16+
name="submission-list-create",
17+
),
1218
path("<int:application_id>/", ApplicationDetailView.as_view(), name="detail"),
1319
path(
1420
"<int:application_id>/submit/",

partner_programs/models.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -364,6 +364,9 @@ def clean(self):
364364
if (
365365
self.submitted_by_id
366366
and self.submitted_by_id not in allowed_submitter_ids
367+
and not (
368+
self.submitted_by.is_staff or self.submitted_by.is_superuser
369+
)
367370
):
368371
errors["submitted_by"] = (
369372
"Submitted by must match the application user or creator."

partner_programs/serializers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
ProgramProjectFilterRequestSerializer,
1717
UserProgramsSerializer,
1818
)
19+
from .submissions import SubmissionSerializer
1920

2021
__all__ = [
2122
"ApplicationSerializer",
@@ -33,5 +34,6 @@
3334
"PartnerProgramUserSerializer",
3435
"ProgramProjectCreateSerializer",
3536
"ProgramProjectFilterRequestSerializer",
37+
"SubmissionSerializer",
3638
"UserProgramsSerializer",
3739
]
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
from rest_framework import serializers
2+
3+
from partner_programs.models import Submission
4+
5+
6+
class SubmissionSerializer(serializers.ModelSerializer):
7+
create_fields = frozenset(
8+
{
9+
"title",
10+
"description",
11+
"form_data",
12+
"links",
13+
"stage_key",
14+
"version",
15+
}
16+
)
17+
update_fields = frozenset(
18+
{
19+
"title",
20+
"description",
21+
"form_data",
22+
"links",
23+
}
24+
)
25+
26+
class Meta:
27+
model = Submission
28+
fields = (
29+
"id",
30+
"application",
31+
"program",
32+
"submitted_by",
33+
"title",
34+
"description",
35+
"form_data",
36+
"links",
37+
"status",
38+
"stage_key",
39+
"version",
40+
"submitted_at",
41+
"created_at",
42+
"updated_at",
43+
)
44+
read_only_fields = (
45+
"id",
46+
"application",
47+
"program",
48+
"submitted_by",
49+
"status",
50+
"submitted_at",
51+
"created_at",
52+
"updated_at",
53+
)
54+
extra_kwargs = {
55+
"description": {"required": False},
56+
"form_data": {"required": False},
57+
"links": {"required": False},
58+
"stage_key": {"required": False},
59+
"version": {"required": False, "min_value": 1},
60+
}
61+
62+
def validate(self, attrs):
63+
allowed_fields = self.update_fields if self.instance else self.create_fields
64+
unsupported_fields = set(self.initial_data).difference(allowed_fields)
65+
if unsupported_fields:
66+
raise serializers.ValidationError(
67+
{
68+
field: "This field is read-only."
69+
for field in sorted(unsupported_fields)
70+
}
71+
)
72+
73+
if self.instance and not self.instance.can_edit:
74+
raise serializers.ValidationError(
75+
{"status": "Only draft or returned submissions can be updated."}
76+
)
77+
78+
return attrs
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from django.urls import path
2+
3+
from partner_programs.submission_views import (
4+
SubmissionCancelView,
5+
SubmissionDetailView,
6+
SubmissionSubmitView,
7+
)
8+
9+
app_name = "submissions"
10+
11+
urlpatterns = [
12+
path("<int:submission_id>/", SubmissionDetailView.as_view(), name="detail"),
13+
path(
14+
"<int:submission_id>/submit/",
15+
SubmissionSubmitView.as_view(),
16+
name="submit",
17+
),
18+
path(
19+
"<int:submission_id>/cancel/",
20+
SubmissionCancelView.as_view(),
21+
name="cancel",
22+
),
23+
]
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
from django.core.exceptions import ValidationError as DjangoValidationError
2+
from django.db import IntegrityError, transaction
3+
from django.db.models import Max
4+
from django.shortcuts import get_object_or_404
5+
from django.utils import timezone
6+
from rest_framework import status
7+
from rest_framework.exceptions import ValidationError
8+
from rest_framework.permissions import IsAuthenticated
9+
from rest_framework.response import Response
10+
from rest_framework.views import APIView
11+
12+
from core.throttling import PostOnlyScopedRateThrottle
13+
from partner_programs.models import Application, Submission
14+
from partner_programs.serializers import SubmissionSerializer
15+
16+
17+
def _application_queryset_for(user):
18+
queryset = Application.objects.select_related("program", "user", "created_by")
19+
if user.is_staff or user.is_superuser:
20+
return queryset
21+
return queryset.filter(user=user)
22+
23+
24+
def _submission_queryset_for(user):
25+
queryset = Submission.objects.select_related(
26+
"application",
27+
"application__user",
28+
"application__created_by",
29+
"program",
30+
"submitted_by",
31+
)
32+
if user.is_staff or user.is_superuser:
33+
return queryset
34+
return queryset.filter(application__user=user)
35+
36+
37+
def _submission_response(submission, response_status=status.HTTP_200_OK):
38+
return Response(
39+
SubmissionSerializer(submission).data,
40+
status=response_status,
41+
)
42+
43+
44+
class ApplicationSubmissionListCreateView(APIView):
45+
permission_classes = [IsAuthenticated]
46+
throttle_classes = [PostOnlyScopedRateThrottle]
47+
throttle_scope = "submission_create"
48+
49+
def get_application(self, request, application_id):
50+
return get_object_or_404(
51+
_application_queryset_for(request.user),
52+
pk=application_id,
53+
)
54+
55+
def get(self, request, application_id):
56+
application = self.get_application(request, application_id)
57+
submissions = (
58+
Submission.objects.select_related("application", "program", "submitted_by")
59+
.filter(application=application)
60+
.order_by("-created_at", "-id")
61+
)
62+
return Response(SubmissionSerializer(submissions, many=True).data)
63+
64+
def post(self, request, application_id):
65+
application = self.get_application(request, application_id)
66+
if application.status not in (
67+
Application.STATUS_SUBMITTED,
68+
Application.STATUS_APPROVED,
69+
):
70+
raise ValidationError(
71+
{
72+
"application": (
73+
"Submissions require a submitted or approved application."
74+
)
75+
}
76+
)
77+
78+
serializer = SubmissionSerializer(data=request.data)
79+
serializer.is_valid(raise_exception=True)
80+
submission_data = dict(serializer.validated_data)
81+
stage_key = submission_data.pop("stage_key", "main")
82+
validated_version = submission_data.pop("version", None)
83+
requested_version = (
84+
validated_version if "version" in request.data else None
85+
)
86+
version = requested_version
87+
88+
try:
89+
with transaction.atomic():
90+
locked_application = get_object_or_404(
91+
_application_queryset_for(request.user).select_for_update(),
92+
pk=application.pk,
93+
)
94+
if locked_application.status not in (
95+
Application.STATUS_SUBMITTED,
96+
Application.STATUS_APPROVED,
97+
):
98+
raise ValidationError(
99+
{
100+
"application": (
101+
"Submissions require a submitted or approved application."
102+
)
103+
}
104+
)
105+
if version is None:
106+
latest_version = (
107+
Submission.objects.filter(
108+
application=locked_application,
109+
stage_key=stage_key,
110+
).aggregate(max_version=Max("version"))["max_version"]
111+
or 0
112+
)
113+
version = latest_version + 1
114+
115+
submission = Submission.objects.create(
116+
application=locked_application,
117+
program=locked_application.program,
118+
submitted_by=request.user,
119+
stage_key=stage_key,
120+
version=version,
121+
**submission_data,
122+
)
123+
except (DjangoValidationError, IntegrityError) as exc:
124+
if Submission.objects.filter(
125+
application=application,
126+
stage_key=stage_key,
127+
version=version,
128+
).exists():
129+
raise ValidationError(
130+
{
131+
"version": (
132+
"A submission with this stage and version already exists."
133+
)
134+
}
135+
) from exc
136+
if isinstance(exc, DjangoValidationError):
137+
raise ValidationError(exc.message_dict) from exc
138+
raise
139+
140+
return _submission_response(submission, status.HTTP_201_CREATED)
141+
142+
143+
class SubmissionDetailView(APIView):
144+
permission_classes = [IsAuthenticated]
145+
146+
def get_object(self, request, submission_id):
147+
return get_object_or_404(
148+
_submission_queryset_for(request.user),
149+
pk=submission_id,
150+
)
151+
152+
def get(self, request, submission_id):
153+
return _submission_response(self.get_object(request, submission_id))
154+
155+
def patch(self, request, submission_id):
156+
submission = self.get_object(request, submission_id)
157+
if not submission.can_edit:
158+
raise ValidationError(
159+
{"status": "Only draft or returned submissions can be updated."}
160+
)
161+
162+
serializer = SubmissionSerializer(
163+
submission,
164+
data=request.data,
165+
partial=True,
166+
)
167+
serializer.is_valid(raise_exception=True)
168+
try:
169+
serializer.save()
170+
except DjangoValidationError as exc:
171+
raise ValidationError(exc.message_dict) from exc
172+
return Response(serializer.data)
173+
174+
175+
class SubmissionSubmitView(APIView):
176+
permission_classes = [IsAuthenticated]
177+
178+
def post(self, request, submission_id):
179+
with transaction.atomic():
180+
submission = get_object_or_404(
181+
_submission_queryset_for(request.user).select_for_update(),
182+
pk=submission_id,
183+
)
184+
if submission.status == Submission.STATUS_SUBMITTED:
185+
return _submission_response(submission)
186+
if not submission.can_submit:
187+
raise ValidationError(
188+
{"status": "Only draft or returned submissions can be submitted."}
189+
)
190+
191+
submission.status = Submission.STATUS_SUBMITTED
192+
submission.submitted_at = timezone.now()
193+
submission.save(update_fields=["status", "submitted_at", "updated_at"])
194+
return _submission_response(submission)
195+
196+
197+
class SubmissionCancelView(APIView):
198+
permission_classes = [IsAuthenticated]
199+
200+
def post(self, request, submission_id):
201+
with transaction.atomic():
202+
submission = get_object_or_404(
203+
_submission_queryset_for(request.user).select_for_update(),
204+
pk=submission_id,
205+
)
206+
if submission.status == Submission.STATUS_CANCELLED:
207+
return _submission_response(submission)
208+
if not submission.can_edit:
209+
raise ValidationError(
210+
{"status": "Only draft or returned submissions can be cancelled."}
211+
)
212+
213+
submission.status = Submission.STATUS_CANCELLED
214+
submission.save(update_fields=["status", "updated_at"])
215+
return _submission_response(submission)

0 commit comments

Comments
 (0)