Skip to content

Commit 3505085

Browse files
authored
Merge pull request #17 from pythonkr/feature/admin-editor
feat: PyCon Korea Admin을 위한 admin-api Django App 추가
2 parents cc05980 + 5ba7582 commit 3505085

24 files changed

Lines changed: 518 additions & 15 deletions

app/admin_api/__init__.py

Whitespace-only changes.

app/admin_api/apps.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from django.apps import AppConfig
2+
3+
4+
class AdminApiConfig(AppConfig):
5+
name = "admin_api"

app/admin_api/serializers/cms.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
from cms.models import Page, Section, Sitemap
2+
from core.const.serializer import COMMON_ADMIN_FIELDS
3+
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
4+
from core.serializer.json_schema_serializer import JsonSchemaSerializer
5+
from rest_framework import serializers
6+
7+
8+
class SitemapAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
9+
class Meta:
10+
model = Sitemap
11+
fields = COMMON_ADMIN_FIELDS + ("parent_sitemap", "route_code", "order", "page", "name_ko", "name_en")
12+
translation_fields = ("name",)
13+
14+
15+
class PageAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
16+
class Meta:
17+
model = Page
18+
fields = COMMON_ADMIN_FIELDS + ("title_ko", "title_en", "subtitle_ko", "subtitle_en")
19+
translation_fields = ("title", "subtitle")
20+
21+
22+
class SectionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
23+
page = serializers.PrimaryKeyRelatedField(queryset=Page.objects.filter_active(), required=False)
24+
25+
class Meta:
26+
model = Section
27+
fields = COMMON_ADMIN_FIELDS + ("page", "order", "body_ko", "body_en")
28+
translation_fields = ("body",)

app/admin_api/serializers/file.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from core.const.serializer import COMMON_ADMIN_FIELDS
2+
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
3+
from core.serializer.json_schema_serializer import JsonSchemaSerializer
4+
from file.models import PublicFile
5+
from rest_framework import serializers
6+
7+
8+
class PublicFileAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
9+
file = serializers.FileField(read_only=True)
10+
mimetype = serializers.CharField(read_only=True, allow_blank=True, allow_null=True, required=False)
11+
hash = serializers.CharField(read_only=True, allow_blank=True, allow_null=True, required=False)
12+
size = serializers.IntegerField(read_only=True, allow_null=True, required=False)
13+
14+
class Meta:
15+
model = PublicFile
16+
fields = COMMON_ADMIN_FIELDS + ("file", "mimetype", "hash", "size")
17+
18+
19+
class PublicFileAdmimUploadSerializer(serializers.Serializer):
20+
file = serializers.FileField()
21+
22+
def create(self, validated_data: dict) -> PublicFile:
23+
new_file = PublicFile(file=validated_data["file"])
24+
new_file.clean()
25+
26+
if new_file.hash and (existing_file := PublicFile.objects.filter(hash=new_file.hash).first()):
27+
return existing_file
28+
29+
new_file.save()
30+
31+
return new_file

app/admin_api/serializers/user.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import functools
2+
import typing
3+
4+
from core.serializer.json_schema_serializer import JsonSchemaSerializer
5+
from core.serializer.read_only_serializer import ReadOnlyModelSerializer
6+
from rest_framework import serializers
7+
from user.models import UserExt
8+
9+
10+
class UserAdminSerializer(JsonSchemaSerializer, ReadOnlyModelSerializer, serializers.ModelSerializer):
11+
class Meta:
12+
model = UserExt
13+
fields = ("id", "username", "email", "first_name", "last_name", "is_staff", "is_active", "date_joined")
14+
15+
16+
class UserAdminSignInSerializerData(typing.TypedDict):
17+
identity: str
18+
password: str
19+
20+
21+
class UserAdminSignInSerializer(JsonSchemaSerializer, ReadOnlyModelSerializer):
22+
identity = serializers.CharField(max_length=150, required=True)
23+
password = serializers.CharField(write_only=True, required=True)
24+
25+
class Meta:
26+
fields = ("identity", "password")
27+
28+
@functools.cached_property
29+
def user(self) -> UserExt | None:
30+
identity = typing.cast(UserAdminSignInSerializerData, self.initial_data)["identity"].strip()
31+
field = "username" if identity.startswith("@") or "@" not in identity else "email"
32+
return UserExt.objects.filter(**{field: identity, "is_active": True}).first()
33+
34+
def validate(self, attrs: UserAdminSignInSerializerData) -> UserAdminSignInSerializerData:
35+
if not (self.user and self.user.check_password(attrs["password"])):
36+
raise serializers.ValidationError("User not found or inactive or wrong password.")
37+
38+
return attrs

app/admin_api/urls.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from admin_api.views.cms import PageAdminViewSet, SitemapAdminViewSet
2+
from admin_api.views.file import PublicFileAdminViewSet
3+
from admin_api.views.user import UserAdminViewSet
4+
from django.urls import include, path
5+
from rest_framework import routers
6+
7+
admin_user_router = routers.SimpleRouter()
8+
admin_user_router.register("userext", UserAdminViewSet, basename="admin-user")
9+
10+
admin_cms_router = routers.SimpleRouter()
11+
admin_cms_router.register("sitemap", SitemapAdminViewSet, basename="admin-sitemap")
12+
admin_cms_router.register("page", PageAdminViewSet, basename="admin-page")
13+
14+
admin_file_router = routers.SimpleRouter()
15+
admin_file_router.register("publicfile", PublicFileAdminViewSet, basename="admin-public-file")
16+
17+
urlpatterns = [
18+
path("cms/", include(admin_cms_router.urls)),
19+
path("file/", include(admin_file_router.urls)),
20+
path("user/", include(admin_user_router.urls)),
21+
]

app/admin_api/views/cms.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
from __future__ import annotations
2+
3+
import typing
4+
5+
from admin_api.serializers.cms import PageAdminSerializer, SectionAdminSerializer, SitemapAdminSerializer
6+
from cms.models import Page, Section, Sitemap
7+
from core.const.tag import OpenAPITag
8+
from core.permissions import IsSuperUser
9+
from core.viewset.json_schema_viewset import JsonSchemaViewSet
10+
from django.db import transaction
11+
from drf_spectacular.utils import extend_schema, extend_schema_view
12+
from drf_standardized_errors.openapi_serializers import (
13+
ValidationErrorEnum,
14+
ValidationErrorResponseSerializer,
15+
ValidationErrorSerializer,
16+
)
17+
from rest_framework import decorators, request, response, status, viewsets
18+
19+
ADMIN_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"]
20+
21+
22+
class SectionData(typing.TypedDict):
23+
id: typing.NotRequired[str]
24+
page_id: typing.NotRequired[str]
25+
order: int
26+
body_ko: str
27+
body_en: str
28+
29+
30+
@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_CMS]) for m in ADMIN_METHODS})
31+
class SitemapAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet):
32+
http_method_names = ["get", "post", "patch", "delete"]
33+
serializer_class = SitemapAdminSerializer
34+
permission_classes = [IsSuperUser]
35+
queryset = Sitemap.objects.filter_active().select_related("created_by", "updated_by", "deleted_by")
36+
37+
38+
@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_CMS]) for m in ADMIN_METHODS})
39+
class PageAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet):
40+
serializer_class = PageAdminSerializer
41+
permission_classes = [IsSuperUser]
42+
queryset = Page.objects.filter_active().select_related("created_by", "updated_by", "deleted_by")
43+
44+
@staticmethod
45+
def _response_section_validation_error(detail: str) -> response.Response:
46+
return response.Response(
47+
data=ValidationErrorResponseSerializer(
48+
instance={
49+
"type": ValidationErrorEnum.VALIDATION_ERROR,
50+
"errors": ValidationErrorSerializer(
51+
instance=[
52+
{
53+
"code": "section_validation_error",
54+
"detail": detail,
55+
"attr": "sections",
56+
},
57+
],
58+
many=True,
59+
).data,
60+
},
61+
).data,
62+
status=status.HTTP_400_BAD_REQUEST,
63+
)
64+
65+
@extend_schema(tags=[OpenAPITag.ADMIN_CMS], responses={status.HTTP_200_OK: SectionAdminSerializer(many=True)})
66+
@decorators.action(detail=True, methods=["get"], url_path="section")
67+
def list_sections(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
68+
if not (page_id := kwargs.get("pk")):
69+
return self._response_section_validation_error("페이지 ID가 제공되지 않았습니다.")
70+
71+
return response.Response(
72+
data=SectionAdminSerializer(
73+
instance=(
74+
Section.objects.filter_active()
75+
.filter(page_id=page_id)
76+
.select_related("created_by", "updated_by", "deleted_by")
77+
.order_by("order")
78+
),
79+
many=True,
80+
).data,
81+
)
82+
83+
@extend_schema(
84+
tags=[OpenAPITag.ADMIN_CMS],
85+
request=SectionAdminSerializer(many=True),
86+
responses={
87+
status.HTTP_200_OK: SectionAdminSerializer(many=True),
88+
status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
89+
},
90+
)
91+
@decorators.action(detail=True, methods=["put"], url_path="section/bulk-update")
92+
@transaction.atomic
93+
def bulk_update_sections(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
94+
if not (page_id := kwargs.get("pk")):
95+
return self._response_section_validation_error("페이지 ID가 제공되지 않았습니다.")
96+
97+
section_qs = Section.objects.filter_active().filter(page_id=page_id).order_by("order")
98+
sections_data: list[SectionData] = request.data.get("sections", [])
99+
if not isinstance(sections_data, list):
100+
return self._response_section_validation_error("섹션 데이터는 리스트 형식이어야 합니다.")
101+
if not sections_data:
102+
return self._response_section_validation_error("섹션 데이터가 비어 있습니다.")
103+
104+
id_in_new_sections = {sid for section_datum in sections_data if (sid := section_datum.get("id"))}
105+
section_qs.exclude(id__in=id_in_new_sections).delete()
106+
107+
for section_datum in sections_data:
108+
section_id = section_datum.get("id")
109+
section_datum["page"] = page_id
110+
section_instance: Section | None = section_id and section_qs.filter(id=section_id).first()
111+
112+
if section_id and not section_instance:
113+
return self._response_section_validation_error(f"<{section_id}> 섹션이 존재하지 않습니다.")
114+
115+
serializer = SectionAdminSerializer(instance=section_instance, data=section_datum)
116+
serializer.is_valid(raise_exception=True)
117+
serializer.save()
118+
119+
return response.Response(data=SectionAdminSerializer(instance=section_qs, many=True).data)

app/admin_api/views/file.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
from admin_api.serializers.file import PublicFileAdmimUploadSerializer, PublicFileAdminSerializer
2+
from core.const.tag import OpenAPITag
3+
from core.permissions import IsSuperUser
4+
from core.viewset.json_schema_viewset import JsonSchemaViewSet
5+
from drf_spectacular import utils
6+
from file.models import PublicFile
7+
from rest_framework import decorators, mixins, parsers, request, response, serializers, status, viewsets
8+
9+
ADMIN_METHODS = ["list", "retrieve", "destroy"]
10+
11+
12+
@utils.extend_schema_view(**{m: utils.extend_schema(tags=[OpenAPITag.ADMIN_PUBLIC_FILE]) for m in ADMIN_METHODS})
13+
class PublicFileAdminViewSet(
14+
mixins.RetrieveModelMixin,
15+
mixins.DestroyModelMixin,
16+
mixins.ListModelMixin,
17+
JsonSchemaViewSet,
18+
viewsets.GenericViewSet,
19+
):
20+
serializer_class = PublicFileAdminSerializer
21+
permission_classes = [IsSuperUser]
22+
queryset = PublicFile.objects.filter_active().select_related("created_by", "updated_by", "deleted_by")
23+
24+
@utils.extend_schema(
25+
tags=[OpenAPITag.ADMIN_PUBLIC_FILE],
26+
responses={200: PublicFileAdminSerializer},
27+
)
28+
@decorators.action(
29+
detail=False,
30+
methods=["post"],
31+
url_path="upload",
32+
serializer_class=PublicFileAdmimUploadSerializer,
33+
parser_classes=[parsers.MultiPartParser, parsers.FileUploadParser],
34+
)
35+
def upload(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
36+
if "file" not in request.FILES:
37+
raise serializers.ValidationError({"file": "This field is required."})
38+
39+
serializer = PublicFileAdmimUploadSerializer(data=request.FILES)
40+
serializer.is_valid(raise_exception=True)
41+
instance = serializer.save()
42+
43+
file_data = PublicFileAdminSerializer(instance=instance).data
44+
return response.Response(data=file_data, status=status.HTTP_201_CREATED)

app/admin_api/views/user.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from admin_api.serializers.user import UserAdminSerializer, UserAdminSignInSerializer
2+
from core.const.tag import OpenAPITag
3+
from core.permissions import IsSuperUser
4+
from core.viewset.json_schema_viewset import JsonSchemaViewSet
5+
from django.contrib.auth import login, logout
6+
from drf_spectacular.utils import extend_schema, extend_schema_view
7+
from rest_framework import decorators, request, response, status, viewsets
8+
from user.models import UserExt
9+
10+
ADMIN_METHODS = ["list", "retrieve", "create", "partial_update", "destroy"]
11+
12+
13+
@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_USER]) for m in ADMIN_METHODS})
14+
class UserAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet):
15+
http_method_names = ["get", "post", "patch", "delete"]
16+
serializer_class = UserAdminSerializer
17+
permission_classes = [IsSuperUser]
18+
queryset = UserExt.objects.filter(is_active=True)
19+
20+
@extend_schema(tags=[OpenAPITag.ADMIN_USER], responses={status.HTTP_200_OK: UserAdminSerializer})
21+
@decorators.action(detail=False, methods=["GET"], permission_classes=[])
22+
def me(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
23+
if not request.user.is_authenticated:
24+
return response.Response(status=status.HTTP_401_UNAUTHORIZED)
25+
26+
return response.Response(data=UserAdminSerializer(request.user).data)
27+
28+
@extend_schema(
29+
tags=[OpenAPITag.ADMIN_USER],
30+
request=UserAdminSignInSerializer,
31+
responses={status.HTTP_200_OK: UserAdminSerializer},
32+
)
33+
@decorators.action(detail=False, methods=["POST"], url_path="signin", permission_classes=[])
34+
def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
35+
serializer = UserAdminSignInSerializer(data=request.data)
36+
serializer.is_valid(raise_exception=True)
37+
38+
login(request=request, user=serializer.user)
39+
return response.Response(data=UserAdminSerializer(serializer.user).data)
40+
41+
@extend_schema(tags=[OpenAPITag.ADMIN_USER], responses={status.HTTP_204_NO_CONTENT: None})
42+
@decorators.action(detail=False, methods=["DELETE"], url_path="signout", permission_classes=[])
43+
def signout(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
44+
logout(request=request)
45+
return response.Response(status=status.HTTP_204_NO_CONTENT)

app/cms/serializers.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,23 @@
11
from cms.models import Page, Section, Sitemap
2+
from core.const.serializer import COMMON_FIELDS
23
from rest_framework import serializers
34

45

56
class SitemapSerializer(serializers.ModelSerializer):
67
class Meta:
78
model = Sitemap
8-
fields = ("id", "parent_sitemap", "route_code", "name", "order", "page")
9+
fields = COMMON_FIELDS + ("parent_sitemap", "route_code", "name", "order", "page")
910

1011

1112
class SectionSerializer(serializers.ModelSerializer):
1213
class Meta:
1314
model = Section
14-
fields = ("id", "order", "css", "body")
15+
fields = COMMON_FIELDS + ("order", "css", "body")
1516

1617

1718
class PageSerializer(serializers.ModelSerializer):
1819
sections = SectionSerializer(many=True, read_only=True)
1920

2021
class Meta:
2122
model = Page
22-
fields = ("id", "title", "subtitle", "css", "sections", "created_at", "updated_at")
23+
fields = COMMON_FIELDS + ("title", "subtitle", "css", "sections")

0 commit comments

Comments
 (0)