Skip to content

Commit 5247318

Browse files
committed
feat: 옵션 그룹의 기간 제한 추가 및 다중 구매 시의 검증 누락을 수정
1 parent 4595335 commit 5247318

16 files changed

Lines changed: 1260 additions & 313 deletions

app/admin_api/serializers/shop/products.py

Lines changed: 78 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
NestedFieldSpec,
88
NestedModelSerializer,
99
)
10+
from core.util.timespan import TimeSpan
1011
from file.models import PublicFile
1112
from rest_framework import serializers
1213
from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag
@@ -81,6 +82,11 @@ class Meta:
8182
"name_en",
8283
"min_quantity_per_product",
8384
"max_quantity_per_product",
85+
"max_quantity_per_user",
86+
"visible_starts_at",
87+
"visible_ends_at",
88+
"orderable_starts_at",
89+
"orderable_ends_at",
8490
"is_custom_response",
8591
"custom_response_pattern",
8692
"response_modifiable_ends_at",
@@ -104,8 +110,65 @@ def validate(self, attrs: dict) -> dict:
104110
raise serializers.ValidationError(
105111
{"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."}
106112
)
113+
114+
if errors := self._validate_period(attrs):
115+
raise serializers.ValidationError(errors)
107116
return attrs
108117

118+
# 메시지 템플릿 — visible / orderable 둘 다 같은 구조라 label 만 치환.
119+
_GROUP_PERIOD_LABELS = (("visible", "노출"), ("orderable", "판매"))
120+
121+
def _validate_period(self, attrs: dict) -> dict[str, str]:
122+
merged = {**attrs}
123+
for field in (
124+
"visible_starts_at",
125+
"visible_ends_at",
126+
"orderable_starts_at",
127+
"orderable_ends_at",
128+
"min_quantity_per_product",
129+
"product",
130+
):
131+
if self.instance is not None:
132+
merged.setdefault(field, getattr(self.instance, field, None))
133+
134+
product: Product = merged["product"]
135+
min_qty = merged.get("min_quantity_per_product") or 0
136+
137+
errors: dict[str, str] = {}
138+
for kind, label in self._GROUP_PERIOD_LABELS:
139+
span = TimeSpan(merged.get(f"{kind}_starts_at"), merged.get(f"{kind}_ends_at"))
140+
parent = getattr(product, f"{kind}_period")
141+
errors.update(self._check_group_span(span, parent, kind=kind, label=label))
142+
if min_qty >= 1:
143+
# 필수 옵션 그룹은 상품 기간과 동기되어야 한다 — 그룹 starts_at 이 늦거나 ends_at 이 일찍이면
144+
# 상품은 노출/판매되는데 필수 옵션을 채울 수 없는 죽은 구간이 생기므로 그룹 단위 윈도우 지정 금지.
145+
if span.starts_at is not None:
146+
msg = f"필수 옵션 그룹의 {label} 시작은 별도로 지정할 수 없습니다 (상품 기준)."
147+
errors[f"{kind}_starts_at"] = msg
148+
if span.ends_at is not None:
149+
msg = f"필수 옵션 그룹의 {label} 종료는 별도로 지정할 수 없습니다 (상품 기준)."
150+
errors[f"{kind}_ends_at"] = msg
151+
152+
return errors
153+
154+
@staticmethod
155+
def _check_group_span(span: TimeSpan, parent: TimeSpan, *, kind: str, label: str) -> dict[str, str]:
156+
# 그룹의 visible/orderable 윈도우 검증 — 자기 inverted + 상품 윈도우 안 포함 + 한 boundary fallback inverted.
157+
errors: dict[str, str] = {}
158+
if span.is_inverted:
159+
errors[f"{kind}_starts_at"] = f"옵션 그룹의 {label} 시작은 {label} 종료 이전이어야 합니다."
160+
if span.starts_before(parent):
161+
errors[f"{kind}_starts_at"] = f"옵션 그룹의 {label} 시작은 상품 {label} 시작 이후여야 합니다."
162+
if span.ends_after(parent):
163+
errors[f"{kind}_ends_at"] = f"옵션 그룹의 {label} 종료는 상품 {label} 종료 이전이어야 합니다."
164+
# 한 쪽 boundary 만 명시하면 model effective_*_period (None → product fallback) 가 inverted 될 수 있다.
165+
# 예: product=[2020,2099], group ends_at=2019 → effective=[2020,2019]. admin 이 잡는다.
166+
if span.ends_at and span.ends_at < parent.effective_starts_at:
167+
errors[f"{kind}_ends_at"] = f"옵션 그룹의 {label} 종료는 상품 {label} 시작 이후여야 합니다."
168+
if span.starts_at and span.starts_at > parent.effective_ends_at:
169+
errors[f"{kind}_starts_at"] = f"옵션 그룹의 {label} 시작은 상품 {label} 종료 이전이어야 합니다."
170+
return errors
171+
109172

