Skip to content

Commit 03416ba

Browse files
committed
feat: shop admin API 보완 (필터/검증/재고/상태/부분환불)
1 parent 6f357e1 commit 03416ba

6 files changed

Lines changed: 188 additions & 14 deletions

File tree

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/shop/orders.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
from core.const.serializer import COMMON_ADMIN_FIELDS
66
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
77
from core.serializer.json_schema_serializer import JsonSchemaSerializer
8-
from core.serializer.read_only_serializer import ReadOnlyModelSerializer
98
from core.serializer.skip_none_list_serializer import SkipNoneListSerializer
109
from django.conf import settings
1110
from notification.channels import NotificationChannel
@@ -24,7 +23,6 @@
2423

2524

2625
class OrderAdminSerializer(
27-
ReadOnlyModelSerializer,
2826
BaseAbstractSerializer,
2927
JsonSchemaSerializer,
3028
serializers.ModelSerializer,
@@ -37,7 +35,7 @@ class Meta:
3735
class SimpleCustomerInfoSerializer(serializers.ModelSerializer):
3836
class Meta:
3937
model = CustomerInfo
40-
read_only_fields = fields = ("name", "phone", "email", "organization")
38+
fields = ("name", "phone", "email", "organization")
4139

4240
class SimplePaymentHistorySerializer(serializers.ModelSerializer):
4341
class Meta:
@@ -76,7 +74,7 @@ class Meta:
7674
read_only_fields = ("id", "product", "price", "donation_price", "options")
7775

7876
user = SimpleUserSerializer(read_only=True)
79-
customer_info = SimpleCustomerInfoSerializer(read_only=True)
77+
customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True)
8078
products = SimpleOrderProductRelationSerializer(many=True, read_only=True)
8179
payment_histories = SimplePaymentHistorySerializer(many=True, read_only=True)
8280
first_paid_price = serializers.IntegerField(read_only=True)
@@ -100,6 +98,21 @@ class Meta:
10098
"first_paid_at",
10199
"latest_imp_id",
102100
)
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
103116

104117

105118
class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer):

app/admin_api/serializers/shop/products.py

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,19 @@ class CategoryAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, Nest
1818
class Meta:
1919
model = Category
2020
fields = COMMON_ADMIN_FIELDS + ("group", "name", "priority")
21+
# group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능.
22+
# validators=[] — auto UniqueTogetherValidator(group, name) 가 group 누락 시 required 로 막음.
23+
# DB unique constraint(uq__cat__grp_nm) 가 여전히 enforce.
24+
extra_kwargs = {"group": {"required": False}}
25+
validators: list = []
2126
list_serializer_class = InstanceListSerializer
2227

2328
categories = CategoryAdminSerializer(many=True, required=False, source="category_set")
29+
category_count = serializers.IntegerField(read_only=True)
2430

2531
class Meta:
2632
model = CategoryGroup
27-
fields = COMMON_ADMIN_FIELDS + ("name", "priority", "categories")
33+
fields = COMMON_ADMIN_FIELDS + ("name", "priority", "categories", "category_count")
2834
nested_fields = {
2935
"category_set": NestedFieldSpec(
3036
related_manager_name="category_set",
@@ -35,14 +41,17 @@ class Meta:
3541

3642

3743
class TagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
44+
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
45+
3846
class Meta:
3947
model = Tag
40-
fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user")
48+
fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user", "leftover_stock")
4149

4250

4351
class OptionGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer):
4452
class OptionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer):
4553
id = serializers.UUIDField(required=False, help_text="기존 Option 수정 시 PK 전달, 새로 추가 시 생략")
54+
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
4655

4756
class Meta:
4857
model = Option
@@ -54,7 +63,10 @@ class Meta:
5463
"max_quantity_per_user",
5564
"additional_price",
5665
"stock",
66+
"leftover_stock",
5767
)
68+
# group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능.
69+
extra_kwargs = {"group": {"required": False}}
5870
list_serializer_class = InstanceListSerializer
5971

6072
options = OptionAdminSerializer(many=True, required=False)
@@ -97,6 +109,9 @@ def validate(self, attrs: dict) -> dict:
97109
class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
98110
option_groups = OptionGroupAdminSerializer(many=True, read_only=True)
99111
tag_set = serializers.PrimaryKeyRelatedField(many=True, queryset=Tag.objects.filter_active(), required=False)
112+
tag_set_detail = TagAdminSerializer(many=True, read_only=True, source="tag_set")
113+
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
114+
current_status = serializers.ChoiceField(choices=Product.CurrentStatus.choices, read_only=True)
100115

