Skip to content

Commit da0cfd1

Browse files
authored
Merge pull request #654 from PROCOLLAB-github/feature/application-api
Add application API
2 parents dc4f4d4 + b3c0dac commit da0cfd1

9 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
@@ -13,6 +13,7 @@ DRF_THROTTLE_AUTH_RESEND_EMAIL=3/min
1313
DRF_THROTTLE_AUTH_RESET_PASSWORD=3/min
1414
DRF_THROTTLE_TOKEN_OBTAIN=10/min
1515
DRF_THROTTLE_PROGRAM_REGISTER_NEW=10/min
16+
DRF_THROTTLE_APPLICATION_CREATE=10/min
1617

1718
SENTRY_DSN=
1819

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.applications_views import (
4+
ApplicationDetailView,
5+
ApplicationSubmitView,
6+
ApplicationWithdrawView,
7+
)
8+
9+
app_name = "applications"
10+
11+
urlpatterns = [
12+
path("<int:application_id>/", ApplicationDetailView.as_view(), name="detail"),
13+
path(
14+
"<int:application_id>/submit/",
15+
ApplicationSubmitView.as_view(),
16+
name="submit",
17+
),
18+
path(
19+
"<int:application_id>/withdraw/",
20+
ApplicationWithdrawView.as_view(),
21+
name="withdraw",
22+
),
23+
]
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
from django.core.exceptions import ValidationError as DjangoValidationError
2+
from django.db import IntegrityError, transaction
3+
from django.shortcuts import get_object_or_404
4+
from django.utils import timezone
5+
from rest_framework import status
6+
from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError
7+
from rest_framework.permissions import IsAuthenticated
8+
from rest_framework.response import Response
9+
from rest_framework.views import APIView
10+
11+
from core.throttling import PostOnlyScopedRateThrottle
12+
from partner_programs.models import Application, PartnerProgram
13+
from partner_programs.serializers import ApplicationSerializer
14+
15+
16+
def _application_queryset_for(user):
17+
queryset = Application.objects.select_related(
18+
"program",
19+
"user",
20+
"created_by",
21+
"project",
22+
)
23+
if user.is_staff or user.is_superuser:
24+
return queryset
25+
return queryset.filter(user=user)
26+
27+
28+
def _active_application(*, program, user):
29+
return (
30+
Application.objects.select_related(
31+
"program",
32+
"user",
33+
"created_by",
34+
"project",
35+
)
36+
.filter(
37+
program=program,
38+
user=user,
39+
status__in=Application.ACTIVE_STATUSES,
40+
)
41+
.order_by("-created_at")
42+
.first()
43+
)
44+
45+
46+
def _application_response(application, request, response_status=status.HTTP_200_OK):
47+
serializer = ApplicationSerializer(application, context={"request": request})
48+
return Response(serializer.data, status=response_status)
49+
50+
51+
class ProgramApplicationCreateView(APIView):
52+
permission_classes = [IsAuthenticated]
53+
throttle_classes = [PostOnlyScopedRateThrottle]
54+
throttle_scope = "application_create"
55+
56+
def post(self, request, program_id):
57+
program = get_object_or_404(PartnerProgram, pk=program_id)
58+
serializer = ApplicationSerializer(
59+
data=request.data,
60+
context={"request": request},
61+
)
62+
serializer.is_valid(raise_exception=True)
63+
64+
existing_application = _active_application(
65+
program=program,
66+
user=request.user,
67+
)
68+
if existing_application:
69+
return _application_response(existing_application, request)
70+
71+
try:
72+
with transaction.atomic():
73+
application = Application.objects.create(
74+
program=program,
75+
user=request.user,
76+
created_by=request.user,
77+
**serializer.validated_data,
78+
)
79+
except DjangoValidationError as exc:
80+
existing_application = _active_application(
81+
program=program,
82+
user=request.user,
83+
)
84+
if existing_application:
85+
return _application_response(existing_application, request)
86+
raise ValidationError(exc.message_dict) from exc
87+
except IntegrityError:
88+
existing_application = _active_application(
89+
program=program,
90+
user=request.user,
91+
)
92+
if existing_application:
93+
return _application_response(existing_application, request)
94+
raise
95+
96+
return _application_response(
97+
application,
98+
request,
99+
response_status=status.HTTP_201_CREATED,
100+
)
101+
102+
103+
class MyProgramApplicationView(APIView):
104+
permission_classes = [IsAuthenticated]
105+
106+
def get(self, request, program_id):
107+
program = get_object_or_404(PartnerProgram, pk=program_id)
108+
application = _active_application(program=program, user=request.user)
109+
if application is None:
110+
application = (
111+
Application.objects.select_related(
112+
"program",
113+
"user",
114+
"created_by",
115+
"project",
116+
)
117+
.filter(program=program, user=request.user)
118+
.order_by("-created_at")
119+
.first()
120+
)
121+
if application is None:
122+
raise NotFound("Application not found.")
123+
return _application_response(application, request)
124+
125+
126+
class ApplicationDetailView(APIView):
127+
permission_classes = [IsAuthenticated]
128+
129+
def get_object(self, request, application_id):
130+
return get_object_or_404(
131+
_application_queryset_for(request.user),
132+
pk=application_id,
133+
)
134+
135+
def get(self, request, application_id):
136+
application = self.get_object(request, application_id)
137+
return _application_response(application, request)
138+
139+
def patch(self, request, application_id):
140+
application = self.get_object(request, application_id)
141+
if application.user_id != request.user.pk:
142+
raise PermissionDenied("Only the application owner can update it.")
143+
144+
serializer = ApplicationSerializer(
145+
application,
146+
data=request.data,
147+
partial=True,
148+
context={"request": request},
149+
)
150+
serializer.is_valid(raise_exception=True)
151+
try:
152+
serializer.save()
153+
except DjangoValidationError as exc:
154+
raise ValidationError(exc.message_dict) from exc
155+
return Response(serializer.data, status=status.HTTP_200_OK)
156+
157+
158+
class ApplicationSubmitView(APIView):
159+
permission_classes = [IsAuthenticated]
160+
161+
def post(self, request, application_id):
162+
with transaction.atomic():
163+
application = get_object_or_404(
164+
_application_queryset_for(request.user).select_for_update(),
165+
pk=application_id,
166+
)
167+
if application.status == Application.STATUS_SUBMITTED:
168+
return _application_response(application, request)
169+
if application.status != Application.STATUS_DRAFT:
170+
raise ValidationError(
171+
{"status": "Only draft applications can be submitted."}
172+
)
173+
174+
application.status = Application.STATUS_SUBMITTED
175+
application.submitted_at = timezone.now()
176+
application.save(update_fields=["status", "submitted_at", "updated_at"])
177+
return _application_response(application, request)
178+
179+
180+
class ApplicationWithdrawView(APIView):
181+
permission_classes = [IsAuthenticated]
182+
allowed_statuses = frozenset(
183+
{
184+
Application.STATUS_DRAFT,
185+
Application.STATUS_SUBMITTED,
186+
Application.STATUS_APPROVED,
187+
}
188+
)
189+
190+
def post(self, request, application_id):
191+
with transaction.atomic():
192+
application = get_object_or_404(
193+
_application_queryset_for(request.user).select_for_update(),
194+
pk=application_id,
195+
)
196+
if application.status == Application.STATUS_WITHDRAWN:
197+
return _application_response(application, request)
198+
if application.status not in self.allowed_statuses:
199+
raise ValidationError({"status": "This application cannot be withdrawn."})
200+
201+
application.status = Application.STATUS_WITHDRAWN
202+
application.withdrawn_at = timezone.now()
203+
application.save(update_fields=["status", "withdrawn_at", "updated_at"])
204+
return _application_response(application, request)

