Skip to content

Commit ff3fb8c

Browse files
authored
Merge pull request #43 from pythonkr/feature/migrate-purchase-repo
feat: Payment repo를 backend repo에 병합
2 parents d4782a1 + fdf0290 commit ff3fb8c

116 files changed

Lines changed: 8475 additions & 26 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

app/admin_api/filtersets/shop/__init__.py

Whitespace-only changes.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from core.filter.multi_field import MultiFieldOrCharInFilter
2+
from django_filters import rest_framework as filters
3+
from shop.order.models import Order
4+
5+
6+
class OrderAdminFilterSet(filters.FilterSet):
7+
"""admin 운영자 검색. CSV (콤마 구분) 다중 값 지원: `?name=철수,영희&status=completed,refunded`"""
8+
9+
id = filters.BaseInFilter(field_name="id")
10+
user_id = filters.BaseInFilter(field_name="user_id")
11+
user_unique_id = filters.BaseInFilter(field_name="user__unique_id")
12+
name = MultiFieldOrCharInFilter(
13+
field_names=["user__nickname_ko", "user__nickname_en", "user__username", "customer_info__name"],
14+
lookup_expr="icontains",
15+
)
16+
email = MultiFieldOrCharInFilter(field_names=["user__email", "customer_info__email"], lookup_expr="icontains")
17+
imp_id = MultiFieldOrCharInFilter(field_names=["latest_imp_id"], lookup_expr="icontains")
18+
status = filters.BaseCSVFilter(field_name="current_status", lookup_expr="in")
19+
20+
created_at_after = filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
21+
created_at_before = filters.DateTimeFilter(field_name="created_at", lookup_expr="lte")
22+
23+
first_paid_at_after = filters.DateTimeFilter(field_name="first_paid_at", lookup_expr="gte")
24+
first_paid_at_before = filters.DateTimeFilter(field_name="first_paid_at", lookup_expr="lte")
25+
26+
status_changed_at_after = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="gte")
27+
status_changed_at_before = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="lte")
28+
29+
product_id = filters.BaseInFilter(field_name="products__product_id", distinct=True)
30+
category_id = filters.BaseInFilter(field_name="products__product__category_id", distinct=True)
31+
category_group_id = filters.BaseInFilter(field_name="products__product__category__group_id", distinct=True)
32+
33+
price_min = filters.NumberFilter(field_name="latest_price", lookup_expr="gte")
34+
price_max = filters.NumberFilter(field_name="latest_price", lookup_expr="lte")
35+
36+
class Meta:
37+
model = Order
38+
fields = [
39+
"id",
40+
"user_id",
41+
"user_unique_id",
42+
"name",
43+
"email",
44+
"imp_id",
45+
"status",
46+
"created_at_after",
47+
"created_at_before",
48+
"first_paid_at_after",
49+
"first_paid_at_before",
50+
"status_changed_at_after",
51+
"status_changed_at_before",
52+
"product_id",
53+
"category_id",
54+
"category_group_id",
55+
"price_min",
56+
"price_max",
57+
]
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
from core.filter.multi_field import MultiFieldOrCharInFilter
2+
from core.util.dateutil import now_aware
3+
from django.db.models import Q
4+
from django_filters import rest_framework as filters
5+
from shop.product.models import Product
6+
7+
8+
class ProductAdminFilterSet(filters.FilterSet):
9+
id = filters.BaseInFilter(field_name="id")
10+
name = MultiFieldOrCharInFilter(field_names=["name_ko", "name_en"], lookup_expr="icontains")
11+
category = filters.BaseInFilter(field_name="category_id")
12+
category_group = filters.BaseInFilter(field_name="category__group_id")
13+
hidden = filters.BooleanFilter(field_name="hidden")
14+
tag = filters.BaseInFilter(field_name="tag_set", distinct=True)
15+
16+
price_min = filters.NumberFilter(field_name="price", lookup_expr="gte")
17+
price_max = filters.NumberFilter(field_name="price", lookup_expr="lte")
18+
19+
status = filters.BaseCSVFilter(method="filter_by_status")
20+
21+
def filter_by_status(self, queryset, name, values):
22+
if not values:
23+
return queryset
24+
25+
now = now_aware()
26+
q = Q()
27+
for value in values:
28+
if value == Product.CurrentStatus.HIDDEN:
29+
q |= Q(hidden=True)
30+
elif value == Product.CurrentStatus.OUT_OF_VISIBLE_PERIOD:
31+
q |= Q(hidden=False) & (Q(visible_starts_at__gt=now) | Q(visible_ends_at__lt=now))
32+
elif value == Product.CurrentStatus.OUT_OF_ORDERABLE_PERIOD:
33+
q |= (
34+
Q(hidden=False)
35+
& Q(visible_starts_at__lte=now)
36+
& Q(visible_ends_at__gte=now)
37+
& (Q(orderable_starts_at__gt=now) | Q(orderable_ends_at__lt=now))
38+
)
39+
elif value == Product.CurrentStatus.ACTIVE:
40+
q |= (
41+
Q(hidden=False)
42+
& Q(visible_starts_at__lte=now)
43+
& Q(visible_ends_at__gte=now)
44+
& Q(orderable_starts_at__lte=now)
45+
& Q(orderable_ends_at__gte=now)
46+
)
47+
return queryset.filter(q).distinct()
48+
49+
class Meta:
50+
model = Product
51+
fields = [
52+
"id",
53+
"name",
54+
"category",
55+
"category_group",
56+
"hidden",
57+
"tag",
58+
"price_min",
59+
"price_max",
60+
"status",
61+
]

