Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2ae14e3
feat: python-korea-payment repo에서 purchase_shared를 migrate
MU-Software May 9, 2026
d7b1f85
feat: python-korea-payment의 order/product/payment_history를 app/shop s…
MU-Software May 11, 2026
3b2bdb1
feat: UserExt에 unique_id/scancode 추가, allauth 통합, desk_support/patron 흡수
MU-Software May 11, 2026
3c0072d
feat: 어드민용 shop api 구현
MU-Software May 11, 2026
6524129
feat: 어드민용 주문 알림 발송 API (preview/send) 구현
MU-Software May 11, 2026
e07ae90
feat: QR 코드 조회용 라우트 추가 및 코드 정리
MU-Software May 11, 2026
d1bd0b4
fix: 전반적인 코드 개선 1차
MU-Software May 11, 2026
5a8e943
fix: 전반적인 코드 개선 2차
MU-Software May 11, 2026
47a6a38
fix: 전반적인 코드 개선 3차
MU-Software May 11, 2026
1bd07a5
fix: lint가 실패하는 문제 수정
MU-Software May 11, 2026
9ff4171
fix: 어드민 / 참가자 포털에서 로그인 실패를 수정
MU-Software May 12, 2026
071740b
feat: shop admin API 보완 (필터/검증/재고/상태/부분환불)
MU-Software May 12, 2026
dccfa91
fix: NestedModelSerializer source 매핑 및 mixed-list create-during-updat…
MU-Software May 12, 2026
36c174b
feat: Order 목록 조회 API에 pagination 추가
MU-Software May 12, 2026
9593cb1
feat: Order 목록 조회 API에 필터 추가
MU-Software May 12, 2026
094c09f
fix: QR 코드 안내 메시지의 케이스를 좀 더 자세하게 수정
MU-Software May 12, 2026
542b44e
docs: PortOneV1WebhookRequestSerializer의 주문 잠금 및 승격 로직에 대한 설명 추가
MU-Software May 12, 2026
20fa5e9
refactor: cart_validation을 검증 항목별로 분리
MU-Software May 12, 2026
e469957
refactor: OrderProductRelation 및 PaymentHistory의 prefetch 쿼리를 분리
MU-Software May 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file.
57 changes: 57 additions & 0 deletions app/admin_api/filtersets/shop/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from core.filter.multi_field import MultiFieldOrCharInFilter
from django_filters import rest_framework as filters
from shop.order.models import Order


class OrderAdminFilterSet(filters.FilterSet):
"""admin 운영자 검색. CSV (콤마 구분) 다중 값 지원: `?name=철수,영희&status=completed,refunded`"""

id = filters.BaseInFilter(field_name="id")
user_id = filters.BaseInFilter(field_name="user_id")
user_unique_id = filters.BaseInFilter(field_name="user__unique_id")
name = MultiFieldOrCharInFilter(
field_names=["user__nickname_ko", "user__nickname_en", "user__username", "customer_info__name"],
lookup_expr="icontains",
)
email = MultiFieldOrCharInFilter(field_names=["user__email", "customer_info__email"], lookup_expr="icontains")
imp_id = MultiFieldOrCharInFilter(field_names=["latest_imp_id"], lookup_expr="icontains")
status = filters.BaseCSVFilter(field_name="current_status", lookup_expr="in")

created_at_after = filters.DateTimeFilter(field_name="created_at", lookup_expr="gte")
created_at_before = filters.DateTimeFilter(field_name="created_at", lookup_expr="lte")

first_paid_at_after = filters.DateTimeFilter(field_name="first_paid_at", lookup_expr="gte")
first_paid_at_before = filters.DateTimeFilter(field_name="first_paid_at", lookup_expr="lte")

status_changed_at_after = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="gte")
status_changed_at_before = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="lte")

product_id = filters.BaseInFilter(field_name="products__product_id", distinct=True)
category_id = filters.BaseInFilter(field_name="products__product__category_id", distinct=True)
category_group_id = filters.BaseInFilter(field_name="products__product__category__group_id", distinct=True)

price_min = filters.NumberFilter(field_name="latest_price", lookup_expr="gte")
price_max = filters.NumberFilter(field_name="latest_price", lookup_expr="lte")

class Meta:
model = Order
fields = [
"id",
"user_id",
"user_unique_id",
"name",
"email",
"imp_id",
"status",
"created_at_after",
"created_at_before",
"first_paid_at_after",
"first_paid_at_before",
"status_changed_at_after",
"status_changed_at_before",
"product_id",
"category_id",
"category_group_id",
"price_min",
"price_max",
]
61 changes: 61 additions & 0 deletions app/admin_api/filtersets/shop/products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from core.filter.multi_field import MultiFieldOrCharInFilter
from core.util.dateutil import now_aware
from django.db.models import Q
from django_filters import rest_framework as filters
from shop.product.models import Product


class ProductAdminFilterSet(filters.FilterSet):
id = filters.BaseInFilter(field_name="id")
name = MultiFieldOrCharInFilter(field_names=["name_ko", "name_en"], lookup_expr="icontains")
category = filters.BaseInFilter(field_name="category_id")
category_group = filters.BaseInFilter(field_name="category__group_id")
hidden = filters.BooleanFilter(field_name="hidden")
tag = filters.BaseInFilter(field_name="tag_set", distinct=True)

price_min = filters.NumberFilter(field_name="price", lookup_expr="gte")
price_max = filters.NumberFilter(field_name="price", lookup_expr="lte")

status = filters.BaseCSVFilter(method="filter_by_status")

def filter_by_status(self, queryset, name, values):
if not values:
return queryset

now = now_aware()
q = Q()
for value in values:
if value == Product.CurrentStatus.HIDDEN:
q |= Q(hidden=True)
elif value == Product.CurrentStatus.OUT_OF_VISIBLE_PERIOD:
q |= Q(hidden=False) & (Q(visible_starts_at__gt=now) | Q(visible_ends_at__lt=now))
elif value == Product.CurrentStatus.OUT_OF_ORDERABLE_PERIOD:
q |= (
Q(hidden=False)
& Q(visible_starts_at__lte=now)
& Q(visible_ends_at__gte=now)
& (Q(orderable_starts_at__gt=now) | Q(orderable_ends_at__lt=now))
)
elif value == Product.CurrentStatus.ACTIVE:
q |= (
Q(hidden=False)
& Q(visible_starts_at__lte=now)
& Q(visible_ends_at__gte=now)
& Q(orderable_starts_at__lte=now)
& Q(orderable_ends_at__gte=now)
)
return queryset.filter(q).distinct()

class Meta:
model = Product
fields = [
"id",
"name",
"category",
"category_group",
"hidden",
"tag",
"price_min",
"price_max",
"status",
]
10 changes: 10 additions & 0 deletions app/admin_api/serializers/notification.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from core.const.serializer import COMMON_ADMIN_FIELDS
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
from core.serializer.json_schema_serializer import JsonSchemaSerializer
from notification.channels import NotificationChannel
from notification.models import (
EmailNotificationHistory,
EmailNotificationHistorySentTo,
Expand Down Expand Up @@ -185,3 +186,12 @@ class NotificationHistoryRetryRequestAdminSerializer(serializers.Serializer):
required=False,
default=[NotificationStatus.FAILED],
)


# ---- Channel → response serializer 매핑 -------------------------------------

HISTORY_ADMIN_SERIALIZER_BY_CHANNEL: dict[NotificationChannel, type[_NotiHistoryAdminSerializerBase]] = {
NotificationChannel.EMAIL: EmailNotificationHistoryAdminSerializer,
NotificationChannel.NHN_CLOUD_SMS: NHNCloudSMSNotificationHistoryAdminSerializer,
NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer,
}
Empty file.
217 changes: 217 additions & 0 deletions app/admin_api/serializers/shop/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
from typing import Any
from urllib.parse import urljoin

from admin_api.serializers.notification import HISTORY_ADMIN_SERIALIZER_BY_CHANNEL
from core.const.serializer import COMMON_ADMIN_FIELDS
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
from core.serializer.json_schema_serializer import JsonSchemaSerializer
from core.serializer.skip_none_list_serializer import SkipNoneListSerializer
from django.conf import settings
from notification.channels import NotificationChannel
from notification.models.base import Recipient
from rest_framework import serializers
from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation
from shop.payment_history.models import PaymentHistory
from shop.product.models import Product
from user.models import UserExt

CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL = {
NotificationChannel.EMAIL: "email",
NotificationChannel.NHN_CLOUD_SMS: "phone",
NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: "phone",
}


class OrderAdminSerializer(
BaseAbstractSerializer,
JsonSchemaSerializer,
serializers.ModelSerializer,
):
class SimpleUserSerializer(serializers.ModelSerializer):
class Meta:
model = UserExt
read_only_fields = fields = ("id", "username", "email", "unique_id")

class SimpleCustomerInfoSerializer(serializers.ModelSerializer):
class Meta:
model = CustomerInfo
fields = ("name", "phone", "email", "organization")

class SimplePaymentHistorySerializer(serializers.ModelSerializer):
class Meta:
model = PaymentHistory
read_only_fields = fields = ("id", "imp_id", "status", "price", "created_at")

class SimpleOrderProductRelationSerializer(serializers.ModelSerializer):
class SimpleProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
read_only_fields = fields = ("id", "name_ko", "name_en", "price")

class SimpleOrderProductOptionRelationSerializer(serializers.ModelSerializer):
option_group_name_ko = serializers.CharField(source="product_option_group.name_ko", read_only=True)
option_group_name_en = serializers.CharField(source="product_option_group.name_en", read_only=True)
option_name_ko = serializers.CharField(source="product_option.name_ko", read_only=True, allow_null=True)
option_name_en = serializers.CharField(source="product_option.name_en", read_only=True, allow_null=True)

class Meta:
model = OrderProductOptionRelation
read_only_fields = fields = (
"id",
"option_group_name_ko",
"option_group_name_en",
"option_name_ko",
"option_name_en",
"custom_response",
)

product = SimpleProductSerializer(read_only=True)
options = SimpleOrderProductOptionRelationSerializer(many=True, read_only=True)