101116
class Meta:
102117
model = Product
@@ -122,4 +137,28 @@ class Meta:
122137
"donation_max_price",
123138
"option_groups",
124139
"tag_set",
140+
"tag_set_detail",
141+
"leftover_stock",
142+
"current_status",
125143
)
144+
145+
def validate(self, attrs: dict) -> dict:
146+
merged = {**attrs}
147+
if self.instance is not None:
148+
for field in ("visible_starts_at", "visible_ends_at", "orderable_starts_at", "orderable_ends_at"):
149+
merged.setdefault(field, getattr(self.instance, field, None))
150+
151+
v_start = merged.get("visible_starts_at")
152+
v_end = merged.get("visible_ends_at")
153+
o_start = merged.get("orderable_starts_at")
154+
o_end = merged.get("orderable_ends_at")
155+
156+
errors: dict[str, str] = {}
157+
if v_start and o_start and o_start < v_start:
158+
errors["orderable_starts_at"] = "판매 시작은 노출 시작 이후여야 합니다."
159+
if v_end and o_end and o_end > v_end:
160+
errors["orderable_ends_at"] = "판매 종료는 노출 종료 이전이어야 합니다."
161+
162+
if errors:
163+
raise serializers.ValidationError(errors)
164+
return attrs

app/admin_api/views/shop/orders.py

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,27 +14,34 @@
1414
from django.db import models, transaction
1515
from django.http.response import StreamingHttpResponse
1616
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view
17-
from rest_framework import exceptions, parsers, request, response, status, viewsets
17+
from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets
1818
from rest_framework.decorators import action
1919
from shop.order import exports, imports
2020
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
2121
from shop.payment_history.models import PURCHASED_STATUSES, REFUNDABLE_STATUSES, PaymentHistory
2222
from shop.product.models import Product
23-
from shop.serializers.refund import OrderTotalRefundSerializer
23+
from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer
2424

2525
logger = getLogger(__name__)
2626

27-
ADMIN_METHODS = ["list", "retrieve"]
27+
ADMIN_METHODS = ["list", "retrieve", "partial_update"]
2828

2929