app/admin_api/serializers/notification.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from core.const.serializer import COMMON_ADMIN_FIELDS
44
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
55
from core.serializer.json_schema_serializer import JsonSchemaSerializer
6+
from notification.channels import NotificationChannel
67
from notification.models import (
78
EmailNotificationHistory,
89
EmailNotificationHistorySentTo,
@@ -185,3 +186,12 @@ class NotificationHistoryRetryRequestAdminSerializer(serializers.Serializer):
185186
required=False,
186187
default=[NotificationStatus.FAILED],
187188
)
189+
190+
191+
# ---- Channel → response serializer 매핑 -------------------------------------
192+
193+
HISTORY_ADMIN_SERIALIZER_BY_CHANNEL: dict[NotificationChannel, type[_NotiHistoryAdminSerializerBase]] = {
194+
NotificationChannel.EMAIL: EmailNotificationHistoryAdminSerializer,
195+
NotificationChannel.NHN_CLOUD_SMS: NHNCloudSMSNotificationHistoryAdminSerializer,
196+
NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer,
197+
}

app/admin_api/serializers/shop/__init__.py

Whitespace-only changes.
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
from typing import Any
2+
from urllib.parse import urljoin
3+
4+
from admin_api.serializers.notification import HISTORY_ADMIN_SERIALIZER_BY_CHANNEL
5+
from core.const.serializer import COMMON_ADMIN_FIELDS
6+
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
7+
from core.serializer.json_schema_serializer import JsonSchemaSerializer
8+
from core.serializer.skip_none_list_serializer import SkipNoneListSerializer
9+
from django.conf import settings
10+
from notification.channels import NotificationChannel
11+
from notification.models.base import Recipient
12+
from rest_framework import serializers
13+
from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation
14+
from shop.payment_history.models import PaymentHistory
15+
from shop.product.models import Product
16+
from user.models import UserExt
17+
18+
CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL = {
19+
NotificationChannel.EMAIL: "email",
20+
NotificationChannel.NHN_CLOUD_SMS: "phone",
21+
NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: "phone",
22+
}
23+
24+
25+
class OrderAdminSerializer(
26+
BaseAbstractSerializer,
27+
JsonSchemaSerializer,
28+
serializers.ModelSerializer,
29+
):
30+
class SimpleUserSerializer(serializers.ModelSerializer):
31+
class Meta:
32+
model = UserExt
33+
read_only_fields = fields = ("id", "username", "email", "unique_id")
34+
35+
class SimpleCustomerInfoSerializer(serializers.ModelSerializer):
36+
class Meta:
37+
model = CustomerInfo
38+
fields = ("name", "phone", "email", "organization")
39+
40+
class SimplePaymentHistorySerializer(serializers.ModelSerializer):
41+
class Meta:
42+
model = PaymentHistory
43+
read_only_fields = fields = ("id", "imp_id", "status", "price", "created_at")
44+
45+
class SimpleOrderProductRelationSerializer(serializers.ModelSerializer):
46+
class SimpleProductSerializer(serializers.ModelSerializer):
47+
class Meta:
48+
model = Product
49+
read_only_fields = fields = ("id", "name_ko", "name_en", "price")
50+
51+
class SimpleOrderProductOptionRelationSerializer(serializers.ModelSerializer):
52+
option_group_name_ko = serializers.CharField(source="product_option_group.name_ko", read_only=True)
53+
option_group_name_en = serializers.CharField(source="product_option_group.name_en", read_only=True)
54+
option_name_ko = serializers.CharField(source="product_option.name_ko", read_only=True, allow_null=True)
55+
option_name_en = serializers.CharField(source="product_option.name_en", read_only=True, allow_null=True)
56+
57+
class Meta:
58+
model = OrderProductOptionRelation
59+
read_only_fields = fields = (
60+
"id",
61+
"option_group_name_ko",
62+
"option_group_name_en",
63+
"option_name_ko",
64+
"option_name_en",
65+
"custom_response",
66+
)
67+
68+
product = SimpleProductSerializer(read_only=True)
69+
options = SimpleOrderProductOptionRelationSerializer(many=True, read_only=True)
70+
71+
class Meta:
72+
model = OrderProductRelation
73+
fields = ("id", "product", "status", "price", "donation_price", "options")
74+
read_only_fields = ("id", "product", "price", "donation_price", "options")
75+
76+
user = SimpleUserSerializer(read_only=True)
77+
customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True)
78+
products = SimpleOrderProductRelationSerializer(many=True, read_only=True)
79+
payment_histories = SimplePaymentHistorySerializer(many=True, read_only=True)
80+
first_paid_price = serializers.IntegerField(read_only=True)
81+
current_paid_price = serializers.IntegerField(read_only=True)
82+
current_status = serializers.CharField(read_only=True)
83+
first_paid_at = serializers.DateTimeField(read_only=True)
84+
latest_imp_id = serializers.CharField(read_only=True)
85+
86+
class Meta:
87+
model = Order
88+
fields = COMMON_ADMIN_FIELDS + (
89+
"name_ko",
90+
"name_en",
91+
"user",
92+
"customer_info",
93+
"products",
94+
"payment_histories",
95+
"first_paid_price",
96+
"current_paid_price",
97+
"current_status",
98+
"first_paid_at",
99+
"latest_imp_id",
100+
)
101+
read_only_fields = ("name_ko", "name_en")
102+
103+
def update(self, instance: Order, validated_data: dict) -> Order:
104+
customer_info_data = validated_data.pop("customer_info", None)
105+
order = super().update(instance, validated_data)
106+
107+
if customer_info_data is not None:
108+
if order.customer_info:
109+
for field, value in customer_info_data.items():
110+
setattr(order.customer_info, field, value)
111+
order.customer_info.save()
112+
else:
113+
CustomerInfo.objects.create(order=order, **customer_info_data)
114+
115+
return order
116+
117+
118+
class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer):
119+
product_ids = serializers.ListField(child=serializers.UUIDField(), required=True, min_length=1)
120+
include_refunded = serializers.BooleanField(default=False)
121+
122+
123+
class _OrderRecipientItemSerializer(serializers.Serializer):
124+
"""Order → Recipient ({recipient, context}) 변환.
125+
126+
customer_info / 첫 상품 / recipient 부재 시 None 반환. None-skip 의미를 가지므로
127+
반드시 `SkipNoneListSerializer` (Meta.list_serializer_class) 와 함께 `many=True` 로 사용 — 단독 사용 시 호출자가 None 처리 책임.
128+
"""
129+
130+
recipient = serializers.CharField()
131+
context = serializers.JSONField()
132+
133+
class Meta:
134+
list_serializer_class = SkipNoneListSerializer
135+
136+
def to_representation(self, order: Order) -> Recipient | None:
137+
channel: NotificationChannel = self.context["channel"]
138+
139+
if not (customer_info := getattr(order, "customer_info", None)):
140+
return None
141+
if not (recipient := getattr(customer_info, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")):
142+
return None
143+
if not (order_product_rel := next(iter(order.products.all()), None)):
144+
return None
145+
146+
ctx: dict[str, Any] = {
147+
o_rel.product_option_group.name: (
148+
o_rel.custom_response
149+
if o_rel.product_option_group.is_custom_response
150+
else (o_rel.product_option.name if o_rel.product_option else "")
151+
)
152+
for o_rel in order_product_rel.options.all()
153+
}
154+
ctx["scancode_url"] = urljoin(settings.BACKEND_DOMAIN, order.scancode_path)
155+
156+
return {"recipient": recipient, "context": ctx | self.context["context_override"]}
157+
158+
159+
class OrderSendNotificationPreviewResponseSerializer(JsonSchemaSerializer, serializers.Serializer):
160+
class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer):
161+
recipient = serializers.CharField()
162+
context = serializers.JSONField()
163+
missing_variables = serializers.ListField(child=serializers.CharField())
164+
165+
template_variables = serializers.ListField(child=serializers.CharField())
166+
recipients = RecipientItemSerializer(many=True)
167+
168+
169+
class OrderSendNotificationSerializer(JsonSchemaSerializer, serializers.Serializer):
170+
channel = serializers.ChoiceField(choices=NotificationChannel.choices)
171+
template_id = serializers.UUIDField()
172+
context_override = serializers.JSONField(required=False, default=dict)
173+
174+
def validate_channel(self, value: str) -> NotificationChannel:
175+
return NotificationChannel(value)
176+
177+
def validate(self, attrs: dict) -> dict:
178+
if not (t := attrs["channel"].template_class.objects.filter_active().filter(pk=attrs["template_id"]).first()):
179+
raise serializers.ValidationError({"template_id": "Template not found."})
180+
# validated_data 에 template_id (UUID) 와 template (instance) 가 공존.
181+
# downstream 은 template 만 사용; template_id 는 input round-trip 용으로 남김.
182+
return {**attrs, "template": t}
183+
184+
def _build_recipient_items(self) -> list[Recipient]:
185+
return _OrderRecipientItemSerializer(instance=self.instance, many=True, context=self.validated_data).data
186+
187+
def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializer:
188+
template_vars = self.validated_data["template"].template_variables
189+
return OrderSendNotificationPreviewResponseSerializer(
190+
instance={
191+
"template_variables": sorted(template_vars),
192+
"recipients": [
193+
{**i, "missing_variables": sorted(template_vars - i["context"].keys())}
194+
for i in self._build_recipient_items()
195+
],
196+
},
197+
)
198+
199+
def build_send_response(self) -> serializers.Serializer:
200+
if not (items := self._build_recipient_items()):
201+
raise serializers.ValidationError(
202+
"발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)."
203+
)
204+
channel: NotificationChannel = self.validated_data["channel"]
205+
template = self.validated_data["template"]
206+
if invalid := [
207+
{**i, "missing_variables": missing}
208+
for i in items
209+
if (missing := sorted(template.template_variables - i["context"].keys()))
210+
]:
211+
raise serializers.ValidationError({"missing_context_variables": invalid})
212+
213+
# create_for_recipients (DB write) + history.send() (Celery dispatch on commit).
214+
history = channel.history_class.objects.create_for_recipients(template=template, recipients=items)
215+
history.send()
216+
history.refresh_from_db()
217+
return HISTORY_ADMIN_SERIALIZER_BY_CHANNEL[channel](instance=history)

0 commit comments

Comments
 (0)