Skip to content

Commit e9f0529

Browse files
committed
Merge branch 'feat/option-group-and-period-validation'
# Conflicts: # app/admin_api/test/shop/products_api_test.py
2 parents d64198d + 5247318 commit e9f0529

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
@@ -9,6 +9,7 @@
99
NestedFieldSpec,
1010
NestedModelSerializer,
1111
)
12+
from core.util.timespan import TimeSpan
1213
from file.models import PublicFile
1314
from rest_framework import serializers
1415
from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag
@@ -83,6 +84,11 @@ class Meta:
8384
"name_en",
8485
"min_quantity_per_product",
8586
"max_quantity_per_product",
87+
"max_quantity_per_user",
88+
"visible_starts_at",
89+
"visible_ends_at",
90+
"orderable_starts_at",
91+
"orderable_ends_at",
8692
"is_custom_response",
8793
"custom_response_pattern",
8894
"response_modifiable_ends_at",
@@ -114,8 +120,65 @@ def validate(self, attrs: dict) -> dict:
114120
raise serializers.ValidationError(
115121
{"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."}
116122
)
123+
124+
if errors := self._validate_period(attrs):
125+
raise serializers.ValidationError(errors)
117126
return attrs
118127

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

120183
class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
121184
option_groups = OptionGroupAdminSerializer(many=True, read_only=True)
@@ -158,22 +221,27 @@ class Meta:
158221
)
159222

160223
def validate(self, attrs: dict) -> dict:
224+
if errors := self._validate_period(attrs):
225+
raise serializers.ValidationError(errors)
226+
return attrs
227+
228+
def _validate_period(self, attrs: dict) -> dict[str, str]:
229+
"""visible/orderable 윈도우 검증 — partial update 대비 merged 패턴."""
161230
merged = {**attrs}
162231
if self.instance is not None:
163232
for field in ("visible_starts_at", "visible_ends_at", "orderable_starts_at", "orderable_ends_at"):
164233
merged.setdefault(field, getattr(self.instance, field, None))
165234

166-
v_start = merged.get("visible_starts_at")
167-
v_end = merged.get("visible_ends_at")
168-
o_start = merged.get("orderable_starts_at")
169-
o_end = merged.get("orderable_ends_at")
235+
visible = TimeSpan(merged.get("visible_starts_at"), merged.get("visible_ends_at"))
236+
orderable = TimeSpan(merged.get("orderable_starts_at"), merged.get("orderable_ends_at"))
170237

171238
errors: dict[str, str] = {}
172-
if v_start and o_start and o_start < v_start:
239+
if visible.is_inverted:
240+
errors["visible_starts_at"] = "노출 시작은 노출 종료 이전이어야 합니다."
241+
if orderable.is_inverted:
242+
errors["orderable_starts_at"] = "판매 시작은 판매 종료 이전이어야 합니다."
243+
if orderable.starts_before(visible):
173244
errors["orderable_starts_at"] = "판매 시작은 노출 시작 이후여야 합니다."
174-
if v_end and o_end and o_end > v_end:
245+
if orderable.ends_after(visible):
175246
errors["orderable_ends_at"] = "판매 종료는 노출 종료 이전이어야 합니다."
176-
177-
if errors:
178-
raise serializers.ValidationError(errors)
179-
return attrs
247+
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 사용 → 성공.
@@ -159,6 +180,183 @@ def test_admin_option_group_create_rejects_invalid_regex_pattern(api_client, pro
159180
assert "custom_response_pattern" in str(response.json())
160181

161182

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