Skip to content

Commit e07ae90

Browse files
committed
feat: QR 코드 조회용 라우트 추가 및 코드 정리
1 parent 6524129 commit e07ae90

19 files changed

Lines changed: 414 additions & 664 deletions

File tree

app/admin_api/views/shop/order_notifications.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@
88
OrderSendNotificationPreviewResponseSerializer,
99
OrderSendNotificationSerializer,
1010
)
11-
from admin_api.views.shop.orders import REFUNDABLE_STATUSES
1211
from core.authz import IsSuperUser
1312
from core.const.tag import OpenAPITag
1413
from core.viewset.json_schema_viewset import JsonSchemaViewSet
@@ -17,7 +16,7 @@
1716
from rest_framework import request, response, status, viewsets
1817
from rest_framework.decorators import action
1918
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
20-
from shop.payment_history.models import PaymentHistory
19+
from shop.payment_history.models import REFUNDABLE_STATUSES, PaymentHistory
2120

2221
ACTION_METHODS = ["preview", "send"]
2322

app/admin_api/views/shop/orders.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,12 @@
2020
from rest_framework.decorators import action
2121
from shop.order import exports, imports
2222
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
23-
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus
23+
from shop.payment_history.models import PURCHASED_STATUSES, REFUNDABLE_STATUSES, PaymentHistory
2424
from shop.product.models import Product
2525
from shop.serializers.refund import OrderTotalRefundSerializer
2626

2727
logger = getLogger(__name__)
2828

29-
REFUNDABLE_STATUSES = {PaymentHistoryStatus.completed, PaymentHistoryStatus.partial_refunded}
3029
ADMIN_METHODS = ["list", "retrieve"]
3130

3231

@@ -164,7 +163,7 @@ def export(self, request: request.Request) -> StreamingHttpResponse:
164163
product_ids = req.validated_data["product_ids"]
165164
include_refunded = req.validated_data["include_refunded"]
166165

167-
statuses = REFUNDABLE_STATUSES | {PaymentHistoryStatus.refunded} if include_refunded else REFUNDABLE_STATUSES
166+
statuses = PURCHASED_STATUSES if include_refunded else REFUNDABLE_STATUSES
168167

