Skip to content

Commit d1bd0b4

Browse files
committed
fix: 전반적인 코드 개선 1차
1 parent e07ae90 commit d1bd0b4

15 files changed

Lines changed: 121 additions & 117 deletions

File tree

app/admin_api/serializers/shop/orders.py

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from contextlib import suppress
21
from typing import Any
32
from urllib.parse import urljoin
43

@@ -9,7 +8,6 @@
98
from core.serializer.read_only_serializer import ReadOnlyModelSerializer
109
from core.serializer.skip_none_list_serializer import SkipNoneListSerializer
1110
from django.conf import settings
12-
from django.urls import NoReverseMatch
1311
from notification.channels import NotificationChannel
1412
from notification.models.base import Recipient
1513
from rest_framework import serializers
@@ -140,9 +138,7 @@ def to_representation(self, order: Order) -> Recipient | None:
140138
)
141139
for o_rel in order_product_rel.options.all()
142140
}
143-
with suppress(NoReverseMatch):
144-
# Order scancode viewset 미등록 (TODO.md). 미설정 시 missing_variables 로 보고.
145-
ctx["scancode_url"] = urljoin(settings.BACKEND_DOMAIN, order.scancode_path)
141+
ctx["scancode_url"] = urljoin(settings.BACKEND_DOMAIN, order.scancode_path)
146142

147143
return {"recipient": recipient, "context": ctx | self.context["context_override"]}
148144

app/admin_api/views/shop/orders.py

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,7 @@
99
from admin_api.serializers.shop.orders import OrderAdminSerializer, OrderExportRequestSerializer
1010
from core.authz import IsSuperUser
1111
from core.const.tag import OpenAPITag
12-
from core.util.totp import TOTPInfo
1312
from core.viewset.json_schema_viewset import JsonSchemaViewSet
14-
from django.conf import settings
1513
from django.core.files import File
1614
from django.db import models, transaction
1715
from django.http.response import StreamingHttpResponse
@@ -70,20 +68,16 @@ class OrderAdminViewSet(JsonSchemaViewSet, viewsets.ReadOnlyModelViewSet):
7068
@extend_schema(
7169
summary="주문 전체 환불 (환불 승인자 TOTP 필수)",
7270
tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND],
73-
parameters=[OpenApiParameter(name="otp", location=OpenApiParameter.QUERY, required=True)],
71+
parameters=[OpenApiParameter(name="totp", location=OpenApiParameter.QUERY, required=True)],
7472
responses={status.HTTP_204_NO_CONTENT: None},
7573
)
7674
@action(detail=True, methods=["post"], url_path="refund")
7775
@transaction.atomic
7876
def refund(self, request: request.Request, pk: typing.Any = None) -> response.Response:
79-
if not (otp := request.query_params.get("otp")):
80-
raise exceptions.NotAuthenticated("환불 승인자의 OTP 코드가 필요합니다.")
81-
if not TOTPInfo(key=settings.SHOP.refund_authorizer_secret_key.encode()).check(otp):
82-
raise exceptions.PermissionDenied("OTP 코드가 올바르지 않습니다.")
83-
8477
serializer = OrderTotalRefundSerializer(
8578
instance=self.get_object(),
86-
data={"check_refundable_date": False},
79+
data={"totp": request.query_params.get("totp")},
80+
context={"check_refundable_date": False},
8781
)
8882
serializer.is_valid(raise_exception=True)
8983
serializer.refund()

app/core/authn/api_key.py

Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import TYPE_CHECKING
44

55
from django.conf import settings
6+
from django.contrib.auth.hashers import make_password
67
from drf_spectacular.extensions import OpenApiAuthenticationExtension
78
from drf_spectacular.openapi import AutoSchema
89
from rest_framework.authentication import BaseAuthentication
@@ -20,18 +21,10 @@ def _get_or_create_api_key_user(api_key: str) -> "UserExt":
2021
username = f"API_KEY_USER_{api_key.upper()}"
2122
email = f"api_key_user_{api_key.lower()}@pycon.kr"
2223

