Skip to content

Commit 10a3f6a

Browse files
authored
Merge pull request #38 from pythonkr/feature/add-choices-api-for-admin
feat: FK/M2M 필드의 choices를 별도 API로 분리
2 parents e660b36 + b9b529e commit 10a3f6a

2 files changed

Lines changed: 88 additions & 35 deletions

File tree

.pre-commit-config.yaml

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,24 @@ repos:
5151
rev: "6.0.1"
5252
hooks:
5353
- id: isort
54-
- repo: https://github.com/dosisod/refurb
55-
rev: v2.0.0
56-
hooks:
57-
- id: refurb
58-
additional_dependencies:
59-
- boto3
60-
- django-constance
61-
- django-cors-headers
62-
- django-environ
63-
- django-extensions
64-
- django-filter
65-
- django-simple-history
66-
- django-stubs[compatible-mypy]
67-
- drf-spectacular
68-
- drf-standardized-errors
69-
- djangorestframework-stubs[compatible-mypy]
70-
- zappa-django-utils
54+
# TODO: https://github.com/dosisod/refurb/issues/372 문제 해소 후 uncomment 필요
55+
# - repo: https://github.com/dosisod/refurb
56+
# rev: v2.0.0
57+
# hooks:
58+
# - id: refurb
59+
# additional_dependencies:
60+
# - boto3
61+
# - django-constance
62+
# - django-cors-headers
63+
# - django-environ
64+
# - django-extensions
65+
# - django-filter
66+
# - django-simple-history
67+
# - django-stubs[compatible-mypy]
68+
# - drf-spectacular
69+
# - drf-standardized-errors
70+
# - djangorestframework-stubs[compatible-mypy]
71+
# - zappa-django-utils
7172
- repo: https://github.com/astral-sh/uv-pre-commit
7273
rev: 0.6.12
7374
hooks:

app/core/viewset/json_schema_viewset.py

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

3-
import functools
43
import typing
54

65
from core.const.tag import OpenAPITag
@@ -23,26 +22,74 @@ def __new__(cls, *args: tuple, **kwargs: dict) -> JsonSchemaViewSet:
2322
return super().__new__(cls)
2423

2524
@staticmethod
26-
@functools.lru_cache
27-
def get_enum_values(model_qs: QuerySet, is_nullable: bool) -> list[dict[str, str]]:
28-
enum_values: list[dict[str, str]] = [{"const": None, "title": "빈 값"}] if is_nullable else []
29-
30-
qs = model_qs.all()
31-
if hasattr(qs, "filter_active"):
32-
qs = qs.filter_active()
33-
elif hasattr(model_qs.model, "is_active"):
34-
qs = qs.filter(is_active=True)
25+
def _get_choices_from_queryset(qs: QuerySet, is_nullable: bool) -> list[dict[str, str]]:
26+
choices: list[dict[str, str]] = [{"const": None, "title": "빈 값"}] if is_nullable else []
27+
28+
related_model = qs.model
29+
if hasattr(related_model, "get_choices_queryset"):
30+
qs = related_model.get_choices_queryset()
31+
else:
32+
qs = qs.all()
33+
if hasattr(qs, "filter_active"):
34+
qs = qs.filter_active()
35+
elif hasattr(related_model, "is_active"):
36+
qs = qs.filter(is_active=True)
3537

3638
for row in qs:
37-
enum_values.append({"const": str(row.pk), "title": str(row)})
39+
choices.append({"const": str(row.pk), "title": str(row)})
3840

39-
return enum_values
41+
return choices
4042

4143
@staticmethod
4244
def set_ui_schema(ui_schema: dict, field_name: str, data: dict) -> None:
4345
ui_schema.setdefault(field_name, {})
4446
ui_schema[field_name].update(data)
4547

48+
def _get_related_field_info(self) -> list[tuple[str, object, serializers.Field, bool]]:
49+
"""Returns list of (field_name, model_field, serializer_field, is_m2m) for FK/M2M fields."""
50+
serializer_class = typing.cast(type[JsonSchemaSerializer], self.get_serializer_class())
51+
52+
if not hasattr(serializer_class.Meta, "model"):
53+
return []
54+
55+
ser_fields: dict[str, serializers.Field] = serializer_class().fields
56+
model_fields = serializer_class.Meta.model._meta.fields
57+
model_m2m_fields = serializer_class.Meta.model._meta.many_to_many
58+
schema = serializer_class.get_json_schema()
59+
60+
result = []
61+
for field in model_fields + model_m2m_fields:
62+
if field.name not in schema.get("properties", {}) or field.name not in ser_fields:
63+
continue
64+
65+
serializer_field = ser_fields[field.name]
66+
67+
if isinstance(field, ForeignKey):
68+
s_field = typing.cast(serializers.PrimaryKeyRelatedField | None, serializer_field)
69+
if not s_field or serializer_field.read_only:
70+
continue
71+
result.append((field.name, field, serializer_field, False))
72+
elif isinstance(field, ManyToManyField):
73+
s_field = typing.cast(serializers.ManyRelatedField | None, serializer_field)
74+
if not s_field or serializer_field.read_only:
75+
continue
76+
result.append((field.name, field, serializer_field, True))
77+
78+
return result
79+
80+
def get_choices(self) -> dict[str, list[dict[str, str]]]:
81+
choices: dict[str, list[dict[str, str]]] = {}
82+
83+
for field_name, field, serializer_field, is_m2m in self._get_related_field_info():
84+
if is_m2m:
85+
qs = typing.cast(serializers.ManyRelatedField, serializer_field).child_relation.get_queryset()
86+
choices[field_name] = self._get_choices_from_queryset(qs, False)
87+
else:
88+
qs = typing.cast(serializers.PrimaryKeyRelatedField, serializer_field).get_queryset()
89+
choices[field_name] = self._get_choices_from_queryset(qs, field.null)
90+
91+
return choices
92+
4693
def get_json_schema(self) -> dict: # noqa: C901
4794
serializer_class = typing.cast(type[JsonSchemaSerializer], self.get_serializer_class())
4895

@@ -70,19 +117,15 @@ def get_json_schema(self) -> dict: # noqa: C901
70117
serializer_field = ser_fields[field.name]
71118

72119
if isinstance(field, ForeignKey):
73-
if not (s_field := typing.cast(serializers.PrimaryKeyRelatedField | None, serializer_field)):
120+
if not typing.cast(serializers.PrimaryKeyRelatedField | None, serializer_field):
74121
continue
75122
if serializer_field.read_only:
76123
continue
77-
e_values = self.get_enum_values(s_field.get_queryset(), field.null)
78-
result["schema"]["properties"][field.name]["oneOf"] = e_values
79124
elif isinstance(field, ManyToManyField):
80-
if not (s_field := typing.cast(serializers.ManyRelatedField | None, serializer_field)):
125+
if not typing.cast(serializers.ManyRelatedField | None, serializer_field):
81126
continue
82127
if serializer_field.read_only:
83128
continue
84-
e_values = self.get_enum_values(s_field.child_relation.get_queryset(), False)
85-
result["schema"]["properties"][field.name]["items"]["oneOf"] = e_values
86129
result["schema"]["properties"][field.name]["uniqueItems"] = True
87130
self.set_ui_schema(result["ui_schema"], field.name, {"ui:field": "m2m_select"})
88131
elif isinstance(field, FileField):
@@ -115,3 +158,12 @@ def get_json_schema(self) -> dict: # noqa: C901
115158
@decorators.action(detail=False, methods=["get"], url_path="json-schema")
116159
def response_json_schema(self, *args: tuple, **kwargs: dict) -> response.Response:
117160
return response.Response(data=self.get_json_schema())
161+
162+
@utils.extend_schema(
163+
tags=[OpenAPITag.ADMIN_JSON_SCHEMA],
164+
summary="Choices for related fields",
165+
responses={status.HTTP_200_OK: openapi.OpenApiResponse(response=types.OpenApiTypes.OBJECT)},
166+
)
167+
@decorators.action(detail=False, methods=["get"], url_path="choices")
168+
def response_choices(self, *args: tuple, **kwargs: dict) -> response.Response:
169+
return response.Response(data=self.get_choices())

0 commit comments

Comments
 (0)