Skip to content

Commit 7eb92f2

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 4b5d11a commit 7eb92f2

17 files changed

+402
-37
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: 75 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,27 @@ 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_serializer(
202+
data=request.data,
203+
context={
204+
"serializer_type": ActionSerializerType.REQUEST,
205+
},
206+
)
183207
if not serializer.is_valid():
184208
return Response(serializer.errors, status=400)
185209

186210
product = serializer.validated_data.get("product")
187211
course = serializer.validated_data.get("course")
188-
billing_address = serializer.initial_data.get("billing_address")
212+
billing_address = serializer.validated_data.get("billing_address")
213+
credit_card_id = serializer.validated_data.get("credit_card_id")
189214

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

211236
# - Validate data then create an order
212237
try:
213-
self.perform_create(serializer)
238+
order_validated_data = {**serializer.validated_data}
239+
if billing_address:
240+
order_validated_data.pop("billing_address")
241+
if credit_card_id:
242+
order_validated_data.pop("credit_card_id")
243+
# FIXME this pop stuff should be done in OrderCreateSerializer.save
244+
order = self.perform_create(order_validated_data)
214245
except (DRFValidationError, IntegrityError):
215246
return Response(
216247
(
@@ -222,10 +253,7 @@ def create(self, request, *args, **kwargs):
222253

223254
# Once order has been created, if product is not free, create a payment
224255
if product.price.amount > 0:
225-
order = serializer.instance
226256
payment_backend = get_payment_backend()
227-
credit_card_id = serializer.initial_data.get("credit_card_id")
228-
229257
# if payment in one click
230258
if credit_card_id:
231259
try:
@@ -245,14 +273,28 @@ def create(self, request, *args, **kwargs):
245273
request=request, order=order, billing_address=billing_address
246274
)
247275

248-
# Return the fresh new order with payment_info
249-
return Response(
250-
{**serializer.data, "payment_info": payment_info}, status=201
276+
response_serializer = self.get_serializer(
277+
instance=order,
278+
context={
279+
"serializer_type": ActionSerializerType.RESPONSE,
280+
"payment_info": payment_info,
281+
},
251282
)
283+
return Response(response_serializer.data, status=201)
252284

253285
# Else return the fresh new order
254-
return Response(serializer.data, status=201)
286+
response_serializer = self.get_serializer(
287+
instance=order,
288+
context={
289+
"serializer_type": ActionSerializerType.RESPONSE,
290+
},
291+
)
292+
return Response(response_serializer.data, status=201)
255293

294+
@swagger_auto_schema(
295+
request_body=serializers.OrderAbortBodySerializer,
296+
responses={204: serializers.EmptyResponseSerializer},
297+
)
256298
@action(detail=True, methods=["POST"])
257299
def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
258300
"""Abort a pending order and the related payment if there is one."""
@@ -277,6 +319,17 @@ def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
277319

278320
return Response(status=204)
279321

322+
@swagger_auto_schema(
323+
query_serializer=serializers.OrderInvoiceQuerySerializer,
324+
responses={
325+
200: openapi.Response(
326+
"File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
327+
),
328+
400: serializers.ErrorResponseSerializer,
329+
404: serializers.ErrorResponseSerializer,
330+
},
331+
produces="application/pdf",
332+
)
280333
@action(detail=True, methods=["GET"])
281334
def invoice(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
282335
"""
@@ -391,6 +444,16 @@ def get_queryset(self):
391444
user = User.update_or_create_from_request_user(request_user=self.request.user)
392445
return models.Certificate.objects.filter(order__owner=user)
393446

447+
@swagger_auto_schema(
448+
responses={
449+
200: openapi.Response(
450+
"File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
451+
),
452+
404: serializers.ErrorResponseSerializer,
453+
422: serializers.ErrorResponseSerializer,
454+
},
455+
produces="application/pdf",
456+
)
394457
@action(detail=True, methods=["GET"])
395458
def download(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
396459
"""

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)