partner_programs/serializers/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from .applications import ApplicationSerializer
12
from .fields import PartnerProgramFieldValueUpdateSerializer
23
from .programs import (
34
PartnerProgramBaseSerializerMixin,
@@ -17,6 +18,7 @@
1718
)
1819

1920
__all__ = [
21+
"ApplicationSerializer",
2022
"PartnerProgramBaseSerializerMixin",
2123
"PartnerProgramDataSchemaSerializer",
2224
"PartnerProgramFieldSerializer",
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
from rest_framework import serializers
2+
3+
from partner_programs.models import Application
4+
from projects.models import Project
5+
6+
7+
class ApplicationSerializer(serializers.ModelSerializer):
8+
project = serializers.PrimaryKeyRelatedField(
9+
queryset=Project.objects.all(),
10+
allow_null=True,
11+
required=False,
12+
)
13+
project_id = serializers.PrimaryKeyRelatedField(
14+
source="project",
15+
queryset=Project.objects.all(),
16+
allow_null=True,
17+
required=False,
18+
write_only=True,
19+
)
20+
21+
immutable_input_fields = frozenset(
22+
{
23+
"id",
24+
"program",
25+
"user",
26+
"created_by",
27+
"status",
28+
"submitted_at",
29+
"approved_at",
30+
"rejected_at",
31+
"withdrawn_at",
32+
"created_at",
33+
"updated_at",
34+
}
35+
)
36+
37+
class Meta:
38+
model = Application
39+
fields = (
40+
"id",
41+
"program",
42+
"user",
43+
"created_by",
44+
"status",
45+
"form_data",
46+
"project",
47+
"project_id",
48+
"submitted_at",
49+
"approved_at",
50+
"rejected_at",
51+
"withdrawn_at",
52+
"created_at",
53+
"updated_at",
54+
)
55+
read_only_fields = (
56+
"id",
57+
"program",
58+
"user",
59+
"created_by",
60+
"status",
61+
"submitted_at",
62+
"approved_at",
63+
"rejected_at",
64+
"withdrawn_at",
65+
"created_at",
66+
"updated_at",
67+
)
68+
extra_kwargs = {
69+
"form_data": {"required": False},
70+
}
71+
72+
def validate(self, attrs):
73+
supplied_immutable_fields = self.immutable_input_fields.intersection(
74+
self.initial_data
75+
)
76+
if supplied_immutable_fields:
77+
raise serializers.ValidationError(
78+
{
79+
field: "This field is read-only."
80+
for field in sorted(supplied_immutable_fields)
81+
}
82+
)
83+
84+
if "project" in self.initial_data and "project_id" in self.initial_data:
85+
raise serializers.ValidationError(
86+
{"project": "Use either project or project_id, not both."}
87+
)
88+
89+
if self.instance and self.instance.status != Application.STATUS_DRAFT:
90+
raise serializers.ValidationError(
91+
{"status": "Only draft applications can be updated."}
92+
)
93+
94+
project = attrs.get("project", serializers.empty)
95+
if project is not serializers.empty and project is not None:
96+
request = self.context.get("request")
97+
user = getattr(request, "user", None)
98+
if not user or not user.is_authenticated or project.leader_id != user.pk:
99+
raise serializers.ValidationError(
100+
{"project": "You can only use a project that you lead."}
101+
)
102+
103+
return attrs

0 commit comments

Comments
 (0)