23-
if api_key_user := UserExt.objects.filter(username=username).first():
24-
return api_key_user
25-
26-
api_key_user = UserExt.objects.create_user(
24+
return UserExt.objects.get_or_create(
2725
username=username,
28-
email=email,
29-
password=None,
30-
)
31-
api_key_user.set_unusable_password()
32-
api_key_user.save()
33-
34-
return api_key_user
26+
defaults={"email": email, "password": make_password(None)},
27+
)[0]
3528

3629
def authenticate(self, request: Request) -> tuple["UserExt", None] | None:
3730
api_key = request.headers.get("x-api-key", "")

app/core/const/shop_error_messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class SignInErrorMessages:
99
class PermissionErrorMessages:
1010
INVALID_API_KEY = "API Key가 올바르지 않습니다."
1111
INVALID_OTP_CODE = "OTP 코드가 올바르지 않습니다."
12+
OTP_REQUIRED = "환불 승인자의 OTP 코드가 필요합니다."
1213

1314

1415
class DonationNotOrderableErrorMessages:
@@ -96,3 +97,4 @@ class PortOneWebhookFailureMessages:
9697
UNEXPECTED_RETRIEVED_ORDER_STATUS = "예상한 결제 상태가 아닙니다."
9798
UNEXPECTED_RETRIEVED_ORDER_ID = "결제 ID가 일치하지 않습니다."
9899
UNEXPECTED_PAID_PRICE = "결제 금액이 일치하지 않습니다."
100+
UNSUPPORTED_CURRENCY = "지원하지 않는 통화입니다."

app/core/util/dateutil.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
from typing import Any
33

44

5+
def now_aware() -> datetime:
6+
"""현재 시각을 로컬 타임존이 반영된 timezone-aware datetime 으로 반환."""
7+
return datetime.now().astimezone()
8+
9+
510
def any_to_datetime(value: Any, tzinfo: timezone | None = None, raise_exception: bool = True) -> datetime | None:
611
"""Convert a string or datetime to a datetime object."""
712
if not value:

