Skip to content

Commit 363a050

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 363a050

17 files changed

+421
-41
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: 68 additions & 13 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,23 @@ 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+
print(">>>> owner", owner)
197+
return models.Order.objects.create(**validated_data, owner=owner)
178198

179199
@transaction.atomic
180200
def create(self, request, *args, **kwargs):
181201
"""Try to create an order and a related payment if the payment is fee."""
182-
serializer = self.get_serializer(data=request.data)
202+
serializer = self.get_request_serializer(data=request.data)
183203
if not serializer.is_valid():
184204
return Response(serializer.errors, status=400)
185205

186206
product = serializer.validated_data.get("product")
187207
course = serializer.validated_data.get("course")
188-
billing_address = serializer.initial_data.get("billing_address")
208+
billing_address = serializer.validated_data.get("billing_address")
209+
credit_card_id = serializer.validated_data.get("credit_card_id")
189210

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

211232
# - Validate data then create an order
212233
try:
213-
self.perform_create(serializer)
234+
order_validated_data = {**serializer.validated_data}
235+
if billing_address:
236+
order_validated_data.pop("billing_address")
237+
if credit_card_id:
238+
order_validated_data.pop("credit_card_id")
239+
# FIXME this pop stuff should be done in OrderCreateSerializer.save
240+
order = self.perform_create(order_validated_data)
214241
except (DRFValidationError, IntegrityError):
215242
return Response(
216243
(
@@ -222,12 +249,10 @@ def create(self, request, *args, **kwargs):
222249

223250
# Once order has been created, if product is not free, create a payment
224251
if product.price.amount > 0:
225-
order = serializer.instance
226252
payment_backend = get_payment_backend()
227-
credit_card_id = serializer.initial_data.get("credit_card_id")
228-
229253
# if payment in one click
230254
if credit_card_id:
255+
print("payment info 3")
231256
try:
232257
credit_card = CreditCard.objects.get(
233258
owner=order.owner, id=credit_card_id
@@ -241,18 +266,27 @@ def create(self, request, *args, **kwargs):
241266
except (CreditCard.DoesNotExist, NotImplementedError):
242267
pass
243268
else:
269+
print("payment info 2")
244270
payment_info = payment_backend.create_payment(
245271
request=request, order=order, billing_address=billing_address
246272
)
247273

248-
# Return the fresh new order with payment_info
249-
return Response(
250-
{**serializer.data, "payment_info": payment_info}, status=201
274+
response_serializer = self.get_response_serializer(
275+
instance=order,
276+
context={
277+
"payment_info": payment_info,
278+
},
251279
)
252-
280+
return Response(response_serializer.data, status=201)
281+
print("payment info None")
253282
# Else return the fresh new order
254-
return Response(serializer.data, status=201)
283+
response_serializer = self.get_response_serializer(instance=order)
284+
return Response(response_serializer.data, status=201)
255285

286+
@swagger_auto_schema(
287+
request_body=serializers.OrderAbortBodySerializer,
288+
responses={204: serializers.EmptyResponseSerializer},
289+
)
256290
@action(detail=True, methods=["POST"])
257291
def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
258292
"""Abort a pending order and the related payment if there is one."""
@@ -277,6 +311,17 @@ def abort(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
277311

278312
return Response(status=204)
279313

314+
@swagger_auto_schema(
315+
query_serializer=serializers.OrderInvoiceQuerySerializer,
316+
responses={
317+
200: openapi.Response(
318+
"File Attachment", schema=openapi.Schema(type=openapi.TYPE_FILE)
319+
),
320+
400: serializers.ErrorResponseSerializer,
321+
404: serializers.ErrorResponseSerializer,
322+
},
323+
produces="application/pdf",
324+
)
280325
@action(detail=True, methods=["GET"])
281326
def invoice(self, request, pk=None): # pylint: disable=no-self-use, invalid-name
282327
"""
@@ -391,6 +436,16 @@ def get_queryset(self):
391436
user = User.update_or_create_from_request_user(request_user=self.request.user)
392437
return models.Certificate.objects.filter(order__owner=user)
393438

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

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)