diff --git a/app/core/const/tag.py b/app/core/const/tag.py index 56dcc19..800344d 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -11,3 +11,7 @@ class OpenAPITag: ADMIN_EVENT_PRESENTATION = "Admin > Event > Presentation" ADMIN_EVENT_SPONSOR = "Admin > Event > Sponsor" ADMIN_JSON_SCHEMA = "Admin > JSON Schema" + + PARTICIPANT_PORTAL_USER = "Participant Portal > Sign-In & Sign-Out & My Profile" + PARTICIPANT_PORTAL_PUBLIC_FILE = "Participant Portal > Public File" + PARTICIPANT_PORTAL_PRESENTATION = "Participant Portal > Presentation" diff --git a/app/core/settings.py b/app/core/settings.py index 445c5ea..ff2025f 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -161,6 +161,7 @@ "event.presentation", "event.sponsor", "admin_api", + "participant_portal_api", # django-constance "constance", ] diff --git a/app/core/urls.py b/app/core/urls.py index b2eae50..e7acda2 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -26,6 +26,7 @@ v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [ path("cms/", include("cms.urls")), path("admin-api/", include("admin_api.urls")), + path("participant-portal/", include("participant_portal_api.urls")), path("event/presentation/", include("event.presentation.urls")), path("event/sponsor/", include("event.sponsor.urls")), ] diff --git a/app/event/presentation/migrations/0007_historicalpresentation_summary_and_more.py b/app/event/presentation/migrations/0007_historicalpresentation_summary_and_more.py new file mode 100644 index 0000000..88d1a04 --- /dev/null +++ b/app/event/presentation/migrations/0007_historicalpresentation_summary_and_more.py @@ -0,0 +1,42 @@ +# Generated by Django 5.2 on 2025-06-29 10:49 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("presentation", "0006_remove_historicalpresentation_page_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="historicalpresentation", + name="summary", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="historicalpresentation", + name="summary_en", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AddField( + model_name="historicalpresentation", + name="summary_ko", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AddField( + model_name="presentation", + name="summary", + field=models.TextField(blank=True, default=""), + ), + migrations.AddField( + model_name="presentation", + name="summary_en", + field=models.TextField(blank=True, default="", null=True), + ), + migrations.AddField( + model_name="presentation", + name="summary_ko", + field=models.TextField(blank=True, default="", null=True), + ), + ] diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py index 9f3551c..e8f07ce 100644 --- a/app/event/presentation/models.py +++ b/app/event/presentation/models.py @@ -62,6 +62,7 @@ def __str__(self) -> str: class Presentation(BaseAbstractModel): type = models.ForeignKey(PresentationType, on_delete=models.PROTECT) title = models.CharField(max_length=256) + summary = models.TextField(blank=True, default="") description = MarkdownField(blank=True, default="") image = models.ForeignKey(PublicFile, on_delete=models.PROTECT, null=True, blank=True) categories = models.ManyToManyField(to="PresentationCategory", through="PresentationCategoryRelation") diff --git a/app/event/presentation/translation.py b/app/event/presentation/translation.py index b96f8ad..f523a12 100644 --- a/app/event/presentation/translation.py +++ b/app/event/presentation/translation.py @@ -14,7 +14,7 @@ class PresentationCategoryTranslationOptions(TranslationOptions): @register(Presentation) class PresentationTranslationOptions(TranslationOptions): - fields = ("title", "description") + fields = ("title", "summary", "description") @register(PresentationSpeaker) diff --git a/app/participant_portal_api/__init__.py b/app/participant_portal_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/participant_portal_api/apps.py b/app/participant_portal_api/apps.py new file mode 100644 index 0000000..85ab4a6 --- /dev/null +++ b/app/participant_portal_api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ParticipantPortalApiConfig(AppConfig): + name = "participant_portal_api" diff --git a/app/participant_portal_api/permissions/__init__.py b/app/participant_portal_api/permissions/__init__.py new file mode 100644 index 0000000..92a6e74 --- /dev/null +++ b/app/participant_portal_api/permissions/__init__.py @@ -0,0 +1,20 @@ +from event.presentation.models import PresentationSpeaker +from rest_framework import permissions, request, views +from user.models import UserExt + + +class IsSessionSpeaker(permissions.BasePermission): + message = "You do not have permission to perform this action." + + def has_permission(self, request: request.Request, view: views.APIView) -> bool: + if not (isinstance(request.user, UserExt) and request.user.is_active and request.user.is_authenticated): + return False + + return ( + PresentationSpeaker.objects.filter_active() + .filter( + user=request.user, + presentation__deleted_at__isnull=True, + ) + .exists() + ) diff --git a/app/participant_portal_api/serializers/file.py b/app/participant_portal_api/serializers/file.py new file mode 100644 index 0000000..f20cf59 --- /dev/null +++ b/app/participant_portal_api/serializers/file.py @@ -0,0 +1,25 @@ +from file.models import PublicFile +from rest_framework import serializers + + +class PublicFilePortalSerializer(serializers.ModelSerializer): + file = serializers.FileField(read_only=True) + name = serializers.CharField(read_only=True, source="file.name") + + class Meta: + model = PublicFile + fields = ("id", "file", "name", "created_at") + + +class PublicFilePortalUploadSerializer(serializers.Serializer): + file = serializers.FileField() + + def create(self, validated_data: dict) -> PublicFile: + new_file = PublicFile(file=validated_data["file"]) + new_file.clean() + + if new_file.hash and PublicFile.objects.filter(hash=new_file.hash).exists(): + raise serializers.ValidationError({"file": "A file with the same hash already exists."}) + + new_file.save() + return new_file diff --git a/app/participant_portal_api/serializers/presentation.py b/app/participant_portal_api/serializers/presentation.py new file mode 100644 index 0000000..441e74f --- /dev/null +++ b/app/participant_portal_api/serializers/presentation.py @@ -0,0 +1,46 @@ +from core.util.thread_local import get_current_user +from event.presentation.models import Presentation, PresentationSpeaker +from file.models import PublicFile +from rest_framework import serializers + + +class PresentationSpeakerPortalSerializer(serializers.ModelSerializer): + image = serializers.PrimaryKeyRelatedField(queryset=PublicFile.objects.filter_active(), allow_null=True) + + class Meta: + model = PresentationSpeaker + fields = ("id", "biography_ko", "biography_en", "image", "user") + + +class PresentationPortalSerializer(serializers.ModelSerializer): + title = serializers.CharField(read_only=True) + summary = serializers.CharField(read_only=True) + description = serializers.CharField(read_only=True) + + image = serializers.PrimaryKeyRelatedField(queryset=PublicFile.objects.filter_active(), allow_null=True) + speakers = PresentationSpeakerPortalSerializer(many=True, read_only=True) + + class Meta: + model = Presentation + fields = ( + "id", + "title", + "title_ko", + "title_en", + "summary", + "summary_ko", + "summary_en", + "description", + "description_ko", + "description_en", + "image", + "speakers", + ) + + def to_representation(self, instance): + result = super().to_representation(instance) + + if (current_user := get_current_user()) and (speakers := result.get("speakers")): + result["speakers"] = [s for s in speakers if s["user"] == current_user.pk] + + return result diff --git a/app/participant_portal_api/serializers/user.py b/app/participant_portal_api/serializers/user.py new file mode 100644 index 0000000..98992d8 --- /dev/null +++ b/app/participant_portal_api/serializers/user.py @@ -0,0 +1,128 @@ +import functools +import typing +import unicodedata + +from core.serializer.read_only_serializer import ReadOnlyModelSerializer +from core.util.thread_local import get_current_user +from file.models import PublicFile +from rest_framework import serializers +from user.models import UserExt + + +def normalize_str(value: str) -> str: + return unicodedata.normalize("NFC", value).strip() if value else "" + + +class UserPortalSerializer(serializers.ModelSerializer): + id = serializers.IntegerField(read_only=True) + email = serializers.EmailField(read_only=True) + nickname = serializers.CharField(read_only=True) # django-modeltranslation에 의해 accept-language에 따라 응답됨 + profile_image = serializers.FileField(read_only=True, allow_null=True, source="image.file") + + image = serializers.PrimaryKeyRelatedField(queryset=PublicFile.objects.filter_active(), allow_null=True) + + class Meta: + model = UserExt + fields = ("id", "email", "profile_image", "username", "nickname", "nickname_ko", "nickname_en", "image") + + def validate_image(self, image: PublicFile | None) -> PublicFile | None: + if not image: + return None + + image_owner = image.created_by or image.updated_by + if (current_user := get_current_user()) and not (image_owner == current_user == self.instance): + raise serializers.ValidationError("You can only set your own profile image.") + + return image + + def validate(self, attrs: dict[str, typing.Any]) -> dict[str, typing.Any]: + if self.instance != get_current_user(): + raise serializers.ValidationError("You can only update your own profile.") + + return super().validate(attrs) + + +class UserPortalSignInSerializer(ReadOnlyModelSerializer): + identity = serializers.CharField(max_length=150, required=True) + password = serializers.CharField(write_only=True, required=True) + + class Meta: + fields = ("identity", "password") + + @functools.cached_property + def user(self) -> UserExt | None: + if not (email := normalize_str(self.initial_data.get("identity", ""))): + return None + + return UserExt.objects.filter(is_active=True, email=email).first() + + def validate_identity(self, email: str) -> str: + if not (email := normalize_str(email)): + raise serializers.ValidationError("Email cannot be empty.") + + if not self.user: + raise serializers.ValidationError("User not found or inactive or wrong password.") + + return email + + def validate_password(self, password: str) -> str: + if not (password := normalize_str(password)): + raise serializers.ValidationError("Password cannot be empty.") + + return password + + def validate(self, attrs: dict[str, str]) -> dict[str, str]: + if not (self.user and self.user.check_password(attrs["password"])): + raise serializers.ValidationError("User not found or inactive or wrong password.") + + return attrs + + +class UserPortalPasswordChangeSerializer(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_old_password(self, old_password: str) -> str: + if not (old_password := normalize_str(old_password)): + raise serializers.ValidationError("Old password cannot be empty.") + return old_password + + def validate_new_password(self, new_password: str) -> str: + if not (new_password := normalize_str(new_password)): + raise serializers.ValidationError("New password cannot be empty.") + return new_password + + def validate_new_password_confirm(self, new_password_confirm: str) -> str: + if not (new_password_confirm := normalize_str(new_password_confirm)): + raise serializers.ValidationError("New password confirmation cannot be empty.") + return new_password_confirm + + def validate(self, attrs: dict[str, str]) -> dict[str, str]: + user: UserExt = self.instance + old_password, new_password, new_password_confirm = ( + attrs["old_password"], + attrs["new_password"], + attrs["new_password_confirm"], + ) + + if not user.check_password(old_password): + raise serializers.ValidationError("Old password is incorrect.") + + if new_password == old_password: + raise serializers.ValidationError("New password cannot be the same as the old password.") + + if new_password != 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/participant_portal_api/urls.py b/app/participant_portal_api/urls.py new file mode 100644 index 0000000..de278f1 --- /dev/null +++ b/app/participant_portal_api/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from participant_portal_api.views.file import PublicFilePortalViewSet +from participant_portal_api.views.presentation import PresentationPortalViewSet +from participant_portal_api.views.user import UserPortalViewSet +from rest_framework import routers + +participant_router = routers.SimpleRouter() +participant_router.register("user", UserPortalViewSet, basename="participant-user") +participant_router.register("public-file", PublicFilePortalViewSet, basename="participant-publicfile") +participant_router.register("presentation", PresentationPortalViewSet, basename="participant-presentation") + +urlpatterns = [path("", include(participant_router.urls))] diff --git a/app/participant_portal_api/views/file.py b/app/participant_portal_api/views/file.py new file mode 100644 index 0000000..6b63e5b --- /dev/null +++ b/app/participant_portal_api/views/file.py @@ -0,0 +1,45 @@ +from core.const.tag import OpenAPITag +from django.db import models +from drf_spectacular import utils +from file.models import PublicFile +from participant_portal_api.permissions import IsSessionSpeaker +from participant_portal_api.serializers.file import PublicFilePortalSerializer, PublicFilePortalUploadSerializer +from rest_framework import decorators, mixins, parsers, request, response, serializers, status, viewsets + + +@utils.extend_schema_view(list=utils.extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_PUBLIC_FILE])) +class PublicFilePortalViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + serializer_class = PublicFilePortalSerializer + queryset = PublicFile.objects.filter_active().select_related("created_by", "updated_by", "deleted_by") + permission_classes = [IsSessionSpeaker] + + def get_queryset(self) -> models.QuerySet[PublicFile]: + """본인이 업로드한 파일만 조회 가능하도록 필터링""" + if not self.request.user.is_authenticated: + return super().get_queryset().none() + return ( + super() + .get_queryset() + .filter(models.Q(created_by=self.request.user) | models.Q(updated_by=self.request.user)) + ) + + @utils.extend_schema( + tags=[OpenAPITag.PARTICIPANT_PORTAL_PUBLIC_FILE], + responses={status.HTTP_201_CREATED: PublicFilePortalSerializer}, + ) + @decorators.action( + detail=False, + methods=["POST"], + url_path="upload", + serializer_class=PublicFilePortalUploadSerializer, + parser_classes=[parsers.MultiPartParser, parsers.FileUploadParser], + ) + def upload(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if "file" not in request.FILES: + raise serializers.ValidationError({"file": "This field is required."}) + + serializer = PublicFilePortalUploadSerializer(data=request.FILES) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + return response.Response(data=PublicFilePortalUploadSerializer(instance).data, status=status.HTTP_201_CREATED) diff --git a/app/participant_portal_api/views/presentation.py b/app/participant_portal_api/views/presentation.py new file mode 100644 index 0000000..b433fa3 --- /dev/null +++ b/app/participant_portal_api/views/presentation.py @@ -0,0 +1,39 @@ +from core.const.tag import OpenAPITag +from drf_spectacular import utils +from event.presentation.models import Presentation, PresentationSpeaker +from participant_portal_api.permissions import IsSessionSpeaker +from participant_portal_api.serializers.presentation import PresentationPortalSerializer +from rest_framework import mixins, viewsets + + +@utils.extend_schema_view( + list=utils.extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_PRESENTATION]), + retrieve=utils.extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_PRESENTATION]), + partial_update=utils.extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_PRESENTATION]), +) +class PresentationPortalViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + serializer_class = PresentationPortalSerializer + queryset = Presentation.objects.filter_active().get_all_nested_data().order_by("-created_at") + permission_classes = [IsSessionSpeaker] + http_method_names = ["get", "patch"] + + def get_queryset(self): + """본인의 발표만 조회 가능하도록 필터링""" + if not self.request.user.is_authenticated: + return super().get_queryset().none() + + return ( + super() + .get_queryset() + .filter( + id__in=PresentationSpeaker.objects.filter( + user=self.request.user, + presentation__deleted_at__isnull=True, + ).values_list("presentation_id", flat=True), + ) + ) diff --git a/app/participant_portal_api/views/user.py b/app/participant_portal_api/views/user.py new file mode 100644 index 0000000..0f27d4e --- /dev/null +++ b/app/participant_portal_api/views/user.py @@ -0,0 +1,74 @@ +from core.const.tag import OpenAPITag +from django.contrib.auth import login, logout +from drf_spectacular.utils import extend_schema +from participant_portal_api.permissions import IsSessionSpeaker +from participant_portal_api.serializers.user import ( + UserPortalPasswordChangeSerializer, + UserPortalSerializer, + UserPortalSignInSerializer, +) +from rest_framework import decorators, request, response, status, viewsets +from user.models import UserExt + + +class UserPortalViewSet(viewsets.GenericViewSet): + serializer_class = UserPortalSerializer + queryset = UserExt.objects.filter(is_active=True) + permission_classes = [IsSessionSpeaker] + + @extend_schema( + tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], + responses={status.HTTP_200_OK: UserPortalSerializer}, + ) + @decorators.action(detail=False, methods=["get"], url_path="me") + def retrieve_profile(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if not request.user.is_authenticated: + return response.Response(status=status.HTTP_401_UNAUTHORIZED) + + return response.Response(data=UserPortalSerializer(request.user).data) + + @extend_schema( + tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], + responses={status.HTTP_200_OK: UserPortalSerializer}, + ) + @retrieve_profile.mapping.patch + def patch_profile(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + if not request.user.is_authenticated: + return response.Response(status=status.HTTP_401_UNAUTHORIZED) + + serializer = self.get_serializer(instance=request.user, data=request.data, partial=True) + serializer.is_valid(raise_exception=True) + instance = serializer.save() + + return response.Response(data=UserPortalSerializer(instance).data) + + @extend_schema( + tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], + request=UserPortalSignInSerializer, + responses={status.HTTP_200_OK: UserPortalSerializer}, + ) + @decorators.action(detail=False, methods=["post"], url_path="signin") + def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + serializer = UserPortalSignInSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + login(request=request, user=serializer.user) + return response.Response(data=UserPortalSerializer(serializer.user).data) + + @extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], responses={status.HTTP_204_NO_CONTENT: None}) + @decorators.action(detail=False, methods=["delete"], url_path="signout") + 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.PARTICIPANT_PORTAL_USER], + request=UserPortalPasswordChangeSerializer, + responses={status.HTTP_200_OK: UserPortalSerializer}, + ) + @decorators.action(detail=False, methods=["put"], url_path="password") + def change_password(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response: + serializer = UserPortalPasswordChangeSerializer(data=request.data, instance=request.user) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(data=UserPortalSerializer(request.user).data) diff --git a/app/user/migrations/0007_historicaluserext_image_userext_image.py b/app/user/migrations/0007_historicaluserext_image_userext_image.py new file mode 100644 index 0000000..5719144 --- /dev/null +++ b/app/user/migrations/0007_historicaluserext_image_userext_image.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2 on 2025-06-29 08:51 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("file", "0001_initial"), + ("user", "0006_alter_historicalorganization_name_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="historicaluserext", + name="image", + field=models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="file.publicfile", + ), + ), + migrations.AddField( + model_name="userext", + name="image", + field=models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="file.publicfile" + ), + ), + ] diff --git a/app/user/models/user.py b/app/user/models/user.py index 2b2e3f9..0801dd2 100644 --- a/app/user/models/user.py +++ b/app/user/models/user.py @@ -3,6 +3,7 @@ class UserExt(AbstractUser): + image = models.ForeignKey("file.PublicFile", on_delete=models.PROTECT, null=True, blank=True) nickname = models.CharField(max_length=128, null=True, blank=True) class Meta: