Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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.
43 changes: 43 additions & 0 deletions app/admin_api/filtersets/shop/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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")

product_id = filters.BaseInFilter(field_name="products__product_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",
"product_id",
"price_min",
"price_max",
]
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.
204 changes: 204 additions & 0 deletions app/admin_api/serializers/shop/orders.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
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.read_only_serializer import ReadOnlyModelSerializer
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(
ReadOnlyModelSerializer,
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
read_only_fields = 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(read_only=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",
)


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)
125 changes: 125 additions & 0 deletions app/admin_api/serializers/shop/products.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
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.nested_model_serializer import (
InstanceListSerializer,
NestedFieldModelSerializer,
NestedFieldSpec,
NestedModelSerializer,
)
from rest_framework import serializers
from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag


class CategoryGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer):
class CategoryAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer):
id = serializers.UUIDField(required=False, help_text="기존 Category 수정 시 PK 전달, 새로 추가 시 생략")

class Meta:
model = Category
fields = COMMON_ADMIN_FIELDS + ("group", "name", "priority")
list_serializer_class = InstanceListSerializer

categories = CategoryAdminSerializer(many=True, required=False, source="category_set")

class Meta:
model = CategoryGroup
fields = COMMON_ADMIN_FIELDS + ("name", "priority", "categories")
nested_fields = {
"category_set": NestedFieldSpec(
related_manager_name="category_set",
child_model=Category,
parent_fk_name="group",
),
}


class TagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
class Meta:
model = Tag
fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user")


class OptionGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer):
class OptionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer):
id = serializers.UUIDField(required=False, help_text="기존 Option 수정 시 PK 전달, 새로 추가 시 생략")

class Meta:
model = Option
fields = COMMON_ADMIN_FIELDS + (
"group",
"priority",
"name_ko",
"name_en",
"max_quantity_per_user",
"additional_price",
"stock",
)
list_serializer_class = InstanceListSerializer

options = OptionAdminSerializer(many=True, required=False)

class Meta:
model = OptionGroup
fields = COMMON_ADMIN_FIELDS + (
"product",
"priority",
"name_ko",
"name_en",
"min_quantity_per_product",
"max_quantity_per_product",
"is_custom_response",
"custom_response_pattern",
"response_modifiable_ends_at",
"options",
)
nested_fields = {
"options": NestedFieldSpec(
related_manager_name="options",
child_model=Option,
parent_fk_name="group",
),
}

def validate(self, attrs: dict) -> dict:
# is_custom_response=True 면 패턴이 admin 계약 — 빈 답변 허용은 ".*", 비공란 강제는 ".+" 등으로 명시.
is_custom_response = attrs.get("is_custom_response", getattr(self.instance, "is_custom_response", False))
custom_response_pattern = attrs.get(
"custom_response_pattern", getattr(self.instance, "custom_response_pattern", None)
)
if is_custom_response and not custom_response_pattern:
raise serializers.ValidationError(
{"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."}
)
return attrs


class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
option_groups = OptionGroupAdminSerializer(many=True, read_only=True)
tag_set = serializers.PrimaryKeyRelatedField(many=True, queryset=Tag.objects.filter_active(), required=False)

class Meta:
model = Product
fields = COMMON_ADMIN_FIELDS + (
"name_ko",
"name_en",
"description_ko",
"description_en",
"image",
"price",
"stock",
"hidden",
"max_quantity_per_user",
"visible_starts_at",
"visible_ends_at",
"orderable_starts_at",
"orderable_ends_at",
"refundable_ends_at",
"category",
"priority",
"donation_allowed",
"donation_min_price",
"donation_max_price",
"option_groups",
"tag_set",
)
Loading
Loading