From d299d9ea3bae70f0d6407682e67ce3002be26371 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Thu, 2 Apr 2026 21:19:53 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20FK/M2M=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=EC=9D=98=20choices=EB=A5=BC=20=EB=B3=84=EB=8F=84=20API?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/viewset/json_schema_viewset.py | 88 ++++++++++++++++++++----- 1 file changed, 70 insertions(+), 18 deletions(-) diff --git a/app/core/viewset/json_schema_viewset.py b/app/core/viewset/json_schema_viewset.py index 66259f9..a27f079 100644 --- a/app/core/viewset/json_schema_viewset.py +++ b/app/core/viewset/json_schema_viewset.py @@ -1,6 +1,5 @@ from __future__ import annotations -import functools import typing from core.const.tag import OpenAPITag @@ -23,26 +22,74 @@ def __new__(cls, *args: tuple, **kwargs: dict) -> JsonSchemaViewSet: return super().__new__(cls) @staticmethod - @functools.lru_cache - def get_enum_values(model_qs: QuerySet, is_nullable: bool) -> list[dict[str, str]]: - enum_values: list[dict[str, str]] = [{"const": None, "title": "빈 값"}] if is_nullable else [] - - qs = model_qs.all() - if hasattr(qs, "filter_active"): - qs = qs.filter_active() - elif hasattr(model_qs.model, "is_active"): - qs = qs.filter(is_active=True) + def _get_choices_from_queryset(qs: QuerySet, is_nullable: bool) -> list[dict[str, str]]: + choices: list[dict[str, str]] = [{"const": None, "title": "빈 값"}] if is_nullable else [] + + related_model = qs.model + if hasattr(related_model, "get_choices_queryset"): + qs = related_model.get_choices_queryset() + else: + qs = qs.all() + if hasattr(qs, "filter_active"): + qs = qs.filter_active() + elif hasattr(related_model, "is_active"): + qs = qs.filter(is_active=True) for row in qs: - enum_values.append({"const": str(row.pk), "title": str(row)}) + choices.append({"const": str(row.pk), "title": str(row)}) - return enum_values + return choices @staticmethod def set_ui_schema(ui_schema: dict, field_name: str, data: dict) -> None: ui_schema.setdefault(field_name, {}) ui_schema[field_name].update(data) + def _get_related_field_info(self) -> list[tuple[str, object, serializers.Field, bool]]: + """Returns list of (field_name, model_field, serializer_field, is_m2m) for FK/M2M fields.""" + serializer_class = typing.cast(type[JsonSchemaSerializer], self.get_serializer_class()) + + if not hasattr(serializer_class.Meta, "model"): + return [] + + ser_fields: dict[str, serializers.Field] = serializer_class().fields + model_fields = serializer_class.Meta.model._meta.fields + model_m2m_fields = serializer_class.Meta.model._meta.many_to_many + schema = serializer_class.get_json_schema() + + result = [] + for field in model_fields + model_m2m_fields: + if field.name not in schema.get("properties", {}) or field.name not in ser_fields: + continue + + serializer_field = ser_fields[field.name] + + if isinstance(field, ForeignKey): + s_field = typing.cast(serializers.PrimaryKeyRelatedField | None, serializer_field) + if not s_field or serializer_field.read_only: + continue + result.append((field.name, field, serializer_field, False)) + elif isinstance(field, ManyToManyField): + s_field = typing.cast(serializers.ManyRelatedField | None, serializer_field) + if not s_field or serializer_field.read_only: + continue + result.append((field.name, field, serializer_field, True)) + + return result + + def get_choices(self) -> dict[str, list[dict[str, str]]]: + choices: dict[str, list[dict[str, str]]] = {} + + for field_name, field, serializer_field, is_m2m in self._get_related_field_info(): + if is_m2m: + qs = typing.cast(serializers.ManyRelatedField, serializer_field).child_relation.get_queryset() + choices[field_name] = self._get_choices_from_queryset(qs, False) + else: + qs = typing.cast(serializers.PrimaryKeyRelatedField, serializer_field).get_queryset() + choices[field_name] = self._get_choices_from_queryset(qs, field.null) + + return choices + def get_json_schema(self) -> dict: # noqa: C901 serializer_class = typing.cast(type[JsonSchemaSerializer], self.get_serializer_class()) @@ -70,19 +117,15 @@ def get_json_schema(self) -> dict: # noqa: C901 serializer_field = ser_fields[field.name] if isinstance(field, ForeignKey): - if not (s_field := typing.cast(serializers.PrimaryKeyRelatedField | None, serializer_field)): + if not typing.cast(serializers.PrimaryKeyRelatedField | None, serializer_field): continue if serializer_field.read_only: continue - e_values = self.get_enum_values(s_field.get_queryset(), field.null) - result["schema"]["properties"][field.name]["oneOf"] = e_values elif isinstance(field, ManyToManyField): - if not (s_field := typing.cast(serializers.ManyRelatedField | None, serializer_field)): + if not typing.cast(serializers.ManyRelatedField | None, serializer_field): continue if serializer_field.read_only: continue - e_values = self.get_enum_values(s_field.child_relation.get_queryset(), False) - result["schema"]["properties"][field.name]["items"]["oneOf"] = e_values result["schema"]["properties"][field.name]["uniqueItems"] = True self.set_ui_schema(result["ui_schema"], field.name, {"ui:field": "m2m_select"}) elif isinstance(field, FileField): @@ -115,3 +158,12 @@ def get_json_schema(self) -> dict: # noqa: C901 @decorators.action(detail=False, methods=["get"], url_path="json-schema") def response_json_schema(self, *args: tuple, **kwargs: dict) -> response.Response: return response.Response(data=self.get_json_schema()) + + @utils.extend_schema( + tags=[OpenAPITag.ADMIN_JSON_SCHEMA], + summary="Choices for related fields", + responses={status.HTTP_200_OK: openapi.OpenApiResponse(response=types.OpenApiTypes.OBJECT)}, + ) + @decorators.action(detail=False, methods=["get"], url_path="choices") + def response_choices(self, *args: tuple, **kwargs: dict) -> response.Response: + return response.Response(data=self.get_choices()) From b9b529ed8dc6e09d06634c8923987bc447cf3a72 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Thu, 2 Apr 2026 21:52:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?chore:=20refurb=EB=A5=BC=20=EC=9E=84?= =?UTF-8?q?=EC=8B=9C=EB=A1=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .pre-commit-config.yaml | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9bcb045..cd65ab7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,23 +51,24 @@ repos: rev: "6.0.1" hooks: - id: isort - - repo: https://github.com/dosisod/refurb - rev: v2.0.0 - hooks: - - id: refurb - additional_dependencies: - - boto3 - - django-constance - - django-cors-headers - - django-environ - - django-extensions - - django-filter - - django-simple-history - - django-stubs[compatible-mypy] - - drf-spectacular - - drf-standardized-errors - - djangorestframework-stubs[compatible-mypy] - - zappa-django-utils + # TODO: https://github.com/dosisod/refurb/issues/372 문제 해소 후 uncomment 필요 + # - repo: https://github.com/dosisod/refurb + # rev: v2.0.0 + # hooks: + # - id: refurb + # additional_dependencies: + # - boto3 + # - django-constance + # - django-cors-headers + # - django-environ + # - django-extensions + # - django-filter + # - django-simple-history + # - django-stubs[compatible-mypy] + # - drf-spectacular + # - drf-standardized-errors + # - djangorestframework-stubs[compatible-mypy] + # - zappa-django-utils - repo: https://github.com/astral-sh/uv-pre-commit rev: 0.6.12 hooks: