diff --git a/app/admin_api/__init__.py b/app/admin_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_api/apps.py b/app/admin_api/apps.py new file mode 100644 index 0000000..f1e9595 --- /dev/null +++ b/app/admin_api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class AdminApiConfig(AppConfig): + name = "admin_api" diff --git a/app/admin_api/serializers/cms.py b/app/admin_api/serializers/cms.py new file mode 100644 index 0000000..8c112f5 --- /dev/null +++ b/app/admin_api/serializers/cms.py @@ -0,0 +1,28 @@ +from cms.models import Page, Section, Sitemap +from core.const.serializer import COMMON_ADMIN_FIELDS +from core.serializer.base_abstract_serializer import BaseAbstractSerializer +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from rest_framework import serializers + + +class SitemapAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = Sitemap + fields = COMMON_ADMIN_FIELDS + ("parent_sitemap", "route_code", "order", "page", "name_ko", "name_en") + translation_fields = ("name",) + + +class PageAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = Page + fields = COMMON_ADMIN_FIELDS + ("title_ko", "title_en", "subtitle_ko", "subtitle_en") + translation_fields = ("title", "subtitle") + + +class SectionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + page = serializers.PrimaryKeyRelatedField(queryset=Page.objects.filter_active(), required=False) + + class Meta: + model = Section + fields = COMMON_ADMIN_FIELDS + ("page", "order", "body_ko", "body_en") + translation_fields = ("body",) diff --git a/app/admin_api/serializers/file.py b/app/admin_api/serializers/file.py new file mode 100644 index 0000000..6b6c603 --- /dev/null +++ b/app/admin_api/serializers/file.py @@ -0,0 +1,31 @@ +from core.const.serializer import COMMON_ADMIN_FIELDS +from core.serializer.base_abstract_serializer import BaseAbstractSerializer +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from file.models import PublicFile +from rest_framework import serializers + + +class PublicFileAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + file = serializers.FileField(read_only=True) + mimetype = serializers.CharField(read_only=True, allow_blank=True, allow_null=True, required=False) + hash = serializers.CharField(read_only=True, allow_blank=True, allow_null=True, required=False) + size = serializers.IntegerField(read_only=True, allow_null=True, required=False) + + class Meta: + model = PublicFile + fields = COMMON_ADMIN_FIELDS + ("file", "mimetype", "hash", "size") + + +class PublicFileAdmimUploadSerializer(serializers.Serializer): + file = serializers.FileField() + + def create(self, validated_data: dict) -> PublicFile: + new_file = PublicFile(file=validated_data["file"]) + new_file.clean() + + if new_file.hash and (existing_file := PublicFile.objects.filter(hash=new_file.hash).first()): + return existing_file + + new_file.save() + + return new_file diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py new file mode 100644 index 0000000..3a7f7f0 --- /dev/null +++ b/app/admin_api/serializers/user.py @@ -0,0 +1,38 @@ +import functools +import typing + +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from core.serializer.read_only_serializer import ReadOnlyModelSerializer +from rest_framework import serializers +from user.models import UserExt + + +class UserAdminSerializer(JsonSchemaSerializer, ReadOnlyModelSerializer, serializers.ModelSerializer): + class Meta: + model = UserExt + fields = ("id", "username", "email", "first_name", "last_name", "is_staff", "is_active", "date_joined") + + +class UserAdminSignInSerializerData(typing.TypedDict): + identity: str + password: str + + +class UserAdminSignInSerializer(JsonSchemaSerializer, ReadOnlyModelSerializer): + identity = serializers.CharField(max_length=150, required=True) + password = serializers.CharField(write_only=True, required=True) + + class Meta: + fields = ("identity", "password") + + @functools.cached_property + def user(self) -> UserExt | None: + identity = typing.cast(UserAdminSignInSerializerData, self.initial_data)["identity"].strip() + field = "username" if identity.startswith("@") or "@" not in identity else "email" + return UserExt.objects.filter(**{field: identity, "is_active": True}).first() + + def validate(self, attrs: UserAdminSignInSerializerData) -> UserAdminSignInSerializerData: + if not (self.user and self.user.check_password(attrs["password"])): + raise serializers.ValidationError("User not found or inactive or wrong password.") + + return attrs diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py new file mode 100644 index 0000000..82807c9 --- /dev/null +++ b/app/admin_api/urls.py @@ -0,0 +1,21 @@ +from admin_api.views.cms import PageAdminViewSet, SitemapAdminViewSet +from admin_api.views.file import PublicFileAdminViewSet +from admin_api.views.user import UserAdminViewSet +from django.urls import include, path +from rest_framework import routers + +admin_user_router = routers.SimpleRouter() +admin_user_router.register("userext", UserAdminViewSet, basename="admin-user") + +admin_cms_router = routers.SimpleRouter() +admin_cms_router.register("sitemap", SitemapAdminViewSet, basename="admin-sitemap") +admin_cms_router.register("page", PageAdminViewSet, basename="admin-page") + +admin_file_router = routers.SimpleRouter() +admin_file_router.register("publicfile", PublicFileAdminViewSet, basename="admin-public-file") + +urlpatterns = [ + path("cms/", include(admin_cms_router.urls)), + path("file/", include(admin_file_router.urls)), + path("user/", include(admin_user_router.urls)), +] diff --git a/app/admin_api/views/cms.py b/app/admin_api/views/cms.py new file mode 100644 index 0000000..8c6937b --- /dev/null +++ b/app/admin_api/views/cms.py @@ -0,0 +1,119 @@ +from __future__ import annotations + +import typing + +from admin_api.serializers.cms import PageAdminSerializer, SectionAdminSerializer, SitemapAdminSerializer +from cms.models import Page, Section, Sitemap +from core.const.tag import OpenAPITag +from core.permissions import IsSuperUser +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from django.db import transaction +from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ( + ValidationErrorEnum, + ValidationErrorResponseSerializer, + ValidationErrorSerializer, +) +from rest_framework import decorators, request, response, status, viewsets + +ADMIN_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"] + + +class SectionData(typing.TypedDict): + id: typing.NotRequired[str] + page_id: typing.NotRequired[str] + order: int + body_ko: str + body_en: str + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_CMS]) for m in ADMIN_METHODS}) +class SitemapAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = SitemapAdminSerializer + permission_classes = [IsSuperUser] + queryset = Sitemap.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_CMS]) for m in ADMIN_METHODS}) +class PageAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + serializer_class = PageAdminSerializer + permission_classes = [IsSuperUser] + queryset = Page.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + + @staticmethod + def _response_section_validation_error(detail: str) -> response.Response: + return response.Response( + data=ValidationErrorResponseSerializer( + instance={ + "type": ValidationErrorEnum.VALIDATION_ERROR, + "errors": ValidationErrorSerializer( + instance=[ + { + "code": "section_validation_error", + "detail": detail, + "attr": "sections", + }, + ], + many=True, + ).data, + }, + ).data, + status=status.HTTP_400_BAD_REQUEST, + ) + + @extend_schema(tags=[OpenAPITag.ADMIN_CMS], responses={status.HTTP_200_OK: SectionAdminSerializer(many=True)}) + @decorators.action(detail=True, methods=["get"], url_path="section") + def list_sections(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if not (page_id := kwargs.get("pk")): + return self._response_section_validation_error("페이지 ID가 제공되지 않았습니다.") + + return response.Response( + data=SectionAdminSerializer( + instance=( + Section.objects.filter_active() + .filter(page_id=page_id) + .select_related("created_by", "updated_by", "deleted_by") + .order_by("order") + ), + many=True, + ).data, + ) + + @extend_schema( + tags=[OpenAPITag.ADMIN_CMS], + request=SectionAdminSerializer(many=True), + responses={ + status.HTTP_200_OK: SectionAdminSerializer(many=True), + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, + ) + @decorators.action(detail=True, methods=["put"], url_path="section/bulk-update") + @transaction.atomic + def bulk_update_sections(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if not (page_id := kwargs.get("pk")): + return self._response_section_validation_error("페이지 ID가 제공되지 않았습니다.") + + section_qs = Section.objects.filter_active().filter(page_id=page_id).order_by("order") + sections_data: list[SectionData] = request.data.get("sections", []) + if not isinstance(sections_data, list): + return self._response_section_validation_error("섹션 데이터는 리스트 형식이어야 합니다.") + if not sections_data: + return self._response_section_validation_error("섹션 데이터가 비어 있습니다.") + + id_in_new_sections = {sid for section_datum in sections_data if (sid := section_datum.get("id"))} + section_qs.exclude(id__in=id_in_new_sections).delete() + + for section_datum in sections_data: + section_id = section_datum.get("id") + section_datum["page"] = page_id + section_instance: Section | None = section_id and section_qs.filter(id=section_id).first() + + if section_id and not section_instance: + return self._response_section_validation_error(f"<{section_id}> 섹션이 존재하지 않습니다.") + + serializer = SectionAdminSerializer(instance=section_instance, data=section_datum) + serializer.is_valid(raise_exception=True) + serializer.save() + + return response.Response(data=SectionAdminSerializer(instance=section_qs, many=True).data) diff --git a/app/admin_api/views/file.py b/app/admin_api/views/file.py new file mode 100644 index 0000000..f043f8e --- /dev/null +++ b/app/admin_api/views/file.py @@ -0,0 +1,44 @@ +from admin_api.serializers.file import PublicFileAdmimUploadSerializer, PublicFileAdminSerializer +from core.const.tag import OpenAPITag +from core.permissions import IsSuperUser +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from drf_spectacular import utils +from file.models import PublicFile +from rest_framework import decorators, mixins, parsers, request, response, serializers, status, viewsets + +ADMIN_METHODS = ["list", "retrieve", "destroy"] + + +@utils.extend_schema_view(**{m: utils.extend_schema(tags=[OpenAPITag.ADMIN_PUBLIC_FILE]) for m in ADMIN_METHODS}) +class PublicFileAdminViewSet( + mixins.RetrieveModelMixin, + mixins.DestroyModelMixin, + mixins.ListModelMixin, + JsonSchemaViewSet, + viewsets.GenericViewSet, +): + serializer_class = PublicFileAdminSerializer + permission_classes = [IsSuperUser] + queryset = PublicFile.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + + @utils.extend_schema( + tags=[OpenAPITag.ADMIN_PUBLIC_FILE], + responses={200: PublicFileAdminSerializer}, + ) + @decorators.action( + detail=False, + methods=["post"], + url_path="upload", + serializer_class=PublicFileAdmimUploadSerializer, + parser_classes=[parsers.MultiPartParser, parsers.FileUploadParser], + ) + def upload(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if "file" not in request.FILES: + raise serializers.ValidationError({"file": "This field is required."}) + + serializer = PublicFileAdmimUploadSerializer(data=request.FILES) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + file_data = PublicFileAdminSerializer(instance=instance).data + return response.Response(data=file_data, status=status.HTTP_201_CREATED) diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py new file mode 100644 index 0000000..d2f83ff --- /dev/null +++ b/app/admin_api/views/user.py @@ -0,0 +1,45 @@ +from admin_api.serializers.user import UserAdminSerializer, UserAdminSignInSerializer +from core.const.tag import OpenAPITag +from core.permissions import IsSuperUser +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from django.contrib.auth import login, logout +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import decorators, request, response, status, viewsets +from user.models import UserExt + +ADMIN_METHODS = ["list", "retrieve", "create", "partial_update", "destroy"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_USER]) for m in ADMIN_METHODS}) +class UserAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = UserAdminSerializer + permission_classes = [IsSuperUser] + queryset = UserExt.objects.filter(is_active=True) + + @extend_schema(tags=[OpenAPITag.ADMIN_USER], responses={status.HTTP_200_OK: UserAdminSerializer}) + @decorators.action(detail=False, methods=["GET"], permission_classes=[]) + def me(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if not request.user.is_authenticated: + return response.Response(status=status.HTTP_401_UNAUTHORIZED) + + return response.Response(data=UserAdminSerializer(request.user).data) + + @extend_schema( + tags=[OpenAPITag.ADMIN_USER], + request=UserAdminSignInSerializer, + responses={status.HTTP_200_OK: UserAdminSerializer}, + ) + @decorators.action(detail=False, methods=["POST"], url_path="signin", permission_classes=[]) + def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + serializer = UserAdminSignInSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + login(request=request, user=serializer.user) + return response.Response(data=UserAdminSerializer(serializer.user).data) + + @extend_schema(tags=[OpenAPITag.ADMIN_USER], responses={status.HTTP_204_NO_CONTENT: None}) + @decorators.action(detail=False, methods=["DELETE"], url_path="signout", permission_classes=[]) + def signout(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + logout(request=request) + return response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/cms/serializers.py b/app/cms/serializers.py index 2fcfb74..2f81f7f 100644 --- a/app/cms/serializers.py +++ b/app/cms/serializers.py @@ -1,17 +1,18 @@ from cms.models import Page, Section, Sitemap +from core.const.serializer import COMMON_FIELDS from rest_framework import serializers class SitemapSerializer(serializers.ModelSerializer): class Meta: model = Sitemap - fields = ("id", "parent_sitemap", "route_code", "name", "order", "page") + fields = COMMON_FIELDS + ("parent_sitemap", "route_code", "name", "order", "page") class SectionSerializer(serializers.ModelSerializer): class Meta: model = Section - fields = ("id", "order", "css", "body") + fields = COMMON_FIELDS + ("order", "css", "body") class PageSerializer(serializers.ModelSerializer): @@ -19,4 +20,4 @@ class PageSerializer(serializers.ModelSerializer): class Meta: model = Page - fields = ("id", "title", "subtitle", "css", "sections", "created_at", "updated_at") + fields = COMMON_FIELDS + ("title", "subtitle", "css", "sections") diff --git a/app/cms/views.py b/app/cms/views.py index f7cd7a6..cb7b49f 100644 --- a/app/cms/views.py +++ b/app/cms/views.py @@ -1,13 +1,18 @@ from cms.models import Page, Sitemap from cms.serializers import PageSerializer, SitemapSerializer +from core.const.tag import OpenAPITag +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema from rest_framework import mixins, viewsets +@method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.CMS])) class SitemapViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = SitemapSerializer queryset = Sitemap.objects.filter_active().filter_by_today() +@method_decorator(name="retrieve", decorator=extend_schema(tags=[OpenAPITag.CMS])) class PageViewSet(mixins.RetrieveModelMixin, viewsets.GenericViewSet): serializer_class = PageSerializer queryset = Page.objects.filter_active().prefetch_related("sections") diff --git a/app/core/const/serializer.py b/app/core/const/serializer.py new file mode 100644 index 0000000..696793f --- /dev/null +++ b/app/core/const/serializer.py @@ -0,0 +1,2 @@ +COMMON_FIELDS = ("id", "created_at", "created_by", "updated_at", "updated_by") +COMMON_ADMIN_FIELDS = COMMON_FIELDS + ("deleted_at", "deleted_by", "str_repr") diff --git a/app/core/const/tag.py b/app/core/const/tag.py new file mode 100644 index 0000000..d52e9e6 --- /dev/null +++ b/app/core/const/tag.py @@ -0,0 +1,7 @@ +class OpenAPITag: + CMS = "CMS" + + ADMIN_USER = "Admin > User" + ADMIN_CMS = "Admin > CMS" + ADMIN_PUBLIC_FILE = "Admin > Public File" + ADMIN_JSON_SCHEMA = "Admin > JSON Schema" diff --git a/app/core/models.py b/app/core/models.py index 2fb47b7..9d8c048 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -14,7 +14,7 @@ class BaseAbstractModelQuerySet(models.QuerySet): - def create(self, **kwargs: dict) -> typing.Self: + def create(self, **kwargs: dict) -> models.Model: current_user = get_current_user() return super().create(**(kwargs | {"created_by": current_user, "updated_by": current_user})) diff --git a/app/core/serializer/base_abstract_serializer.py b/app/core/serializer/base_abstract_serializer.py new file mode 100644 index 0000000..1dba3b5 --- /dev/null +++ b/app/core/serializer/base_abstract_serializer.py @@ -0,0 +1,13 @@ +from rest_framework import serializers + + +class BaseAbstractSerializer(serializers.Serializer): + id = serializers.UUIDField(read_only=True, allow_null=True) + created_at = serializers.DateTimeField(read_only=True, allow_null=True) + created_by = serializers.StringRelatedField() + updated_at = serializers.DateTimeField(read_only=True, allow_null=True) + updated_by = serializers.StringRelatedField() + deleted_at = serializers.DateTimeField(read_only=True, allow_null=True) + deleted_by = serializers.StringRelatedField() + + str_repr = serializers.CharField(source="__str__", read_only=True) diff --git a/app/core/serializer/json_schema_serializer.py b/app/core/serializer/json_schema_serializer.py new file mode 100644 index 0000000..6470e5f --- /dev/null +++ b/app/core/serializer/json_schema_serializer.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +import typing + +from core.util.drf_serializer import extract_jsonschema_from_serializer +from rest_framework import serializers + + +class JsonSchema(typing.TypedDict): + type: str + schema: str + properties: dict[str, dict[str, typing.Any]] + required: list[str] + + +class JsonSchemaSerializer: + @classmethod + def get_json_schema(cls: type[serializers.Serializer]) -> JsonSchema: + return extract_jsonschema_from_serializer(cls()) diff --git a/app/core/serializer/read_only_serializer.py b/app/core/serializer/read_only_serializer.py new file mode 100644 index 0000000..7d05d64 --- /dev/null +++ b/app/core/serializer/read_only_serializer.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + + +class ReadOnlyModelSerializer(serializers.Serializer): + def create(self, validated_data): + raise NotImplementedError("This serializer does not support creation.") + + def update(self, instance, validated_data): + raise NotImplementedError("This serializer does not support updates.") + + def save(self, **kwargs): + raise NotImplementedError("This serializer does not support saving.") diff --git a/app/core/settings.py b/app/core/settings.py index 1490fea..bf617d4 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -157,6 +157,7 @@ "user", "file", "cms", + "admin_api", # django-constance "constance", ] @@ -311,25 +312,27 @@ COOKIE_SECURE = not IS_LOCAL COOKIE_HTTPONLY = True COOKIE_DOMAIN = env("COOKIE_DOMAIN", default="pycon.kr") +COOKIE_TRUSTED_ORIGIN_SET = { + f"{protocol}://{domain}:{port}" + for protocol in ("http", "https") + for domain in ("localhost", "127.0.0.1", "local.dev.pycon.kr") + for port in (3000, 5173) +} SESSION_COOKIE_NAME = f"{COOKIE_PREFIX}sessionid" SESSION_COOKIE_SAMESITE = COOKIE_SAMESITE SESSION_COOKIE_SECURE = COOKIE_SECURE SESSION_COOKIE_HTTPONLY = COOKIE_HTTPONLY -SESSION_COOKIE_DOMAIN = None if IS_LOCAL else COOKIE_DOMAIN +SESSION_COOKIE_DOMAIN = COOKIE_DOMAIN CSRF_COOKIE_NAME = f"{COOKIE_PREFIX}csrftoken" CSRF_COOKIE_SAMESITE = COOKIE_SAMESITE CSRF_COOKIE_SECURE = COOKIE_SECURE -CSRF_COOKIE_HTTPONLY = COOKIE_HTTPONLY -CSRF_COOKIE_DOMAIN = None if IS_LOCAL else COOKIE_DOMAIN -CSRF_TRUSTED_ORIGINS = set(env.list("CSRF_TRUSTED_ORIGINS", default=["https://pycon.kr"])) | { - "https://local.dev.pycon.kr:3000", - "https://localhost:3000", - "http://localhost:3000", - "https://127.0.0.1:3000", - "http://127.0.0.1:3000", -} +CSRF_COOKIE_HTTPONLY = False # CSRF_COOKIE_HTTPONLY must be False to allow JavaScript to read the CSRF token +CSRF_COOKIE_DOMAIN = COOKIE_DOMAIN +CSRF_TRUSTED_ORIGINS = ( + set(env.list("CSRF_TRUSTED_ORIGINS", default=["https://rest-api.pycon.kr"])) | COOKIE_TRUSTED_ORIGIN_SET +) # Django Rest Framework Settings REST_FRAMEWORK = { @@ -344,6 +347,7 @@ SPECTACULAR_SETTINGS = { "TITLE": "PyCon KR Backend API", "SERVE_INCLUDE_SCHEMA": False, + "COMPONENT_SPLIT_REQUEST": True, } # Sentry Settings diff --git a/app/core/urls.py b/app/core/urls.py index d347ee9..b3aa710 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -23,7 +23,10 @@ from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView # type: ignore[assignment] -v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [path("cms/", include("cms.urls"))] +v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [ + path("admin-api/", include("admin_api.urls")), + path("cms/", include("cms.urls")), +] urlpatterns = [ # Health Check diff --git a/app/core/util/drf_serializer.py b/app/core/util/drf_serializer.py new file mode 100644 index 0000000..8950822 --- /dev/null +++ b/app/core/util/drf_serializer.py @@ -0,0 +1,14 @@ +from openapi_schema_to_json_schema import to_json_schema +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.serializers import Serializer + + +def extract_openapi_schema_from_serializer(serializer: Serializer) -> dict: + return AutoSchema().map_serializer(serializer) + + +def extract_jsonschema_from_serializer(serializer: Serializer) -> dict: + return to_json_schema( + schema=extract_openapi_schema_from_serializer(serializer), + options={"keepNotSupported": ["readOnly", "writeOnly"]}, + ) diff --git a/app/core/viewset/json_schema_viewset.py b/app/core/viewset/json_schema_viewset.py new file mode 100644 index 0000000..4bca72d --- /dev/null +++ b/app/core/viewset/json_schema_viewset.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import collections +import typing + +from core.const.tag import OpenAPITag +from core.serializer.json_schema_serializer import JsonSchemaSerializer +from django.db.models.fields.files import FileField +from django.db.models.fields.related import ForeignKey +from drf_spectacular import openapi, types, utils +from modeltranslation.fields import TranslationField +from rest_framework import decorators, response, status, viewsets + + +class JsonSchemaViewSet(viewsets.GenericViewSet): + def __new__(cls, *args: tuple, **kwargs: dict) -> JsonSchemaViewSet: + if cls.serializer_class and not hasattr(cls.serializer_class, "get_json_schema"): + raise TypeError(f"{cls.__name__} must have a serializer class with a 'get_json_schema' method.") + + return super().__new__(cls) + + def get_json_schema(self) -> dict: + serializer_class = typing.cast(type[JsonSchemaSerializer], self.get_serializer_class()) + + result = { + "schema": serializer_class.get_json_schema(), + "ui_schema": {}, + "translation_fields": collections.defaultdict(), + } + + nullable_fields = [ + k for k, v in serializer_class.get_json_schema()["properties"].items() if "null" in v.get("type", []) + ] + + if hasattr(serializer_class.Meta, "model") and "properties" in result["schema"]: + model_fields = serializer_class.Meta.model._meta.fields + + for field in model_fields: + if isinstance(field, ForeignKey): + enum_values = [] + row_qs = field.related_model.objects + if hasattr(row_qs, "filter_active"): + row_qs = row_qs.filter_active() + elif hasattr(field.related_model, "is_active"): + row_qs = row_qs.filter(is_active=True) + + for row in row_qs: + enum_values.append({"id": row.pk, "name": str(row)}) + + if field.name in result["schema"]["properties"]: + result["schema"]["properties"][field.name]["enum"] = [e["id"] for e in enum_values] + ( + [None] if field.null else [] + ) + + result["ui_schema"][field.name] = { + "ui:options": { + "ui:widget": "select", + "enumNames": [f"{e['name']} <{e['id']}>" for e in enum_values] + + (["빈 값"] if field.name in nullable_fields else []), + } + } + + elif isinstance(field, FileField): + result["ui_schema"][field.name] = {"ui:field": "file"} + elif isinstance(field, TranslationField): + translated_field = field.translated_field + result["translation_fields"][field.name] = translated_field.name + return result + + @utils.extend_schema( + tags=[OpenAPITag.ADMIN_JSON_SCHEMA], + summary="JSON Schema", + responses={status.HTTP_200_OK: openapi.OpenApiResponse(response=types.OpenApiTypes.OBJECT)}, + ) + @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()) diff --git a/app/file/models.py b/app/file/models.py index 3463299..82cf93e 100644 --- a/app/file/models.py +++ b/app/file/models.py @@ -16,6 +16,9 @@ class Meta: ordering = ["-created_at"] indexes = [models.Index(fields=["file"]), models.Index(fields=["mimetype"]), models.Index(fields=["hash"])] + def __str__(self) -> str: + return self.file.name + def clean(self) -> None: # 파일의 해시값, 크기, mimetype을 계산하여 저장합니다. hash_md5 = hashlib.md5(usedforsecurity=False) diff --git a/pyproject.toml b/pyproject.toml index d615534..c5b4ce1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "httpx>=0.28.1", "packaging>=24.2", "psycopg[binary]>=3.2.6", + "py-openapi-schema-to-json-schema>=0.0.3", "sentry-sdk[django]>=2.25.1", "setuptools>=78.1.0", "zappa>=0.59.0", diff --git a/uv.lock b/uv.lock index c84623a..de00d26 100644 --- a/uv.lock +++ b/uv.lock @@ -122,6 +122,7 @@ dependencies = [ { name = "httpx" }, { name = "packaging" }, { name = "psycopg", extra = ["binary"] }, + { name = "py-openapi-schema-to-json-schema" }, { name = "sentry-sdk", extra = ["django"] }, { name = "setuptools" }, { name = "zappa" }, @@ -170,6 +171,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28.1" }, { name = "packaging", specifier = ">=24.2" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.6" }, + { name = "py-openapi-schema-to-json-schema", specifier = ">=0.0.3" }, { name = "sentry-sdk", extras = ["django"], specifier = ">=2.25.1" }, { name = "setuptools", specifier = ">=78.1.0" }, { name = "zappa", specifier = ">=0.59.0" }, @@ -1094,6 +1096,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 }, ] +[[package]] +name = "py-openapi-schema-to-json-schema" +version = "0.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/c5/5d6a9b08df175a886b4085eb51e0351854a96e4896a367b2373ad19d881b/py-openapi-schema-to-json-schema-0.0.3.tar.gz", hash = "sha256:d557afb6bcc45d62a1383ada0ad57515421552efa3b2e07b2264e5b9e1e9634e", size = 5964 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/1a/a43f73b8762512ab3358aac96c6c6d1d9ec4dbb3bbb99d82c2e90e5f3d16/py_openapi_schema_to_json_schema-0.0.3-py3-none-any.whl", hash = "sha256:456802186309257a9667fd50eca7c6ff6eaf9930ab09dcc87c54537e01066f09", size = 6954 }, +] + [[package]] name = "pycparser" version = "2.22"