diff --git a/app/admin_api/serializers/event/event.py b/app/admin_api/serializers/event/event.py new file mode 100644 index 0000000..535c9aa --- /dev/null +++ b/app/admin_api/serializers/event/event.py @@ -0,0 +1,11 @@ +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 event.models import Event +from rest_framework import serializers + + +class EventAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = Event + fields = COMMON_ADMIN_FIELDS + ("organization", "name_ko", "name_en") diff --git a/app/admin_api/serializers/event/presentation.py b/app/admin_api/serializers/event/presentation.py new file mode 100644 index 0000000..10204d3 --- /dev/null +++ b/app/admin_api/serializers/event/presentation.py @@ -0,0 +1,29 @@ +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 event.presentation.models import Presentation, PresentationCategory, PresentationSpeaker, PresentationType +from rest_framework import serializers + + +class PresentationTypeAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = PresentationType + fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en") + + +class PresentationCategoryAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = PresentationCategory + fields = COMMON_ADMIN_FIELDS + ("type", "name_ko", "name_en") + + +class PresentationAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = Presentation + fields = COMMON_ADMIN_FIELDS + ("title_ko", "title_en") + + +class PresentationSpeakerAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = PresentationSpeaker + fields = COMMON_ADMIN_FIELDS + ("presentation", "user", "biography_ko", "biography_en") diff --git a/app/admin_api/serializers/event/sponsor.py b/app/admin_api/serializers/event/sponsor.py new file mode 100644 index 0000000..0f63a10 --- /dev/null +++ b/app/admin_api/serializers/event/sponsor.py @@ -0,0 +1,17 @@ +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 event.sponsor.models import Sponsor, SponsorTier +from rest_framework import serializers + + +class SponsorTierAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = SponsorTier + fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en", "order") + + +class SponsorAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + class Meta: + model = Sponsor + fields = COMMON_ADMIN_FIELDS + ("event", "logo", "page", "name_ko", "name_en") diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py index 3a7f7f0..a56c054 100644 --- a/app/admin_api/serializers/user.py +++ b/app/admin_api/serializers/user.py @@ -7,10 +7,28 @@ from user.models import UserExt -class UserAdminSerializer(JsonSchemaSerializer, ReadOnlyModelSerializer, serializers.ModelSerializer): +class UserAdminSerializer(JsonSchemaSerializer, serializers.ModelSerializer): + str_repr = serializers.CharField(source="__str__", read_only=True) + class Meta: model = UserExt - fields = ("id", "username", "email", "first_name", "last_name", "is_staff", "is_active", "date_joined") + fields = ( + "id", + "is_active", + "username", + "nickname_ko", + "nickname_en", + "email", + "is_superuser", + "str_repr", + "date_joined", + "last_login", + ) + extra_kwargs = { + "id": {"read_only": True}, + "date_joined": {"read_only": True}, + "last_login": {"read_only": True}, + } class UserAdminSignInSerializerData(typing.TypedDict): @@ -35,4 +53,42 @@ def validate(self, attrs: UserAdminSignInSerializerData) -> UserAdminSignInSeria if not (self.user and self.user.check_password(attrs["password"])): raise serializers.ValidationError("User not found or inactive or wrong password.") + if not self.user.is_superuser: + raise serializers.PermissionDenied("Only permissioned users can sign in using this route.") + return attrs + + +class UserAdminPasswordChangeSerializerData(typing.TypedDict): + old_password: str + new_password: str + new_password_confirm: str + + +class UserAdminPasswordChangeSerializer(JsonSchemaSerializer, ReadOnlyModelSerializer): + old_password = serializers.CharField(write_only=True, required=True) + new_password = serializers.CharField(write_only=True, required=True) + new_password_confirm = serializers.CharField(write_only=True, required=True) + + class Meta: + model = UserExt + fields = ("old_password", "new_password", "new_password_confirm") + + def validate(self, attrs: UserAdminPasswordChangeSerializerData) -> UserAdminPasswordChangeSerializerData: + user: UserExt = self.instance + if not user.check_password(attrs["old_password"]): + raise serializers.ValidationError("Old password is incorrect.") + + if attrs["old_password"] == attrs["new_password"]: + raise serializers.ValidationError("New password cannot be the same as the old password.") + + if attrs["new_password"] != attrs["new_password_confirm"]: + raise serializers.ValidationError("New password and confirmation do not match.") + + return attrs + + def save(self, **kwargs: typing.Any) -> UserExt: + user: UserExt = self.instance + user.set_password(self.validated_data["new_password"]) + user.save(update_fields=["password"]) + return user diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index 82807c9..f0fd0b3 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -1,4 +1,11 @@ from admin_api.views.cms import PageAdminViewSet, SitemapAdminViewSet +from admin_api.views.event.event import EventAdminViewSet +from admin_api.views.event.presentation import ( + PresentationAdminViewSet, + PresentationSpeakerAdminViewSet, + PresentationTypeAdminViewSet, +) +from admin_api.views.event.sponsor import SponsorAdminViewSet, SponsorTierAdminViewSet from admin_api.views.file import PublicFileAdminViewSet from admin_api.views.user import UserAdminViewSet from django.urls import include, path @@ -14,8 +21,20 @@ admin_file_router = routers.SimpleRouter() admin_file_router.register("publicfile", PublicFileAdminViewSet, basename="admin-public-file") +admin_event_router = routers.SimpleRouter() +admin_event_router.register("event", EventAdminViewSet) +admin_event_router.register("sponsortier", SponsorTierAdminViewSet) +admin_event_router.register("sponsor", SponsorAdminViewSet) +admin_event_router.register("presentationtype", PresentationTypeAdminViewSet) +admin_event_router.register("presentation", PresentationAdminViewSet) +admin_event_router.register( + "presentation/(?P{UUID_V4_PATTERN})/speaker", + PresentationSpeakerAdminViewSet, +) + urlpatterns = [ path("cms/", include(admin_cms_router.urls)), path("file/", include(admin_file_router.urls)), path("user/", include(admin_user_router.urls)), + path("event/", include(admin_event_router.urls)), ] diff --git a/app/admin_api/views/event/event.py b/app/admin_api/views/event/event.py new file mode 100644 index 0000000..759d47c --- /dev/null +++ b/app/admin_api/views/event/event.py @@ -0,0 +1,19 @@ +from __future__ import annotations + +from admin_api.serializers.event.event import EventAdminSerializer +from core.const.tag import OpenAPITag +from core.permissions import IsSuperUser +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from drf_spectacular.utils import extend_schema, extend_schema_view +from event.models import Event +from rest_framework import viewsets + +ADMIN_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_EVENT]) for m in ADMIN_METHODS}) +class EventAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = EventAdminSerializer + permission_classes = [IsSuperUser] + queryset = Event.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") diff --git a/app/admin_api/views/event/presentation.py b/app/admin_api/views/event/presentation.py new file mode 100644 index 0000000..bb7d8b3 --- /dev/null +++ b/app/admin_api/views/event/presentation.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +from admin_api.serializers.event.presentation import ( + PresentationAdminSerializer, + PresentationCategoryAdminSerializer, + PresentationSpeakerAdminSerializer, + PresentationTypeAdminSerializer, +) +from core.const.regex import UUID_V4_PATTERN +from core.const.tag import OpenAPITag +from core.permissions import IsSuperUser +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from django.db import models +from drf_spectacular.utils import extend_schema, extend_schema_view +from event.presentation.models import ( + Presentation, + PresentationCategory, + PresentationCategoryRelation, + PresentationSpeaker, + PresentationType, +) +from rest_framework import decorators, exceptions, request, response, status, viewsets + +ADMIN_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) for m in ADMIN_METHODS}) +class PresentationTypeAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = PresentationTypeAdminSerializer + permission_classes = [IsSuperUser] + queryset = PresentationType.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + + @extend_schema( + tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], + responses={status.HTTP_200_OK: PresentationCategoryAdminSerializer(many=True)}, + ) + @decorators.action(detail=True, methods=["get"], url_path="categories") + def list_categories(self, *args: tuple, **kwargs: dict) -> response.Response: + categories = PresentationCategory.objects.filter_active().filter(type=self.get_object()) + return response.Response(data=PresentationCategoryAdminSerializer(instance=categories, many=True).data) + + @extend_schema( + tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], + request=PresentationCategoryAdminSerializer, + responses={status.HTTP_201_CREATED: PresentationCategoryAdminSerializer}, + ) + @decorators.action(detail=True, methods=["post"], url_path="categories") + def add_category(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + serializer = PresentationCategoryAdminSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(data=serializer.data, status=status.HTTP_201_CREATED) + + @extend_schema( + tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], + request=PresentationCategoryAdminSerializer, + responses={status.HTTP_200_OK: PresentationCategoryAdminSerializer}, + ) + @decorators.action(detail=True, methods=["patch"], url_path=f"categories/(?P{UUID_V4_PATTERN})") + def update_category(self, request: request.Request, pk: str, category_id: str, *args, **kwargs): + if not (category := PresentationCategory.objects.filter_active().filter(type_id=pk, id=category_id).first()): + raise exceptions.NotFound(detail="Category not found.") + + serializer = PresentationCategoryAdminSerializer(instance=category, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(data=serializer.data) + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], responses={status.HTTP_204_NO_CONTENT: None}) + @decorators.action(detail=True, methods=["delete"], url_path=f"categories/(?P{UUID_V4_PATTERN})") + def delete_category(self, pk: str, category_id: str, *args: tuple, **kwargs: dict) -> response.Response: + if not (category := PresentationCategory.objects.filter_active().filter(type_id=pk, id=category_id).first()): + raise exceptions.NotFound(detail="Category not found.") + + category.delete() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) for m in ADMIN_METHODS}) +class PresentationAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = PresentationAdminSerializer + permission_classes = [IsSuperUser] + queryset = Presentation.objects.get_all_nested_data().select_related("created_by", "updated_by", "deleted_by") + + @extend_schema( + tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], + responses={status.HTTP_200_OK: PresentationCategoryAdminSerializer(many=True)}, + ) + @decorators.action(detail=True, methods=["get"], url_path="categories") + def list_categories(self, *args: tuple, **kwargs: dict) -> response.Response: + categories = self.get_object().active_categories + return response.Response(data=PresentationCategoryAdminSerializer(instance=categories, many=True).data) + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], responses={status.HTTP_201_CREATED: None}) + @decorators.action(detail=True, methods=["post"], url_path="categories/(?P{UUID_V4_PATTERN})") + def add_category(self, pk: str, category_id: str, *args: tuple, **kwargs: dict) -> response.Response: + PresentationCategoryRelation.objects.get_or_create(presentation_id=pk, category_id=category_id) + return response.Response(status=status.HTTP_201_CREATED) + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION], responses={status.HTTP_204_NO_CONTENT: None}) + @decorators.action(detail=True, methods=["delete"], url_path="categories/(?P{UUID_V4_PATTERN})") + def remove_category(self, pk: str, category_id: str, *args: tuple, **kwargs: dict) -> response.Response: + if not ( + relation := PresentationCategoryRelation.objects.filter(presentation_id=pk, category_id=category_id).first() + ): + raise exceptions.NotFound(detail="Category is not associated with this presentation.") + + relation.delete() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) for m in ADMIN_METHODS}) +class PresentationSpeakerAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = PresentationSpeakerAdminSerializer + permission_classes = [IsSuperUser] + queryset = PresentationSpeaker.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + + def get_queryset(self) -> models.QuerySet[PresentationSpeaker]: + return super().get_queryset().filter(presentation_id=self.kwargs["presentation_id"]) diff --git a/app/admin_api/views/event/sponsor.py b/app/admin_api/views/event/sponsor.py new file mode 100644 index 0000000..17b6cdc --- /dev/null +++ b/app/admin_api/views/event/sponsor.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from admin_api.serializers.event.sponsor import SponsorAdminSerializer, SponsorTierAdminSerializer +from core.const.regex import UUID_V4_PATTERN +from core.const.tag import OpenAPITag +from core.permissions import IsSuperUser +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from django.db import models +from drf_spectacular.utils import extend_schema, extend_schema_view +from event.sponsor.models import Sponsor, SponsorTier, SponsorTierSponsorRelation +from rest_framework import decorators, exceptions, response, status, viewsets + +ADMIN_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_SPONSOR]) for m in ADMIN_METHODS}) +class SponsorTierAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = SponsorTierAdminSerializer + permission_classes = [IsSuperUser] + queryset = ( + SponsorTier.objects.filter_active() + .prefetch_related( + models.Prefetch( + lookup="sponsors", + queryset=Sponsor.objects.filter_active().select_related("created_by", "updated_by", "deleted_by"), + to_attr="_prefetched_active_sponsors", + ), + ) + .select_related("created_by", "updated_by", "deleted_by") + ) + + @extend_schema( + tags=[OpenAPITag.ADMIN_EVENT_SPONSOR], + responses={status.HTTP_200_OK: SponsorAdminSerializer(many=True)}, + ) + @decorators.action(detail=True, methods=["get"], url_path="sponsors") + def list_sponsors(self, *args: tuple, **kwargs: dict) -> response.Response: + tier: SponsorTier = self.get_object() + return response.Response(data=SponsorAdminSerializer(instance=tier.active_sponsors, many=True).data) + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_SPONSOR], responses={status.HTTP_201_CREATED: SponsorAdminSerializer}) + @decorators.action(detail=True, methods=["post"], url_path=f"sponsors/(?P{UUID_V4_PATTERN})") + def add_sponsor(self, sponsor_id: str, *args: tuple, **kwargs: dict) -> response.Response: + tier: SponsorTier = self.get_object() + if not (sponsor := Sponsor.objects.filter_active().filter(id=sponsor_id).first()): + raise exceptions.NotFound(detail="Sponsor not found") + + SponsorTierSponsorRelation.objects.get_or_create(tier=tier, sponsor=sponsor) + return response.Response(data=SponsorAdminSerializer(instance=sponsor).data, status=status.HTTP_201_CREATED) + + @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_SPONSOR], responses={status.HTTP_204_NO_CONTENT: None}) + @decorators.action(detail=True, methods=["delete"], url_path=f"sponsors/(?P{UUID_V4_PATTERN})") + def remove_sponsor(self, pk: str, sponsor_id: str, *args: tuple, **kwargs: dict) -> response.Response: + if not (relation := SponsorTierSponsorRelation.objects.filter(tier_id=pk, sponsor_id=sponsor_id).first()): + raise exceptions.NotFound(detail="Sponsor is not associated with this tier") + + relation.delete() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_SPONSOR]) for m in ADMIN_METHODS}) +class SponsorAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = SponsorAdminSerializer + permission_classes = [IsSuperUser] + queryset = Sponsor.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + + @extend_schema( + tags=[OpenAPITag.ADMIN_EVENT_SPONSOR], + responses={status.HTTP_200_OK: SponsorTierAdminSerializer(many=True)}, + ) + @decorators.action(detail=True, methods=["get"], url_path="tiers") + def list_tiers(self, *args: tuple, **kwargs: dict) -> response.Response: + sponsor: Sponsor = self.get_object() + tier_id_qs = SponsorTierSponsorRelation.objects.filter(sponsor=sponsor).values_list("tier_id", flat=True) + tiers = SponsorTier.objects.filter_active().filter(id__in=tier_id_qs) + return response.Response(data=SponsorTierAdminSerializer(instance=tiers, many=True).data) diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py index d2f83ff..73c1ce6 100644 --- a/app/admin_api/views/user.py +++ b/app/admin_api/views/user.py @@ -1,23 +1,35 @@ -from admin_api.serializers.user import UserAdminSerializer, UserAdminSignInSerializer +from admin_api.serializers.user import ( + UserAdminPasswordChangeSerializer, + UserAdminSerializer, + UserAdminSignInSerializer, +) +from core.const.account import INITIAL_ADMIN_PASSWORD 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 rest_framework import decorators, mixins, 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): +class UserAdminViewSet( + mixins.RetrieveModelMixin, + mixins.ListModelMixin, + mixins.CreateModelMixin, + mixins.UpdateModelMixin, + JsonSchemaViewSet, + viewsets.GenericViewSet, +): 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}) + @extend_schema(tags=[OpenAPITag.ADMIN_ACCOUNT], 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: @@ -26,7 +38,7 @@ def me(self, request: request.Request, *args: tuple, **kwargs: dict) -> response return response.Response(data=UserAdminSerializer(request.user).data) @extend_schema( - tags=[OpenAPITag.ADMIN_USER], + tags=[OpenAPITag.ADMIN_ACCOUNT], request=UserAdminSignInSerializer, responses={status.HTTP_200_OK: UserAdminSerializer}, ) @@ -38,8 +50,28 @@ def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> resp 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}) + @extend_schema(tags=[OpenAPITag.ADMIN_ACCOUNT], 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) + + @extend_schema(tags=[OpenAPITag.ADMIN_USER], responses={status.HTTP_204_NO_CONTENT: None}) + @decorators.action(detail=True, methods=["DELETE"], url_path="password") + def reset_password(self, *args: tuple, **kwargs: dict) -> response.Response: + user: UserExt = self.get_object() + user.set_password(INITIAL_ADMIN_PASSWORD) + user.save(update_fields=["password"]) + return response.Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + tags=[OpenAPITag.ADMIN_ACCOUNT], + request=UserAdminPasswordChangeSerializer, + responses={status.HTTP_200_OK: UserAdminSerializer}, + ) + @decorators.action(detail=False, methods=["POST"], url_path="password", permission_classes=[IsSuperUser]) + def change_password(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + serializer = UserAdminPasswordChangeSerializer(data=request.data, instance=request.user) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(data=UserAdminSerializer(serializer.instance).data) diff --git a/app/core/const/account.py b/app/core/const/account.py new file mode 100644 index 0000000..94ed22c --- /dev/null +++ b/app/core/const/account.py @@ -0,0 +1 @@ +INITIAL_ADMIN_PASSWORD = "pyconkradmin12!@" # nosec: B105 diff --git a/app/core/const/regex.py b/app/core/const/regex.py new file mode 100644 index 0000000..5dcf505 --- /dev/null +++ b/app/core/const/regex.py @@ -0,0 +1,4 @@ +import re + +UUID_V4_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" +UUID_V4_REGEX = re.compile(f"^{UUID_V4_PATTERN}$", re.IGNORECASE) diff --git a/app/core/const/tag.py b/app/core/const/tag.py index d52e9e6..56dcc19 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -1,7 +1,13 @@ class OpenAPITag: CMS = "CMS" + EVENT_PRESENTATION = "Event > Presentation" + EVENT_SPONSOR = "Event > Sponsor" + ADMIN_ACCOUNT = "Admin > Sign-In & Sign-Out" ADMIN_USER = "Admin > User" ADMIN_CMS = "Admin > CMS" ADMIN_PUBLIC_FILE = "Admin > Public File" + ADMIN_EVENT_EVENT = "Admin > Event > Event" + ADMIN_EVENT_PRESENTATION = "Admin > Event > Presentation" + ADMIN_EVENT_SPONSOR = "Admin > Event > Sponsor" ADMIN_JSON_SCHEMA = "Admin > JSON Schema" diff --git a/app/core/settings.py b/app/core/settings.py index 883255e..445c5ea 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -317,7 +317,7 @@ COOKIE_SAMESITE = "Lax" if IS_LOCAL else "None" COOKIE_SECURE = not IS_LOCAL COOKIE_HTTPONLY = True -COOKIE_DOMAIN = env("COOKIE_DOMAIN", default="pycon.kr") +COOKIE_DOMAIN = env("COOKIE_DOMAIN", default="pycon.kr") if not IS_LOCAL else None COOKIE_TRUSTED_ORIGIN_SET = { f"{protocol}://{domain}:{port}" for protocol in ("http", "https") diff --git a/app/core/urls.py b/app/core/urls.py index dd2e0e9..b2eae50 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -26,7 +26,8 @@ v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [ path("cms/", include("cms.urls")), path("admin-api/", include("admin_api.urls")), - path("event/presentations/", include("event.presentation.urls")), + path("event/presentation/", include("event.presentation.urls")), + path("event/sponsor/", include("event.sponsor.urls")), ] urlpatterns = [ diff --git a/app/event/migrations/0002_alter_event_options_alter_event_name_and_more.py b/app/event/migrations/0002_alter_event_options_alter_event_name_and_more.py new file mode 100644 index 0000000..da194f8 --- /dev/null +++ b/app/event/migrations/0002_alter_event_options_alter_event_name_and_more.py @@ -0,0 +1,46 @@ +# Generated by Django 5.2 on 2025-06-08 08:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("event", "0001_initial"), + ] + + operations = [ + migrations.AlterModelOptions( + name="event", + options={"ordering": ["-event_start_at", "-event_end_at"]}, + ), + migrations.AlterField( + model_name="event", + name="name", + field=models.CharField(max_length=256, unique=True), + ), + migrations.AlterField( + model_name="event", + name="name_en", + field=models.CharField(max_length=256, null=True, unique=True), + ), + migrations.AlterField( + model_name="event", + name="name_ko", + field=models.CharField(max_length=256, null=True, unique=True), + ), + migrations.AlterField( + model_name="historicalevent", + name="name", + field=models.CharField(db_index=True, max_length=256), + ), + migrations.AlterField( + model_name="historicalevent", + name="name_en", + field=models.CharField(max_length=256, null=True, unique=True), + ), + migrations.AlterField( + model_name="historicalevent", + name="name_ko", + field=models.CharField(max_length=256, null=True, unique=True), + ), + ] diff --git a/app/event/models.py b/app/event/models.py index 4ae19a8..5aea1e4 100644 --- a/app/event/models.py +++ b/app/event/models.py @@ -1,12 +1,12 @@ from core.models import BaseAbstractModel from django.core.exceptions import ValidationError from django.db import models -from user.models import Organization +from user.models.organization import Organization class Event(BaseAbstractModel): organization = models.ForeignKey(Organization, on_delete=models.PROTECT, related_name="events") - name = models.CharField(max_length=256, null=True, blank=True) + name = models.CharField(max_length=256, unique=True) banner_image = models.TextField(null=True, blank=True) slogan = models.CharField(max_length=1000, null=True, blank=True) description = models.CharField(max_length=1000, null=True, blank=True) @@ -15,6 +15,12 @@ class Event(BaseAbstractModel): banner_display_start_at = models.DateTimeField(null=True, blank=True) banner_display_end_at = models.DateTimeField(null=True, blank=True) + class Meta: + ordering = ["-event_start_at", "-event_end_at"] + + def __str__(self): + return f"{self.name} by {self.organization}" + def clean(self) -> None: super().clean() if self.event_start_at and self.event_end_at and self.event_start_at > self.event_end_at: diff --git a/app/event/presentation/migrations/0002_rename_presentation_type_historicalpresentation_type_and_more.py b/app/event/presentation/migrations/0002_rename_presentation_type_historicalpresentation_type_and_more.py new file mode 100644 index 0000000..56e7b63 --- /dev/null +++ b/app/event/presentation/migrations/0002_rename_presentation_type_historicalpresentation_type_and_more.py @@ -0,0 +1,274 @@ +# Generated by Django 5.2 on 2025-06-08 08:51 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0007_historicalpage_show_bottom_sponsor_banner_and_more"), + ("event", "0002_alter_event_options_alter_event_name_and_more"), + ("presentation", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RenameField( + model_name="historicalpresentation", + old_name="presentation_type", + new_name="type", + ), + migrations.RenameField( + model_name="historicalpresentationcategory", + old_name="presentation_type", + new_name="type", + ), + migrations.RenameField( + model_name="presentation", + old_name="presentation_categories", + new_name="categories", + ), + migrations.RemoveField( + model_name="historicalpresentationspeaker", + name="name", + ), + migrations.RemoveField( + model_name="historicalpresentationspeaker", + name="name_en", + ), + migrations.RemoveField( + model_name="historicalpresentationspeaker", + name="name_ko", + ), + migrations.RemoveField( + model_name="presentation", + name="presentation_type", + ), + migrations.RemoveField( + model_name="presentationcategory", + name="presentation_type", + ), + migrations.RemoveField( + model_name="presentationspeaker", + name="name", + ), + migrations.RemoveField( + model_name="presentationspeaker", + name="name_en", + ), + migrations.RemoveField( + model_name="presentationspeaker", + name="name_ko", + ), + migrations.AddField( + model_name="historicalpresentation", + name="sitemap", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.sitemap", + ), + ), + migrations.AddField( + model_name="historicalpresentation", + name="title", + field=models.CharField(default="", max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name="historicalpresentation", + name="title_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AddField( + model_name="historicalpresentation", + name="title_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AddField( + model_name="presentation", + name="sitemap", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="cms.sitemap" + ), + ), + migrations.AddField( + model_name="presentation", + name="title", + field=models.CharField(default="", max_length=256), + preserve_default=False, + ), + migrations.AddField( + model_name="presentation", + name="title_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AddField( + model_name="presentation", + name="title_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AddField( + model_name="presentation", + name="type", + field=models.ForeignKey( + default=None, on_delete=django.db.models.deletion.PROTECT, to="presentation.presentationtype" + ), + preserve_default=False, + ), + migrations.AddField( + model_name="presentationcategory", + name="type", + field=models.ForeignKey( + default=None, on_delete=django.db.models.deletion.PROTECT, to="presentation.presentationtype" + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="historicalpresentationcategory", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="historicalpresentationcategory", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalpresentationcategory", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalpresentationspeaker", + name="biography", + field=models.TextField(blank=True, default=""), + ), + migrations.AlterField( + model_name="historicalpresentationspeaker", + name="biography_en", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AlterField( + model_name="historicalpresentationspeaker", + name="biography_ko", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AlterField( + model_name="historicalpresentationtype", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="historicalpresentationtype", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalpresentationtype", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="presentationcategory", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="presentationcategory", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="presentationcategory", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="presentationcategoryrelation", + name="category", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="presentation.presentationcategory" + ), + ), + migrations.AlterField( + model_name="presentationcategoryrelation", + name="presentation", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="presentation.presentation"), + ), + migrations.AlterField( + model_name="presentationspeaker", + name="biography", + field=models.TextField(blank=True, default=""), + ), + migrations.AlterField( + model_name="presentationspeaker", + name="biography_en", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AlterField( + model_name="presentationspeaker", + name="biography_ko", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AlterField( + model_name="presentationspeaker", + name="presentation", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="speakers", to="presentation.presentation" + ), + ), + migrations.AlterField( + model_name="presentationspeaker", + name="user", + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name="presentationtype", + name="event", + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="event.event"), + ), + migrations.AlterField( + model_name="presentationtype", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="presentationtype", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="presentationtype", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AddConstraint( + model_name="presentationcategory", + constraint=models.UniqueConstraint(fields=("type", "name"), name="uq__prst_cat__type__name"), + ), + migrations.AddConstraint( + model_name="presentationcategory", + constraint=models.UniqueConstraint(fields=("type", "name_ko"), name="uq__prst_cat__type__name-name_ko"), + ), + migrations.AddConstraint( + model_name="presentationcategory", + constraint=models.UniqueConstraint(fields=("type", "name_en"), name="uq__prst_cat__type__name-name_en"), + ), + migrations.AddConstraint( + model_name="presentationtype", + constraint=models.UniqueConstraint(fields=("event", "name"), name="uq__prst_type__event__name"), + ), + migrations.AddConstraint( + model_name="presentationtype", + constraint=models.UniqueConstraint(fields=("event", "name_ko"), name="uq__prst_type__event__name-name_ko"), + ), + migrations.AddConstraint( + model_name="presentationtype", + constraint=models.UniqueConstraint(fields=("event", "name_en"), name="uq__prst_type__event__name-name_en"), + ), + ] diff --git a/app/event/presentation/migrations/0003_remove_historicalpresentation_sitemap_and_more.py b/app/event/presentation/migrations/0003_remove_historicalpresentation_sitemap_and_more.py new file mode 100644 index 0000000..f814b24 --- /dev/null +++ b/app/event/presentation/migrations/0003_remove_historicalpresentation_sitemap_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2 on 2025-06-08 10:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0007_historicalpage_show_bottom_sponsor_banner_and_more"), + ("presentation", "0002_rename_presentation_type_historicalpresentation_type_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalpresentation", + name="sitemap", + ), + migrations.RemoveField( + model_name="presentation", + name="sitemap", + ), + migrations.AddField( + model_name="historicalpresentation", + name="page", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.page", + ), + ), + migrations.AddField( + model_name="presentation", + name="page", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="cms.page"), + ), + ] diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index 96ba766..f8711a4 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -1,3 +1,9 @@ +from __future__ import annotations + +import collections.abc +import contextlib +import functools + from core.models import BaseAbstractModel, BaseAbstractModelQuerySet from django.contrib.auth import get_user_model from django.db import models @@ -8,39 +14,75 @@ class PresentationQuerySet(BaseAbstractModelQuerySet): def get_all_nested_data(self): - return ( - self.filter_active() - .select_related("presentation_type") - .prefetch_related("presentation_speakers", "presentation_categories") + return self.filter_active().prefetch_related( + models.Prefetch( + lookup="categories", + queryset=PresentationCategory.objects.filter_active(), + to_attr="_prefetched_active_categories", + ), + models.Prefetch( + lookup="speakers", + queryset=PresentationSpeaker.objects.filter_active().select_related("user"), + to_attr="_prefetched_active_speakers", + ), ) class PresentationType(BaseAbstractModel): - event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="presentation_types", null=True, blank=True) - name = models.CharField(max_length=256, null=True, blank=True) + event = models.ForeignKey(Event, on_delete=models.PROTECT) + name = models.CharField(max_length=256) + + class Meta: + constraints = [models.UniqueConstraint(fields=["event", "name"], name="uq__prst_type__event__name")] + + def __str__(self) -> str: + return f"[{self.event.name}] {self.name}" class PresentationCategory(BaseAbstractModel): - presentation_type = models.ForeignKey( - PresentationType, on_delete=models.PROTECT, related_name="presentation_categories" - ) - name = models.CharField(max_length=256, null=True, blank=True) + type = models.ForeignKey(PresentationType, on_delete=models.PROTECT) + name = models.CharField(max_length=256) + + class Meta: + constraints = [models.UniqueConstraint(fields=["type", "name"], name="uq__prst_cat__type__name")] + + def __str__(self) -> str: + return self.name class Presentation(BaseAbstractModel): - presentation_type = models.ForeignKey(PresentationType, on_delete=models.PROTECT, related_name="presentations") - presentation_categories = models.ManyToManyField(to="PresentationCategory", through="PresentationCategoryRelation") + type = models.ForeignKey(PresentationType, on_delete=models.PROTECT) + title = models.CharField(max_length=256) + page = models.ForeignKey(to="cms.Page", on_delete=models.PROTECT, null=True, blank=True) + + categories = models.ManyToManyField(to="PresentationCategory", through="PresentationCategoryRelation") objects: PresentationQuerySet = PresentationQuerySet.as_manager() + def __str__(self) -> str: + return f"[{self.type.name}] {self.title}" + + @functools.cached_property + def active_categories(self) -> collections.abc.Iterable[PresentationCategory]: + with contextlib.suppress(AttributeError): + return self._prefetched_active_categories + + return self.categories.filter_active() + + @functools.cached_property + def active_speakers(self) -> collections.abc.Iterable[PresentationSpeaker]: + with contextlib.suppress(AttributeError): + return self._prefetched_active_speakers + + return self.speakers.filter_active().select_related("user") + class PresentationCategoryRelation(models.Model): - presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE, related_name="relations") - category = models.ForeignKey(PresentationCategory, on_delete=models.CASCADE, related_name="relations") + presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE) + category = models.ForeignKey(PresentationCategory, on_delete=models.CASCADE) class PresentationSpeaker(BaseAbstractModel): - presentation = models.ForeignKey(Presentation, on_delete=models.PROTECT, related_name="presentation_speakers") - user = models.ForeignKey(User, on_delete=models.PROTECT, related_name="presentation_speakers") - name = models.CharField(max_length=256, null=True, blank=True) - biography = models.CharField(max_length=256, null=True, blank=True) + presentation = models.ForeignKey(Presentation, on_delete=models.PROTECT, related_name="speakers") + user = models.ForeignKey(User, on_delete=models.PROTECT) + biography = models.TextField(blank=True, default="") diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py index 3a04fb0..08b283f 100644 --- a/app/event/presentation/serializers.py +++ b/app/event/presentation/serializers.py @@ -1,30 +1,25 @@ -from event.presentation.models import Presentation, PresentationCategory, PresentationSpeaker, PresentationType +from event.presentation.models import Presentation, PresentationCategory, PresentationSpeaker from rest_framework import serializers -class PresentationTypeSerializer(serializers.ModelSerializer): - class Meta: - model = PresentationType - fields = ("id", "event", "name") - - class PresentationCategorySerializer(serializers.ModelSerializer): class Meta: model = PresentationCategory - fields = ("id", "presentation_type", "name") + fields = ("id", "name") class PresentationSpeakerSerializer(serializers.ModelSerializer): + nickname = serializers.CharField(source="user.nickname", read_only=True) + class Meta: model = PresentationSpeaker - fields = ("id", "presentation", "user", "name", "biography") + fields = ("id", "nickname", "biography") class PresentationSerializer(serializers.ModelSerializer): - presentation_type = PresentationTypeSerializer(read_only=True) - presentation_categories = PresentationCategorySerializer(many=True, read_only=True) - presentation_speakers = PresentationSpeakerSerializer(many=True, read_only=True) + categories = PresentationCategorySerializer(source="active_categories", many=True, read_only=True) + speakers = PresentationSpeakerSerializer(source="active_speakers", many=True, read_only=True) class Meta: model = Presentation - fields = ("id", "presentation_type", "presentation_categories", "presentation_speakers") + fields = ("id", "title", "categories", "speakers") diff --git a/app/event/presentation/test/api_test.py b/app/event/presentation/test/api_test.py index cf8eebe..d98d4a6 100644 --- a/app/event/presentation/test/api_test.py +++ b/app/event/presentation/test/api_test.py @@ -1,4 +1,5 @@ import http +import uuid import pytest from django.urls import reverse @@ -16,8 +17,19 @@ def test_presentation_api(api_client: APIClient, create_presentation_set: Presen @pytest.mark.django_db def test_presentation_category_filter_api(api_client: APIClient, create_presentation_set: PresentationTestEntity): - presentation_category = create_presentation_set.presentation_category - url = reverse("v1:presentation-list") + f"?category={presentation_category.name}" + url = f"{reverse('v1:presentation-list')}?categories={create_presentation_set.presentation_category.id}" response = api_client.get(url) + assert response.status_code == http.HTTPStatus.OK assert len(response.json()) > 0 + + +@pytest.mark.django_db +def test_presentation_category_filter_api_should_not_return_unknown_category_id( + api_client: APIClient, create_presentation_set: PresentationTestEntity +): + url = f"{reverse('v1:presentation-list')}?categories={uuid.uuid4()}" + response = api_client.get(url) + + assert response.status_code == http.HTTPStatus.OK + assert len(response.json()) == 0, "Should not return any presentations for unknown category ID" diff --git a/app/event/presentation/test/conftest.py b/app/event/presentation/test/conftest.py index e8f0964..b694037 100644 --- a/app/event/presentation/test/conftest.py +++ b/app/event/presentation/test/conftest.py @@ -12,7 +12,8 @@ from faker import Faker from model_bakery import baker from rest_framework.test import APIClient -from user.models import Organization, OrganizationUserRelation, UserExt +from user.models.organization import Organization, OrganizationUserRelation +from user.models.user import UserExt @dataclasses.dataclass @@ -47,8 +48,8 @@ def create_presentation_set(create_event): fake = Faker() user, organization, relation, event = create_event presentation_type = baker.make(PresentationType, event=event) - presentation = baker.make(Presentation, presentation_type=presentation_type) - presentation_category = baker.make(PresentationCategory, presentation_type=presentation_type, name=fake.name()) + presentation = baker.make(Presentation, type=presentation_type) + presentation_category = baker.make(PresentationCategory, type=presentation_type, name=fake.name()) presentation_category_relation = baker.make( PresentationCategoryRelation, presentation=presentation, category=presentation_category ) diff --git a/app/event/presentation/translation.py b/app/event/presentation/translation.py index b235682..d076ece 100644 --- a/app/event/presentation/translation.py +++ b/app/event/presentation/translation.py @@ -1,4 +1,4 @@ -from event.presentation.models import PresentationCategory, PresentationSpeaker, PresentationType +from event.presentation.models import Presentation, PresentationCategory, PresentationSpeaker, PresentationType from modeltranslation.translator import TranslationOptions, register @@ -12,9 +12,11 @@ class PresentationCategoryTranslationOptions(TranslationOptions): fields = ("name",) +@register(Presentation) +class PresentationTranslationOptions(TranslationOptions): + fields = ("title",) + + @register(PresentationSpeaker) class PresentationSpeakerTranslationOptions(TranslationOptions): - fields = ( - "name", - "biography", - ) + fields = ("biography",) diff --git a/app/event/presentation/urls.py b/app/event/presentation/urls.py index 36a1985..44b0664 100644 --- a/app/event/presentation/urls.py +++ b/app/event/presentation/urls.py @@ -20,6 +20,7 @@ from rest_framework import routers cms_router = routers.SimpleRouter() -cms_router.register("presentation", views.PresentationViewSet, basename="presentation") +cms_router.register("", views.PresentationViewSet, basename="presentation") +cms_router.register("category", views.PresentationCategoryViewSet, basename="presentation-category") urlpatterns = [path("", include(cms_router.urls))] diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py index f9ae2a9..f9e6715 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,14 +1,37 @@ +from core.const.regex import UUID_V4_REGEX +from core.const.tag import OpenAPITag +from django.db.models import QuerySet +from django.utils.decorators import method_decorator from django_filters import rest_framework as filters -from event.presentation.models import Presentation +from django_filters.constants import EMPTY_VALUES +from drf_spectacular.utils import extend_schema +from event.presentation.models import Presentation, PresentationCategory, PresentationCategoryRelation from event.presentation.serializers import PresentationSerializer -from rest_framework import mixins, viewsets +from rest_framework import mixins, serializers, viewsets class PresentationFilterSet(filters.FilterSet): - category = filters.CharFilter(field_name="presentation_categories__name", lookup_expr="exact") + type = filters.UUIDFilter(field_name="type_id") + categories = filters.BaseCSVFilter(method="filter_by_category_ids") + def filter_by_category_ids(self, queryset: QuerySet, name: str, value: list[str]) -> QuerySet: + if not value or value in EMPTY_VALUES: + return queryset + if not any(UUID_V4_REGEX.match(v) for v in value): + return serializers.ValidationError(f"Invalid UUID format in {name} filter: {value}.") -class PresentationViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + target_ids = PresentationCategoryRelation.objects.filter(category__id__in=value).values_list("presentation_id") + return queryset.filter(id__in=target_ids) + + +@method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) +class PresentationCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + queryset = PresentationCategory.objects.filter_active() + + +@method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) +@method_decorator(name="retrieve", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION])) +class PresentationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): queryset = Presentation.objects.get_all_nested_data() serializer_class = PresentationSerializer filterset_class = PresentationFilterSet diff --git a/app/event/sponsor/migrations/0004_alter_sponsor_options_alter_sponsortier_options_and_more.py b/app/event/sponsor/migrations/0004_alter_sponsor_options_alter_sponsortier_options_and_more.py new file mode 100644 index 0000000..b731f16 --- /dev/null +++ b/app/event/sponsor/migrations/0004_alter_sponsor_options_alter_sponsortier_options_and_more.py @@ -0,0 +1,224 @@ +# Generated by Django 5.2 on 2025-06-08 08:51 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0007_historicalpage_show_bottom_sponsor_banner_and_more"), + ("event", "0002_alter_event_options_alter_event_name_and_more"), + ("file", "0001_initial"), + ("sponsor", "0003_alter_historicalsponsor_description_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name="sponsor", + options={"ordering": ["name"]}, + ), + migrations.AlterModelOptions( + name="sponsortier", + options={"ordering": ["order"]}, + ), + migrations.RemoveField( + model_name="historicalsponsor", + name="description", + ), + migrations.RemoveField( + model_name="historicalsponsor", + name="description_en", + ), + migrations.RemoveField( + model_name="historicalsponsor", + name="description_ko", + ), + migrations.RemoveField( + model_name="sponsor", + name="description", + ), + migrations.RemoveField( + model_name="sponsor", + name="description_en", + ), + migrations.RemoveField( + model_name="sponsor", + name="description_ko", + ), + migrations.RemoveField( + model_name="sponsor", + name="sponsor_tiers", + ), + migrations.RemoveField( + model_name="sponsortiersponsorrelation", + name="sponsor_tier", + ), + migrations.AddField( + model_name="historicalsponsor", + name="sitemap", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.sitemap", + ), + ), + migrations.AddField( + model_name="sponsor", + name="sitemap", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="cms.sitemap" + ), + ), + migrations.AddField( + model_name="sponsortier", + name="sponsors", + field=models.ManyToManyField(through="sponsor.SponsorTierSponsorRelation", to="sponsor.sponsor"), + ), + migrations.AddField( + model_name="sponsortiersponsorrelation", + name="tier", + field=models.ForeignKey( + default=None, on_delete=django.db.models.deletion.CASCADE, to="sponsor.sponsortier" + ), + preserve_default=False, + ), + migrations.AlterField( + model_name="historicalsponsor", + name="logo", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="file.publicfile", + ), + ), + migrations.AlterField( + model_name="historicalsponsor", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="historicalsponsor", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalsponsor", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalsponsortier", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="historicalsponsortier", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalsponsortier", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="historicalsponsortier", + name="order", + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name="sponsor", + name="event", + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="event.event"), + ), + migrations.AlterField( + model_name="sponsor", + name="logo", + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="file.publicfile"), + ), + migrations.AlterField( + model_name="sponsor", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="sponsor", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="sponsor", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="sponsortier", + name="event", + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="event.event"), + ), + migrations.AlterField( + model_name="sponsortier", + name="name", + field=models.CharField(max_length=256), + ), + migrations.AlterField( + model_name="sponsortier", + name="name_en", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="sponsortier", + name="name_ko", + field=models.CharField(max_length=256, null=True), + ), + migrations.AlterField( + model_name="sponsortier", + name="order", + field=models.IntegerField(default=0), + ), + migrations.AlterField( + model_name="sponsortiersponsorrelation", + name="sponsor", + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="sponsor.sponsor"), + ), + migrations.AddConstraint( + model_name="sponsor", + constraint=models.UniqueConstraint(fields=("event", "name"), name="uq__spsr__name"), + ), + migrations.AddConstraint( + model_name="sponsor", + constraint=models.UniqueConstraint(fields=("event", "name_ko"), name="uq__spsr__name-name_ko"), + ), + migrations.AddConstraint( + model_name="sponsor", + constraint=models.UniqueConstraint(fields=("event", "name_en"), name="uq__spsr__name-name_en"), + ), + migrations.AddConstraint( + model_name="sponsortier", + constraint=models.UniqueConstraint(fields=("event", "name"), name="uq__spsr_tier__name"), + ), + migrations.AddConstraint( + model_name="sponsortier", + constraint=models.UniqueConstraint(fields=("event", "order"), name="uq__spsr_tier__order"), + ), + migrations.AddConstraint( + model_name="sponsortier", + constraint=models.UniqueConstraint(fields=("event", "name_ko"), name="uq__spsr_tier__name-name_ko"), + ), + migrations.AddConstraint( + model_name="sponsortier", + constraint=models.UniqueConstraint(fields=("event", "name_en"), name="uq__spsr_tier__name-name_en"), + ), + migrations.AddConstraint( + model_name="sponsortiersponsorrelation", + constraint=models.UniqueConstraint(fields=("tier", "sponsor"), name="uq__spsr_tier_rel__tier_spsr"), + ), + ] diff --git a/app/event/sponsor/migrations/0005_remove_historicalsponsor_sitemap_and_more.py b/app/event/sponsor/migrations/0005_remove_historicalsponsor_sitemap_and_more.py new file mode 100644 index 0000000..ff31174 --- /dev/null +++ b/app/event/sponsor/migrations/0005_remove_historicalsponsor_sitemap_and_more.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2 on 2025-06-08 10:04 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("cms", "0007_historicalpage_show_bottom_sponsor_banner_and_more"), + ("sponsor", "0004_alter_sponsor_options_alter_sponsortier_options_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="historicalsponsor", + name="sitemap", + ), + migrations.RemoveField( + model_name="sponsor", + name="sitemap", + ), + migrations.AddField( + model_name="historicalsponsor", + name="page", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="cms.page", + ), + ), + migrations.AddField( + model_name="sponsor", + name="page", + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="cms.page"), + ), + ] diff --git a/app/event/sponsor/models.py b/app/event/sponsor/models.py index 9155c82..b89de3a 100644 --- a/app/event/sponsor/models.py +++ b/app/event/sponsor/models.py @@ -1,24 +1,49 @@ +import collections.abc +import contextlib +import functools + from core.models import BaseAbstractModel from django.db import models from event.models import Event class Sponsor(BaseAbstractModel): - event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="sponsors") - name = models.CharField(max_length=256, null=True, blank=True) - logo = models.URLField(null=True, blank=True) - description = models.TextField(null=True, blank=True) - sponsor_tiers = models.ManyToManyField(to="SponsorTier", through="SponsorTierSponsorRelation") + event = models.ForeignKey(Event, on_delete=models.PROTECT) + name = models.CharField(max_length=256) + + logo = models.ForeignKey(to="file.PublicFile", on_delete=models.PROTECT) + page = models.ForeignKey(to="cms.Page", on_delete=models.PROTECT, null=True, blank=True) + + class Meta: + ordering = ["name"] + constraints = [models.UniqueConstraint(fields=["event", "name"], name="uq__spsr__name")] class SponsorTier(BaseAbstractModel): - event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name="sponsor_tiers") - name = models.CharField(max_length=256, null=True, blank=True) - order = models.IntegerField(null=True, blank=True) + event = models.ForeignKey(Event, on_delete=models.PROTECT) + name = models.CharField(max_length=256) + order = models.IntegerField(default=0) + + sponsors = models.ManyToManyField(to=Sponsor, through="SponsorTierSponsorRelation") + + class Meta: + ordering = ["order"] + constraints = [ + models.UniqueConstraint(fields=["event", "name"], name="uq__spsr_tier__name"), + models.UniqueConstraint(fields=["event", "order"], name="uq__spsr_tier__order"), + ] + + @functools.cached_property + def active_sponsors(self) -> collections.abc.Iterable[Sponsor]: + with contextlib.suppress(AttributeError): + return self._prefetched_active_sponsors + + return self.sponsors.filter_active().select_related("logo") class SponsorTierSponsorRelation(models.Model): - sponsor_tier = models.ForeignKey( - SponsorTier, on_delete=models.CASCADE, related_name="sponsor_tier_sponsor_relations" - ) - sponsor = models.ForeignKey(Sponsor, on_delete=models.CASCADE, related_name="sponsor_tier_sponsor_relations") + tier = models.ForeignKey(SponsorTier, on_delete=models.CASCADE) + sponsor = models.ForeignKey(Sponsor, on_delete=models.CASCADE) + + class Meta: + constraints = [models.UniqueConstraint(fields=["tier", "sponsor"], name="uq__spsr_tier_rel__tier_spsr")] diff --git a/app/event/sponsor/serializers.py b/app/event/sponsor/serializers.py index 272cc41..15fd5ba 100644 --- a/app/event/sponsor/serializers.py +++ b/app/event/sponsor/serializers.py @@ -2,26 +2,18 @@ from rest_framework import serializers -class SponsorTierSerializer(serializers.ModelSerializer): +class SponsorSerializer(serializers.ModelSerializer): + logo = serializers.FileField(source="logo.file", read_only=True) + page = serializers.UUIDField(source="page_id", read_only=True) + class Meta: - model = SponsorTier - fields = ( - "id", - "name", - "order", - ) + model = Sponsor + fields = ("id", "name", "logo", "page") -class SponsorSerializer(serializers.ModelSerializer): - sponsor_tiers = SponsorTierSerializer(many=True, read_only=True) +class SponsorTierSerializer(serializers.ModelSerializer): + sponsors = SponsorSerializer(source="active_sponsors", many=True, read_only=True) class Meta: - model = Sponsor - fields = ( - "id", - "event", - "name", - "logo", - "description", - "sponsor_tiers", - ) + model = SponsorTier + fields = ("id", "name", "order", "sponsors") diff --git a/app/event/sponsor/translation.py b/app/event/sponsor/translation.py index 387c8aa..b3b9f7f 100644 --- a/app/event/sponsor/translation.py +++ b/app/event/sponsor/translation.py @@ -9,4 +9,4 @@ class SponsorTierTranslationOptions(TranslationOptions): @register(Sponsor) class SponsorTranslationOptions(TranslationOptions): - fields = ("name", "description") + fields = ("name",) diff --git a/app/event/sponsor/urls.py b/app/event/sponsor/urls.py index 922a0e5..938421e 100644 --- a/app/event/sponsor/urls.py +++ b/app/event/sponsor/urls.py @@ -3,6 +3,6 @@ from rest_framework import routers cms_router = routers.SimpleRouter() -cms_router.register("sponsors", views.SponsorViewSet, basename="sponsor") +cms_router.register("", views.SponsorTierViewSet, basename="sponsor") urlpatterns = [path("", include(cms_router.urls))] diff --git a/app/event/sponsor/views.py b/app/event/sponsor/views.py index 27aedd3..25d22aa 100644 --- a/app/event/sponsor/views.py +++ b/app/event/sponsor/views.py @@ -1,8 +1,19 @@ -from event.sponsor.models import Sponsor -from event.sponsor.serializers import SponsorSerializer +from core.const.tag import OpenAPITag +from django.db import models +from django.utils.decorators import method_decorator +from drf_spectacular.utils import extend_schema +from event.sponsor.models import Sponsor, SponsorTier +from event.sponsor.serializers import SponsorTierSerializer from rest_framework import mixins, viewsets -class SponsorViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): - queryset = Sponsor.objects.prefetch_related("sponsor_tier").filter_active() - serializer_class = SponsorSerializer +@method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_SPONSOR])) +class SponsorTierViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + queryset = SponsorTier.objects.filter_active().prefetch_related( + models.Prefetch( + lookup="sponsors", + queryset=Sponsor.objects.filter_active().select_related("logo"), + to_attr="_prefetched_active_sponsors", + ) + ) + serializer_class = SponsorTierSerializer diff --git a/app/user/apps.py b/app/user/apps.py index c2d7ddc..a26a511 100644 --- a/app/user/apps.py +++ b/app/user/apps.py @@ -11,7 +11,10 @@ def ready(self): importlib.import_module("user.translation") from simple_history import register - from user.models import Organization, UserExt + from user.models import UserExt register(UserExt) + + from user.models.organization import Organization + register(Organization) diff --git a/app/user/migrations/0004_alter_historicaluserext_options_and_more.py b/app/user/migrations/0004_alter_historicaluserext_options_and_more.py new file mode 100644 index 0000000..085ae05 --- /dev/null +++ b/app/user/migrations/0004_alter_historicaluserext_options_and_more.py @@ -0,0 +1,93 @@ +# Generated by Django 5.2 on 2025-06-07 13:13 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("user", "0003_alter_historicaluserext_is_active"), + ] + + operations = [ + migrations.AlterModelOptions( + name="historicaluserext", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical user ext", + "verbose_name_plural": "historical user exts", + }, + ), + migrations.AlterModelOptions( + name="userext", + options={"ordering": ["-date_joined"]}, + ), + migrations.AddField( + model_name="historicalorganization", + name="created_by", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="historicalorganization", + name="deleted_by", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="historicalorganization", + name="updated_by", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="organization", + name="created_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_created_by", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="organization", + name="deleted_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_deleted_by", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="organization", + name="updated_by", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ] diff --git a/app/user/migrations/0005_historicaluserext_nickname_and_more.py b/app/user/migrations/0005_historicaluserext_nickname_and_more.py new file mode 100644 index 0000000..1d33770 --- /dev/null +++ b/app/user/migrations/0005_historicaluserext_nickname_and_more.py @@ -0,0 +1,72 @@ +# Generated by Django 5.2 on 2025-06-08 08:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("user", "0004_alter_historicaluserext_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="historicaluserext", + name="nickname", + field=models.CharField(blank=True, db_index=True, max_length=128, null=True), + ), + migrations.AddField( + model_name="historicaluserext", + name="nickname_en", + field=models.CharField(blank=True, max_length=128, null=True, unique=True), + ), + migrations.AddField( + model_name="historicaluserext", + name="nickname_ko", + field=models.CharField(blank=True, max_length=128, null=True, unique=True), + ), + migrations.AddField( + model_name="userext", + name="nickname", + field=models.CharField(blank=True, max_length=128, null=True, unique=True), + ), + migrations.AddField( + model_name="userext", + name="nickname_en", + field=models.CharField(blank=True, max_length=128, null=True, unique=True), + ), + migrations.AddField( + model_name="userext", + name="nickname_ko", + field=models.CharField(blank=True, max_length=128, null=True, unique=True), + ), + migrations.AlterField( + model_name="historicalorganization", + name="name", + field=models.CharField(db_index=True, max_length=256), + ), + migrations.AlterField( + model_name="historicalorganization", + name="name_en", + field=models.CharField(max_length=256, null=True, unique=True), + ), + migrations.AlterField( + model_name="historicalorganization", + name="name_ko", + field=models.CharField(max_length=256, null=True, unique=True), + ), + migrations.AlterField( + model_name="organization", + name="name", + field=models.CharField(max_length=256, unique=True), + ), + migrations.AlterField( + model_name="organization", + name="name_en", + field=models.CharField(max_length=256, null=True, unique=True), + ), + migrations.AlterField( + model_name="organization", + name="name_ko", + field=models.CharField(max_length=256, null=True, unique=True), + ), + ] diff --git a/app/user/models/__init__.py b/app/user/models/__init__.py new file mode 100644 index 0000000..b708548 --- /dev/null +++ b/app/user/models/__init__.py @@ -0,0 +1,3 @@ +from .user import UserExt + +__all__ = ["UserExt"] diff --git a/app/user/models.py b/app/user/models/organization.py similarity index 60% rename from app/user/models.py rename to app/user/models/organization.py index b3a5bd0..50ef838 100644 --- a/app/user/models.py +++ b/app/user/models/organization.py @@ -1,20 +1,14 @@ -import uuid - -from django.contrib.auth.models import AbstractUser +from core.models import BaseAbstractModel from django.core.exceptions import ValidationError from django.db import models +from user.models.user import UserExt -class UserExt(AbstractUser): - pass - +class Organization(BaseAbstractModel): + name = models.CharField(max_length=256, unique=True) -class Organization(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=256, null=True, blank=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) - deleted_at = models.DateTimeField(null=True, blank=True) + def __str__(self): + return self.name class OrganizationUserRelation(models.Model): diff --git a/app/user/models/user.py b/app/user/models/user.py new file mode 100644 index 0000000..7477881 --- /dev/null +++ b/app/user/models/user.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + + +class UserExt(AbstractUser): + nickname = models.CharField(max_length=128, unique=True, null=True, blank=True) + + class Meta: + ordering = ["-date_joined"] + + def __str__(self): + return f"{self.nickname} <{self.email}>" diff --git a/app/user/translation.py b/app/user/translation.py index 2bb7295..0a39281 100644 --- a/app/user/translation.py +++ b/app/user/translation.py @@ -1,5 +1,11 @@ from modeltranslation.translator import TranslationOptions, register -from user.models import Organization +from user.models.organization import Organization +from user.models.user import UserExt + + +@register(UserExt) +class UserExtTranslationOptions(TranslationOptions): + fields = ("nickname",) @register(Organization)