Skip to content

Commit d64198d

Browse files
committed
Merge branch 'fix/order-soft-delete-filtering'
# Conflicts: # app/shop/order/views/orders.py
2 parents 210b13a + 2abe944 commit d64198d

20 files changed

Lines changed: 682 additions & 118 deletions

File tree

app/admin_api/filtersets/shop/orders.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from core.filter.multi_field import MultiFieldOrCharInFilter
2+
from django.db.models import Exists, OuterRef, QuerySet
23
from django_filters import rest_framework as filters
3-
from shop.order.models import Order
4+
from shop.order.models import Order, OrderProductRelation
45

56

67
class OrderAdminFilterSet(filters.FilterSet):
@@ -26,13 +27,25 @@ class OrderAdminFilterSet(filters.FilterSet):
2627
status_changed_at_after = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="gte")
2728
status_changed_at_before = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="lte")
2829

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)
30+
product_id = filters.BaseCSVFilter(method="filter_by_active_opr_product_id")
31+
category_id = filters.BaseCSVFilter(method="filter_by_active_opr_category_id")
32+
category_group_id = filters.BaseCSVFilter(method="filter_by_active_opr_category_group_id")
3233

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

37+
def _filter_by_active_opr_exists(self, qs: QuerySet[Order], **kw: object) -> QuerySet[Order]:
38+
return qs.filter(Exists(OrderProductRelation.objects.filter_active().filter(order_id=OuterRef("pk"), **kw)))
39+
40+
def filter_by_active_opr_product_id(self, qs: QuerySet[Order], n: str, vs: list[str]) -> QuerySet[Order]:
41+
return self._filter_by_active_opr_exists(qs, product_id__in=vs) if vs else qs
42+
43+
def filter_by_active_opr_category_id(self, qs: QuerySet[Order], n: str, vs: list[str]) -> QuerySet[Order]:
44+
return self._filter_by_active_opr_exists(qs, product__category_id__in=vs) if vs else qs
45+
46+
def filter_by_active_opr_category_group_id(self, qs: QuerySet[Order], n: str, v: list[str]) -> QuerySet[Order]:
47+
return self._filter_by_active_opr_exists(qs, product__category__group_id__in=v) if v else qs
48+
3649
class Meta:
3750
model = Order
3851
fields = [

app/admin_api/serializers/shop/orders.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ def to_representation(self, order: Order) -> Recipient | None:
139139
return None
140140
if not (recipient := getattr(customer_info, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")):
141141
return None
142-
if not (order_product_rel := next(iter(order.products.all()), None)):
142+
if not (order_product_rel := next(iter(order.products.filter_active()), None)):
143143
return None
144144

145145
ctx: dict[str, Any] = {
@@ -148,7 +148,7 @@ def to_representation(self, order: Order) -> Recipient | None:
148148
if o_rel.product_option_group.is_custom_response
149149
else (o_rel.product_option.name if o_rel.product_option else "")
150150
)
151-
for o_rel in order_product_rel.options.all()
151+
for o_rel in order_product_rel.options.filter_active()
152152
} | order.build_notification_context()
153153

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

app/admin_api/test/shop/orders_api_test.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,24 @@ def test_admin_list_filters_by_product_id_distinct(api_client, product, order_fa
6868
}
6969

7070

71+
@pytest.mark.django_db
72+
def test_admin_list_filters_by_active_opr_category(api_client, product, order_factory):
73+
"""`?category_id=` 가 active OPR 가 있는 주문만 매칭한다."""
74+
completed_order = order_factory(status="completed")
75+
response = OrdersAdminApi(http_client=api_client).list({"category_id": str(product.category_id)})
76+
assert response.status_code == HTTP_200_OK
77+
assert {row["id"] for row in response.json()["results"]} == {str(completed_order.id)}
78+
79+
80+
@pytest.mark.django_db
81+
def test_admin_list_filters_by_active_opr_category_group(api_client, product, order_factory):
82+
"""`?category_group_id=` 가 active OPR 가 있는 주문만 매칭한다."""
83+
completed_order = order_factory(status="completed")
84+
response = OrdersAdminApi(http_client=api_client).list({"category_group_id": str(product.category.group_id)})
85+
assert response.status_code == HTTP_200_OK
86+
assert {row["id"] for row in response.json()["results"]} == {str(completed_order.id)}
87+
88+
7189
@pytest.mark.django_db
7290
def test_admin_retrieve_returns_nested_payload(api_client, order_factory):
7391
completed_order = order_factory(status="completed")
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
"""Admin shop API soft-delete 회귀 — export / 부분 환불 lookup / list filter 가 soft-deleted row 를 노출하지 않는다."""
2+
3+
from datetime import datetime, timezone
4+
from io import BytesIO
5+
6+
import pandas
7+
import pytest
8+
from freezegun import freeze_time
9+
from rest_framework.status import HTTP_200_OK, HTTP_404_NOT_FOUND
10+
from shop.order.models import Order, OrderProductRelation
11+
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus
12+
from shop.product.models import Product
13+
from shop.test.helpers import OrdersAdminApi, valid_refund_totp
14+
15+
16+
@pytest.mark.django_db
17+
def test_admin_refund_product_404_for_soft_deleted_opr(api_client, order_factory, product):
18+
"""soft-deleted paid OPR 은 admin refund_product 에서 lookup 되지 않아 404."""
19+
completed = order_factory(status="completed")
20+
stale_opr = OrderProductRelation.objects.create(
21+
order=completed, product=product, price=product.price, status=OrderProductRelation.OrderProductStatus.paid
22+
)
23+
stale_opr.delete()
24+
25+
response = OrdersAdminApi(http_client=api_client).refund_product(
26+
completed.id, stale_opr.id, totp=valid_refund_totp()
27+
)
28+
assert response.status_code == HTTP_404_NOT_FOUND
29+
30+
31+
@freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc))
32+
@pytest.mark.django_db
33+
def test_admin_export_excludes_soft_deleted_opr_from_product_sheet(api_client, order_factory, product):
34+
"""admin export 의 '주문상품' 시트에 soft-deleted OPR 가 들어가지 않는다."""
35+
completed = order_factory(status="completed")
36+
active_opr = completed.products.get()
37+
stale_opr = OrderProductRelation.objects.create(
38+
order=completed,
39+
product=product,
40+
price=99999,
41+
status=OrderProductRelation.OrderProductStatus.paid,
42+
)
43+
stale_opr.delete()
44+
45+
response = OrdersAdminApi(http_client=api_client).export(
46+
{"product_ids": [str(product.id)], "include_refunded": False}
47+
)
48+
assert response.status_code == HTTP_200_OK
49+
df_dict = pandas.read_excel(
50+
BytesIO(b"".join(response.streaming_content)),
51+
sheet_name="주문상품",
52+
index_col=0,
53+
na_filter=False,
54+
dtype={"고객 전화번호": str, "PortOne ID": str},
55+
)
56+
rows = df_dict.to_dict(orient="records")
57+
prices = {row["결제 금액"] for row in rows}
58+
assert active_opr.price in prices # 활성 OPR row 가 존재
59+
assert 99999 not in prices # soft-deleted OPR(price=99999) 는 시트에 없음
60+
assert str(completed.id) in {row["주문 번호"] for row in rows}
61+
62+
63+
@freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc))
64+
@pytest.mark.django_db
65+
def test_admin_export_excludes_order_with_only_soft_deleted_matching_opr(
66+
api_client, customer_user, product, order_factory
67+
):
68+
"""선택 product_id 가 soft-deleted OPR 에만 있는 주문은 export 양쪽 시트 모두에서 제외돼야 한다."""
69+
target_product = product
70+
71+
# 1) 활성 OPR 가 있는 정상 주문 — export 대상.
72+
normal = order_factory(status="completed")
73+
74+
# 2) 같은 product 로 soft-deleted OPR 만 가진 주문 (다른 product 의 paid OPR 보유) — export 에서 빠져야.
75+
other_product = Product.objects.create(
76+
category=target_product.category,
77+
name="기타",
78+
price=500,
79+
visible_starts_at=target_product.visible_starts_at,
80+
visible_ends_at=target_product.visible_ends_at,
81+
orderable_starts_at=target_product.orderable_starts_at,
82+
orderable_ends_at=target_product.orderable_ends_at,
83+
refundable_ends_at=target_product.refundable_ends_at,
84+
)
85+
leak_candidate = Order.objects.create(user=customer_user, name="leak")
86+
OrderProductRelation.objects.create(
87+
order=leak_candidate,
88+
product=other_product,
89+
price=500,
90+
status=OrderProductRelation.OrderProductStatus.paid,
91+
)
92+
stale = OrderProductRelation.objects.create(
93+
order=leak_candidate,
94+
product=target_product,
95+
price=99999,
96+
status=OrderProductRelation.OrderProductStatus.paid,
97+
)
98+
stale.delete()
99+
# leak_candidate 도 완료 PH 가 있어야 current_status filter 를 통과 — 그래야 누수 가능성이 노출됨.
100+
PaymentHistory.objects.create(order=leak_candidate, imp_id="leak", status=PaymentHistoryStatus.completed, price=500)
101+
102+
response = OrdersAdminApi(http_client=api_client).export(
103+
{"product_ids": [str(target_product.id)], "include_refunded": True}
104+
)
105+
assert response.status_code == HTTP_200_OK
106+
df_dict = pandas.read_excel(
107+
BytesIO(b"".join(response.streaming_content)),
108+
sheet_name=None,
109+
index_col=0,
110+
na_filter=False,
111+
dtype={"고객 전화번호": str, "PortOne ID": str},
112+
)
113+
order_ids = {row["주문 번호"] for row in df_dict["주문"].to_dict(orient="records")}
114+
assert str(normal.id) in order_ids
115+
assert str(leak_candidate.id) not in order_ids
116+
117+
118+
@pytest.mark.django_db
119+
def test_admin_list_filter_by_product_id_ignores_soft_deleted_opr(api_client, customer_user, product, order_factory):
120+
"""`?product_id=` 필터가 soft-deleted OPR 만 가진 주문을 매칭하지 않는다."""
121+
matching_order = order_factory(status="completed")
122+
123+
other_product = Product.objects.create(
124+
category=product.category,
125+
name="기타2",
126+
price=500,
127+
visible_starts_at=product.visible_starts_at,
128+
visible_ends_at=product.visible_ends_at,
129+
orderable_starts_at=product.orderable_starts_at,
130+
orderable_ends_at=product.orderable_ends_at,
131+
refundable_ends_at=product.refundable_ends_at,
132+
)
133+
leak_candidate = Order.objects.create(user=customer_user, name="leak")
134+
OrderProductRelation.objects.create(
135+
order=leak_candidate,
136+
product=other_product,
137+
price=500,
138+
status=OrderProductRelation.OrderProductStatus.paid,
139+
)
140+
stale = OrderProductRelation.objects.create(
141+
order=leak_candidate, product=product, price=99999, status=OrderProductRelation.OrderProductStatus.paid
142+
)
143+
stale.delete()
144+
PaymentHistory.objects.create(order=leak_candidate, imp_id="leak", status=PaymentHistoryStatus.completed, price=500)
145+
146+
response = OrdersAdminApi(http_client=api_client).list({"product_id": str(product.id)})
147+
assert response.status_code == HTTP_200_OK
148+
returned_ids = {row["id"] for row in response.json()["results"]}
149+
assert str(matching_order.id) in returned_ids
150+
assert str(leak_candidate.id) not in returned_ids