110173
class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
111174
option_groups = OptionGroupAdminSerializer(many=True, read_only=True)
@@ -148,22 +211,27 @@ class Meta:
148211
)
149212

150213
def validate(self, attrs: dict) -> dict:
214+
if errors := self._validate_period(attrs):
215+
raise serializers.ValidationError(errors)
216+
return attrs
217+
218+
def _validate_period(self, attrs: dict) -> dict[str, str]:
219+
"""visible/orderable 윈도우 검증 — partial update 대비 merged 패턴."""
151220
merged = {**attrs}
152221
if self.instance is not None:
153222
for field in ("visible_starts_at", "visible_ends_at", "orderable_starts_at", "orderable_ends_at"):
154223
merged.setdefault(field, getattr(self.instance, field, None))
155224

156-
v_start = merged.get("visible_starts_at")
157-
v_end = merged.get("visible_ends_at")
158-
o_start = merged.get("orderable_starts_at")
159-
o_end = merged.get("orderable_ends_at")
225+
visible = TimeSpan(merged.get("visible_starts_at"), merged.get("visible_ends_at"))
226+
orderable = TimeSpan(merged.get("orderable_starts_at"), merged.get("orderable_ends_at"))
160227

161228
errors: dict[str, str] = {}
162-
if v_start and o_start and o_start < v_start:
229+
if visible.is_inverted:
230+
errors["visible_starts_at"] = "노출 시작은 노출 종료 이전이어야 합니다."
231+
if orderable.is_inverted:
232+
errors["orderable_starts_at"] = "판매 시작은 판매 종료 이전이어야 합니다."
233+
if orderable.starts_before(visible):
163234
errors["orderable_starts_at"] = "판매 시작은 노출 시작 이후여야 합니다."
164-
if v_end and o_end and o_end > v_end:
235+
if orderable.ends_after(visible):
165236
errors["orderable_ends_at"] = "판매 종료는 노출 종료 이전이어야 합니다."
166-
167-
if errors:
168-
raise serializers.ValidationError(errors)
169-
return attrs
237+
return errors

app/admin_api/test/shop/products_api_test.py

Lines changed: 199 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
HTTP_403_FORBIDDEN,
1010
)
1111
from shop.conftest import FAR_FUTURE, FAR_PAST
12-
from shop.product.models import Category, CategoryGroup, Product, Tag
12+
from shop.product.models import Category, CategoryGroup, OptionGroup, Product, Tag
1313
from shop.test.helpers import CategoryGroupsAdminApi, OptionGroupsAdminApi, ProductsAdminApi, TagsAdminApi
1414

1515