app/internal_api/views.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
from core.authn.api_key import APIKeyAuthentication
44
from core.authz.api_key import RegistrationDeskAPIKeyPermission
5-
from core.const.shop_error_messages import PermissionErrorMessages
65
from core.const.tag import OpenAPITag
76
from django.db import models, transaction
87
from django.utils.decorators import method_decorator
@@ -14,7 +13,7 @@
1413
)
1514
from internal_api.filters import DeskSupportFilterSet
1615
from internal_api.serializers import DeskSupportSerializer
17-
from rest_framework import exceptions, mixins, request, response, status, viewsets
16+
from rest_framework import mixins, request, response, status, viewsets
1817
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
1918
from shop.payment_history.models import PaymentHistory
2019
from shop.serializers.refund import OrderTotalRefundSerializer
@@ -111,12 +110,10 @@ def destroy(
111110
Order의 사용 및 환불하지 않은 상품을 refunded 상태로 변경하고, 결제 취소를 요청합니다.
112111
일반 전체 환불 API와의 차이점은, 환불 시간에 대한 제약이 없고, 환불 승인자의 OTP 코드가 필요하다는 점입니다.
113112
"""
114-
if not (otp := request.GET.get("otp")):
115-
raise exceptions.NotAuthenticated(PermissionErrorMessages.INVALID_OTP_CODE)
116-
117113
serializer = OrderTotalRefundSerializer(
118114
instance=self.get_object(),
119-
data={"totp": otp, "check_refundable_date": False},
115+
data={"totp": request.GET.get("otp")},
116+
context={"check_refundable_date": False},
120117
)
121118
serializer.is_valid(raise_exception=True)
122119
serializer.refund()

app/shop/order/models.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from core.const.shop_error_messages import NotRefundableErrorMessages
88
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
99
from core.scancode_mixin import ScanCodeMixin
10+
from core.util.dateutil import now_aware
1011
from django.contrib.auth import get_user_model
1112
from django.db import models
1213
from django.db.models.manager import BaseManager
@@ -183,7 +184,7 @@ def not_fully_refundable_reason(self) -> str | None:
183184
if self.current_paid_price != expected_refund_price:
184185
return NotRefundableErrorMessages.ORDER_REFUND_TARGET_PRICE_IS_MISMATCH
185186

186-
now = datetime.datetime.now().astimezone()
187+
now = now_aware()
187188
if any(typing.cast(Product, rel.product).refundable_ends_at < now for rel in refund_target_product_relations):
188189
return NotRefundableErrorMessages.ONE_OF_PRODUCT_REFUND_TIME_EXPIRED
189190

@@ -238,7 +239,7 @@ def not_refundable_reason(self) -> str | None:
238239
if self.status != OrderProductRelation.OrderProductStatus.paid:
239240
return NotRefundableErrorMessages.PRODUCT_STATUS_IS_NOT_PAID
240241

241-
if typing.cast(Product, self.product).refundable_ends_at < datetime.datetime.now().astimezone():
242+
if typing.cast(Product, self.product).refundable_ends_at < now_aware():
242243
return NotRefundableErrorMessages.PRODUCT_REFUND_TIME_EXPIRED
243244

244245
if (self.price + self.donation_price) == 0:

app/shop/order/serializers/validator.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
from __future__ import annotations
22

3-
import datetime
43
import re
54
import typing
65

76
from core.const.shop_error_messages import OptionGroupNotModifiableErrorMessages
7+
from core.util.dateutil import now_aware
88
from rest_framework import serializers
99
from shop.order.models import OrderProductOptionRelation, OrderProductRelation
1010
from shop.product.models import OptionGroup
@@ -36,7 +36,7 @@ def validate(self, data: dict[str, str]) -> dict[str, str]:
3636
if not product_option_group.response_modifiable_ends_at:
3737
raise serializers.ValidationError(OptionGroupNotModifiableErrorMessages.RESPONSE_NOT_MODIFIABLE)
3838

39-
if product_option_group.response_modifiable_ends_at < datetime.datetime.now().astimezone():
39+
if product_option_group.response_modifiable_ends_at < now_aware():
4040
raise serializers.ValidationError(OptionGroupNotModifiableErrorMessages.RESPONSE_MODIFIABLE_ENDS_AT)
4141

4242
if product_option_group.custom_response_pattern and not re.match(

app/shop/order/views/carts.py

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,8 @@
22

33
from core.const.tag import OpenAPITag
44
from django.db.models import Exists, OuterRef, QuerySet
5-
from django.utils.decorators import method_decorator
65
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
7-
from drf_spectacular.utils import extend_schema
6+
from drf_spectacular.utils import extend_schema, extend_schema_view
87
from drf_standardized_errors.openapi_serializers import ErrorResponse403Serializer, ValidationErrorResponseSerializer
98
from rest_framework import mixins, request, response, status, viewsets
109
from shop.order.models import Order, OrderProductRelation
@@ -14,9 +13,8 @@
1413
from user.models import UserExt
1514

1615

17-
@method_decorator(
18-
name="list",
19-
decorator=extend_schema(
16+
@extend_schema_view(
17+
list=extend_schema(
2018
summary="장바구니 정보 조회",
2119
tags=[OpenAPITag.SHOP_CART],
2220
responses={
@@ -43,9 +41,8 @@ def list(
4341
return response.Response(data={})
4442

4543

46-
@method_decorator(
47-
name="create",
48-
decorator=extend_schema(
44+
@extend_schema_view(
45+
create=extend_schema(
4946
summary="장바구니에 상품 추가",
5047
tags=[OpenAPITag.SHOP_CART],
5148
responses={
@@ -54,10 +51,7 @@ def list(
5451
status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer,
5552
},
5653
),
57-
)
58-
@method_decorator(
59-
name="destroy",
60-
decorator=extend_schema(
54+
destroy=extend_schema(
6155
summary="장바구니에서 상품 제거",
6256
tags=[OpenAPITag.SHOP_CART],
6357
parameters=[

app/shop/order/views/orders.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,8 @@
77
from core.openapi.schemas import build_html_responses
88
from django.conf import settings
99
from django.db import models, transaction
10-
from django.utils.decorators import method_decorator
1110
from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes
12-
from drf_spectacular.utils import extend_schema
11+
from drf_spectacular.utils import extend_schema, extend_schema_view
1312
from drf_standardized_errors.openapi_serializers import ErrorResponse403Serializer, ValidationErrorResponseSerializer
1413
from rest_framework import mixins, renderers, request, response, serializers, status, viewsets
1514
from rest_framework.decorators import action
@@ -28,20 +27,16 @@
2827
from user.models import UserExt
2928

3029

31-
@method_decorator(
32-
name="list",
33-
decorator=extend_schema(
30+
@extend_schema_view(
31+
list=extend_schema(
3432
summary="주문 이력 목록 조회",
3533
tags=[OpenAPITag.SHOP_ORDER],
3634
responses={
3735
status.HTTP_200_OK: OrderDto(many=True),
3836
status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer,
3937
},
4038
),
41-
)
42-
@method_decorator(
43-
name="retrieve",
44-
decorator=extend_schema(
39+
retrieve=extend_schema(
4540
summary="주문 이력 상세 조회",
4641
tags=[OpenAPITag.SHOP_ORDER],
4742
parameters=[
@@ -145,6 +140,7 @@ def create_single_product_order(
145140
status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer,
146141
},
147142
)
143+
@transaction.atomic
148144
def create(
149145
self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any]
150146
) -> response.Response:
@@ -191,7 +187,12 @@ def create(
191187
cart.name_en += f" and {len(cart_product_rels) - 1} more"
192188
cart.save()
193189

194-
portone_client.register_or_update_prepared_payment(merchant_id=str(cart.id), price=cart.first_paid_price)
190+
# idempotent + forward-only — DB commit 후 비동기 호출. 실패 시 클라이언트 retry 가 자연 보상.
191+
transaction.on_commit(
192+
lambda: portone_client.register_or_update_prepared_payment(
193+
merchant_id=str(cart.id), price=cart.first_paid_price
194+
)
195+
)
195196

196197
return response.Response(data=OrderDto(instance=cart).data, status=status.HTTP_201_CREATED)
197198

@@ -217,7 +218,7 @@ def destroy(
217218
self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any]
218219
) -> response.Response:
219220
"""Order의 사용 및 환불하지 않은 상품을 refunded 상태로 변경하고, 결제 취소를 요청합니다."""
220-
serializer = OrderTotalRefundSerializer(instance=self.get_object(), data={"check_refundable_date": True})
221+
serializer = OrderTotalRefundSerializer(instance=self.get_object(), data={}, context={"check_totp": False})
221222
serializer.is_valid(raise_exception=True)
222223
serializer.refund()
223224
return response.Response(status=status.HTTP_204_NO_CONTENT)
@@ -338,7 +339,7 @@ def destroy(
338339
self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any]
339340
) -> response.Response:
340341
"""부분 환불을 진행합니다."""
341-
serializer = OrderProductRefundSerializer(instance=self.get_object(), data={"check_refundable_date": True})
342+
serializer = OrderProductRefundSerializer(instance=self.get_object(), data={}, context={"check_totp": False})
342343
serializer.is_valid(raise_exception=True)
343344
serializer.refund()
344345
return response.Response(status=status.HTTP_204_NO_CONTENT)

0 commit comments

Comments
 (0)