app/admin_api/views/shop/order_notifications.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ class OrderNotificationAdminViewSet(JsonSchemaViewSet, viewsets.GenericViewSet):
3434
.filter(current_status__in=REFUNDABLE_STATUSES)
3535
.select_related("customer_info")
3636
.prefetch_related(
37-
Order.prefetchs["_payment_histories_by_latest"],
37+
Order.prefetchs["_active_payment_histories"],
3838
models.Prefetch(
3939
"products",
4040
queryset=OrderProductRelation.objects.filter_active().prefetch_related(

app/admin_api/views/shop/orders.py

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
def _payment_history_created_at_subquery(*, latest: bool) -> models.Subquery:
5050
"""Order 별 첫/마지막 PaymentHistory.created_at scalar subquery (filter/annotate 양쪽 용)."""
5151
return models.Subquery(
52-
PaymentHistory.objects.filter(order_id=models.OuterRef("pk"))
52+
PaymentHistory.objects.filter_active()
53+
.filter(order_id=models.OuterRef("pk"))
5354
.order_by("-created_at" if latest else "created_at")
5455
.values("created_at")[:1]
5556
)
@@ -70,7 +71,7 @@ class OrderAdminViewSet(
7071
permission_classes = [IsSuperUser]
7172
queryset = (
7273
Order.objects.filter_has_payment_histories()
73-
.filter(models.Exists(OrderProductRelation.objects.filter(order=models.OuterRef("pk"))))
74+
.filter(models.Exists(OrderProductRelation.objects.filter_active().filter(order=models.OuterRef("pk"))))
7475
.select_related_with_user("user", "customer_info")
7576
.prefetch_related(
7677
models.Prefetch("products", queryset=_OPR_PREFETCH_QS),
@@ -118,7 +119,7 @@ def refund_product(
118119
pk: typing.Any = None,
119120
rel_id: typing.Any = None,
120121
) -> response.Response:
121-
order_product_rel = OrderProductRelation.objects.filter(order_id=pk, id=rel_id).first()
122+
order_product_rel = OrderProductRelation.objects.filter_active().filter(order_id=pk, id=rel_id).first()
122123
if not order_product_rel:
123124
raise exceptions.NotFound("OrderProductRelation not found.")
124125

@@ -208,15 +209,31 @@ def export(self, request: request.Request) -> StreamingHttpResponse:
208209
statuses = PURCHASED_STATUSES if include_refunded else REFUNDABLE_STATUSES
209210

210211
order_qs = (
211-
Order.objects.annotate(current_status=PaymentHistory.objects.latest_per_order_field("status"))
212+
Order.objects.filter_active()
213+
.annotate(current_status=PaymentHistory.objects.latest_per_order_field("status"))
212214
.select_related("user")
213-
.prefetch_related("products", "payment_histories")
214-
.filter(products__product_id__in=product_ids, current_status__in=statuses)
215+
.with_dto_prefetches()
216+
.filter(
217+
models.Exists(
218+
OrderProductRelation.objects.filter_active().filter(
219+
order_id=models.OuterRef("pk"), product_id__in=product_ids
220+
)
221+
),
222+
current_status__in=statuses,
223+
)
215224
)
216225
order_product_qs = (
217-
OrderProductRelation.objects.filter(order__in=order_qs)
226+
OrderProductRelation.objects.filter_active()
227+
.filter(order__in=order_qs)
218228
.select_related("product")
219-
.prefetch_related("options__product_option_group", "options__product_option")
229+
.prefetch_related(
230+
models.Prefetch(
231+
"options",
232+
queryset=OrderProductOptionRelation.objects.filter_active().select_related(
233+
"product_option_group", "product_option"
234+
),
235+
),
236+
)
220237
.distinct()
221238
)
222239

app/internal_api/filters.py

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -28,35 +28,49 @@ def filter_by_category_groups(self, qs: OrderQuerySet, name: str, values: list[s
2828
if not (filtered_values := [v.strip() for v in values if v.strip()]):
2929
return qs
3030

31-
opor_order_qs = OrderProductRelation.objects.filter(
32-
product__category__group__name__in=filtered_values,
33-
).values_list("order_id", flat=True)
31+
opor_order_qs = (
32+
OrderProductRelation.objects.filter_active()
33+
.filter(
34+
product__category__group__name__in=filtered_values,
35+
)
36+
.values_list("order_id", flat=True)
37+
)
3438

3539
return qs.filter(id__in=opor_order_qs)
3640

3741
def filter_by_categories(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet:
3842
if not (filtered_values := [v.strip() for v in values if v.strip()]):
3943
return qs
4044

41-
opor_order_qs = OrderProductRelation.objects.filter(
42-
product__category__name__in=filtered_values,
43-
).values_list("order_id", flat=True)
45+
opor_order_qs = (
46+
OrderProductRelation.objects.filter_active()
47+
.filter(
48+
product__category__name__in=filtered_values,
49+
)
50+
.values_list("order_id", flat=True)
51+
)
4452

4553
return qs.filter(id__in=opor_order_qs)
4654

4755
def filter_by_order_product_relation_id(self, qs: OrderQuerySet, name: str, value: str) -> OrderQuerySet:
4856
if not value:
4957
return qs
5058

51-
return qs.filter(id__in=OrderProductRelation.objects.filter(id=value).values_list("order_id", flat=True))
59+
return qs.filter(
60+
id__in=OrderProductRelation.objects.filter_active().filter(id=value).values_list("order_id", flat=True)
61+
)
5262

5363
def filter_by_keywords(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet:
5464
if not (filtered_values := [v.strip() for v in values if v.strip()]):
5565
return qs
5666

57-
opor_order_qs = OrderProductOptionRelation.objects.filter(
58-
custom_response__in=filtered_values,
59-
).values_list("order_product_relation__order_id", flat=True)
67+
opor_order_qs = (
68+
OrderProductOptionRelation.objects.filter_active()
69+
.filter(
70+
custom_response__in=filtered_values,
71+
)
72+
.values_list("order_product_relation__order_id", flat=True)
73+
)
6074
ci_order_qs = CustomerInfo.objects.filter(
6175
models.Q(name__in=filtered_values)
6276
| models.Q(email__in=filtered_values)

app/internal_api/views.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,8 @@ class DeskSupportViewSet(
7171
),
7272
models.Prefetch(
7373
"payment_histories",
74-
queryset=PaymentHistory.objects.filter_active().order_by("-created_at"),
75-
to_attr="_payment_histories_by_latest",
74+
queryset=PaymentHistory.objects.filter_active(),
75+
to_attr="_active_payment_histories",
7676
),
7777
)
7878
.order_by("-created_at")

app/shop/order/exports.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ class Meta:
6666
def to_representation(self, instance: OrderProductRelation) -> dict[str, typing.Any]:
6767
result: dict[str, typing.Any] = super().to_representation(instance)
6868

69-
options: collections.abc.Iterable[OrderProductOptionRelation] = instance.options.all()
69+
options: collections.abc.Iterable[OrderProductOptionRelation] = instance.options.filter_active()
7070
for option in options:
7171
option_group: OptionGroup = option.product_option_group
7272
selected_option: Option = option.product_option

0 commit comments

Comments
 (0)