class Meta:
model = OrderProductRelation
fields = ("id", "product", "status", "price", "donation_price", "options")
read_only_fields = ("id", "product", "price", "donation_price", "options")

user = SimpleUserSerializer(read_only=True)
customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True)
products = SimpleOrderProductRelationSerializer(many=True, read_only=True)
payment_histories = SimplePaymentHistorySerializer(many=True, read_only=True)
first_paid_price = serializers.IntegerField(read_only=True)
current_paid_price = serializers.IntegerField(read_only=True)
current_status = serializers.CharField(read_only=True)
first_paid_at = serializers.DateTimeField(read_only=True)
latest_imp_id = serializers.CharField(read_only=True)

class Meta:
model = Order
fields = COMMON_ADMIN_FIELDS + (
"name_ko",
"name_en",
"user",
"customer_info",
"products",
"payment_histories",
"first_paid_price",
"current_paid_price",
"current_status",
"first_paid_at",
"latest_imp_id",
)
read_only_fields = ("name_ko", "name_en")

def update(self, instance: Order, validated_data: dict) -> Order:
customer_info_data = validated_data.pop("customer_info", None)
order = super().update(instance, validated_data)

if customer_info_data is not None:
if order.customer_info:
for field, value in customer_info_data.items():
setattr(order.customer_info, field, value)
order.customer_info.save()
else:
CustomerInfo.objects.create(order=order, **customer_info_data)

return order


class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer):
product_ids = serializers.ListField(child=serializers.UUIDField(), required=True, min_length=1)
include_refunded = serializers.BooleanField(default=False)


class _OrderRecipientItemSerializer(serializers.Serializer):
"""Order → Recipient ({recipient, context}) 변환.

customer_info / 첫 상품 / recipient 부재 시 None 반환. None-skip 의미를 가지므로
반드시 `SkipNoneListSerializer` (Meta.list_serializer_class) 와 함께 `many=True` 로 사용 — 단독 사용 시 호출자가 None 처리 책임.
"""

recipient = serializers.CharField()
context = serializers.JSONField()

class Meta:
list_serializer_class = SkipNoneListSerializer

def to_representation(self, order: Order) -> Recipient | None:
channel: NotificationChannel = self.context["channel"]

if not (customer_info := getattr(order, "customer_info", None)):
return None
if not (recipient := getattr(customer_info, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")):
return None
if not (order_product_rel := next(iter(order.products.all()), None)):
return None

ctx: dict[str, Any] = {
o_rel.product_option_group.name: (
o_rel.custom_response
if o_rel.product_option_group.is_custom_response
else (o_rel.product_option.name if o_rel.product_option else "")
)
for o_rel in order_product_rel.options.all()
}
ctx["scancode_url"] = urljoin(settings.BACKEND_DOMAIN, order.scancode_path)

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


class OrderSendNotificationPreviewResponseSerializer(JsonSchemaSerializer, serializers.Serializer):
class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer):
recipient = serializers.CharField()
context = serializers.JSONField()
missing_variables = serializers.ListField(child=serializers.CharField())

template_variables = serializers.ListField(child=serializers.CharField())
recipients = RecipientItemSerializer(many=True)


class OrderSendNotificationSerializer(JsonSchemaSerializer, serializers.Serializer):
channel = serializers.ChoiceField(choices=NotificationChannel.choices)
template_id = serializers.UUIDField()
context_override = serializers.JSONField(required=False, default=dict)

def validate_channel(self, value: str) -> NotificationChannel:
return NotificationChannel(value)

def validate(self, attrs: dict) -> dict:
if not (t := attrs["channel"].template_class.objects.filter_active().filter(pk=attrs["template_id"]).first()):
raise serializers.ValidationError({"template_id": "Template not found."})
# validated_data 에 template_id (UUID) 와 template (instance) 가 공존.
# downstream 은 template 만 사용; template_id 는 input round-trip 용으로 남김.
return {**attrs, "template": t}

def _build_recipient_items(self) -> list[Recipient]:
return _OrderRecipientItemSerializer(instance=self.instance, many=True, context=self.validated_data).data

def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializer:
template_vars = self.validated_data["template"].template_variables
return OrderSendNotificationPreviewResponseSerializer(
instance={
"template_variables": sorted(template_vars),
"recipients": [
{**i, "missing_variables": sorted(template_vars - i["context"].keys())}
for i in self._build_recipient_items()
],
},
)

def build_send_response(self) -> serializers.Serializer:
if not (items := self._build_recipient_items()):
raise serializers.ValidationError(
"발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)."
)
channel: NotificationChannel = self.validated_data["channel"]
template = self.validated_data["template"]
if invalid := [
{**i, "missing_variables": missing}
for i in items
if (missing := sorted(template.template_variables - i["context"].keys()))
]:
raise serializers.ValidationError({"missing_context_variables": invalid})

# create_for_recipients (DB write) + history.send() (Celery dispatch on commit).
history = channel.history_class.objects.create_for_recipients(template=template, recipients=items)
history.send()
history.refresh_from_db()
return HISTORY_ADMIN_SERIALIZER_BY_CHANNEL[channel](instance=history)
Loading
Loading