Skip to content

Commit ce17fb3

Browse files
committed
feat: choices API에서 필터링을 위한 meta 추가
1 parent 337401d commit ce17fb3

8 files changed

Lines changed: 115 additions & 11 deletions

File tree

‎app/admin_api/test/shop/categories_api_test.py‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
LIST_URL = reverse("v1:admin-shop-category-list")
1111
CHOICES_URL = LIST_URL + "choices/"
12+
JSON_SCHEMA_URL = LIST_URL + "json-schema/"
1213

1314

1415
@pytest.fixture
@@ -51,6 +52,28 @@ def test_category_choices_returns_group_and_event(api_client, category_fixtures)
5152
assert str(category_fixtures["event"].id) in {c["const"] for c in data["event"]}
5253

5354

55+
@pytest.mark.django_db
56+
def test_category_choices_include_meta_for_event(api_client, category_fixtures):
57+
# Event 모델이 choices_meta_schema/get_choice_meta 를 정의하므로 event choice 에 meta 가 붙어야 한다.
58+
response = api_client.get(CHOICES_URL)
59+
assert response.status_code == HTTP_200_OK
60+
event_choices = {c["const"]: c for c in response.json()["event"] if c["const"] is not None}
61+
event_meta = event_choices[str(category_fixtures["event"].id)]["meta"]
62+
assert event_meta["organization"] == "Org"
63+
assert event_meta["started_at"] == "2026-08-01"
64+
65+
66+
@pytest.mark.django_db
67+
def test_category_json_schema_exposes_choice_meta_schema(api_client, category_fixtures):
68+
response = api_client.get(JSON_SCHEMA_URL)
69+
assert response.status_code == HTTP_200_OK
70+
ui_schema = response.json()["ui_schema"]
71+
meta_schema = ui_schema["event"]["ui:options"]["choiceMetaSchema"]
72+
assert meta_schema["organization"]["label"] == "조직"
73+
# meta 정의가 없는 대상(CategoryGroup)에는 choiceMetaSchema 가 붙지 않는다.
74+
assert "choiceMetaSchema" not in ui_schema.get("group", {}).get("ui:options", {})
75+
76+
5477
@pytest.mark.django_db
5578
def test_category_filter_by_group(api_client, category_fixtures):
5679
response = api_client.get(LIST_URL, {"group": str(category_fixtures["g2026"].id)})

‎app/core/models.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def get_choices_queryset(self) -> typing.Self:
4343

4444
class BaseAbstractModel(models.Model):
4545
choices_select_related: typing.ClassVar[tuple[str, ...]] = ()
46+
choices_meta_schema: typing.ClassVar[dict[str, dict[str, str]]] = {}
4647

4748
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
4849

‎app/core/openapi/ui_hints.py‎

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,19 @@
11
from core.models import MarkdownField
22
from django.db.models.fields import TextField
33
from django.db.models.fields.files import FileField
4-
from django.db.models.fields.related import ManyToManyField
4+
from django.db.models.fields.related import ForeignKey, ManyToManyField
55
from modeltranslation.fields import TranslationField
66

77

8-
def ui_hints_for_model_field(model_field: object) -> dict[str, str]:
8+
def ui_hints_for_model_field(model_field: object) -> dict:
9+
hints: dict = {}
10+
11+
if isinstance(model_field, (ForeignKey, ManyToManyField)):
12+
if meta_schema := getattr(model_field.related_model, "choices_meta_schema", None):
13+
hints["ui:options"] = {"choiceMetaSchema": meta_schema}
14+
915
if isinstance(model_field, ManyToManyField):
10-
return {"ui:field": "m2m_select"}
16+
return hints | {"ui:field": "m2m_select"}
1117
if isinstance(model_field, FileField):
1218
return {"ui:field": "file"}
1319
if isinstance(model_field, TranslationField):
@@ -16,4 +22,4 @@ def ui_hints_for_model_field(model_field: object) -> dict[str, str]:
1622
return {"ui:widget": "textarea", "ui:field": "markdown"}
1723
if isinstance(model_field, TextField):
1824
return {"ui:widget": "textarea"}
19-
return {}
25+
return hints

‎app/core/viewset/json_schema_viewset.py‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ def __new__(cls, *args: tuple, **kwargs: dict) -> JsonSchemaViewSet:
2121
return super().__new__(cls)
2222

2323
@staticmethod
24-
def _get_choices_from_queryset(qs: QuerySet, is_nullable: bool) -> list[dict[str, str]]:
25-
choices: list[dict[str, str]] = [{"const": None, "title": "빈 값"}] if is_nullable else []
24+
def _get_choices_from_queryset(qs: QuerySet, is_nullable: bool) -> list[dict]:
25+
choices: list[dict] = [{"const": None, "title": "빈 값"}] if is_nullable else []
2626

2727
if hasattr(qs, "get_choices_queryset"):
2828
qs = qs.get_choices_queryset()
@@ -33,7 +33,10 @@ def _get_choices_from_queryset(qs: QuerySet, is_nullable: bool) -> list[dict[str
3333
qs = qs.filter(is_active=True)
3434

3535
for row in qs:
36-
choices.append({"const": str(row.pk), "title": str(row)})
36+
item: dict = {"const": str(row.pk), "title": str(row)}
37+
if hasattr(row, "get_choice_meta") and (meta := row.get_choice_meta()):
38+
item["meta"] = meta
39+
choices.append(item)
3740

3841
return choices
3942

@@ -74,8 +77,8 @@ def _get_related_field_info(self) -> list[tuple[str, object, serializers.Field,
7477

7578
return result
7679

77-
def get_choices(self) -> dict[str, list[dict[str, str]]]:
78-
choices: dict[str, list[dict[str, str]]] = {}
80+
def get_choices(self) -> dict[str, list[dict]]:
81+
choices: dict[str, list[dict]] = {}
7982

8083
for field_name, field, serializer_field, is_m2m in self._get_related_field_info():
8184
if is_m2m:

‎app/event/models.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
1+
import typing
2+
13
from core.models import BaseAbstractModel
24
from django.core.exceptions import ValidationError
35
from django.db import models
6+
from django.utils import timezone
47
from file.models import PublicFile
58
from user.models.organization import Organization
69

710

811
class Event(BaseAbstractModel):
12+
choices_select_related = ("organization",)
13+
choices_meta_schema: typing.ClassVar[dict] = {
14+
"organization": {"label": "조직", "type": "string", "filter": "select"},
15+
"started_at": {"label": "시작일", "type": "string", "filter": "search"},
16+
}
17+
918
organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="events")
1019
name = models.CharField(max_length=256)
1120
logo = models.ForeignKey(PublicFile, on_delete=models.PROTECT, null=True, blank=True, related_name="+")
@@ -28,6 +37,12 @@ class Meta:
2837
def __str__(self):
2938
return f"{self.name} by {self.organization}"
3039

40+
def get_choice_meta(self) -> dict:
41+
return {
42+
"organization": str(self.organization),
43+
"started_at": timezone.localtime(self.event_start_at).date().isoformat() if self.event_start_at else None,
44+
}
45+
3146
def clean(self) -> None:
3247
super().clean()
3348
if self.event_start_at and self.event_end_at and self.event_start_at > self.event_end_at:

‎app/event/presentation/models.py‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import datetime
44
import uuid
55
from contextlib import suppress
6-
from typing import Self
6+
from typing import ClassVar, Self
77

88
from core.models import BaseAbstractModel, BaseAbstractModelQuerySet, MarkdownField
99
from django.contrib.auth import get_user_model
@@ -81,7 +81,11 @@ def __str__(self) -> str:
8181

8282

8383
class Presentation(BaseAbstractModel):
84-
choices_select_related = ("type",)
84+
choices_select_related = ("type", "type__event")
85+
choices_meta_schema: ClassVar[dict] = {
86+
"type": {"label": "발표 타입", "type": "string", "filter": "select"},
87+
"event": {"label": "이벤트", "type": "string", "filter": "select"},
88+
}
8589

8690
type = models.ForeignKey(PresentationType, on_delete=models.PROTECT)
8791
title = models.CharField(max_length=256)
@@ -104,6 +108,12 @@ class Presentation(BaseAbstractModel):
104108
def __str__(self) -> str:
105109
return f"[{self.type.name}] {self.title}"
106110

111+
def get_choice_meta(self) -> dict:
112+
return {
113+
"type": self.type.name,
114+
"event": self.type.event.name,
115+
}
116+
107117
def active_categories(self) -> list[PresentationCategory]:
108118
with suppress(AttributeError):
109119
return self._prefetched_active_categories

‎app/shop/product/models.py‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,13 @@ def __str__(self) -> str: # pragma: no cover
3333

3434

3535
class Category(BaseAbstractModel):
36+
choices_select_related = ("group", "event", "event__organization")
37+
choices_meta_schema: typing.ClassVar[dict] = {
38+
"is_ticket": {"label": "티켓", "type": "boolean"},
39+
"group": {"label": "그룹", "type": "string", "filter": "select"},
40+
"event": {"label": "이벤트", "type": "string", "filter": "select"},
41+
}
42+
3643
group = models.ForeignKey(CategoryGroup, on_delete=models.PROTECT)
3744
name = models.CharField(max_length=100)
3845
priority = models.IntegerField(default=0)
@@ -49,6 +56,13 @@ class Meta:
4956
def __str__(self) -> str: # pragma: no cover
5057
return f"{self.group.name} > {self.name}"
5158

59+
def get_choice_meta(self) -> dict:
60+
return {
61+
"group": self.group.name,
62+
"is_ticket": self.is_ticket,
63+
"event": str(self.event) if self.event_id else None,
64+
}
65+
5266

5367
class Tag(BaseAbstractModel):
5468
name = models.CharField(max_length=100)
@@ -119,6 +133,14 @@ class CurrentStatus(models.TextChoices):
119133
OUT_OF_ORDERABLE_PERIOD = "out_of_orderable_period", "판매 기간 아님"
120134
ACTIVE = "active", "노출 중"
121135

136+
choices_select_related = ("category", "category__group")
137+
choices_meta_schema: typing.ClassVar[dict] = {
138+
"category": {"label": "카테고리", "type": "string", "filter": "select"},
139+
"price": {"label": "가격", "type": "number"},
140+
"stock": {"label": "재고", "type": "number"},
141+
"status": {"label": "상태", "type": "string", "filter": "select"},
142+
}
143+
122144
name = models.TextField()
123145
description = models.TextField(null=True, blank=True)
124146
image = models.ForeignKey(
@@ -169,6 +191,14 @@ class Meta:
169191
def __str__(self) -> str: # pragma: no cover
170192
return f"{self.category} > {self.name} ({self.price}원)"
171193

194+
def get_choice_meta(self) -> dict:
195+
return {
196+
"category": str(self.category),
197+
"price": self.price,
198+
"stock": self.stock,
199+
"status": self.current_status.label,
200+
}
201+
172202
@property
173203
def visible_period(self) -> TimeSpan:
174204
return TimeSpan(self.visible_starts_at, self.visible_ends_at)

‎app/user/models/user.py‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import typing
34
from uuid import uuid4
45

56
from core.const.system import SYSTEM_EMAIL, SYSTEM_USERNAME
@@ -12,6 +13,13 @@ class UserExt(ScanCodeMixin, AbstractUser):
1213
scancode_prefix = "user"
1314
scancode_uuid_field = "unique_id"
1415

16+
choices_meta_schema: typing.ClassVar[dict] = {
17+
"email": {"label": "이메일", "type": "string", "filter": "search"},
18+
"nickname": {"label": "닉네임", "type": "string", "filter": "search"},
19+
"is_active": {"label": "활성", "type": "boolean"},
20+
"is_superuser": {"label": "스태프", "type": "boolean"},
21+
}
22+
1523
image = models.ForeignKey("file.PublicFile", on_delete=models.PROTECT, null=True, blank=True)
1624
nickname = models.CharField(max_length=128, null=True, blank=True)
1725
unique_id = models.UUIDField(unique=True, editable=False, null=False, blank=False, default=uuid4)
@@ -23,6 +31,14 @@ class Meta(AbstractUser.Meta):
2331
def __str__(self):
2432
return f"[User] {self.nickname} <{self.email}>"
2533

34+
def get_choice_meta(self) -> dict:
35+
return {
36+
"email": self.email,
37+
"nickname": self.nickname,
38+
"is_active": self.is_active,
39+
"is_superuser": self.is_superuser,
40+
}
41+
2642
@classmethod
2743
def get_system_user(cls) -> UserExt:
2844
return cls.objects.get_or_create(username=SYSTEM_USERNAME, email=SYSTEM_EMAIL)[0]

0 commit comments

Comments
 (0)