169168
order_qs = (
170169
Order.objects.annotate(current_status=PaymentHistory.objects.latest_per_order_field("status"))

app/core/scancode_mixin.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from __future__ import annotations
2+
3+
from base64 import urlsafe_b64encode
4+
from contextlib import suppress
5+
from functools import cached_property
6+
from hashlib import sha256
7+
from hmac import new as hmac_new
8+
from typing import ClassVar, Self
9+
from uuid import UUID
10+
11+
from django.conf import settings
12+
from rest_framework.reverse import reverse
13+
from shortuuid import decode, encode
14+
15+
16+
class ScanCodeMixin:
17+
scancode_prefix: ClassVar[str]
18+
scancode_uuid_field: ClassVar[str] = "id"
19+
20+
@property
21+
def _scancode_uuid(self) -> UUID:
22+
return getattr(self, self.scancode_uuid_field)
23+
24+
@cached_property
25+
def short_id(self) -> str:
26+
return encode(self._scancode_uuid)
27+
28+
@cached_property
29+
def salt(self) -> str:
30+
hmac_result = hmac_new(settings.SHOP.order_scancode_salt.encode(), self._scancode_uuid.bytes, sha256).digest()
31+
return urlsafe_b64encode(hmac_result).decode("utf-8").rstrip("=")
32+
33+
@cached_property
34+
def scancode_token(self) -> str:
35+
return f"{self.scancode_prefix}:{self.short_id}:{self.salt}"
36+
37+
@cached_property
38+
def scancode_path(self) -> str:
39+
return f"{reverse('v1:scancode-list')}?token={self.scancode_token}"
40+
41+
@classmethod
42+
def from_short_id(cls, short_id: str) -> Self | None:
43+
with suppress(ValueError):
44+
return cls.objects.filter(**{cls.scancode_uuid_field: decode(short_id)}).first()
45+
return None
46+
47+
@classmethod
48+
def from_scancode_token(cls, scancode_token: str) -> Self | None:
49+
parts = scancode_token.split(":")
50+
if len(parts) != 3:
51+
return None
52+
prefix, short_id, salt = parts
53+
if prefix != cls.scancode_prefix or not (short_id and salt):
54+
return None
55+
if (instance := cls.from_short_id(short_id)) and instance.salt == salt:
56+
return instance
57+
return None

app/internal_api/views.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,10 +58,12 @@ class DeskSupportViewSet(
5858
models.Prefetch(
5959
lookup="products",
6060
queryset=(
61-
OrderProductRelation.objects.select_related("product").prefetch_related(
61+
OrderProductRelation.objects.filter_active()
62+
.select_related("product")
63+
.prefetch_related(
6264
models.Prefetch(
6365
lookup="options",
64-
queryset=OrderProductOptionRelation.objects.select_related(
66+
queryset=OrderProductOptionRelation.objects.filter_active().select_related(
6567
"product_option_group", "product_option"
6668
),
6769
)
@@ -70,7 +72,7 @@ class DeskSupportViewSet(
7072
),
7173
models.Prefetch(
7274
"payment_histories",
73-
queryset=PaymentHistory.objects.order_by("-created_at"),
75+
queryset=PaymentHistory.objects.filter_active().order_by("-created_at"),
7476
to_attr="_payment_histories_by_latest",
7577
),
7678
)

app/shop/order/models.py

Lines changed: 49 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,16 @@
11
from __future__ import annotations
22

3-
import base64
4-
import contextlib
53
import datetime
64
import functools
7-
import hashlib
8-
import hmac
95
import typing
106

117
from core.const.shop_error_messages import NotRefundableErrorMessages
128
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
13-
from core.util.strutil import uuid_to_b64
14-
from django.conf import settings
9+
from core.scancode_mixin import ScanCodeMixin
1510
from django.contrib.auth import get_user_model
1611
from django.db import models
1712
from django.db.models.manager import BaseManager
18-
from rest_framework.reverse import reverse
19-
from shop.payment_history.models import PaymentHistory
20-
from shortuuid import decode, encode
13+
from shop.payment_history.models import PURCHASED_STATUSES, PaymentHistory
2114
from simple_history.models import HistoricalRecords
2215

2316
UserModel = get_user_model()
@@ -30,8 +23,50 @@ def filter_has_payment_histories(self) -> models.QuerySet[Order]:
3023
def filter_has_no_payment_histories(self) -> models.QuerySet[Order]:
3124
return self.filter_active().filter(~models.Exists(PaymentHistory.objects.filter(order=models.OuterRef("id"))))
3225

26+
def filter_purchased_by(self, user: UserModel) -> models.QuerySet[Order]:
27+
"""결제 완료/부분환불/환불된 (terminal status) 주문을 user 별로 필터."""
28+
return (
29+
self.filter_active()
30+
.select_related("customer_info")
31+
.prefetch_related(
32+
models.Prefetch(
33+
lookup="products",
34+
queryset=OrderProductRelation.objects.filter_active()
35+
.select_related("product")
36+
.prefetch_related(
37+
models.Prefetch(
38+
lookup="options",
39+
queryset=OrderProductOptionRelation.objects.filter_active().select_related(
40+
"product_option_group",
41+
"product_option",
42+
),
43+
),
44+
),
45+
),
46+
models.Prefetch(
47+
"payment_histories",
48+
queryset=PaymentHistory.objects.filter_active().order_by("-created_at"),
49+
to_attr="_payment_histories_by_latest",
50+
),
51+
)
52+
.annotate(
53+
current_status=(
54+
PaymentHistory.objects.filter(order_id=models.OuterRef("id"), status__in=PURCHASED_STATUSES)
55+
.order_by("-created_at")
56+
.values_list("status", flat=True)[:1]
57+
),
58+
)
59+
.filter(user=user, current_status__in=PURCHASED_STATUSES)
60+
.order_by("-created_at")
61+
)
62+
63+
def filter_in_last_six_months(self) -> models.QuerySet[Order]:
64+
return self.filter(created_at__gte=datetime.date.today() - datetime.timedelta(days=183))
65+
66+
67+
class Order(ScanCodeMixin, BaseAbstractModel):
68+
scancode_prefix = "order"
3369

34-
class Order(BaseAbstractModel):
3570
user = models.ForeignKey(UserModel, on_delete=models.PROTECT)
3671
name = models.TextField()
3772

@@ -42,7 +77,7 @@ class Order(BaseAbstractModel):
4277
prefetchs = {
4378
"_payment_histories_by_latest": models.Prefetch(
4479
"payment_histories",
45-
queryset=PaymentHistory.objects.order_by("-created_at"),
80+
queryset=PaymentHistory.objects.filter_active().order_by("-created_at"),
4681
to_attr="_payment_histories_by_latest",
4782
),
4883
}
@@ -103,24 +138,6 @@ def is_cart(self) -> bool:
103138

104139
return self.current_status == PaymentHistoryStatus.pending
105140

106-
@property
107-
def short_id(self) -> str:
108-
"""
109-
주문 ID를 base64로 인코딩한 문자열을 반환. QR 코드 생성에 사용됩니다.
110-
(주문 ID의 길이를 줄여 QR 코드를 단순화하기 위함 - 길이가 길면 QR 코드가 복잡해짐)
111-
"""
112-
return uuid_to_b64(self.id)
113-
114-
@property
115-
def scancode_token(self) -> str:
116-
salted_order_id = f"{self.id}{settings.SHOP.order_scancode_salt}".encode()
117-
return hashlib.sha256(salted_order_id).hexdigest()
118-
119-
@property
120-
def scancode_path(self) -> str:
121-
base_path = reverse("v1:orders-retrieve-scancode", kwargs={"order_id": self.id})
122-
return f"{base_path}?token={self.scancode_token}"
123-
124141
@property
125142
def not_fully_refundable_reason(self) -> str | None:
126143
"""
@@ -173,7 +190,9 @@ def not_fully_refundable_reason(self) -> str | None:
173190
return None
174191

175192

176-
class OrderProductRelation(BaseAbstractModel):
193+
class OrderProductRelation(ScanCodeMixin, BaseAbstractModel):
194+
scancode_prefix = "opr"
195+
177196
class OrderProductStatus(models.TextChoices):
178197
pending = "pending", "결제 대기 중"
179198
paid = "paid", "결제 완료"
@@ -197,48 +216,6 @@ class OrderProductStatus(models.TextChoices):
197216
def __str__(self) -> str:
198217
return f"[{self.order}] {self.product} ({self.get_status_display()})"
199218

200-
@functools.cached_property
201-
def short_id(self) -> str:
202-
return encode(self.id)
203-
204-
@functools.cached_property
205-
def salt(self) -> str:
206-
hmac_result = hmac.new(settings.SHOP.order_scancode_salt.encode(), self.id.bytes, hashlib.sha256).digest()
207-
return base64.urlsafe_b64encode(hmac_result).decode("utf-8").rstrip("=")
208-
209-
@functools.cached_property
210-
def scancode_token(self) -> str:
211-
return f"opr:{self.short_id}:{self.salt}"
212-
213-
@functools.cached_property
214-
def scancode_path(self) -> str:
215-
return f"{reverse('v1:order-products-scancode-list')}?token={self.scancode_token}"
216-
217-
@classmethod
218-
def from_short_id(cls, short_id: str) -> OrderProductRelation | None:
219-
with contextlib.suppress(ValueError):
220-
return cls.objects.filter(id=decode(short_id)).first()
221-
222-
return None
223-
224-
@classmethod
225-
def from_scancode_token(cls, scancode_token: str) -> OrderProductRelation | None:
226-
splitted_token = scancode_token.split(":")
227-
if len(splitted_token) != 3:
228-
return None
229-
230-
prefix, short_id, salt = splitted_token
231-
if prefix != "opr":
232-
return None
233-
234-
if not (short_id and salt):
235-
return None
236-
237-
if (opr := cls.from_short_id(short_id)) and opr.salt == salt:
238-
return opr
239-
240-
return None
241-
242219
@property
243220
def not_refundable_reason(self) -> str | None:
244221
"""

app/shop/order/serializers/dto.py

Lines changed: 0 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from typing import cast
21
from urllib.parse import urljoin
32

43
from django.conf import settings
@@ -127,43 +126,3 @@ class Meta:
127126
"customer_info",
128127
)
129128
model = SingleProductCart
130-
131-
132-
class OrderProductScanCodeDto(serializers.ModelSerializer):
133-
class SimpleOrderDto(serializers.ModelSerializer):
134-
class Meta:
135-
fields = ("id", "first_paid_at")
136-
model = Order
137-
138-
class SimpleProductDto(serializers.ModelSerializer):
139-
class Meta:
140-
fields = ("id", "name")
141-
model = Product
142-
143-
class SimpleOptionDto(serializers.ModelSerializer):
144-
name = serializers.CharField(source="product_option_group.name")
145-
value = serializers.SerializerMethodField()
146-
147-
class Meta:
148-
model = OrderProductOptionRelation
149-
fields = ("name", "value")
150-
151-
def get_value(self, obj: OrderProductOptionRelation) -> str:
152-
if obj.product_option_group.is_custom_response:
153-
return obj.custom_response or "-"
154-
155-
if option := cast(Option, obj.product_option):
156-
result = option.name
157-
if option.additional_price > 0:
158-
result += f" (+{option.additional_price}원)"
159-
return result
160-
161-
return "-"
162-
163-
order = SimpleOrderDto()
164-
product = SimpleProductDto()
165-
options = SimpleOptionDto(many=True)
166-
167-
class Meta:
168-
fields = ("id", "order", "product", "options")
169-
model = OrderProductRelation

0 commit comments

Comments
 (0)