Skip to content

Commit ec1c0e0

Browse files
committed
feat: 티켓 상품의 경우 별도 테이블에 정보를 저장하도록 수정
1 parent 1d104f3 commit ec1c0e0

47 files changed

Lines changed: 1533 additions & 610 deletions

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

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
from notification.channels import NotificationChannel
99
from notification.models.base import Recipient
1010
from rest_framework import serializers
11-
from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation
11+
from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, TicketInfo
1212
from shop.payment_history.models import PaymentHistory
1313
from shop.product.models import Product
1414
from user.models import UserExt
@@ -63,13 +63,19 @@ class Meta:
6363
"custom_response",
6464
)
6565

66+
class SimpleTicketInfoSerializer(serializers.ModelSerializer):
67+
class Meta:
68+
model = TicketInfo
69+
fields = ("name", "phone", "email", "organization", "contribution_message")
70+
6671
product = SimpleProductSerializer(read_only=True)
6772
options = SimpleOrderProductOptionRelationSerializer(many=True, read_only=True)
73+
ticket_info = SimpleTicketInfoSerializer(read_only=True, allow_null=True)
6874

6975
class Meta:
7076
model = OrderProductRelation
71-
fields = ("id", "product", "status", "price", "donation_price", "options")
72-
read_only_fields = ("id", "product", "price", "donation_price", "options")
77+
fields = ("id", "product", "status", "price", "donation_price", "options", "ticket_info")
78+
read_only_fields = ("id", "product", "price", "donation_price", "options", "ticket_info")
7379

7480
user = SimpleUserSerializer(read_only=True)
7581
customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True)

app/admin_api/serializers/shop/products.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from core.util.timespan import TimeSpan
1313
from file.models import PublicFile
1414
from rest_framework import serializers
15+
from shop.order.models import OrderProductRelation
1516
from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag
1617

1718

@@ -29,6 +30,26 @@ class Meta:
2930
validators: list = []
3031
list_serializer_class = InstanceListSerializer
3132

33+
def validate(self, attrs: dict) -> dict:
34+
if self.instance is not None:
35+
new_is_ticket = attrs.get("is_ticket", self.instance.is_ticket)
36+
if new_is_ticket != self.instance.is_ticket:
37+
self._validate_is_ticket_change(new_is_ticket=new_is_ticket)
38+
return attrs
39+
40+
def _validate_is_ticket_change(self, *, new_is_ticket: bool) -> None:
41+
purchased = OrderProductRelation.objects.filter_active().filter(
42+
product__category=self.instance,
43+
status__in=OrderProductRelation.PURCHASED_STOCK_STATUS,
44+
)
45+
if new_is_ticket:
46+
if purchased.filter(ticket_info__isnull=True).exists():
47+
msg = "참가자 정보가 없는 구매 건이 있어 티켓으로 전환할 수 없습니다."
48+
raise serializers.ValidationError({"is_ticket": msg})
49+
elif purchased.filter(ticket_info__isnull=False).exists():
50+
msg = "참가자 정보가 수집된 티켓 구매 건이 있어 티켓 설정을 해제할 수 없습니다."
51+
raise serializers.ValidationError({"is_ticket": msg})
52+
3253
categories = CategoryAdminSerializer(many=True, required=False, source="category_set")
3354
category_count = serializers.IntegerField(read_only=True)
3455

app/admin_api/test/shop/conftest.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,20 @@
88
mock_portone_register,
99
mock_portone_req_cancel_payment,
1010
modifiable_option_relation,
11+
non_ticket_opr,
12+
non_ticket_product,
1113
option,
1214
option_group,
1315
order_factory,
1416
other_client,
1517
other_user,
16-
product,
1718
products_by_status,
1819
single_product_cart,
1920
staff_client,
2021
staff_user,
2122
tag,
23+
ticket_opr,
24+
ticket_product,
2225
)
2326

2427

app/admin_api/test/shop/orders_api_test.py

Lines changed: 31 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -56,9 +56,9 @@ def test_admin_list_filters_by_status_csv(api_client, order_factory):
5656

5757

5858
@pytest.mark.django_db
59-
def test_admin_list_filters_by_product_id_distinct(api_client, product, order_factory):
59+
def test_admin_list_filters_by_product_id_distinct(api_client, ticket_product, order_factory):
6060
completed_order = order_factory(status="completed")
61-
response = OrdersAdminApi(http_client=api_client).list({"product_id": str(product.id)})
61+
response = OrdersAdminApi(http_client=api_client).list({"product_id": str(ticket_product.id)})
6262
assert response.status_code == HTTP_200_OK
6363
assert response.json() == {
6464
"count": 1,
@@ -69,19 +69,19 @@ def test_admin_list_filters_by_product_id_distinct(api_client, product, order_fa
6969

7070

7171
@pytest.mark.django_db
72-
def test_admin_list_filters_by_active_opr_category(api_client, product, order_factory):
72+
def test_admin_list_filters_by_active_opr_category(api_client, ticket_product, order_factory):
7373
"""`?category_id=` 가 active OPR 가 있는 주문만 매칭한다."""
7474
completed_order = order_factory(status="completed")
75-
response = OrdersAdminApi(http_client=api_client).list({"category_id": str(product.category_id)})
75+
response = OrdersAdminApi(http_client=api_client).list({"category_id": str(ticket_product.category_id)})
7676
assert response.status_code == HTTP_200_OK
7777
assert {row["id"] for row in response.json()["results"]} == {str(completed_order.id)}
7878

7979

8080
@pytest.mark.django_db
81-
def test_admin_list_filters_by_active_opr_category_group(api_client, product, order_factory):
81+
def test_admin_list_filters_by_active_opr_category_group(api_client, ticket_product, order_factory):
8282
"""`?category_group_id=` 가 active OPR 가 있는 주문만 매칭한다."""
8383
completed_order = order_factory(status="completed")
84-
response = OrdersAdminApi(http_client=api_client).list({"category_group_id": str(product.category.group_id)})
84+
response = OrdersAdminApi(http_client=api_client).list({"category_group_id": str(ticket_product.category.group_id)})
8585
assert response.status_code == HTTP_200_OK
8686
assert {row["id"] for row in response.json()["results"]} == {str(completed_order.id)}
8787

@@ -123,12 +123,15 @@ def test_admin_refund_action_rejects_missing_totp(api_client, mock_portone_req_c
123123

124124
@pytest.mark.django_db
125125
def test_admin_refund_product_action_does_partial_refund(
126-
api_client, product, mock_portone_req_cancel_payment, order_factory
126+
api_client, ticket_product, mock_portone_req_cancel_payment, order_factory
127127
):
128128
completed_order = order_factory(status="completed")
129129
target_opr = completed_order.products.first()
130130
OrderProductRelation.objects.create(
131-
order=completed_order, product=product, price=product.price, status=OrderProductRelation.OrderProductStatus.paid
131+
order=completed_order,
132+
product=ticket_product,
133+
price=ticket_product.price,
134+
status=OrderProductRelation.OrderProductStatus.paid,
132135
)
133136
response = OrdersAdminApi(http_client=api_client).refund_product(
134137
completed_order.id, target_opr.id, totp=valid_refund_totp()
@@ -152,17 +155,17 @@ def test_admin_refund_product_action_returns_404_for_unknown_rel(api_client, ord
152155
@pytest.mark.django_db
153156
def test_admin_refund_allows_expired_window(api_client, mock_portone_req_cancel_payment, order_factory):
154157
completed_order = order_factory(status="completed")
155-
product = completed_order.products.first().product
156-
product.refundable_ends_at = datetime(2020, 1, 1, tzinfo=timezone.utc)
157-
product.save()
158+
ticket_product = completed_order.products.first().product
159+
ticket_product.refundable_ends_at = datetime(2020, 1, 1, tzinfo=timezone.utc)
160+
ticket_product.save()
158161

159162
response = OrdersAdminApi(http_client=api_client).refund(completed_order.id, totp=valid_refund_totp())
160163
assert response.status_code == HTTP_204_NO_CONTENT
161164

162165

163166
@pytest.mark.django_db
164-
def test_admin_import_template_returns_csv(api_client, product):
165-
response = OrdersAdminApi(http_client=api_client).import_template(product_id=str(product.id))
167+
def test_admin_import_template_returns_csv(api_client, ticket_product):
168+
response = OrdersAdminApi(http_client=api_client).import_template(product_id=str(ticket_product.id))
166169
assert response.status_code == HTTP_200_OK
167170
assert "text/csv" in response.headers["Content-Type"]
168171

@@ -186,15 +189,15 @@ def _csv_file(rows: str) -> BytesIO:
186189

187190

188191
@pytest.mark.django_db
189-
def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, customer_user, product):
192+
def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, customer_user, ticket_product):
190193
response = OrdersAdminApi(http_client=api_client).import_csv(
191194
csv_file=_csv_file(
192195
"name,phone,email,organization,product_id,donation_price\n"
193-
f"홍길동,010-1234-5678,{customer_user.email},,{product.id},0\n"
196+
f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0\n"
194197
)
195198
)
196199
assert response.status_code == HTTP_201_CREATED
197-
opr = OrderProductRelation.objects.get(product=product)
200+
opr = OrderProductRelation.objects.get(product=ticket_product)
198201
assert opr.status == OrderProductRelation.OrderProductStatus.paid
199202
assert opr.order.user == customer_user
200203

@@ -206,12 +209,12 @@ def test_admin_import_csv_rejects_missing_file(api_client):
206209

207210

208211
@pytest.mark.django_db
209-
def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_client, product):
212+
def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_client, ticket_product):
210213
# email 매칭되는 user 부재 → 모든 row validate 실패 → atomic rollback.
211214
response = OrdersAdminApi(http_client=api_client).import_csv(
212215
csv_file=_csv_file(
213216
"name,phone,email,organization,product_id,donation_price\n"
214-
f"홍길동,010-1234-5678,nobody@example.com,,{product.id},0\n"
217+
f"홍길동,010-1234-5678,nobody@example.com,,{ticket_product.id},0\n"
215218
)
216219
)
217220
assert response.status_code == HTTP_400_BAD_REQUEST
@@ -222,11 +225,11 @@ def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_cl
222225
@freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc))
223226
@pytest.mark.django_db
224227
def test_admin_export_returns_xlsx_filtering_refunded_per_include_flag(
225-
api_client, customer_user, product, include_refunded, order_factory
228+
api_client, customer_user, ticket_product, include_refunded, order_factory
226229
):
227230
refunded_order = order_factory(status="refunded")
228231
response = OrdersAdminApi(http_client=api_client).export(
229-
{"product_ids": [str(product.id)], "include_refunded": include_refunded}
232+
{"product_ids": [str(ticket_product.id)], "include_refunded": include_refunded}
230233
)
231234
assert response.status_code == HTTP_200_OK
232235
assert response.headers["Content-Type"] == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
@@ -272,8 +275,8 @@ def test_admin_export_returns_xlsx_filtering_refunded_per_include_flag(
272275
assert df_dict["주문상품"].to_dict(orient="records") == [
273276
{
274277
"주문 번호": str(refunded_order.id),
275-
"상품 ID": str(product.id),
276-
"상품명": product.name,
278+
"상품 ID": str(ticket_product.id),
279+
"상품명": ticket_product.name,
277280
"상태": "refunded",
278281
"결제 금액": opr.price,
279282
"추가 기부액": opr.donation_price,
@@ -314,14 +317,17 @@ def test_admin_partial_update_creates_customer_info_when_missing(api_client, ord
314317

315318

316319
@pytest.mark.django_db
317-
def test_admin_list_filters_by_user_id(api_client, customer_user, other_user, product, order_factory):
320+
def test_admin_list_filters_by_user_id(api_client, customer_user, other_user, ticket_product, order_factory):
318321
completed_order = order_factory(status="completed")
319322
other_order = Order.objects.create(user=other_user, name="other")
320323
OrderProductRelation.objects.create(
321-
order=other_order, product=product, price=product.price, status=OrderProductRelation.OrderProductStatus.paid
324+
order=other_order,
325+
product=ticket_product,
326+
price=ticket_product.price,
327+
status=OrderProductRelation.OrderProductStatus.paid,
322328
)
323329
PaymentHistory.objects.create(
324-
order=other_order, imp_id="imp_o", status=PaymentHistoryStatus.completed, price=product.price
330+
order=other_order, imp_id="imp_o", status=PaymentHistoryStatus.completed, price=ticket_product.price
325331
)
326332

327333
response = OrdersAdminApi(http_client=api_client).list({"user_id": str(customer_user.id)})

0 commit comments

Comments
 (0)