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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/core/const/tag.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
1 change: 1 addition & 0 deletions app/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"event.presentation",
"event.sponsor",
"admin_api",
"participant_portal_api",
# django-constance
"constance",
]
Expand Down
1 change: 1 addition & 0 deletions app/core/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
]
Expand Down
Original file line number Diff line number Diff line change
@@ -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),
),
]
1 change: 1 addition & 0 deletions app/event/presentation/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion app/event/presentation/translation.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class PresentationCategoryTranslationOptions(TranslationOptions):

@register(Presentation)
class PresentationTranslationOptions(TranslationOptions):
fields = ("title", "description")
fields = ("title", "summary", "description")


@register(PresentationSpeaker)
Expand Down
Empty file.
5 changes: 5 additions & 0 deletions app/participant_portal_api/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class ParticipantPortalApiConfig(AppConfig):
name = "participant_portal_api"
20 changes: 20 additions & 0 deletions app/participant_portal_api/permissions/__init__.py
Original file line number Diff line number Diff line change
@@ -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()
)
25 changes: 25 additions & 0 deletions app/participant_portal_api/serializers/file.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions app/participant_portal_api/serializers/presentation.py
Original file line number Diff line number Diff line change
@@ -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
128 changes: 128 additions & 0 deletions app/participant_portal_api/serializers/user.py
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions app/participant_portal_api/urls.py
Original file line number Diff line number Diff line change
@@ -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))]
45 changes: 45 additions & 0 deletions app/participant_portal_api/views/file.py
Original file line number Diff line number Diff line change
@@ -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)
Loading