3030
@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER]) for m in ADMIN_METHODS})
31-
class OrderAdminViewSet(JsonSchemaViewSet, viewsets.ReadOnlyModelViewSet):
32-
http_method_names = ["get", "post"]
31+
class OrderAdminViewSet(
32+
JsonSchemaViewSet,
33+
mixins.ListModelMixin,
34+
mixins.RetrieveModelMixin,
35+
mixins.UpdateModelMixin,
36+
viewsets.GenericViewSet,
37+
):
38+
http_method_names = ["get", "post", "patch"]
3339
serializer_class = OrderAdminSerializer
3440
filterset_class = OrderAdminFilterSet
3541
permission_classes = [IsSuperUser]
3642
queryset = (
37-
Order.objects.filter_active()
43+
Order.objects.filter_has_payment_histories()
44+
.filter(models.Exists(OrderProductRelation.objects.filter(order=models.OuterRef("pk"))))
3845
.select_related_with_user("user", "customer_info")
3946
.prefetch_related(
4047
models.Prefetch(
@@ -54,7 +61,6 @@ class OrderAdminViewSet(JsonSchemaViewSet, viewsets.ReadOnlyModelViewSet):
5461
models.Prefetch(
5562
"payment_histories",
5663
queryset=PaymentHistory.objects.filter_active().order_by("-created_at"),
57-
to_attr="_payment_histories_by_latest",
5864
),
5965
)
6066
.annotate(
@@ -83,6 +89,33 @@ def refund(self, request: request.Request, pk: typing.Any = None) -> response.Re
8389
serializer.refund()
8490
return response.Response(status=status.HTTP_204_NO_CONTENT)
8591

92+
@extend_schema(
93+
summary="주문 부분 환불 (환불 승인자 TOTP 필수)",
94+
tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND],
95+
parameters=[OpenApiParameter(name="totp", location=OpenApiParameter.QUERY, required=True)],
96+
responses={status.HTTP_204_NO_CONTENT: None},
97+
)
98+
@action(detail=True, methods=["post"], url_path=r"products/(?P<rel_id>[^/.]+)/refund")
99+
@transaction.atomic
100+
def refund_product(
101+
self,
102+
request: request.Request,
103+
pk: typing.Any = None,
104+
rel_id: typing.Any = None,
105+
) -> response.Response:
106+
order_product_rel = OrderProductRelation.objects.filter(order_id=pk, id=rel_id).first()
107+
if not order_product_rel:
108+
raise exceptions.NotFound("OrderProductRelation not found.")
109+
110+
serializer = OrderProductRefundSerializer(
111+
instance=order_product_rel,
112+
data={"totp": request.query_params.get("totp")},
113+
context={"check_refundable_date": False},
114+
)
115+
serializer.is_valid(raise_exception=True)
116+
serializer.refund()
117+
return response.Response(status=status.HTTP_204_NO_CONTENT)
118+
86119
@extend_schema(
87120
summary="주문 CSV 가져오기 템플릿 다운로드",
88121
tags=[OpenAPITag.ADMIN_SHOP_ORDER],

app/admin_api/views/shop/products.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from admin_api.filtersets.shop.products import ProductAdminFilterSet
12
from admin_api.serializers.shop.products import (
23
CategoryGroupAdminSerializer,
34
OptionGroupAdminSerializer,
@@ -7,7 +8,7 @@
78
from core.authz import IsSuperUser
89
from core.const.tag import OpenAPITag
910
from core.viewset.json_schema_viewset import JsonSchemaViewSet
10-
from django.db.models import Prefetch
11+
from django.db.models import Count, Prefetch, Q
1112
from drf_spectacular.utils import extend_schema, extend_schema_view
1213
from rest_framework import viewsets
1314
from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, ProductTagRelation, Tag
@@ -27,6 +28,7 @@ class CategoryGroupAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet):
2728
.prefetch_related(
2829
Prefetch("category_set", queryset=Category.objects.filter_active().select_related_with_user()),
2930
)
31+
.annotate(category_count=Count("category", filter=Q(category__deleted_at__isnull=True)))
3032
)
3133

3234

@@ -43,6 +45,7 @@ class ProductAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet):
4345
http_method_names = ["get", "post", "patch", "delete"]
4446
serializer_class = ProductAdminSerializer
4547
permission_classes = [IsSuperUser]
48+
filterset_class = ProductAdminFilterSet
4649
queryset = (
4750
Product.objects.filter_active()
4851
.select_related_with_user("category", "category__group")

app/shop/product/models.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import typing
66

77
from core.models import BaseAbstractModel
8+
from core.util.dateutil import now_aware
89
from django.core.exceptions import ValidationError
910
from django.db import models
1011
from django.db.models.manager import BaseManager
@@ -104,6 +105,12 @@ def get_user_taken_stock_count(
104105

105106

106107
class Product(BaseAbstractModel):
108+
class CurrentStatus(models.TextChoices):
109+
HIDDEN = "hidden", "비공개"
110+
OUT_OF_VISIBLE_PERIOD = "out_of_visible_period", "노출 기간 아님"
111+
OUT_OF_ORDERABLE_PERIOD = "out_of_orderable_period", "판매 기간 아님"
112+
ACTIVE = "active", "노출 중"
113+
107114
name = models.TextField()
108115
description = models.TextField(null=True, blank=True)
109116
image = models.URLField(null=True, blank=True)
@@ -138,6 +145,24 @@ class Meta:
138145
def __str__(self) -> str:
139146
return f"{self.category} > {self.name} ({self.price}원)"
140147

148+
@property
149+
def current_status(self) -> "Product.CurrentStatus":
150+
if self.hidden:
151+
return self.CurrentStatus.HIDDEN
152+
153+
now = now_aware()
154+
if (self.visible_starts_at and now < self.visible_starts_at) or (
155+
self.visible_ends_at and now > self.visible_ends_at
156+
):
157+
return self.CurrentStatus.OUT_OF_VISIBLE_PERIOD
158+
159+
if (self.orderable_starts_at and now < self.orderable_starts_at) or (
160+
self.orderable_ends_at and now > self.orderable_ends_at
161+
):
162+
return self.CurrentStatus.OUT_OF_ORDERABLE_PERIOD
163+
164+
return self.CurrentStatus.ACTIVE
165+
141166
@functools.cached_property
142167
def leftover_stock(self) -> int | None:
143168
"""해당 상품의 재고를 반환합니다."""

0 commit comments

Comments
 (0)