Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added app/admin_api/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions app/admin_api/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class AdminApiConfig(AppConfig):
name = "admin_api"
28 changes: 28 additions & 0 deletions app/admin_api/serializers/cms.py
Original file line number Diff line number Diff line change
@@ -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",)
31 changes: 31 additions & 0 deletions app/admin_api/serializers/file.py
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions app/admin_api/serializers/user.py
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions app/admin_api/urls.py
Original file line number Diff line number Diff line change
@@ -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)),
]
119 changes: 119 additions & 0 deletions app/admin_api/views/cms.py
Original file line number Diff line number Diff line change
@@ -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)
44 changes: 44 additions & 0 deletions app/admin_api/views/file.py
Original file line number Diff line number Diff line change
@@ -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)
45 changes: 45 additions & 0 deletions app/admin_api/views/user.py
Original file line number Diff line number Diff line change
@@ -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)
7 changes: 4 additions & 3 deletions app/cms/serializers.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
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):
sections = SectionSerializer(many=True, read_only=True)

class Meta:
model = Page
fields = ("id", "title", "subtitle", "css", "sections", "created_at", "updated_at")
fields = COMMON_FIELDS + ("title", "subtitle", "css", "sections")
5 changes: 5 additions & 0 deletions app/cms/views.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions app/core/const/serializer.py
Original file line number Diff line number Diff line change
@@ -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")
7 changes: 7 additions & 0 deletions app/core/const/tag.py
Original file line number Diff line number Diff line change
@@ -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"
Loading