@@ -95,6 +95,27 @@ def test_admin_product_partial_update_validates_orderable_before_visible_end(api
9595
assert "orderable_ends_at" in str(response.json())
9696

9797

98+
@pytest.mark.django_db
99+
def test_admin_product_partial_update_rejects_inverted_visible_window(api_client, product):
100+
# visible_starts_at(2100) > visible_ends_at(fixture default FAR_FUTURE=2099) → 400.
101+
response = ProductsAdminApi(http_client=api_client).update(
102+
product.id, {"visible_starts_at": datetime(2100, 1, 1, tzinfo=timezone.utc).isoformat()}
103+
)
104+
assert response.status_code == HTTP_400_BAD_REQUEST
105+
assert "visible_starts_at" in str(response.json())
106+
107+
108+
@pytest.mark.django_db
109+
def test_admin_product_partial_update_rejects_inverted_orderable_window(api_client, product):
110+
# orderable_starts_at(2098) > orderable_ends_at(fixture default FAR_FUTURE=2099) 인 케이스를 만들기 위해
111+
# ends_at 을 starts_at 보다 앞으로 patch — orderable_ends_at(2010) < orderable_starts_at(FAR_PAST=2020).
112+
response = ProductsAdminApi(http_client=api_client).update(
113+
product.id, {"orderable_ends_at": datetime(2010, 1, 1, tzinfo=timezone.utc).isoformat()}
114+
)
115+
assert response.status_code == HTTP_400_BAD_REQUEST
116+
assert "orderable_starts_at" in str(response.json())
117+
118+
98119
@pytest.mark.django_db
99120
def test_admin_product_partial_update_merged_uses_instance_value_for_missing_field(api_client, product):
100121
# patch 에 visible_starts_at 만 보내고 orderable_* 미포함 → merged 가 instance 값 fallback 사용 → 성공.
@@ -143,6 +164,183 @@ def test_admin_option_group_create_rejects_custom_response_without_pattern(api_c
143164
assert "custom_response_pattern" in str(response.json())
144165

145166

167+
@pytest.mark.django_db
168+
def test_admin_option_group_create_rejects_orderable_starts_before_product_starts(api_client, product):
169+
# product.orderable_starts_at(FAR_PAST=2020) 보다 앞 (2019) → 거절.
170+
response = OptionGroupsAdminApi(http_client=api_client).create(
171+
{
172+
"product": str(product.id),
173+
"name_ko": "얼리버드",
174+
"name_en": "Earlybird",
175+
"orderable_starts_at": datetime(2019, 1, 1, tzinfo=timezone.utc).isoformat(),
176+
}
177+
)
178+
assert response.status_code == HTTP_400_BAD_REQUEST
179+
assert "orderable_starts_at" in str(response.json())
180+
181+
182+
@pytest.mark.django_db
183+
def test_admin_option_group_create_rejects_orderable_ends_after_product_ends(api_client, product):
184+
# product.orderable_ends_at(FAR_FUTURE=2099) 보다 뒤 (2100) → 거절.
185+
response = OptionGroupsAdminApi(http_client=api_client).create(
186+
{
187+
"product": str(product.id),
188+
"name_ko": "후반",
189+
"name_en": "Late",
190+
"orderable_ends_at": datetime(2100, 1, 1, tzinfo=timezone.utc).isoformat(),
191+
}
192+
)
193+
assert response.status_code == HTTP_400_BAD_REQUEST
194+
assert "orderable_ends_at" in str(response.json())
195+
196+
197+
@pytest.mark.parametrize("kind", ["visible", "orderable"])
198+
@pytest.mark.django_db
199+
def test_admin_option_group_create_rejects_inverted_window(api_client, product, kind):
200+
response = OptionGroupsAdminApi(http_client=api_client).create(
201+
{
202+
"product": str(product.id),
203+
"name_ko": "옵션",
204+
"name_en": "Opt",
205+
f"{kind}_starts_at": datetime(2050, 1, 1, tzinfo=timezone.utc).isoformat(),
206+
f"{kind}_ends_at": datetime(2030, 1, 1, tzinfo=timezone.utc).isoformat(),
207+
}
208+
)
209+
assert response.status_code == HTTP_400_BAD_REQUEST
210+
assert f"{kind}_starts_at" in str(response.json())
211+
212+
213+
@pytest.mark.parametrize("kind", ["visible", "orderable"])
214+
@pytest.mark.django_db
215+
def test_admin_option_group_create_rejects_starts_before_product_starts(api_client, product, kind):
216+
# group_starts_at < product_starts_at (FAR_PAST=2020) → 거절. visible / orderable 동일 분기.
217+
response = OptionGroupsAdminApi(http_client=api_client).create(
218+
{
219+
"product": str(product.id),
220+
"name_ko": "옵션",
221+
"name_en": "Opt",
222+
f"{kind}_starts_at": datetime(2019, 1, 1, tzinfo=timezone.utc).isoformat(),
223+
}
224+
)
225+
assert response.status_code == HTTP_400_BAD_REQUEST
226+
assert f"{kind}_starts_at" in str(response.json())
227+
228+
229+
@pytest.mark.parametrize("kind", ["visible", "orderable"])
230+
@pytest.mark.django_db
231+
def test_admin_option_group_create_rejects_ends_after_product_ends(api_client, product, kind):
232+
# group_ends_at > product_ends_at (FAR_FUTURE=2099) → 거절.
233+
response = OptionGroupsAdminApi(http_client=api_client).create(
234+
{
235+
"product": str(product.id),
236+
"name_ko": "옵션",
237+
"name_en": "Opt",
238+
f"{kind}_ends_at": datetime(2100, 1, 1, tzinfo=timezone.utc).isoformat(),
239+
}
240+
)
241+
assert response.status_code == HTTP_400_BAD_REQUEST
242+
assert f"{kind}_ends_at" in str(response.json())
243+
244+
245+
@pytest.mark.django_db
246+
def test_admin_option_group_create_allows_period_within_product_window(api_client, product):
247+
response = OptionGroupsAdminApi(http_client=api_client).create(
248+
{
249+
"product": str(product.id),
250+
"name_ko": "얼리버드",
251+
"name_en": "Earlybird",
252+
"orderable_starts_at": datetime(2030, 1, 1, tzinfo=timezone.utc).isoformat(),
253+
"orderable_ends_at": datetime(2031, 1, 1, tzinfo=timezone.utc).isoformat(),
254+
}
255+
)
256+
assert response.status_code == HTTP_201_CREATED
257+
258+
259+
@pytest.mark.parametrize(
260+
"window_field",
261+
["visible_starts_at", "visible_ends_at", "orderable_starts_at", "orderable_ends_at"],
262+
)
263+
@pytest.mark.django_db
264+
def test_admin_option_group_create_rejects_required_group_with_explicit_window(api_client, product, window_field):
265+
# min_quantity_per_product >= 1 인 필수 그룹은 visible/orderable starts_at/ends_at 을 별도 지정할 수 없음 —
266+
# 그룹 윈도우가 상품과 어긋나면 필수 옵션이 비어 상품을 살 수 없는 죽은 구간이 생긴다.
267+
response = OptionGroupsAdminApi(http_client=api_client).create(
268+
{
269+
"product": str(product.id),
270+
"name_ko": "필수옵션",
271+
"name_en": "Required",
272+
"min_quantity_per_product": 1,
273+
window_field: datetime(2030, 1, 1, tzinfo=timezone.utc).isoformat(),
274+
}
275+
)
276+
assert response.status_code == HTTP_400_BAD_REQUEST
277+
assert window_field in str(response.json())
278+
279+
280+
@pytest.mark.parametrize(
281+
"window_field",
282+
["visible_starts_at", "visible_ends_at", "orderable_starts_at", "orderable_ends_at"],
283+
)
284+
@pytest.mark.django_db
285+
def test_admin_option_group_partial_update_rejects_setting_min_quantity_when_window_already_set(
286+
api_client, product, window_field
287+
):
288+
# 역방향 — 윈도우가 이미 설정된 그룹을 min_quantity_per_product>=1 로 patch → merged 가 instance 윈도우를 사용해 거절.
289+
group = OptionGroup.objects.create(
290+
product=product, name="기간옵션", **{window_field: datetime(2030, 1, 1, tzinfo=timezone.utc)}
291+
)
292+
response = OptionGroupsAdminApi(http_client=api_client).update(group.id, {"min_quantity_per_product": 1})
293+
assert response.status_code == HTTP_400_BAD_REQUEST
294+
assert window_field in str(response.json())
295+
296+
297+
@pytest.mark.parametrize("kind", ["visible", "orderable"])
298+
@pytest.mark.django_db
299+
def test_admin_option_group_create_rejects_ends_at_before_product_starts_at(api_client, product, kind):
300+
# P2-A: 한 쪽 boundary 만 명시 — starts_at=None → product fallback(FAR_PAST=2020), ends_at=2019.
301+
# admin 이 model effective_*_period 의 inverted 케이스를 차단해야 함.
302+
response = OptionGroupsAdminApi(http_client=api_client).create(
303+
{
304+
"product": str(product.id),
305+
"name_ko": "옵션",
306+
"name_en": "Opt",
307+
f"{kind}_ends_at": datetime(2019, 1, 1, tzinfo=timezone.utc).isoformat(),
308+
}
309+
)
310+
assert response.status_code == HTTP_400_BAD_REQUEST
311+
assert f"{kind}_ends_at" in str(response.json())
312+
313+
314+
@pytest.mark.parametrize("kind", ["visible", "orderable"])
315+
@pytest.mark.django_db
316+
def test_admin_option_group_create_rejects_starts_at_after_product_ends_at(api_client, product, kind):
317+
# P2-A: starts_at=2100 > product.*_ends_at(FAR_FUTURE=2099) → effective inverted.
318+
response = OptionGroupsAdminApi(http_client=api_client).create(
319+
{
320+
"product": str(product.id),
321+
"name_ko": "옵션",
322+
"name_en": "Opt",
323+
f"{kind}_starts_at": datetime(2100, 1, 1, tzinfo=timezone.utc).isoformat(),
324+
}
325+
)
326+
assert response.status_code == HTTP_400_BAD_REQUEST
327+
assert f"{kind}_starts_at" in str(response.json())
328+
329+
330+
@pytest.mark.django_db
331+
def test_admin_option_group_create_allows_non_required_group_with_explicit_window(api_client, product):
332+
# 비필수 그룹(min_quantity_per_product=0) 은 starts_at 명시 가능.
333+
response = OptionGroupsAdminApi(http_client=api_client).create(
334+
{
335+
"product": str(product.id),
336+
"name_ko": "선택옵션",
337+
"name_en": "Optional",
338+
"orderable_starts_at": datetime(2030, 1, 1, tzinfo=timezone.utc).isoformat(),
339+
}
340+
)
341+
assert response.status_code == HTTP_201_CREATED
342+
343+
146344
@pytest.mark.parametrize("status", list(Product.CurrentStatus))
147345
@pytest.mark.django_db
148346
def test_admin_product_list_filters_by_status(api_client, products_by_status, status):

app/core/const/shop_error_messages.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@ class OptionGroupNotOrderableErrorMessages:
4040
SOLDOUT = "{} 상품의 필수 구매 옵션인 '{}' 옵션이 매진되어 상품을 구매하실 수 없습니다."
4141
NOT_ENOUGH_OPTION = "{} 상품의 필수 구매 옵션인 '{}' 옵션을 선택해주세요."
4242
TOO_MUCH_OPTION = "{} 상품의 '{}' 옵션을 너무 많이 선택하셨습니다."
43+
ALREADY_ORDERED_TOO_MUCH = "{} 상품 '{}' 옵션 그룹의 인당 최대 선택 수량 초과로 구매하실 수 없습니다."
44+
NOT_ORDERABLE_TIME = "{} 상품의 '{}' 옵션 그룹은 현재 구매하실 수 없습니다."
4345

4446

4547
class OptionNotOrderableErrorMessages:

0 commit comments

Comments
 (0)