From f6510ab60f260d8dfb4f69eabb6925aeacc76b60 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 7 Jun 2025 22:16:06 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20user=20app=EC=9D=98=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=A0=95=EB=A6=AC=20=EB=B0=8F=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EB=B9=84=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=B4=88=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/user.py | 22 ++++- app/admin_api/views/user.py | 20 +++- app/core/const/account.py | 1 + app/event/models.py | 2 +- app/event/presentation/test/conftest.py | 3 +- app/user/apps.py | 5 +- ...lter_historicaluserext_options_and_more.py | 93 +++++++++++++++++++ app/user/models/__init__.py | 3 + .../{models.py => models/organization.py} | 15 +-- app/user/models/user.py | 9 ++ app/user/translation.py | 2 +- 11 files changed, 155 insertions(+), 20 deletions(-) create mode 100644 app/core/const/account.py create mode 100644 app/user/migrations/0004_alter_historicaluserext_options_and_more.py create mode 100644 app/user/models/__init__.py rename app/user/{models.py => models/organization.py} (66%) create mode 100644 app/user/models/user.py diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py index 3a7f7f0..ce0b39a 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", + "email", + "first_name", + "last_name", + "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): diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py index d2f83ff..8757225 100644 --- a/app/admin_api/views/user.py +++ b/app/admin_api/views/user.py @@ -1,17 +1,25 @@ from admin_api.serializers.user import 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] @@ -43,3 +51,11 @@ def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> resp 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) 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/event/models.py b/app/event/models.py index 4ae19a8..77ba63d 100644 --- a/app/event/models.py +++ b/app/event/models.py @@ -1,7 +1,7 @@ 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): diff --git a/app/event/presentation/test/conftest.py b/app/event/presentation/test/conftest.py index e8f0964..5870b65 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 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/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 66% rename from app/user/models.py rename to app/user/models/organization.py index b3a5bd0..eebc3b6 100644 --- a/app/user/models.py +++ b/app/user/models/organization.py @@ -1,20 +1,11 @@ -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(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) +class Organization(BaseAbstractModel): 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) class OrganizationUserRelation(models.Model): diff --git a/app/user/models/user.py b/app/user/models/user.py new file mode 100644 index 0000000..9213e43 --- /dev/null +++ b/app/user/models/user.py @@ -0,0 +1,9 @@ +from django.contrib.auth.models import AbstractUser + + +class UserExt(AbstractUser): + class Meta: + ordering = ["-date_joined"] + + def __str__(self): + return f"{self.username} <{self.email}>" diff --git a/app/user/translation.py b/app/user/translation.py index 2bb7295..7f02621 100644 --- a/app/user/translation.py +++ b/app/user/translation.py @@ -1,5 +1,5 @@ from modeltranslation.translator import TranslationOptions, register -from user.models import Organization +from user.models.organization import Organization @register(Organization) From 8d4fab62fe3bc777989d3802b41c227113609d50 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sat, 7 Jun 2025 22:19:26 +0900 Subject: [PATCH 02/10] =?UTF-8?q?chore:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=84=EB=8F=84=EC=9D=98=20API=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/views/user.py | 6 +++--- app/core/const/tag.py | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py index 8757225..2a941e5 100644 --- a/app/admin_api/views/user.py +++ b/app/admin_api/views/user.py @@ -25,7 +25,7 @@ class UserAdminViewSet( 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: @@ -34,7 +34,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}, ) @@ -46,7 +46,7 @@ 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) diff --git a/app/core/const/tag.py b/app/core/const/tag.py index d52e9e6..8f273ea 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -1,6 +1,7 @@ class OpenAPITag: CMS = "CMS" + ADMIN_ACCOUNT = "Admin > Sign-In & Sign-Out" ADMIN_USER = "Admin > User" ADMIN_CMS = "Admin > CMS" ADMIN_PUBLIC_FILE = "Admin > Public File" From bd507ade72e36c36bc7b639f4c88b81e342fa94d Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 17:57:47 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20UserExt=20=EB=AA=A8=EB=8D=B8?= =?UTF-8?q?=EC=97=90=20nickname=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20Organiz?= =?UTF-8?q?ation.name=EC=9D=84=20=EB=B9=84=EC=96=B4=EC=9E=88=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/user.py | 7 +- ...005_historicaluserext_nickname_and_more.py | 72 +++++++++++++++++++ app/user/models/organization.py | 5 +- app/user/models/user.py | 5 +- app/user/translation.py | 6 ++ 5 files changed, 91 insertions(+), 4 deletions(-) create mode 100644 app/user/migrations/0005_historicaluserext_nickname_and_more.py diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py index ce0b39a..88006c8 100644 --- a/app/admin_api/serializers/user.py +++ b/app/admin_api/serializers/user.py @@ -16,9 +16,9 @@ class Meta: "id", "is_active", "username", + "nickname_ko", + "nickname_en", "email", - "first_name", - "last_name", "is_superuser", "str_repr", "date_joined", @@ -53,4 +53,7 @@ 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 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/organization.py b/app/user/models/organization.py index eebc3b6..50ef838 100644 --- a/app/user/models/organization.py +++ b/app/user/models/organization.py @@ -5,7 +5,10 @@ class Organization(BaseAbstractModel): - name = models.CharField(max_length=256, null=True, blank=True) + name = models.CharField(max_length=256, unique=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 index 9213e43..7477881 100644 --- a/app/user/models/user.py +++ b/app/user/models/user.py @@ -1,9 +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.username} <{self.email}>" + return f"{self.nickname} <{self.email}>" diff --git a/app/user/translation.py b/app/user/translation.py index 7f02621..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.organization import Organization +from user.models.user import UserExt + + +@register(UserExt) +class UserExtTranslationOptions(TranslationOptions): + fields = ("nickname",) @register(Organization) From 0f98beeb21da9951bee5f795fc5aef631ee3f0da Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 17:58:22 +0900 Subject: [PATCH 04/10] =?UTF-8?q?chore:=20event.sponsor=20app=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...ions_alter_sponsortier_options_and_more.py | 224 ++++++++++++++++++ app/event/sponsor/models.py | 49 +++- app/event/sponsor/serializers.py | 28 +-- app/event/sponsor/translation.py | 2 +- app/event/sponsor/urls.py | 2 +- app/event/sponsor/views.py | 17 +- 6 files changed, 285 insertions(+), 37 deletions(-) create mode 100644 app/event/sponsor/migrations/0004_alter_sponsor_options_alter_sponsortier_options_and_more.py 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/models.py b/app/event/sponsor/models.py index 9155c82..51b0826 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) + sitemap = models.ForeignKey(to="cms.Sitemap", 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..7bf419e 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("sponsors", 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..11fdce3 100644 --- a/app/event/sponsor/views.py +++ b/app/event/sponsor/views.py @@ -1,8 +1,15 @@ -from event.sponsor.models import Sponsor -from event.sponsor.serializers import SponsorSerializer +from django.db import models +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 +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 From f4113512dd2e1de35c21adb885cd1d403b8ec804 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 17:58:53 +0900 Subject: [PATCH 05/10] =?UTF-8?q?chore:=20event.presentation=20app=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/const/regex.py | 3 + ...event_options_alter_event_name_and_more.py | 46 +++ app/event/models.py | 8 +- ...pe_historicalpresentation_type_and_more.py | 274 ++++++++++++++++++ app/event/presentation/models.py | 78 +++-- app/event/presentation/serializers.py | 21 +- app/event/presentation/test/api_test.py | 16 +- app/event/presentation/test/conftest.py | 4 +- app/event/presentation/translation.py | 12 +- app/event/presentation/views.py | 23 +- 10 files changed, 441 insertions(+), 44 deletions(-) create mode 100644 app/core/const/regex.py create mode 100644 app/event/migrations/0002_alter_event_options_alter_event_name_and_more.py create mode 100644 app/event/presentation/migrations/0002_rename_presentation_type_historicalpresentation_type_and_more.py diff --git a/app/core/const/regex.py b/app/core/const/regex.py new file mode 100644 index 0000000..342d766 --- /dev/null +++ b/app/core/const/regex.py @@ -0,0 +1,3 @@ +import re + +UUID_V4 = re.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) 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 77ba63d..5aea1e4 100644 --- a/app/event/models.py +++ b/app/event/models.py @@ -6,7 +6,7 @@ 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/models.py b/app/event/presentation/models.py index 96ba766..17300d3 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) + sitemap = models.ForeignKey(to="cms.Sitemap", 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 5870b65..b694037 100644 --- a/app/event/presentation/test/conftest.py +++ b/app/event/presentation/test/conftest.py @@ -48,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/views.py b/app/event/presentation/views.py index f9ae2a9..60689a3 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,11 +1,28 @@ +from core.const.regex import UUID_V4 +from django.db.models import QuerySet from django_filters import rest_framework as filters -from event.presentation.models import Presentation +from django_filters.constants import EMPTY_VALUES +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.match(v) for v in value): + return serializers.ValidationError(f"Invalid UUID format in {name} filter: {value}.") + + target_ids = PresentationCategoryRelation.objects.filter(category__id__in=value).values_list("presentation_id") + return queryset.filter(id__in=target_ids) + + +class PresentationCategoryViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + queryset = PresentationCategory.objects.filter_active() class PresentationViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): From 6b5929f9988ce46ba47bea3d4ba62fc5ab625fc7 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 19:44:36 +0900 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=EB=AA=A8=EB=8D=B8=EC=97=90?= =?UTF-8?q?=EC=84=9C=20sitemap=EC=9D=B4=20=EC=95=84=EB=8B=8C=20page?= =?UTF-8?q?=EB=A5=BC=20FK=EB=A1=9C=20=EA=B0=80=EC=A7=80=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...historicalpresentation_sitemap_and_more.py | 39 +++++++++++++++++++ app/event/presentation/models.py | 2 +- ...move_historicalsponsor_sitemap_and_more.py | 39 +++++++++++++++++++ app/event/sponsor/models.py | 2 +- 4 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 app/event/presentation/migrations/0003_remove_historicalpresentation_sitemap_and_more.py create mode 100644 app/event/sponsor/migrations/0005_remove_historicalsponsor_sitemap_and_more.py 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 17300d3..f8711a4 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -53,7 +53,7 @@ def __str__(self) -> str: class Presentation(BaseAbstractModel): type = models.ForeignKey(PresentationType, on_delete=models.PROTECT) title = models.CharField(max_length=256) - sitemap = models.ForeignKey(to="cms.Sitemap", on_delete=models.PROTECT, null=True, blank=True) + page = models.ForeignKey(to="cms.Page", on_delete=models.PROTECT, null=True, blank=True) categories = models.ManyToManyField(to="PresentationCategory", through="PresentationCategoryRelation") 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 51b0826..b89de3a 100644 --- a/app/event/sponsor/models.py +++ b/app/event/sponsor/models.py @@ -12,7 +12,7 @@ class Sponsor(BaseAbstractModel): name = models.CharField(max_length=256) logo = models.ForeignKey(to="file.PublicFile", on_delete=models.PROTECT) - sitemap = models.ForeignKey(to="cms.Sitemap", on_delete=models.PROTECT, null=True, blank=True) + page = models.ForeignKey(to="cms.Page", on_delete=models.PROTECT, null=True, blank=True) class Meta: ordering = ["name"] From f2e1bf1bf8a6b7a4a8e16a3c6c4edf7104404055 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 19:54:51 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20event=20=ED=95=98=EC=9C=84=20?= =?UTF-8?q?=ED=95=AD=EB=AA=A9=EC=97=90=20=EB=8C=80=ED=95=9C=20admin-api=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/event/event.py | 11 ++ .../serializers/event/presentation.py | 29 +++++ app/admin_api/serializers/event/sponsor.py | 17 +++ app/admin_api/urls.py | 19 +++ app/admin_api/views/event/event.py | 19 +++ app/admin_api/views/event/presentation.py | 122 ++++++++++++++++++ app/admin_api/views/event/sponsor.py | 78 +++++++++++ app/core/const/regex.py | 3 +- app/core/const/tag.py | 5 + 9 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 app/admin_api/serializers/event/event.py create mode 100644 app/admin_api/serializers/event/presentation.py create mode 100644 app/admin_api/serializers/event/sponsor.py create mode 100644 app/admin_api/views/event/event.py create mode 100644 app/admin_api/views/event/presentation.py create mode 100644 app/admin_api/views/event/sponsor.py 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/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/core/const/regex.py b/app/core/const/regex.py index 342d766..5dcf505 100644 --- a/app/core/const/regex.py +++ b/app/core/const/regex.py @@ -1,3 +1,4 @@ import re -UUID_V4 = re.compile("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE) +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 8f273ea..56dcc19 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -1,8 +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" From e71c1183b8f4215221c1d6d97fd7ea4b8f436206 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 19:55:34 +0900 Subject: [PATCH 08/10] =?UTF-8?q?chore:=20=EC=9D=BC=EB=B0=98=20API=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/urls.py | 3 ++- app/event/presentation/urls.py | 3 ++- app/event/presentation/views.py | 12 +++++++++--- app/event/sponsor/urls.py | 2 +- app/event/sponsor/views.py | 4 ++++ 5 files changed, 18 insertions(+), 6 deletions(-) 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/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 60689a3..f9e6715 100644 --- a/app/event/presentation/views.py +++ b/app/event/presentation/views.py @@ -1,7 +1,10 @@ -from core.const.regex import UUID_V4 +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 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, serializers, viewsets @@ -14,18 +17,21 @@ class PresentationFilterSet(filters.FilterSet): 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.match(v) for v in value): + if not any(UUID_V4_REGEX.match(v) for v in value): return serializers.ValidationError(f"Invalid UUID format in {name} filter: {value}.") 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() -class PresentationViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): +@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/urls.py b/app/event/sponsor/urls.py index 7bf419e..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.SponsorTierViewSet, 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 11fdce3..25d22aa 100644 --- a/app/event/sponsor/views.py +++ b/app/event/sponsor/views.py @@ -1,9 +1,13 @@ +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 +@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( From 25264bf6e489bbe1d95d2ea2091aadc421c9bb65 Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 19:55:54 +0900 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=EB=A1=9C=EC=BB=AC=EC=97=90?= =?UTF-8?q?=EC=84=9C=20django=20admin=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=B6=88=EA=B0=80=EB=8A=A5=ED=95=9C=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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") From b583e0c6455b0f2e6d74883937edc1761cb59c4a Mon Sep 17 00:00:00 2001 From: MUsoftware Date: Sun, 8 Jun 2025 20:02:50 +0900 Subject: [PATCH 10/10] =?UTF-8?q?feat:=20=EB=B3=B8=EC=9D=B8=20=EB=B9=84?= =?UTF-8?q?=EB=B0=80=EB=B2=88=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20API?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin_api/serializers/user.py | 35 +++++++++++++++++++++++++++++++ app/admin_api/views/user.py | 18 +++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py index 88006c8..a56c054 100644 --- a/app/admin_api/serializers/user.py +++ b/app/admin_api/serializers/user.py @@ -57,3 +57,38 @@ def validate(self, attrs: UserAdminSignInSerializerData) -> UserAdminSignInSeria 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/views/user.py b/app/admin_api/views/user.py index 2a941e5..73c1ce6 100644 --- a/app/admin_api/views/user.py +++ b/app/admin_api/views/user.py @@ -1,4 +1,8 @@ -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 @@ -59,3 +63,15 @@ def reset_password(self, *args: tuple, **kwargs: dict) -> response.Response: 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)