Skip to content

Commit f2a13d1

Browse files
committed
♻️ (schema) refactor view serializers
We decide to generate our javascript client and typescript types from swagger open api schema. That push us to rework our serialization.
1 parent 7f5cb75 commit f2a13d1

17 files changed

+414
-40
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ and this project adheres to
2020
- Allow on-demand page size on the order and enrollment endpoints
2121
- Add yarn cli to generate joanie api client in TypeScript
2222
- Display course runs into the admin course change view
23+
- Add course query param to openapi schema on route products.retrieve
2324

2425
### Removed
2526

src/backend/joanie/core/api.py

Lines changed: 64 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
from django.http import HttpResponse
77
from django.utils.translation import gettext_lazy as _
88

9+
from drf_yasg import openapi
10+
from drf_yasg.utils import swagger_auto_schema
911
from rest_framework import mixins, pagination, permissions, viewsets
1012
from rest_framework.decorators import action
1113
from rest_framework.exceptions import ValidationError as DRFValidationError
@@ -14,6 +16,10 @@
1416

1517
from joanie.core import models
1618
from joanie.core.enums import ORDER_STATE_PENDING
19+
from joanie.core.viewsets import (
20+
RequestResponseSerializersViewSetMixin,
21+
ActionSerializerType,
22+
)
1723
from joanie.payment import get_payment_backend
1824
from joanie.payment.models import Invoice
1925

@@ -111,6 +117,12 @@ def get_serializer_context(self):
111117

112118
return context
113119

120+
@swagger_auto_schema(
121+
query_serializer=serializers.ProductRetrieveQuerySerializer,
122+
)
123+
def retrieve(self, *args, **kwargs):
124+
return super().retrieve(*args, **kwargs)
125+
114126

115127
# pylint: disable=too-many-ancestors
116128
class EnrollmentViewSet(
@@ -141,6 +153,7 @@ def perform_create(self, serializer):
141153

142154
# pylint: disable=too-many-ancestors
143155
class OrderViewSet(
156+
RequestResponseSerializersViewSetMixin,
144157
mixins.ListModelMixin,
145158
mixins.RetrieveModelMixin,
146159
mixins.CreateModelMixin,
@@ -163,6 +176,12 @@ class OrderViewSet(
163176
pagination_class = Pagination
164177
permission_classes = [permissions.IsAuthenticated]
165178
serializer_class = serializers.OrderSerializer
179+
action_serializers = {
180+
"create": {
181+
"request": serializers.OrderCreateSerializer,
182+
"response": serializers.OrderCreateResponseSerializer,
183+
}
184+
}
166185
filterset_class = filters.OrderViewSetFilter
167186
ordering = ["-created_on"]
168187

@@ -171,21 +190,22 @@ def get_queryset(self):
171190
user = User.update_or_create_from_request_user(request_user=self.request.user)
172191
return user.orders.all().select_related("owner", "product", "certificate")
173192

174-
def perform_create(self, serializer):
193+
def perform_create(self, validated_data):
175194
"""Force the order's "owner" field to the logged-in user."""
176195
owner = User.update_or_create_from_request_user(request_user=self.request.user)
177-
serializer.save(owner=owner)
196+
return models.Order.objects.create(**validated_data, owner=owner)
178197

179198
@transaction.atomic
180199
def create(self, request, *args, **kwargs):
181200
"""Try to create an order and a related payment if the payment is fee."""
182-
serializer = self.get_serializer(data=request.data)
201+
serializer = self.get_request_serializer(data=request.data)
183202
if not serializer.is_valid():
184203
return Response(serializer.errors, status=400)
185204

186205
product = serializer.validated_data.get("product")
187206
course = serializer.validated_data.get("course")
188-
billing_address = serializer.initial_data.get("billing_address")
207+
billing_address = serializer.validated_data.get("billing_address")
208+
credit_card_id = serializer.validated_data.get("credit_card_id")
189209

190210
# Populate organization field if it is not set and there is only one
191211
# on the product
@@ -210,7 +230,13 @@ def create(self, request, *args, **kwargs):
210230

211231
# - Validate data then create an order
212232
try:
213-
self.perform_create(serializer)
233+
order_validated_data = {**serializer.validated_data}
234+
if billing_address:
235+
order_validated_data.pop("billing_address")
236+
if credit_card_id:
237+
order_validated_data.pop("credit_card_id")
238+
# FIXME this pop stuff should be done in OrderCreateSerializer.save
239+
order = self.perform_create(order_validated_data)
214240
except (DRFValidationError, IntegrityError):
215241
return Response(
216242
(
@@ -222,10 +248,7 @@ def create(self, request, *args, **kwargs):
222248

223249
# Once order has been created, if product is not free, create a payment
224250
if product.price.amount > 0:
225-
order = serializer.instance
226251
payment_backend = get_payment_backend()
227-
credit_card_id = serializer.initial_data.get("credit_card_id")
228-
229252
# if payment in one click
230253
if credit_card_id:
231254
try:
@@ -245,14 +268,22 @@ def create(self, request, *args, **kwargs):
245268
request=request, order=order, billing_address=billing_address
246269
)
247270

248-
# Return the fresh new order with payment_info
249-
return Response(
250-
{**serializer.data, "payment_info": payment_info}, status=201
271+
response_serializer = self.get_response_serializer(
272+
instance=order,
273+
context={
274+
"payment_info": payment_info,
275+
},
251276
)
277+
return Response(response_serializer.data, status=201)
252278

253279
# Else return the fresh new order
254-
return Response(serializer.data, status=201)
280+
response_serializer = self.get_response_serializer(instance=order)
281+
return Response(response_serializer.data, status=201)
255282

283+
@swagger_auto_schema(
284+
request_body=serializers.OrderAbortBodySerializer,
285+
responses={204: serializers.EmptyResponseSerializer},
286+
)
256287
@action(detail=True, methods=["POST"])
257288
def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
258289
"""Abort a pending order and the related payment if there is one."""
@@ -277,6 +308,17 @@ def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
277308

278309
return Response(status=204)
279310

311+
@swagger_auto_schema(
312+
query_serializer=serializers.OrderInvoiceQuerySerializer,
313+
responses={
314+
200: openapi.Response(
315+
"File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
316+
),
317+
400: serializers.ErrorResponseSerializer,
318+
404: serializers.ErrorResponseSerializer,
319+
},
320+
produces="application/pdf",
321+
)
280322
@action(detail=True, methods=["GET"])
281323
def invoice(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
282324
"""
@@ -391,6 +433,16 @@ def get_queryset(self):
391433
user = User.update_or_create_from_request_user(request_user=self.request.user)
392434
return models.Certificate.objects.filter(order__owner=user)
393435

436+
@swagger_auto_schema(
437+
responses={
438+
200: openapi.Response(
439+
"File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
440+
),
441+
404: serializers.ErrorResponseSerializer,
442+
422: serializers.ErrorResponseSerializer,
443+
},
444+
produces="application/pdf",
445+
)
394446
@action(detail=True, methods=["GET"])
395447
def download(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
396448
"""

src/backend/joanie/core/schema.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from drf_yasg.inspectors import SwaggerAutoSchema
2+
import drf_yasg.inspectors.base
3+
import drf_yasg.openapi
4+
import drf_yasg.utils
5+
6+
from joanie.core.viewsets import ActionSerializerType
7+
8+
9+
def _call_view_method(
10+
view, method_name, fallback_attr=None, default=None, args=None, kwargs=None
11+
):
12+
"""Override of drf_yasg.inspectors.base.call_view_method to allow passing args."""
13+
if hasattr(view, method_name):
14+
try:
15+
view_method, is_callabale = drf_yasg.inspectors.base.is_callable_method(
16+
view, method_name
17+
)
18+
if is_callabale:
19+
args = args or []
20+
kwargs = kwargs or {}
21+
return view_method(*args, **kwargs)
22+
except Exception: # pragma: no cover
23+
drf_yasg.inspectors.base.logger.warning(
24+
"view's %s raised exception during schema generation; use "
25+
"`getattr(self, 'swagger_fake_view', False)` to detect and short-circuit this",
26+
type(view).__name__,
27+
exc_info=True,
28+
)
29+
30+
if fallback_attr and hasattr(view, fallback_attr):
31+
return getattr(view, fallback_attr)
32+
33+
return default
34+
35+
36+
class CustomAutoSchema(SwaggerAutoSchema):
37+
"""
38+
SwaggerAutoSchema for viewsets with Request and Response serializers.
39+
https://github.com/axnsan12/drf-yasg/blob/master/src/drf_yasg/inspectors/view.py
40+
"""
41+
42+
def get_view_serializer(self, serializer_type):
43+
"""Retrieve the serializer type"""
44+
return _call_view_method(
45+
self.view,
46+
"get_serializer",
47+
kwargs={"context": {"serializer_type": serializer_type}},
48+
)
49+
50+
def get_request_serializer(self):
51+
"""Retrieve Request serializer"""
52+
body_override = self._get_request_body_override()
53+
54+
if body_override is None and self.method in self.implicit_body_methods:
55+
return _call_view_method(
56+
self.view,
57+
"get_serializer",
58+
kwargs={
59+
"context": {"serializer_type": ActionSerializerType.REQUEST.value}
60+
},
61+
)
62+
63+
if body_override is drf_yasg.utils.no_body:
64+
return None
65+
66+
return body_override
67+
68+
def get_default_response_serializer(self):
69+
"""Retrieve Redsponse serializer"""
70+
body_override = self._get_request_body_override()
71+
if body_override and body_override is not drf_yasg.utils.no_body:
72+
return body_override
73+
74+
return _call_view_method(
75+
self.view,
76+
"get_serializer",
77+
kwargs={
78+
"context": {"serializer_type": ActionSerializerType.RESPONSE.value}
79+
},
80+
)
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from .model_serializers import *
2+
from .empty_response_serializer import *
3+
from .error_response_serializer import *
4+
from .order_create_body_serializer import *
5+
from .order_abort_body_serializer import *
6+
from .order_invoice_query_serializer import *
7+
from .product_retrieve_query_serializer import *
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
"""Serializers for empty Response"""
2+
3+
from rest_framework import serializers
4+
5+
6+
class EmptyResponseSerializer(serializers.Serializer):
7+
pass
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""Serializers for core.api.OrderViewSet.abort Body"""
2+
3+
from rest_framework import serializers
4+
5+
6+
class ErrorResponseSerializer(serializers.Serializer):
7+
details = serializers.CharField(required=True)
8+
9+
class Meta:
10+
fields = ["details"]

0 commit comments

Comments
 (0)