diff --git a/app/admin_api/filtersets/event/presentation.py b/app/admin_api/filtersets/event/presentation.py
index 0506fe0..da8cb57 100644
--- a/app/admin_api/filtersets/event/presentation.py
+++ b/app/admin_api/filtersets/event/presentation.py
@@ -17,6 +17,7 @@ class PresentationCategoryAdminFilterSet(filters.FilterSet):
class PresentationAdminFilterSet(filters.FilterSet):
type = filters.UUIDFilter(field_name="type_id")
+ event = filters.UUIDFilter(field_name="type__event_id")
class PresentationSpeakerAdminFilterSet(filters.FilterSet):
@@ -26,3 +27,4 @@ class PresentationSpeakerAdminFilterSet(filters.FilterSet):
class RoomScheduleAdminFilterSet(filters.FilterSet):
room = filters.UUIDFilter(field_name="room_id")
presentation = filters.UUIDFilter(field_name="presentation_id")
+ event = filters.UUIDFilter(field_name="room__event_id")
diff --git a/app/admin_api/filtersets/merge.py b/app/admin_api/filtersets/merge.py
new file mode 100644
index 0000000..b5f8306
--- /dev/null
+++ b/app/admin_api/filtersets/merge.py
@@ -0,0 +1,14 @@
+from django.db.models import QuerySet
+from django_filters import rest_framework as filters
+from user.models.merge import UserMergeHistory
+
+
+class UserMergeAdminFilterSet(filters.FilterSet):
+ reverted = filters.BooleanFilter(method="filter_reverted")
+
+ class Meta:
+ model = UserMergeHistory
+ fields = ["source", "target", "reverted"]
+
+ def filter_reverted(self, queryset: QuerySet, name: str, value: bool) -> QuerySet:
+ return queryset.filter(reverted_at__isnull=not value)
diff --git a/app/admin_api/filtersets/shop/order_products.py b/app/admin_api/filtersets/shop/order_products.py
new file mode 100644
index 0000000..c9b196a
--- /dev/null
+++ b/app/admin_api/filtersets/shop/order_products.py
@@ -0,0 +1,63 @@
+from core.filter.multi_field import MultiFieldOrCharInFilter
+from django_filters import rest_framework as filters
+from shop.order.models import OrderProductRelation
+
+
+class OrderProductRelationAdminFilterSet(filters.FilterSet):
+ id = filters.BaseInFilter(field_name="id")
+ order_id = filters.BaseInFilter(field_name="order_id")
+ user_id = filters.BaseInFilter(field_name="order__user_id")
+ user_unique_id = filters.BaseInFilter(field_name="order__user__unique_id")
+ name = MultiFieldOrCharInFilter(
+ field_names=[
+ "order__user__nickname_ko",
+ "order__user__nickname_en",
+ "order__user__username",
+ "order__customer_info__name",
+ "ticket_info__name",
+ ],
+ lookup_expr="icontains",
+ )
+ email = MultiFieldOrCharInFilter(
+ field_names=["order__user__email", "order__customer_info__email", "ticket_info__email"],
+ lookup_expr="icontains",
+ )
+ imp_id = MultiFieldOrCharInFilter(field_names=["order_latest_imp_id"], lookup_expr="icontains")
+
+ status = filters.BaseCSVFilter(field_name="status", lookup_expr="in")
+ order_status = filters.BaseCSVFilter(field_name="order_current_status", lookup_expr="in")
+
+ first_paid_at_after = filters.DateTimeFilter(field_name="order_first_paid_at", lookup_expr="gte")
+ first_paid_at_before = filters.DateTimeFilter(field_name="order_first_paid_at", lookup_expr="lte")
+
+ product_id = filters.BaseInFilter(field_name="product_id")
+ category_id = filters.BaseInFilter(field_name="product__category_id")
+ category_group_id = filters.BaseInFilter(field_name="product__category__group_id")
+ event_id = filters.BaseInFilter(field_name="product__category__event_id")
+ is_ticket = filters.BooleanFilter(field_name="product__category__is_ticket")
+
+ price_min = filters.NumberFilter(field_name="price", lookup_expr="gte")
+ price_max = filters.NumberFilter(field_name="price", lookup_expr="lte")
+
+ class Meta:
+ model = OrderProductRelation
+ fields = [
+ "id",
+ "order_id",
+ "user_id",
+ "user_unique_id",
+ "name",
+ "email",
+ "imp_id",
+ "status",
+ "order_status",
+ "first_paid_at_after",
+ "first_paid_at_before",
+ "product_id",
+ "category_id",
+ "category_group_id",
+ "event_id",
+ "is_ticket",
+ "price_min",
+ "price_max",
+ ]
diff --git a/app/admin_api/serializers/event/presentation.py b/app/admin_api/serializers/event/presentation.py
index ac92848..8e9a58d 100644
--- a/app/admin_api/serializers/event/presentation.py
+++ b/app/admin_api/serializers/event/presentation.py
@@ -102,4 +102,4 @@ def validate(self, attrs: dict) -> dict:
class RoomAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
class Meta:
model = Room
- fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en")
+ fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en", "order")
diff --git a/app/admin_api/serializers/event/sponsor.py b/app/admin_api/serializers/event/sponsor.py
index 8fa7795..c63087f 100644
--- a/app/admin_api/serializers/event/sponsor.py
+++ b/app/admin_api/serializers/event/sponsor.py
@@ -1,3 +1,5 @@
+from typing import ClassVar
+
from core.const.serializer import COMMON_ADMIN_FIELDS
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
from core.serializer.json_schema_serializer import JsonSchemaSerializer
@@ -14,7 +16,8 @@ class Meta:
class SponsorTagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
class Meta:
model = SponsorTag
- fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en")
+ fields = COMMON_ADMIN_FIELDS + ("event", "name_ko", "name_en", "color")
+ extra_kwargs: ClassVar = {"color": {"allow_blank": False}}
class SponsorAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
diff --git a/app/admin_api/serializers/event/timetable.py b/app/admin_api/serializers/event/timetable.py
new file mode 100644
index 0000000..12ec014
--- /dev/null
+++ b/app/admin_api/serializers/event/timetable.py
@@ -0,0 +1,110 @@
+from __future__ import annotations
+
+import hashlib
+from collections import defaultdict
+
+from core.serializer.operation_serializer import Operation, OperationSerializer
+from core.util.dateutil import any_to_datetime
+from django.db import transaction
+from django.db.models import Count, Max
+from event.models import Event
+from event.presentation.models import Presentation, Room, RoomSchedule
+from rest_framework import serializers
+
+
+class RoomOperationAdminSerializer(OperationSerializer, serializers.ModelSerializer):
+ # 같은 요청의 신규 스케줄이 이 방을 참조하기 위한 요청 스코프 토큰(저장 안 함, 부모 save 에서 실제 pk 로 remap).
+ ref = serializers.CharField(write_only=True, required=False)
+
+ class Meta(OperationSerializer.Meta):
+ model = Room
+ fields = OperationSerializer.Meta.fields + ("ref", "name_ko", "name_en", "order")
+
+ def get_operation_queryset(self): # update/delete 는 이 event 하위 방으로 한정
+ return Room.objects.filter_active().filter(event=self.context["event"])
+
+
+class RoomScheduleOperationAdminSerializer(OperationSerializer, serializers.ModelSerializer):
+ # room_id: 읽기는 방 pk 를 그대로 노출, 쓰기는 기존 방 id 또는 같은 요청 방 create 의 ref → 부모 save 에서 해소.
+ room_id = serializers.CharField()
+ presentation = serializers.PrimaryKeyRelatedField(queryset=Presentation.objects.filter_active())
+
+ class Meta(OperationSerializer.Meta):
+ model = RoomSchedule
+ fields = OperationSerializer.Meta.fields + ("room_id", "start_at", "end_at", "presentation")
+
+ def get_operation_queryset(self): # update/delete 는 이 event 하위 스케줄로 한정
+ return RoomSchedule.objects.filter_active().filter(room__event=self.context["event"])
+
+ def validate(self, attrs: dict) -> dict:
+ start = any_to_datetime(attrs.get("start_at", getattr(self.instance, "start_at", None)))
+ end = any_to_datetime(attrs.get("end_at", getattr(self.instance, "end_at", None)))
+ if start and end and start >= end:
+ raise serializers.ValidationError({"start_at": "시작 시간은 종료 시간보다 전이어야 합니다."})
+ return attrs
+
+
+def timetable_version(event: Event) -> str:
+ rooms = Room.objects.filter_active().filter(event_id=event.id).aggregate(c=Count("id"), m=Max("updated_at"))
+ scheds = (
+ RoomSchedule.objects.filter_active()
+ .filter(room__event_id=event.id)
+ .aggregate(c=Count("id"), m=Max("updated_at"))
+ )
+ raw = f"{rooms['c']}|{rooms['m']}|{scheds['c']}|{scheds['m']}"
+ return hashlib.sha256(raw.encode()).hexdigest()[:16]
+
+
+class TimetableAdminSerializer(serializers.Serializer):
+ rooms = RoomOperationAdminSerializer(many=True, required=False)
+ schedules = RoomScheduleOperationAdminSerializer(many=True, required=False)
+
+ def to_representation(self, event: Event) -> dict:
+ return super().to_representation(
+ {
+ "rooms": Room.objects.filter_active().filter(event_id=event.id).order_by("order", "pk"),
+ "schedules": (
+ RoomSchedule.objects.filter_active().filter(room__event_id=event.id).order_by("start_at", "pk")
+ ),
+ }
+ )
+
+ @transaction.atomic
+ def save(self, **kwargs) -> Event:
+ event = self.context["event"]
+ room_ops = self.validated_data.get("rooms", [])
+ room_results = self.fields["rooms"].apply(room_ops, event=event)
+
+ ref_to_room = {
+ op["ref"]: room
+ for op, room in zip(room_ops, room_results)
+ if op["op"] == Operation.CREATE and op.get("ref") is not None
+ }
+ rooms_by_id = {str(room.id): room for room in Room.objects.filter_active().filter(event_id=event.id)}
+
+ schedule_ops = self.validated_data.get("schedules", [])
+ for attrs in schedule_ops:
+ if not (room_key := attrs.get("room_id")):
+ continue
+ if not (room := ref_to_room.get(room_key) or rooms_by_id.get(room_key)):
+ raise serializers.ValidationError({"schedules": "이벤트에 속한 활성 발표장이 아닙니다."})
+ attrs["room_id"] = room.id
+ self.fields["schedules"].apply(schedule_ops)
+
+ rows = (
+ RoomSchedule.objects.filter_active()
+ .filter(room__event_id=event.id)
+ .order_by("room_id", "start_at")
+ .values("room_id", "room__deleted_at", "start_at", "end_at")
+ )
+ by_room: dict = defaultdict(list)
+ for row in rows:
+ if row["room__deleted_at"] is not None:
+ raise serializers.ValidationError({"schedules": "삭제된 발표장을 참조하는 세션이 남아 있습니다."})
+ by_room[row["room_id"]].append(row)
+ for items in by_room.values():
+ for prev, cur in zip(items, items[1:]):
+ if cur["start_at"] < prev["end_at"]:
+ raise serializers.ValidationError({"schedules": "같은 발표장에 시간이 겹치는 세션이 있습니다."})
+
+ return event
diff --git a/app/admin_api/serializers/internal_api.py b/app/admin_api/serializers/internal_api.py
new file mode 100644
index 0000000..5c548ec
--- /dev/null
+++ b/app/admin_api/serializers/internal_api.py
@@ -0,0 +1,56 @@
+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 internal_api.models import RegistrationDeskConfig
+from rest_framework import serializers
+from shop.product.models import Category
+
+
+class RegistrationDeskConfigAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
+ event = serializers.PrimaryKeyRelatedField(queryset=Event.objects.filter_active())
+ logo_url = serializers.CharField(source="event.logo.file.url", read_only=True, allow_null=True)
+
+ categories = serializers.PrimaryKeyRelatedField(
+ many=True, allow_empty=False, queryset=Category.objects.filter_active()
+ )
+
+ class Meta:
+ model = RegistrationDeskConfig
+ fields = COMMON_ADMIN_FIELDS + (
+ "name",
+ "event",
+ "start_date",
+ "end_date",
+ "logo_url",
+ "categories",
+ )
+
+ def validate(self, attrs: dict) -> dict:
+ merged = {**attrs}
+ for field, fallback in (
+ ("start_date", RegistrationDeskConfig.DEFAULT_START_DATE),
+ ("end_date", RegistrationDeskConfig.DEFAULT_END_DATE),
+ ):
+ if merged.get(field) is None:
+ merged[field] = getattr(self.instance, field, None) or fallback
+
+ start, end = merged["start_date"], merged["end_date"]
+ if start > end:
+ raise serializers.ValidationError({"end_date": "종료일은 시작일보다 빠를 수 없습니다."})
+
+ # 행사 없는 공용 카테고리(굿즈 등)는 어느 설정에서나 허용.
+ event = attrs.get("event") or getattr(self.instance, "event", None)
+ categories = attrs.get("categories", [] if self.instance is None else list(self.instance.categories.all()))
+ if event and (mismatched := [c.name for c in categories if c.event_id not in (None, event.id)]):
+ joined = ", ".join(mismatched)
+ raise serializers.ValidationError({"categories": f"설정의 행사와 다른 행사의 카테고리입니다: {joined}"})
+
+ if conflict := (
+ RegistrationDeskConfig.objects.filter_active()
+ .filter_by_overlap(start_date=start, end_date=end, exclude_pk=self.instance.pk if self.instance else None)
+ .first()
+ ):
+ raise serializers.ValidationError({"start_date": f"적용 기간이 «{conflict.name}» 과(와) 겹칩니다."})
+
+ return attrs
diff --git a/app/admin_api/serializers/merge.py b/app/admin_api/serializers/merge.py
new file mode 100644
index 0000000..3984bda
--- /dev/null
+++ b/app/admin_api/serializers/merge.py
@@ -0,0 +1,60 @@
+from __future__ import annotations
+
+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 core.serializer.pk_related_serializer_field import PrimaryKeyRelatedSerializerField
+from django.db.transaction import atomic
+from rest_framework import serializers
+from user.models import UserExt
+from user.models.merge import MergeError, UserMergeHistory, UserMergeObject
+
+
+class UserMergeHistoryListAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
+ class UserExtSerializer(serializers.ModelSerializer):
+ str_repr = serializers.CharField(source="__str__", read_only=True)
+
+ class Meta:
+ model = UserExt
+ fields = ("id", "username", "email", "nickname", "is_active", "str_repr")
+
+ source = PrimaryKeyRelatedSerializerField(queryset=UserExt.objects.all(), serializer=UserExtSerializer)
+ target = PrimaryKeyRelatedSerializerField(queryset=UserExt.objects.all(), serializer=UserExtSerializer)
+ is_self_merge = serializers.BooleanField(read_only=True)
+
+ class Meta:
+ model = UserMergeHistory
+ fields = COMMON_ADMIN_FIELDS + ("source", "target", "is_self_merge", "reverted_at")
+
+ def validate(self, attrs: dict) -> dict:
+ if attrs["source"] == attrs["target"]:
+ raise serializers.ValidationError({"target": MergeError("same_account").localized(en=False)})
+ try:
+ UserMergeHistory.assert_emails_mergeable(attrs["source"], attrs["target"])
+ except MergeError as e:
+ raise serializers.ValidationError({"detail": e.localized(en=False)}) from e
+ return attrs
+
+ def create(self, validated_data: dict) -> UserMergeHistory:
+ try:
+ with atomic():
+ history = super().create(validated_data)
+ history.merge()
+ except MergeError as e:
+ raise serializers.ValidationError({"detail": e.localized(en=False)}) from e
+ return history
+
+
+class UserMergeHistoryAdminSerializer(UserMergeHistoryListAdminSerializer):
+ class UserMergeObjectAdminSerializer(serializers.ModelSerializer):
+ target_type_app = serializers.CharField(read_only=True)
+ target_type_resource = serializers.CharField(read_only=True)
+
+ class Meta:
+ model = UserMergeObject
+ fields = ("id", "target_type_app", "target_type_resource", "target_id", "field_names")
+
+ merged_objects = UserMergeObjectAdminSerializer(many=True, read_only=True)
+
+ class Meta(UserMergeHistoryListAdminSerializer.Meta):
+ fields = UserMergeHistoryListAdminSerializer.Meta.fields + ("merged_objects",)
diff --git a/app/admin_api/serializers/modification_audit.py b/app/admin_api/serializers/modification_audit.py
index 3fb9605..978a96e 100644
--- a/app/admin_api/serializers/modification_audit.py
+++ b/app/admin_api/serializers/modification_audit.py
@@ -2,6 +2,7 @@
import unicodedata
from core.serializer.json_schema_serializer import JsonSchemaSerializer
+from django.db import transaction
from event.presentation.models import Presentation, PresentationSpeaker
from participant_portal_api.models import ModificationAudit, ModificationAuditComment
from rest_framework import serializers
@@ -63,9 +64,10 @@ def validate(self, attrs: dict) -> dict:
def save(self, **kwargs: dict) -> ModificationAudit:
instance: ModificationAudit = self.instance
- instance.status = ModificationAudit.Status.APPROVED
- instance.apply_modification()
- instance.save()
+ with transaction.atomic():
+ instance.status = ModificationAudit.Status.APPROVED
+ instance.apply_modification()
+ instance.save()
return instance
diff --git a/app/admin_api/serializers/notification.py b/app/admin_api/serializers/notification.py
index 3d7062f..befd149 100644
--- a/app/admin_api/serializers/notification.py
+++ b/app/admin_api/serializers/notification.py
@@ -1,3 +1,4 @@
+from json import loads as json_loads
from typing import Any
from core.const.serializer import COMMON_ADMIN_FIELDS
@@ -23,13 +24,30 @@
)
from rest_framework import serializers
+
+def _validate_template_data(value: str, template_class: type[NotificationTemplateBase]) -> str:
+ # 에디터가 컴파일된 HTML을 통째로 넣는 실수를 발송 시점이 아니라 저장 시점에 잡는다.
+ try:
+ parsed = json_loads(value)
+ except ValueError as e:
+ raise serializers.ValidationError("올바른 JSON이 아닙니다.") from e
+
+ if not isinstance(parsed, dict):
+ raise serializers.ValidationError('JSON object여야 합니다. (예: {"title": "제목", "body": "..."})')
+
+ if missing := sorted(k for k in template_class.required_data_keys if not str(parsed.get(k) or "").strip()):
+ raise serializers.ValidationError(f"다음 key가 비어 있습니다: {missing}")
+
+ return value
+
+
# ---- SentTo nested ----------------------------------------------------------
class _NotiHistorySentToAdminSerializerBase(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
class Meta:
- fields = COMMON_ADMIN_FIELDS + ("recipient", "context", "status", "failure_reason")
- read_only_fields = (*COMMON_ADMIN_FIELDS, "status", "failure_reason")
+ fields = COMMON_ADMIN_FIELDS + ("recipient", "dedupe_key", "context", "status", "failure_reason")
+ read_only_fields = (*COMMON_ADMIN_FIELDS, "dedupe_key", "status", "failure_reason")
class EmailNotificationHistorySentToAdminSerializer(_NotiHistorySentToAdminSerializerBase):
@@ -70,6 +88,9 @@ class Meta:
"sent_to_status_summary",
)
+ def validate_template_data(self, value: str) -> str:
+ return _validate_template_data(value, self.Meta.model.template_class) if value else value
+
def create(self, validated_data: dict[str, Any]) -> NotificationHistoryBase:
# template이 명시되지 않은 templateless 경로면 transient (unsaved) template_class 인스턴스로 폴백.
# Kakao는 template이 required + template_data/sent_from이 read-only라 or 우측이 실행되지 않음.
@@ -155,6 +176,9 @@ class Meta:
def get_template_variables(self, obj: NotificationTemplateBase) -> list[str]:
return sorted(obj.template_variables)
+ def validate_data(self, value: str) -> str:
+ return _validate_template_data(value, self.Meta.model)
+
def render(self, context: dict[str, Any]) -> str:
return self.instance.build_preview_sent_to(context).render_as_html(undef_var=UnhandledVariableHandling.RANDOM)
diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py
index 32d022e..7d85d4b 100644
--- a/app/admin_api/serializers/shop/orders.py
+++ b/app/admin_api/serializers/shop/orders.py
@@ -8,7 +8,14 @@
from notification.channels import NotificationChannel
from notification.models.base import Recipient
from rest_framework import serializers
-from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, TicketInfo
+from shop.order.models import (
+ CustomerInfo,
+ Order,
+ OrderProductOptionRelation,
+ OrderProductRelation,
+ OrderProductRelationTag,
+ TicketInfo,
+)
from shop.payment_history.models import PaymentHistory
from shop.product.models import Product
from user.models import UserExt
@@ -68,14 +75,20 @@ class Meta:
model = TicketInfo
fields = ("name", "phone", "email", "organization", "contribution_message")
+ class SimpleTagSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = OrderProductRelationTag
+ fields = ("id", "code", "name")
+
product = SimpleProductSerializer(read_only=True)
options = SimpleOrderProductOptionRelationSerializer(many=True, read_only=True)
ticket_info = SimpleTicketInfoSerializer(read_only=True, allow_null=True)
+ tags = SimpleTagSerializer(many=True, read_only=True)
class Meta:
model = OrderProductRelation
- fields = ("id", "product", "status", "price", "donation_price", "options", "ticket_info")
- read_only_fields = ("id", "product", "price", "donation_price", "options", "ticket_info")
+ fields = ("id", "product", "status", "price", "donation_price", "options", "ticket_info", "tags")
+ read_only_fields = ("id", "product", "price", "donation_price", "options", "ticket_info", "tags")
user = SimpleUserSerializer(read_only=True)
customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True)
@@ -85,7 +98,7 @@ class Meta:
current_paid_price = serializers.IntegerField(read_only=True)
current_status = serializers.CharField(read_only=True)
first_paid_at = serializers.DateTimeField(read_only=True)
- latest_imp_id = serializers.CharField(read_only=True)
+ latest_imp_id = serializers.ReadOnlyField()
class Meta:
model = Order
@@ -124,8 +137,19 @@ class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer)
include_refunded = serializers.BooleanField(default=False)
+def _option_response_context(order_product_rel: OrderProductRelation) -> dict[str, Any]:
+ return {
+ o_rel.product_option_group.name: (
+ o_rel.custom_response
+ if o_rel.product_option_group.is_custom_response
+ else (o_rel.product_option.name if o_rel.product_option else "")
+ )
+ for o_rel in order_product_rel.options.all()
+ }
+
+
class _OrderRecipientItemSerializer(serializers.Serializer):
- """Order → Recipient ({recipient, context}) 변환.
+ """Order → Recipient ({recipient, context, dedupe_key}) 변환.
customer_info / 첫 상품 / recipient 부재 시 None 반환. None-skip 의미를 가지므로
반드시 `SkipNoneListSerializer` (Meta.list_serializer_class) 와 함께 `many=True` 로 사용 — 단독 사용 시 호출자가 None 처리 책임.
@@ -133,6 +157,7 @@ class _OrderRecipientItemSerializer(serializers.Serializer):
recipient = serializers.CharField()
context = serializers.JSONField()
+ dedupe_key = serializers.CharField(allow_blank=True)
class Meta:
list_serializer_class = SkipNoneListSerializer
@@ -144,25 +169,52 @@ def to_representation(self, order: Order) -> Recipient | None:
return None
if not (recipient := getattr(customer_info, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")):
return None
- if not (order_product_rel := next(iter(order.products.filter_active()), None)):
+ if not (order_product_rel := next(iter(order.products.all()), None)):
return None
- ctx: dict[str, Any] = {
- o_rel.product_option_group.name: (
- o_rel.custom_response
- if o_rel.product_option_group.is_custom_response
- else (o_rel.product_option.name if o_rel.product_option else "")
- )
- for o_rel in order_product_rel.options.filter_active()
- } | order.build_notification_context()
+ ctx = _option_response_context(order_product_rel) | order.build_notification_context()
+ return {"recipient": recipient, "context": ctx | self.context["context_override"], "dedupe_key": ""}
- return {"recipient": recipient, "context": ctx | self.context["context_override"]}
+
+class _OrderProductRecipientItemSerializer(serializers.Serializer):
+ """OrderProductRelation → Recipient 변환. None-skip 규약은 `_OrderRecipientItemSerializer` 와 동일.
+
+ 한 주문에서 여러 건이 나오므로 같은 수신자 중복을 허용하기 위해 `dedupe_key` 에 OPR id 를 싣는다.
+ """
+
+ recipient = serializers.CharField()
+ context = serializers.JSONField()
+ dedupe_key = serializers.CharField(allow_blank=True)
+
+ class Meta:
+ list_serializer_class = SkipNoneListSerializer
+
+ def to_representation(self, opr: OrderProductRelation) -> Recipient | None:
+ channel: NotificationChannel = self.context["channel"]
+
+ if (participant := opr.participant_info) is None:
+ return None
+ if not (recipient := getattr(participant, CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL[channel], "")):
+ return None
+
+ ctx: dict[str, Any] = {}
+ if (order := opr.order) is not None and getattr(order, "customer_info", None):
+ ctx = order.build_notification_context()
+
+ ctx |= opr.build_notification_context(participant=participant) | _option_response_context(opr)
+
+ return {
+ "recipient": recipient,
+ "context": ctx | self.context["context_override"],
+ "dedupe_key": str(opr.id),
+ }
class OrderSendNotificationPreviewResponseSerializer(JsonSchemaSerializer, serializers.Serializer):
class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer):
recipient = serializers.CharField()
context = serializers.JSONField()
+ dedupe_key = serializers.CharField(allow_blank=True)
missing_variables = serializers.ListField(child=serializers.CharField())
template_variables = serializers.ListField(child=serializers.CharField())
@@ -170,6 +222,9 @@ class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer):
class OrderSendNotificationSerializer(JsonSchemaSerializer, serializers.Serializer):
+ recipient_item_serializer_class: type[serializers.Serializer] = _OrderRecipientItemSerializer
+ empty_recipients_message = "발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)."
+
channel = serializers.ChoiceField(choices=NotificationChannel.choices)
template_id = serializers.UUIDField()
context_override = serializers.JSONField(required=False, default=dict)
@@ -185,7 +240,11 @@ def validate(self, attrs: dict) -> dict:
return {**attrs, "template": t}
def _build_recipient_items(self) -> list[Recipient]:
- return _OrderRecipientItemSerializer(instance=self.instance, many=True, context=self.validated_data).data
+ return self.recipient_item_serializer_class(
+ instance=self.instance,
+ many=True,
+ context=self.validated_data,
+ ).data
def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializer:
template_vars = self.validated_data["template"].template_variables
@@ -199,11 +258,15 @@ def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializ
},
)
+ def build_rendered_html(self) -> str:
+ """filterset 에 걸린 첫 대상의 context 로 렌더한 HTML — 특정 건을 보려면 `?id=` 로 좁힌다."""
+ if not (items := self._build_recipient_items()):
+ raise serializers.ValidationError(self.empty_recipients_message)
+ return self.validated_data["template"].build_preview_sent_to(items[0]["context"]).render_as_html()
+
def build_send_response(self) -> serializers.Serializer:
if not (items := self._build_recipient_items()):
- raise serializers.ValidationError(
- "발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)."
- )
+ raise serializers.ValidationError(self.empty_recipients_message)
channel: NotificationChannel = self.validated_data["channel"]
template = self.validated_data["template"]
if invalid := [
@@ -218,3 +281,28 @@ def build_send_response(self) -> serializers.Serializer:
history.send()
history.refresh_from_db()
return HISTORY_ADMIN_SERIALIZER_BY_CHANNEL[channel](instance=history)
+
+
+class OrderProductSendNotificationSerializer(OrderSendNotificationSerializer):
+ recipient_item_serializer_class = _OrderProductRecipientItemSerializer
+ empty_recipients_message = "발송 대상이 없습니다 (filterset 결과 0건 또는 참가자/주문자 정보 부재)."
+
+
+class OrderProductRelationTagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
+ class Meta:
+ model = OrderProductRelationTag
+ fields = COMMON_ADMIN_FIELDS + ("code", "name", "priority")
+
+ def validate_code(self, value: str) -> str:
+ duplicated = (
+ OrderProductRelationTag.objects.filter_active()
+ .filter(code=value)
+ .exclude(pk=self.instance.pk if self.instance else None)
+ )
+ if duplicated.exists():
+ raise serializers.ValidationError("이미 있는 코드입니다.")
+ return value
+
+
+class OrderProductRelationTagAssignResultSerializer(serializers.Serializer):
+ affected = serializers.IntegerField()
diff --git a/app/admin_api/serializers/shop/products.py b/app/admin_api/serializers/shop/products.py
index 12d5fae..0c144ed 100644
--- a/app/admin_api/serializers/shop/products.py
+++ b/app/admin_api/serializers/shop/products.py
@@ -99,16 +99,25 @@ class Meta:
class TagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer):
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
+ sold_count = serializers.IntegerField(read_only=True)
class Meta:
model = Tag
- fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user", "leftover_stock")
+ fields = COMMON_ADMIN_FIELDS + (
+ "name_ko",
+ "name_en",
+ "stock",
+ "max_quantity_per_user",
+ "leftover_stock",
+ "sold_count",
+ )
class OptionGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer):
class OptionAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer):
id = serializers.UUIDField(required=False, help_text="기존 Option 수정 시 PK 전달, 새로 추가 시 생략")
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
+ sold_count = serializers.IntegerField(read_only=True)
class Meta:
model = Option
@@ -121,11 +130,24 @@ class Meta:
"additional_price",
"stock",
"leftover_stock",
+ "sold_count",
)
# group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능.
extra_kwargs = {"group": {"required": False}}
list_serializer_class = InstanceListSerializer
+ def validate_stock(self, stock: int) -> int:
+ # stock=0 은 "무제한" sentinel, 판매 이력이 없는 옵션의 음수는 품절 처리 관용구라 둘 다 허용.
+ # 판매분보다 작은 값만 거절 — leftover_stock 이 음수가 되는 유일한 입력 경로다.
+ if self.instance is None or stock == 0:
+ return stock
+ sold_count: int = self.instance.sold_count
+ if sold_count and stock < sold_count:
+ raise serializers.ValidationError(
+ f"이미 {sold_count}개가 판매된 옵션입니다. 재고는 {sold_count} 이상이거나 0(무제한)이어야 합니다."
+ )
+ return stock
+
options = OptionAdminSerializer(many=True, required=False)
class Meta:
@@ -239,6 +261,7 @@ class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, seria
tag_set = serializers.PrimaryKeyRelatedField(many=True, queryset=Tag.objects.filter_active(), required=False)
tag_set_detail = TagAdminSerializer(many=True, read_only=True, source="tag_set")
leftover_stock = serializers.IntegerField(read_only=True, allow_null=True)
+ sold_count = serializers.IntegerField(read_only=True)
current_status = serializers.ChoiceField(choices=Product.CurrentStatus.choices, read_only=True)
image = serializers.PrimaryKeyRelatedField(
queryset=PublicFile.objects.filter_active(),
@@ -271,6 +294,7 @@ class Meta:
"tag_set",
"tag_set_detail",
"leftover_stock",
+ "sold_count",
"current_status",
)
diff --git a/app/admin_api/serializers/user.py b/app/admin_api/serializers/user.py
index b63d549..4e5744d 100644
--- a/app/admin_api/serializers/user.py
+++ b/app/admin_api/serializers/user.py
@@ -1,3 +1,4 @@
+import contextlib
import functools
import typing
@@ -7,20 +8,28 @@
from allauth.socialaccount.models import SocialAccount
from core.const.account import generate_random_password
from core.const.serializer import COMMON_ADMIN_FIELDS
+from core.external_apis.nhn_cloud.dooray import DoorayError, DoorayMember, nhn_cloud_dooray_client
from core.serializer.base_abstract_serializer import BaseAbstractSerializer
from core.serializer.json_schema_serializer import JsonSchemaSerializer
from core.serializer.nested_model_serializer import NestedFieldModelSerializer, NestedFieldSpec
from core.serializer.read_only_serializer import ReadOnlyModelSerializer
+from core.util.thread_local import get_current_user
from rest_framework import serializers
+from rest_framework.fields import empty
from user.models import UserExt
from user.models.organization import Organization
+_DOORAY_KEY_MASK = "************"
+
class UserAdminSerializer(JsonSchemaSerializer, NestedFieldModelSerializer):
str_repr = serializers.CharField(source="__str__", read_only=True)
email_addresses = EmailAddressNestedAdminSerializer(many=True, required=False, source="emailaddress_set")
social_accounts = SocialAccountNestedAdminSerializer(many=True, required=False, source="socialaccount_set")
+ dooray_api_key = serializers.CharField(required=False, allow_blank=True, allow_null=True)
+ dooray_account_info = serializers.SerializerMethodField(read_only=True)
+
class Meta:
model = UserExt
fields = (
@@ -37,6 +46,8 @@ class Meta:
"last_login",
"email_addresses",
"social_accounts",
+ "dooray_api_key",
+ "dooray_account_info",
)
extra_kwargs = {
"id": {"read_only": True},
@@ -56,6 +67,19 @@ class Meta:
),
}
+ def _dooray_me(self, token: str) -> DoorayMember | None:
+ cache = self.__dict__.setdefault("_dooray_me_cache", {})
+ if token not in cache:
+ cache[token] = None
+ with contextlib.suppress(DoorayError):
+ cache[token] = nhn_cloud_dooray_client.members_me(token)
+ return cache[token]
+
+ def get_dooray_account_info(self, obj: UserExt) -> DoorayMember | None:
+ if not obj.dooray_api_key or self.context.get("skip_dooray_connection_check"):
+ return None
+ return self._dooray_me(obj.dooray_api_key)
+
def validate(self, attrs: dict) -> dict:
# social_accounts=[] 는 마지막 SA cascade 를 트리거해 같은 user 의 EA 전체를 삭제함.
# 같은 PATCH 의 email_addresses 입력은 cascade 로 즉시 사라져 의도와 다른 결과가 되므로,
@@ -83,6 +107,19 @@ def validate_social_accounts(self, value: list[dict]) -> list[dict]:
raise serializers.ValidationError(msg)
return value
+ def validate_dooray_api_key(self, value: str | None) -> str | None:
+ if not value or value == _DOORAY_KEY_MASK:
+ return value
+ if not self.instance:
+ return None
+ if not ((current := get_current_user()) and self.instance.pk == current.pk):
+ raise serializers.ValidationError("본인 계정에만 Dooray 토큰을 등록할 수 있습니다.")
+ if not (me := self._dooray_me(value)):
+ raise serializers.ValidationError("유효하지 않거나 폐기된 Dooray 토큰입니다.")
+ if (me.get("externalEmailAddress") or "").lower() not in self.instance.emails:
+ raise serializers.ValidationError("Dooray 토큰의 이메일이 본인 계정 이메일과 일치하지 않습니다.")
+ return value
+
def create(self, validated_data: dict[str, typing.Any]) -> UserExt:
password = generate_random_password()
self._generated_password = password
@@ -91,6 +128,19 @@ def create(self, validated_data: dict[str, typing.Any]) -> UserExt:
self._apply_nested_sync(instance, nested_data)
return instance
+ def update(self, instance: UserExt, validated_data: dict[str, typing.Any]) -> UserExt:
+ dooray_api_key = validated_data.pop("dooray_api_key", empty)
+ if dooray_api_key not in {empty, _DOORAY_KEY_MASK}:
+ instance.dooray_api_key = dooray_api_key or None
+
+ return super().update(instance, validated_data)
+
+ def to_representation(self, instance: UserExt) -> dict:
+ data = super().to_representation(instance)
+ if data["dooray_api_key"] and not self.context.get("show_dooray_api_key"):
+ data["dooray_api_key"] = _DOORAY_KEY_MASK
+ return data
+
def _apply_nested_sync(self, instance: UserExt, nested_data: dict[str, list[dict] | None]) -> None:
# SocialAccount는 delete-only — 기존 set 에서 input 에 없는 것만 삭제.
sa_data = nested_data.pop("socialaccount_set", None)
diff --git a/app/admin_api/test/dooray_test.py b/app/admin_api/test/dooray_test.py
new file mode 100644
index 0000000..859d35a
--- /dev/null
+++ b/app/admin_api/test/dooray_test.py
@@ -0,0 +1,165 @@
+import httpx
+from core.external_apis.nhn_cloud.dooray import DoorayError, nhn_cloud_dooray_client
+from django.urls import reverse
+from rest_framework.test import APIClient
+from user.models import UserExt
+
+MASK = "*" * 12
+ME_URL = reverse("v1:admin-user-me")
+
+
+def _detail_url(pk) -> str:
+ return reverse("v1:admin-user-detail", kwargs={"pk": pk})
+
+
+def _me_returns(email: str):
+ return lambda _token: {"name": "홍길동", "externalEmailAddress": email, "id": "member-1"}
+
+
+def test_register_via_patch_validates_and_encrypts(api_client, superuser, monkeypatch):
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _me_returns(superuser.email))
+
+ resp = api_client.patch(_detail_url(superuser.pk), {"dooray_api_key": "tok-123"}, format="json")
+
+ assert resp.status_code == 200, resp.content
+ assert resp.json()["dooray_api_key"] == MASK # 실토큰 대신 마스크
+ assert UserExt.objects.get(pk=superuser.pk).dooray_api_key == "tok-123" # DB 는 암호문, 필드가 복호화
+
+
+def test_register_rejects_email_mismatch(api_client, superuser, monkeypatch):
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _me_returns("someone@dooray.com"))
+
+ resp = api_client.patch(_detail_url(superuser.pk), {"dooray_api_key": "tok"}, format="json")
+
+ assert resp.status_code == 400
+ assert UserExt.objects.get(pk=superuser.pk).dooray_api_key is None
+
+
+def test_register_rejects_invalid_token(api_client, superuser, monkeypatch):
+ def _raise(_token):
+ raise DoorayError(httpx.Response(401))
+
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _raise)
+
+ resp = api_client.patch(_detail_url(superuser.pk), {"dooray_api_key": "bad"}, format="json")
+
+ assert resp.status_code == 400
+
+
+def test_cannot_register_on_other_users_record(api_client, superuser, monkeypatch, db):
+ other = UserExt.objects.create_superuser(username="other", email="other@example.com", password="x") # nosec B106
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _me_returns(superuser.email))
+
+ resp = api_client.patch(_detail_url(other.pk), {"dooray_api_key": "tok"}, format="json")
+
+ assert resp.status_code == 400 # 본인 레코드에만 허용
+ assert UserExt.objects.get(pk=other.pk).dooray_api_key is None
+
+
+def test_retrieve_other_user_masks_token_but_checks_connection(api_client, superuser, monkeypatch, db):
+ other = UserExt.objects.create_superuser(username="other", email="o@example.com", password="x") # nosec B106
+ other.dooray_api_key = "secret"
+ other.save(update_fields=["dooray_api_key"])
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _me_returns("o@example.com"))
+
+ data = api_client.get(_detail_url(other.pk)).json()
+
+ assert data["dooray_api_key"] == MASK # 타인 실토큰 미노출
+ # 상세는 해당 유저 토큰으로 라이브 검증 → 계정정보 노출(시크릿 아님, =연결됨 신호)
+ assert data["dooray_account_info"]["externalEmailAddress"] == "o@example.com"
+
+
+def test_owner_sees_real_token_on_retrieve_self(api_client, superuser, monkeypatch):
+ superuser.dooray_api_key = "my-real-token"
+ superuser.save(update_fields=["dooray_api_key"])
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _me_returns(superuser.email))
+
+ data = api_client.get(_detail_url(superuser.pk)).json()
+
+ assert data["dooray_api_key"] == "my-real-token" # 본인 상세 → 실토큰
+ assert data["dooray_account_info"]["name"] == "홍길동"
+
+
+def test_me_masks_token_and_exposes_account_info(api_client, superuser, monkeypatch):
+ before = api_client.get(ME_URL).json()
+ assert before["dooray_api_key"] is None and before["dooray_account_info"] is None
+ monkeypatch.setattr(nhn_cloud_dooray_client, "members_me", _me_returns(superuser.email))
+ api_client.patch(_detail_url(superuser.pk), {"dooray_api_key": "tok"}, format="json")
+ superuser.refresh_from_db() # 실제 me 는 매 요청 DB 에서 유저를 새로 로드 (force_authenticate 는 객체 캐시)
+
+ after = api_client.get(ME_URL).json()
+
+ assert after["dooray_api_key"] == MASK # mcp 는 토큰 미조회(마스킹)
+ # me 는 라이브 검증(mcp gating 정확도) → 계정정보 존재 = 연결됨. list 만 예외.
+ assert after["dooray_account_info"]["externalEmailAddress"] == superuser.email
+
+
+def test_list_does_not_call_dooray(api_client, superuser):
+ # members_me 를 mock 하지 않음 — list 가 라이브 호출하면 SocketBlockedError 로 실패한다.
+ superuser.dooray_api_key = "tok"
+ superuser.save(update_fields=["dooray_api_key"])
+
+ resp = api_client.get(reverse("v1:admin-user-list"))
+
+ assert resp.status_code == 200 # 라이브 호출 없음(SocketBlockedError 안 남)
+ row = next(u for u in resp.json()["results"] if u["id"] == superuser.pk)
+ assert row["dooray_account_info"] is None # list 는 계정정보 미조회
+ assert row["dooray_api_key"] == MASK
+
+
+def test_clear_via_patch_empty_string(api_client, superuser):
+ superuser.dooray_api_key = "tok"
+ superuser.save(update_fields=["dooray_api_key"])
+
+ resp = api_client.patch(_detail_url(superuser.pk), {"dooray_api_key": ""}, format="json")
+
+ assert resp.status_code == 200
+ assert UserExt.objects.get(pk=superuser.pk).dooray_api_key is None
+
+
+def _forward_ok(*_args, **_kwargs):
+ return httpx.Response(200, json={"header": {"isSuccessful": True, "resultCode": 0}, "result": []})
+
+
+def _proxy_url(route: str) -> str:
+ return reverse("v1:admin-dooray-proxy", kwargs={"route": route})
+
+
+def test_proxy_requires_registered_token(api_client):
+ assert api_client.get(_proxy_url("project/v1/projects")).status_code == 409
+
+
+def test_proxy_rejects_disallowed_path(api_client, superuser):
+ superuser.dooray_api_key = "tok-123"
+ superuser.save(update_fields=["dooray_api_key"])
+ # Messenger 는 allowlist 밖 → 403 (SSRF/스코프 가드).
+ assert api_client.get(_proxy_url("messenger/v1/channels")).status_code == 403
+
+
+def test_proxy_forwards_allowed_path(superuser, monkeypatch):
+ monkeypatch.setattr(nhn_cloud_dooray_client, "forward", _forward_ok)
+ superuser.dooray_api_key = "tok-123"
+ superuser.save(update_fields=["dooray_api_key"])
+ client = APIClient()
+ client.force_authenticate(user=superuser)
+
+ resp = client.get(_proxy_url("project/v1/projects"))
+
+ assert resp.status_code == 200
+ assert resp.json()["result"] == []
+
+
+def test_proxy_passes_through_dooray_error(superuser, monkeypatch):
+ def _raise(*_args, **_kwargs):
+ raise DoorayError(httpx.Response(404, json={"header": {"isSuccessful": False, "resultMessage": "없음"}}))
+
+ monkeypatch.setattr(nhn_cloud_dooray_client, "forward", _raise)
+ superuser.dooray_api_key = "tok-123"
+ superuser.save(update_fields=["dooray_api_key"])
+ client = APIClient()
+ client.force_authenticate(user=superuser)
+
+ resp = client.get(_proxy_url("project/v1/projects/x/posts/y"))
+
+ assert resp.status_code == 404 # Dooray 오류 상태·본문 그대로 통과
+ assert resp.json()["header"]["resultMessage"] == "없음"
diff --git a/app/admin_api/test/event/presentation/filterset_test.py b/app/admin_api/test/event/presentation/filterset_test.py
new file mode 100644
index 0000000..4e4b11f
--- /dev/null
+++ b/app/admin_api/test/event/presentation/filterset_test.py
@@ -0,0 +1,62 @@
+import pytest
+from admin_api.filtersets.event.presentation import (
+ PresentationAdminFilterSet,
+ RoomScheduleAdminFilterSet,
+)
+from event.models import Event
+from event.presentation.models import Presentation, PresentationType, Room, RoomSchedule
+from user.models.organization import Organization
+
+
+@pytest.mark.django_db
+def test_presentation_admin_filterset_filters_by_event() -> None:
+ # Given: 서로 다른 이벤트에 속한 발표 2개
+ organization = Organization.objects.create(name="Test Organization")
+ event_a = Event.objects.create(name="Event A", organization=organization)
+ event_b = Event.objects.create(name="Event B", organization=organization)
+
+ type_a = PresentationType.objects.create(name="Type A", event=event_a)
+ type_b = PresentationType.objects.create(name="Type B", event=event_b)
+ presentation_a = Presentation.objects.create(type=type_a, title="A")
+ Presentation.objects.create(type=type_b, title="B")
+
+ # When: event_a 로 필터링
+ filterset = PresentationAdminFilterSet({"event": str(event_a.pk)}, queryset=Presentation.objects.all())
+
+ # Then: event_a 의 발표만 반환
+ assert list(filterset.qs) == [presentation_a]
+
+
+@pytest.mark.django_db
+def test_room_schedule_admin_filterset_filters_by_event() -> None:
+ # Given: 서로 다른 이벤트의 방/발표에 속한 스케줄 2개
+ organization = Organization.objects.create(name="Test Organization")
+ event_a = Event.objects.create(name="Event A", organization=organization)
+ event_b = Event.objects.create(name="Event B", organization=organization)
+
+ room_a = Room.objects.create(event=event_a, name="Room A")
+ room_b = Room.objects.create(event=event_b, name="Room B")
+
+ type_a = PresentationType.objects.create(name="Type A", event=event_a)
+ type_b = PresentationType.objects.create(name="Type B", event=event_b)
+ presentation_a = Presentation.objects.create(type=type_a, title="A")
+ presentation_b = Presentation.objects.create(type=type_b, title="B")
+
+ schedule_a = RoomSchedule.objects.create(
+ room=room_a,
+ presentation=presentation_a,
+ start_at="2026-08-16T10:00:00+09:00",
+ end_at="2026-08-16T11:00:00+09:00",
+ )
+ RoomSchedule.objects.create(
+ room=room_b,
+ presentation=presentation_b,
+ start_at="2026-08-16T10:00:00+09:00",
+ end_at="2026-08-16T11:00:00+09:00",
+ )
+
+ # When: event_a 로 필터링
+ filterset = RoomScheduleAdminFilterSet({"event": str(event_a.pk)}, queryset=RoomSchedule.objects.all())
+
+ # Then: event_a 의 스케줄만 반환
+ assert list(filterset.qs) == [schedule_a]
diff --git a/app/admin_api/test/event/sponsor_test.py b/app/admin_api/test/event/sponsor_test.py
new file mode 100644
index 0000000..ad1f9b5
--- /dev/null
+++ b/app/admin_api/test/event/sponsor_test.py
@@ -0,0 +1,38 @@
+import pytest
+from admin_api.serializers.event.sponsor import SponsorTagAdminSerializer
+from admin_api.views.event.sponsor import SponsorTagAdminViewSet
+from model_bakery import baker
+
+
+@pytest.fixture
+def sponsor_tag(db):
+ return baker.make("sponsor.SponsorTag", color=None)
+
+
+@pytest.mark.parametrize("color", ["#3498db", "#ABCDEF", None])
+def test_admin_serializer_accepts_color(sponsor_tag, color):
+ serializer = SponsorTagAdminSerializer(instance=sponsor_tag, data={"color": color}, partial=True)
+ assert serializer.is_valid(), serializer.errors
+
+
+@pytest.mark.parametrize("color", ["", "3498db", "#3498d", "red"])
+def test_admin_serializer_rejects_invalid_color(sponsor_tag, color):
+ serializer = SponsorTagAdminSerializer(instance=sponsor_tag, data={"color": color}, partial=True)
+ assert not serializer.is_valid()
+ assert "color" in serializer.errors
+
+
+def test_admin_serializer_stores_null_color(sponsor_tag):
+ serializer = SponsorTagAdminSerializer(instance=sponsor_tag, data={"color": None}, partial=True)
+ assert serializer.is_valid(), serializer.errors
+ tag = serializer.save()
+ tag.refresh_from_db()
+ assert tag.color is None
+
+
+def test_json_schema_exposes_color_picker_widget():
+ schema = SponsorTagAdminViewSet().get_json_schema()
+
+ assert schema["schema"]["properties"]["color"]["type"] == ["string", "null"]
+ assert "color" not in schema["schema"]["required"]
+ assert schema["ui_schema"]["color"] == {"ui:widget": "color"}
diff --git a/app/admin_api/test/event/timetable_test.py b/app/admin_api/test/event/timetable_test.py
new file mode 100644
index 0000000..106ddbf
--- /dev/null
+++ b/app/admin_api/test/event/timetable_test.py
@@ -0,0 +1,260 @@
+import http
+import uuid
+
+import pytest
+from admin_api.serializers.event.timetable import timetable_version
+from django.urls import reverse
+from event.models import Event
+from event.presentation.models import Presentation, PresentationType, Room, RoomSchedule
+from rest_framework.test import APIClient
+from user.models.organization import Organization
+
+TIMETABLE = "v1:admin-event-presentation-timetable-detail"
+
+
+@pytest.fixture
+def event(db) -> Event:
+ organization = Organization.objects.create(name="Org")
+ return Event.objects.create(name="PyCon", organization=organization)
+
+
+@pytest.fixture
+def presentation(event) -> Presentation:
+ ptype = PresentationType.objects.create(name="Talk", event=event)
+ return Presentation.objects.create(type=ptype, title="A talk")
+
+
+@pytest.fixture
+def room(event) -> Room:
+ return Room.objects.create(event=event, name="Room A")
+
+
+def _url(event: Event) -> str:
+ return reverse(TIMETABLE, args=[event.id])
+
+
+def _version(event: Event) -> str:
+ return timetable_version(event)
+
+
+def _get(api_client: APIClient, event: Event):
+ return api_client.get(_url(event))
+
+
+def _put(api_client: APIClient, event: Event, payload: dict, if_match: str | None = None):
+ extra = {"HTTP_IF_MATCH": if_match} if if_match is not None else {}
+ return api_client.put(_url(event), payload, format="json", **extra)
+
+
+# ---- Auth -------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_unauthenticated_rejected(event):
+ response = APIClient().get(_url(event))
+ assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED)
+
+
+@pytest.mark.django_db
+def test_non_superuser_rejected(event, customer_user):
+ client = APIClient()
+ client.force_authenticate(user=customer_user)
+ assert client.get(_url(event)).status_code == http.HTTPStatus.FORBIDDEN
+
+
+# ---- Read -------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_get_returns_rooms_schedules_and_etag(api_client, event, room, presentation):
+ RoomSchedule.objects.create(
+ room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00"
+ )
+ response = _get(api_client, event)
+ assert response.status_code == http.HTTPStatus.OK
+ assert len(response.data["rooms"]) == 1
+ assert len(response.data["schedules"]) == 1
+ assert "version" not in response.data # 동시성 토큰은 ETag 헤더로만
+ assert response["ETag"].strip('"') == _version(event)
+
+
+@pytest.mark.django_db
+def test_unknown_event_returns_404(api_client):
+ assert api_client.get(reverse(TIMETABLE, args=[uuid.uuid4()])).status_code == http.HTTPStatus.NOT_FOUND
+
+
+@pytest.mark.django_db
+def test_head_returns_etag_without_body(api_client, event, room):
+ response = api_client.head(_url(event))
+ assert response.status_code == http.HTTPStatus.OK
+ assert response["ETag"].strip('"') == _version(event)
+ assert not response.content
+
+
+# ---- Save (op-based partial reflection) -------------------------------------
+
+
+@pytest.mark.django_db
+def test_put_creates_room_and_schedule(api_client, event, presentation):
+ payload = {
+ "rooms": [{"op": "create", "ref": "new-room", "name_ko": "새방", "name_en": "New", "order": 0}],
+ "schedules": [
+ {
+ "op": "create",
+ "room_id": "new-room", # 같은 요청 방 create 의 ref 로 참조 → 서버가 실제 pk 로 remap
+ "presentation": str(presentation.id),
+ "start_at": "2026-08-16T10:00:00+09:00",
+ "end_at": "2026-08-16T11:00:00+09:00",
+ }
+ ],
+ }
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.OK
+ [created_room] = response.data["rooms"]
+ [schedule] = response.data["schedules"]
+ assert str(schedule["room_id"]) == str(created_room["id"]) # ref → 서버 생성 id 로 해소됨
+
+
+@pytest.mark.django_db
+def test_put_schedule_references_existing_room_by_id(api_client, event, room, presentation):
+ payload = {
+ "schedules": [
+ {
+ "op": "create",
+ "room_id": str(room.id), # 기존 방은 실제 id 로 참조
+ "presentation": str(presentation.id),
+ "start_at": "2026-08-16T10:00:00+09:00",
+ "end_at": "2026-08-16T11:00:00+09:00",
+ }
+ ],
+ }
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.OK
+ [schedule] = response.data["schedules"]
+ assert str(schedule["room_id"]) == str(room.id)
+
+
+@pytest.mark.django_db
+def test_put_unlisted_items_are_untouched(api_client, event):
+ keep = Room.objects.create(event=event, name="Keep")
+ edit = Room.objects.create(event=event, name="Edit")
+
+ payload = {"rooms": [{"op": "update", "id": str(edit.id), "name_ko": "수정됨"}]}
+ response = _put(api_client, event, payload, if_match=_version(event))
+
+ assert response.status_code == http.HTTPStatus.OK
+ edit.refresh_from_db()
+ assert edit.name_ko == "수정됨"
+ assert Room.objects.filter_active().filter(id=keep.id).exists() # 목록에 없던 방은 그대로
+ assert Room.objects.filter_active().filter(event_id=event.id).count() == 2
+
+
+@pytest.mark.django_db
+def test_put_deletes_room_and_its_schedule(api_client, event, room, presentation):
+ schedule = RoomSchedule.objects.create(
+ room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00"
+ )
+ payload = {
+ "rooms": [{"op": "delete", "id": str(room.id)}],
+ "schedules": [{"op": "delete", "id": str(schedule.id)}],
+ }
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.OK
+ assert not Room.objects.filter_active().filter(id=room.id).exists()
+ assert not RoomSchedule.objects.filter_active().filter(id=schedule.id).exists()
+
+
+@pytest.mark.django_db
+def test_put_deleting_room_with_remaining_schedule_is_rejected(api_client, event, room, presentation):
+ RoomSchedule.objects.create(
+ room=room, presentation=presentation, start_at="2026-08-16T10:00:00+09:00", end_at="2026-08-16T11:00:00+09:00"
+ )
+ # 방만 삭제하고 세션은 남겨두면 고아 세션이 되므로 거부되어야 한다.
+ payload = {"rooms": [{"op": "delete", "id": str(room.id)}]}
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ assert Room.objects.filter_active().filter(id=room.id).exists() # 롤백됨
+
+
+@pytest.mark.django_db
+def test_put_stale_if_match_returns_412(api_client, event, room):
+ stale = _version(event)
+ Room.objects.create(event=event, name="Concurrent") # 그 사이 버전 변경
+
+ response = _put(api_client, event, {"rooms": [{"op": "delete", "id": str(room.id)}]}, if_match=stale)
+ assert response.status_code == http.HTTPStatus.PRECONDITION_FAILED
+ assert len(response.data["rooms"]) == 2 # 현재 서버 상태를 함께 반환
+ assert Room.objects.filter_active().filter(id=room.id).exists() # 저장 미적용
+
+
+@pytest.mark.django_db
+def test_put_conflict_returns_400_and_rolls_back(api_client, event, room, presentation):
+ overlapping = {
+ "schedules": [
+ {
+ "op": "create",
+ "room_id": str(room.id),
+ "presentation": str(presentation.id),
+ "start_at": "2026-08-16T10:00:00+09:00",
+ "end_at": "2026-08-16T11:00:00+09:00",
+ },
+ {
+ "op": "create",
+ "room_id": str(room.id),
+ "presentation": str(presentation.id),
+ "start_at": "2026-08-16T10:30:00+09:00",
+ "end_at": "2026-08-16T11:30:00+09:00",
+ },
+ ],
+ }
+ response = _put(api_client, event, overlapping, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ assert RoomSchedule.objects.filter_active().filter(room__event_id=event.id).count() == 0 # 롤백됨
+
+
+@pytest.mark.django_db
+def test_put_update_missing_instance_returns_400(api_client, event):
+ payload = {"rooms": [{"op": "update", "id": str(uuid.uuid4()), "name_ko": "없음"}]}
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_put_cannot_touch_other_event_room(api_client, event):
+ # 다른 event 의 방 id 로 update 를 시도해도 이 event 스코프 밖이라 400, 대상은 그대로여야 한다.
+ other_event = Event.objects.create(name="Other", organization=event.organization)
+ foreign = Room.objects.create(event=other_event, name="Foreign")
+
+ payload = {"rooms": [{"op": "update", "id": str(foreign.id), "name_ko": "침범"}]}
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ foreign.refresh_from_db()
+ assert foreign.name_ko == "Foreign" # 변경 안 됨
+
+
+@pytest.mark.django_db
+def test_put_schedule_with_unknown_room_ref_returns_400(api_client, event, presentation):
+ payload = {
+ "schedules": [
+ {
+ "op": "create",
+ "room_id": "no-such-ref", # 존재하는 방 id 도, 이번 요청의 방 ref 도 아님
+ "presentation": str(presentation.id),
+ "start_at": "2026-08-16T10:00:00+09:00",
+ "end_at": "2026-08-16T11:00:00+09:00",
+ }
+ ],
+ }
+ response = _put(api_client, event, payload, if_match=_version(event))
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+
+# ---- Version token ----------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_version_changes_on_delete(event):
+ room = Room.objects.create(event=event, name="R")
+ before = _version(event)
+ room.delete() # soft delete (updated_at 은 안 오르지만 count 가 바뀜)
+ assert _version(event) != before
diff --git a/app/admin_api/test/helpers.py b/app/admin_api/test/helpers.py
index 4a47a26..8d63110 100644
--- a/app/admin_api/test/helpers.py
+++ b/app/admin_api/test/helpers.py
@@ -30,14 +30,27 @@ def export(self, params=None):
return self.http_client.post(url, format="json")
-class OrderNotificationsAdminApi(ModelApiFixture):
+class _NotificationSendApiFixture(ModelApiFixture):
+ def _url(self, action, params):
+ url = reverse(f"{self.name}-{action}")
+ return f"{url}?{urlencode(params, doseq=True)}" if params else url
+
+ def preview(self, data=None, params=None):
+ return self.http_client.post(self._url("preview", params), data, format="json")
+
+ def send(self, data=None, params=None):
+ return self.http_client.post(self._url("send", params), data, format="json")
+
+ def render(self, data=None, params=None):
+ return self.http_client.post(self._url("render-preview", params), data, format="json")
+
+
+class OrderNotificationsAdminApi(_NotificationSendApiFixture):
name: ClassVar[str] = "v1:admin-shop-order-notification"
- def preview(self, data=None):
- return self.http_client.post(reverse(f"{self.name}-preview"), data, format="json")
- def send(self, data=None):
- return self.http_client.post(reverse(f"{self.name}-send"), data, format="json")
+class OrderProductNotificationsAdminApi(_NotificationSendApiFixture):
+ name: ClassVar[str] = "v1:admin-shop-order-product-notification"
class CategoryGroupsAdminApi(ModelApiFixture):
diff --git a/app/admin_api/test/internal_api_test.py b/app/admin_api/test/internal_api_test.py
new file mode 100644
index 0000000..5575728
--- /dev/null
+++ b/app/admin_api/test/internal_api_test.py
@@ -0,0 +1,251 @@
+from datetime import date
+
+import pytest
+from django.db.utils import IntegrityError
+from django.urls import reverse
+from event.models import Event
+from file.models import PublicFile
+from internal_api.models import RegistrationDeskConfig
+from model_bakery import baker
+from rest_framework.status import (
+ HTTP_200_OK,
+ HTTP_201_CREATED,
+ HTTP_204_NO_CONTENT,
+ HTTP_400_BAD_REQUEST,
+ HTTP_403_FORBIDDEN,
+)
+from rest_framework.test import APIClient
+from shop.conftest import ticket_product # noqa: F401
+
+LIST_URL = reverse("v1:admin-registration-desk-config-list")
+
+
+def _detail_url(pk) -> str:
+ return reverse("v1:admin-registration-desk-config-detail", args=[pk])
+
+
+def _payload(event, category_id, **overrides) -> dict:
+ return {"name": "설정", "event": str(event.id), "categories": [str(category_id)], **overrides}
+
+
+@pytest.fixture
+def anon_client() -> APIClient:
+ return APIClient()
+
+
+@pytest.fixture
+def customer_client(customer_user) -> APIClient:
+ client = APIClient()
+ client.force_authenticate(user=customer_user)
+ return client
+
+
+@pytest.fixture
+def event(db) -> Event:
+ return baker.make("event.Event", name="파이콘 한국 2026")
+
+
+@pytest.fixture
+def day1_config(event) -> RegistrationDeskConfig:
+ return RegistrationDeskConfig.objects.create(
+ name="Day 1", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 15)
+ )
+
+
+@pytest.mark.parametrize("client_fixture", ["anon_client", "customer_client"])
+@pytest.mark.django_db
+def test_config_list_rejects_non_superuser(request, client_fixture):
+ assert request.getfixturevalue(client_fixture).get(LIST_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_config_list_is_paginated(api_client, day1_config):
+ response = api_client.get(LIST_URL)
+
+ assert response.status_code == HTTP_200_OK
+ body = response.json()
+ assert body["count"] == 1
+ assert body["results"][0]["name"] == "Day 1"
+
+
+@pytest.mark.django_db
+def test_config_create_persists_target_categories(api_client, ticket_product, event): # noqa: F811
+ response = api_client.post(
+ LIST_URL,
+ {
+ "name": "Day 1",
+ "event": str(event.id),
+ "start_date": "2026-08-15",
+ "end_date": "2026-08-15",
+ "categories": [str(ticket_product.category_id)],
+ },
+ format="json",
+ )
+
+ assert response.status_code == HTTP_201_CREATED
+ body = response.json()
+ assert body["categories"] == [str(ticket_product.category_id)]
+ config = RegistrationDeskConfig.objects.get(id=body["id"])
+ assert config.start_date == date(2026, 8, 15)
+ assert list(config.categories.all()) == [ticket_product.category]
+
+
+@pytest.mark.django_db
+def test_config_exposes_event_logo_url(api_client, event, ticket_product): # noqa: F811
+ logo = PublicFile.objects.create(file="public/desk-logo.png", mimetype="image/png", hash="desk-logo", size=0)
+ event.logo = logo
+ event.save()
+
+ response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json")
+
+ assert response.status_code == HTTP_201_CREATED
+ assert response.json()["logo_url"] == logo.file.url
+
+
+@pytest.mark.django_db
+def test_config_create_requires_event(api_client):
+ response = api_client.post(LIST_URL, {"name": "이벤트 없음"}, format="json")
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "event" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_config_create_rejects_end_date_before_start_date(api_client, event, ticket_product): # noqa: F811
+ response = api_client.post(
+ LIST_URL,
+ _payload(event, ticket_product.category_id, start_date="2026-08-16", end_date="2026-08-15"),
+ format="json",
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "end_date" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_config_create_rejects_overlapping_period(api_client, day1_config, event, ticket_product): # noqa: F811
+ response = api_client.post(
+ LIST_URL,
+ _payload(event, ticket_product.category_id, start_date="2026-08-15", end_date="2026-08-16"),
+ format="json",
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "Day 1" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_config_create_allows_adjacent_period(api_client, day1_config, event, ticket_product): # noqa: F811
+ response = api_client.post(
+ LIST_URL,
+ _payload(event, ticket_product.category_id, start_date="2026-08-16", end_date="2026-08-16"),
+ format="json",
+ )
+
+ assert response.status_code == HTTP_201_CREATED
+
+
+@pytest.mark.django_db
+def test_config_create_rejects_open_ended_period_when_another_exists(
+ api_client,
+ day1_config,
+ event,
+ ticket_product, # noqa: F811
+):
+ response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json")
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_config_update_does_not_conflict_with_itself(api_client, day1_config):
+ response = api_client.patch(_detail_url(day1_config.id), {"name": "첫째 날"}, format="json")
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["name"] == "첫째 날"
+
+
+@pytest.mark.django_db
+def test_config_partial_update_merges_stored_dates_for_overlap_check(api_client, day1_config, event):
+ RegistrationDeskConfig.objects.create(
+ name="Day 2", event=event, start_date=date(2026, 8, 16), end_date=date(2026, 8, 16)
+ )
+
+ # end_date 만 보내도 저장된 start_date 와 합쳐 Day 2 와의 겹침을 잡아야 한다.
+ response = api_client.patch(_detail_url(day1_config.id), {"end_date": "2026-08-16"}, format="json")
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "Day 2" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_config_destroy_soft_deletes(api_client, day1_config):
+ assert api_client.delete(_detail_url(day1_config.id)).status_code == HTTP_204_NO_CONTENT
+
+ day1_config.refresh_from_db()
+ assert day1_config.deleted_at is not None
+ assert api_client.get(LIST_URL).json()["count"] == 0
+
+
+@pytest.mark.django_db
+def test_config_json_schema_and_selectables_are_available(api_client, day1_config):
+ schema_response = api_client.get(f"{LIST_URL}json-schema/")
+ selectables_response = api_client.get(f"{LIST_URL}selectables/")
+
+ assert schema_response.status_code == HTTP_200_OK
+ assert "start_date" in schema_response.json()["schema"]["properties"]
+ assert selectables_response.status_code == HTTP_200_OK
+ assert [row["const"] for row in selectables_response.json()["results"]] == [str(day1_config.id)]
+
+
+@pytest.mark.django_db
+def test_config_create_rejects_empty_categories(api_client, event):
+ response = api_client.post(LIST_URL, {"name": "빈 설정", "event": str(event.id), "categories": []}, format="json")
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "categories" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_config_create_rejects_category_of_other_event(api_client, event, ticket_product): # noqa: F811
+ other_event = baker.make("event.Event", name="다른 행사")
+ ticket_product.category.event = other_event
+ ticket_product.category.save(update_fields=["event"])
+
+ response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json")
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "categories" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_config_create_allows_category_without_event(api_client, event, ticket_product): # noqa: F811
+ assert ticket_product.category.event_id is None
+
+ response = api_client.post(LIST_URL, _payload(event, ticket_product.category_id), format="json")
+
+ assert response.status_code == HTTP_201_CREATED
+
+
+@pytest.mark.django_db
+def test_config_overlapping_period_is_blocked_by_db_constraint(event):
+ # 시리얼라이저를 우회한 동시 요청까지 DB EXCLUDE 제약이 막는다.
+ RegistrationDeskConfig.objects.create(
+ name="Day 1", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 16)
+ )
+
+ with pytest.raises(IntegrityError):
+ RegistrationDeskConfig.objects.create(
+ name="겹침", event=event, start_date=date(2026, 8, 16), end_date=date(2026, 8, 17)
+ )
+
+
+@pytest.mark.django_db
+def test_config_overlapping_period_ignores_soft_deleted(event):
+ RegistrationDeskConfig.objects.create(
+ name="Day 1", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 16)
+ ).delete()
+
+ RegistrationDeskConfig.objects.create(
+ name="Day 1 재등록", event=event, start_date=date(2026, 8, 15), end_date=date(2026, 8, 16)
+ )
diff --git a/app/admin_api/test/notification_test.py b/app/admin_api/test/notification_test.py
index fb59b4d..15461e8 100644
--- a/app/admin_api/test/notification_test.py
+++ b/app/admin_api/test/notification_test.py
@@ -64,6 +64,40 @@ def test_template_create(api_client):
assert EmailNotificationTemplate.objects.filter(code="new-tpl").exists()
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ "data",
+ [
+ "
Hello", # 에디터 컴파일 결과를 그대로 넣은 경우
+ '["not", "an", "object"]',
+ '{"body":"b"}', # title 누락
+ '{"title":" ","body":"b"}',
+ ],
+)
+def test_template_create_rejects_invalid_data(api_client, data):
+ response = api_client.post(
+ reverse("v1:admin-notification-email-template-list"),
+ data={"code": "bad-tpl", "title": "잘못됨", "sent_from": "from@example.com", "data": data},
+ format="json",
+ )
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ assert not EmailNotificationTemplate.objects.filter(code="bad-tpl").exists()
+
+
+@pytest.mark.django_db
+def test_create_history_templateless_rejects_invalid_template_data(api_client):
+ response = api_client.post(
+ reverse("v1:admin-notification-email-history-list"),
+ data={
+ "template_data": "plain",
+ "sent_from": "from@example.com",
+ "sent_to_list": [{"recipient": "to@example.com"}],
+ },
+ format="json",
+ )
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+
@pytest.mark.django_db
def test_template_partial_update(api_client, email_template):
response = api_client.patch(
diff --git a/app/admin_api/test/shop/order_notifications_api_test.py b/app/admin_api/test/shop/order_notifications_api_test.py
index 70c6869..a1dd710 100644
--- a/app/admin_api/test/shop/order_notifications_api_test.py
+++ b/app/admin_api/test/shop/order_notifications_api_test.py
@@ -1,8 +1,5 @@
-from urllib.parse import urljoin
-
import pytest
from admin_api.test.helpers import OrderNotificationsAdminApi
-from django.conf import settings
from notification.models.email import EmailNotificationHistory, EmailNotificationTemplate
from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
from shop.order.models import CustomerInfo
@@ -27,7 +24,6 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client,
{"channel": "email", "template_id": str(order_email_template.id)}
)
assert response.status_code == HTTP_200_OK
- scancode_url = urljoin(settings.BACKEND_DOMAIN, completed_order.scancode_path)
# 서버에서 isoformat 문자열로 변환된 채로 응답 / DB 저장 (JSONField datetime 미지원 회피).
first_paid_at_str = completed_order.first_paid_at.isoformat()
assert response.json() == {
@@ -36,14 +32,50 @@ def test_notification_preview_returns_recipients_for_completed_order(api_client,
{
"recipient": "customer@example.com",
"context": {
- "scancode_url": scancode_url,
"order_name": "파이콘 한국 2026 티켓",
"first_paid_at": first_paid_at_str,
"first_paid_price": 10000,
"customer_name": "홍길동",
"customer_phone": "01012345678",
"customer_email": "customer@example.com",
+ "customer_organization": "",
+ "성함": "홍길동",
+ "성명": "홍길동",
+ "소속": "",
+ },
+ "dedupe_key": "",
+ "missing_variables": [],
+ }
+ ],
+ }
+
+
+@pytest.mark.django_db
+def test_notification_preview_includes_free_completed_order(api_client, order_email_template, order_factory):
+ order = order_factory(status="completed", product_price=0, imp_id=None)
+ response = OrderNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(order_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json() == {
+ "template_variables": ["customer_email", "customer_name", "first_paid_price", "order_name"],
+ "recipients": [
+ {
+ "recipient": order.customer_info.email,
+ "context": {
+ "order_name": order.name,
+ "first_paid_at": order.first_paid_at.isoformat(),
+ "first_paid_price": 0,
+ "customer_name": order.customer_info.name,
+ "customer_phone": order.customer_info.phone,
+ "customer_email": order.customer_info.email,
+ "customer_organization": "",
+ "성함": order.customer_info.name,
+ "성명": order.customer_info.name,
+ "소속": "",
},
+ "dedupe_key": "",
"missing_variables": [],
}
],
@@ -64,16 +96,24 @@ def test_notification_preview_rejects_unknown_template_id(api_client, order_fact
@pytest.mark.django_db
-def test_notification_preview_excludes_refunded_orders(api_client, order_email_template, order_factory):
- order_factory(status="refunded")
+def test_notification_preview_includes_refunded_orders(api_client, order_email_template, order_factory):
+ order = order_factory(status="refunded")
response = OrderNotificationsAdminApi(http_client=api_client).preview(
{"channel": "email", "template_id": str(order_email_template.id)}
)
assert response.status_code == HTTP_200_OK
- assert response.json() == {
- "template_variables": ["customer_email", "customer_name", "first_paid_price", "order_name"],
- "recipients": [],
- }
+ [recipient] = response.json()["recipients"]
+ assert recipient["recipient"] == order.customer_info.email
+
+
+@pytest.mark.django_db
+def test_notification_preview_excludes_unpaid_orders(api_client, order_email_template, order_factory):
+ order_factory(status="cart")
+ response = OrderNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(order_email_template.id)}
+ )
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["recipients"] == []
@pytest.mark.django_db
diff --git a/app/admin_api/test/shop/order_product_notifications_api_test.py b/app/admin_api/test/shop/order_product_notifications_api_test.py
new file mode 100644
index 0000000..a436ae0
--- /dev/null
+++ b/app/admin_api/test/shop/order_product_notifications_api_test.py
@@ -0,0 +1,382 @@
+from datetime import UTC, datetime
+from urllib.parse import urljoin
+
+import pytest
+from admin_api.test.helpers import OrderProductNotificationsAdminApi
+from django.conf import settings
+from notification.models.email import EmailNotificationHistory, EmailNotificationTemplate
+from rest_framework.status import HTTP_200_OK, HTTP_201_CREATED, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
+from shop.order.models import CustomerInfo, OrderProductRelation, TicketInfo
+
+
+@pytest.fixture
+def opr_email_template(superuser) -> EmailNotificationTemplate:
+ return EmailNotificationTemplate.objects.create(
+ code="ticket-qr",
+ title="티켓 QR 안내",
+ sent_from="from@example.com",
+ data=(
+ '{"title":"{{ product_name }} QR",'
+ '"from_":"f",'
+ '"send_to":"{{ participant_email }}",'
+ '"body":"{{ participant_name }}님 {{ scancode_url }}"}'
+ ),
+ created_by=superuser,
+ updated_by=superuser,
+ )
+
+
+@pytest.fixture
+def two_ticket_order(order_factory, ticket_product):
+ order = order_factory(status="completed")
+ OrderProductRelation.objects.create(
+ order=order,
+ product=ticket_product,
+ price=ticket_product.price,
+ status=OrderProductRelation.OrderProductStatus.paid,
+ )
+ return order
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_rejects_non_superuser(customer_client):
+ response = OrderProductNotificationsAdminApi(http_client=customer_client).preview({})
+ assert response.status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_opr_notification_send_rejects_non_superuser(customer_client):
+ response = OrderProductNotificationsAdminApi(http_client=customer_client).send({})
+ assert response.status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_uses_product_scancode_url(api_client, opr_email_template, ticket_opr):
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ order = ticket_opr.order
+ assert recipient["dedupe_key"] == str(ticket_opr.id)
+ assert recipient["context"]["scancode_url"] == urljoin(settings.BACKEND_DOMAIN, ticket_opr.scancode_path)
+ assert recipient["context"]["order_name"] == order.name
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_fans_out_per_product(api_client, opr_email_template, two_ticket_order):
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ recipients = response.json()["recipients"]
+ assert len(recipients) == 2
+ assert {r["recipient"] for r in recipients} == {"customer@example.com"}
+ assert len({r["context"]["scancode_url"] for r in recipients}) == 2
+ assert {r["dedupe_key"] for r in recipients} == {str(p.id) for p in two_ticket_order.products.all()}
+
+
+@pytest.mark.django_db
+def test_opr_notification_send_creates_one_sent_to_per_product(api_client, opr_email_template, two_ticket_order):
+ response = OrderProductNotificationsAdminApi(http_client=api_client).send(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_201_CREATED
+ history = EmailNotificationHistory.objects.get(id=response.json()["id"])
+ sent_to_list = history.sent_to_list.all()
+ assert len(sent_to_list) == 2
+ assert {s.recipient for s in sent_to_list} == {"customer@example.com"}
+ assert {s.dedupe_key for s in sent_to_list} == {str(p.id) for p in two_ticket_order.products.all()}
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_prefers_ticket_info_over_customer_info(api_client, opr_email_template, ticket_opr):
+ TicketInfo.objects.create(
+ order_product_relation=ticket_opr,
+ name="참가자",
+ phone="01099998888",
+ email="participant@example.com",
+ organization="파이콘",
+ contribution_message="기여 메시지",
+ )
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["recipient"] == "participant@example.com"
+ assert recipient["context"]["participant_name"] == "참가자"
+ assert recipient["context"]["participant_organization"] == "파이콘"
+ assert recipient["context"]["contribution_message"] == "기여 메시지"
+ assert recipient["context"]["customer_email"] == "customer@example.com"
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_falls_back_to_customer_info(api_client, opr_email_template, ticket_opr):
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["recipient"] == "customer@example.com"
+ assert recipient["context"]["participant_name"] == "홍길동"
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_includes_refunded_product(api_client, opr_email_template, two_ticket_order):
+ refunded = two_ticket_order.products.first()
+ refunded.status = OrderProductRelation.OrderProductStatus.refunded
+ refunded.save()
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ recipients = response.json()["recipients"]
+ assert len(recipients) == 2
+ assert str(refunded.id) in {r["dedupe_key"] for r in recipients}
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_includes_fully_refunded_order(api_client, opr_email_template, order_factory):
+ order = order_factory(status="refunded")
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["dedupe_key"] == str(order.products.get().id)
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_excludes_unpaid_product(api_client, opr_email_template, order_factory):
+ order_factory(status="cart")
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["recipients"] == []
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_product_id_filter_scopes_to_that_product(
+ api_client, opr_email_template, order_factory, ticket_product, non_ticket_product
+):
+ order = order_factory(status="completed")
+ OrderProductRelation.objects.create(
+ order=order,
+ product=non_ticket_product,
+ price=non_ticket_product.price,
+ status=OrderProductRelation.OrderProductStatus.paid,
+ )
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)},
+ params={"product_id": str(ticket_product.id)},
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["context"]["product_name"] == ticket_product.name
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_status_filter_scopes_to_opr_status(api_client, opr_email_template, two_ticket_order):
+ used = two_ticket_order.products.first()
+ used.status = OrderProductRelation.OrderProductStatus.used
+ used.save()
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)},
+ params={"status": "used"},
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["dedupe_key"] == str(used.id)
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_is_ticket_filter(api_client, opr_email_template, order_factory, non_ticket_product):
+ order = order_factory(status="completed")
+ goods = OrderProductRelation.objects.create(
+ order=order,
+ product=non_ticket_product,
+ price=non_ticket_product.price,
+ status=OrderProductRelation.OrderProductStatus.paid,
+ )
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)},
+ params={"is_ticket": "true"},
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["dedupe_key"] != str(goods.id)
+
+
+@pytest.mark.django_db
+def test_opr_notification_send_rejects_when_no_eligible_recipients(api_client, opr_email_template, ticket_opr):
+ CustomerInfo.objects.filter(order=ticket_opr.order).hard_delete()
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).send(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "발송 대상이 없습니다" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_opr_notification_context_override_wins(api_client, opr_email_template, ticket_opr):
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {
+ "channel": "email",
+ "template_id": str(opr_email_template.id),
+ "context_override": {"participant_name": "덮어쓴 이름"},
+ }
+ )
+
+ assert response.status_code == HTTP_200_OK
+ [recipient] = response.json()["recipients"]
+ assert recipient["context"]["participant_name"] == "덮어쓴 이름"
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_query_count_does_not_grow_with_targets(
+ api_client, opr_email_template, order_factory, ticket_product, django_assert_max_num_queries
+):
+ for _ in range(5):
+ order = order_factory(status="completed")
+ OrderProductRelation.objects.create(
+ order=order,
+ product=ticket_product,
+ price=ticket_product.price,
+ status=OrderProductRelation.OrderProductStatus.paid,
+ )
+
+ with django_assert_max_num_queries(12):
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert len(response.json()["recipients"]) == 10
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_provides_korean_aliases(api_client, opr_email_template, ticket_opr):
+ TicketInfo.objects.create(
+ order_product_relation=ticket_opr,
+ name="참가자",
+ phone="01099998888",
+ email="participant@example.com",
+ organization="파이콘",
+ )
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ ctx = response.json()["recipients"][0]["context"]
+ assert ctx["성함"] == ctx["성명"] == "참가자"
+ assert ctx["소속"] == "파이콘"
+ assert "연도" not in ctx
+
+
+@pytest.mark.django_db
+def test_opr_notification_preview_derives_year_from_event(api_client, opr_email_template, used_ticket_opr):
+ event = used_ticket_opr.product.category.event
+ event.event_start_at = datetime(2026, 8, 15, tzinfo=UTC)
+ event.save(update_fields=["event_start_at"])
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).preview(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["recipients"][0]["context"]["연도"] == 2026
+
+
+@pytest.mark.django_db
+def test_opr_notification_render_returns_html_for_first_target(api_client, opr_email_template, ticket_opr):
+ TicketInfo.objects.create(
+ order_product_relation=ticket_opr,
+ name="참가자",
+ phone="01099998888",
+ email="participant@example.com",
+ )
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).render(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert response["Content-Type"].startswith("text/html")
+ body = response.content.decode()
+ assert "참가자님" in body
+ assert urljoin(settings.BACKEND_DOMAIN, ticket_opr.scancode_path) in body
+
+
+@pytest.mark.django_db
+def test_opr_notification_render_scopes_to_filtered_target(api_client, opr_email_template, two_ticket_order):
+ second = two_ticket_order.products.last()
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).render(
+ {"channel": "email", "template_id": str(opr_email_template.id)},
+ params={"id": str(second.id)},
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert urljoin(settings.BACKEND_DOMAIN, second.scancode_path) in response.content.decode()
+
+
+@pytest.mark.django_db
+def test_opr_notification_render_rejects_when_no_target(api_client, opr_email_template, ticket_opr):
+ CustomerInfo.objects.filter(order=ticket_opr.order).hard_delete()
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).render(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_opr_notification_render_rejects_non_superuser(customer_client):
+ response = OrderProductNotificationsAdminApi(http_client=customer_client).render({})
+ assert response.status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_opr_notification_render_escapes_participant_supplied_html(api_client, opr_email_template, ticket_opr):
+ # 참가자 정보는 구매자가 자유 입력하므로, 렌더 결과가 admin 브라우저에서 실행되면 stored XSS 가 된다.
+ payload = ' '
+ TicketInfo.objects.create(
+ order_product_relation=ticket_opr,
+ name=payload,
+ phone="01099998888",
+ email="participant@example.com",
+ )
+
+ response = OrderProductNotificationsAdminApi(http_client=api_client).render(
+ {"channel": "email", "template_id": str(opr_email_template.id)}
+ )
+
+ assert response.status_code == HTTP_200_OK
+ body = response.content.decode()
+ assert payload not in body
+ assert "<img src=x onerror="alert(1)">" in body
diff --git a/app/admin_api/test/shop/order_product_relation_tags_api_test.py b/app/admin_api/test/shop/order_product_relation_tags_api_test.py
new file mode 100644
index 0000000..be174bc
--- /dev/null
+++ b/app/admin_api/test/shop/order_product_relation_tags_api_test.py
@@ -0,0 +1,122 @@
+import pytest
+from django.urls import reverse
+from rest_framework.status import (
+ HTTP_200_OK,
+ HTTP_201_CREATED,
+ HTTP_400_BAD_REQUEST,
+ HTTP_403_FORBIDDEN,
+)
+from shop.order.models import OrderProductRelationTag
+
+LIST_URL = reverse("v1:admin-shop-order-product-relation-tag-list")
+
+
+def _assign_url(tag_id, action: str = "assign") -> str:
+ return reverse(f"v1:admin-shop-order-product-relation-tag-{action}", args=[tag_id])
+
+
+@pytest.fixture
+def speaker_tag(db) -> OrderProductRelationTag:
+ return OrderProductRelationTag.objects.create(code="speaker", name="발표자")
+
+
+@pytest.mark.parametrize("client_fixture", ["anon_client", "customer_client"])
+@pytest.mark.django_db
+def test_tag_list_rejects_non_superuser(request, client_fixture):
+ assert request.getfixturevalue(client_fixture).get(LIST_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_tag_create_and_reject_duplicated_code(api_client):
+ create = api_client.post(LIST_URL, {"code": "speaker", "name": "발표자", "priority": 1}, format="json")
+ assert create.status_code == HTTP_201_CREATED
+
+ duplicated = api_client.post(LIST_URL, {"code": "speaker", "name": "다른 이름"}, format="json")
+ assert duplicated.status_code == HTTP_400_BAD_REQUEST
+ assert "code" in str(duplicated.json())
+
+
+@pytest.mark.django_db
+def test_tag_create_allows_code_of_soft_deleted_tag(api_client):
+ OrderProductRelationTag.objects.create(code="speaker", name="발표자").delete()
+
+ assert api_client.post(LIST_URL, {"code": "speaker", "name": "발표자"}, format="json").status_code == (
+ HTTP_201_CREATED
+ )
+
+
+@pytest.mark.django_db
+def test_assign_attaches_tag_to_filtered_order_products(api_client, speaker_tag, order_factory):
+ order = order_factory(status="completed")
+ order_product = order.products.get()
+
+ response = api_client.post(f"{_assign_url(speaker_tag.id)}?order_id={order.id}")
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json() == {"affected": 1}
+ assert list(order_product.tags.all()) == [speaker_tag]
+
+
+@pytest.mark.django_db
+def test_assign_is_idempotent(api_client, speaker_tag, order_factory):
+ order = order_factory(status="completed")
+ url = f"{_assign_url(speaker_tag.id)}?order_id={order.id}"
+
+ api_client.post(url)
+ api_client.post(url)
+
+ assert order.products.get().tags.count() == 1
+
+
+@pytest.mark.django_db
+def test_assign_skips_order_products_outside_the_filter(api_client, speaker_tag, order_factory):
+ order_factory(status="completed")
+ other_order = order_factory(status="completed")
+
+ api_client.post(f"{_assign_url(speaker_tag.id)}?order_id={other_order.id}")
+
+ assert speaker_tag.order_product_relations.count() == 1
+
+
+@pytest.mark.django_db
+def test_assign_rejects_request_without_any_filter(api_client, speaker_tag, order_factory):
+ order_factory(status="completed")
+
+ response = api_client.post(_assign_url(speaker_tag.id))
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "조회 조건" in str(response.json())
+ assert speaker_tag.order_product_relations.count() == 0
+
+
+@pytest.mark.django_db
+def test_unassign_detaches_tag(api_client, speaker_tag, order_factory):
+ order = order_factory(status="completed")
+ speaker_tag.order_product_relations.add(order.products.get())
+
+ response = api_client.post(f"{_assign_url(speaker_tag.id, 'unassign')}?order_id={order.id}")
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json() == {"affected": 1}
+ assert speaker_tag.order_product_relations.count() == 0
+
+
+@pytest.mark.django_db
+def test_order_detail_exposes_tags(api_client, speaker_tag, order_factory):
+ order = order_factory(status="completed")
+ speaker_tag.order_product_relations.add(order.products.get())
+
+ body = api_client.get(reverse("v1:admin-shop-order-detail", args=[order.id])).json()
+
+ assert body["products"][0]["tags"] == [{"id": str(speaker_tag.id), "code": "speaker", "name": "발표자"}]
+
+
+@pytest.mark.django_db
+def test_assign_rejects_blank_filter_value(api_client, speaker_tag, order_factory):
+ # django-filter 는 빈 값을 무시하므로 `?id=` 가 전체 태깅이 되면 안 된다.
+ order_factory(status="completed")
+
+ response = api_client.post(f"{_assign_url(speaker_tag.id)}?id=")
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert speaker_tag.order_product_relations.count() == 0
diff --git a/app/admin_api/test/shop/orders_api_test.py b/app/admin_api/test/shop/orders_api_test.py
index 9d140ac..ce76079 100644
--- a/app/admin_api/test/shop/orders_api_test.py
+++ b/app/admin_api/test/shop/orders_api_test.py
@@ -1,11 +1,14 @@
+from codecs import BOM_UTF8
from datetime import datetime, timezone
from io import BytesIO
import pandas
import pytest
+import yaml
from admin_api.serializers.shop.orders import OrderAdminSerializer
from admin_api.test.helpers import OrdersAdminApi
from admin_api.views.shop.orders import OrderAdminViewSet
+from core.const.shop_error_messages import NotRefundableErrorMessages
from freezegun import freeze_time
from model_bakery import baker
from rest_framework.fields import DateTimeField
@@ -17,8 +20,10 @@
HTTP_403_FORBIDDEN,
HTTP_404_NOT_FOUND,
)
+from rest_framework.test import APIClient
from shop.order.models import CustomerInfo, Order, OrderProductRelation
from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus
+from user.models import UserExt
@pytest.mark.parametrize("client_fixture", ["anon_client", "customer_client"])
@@ -42,6 +47,22 @@ def test_admin_list_returns_only_orders_with_payment_history_and_products(api_cl
}
+@pytest.mark.django_db
+def test_admin_list_includes_free_completed_order(api_client, order_factory):
+ order = order_factory(status="completed", product_price=0, imp_id=None)
+
+ response = OrdersAdminApi(http_client=api_client).list()
+
+ assert response.status_code == HTTP_200_OK
+ row = response.json()["results"][0]
+ assert row["id"] == str(order.id)
+ assert row["current_status"] == PaymentHistoryStatus.completed
+ assert row["current_paid_price"] == 0
+ assert row["latest_imp_id"] is None
+ assert row["payment_histories"][0]["price"] == 0
+ assert row["payment_histories"][0]["imp_id"] is None
+
+
@pytest.mark.django_db
def test_admin_list_orders_by_first_paid_at_desc(api_client, order_factory):
# 먼저 생성된 주문(= created_at 이 더 과거)이 더 최근에 결제되도록 구성.
@@ -136,6 +157,19 @@ def test_admin_refund_action_refunds_order(api_client, mock_portone_req_cancel_p
assert completed_order.payment_histories.filter(status=PaymentHistoryStatus.refunded).exists()
+@pytest.mark.django_db
+def test_admin_refund_action_rejects_free_completed_order_without_portone_cancel(
+ api_client, mock_portone_req_cancel_payment, order_factory
+):
+ order = order_factory(status="completed", product_price=0, imp_id=None)
+
+ response = OrdersAdminApi(http_client=api_client).refund(order.id)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST in str(response.json())
+ mock_portone_req_cancel_payment.assert_not_called()
+
+
@pytest.mark.django_db
def test_admin_refund_product_action_does_partial_refund(
api_client, ticket_product, mock_portone_req_cancel_payment, order_factory
@@ -165,6 +199,39 @@ def test_admin_refund_product_action_returns_404_for_unknown_rel(api_client, ord
assert response.status_code == HTTP_404_NOT_FOUND
+@pytest.mark.django_db
+def test_admin_refund_product_action_rejects_free_completed_opr_without_portone_cancel(
+ api_client, mock_portone_req_cancel_payment, order_factory
+):
+ order = order_factory(status="completed", product_price=0, imp_id=None)
+ opr = order.products.get()
+
+ response = OrdersAdminApi(http_client=api_client).refund_product(order.id, opr.id)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE in str(response.json())
+ mock_portone_req_cancel_payment.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_admin_refund_actions_document_validation_error_responses():
+ response = APIClient().get("/api/schema/v1/")
+ assert response.status_code == HTTP_200_OK
+
+ schema = yaml.safe_load(response.content)
+ total_refund_path = next(path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/refund/"))
+ product_refund_path = next(
+ path for path in schema["paths"] if path.endswith("/admin-api/shop/order/{id}/products/{rel_id}/refund/")
+ )
+
+ for path in (total_refund_path, product_refund_path):
+ responses = schema["paths"][path]["post"]["responses"]
+ assert "204" in responses
+ assert responses["400"]["content"]["application/json"]["schema"] == {
+ "$ref": "#/components/schemas/ValidationErrorResponse",
+ }
+
+
@pytest.mark.django_db
def test_admin_refund_allows_expired_window(api_client, mock_portone_req_cancel_payment, order_factory):
completed_order = order_factory(status="completed")
@@ -181,6 +248,11 @@ def test_admin_import_template_returns_csv(api_client, ticket_product):
response = OrdersAdminApi(http_client=api_client).import_template(product_id=str(ticket_product.id))
assert response.status_code == HTTP_200_OK
assert "text/csv" in response.headers["Content-Type"]
+ # JSONRenderer 를 거치지 않은 그대로의 CSV 본문 + Excel 용 BOM 인지 확인.
+ assert response.content.startswith(BOM_UTF8)
+ assert response.content.decode("utf-8-sig").splitlines() == [
+ "name,phone,email,organization,product_id,donation_price"
+ ]
@pytest.mark.django_db
@@ -195,12 +267,16 @@ def test_admin_import_template_returns_404_for_unknown_product(api_client):
assert response.status_code == HTTP_404_NOT_FOUND
-def _csv_file(rows: str) -> BytesIO:
- csv_file = BytesIO(rows.encode("utf-8"))
+def _csv_bytes(raw: bytes) -> BytesIO:
+ csv_file = BytesIO(raw)
csv_file.name = "import.csv"
return csv_file
+def _csv_file(rows: str, encoding: str = "utf-8") -> BytesIO:
+ return _csv_bytes(rows.encode(encoding))
+
+
@pytest.mark.django_db
def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, customer_user, ticket_product):
response = OrdersAdminApi(http_client=api_client).import_csv(
@@ -213,6 +289,80 @@ def test_admin_import_csv_persists_paid_order_from_uploaded_row(api_client, cust
opr = OrderProductRelation.objects.get(product=ticket_product)
assert opr.status == OrderProductRelation.OrderProductStatus.paid
assert opr.order.user == customer_user
+ # 빈 셀은 pandas NaN → 문자열 "nan" 이 아니라 빈 문자열로 저장돼야 한다.
+ assert opr.order.customer_info.organization == ""
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_rejects_blank_required_cell(api_client, customer_user, ticket_product):
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_file(
+ "name,phone,email,organization,product_id,donation_price\n"
+ f",010-1234-5678,{customer_user.email},,{ticket_product.id},0\n"
+ )
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert [error["attr"] for error in response.json()["errors"]] == ["0.name"]
+ assert not OrderProductRelation.objects.exists()
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_skips_option_group_with_blank_cell(api_client, customer_user, ticket_product, option_group):
+ option_group.options.create(name="M", additional_price=0)
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_file(
+ "name,phone,email,organization,product_id,donation_price,사이즈\n"
+ f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0,\n"
+ )
+ )
+ assert response.status_code == HTTP_201_CREATED
+ assert OrderProductRelation.objects.get(product=ticket_product).options.count() == 0
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_accepts_template_output_verbatim(api_client, customer_user, ticket_product):
+ """템플릿 → 작성 → 업로드 왕복. BOM 이 첫 컬럼명(name)에 섞여 들어가지 않아야 한다."""
+ template = OrdersAdminApi(http_client=api_client).import_template(product_id=str(ticket_product.id))
+ filled_csv = (
+ template.content.decode("utf-8") + f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0\n"
+ )
+
+ response = OrdersAdminApi(http_client=api_client).import_csv(csv_file=_csv_file(filled_csv))
+
+ assert response.status_code == HTTP_201_CREATED
+ assert OrderProductRelation.objects.filter(product=ticket_product).exists()
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_accepts_cp949_encoded_file(api_client, customer_user, ticket_product):
+ """한국어 Windows Excel 이 저장하는 CP949 파일도 읽어야 한다."""
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_file(
+ "name,phone,email,organization,product_id,donation_price\n"
+ f"홍길동,010-1234-5678,{customer_user.email},파이콘,{ticket_product.id},0\n",
+ encoding="cp949",
+ )
+ )
+ assert response.status_code == HTTP_201_CREATED
+ assert CustomerInfo.objects.get(order__products__product=ticket_product).name == "홍길동"
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_rejects_undecodable_file(api_client):
+ """Excel '유니코드 텍스트'(UTF-16) 저장처럼 지원하지 않는 인코딩은 500 이 아니라 400."""
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_bytes("name,phone\n홍길동,010-1234-5678\n".encode("utf-16"))
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert [error["attr"] for error in response.json()["errors"]] == ["csv_file"]
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_rejects_malformed_csv(api_client):
+ """열 개수가 어긋난 CSV 도 500 이 아니라 400."""
+ response = OrdersAdminApi(http_client=api_client).import_csv(csv_file=_csv_file("a,b,c\n1,2,3\n4,5,6,7,8\n"))
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert [error["attr"] for error in response.json()["errors"]] == ["csv_file"]
@pytest.mark.django_db
@@ -222,18 +372,65 @@ def test_admin_import_csv_rejects_missing_file(api_client):
@pytest.mark.django_db
-def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_client, ticket_product):
- # email 매칭되는 user 부재 → 모든 row validate 실패 → atomic rollback.
+def test_admin_import_csv_returns_400_for_invalid_rows_without_persisting(api_client, customer_user, ticket_product):
+ # 전화번호 형식 불일치 → 모든 row validate 실패 → atomic rollback.
response = OrdersAdminApi(http_client=api_client).import_csv(
csv_file=_csv_file(
"name,phone,email,organization,product_id,donation_price\n"
- f"홍길동,010-1234-5678,nobody@example.com,,{ticket_product.id},0\n"
+ f"홍길동,전화번호아님,{customer_user.email},,{ticket_product.id},0\n"
+ )
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert not OrderProductRelation.objects.exists()
+ assert response.json()["type"] == "validation_error"
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_error_attr_identifies_the_failing_row(
+ api_client, customer_user, ticket_product, option_group
+):
+ # 1행은 유효, 2행은 정의되지 않은 옵션값 → attr 의 행 인덱스로 실패한 행을 식별할 수 있어야 한다.
+ option_group.options.create(name="M", additional_price=0)
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_file(
+ "name,phone,email,organization,product_id,donation_price,사이즈\n"
+ f"홍길동,010-1234-5678,{customer_user.email},,{ticket_product.id},0,M\n"
+ f"김철수,010-2222-3333,other@example.com,,{ticket_product.id},0,XXL\n"
)
)
assert response.status_code == HTTP_400_BAD_REQUEST
+ assert [error["attr"] for error in response.json()["errors"]] == ["1.non_field_errors"]
+ # 유효한 1행도 저장되지 않는다 (전부 성공해야 저장).
assert not OrderProductRelation.objects.exists()
+@pytest.mark.django_db
+def test_admin_import_csv_creates_missing_user_from_row_email(api_client, ticket_product):
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_file(
+ "name,phone,email,organization,product_id,donation_price\n"
+ f"홍길동,010-1234-5678,nobody@example.com,,{ticket_product.id},0\n"
+ )
+ )
+ assert response.status_code == HTTP_201_CREATED
+ assert OrderProductRelation.objects.get(product=ticket_product).order.user.email == "nobody@example.com"
+
+
+@pytest.mark.django_db
+def test_admin_import_csv_rolls_back_created_users_when_a_later_row_fails(api_client, ticket_product, option_group):
+ # 1행이 유저를 만들고 2행이 실패 → 라우트 atomic 으로 유저 생성까지 되돌아가야 한다.
+ option_group.options.create(name="M", additional_price=0)
+ response = OrdersAdminApi(http_client=api_client).import_csv(
+ csv_file=_csv_file(
+ "name,phone,email,organization,product_id,donation_price,사이즈\n"
+ f"홍길동,010-1234-5678,first@example.com,,{ticket_product.id},0,M\n"
+ f"김철수,010-2222-3333,second@example.com,,{ticket_product.id},0,XXL\n"
+ )
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert not UserExt.objects.filter(email__in=["first@example.com", "second@example.com"]).exists()
+
+
@pytest.mark.parametrize("include_refunded", [False, True])
@freeze_time(datetime(2026, 5, 23, 15, 30, 45, tzinfo=timezone.utc))
@pytest.mark.django_db
diff --git a/app/admin_api/test/shop/products_api_test.py b/app/admin_api/test/shop/products_api_test.py
index ac5e95a..993f38f 100644
--- a/app/admin_api/test/shop/products_api_test.py
+++ b/app/admin_api/test/shop/products_api_test.py
@@ -11,7 +11,8 @@
HTTP_403_FORBIDDEN,
)
from shop.conftest import FAR_FUTURE, FAR_PAST
-from shop.product.models import Category, CategoryGroup, OptionGroup, Product, Tag
+from shop.order.models import OrderProductOptionRelation
+from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag
PRODUCT_SELECTABLES_URL = reverse("v1:admin-shop-product-list") + "selectables/"
@@ -149,6 +150,27 @@ def test_admin_product_create_returns_201(api_client, ticket_product):
assert Product.objects.filter(name_ko="신규 상품").exists()
+@pytest.mark.django_db
+def test_admin_product_create_allows_zero_price(api_client, ticket_product):
+ response = ProductsAdminApi(http_client=api_client).create(
+ {
+ "name_ko": "무료 튜토리얼",
+ "name_en": "Free Tutorial",
+ "price": 0,
+ "stock": 10,
+ "visible_starts_at": FAR_PAST.isoformat(),
+ "visible_ends_at": FAR_FUTURE.isoformat(),
+ "orderable_starts_at": FAR_PAST.isoformat(),
+ "orderable_ends_at": FAR_FUTURE.isoformat(),
+ "refundable_ends_at": FAR_FUTURE.isoformat(),
+ "category": str(ticket_product.category.id),
+ }
+ )
+ assert response.status_code == HTTP_201_CREATED
+ assert response.json()["price"] == 0
+ assert Product.objects.filter(name_ko="무료 튜토리얼", price=0).exists()
+
+
@pytest.mark.django_db
def test_admin_product_partial_update_can_set_refundable_ends_at_null(api_client, ticket_product):
# null = 환불 불가 상품. 운영자가 어드민에서 직접 지정하는 경로.
@@ -468,3 +490,74 @@ def test_admin_product_selectables_include_meta(api_client, ticket_product):
)
# meta_schema 는 모델의 choices_meta_schema 를 반영한다.
assert {"category", "price", "stock", "status"} <= set(body["meta_schema"])
+
+
+@pytest.fixture
+def sold_option(order_factory, option_group) -> Option:
+ """paid OPR 2건이 붙은 옵션 — sold_count=2, stock=10."""
+ sized = option_group.options.create(name="M", stock=10)
+ for _ in range(2):
+ OrderProductOptionRelation.objects.create(
+ order_product_relation=order_factory(status="completed").products.get(),
+ product_option_group=option_group,
+ product_option=sized,
+ )
+ return sized
+
+
+def _option_payload(option: Option, *, stock: int) -> dict:
+ # nested options 는 전량 동기화라 유지할 옵션을 모두 실어야 한다 (누락 시 soft delete).
+ return {"options": [{"id": str(option.id), "name_ko": option.name_ko, "name_en": option.name_en, "stock": stock}]}
+
+
+@pytest.mark.django_db
+def test_admin_option_update_rejects_stock_below_sold_count(api_client, option_group, sold_option):
+ # 이미 2개 팔린 옵션에 stock=1 → leftover_stock 이 -1 이 되는 유일한 입력 경로라 거절.
+ response = OptionGroupsAdminApi(http_client=api_client).update(
+ option_group.id, _option_payload(sold_option, stock=1)
+ )
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "이미 2개가 판매된 옵션입니다" in str(response.json())
+ sold_option.refresh_from_db()
+ assert sold_option.stock == 10
+
+
+@pytest.mark.django_db
+def test_admin_option_update_allows_stock_equal_to_sold_count(api_client, option_group, sold_option):
+ # 판매 수량과 같은 값 = leftover 0 (판매 마감) — 운영자가 실제로 쓰는 마감 방식이라 허용.
+ response = OptionGroupsAdminApi(http_client=api_client).update(
+ option_group.id, _option_payload(sold_option, stock=2)
+ )
+ assert response.status_code == HTTP_200_OK
+ sold_option.refresh_from_db()
+ assert sold_option.stock == 2
+ assert sold_option.leftover_stock == 0
+
+
+@pytest.mark.django_db
+def test_admin_option_update_allows_zero_stock_as_unlimited(api_client, option_group, sold_option):
+ response = OptionGroupsAdminApi(http_client=api_client).update(
+ option_group.id, _option_payload(sold_option, stock=0)
+ )
+ assert response.status_code == HTTP_200_OK
+ sold_option.refresh_from_db()
+ assert sold_option.leftover_stock is None
+
+
+@pytest.mark.django_db
+def test_admin_option_update_allows_negative_stock_when_nothing_sold(api_client, option_group, option):
+ # 판매 이력 없는 옵션을 품절 노출시키는 관용구(-1) — stock=0 이 무제한이라 이 방법뿐이므로 막지 않는다.
+ response = OptionGroupsAdminApi(http_client=api_client).update(option_group.id, _option_payload(option, stock=-1))
+ assert response.status_code == HTTP_200_OK
+ option.refresh_from_db()
+ assert option.stock == -1
+
+
+@pytest.mark.django_db
+def test_admin_option_group_retrieve_exposes_sold_count(api_client, option_group, sold_option):
+ # stock=0 이면 leftover_stock 이 null 이라 어드민에서 판매 수량을 볼 수 없었다 — sold_count 로 항상 노출.
+ response = OptionGroupsAdminApi(http_client=api_client).retrieve(option_group.id)
+ assert response.status_code == HTTP_200_OK
+ payload = {o["id"]: o for o in response.json()["options"]}[str(sold_option.id)]
+ assert payload["sold_count"] == 2
+ assert payload["leftover_stock"] == 8
diff --git a/app/admin_api/test/socialaccount_test.py b/app/admin_api/test/socialaccount_test.py
index 3ea5bc0..3e78a05 100644
--- a/app/admin_api/test/socialaccount_test.py
+++ b/app/admin_api/test/socialaccount_test.py
@@ -22,7 +22,6 @@ def regular_user(db) -> UserExt:
SocialAccount.objects.create(
user=user, provider="google", uid="alice-google-1", extra_data={"email": "alice@example.com"}
)
- EmailAddress.objects.create(user=user, email="alice@example.com", verified=True, primary=True)
return user
@@ -31,7 +30,6 @@ def multi_social_user(db) -> UserExt:
user = UserExt.objects.create_user(username="bob", email="bob@example.com", password="x") # nosec: B106
SocialAccount.objects.create(user=user, provider="google", uid="bob-google-1", extra_data={})
SocialAccount.objects.create(user=user, provider="kakao", uid="bob-kakao-1", extra_data={})
- EmailAddress.objects.create(user=user, email="bob@example.com", verified=True, primary=True)
return user
@@ -280,7 +278,7 @@ def test_email_address_list_filter_by_verified_and_primary(api_client, regular_u
assert response.status_code == http.HTTPStatus.OK
assert {row["email"] for row in response.json()["results"]} == {"alt@example.com"}
# primary=true
- response = api_client.get(reverse("v1:admin-email-address-list"), {"primary": "true"})
+ response = api_client.get(reverse("v1:admin-email-address-list"), {"primary": "true", "user": str(regular_user.id)})
assert response.status_code == http.HTTPStatus.OK
assert {row["email"] for row in response.json()["results"]} == {"alice@example.com"}
diff --git a/app/admin_api/test/user_merge_test.py b/app/admin_api/test/user_merge_test.py
new file mode 100644
index 0000000..37a023d
--- /dev/null
+++ b/app/admin_api/test/user_merge_test.py
@@ -0,0 +1,222 @@
+import http
+
+import pytest
+from allauth.account.models import EmailAddress
+from django.urls import reverse
+from rest_framework.test import APIClient
+from shop.order.models import Order
+from user.models import UserExt
+from user.models.merge import UserMergeHistory
+
+LIST = "v1:admin-user-merge-list"
+DETAIL = "v1:admin-user-merge-detail"
+PREVIEW = "v1:admin-user-merge-preview"
+REVERT = "v1:admin-user-merge-revert"
+
+
+@pytest.fixture
+def source_user(db) -> UserExt:
+ return UserExt.objects.create_user(username="source", email="source@example.com")
+
+
+@pytest.fixture
+def target_user(db) -> UserExt:
+ return UserExt.objects.create_user(username="target", email="target@example.com")
+
+
+# ---- Auth -------------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_unauthenticated_list_rejected():
+ response = APIClient().get(reverse(LIST))
+ assert response.status_code in (http.HTTPStatus.FORBIDDEN, http.HTTPStatus.UNAUTHORIZED)
+
+
+@pytest.mark.django_db
+def test_non_superuser_preview_rejected(customer_user, source_user, target_user):
+ client = APIClient()
+ client.force_authenticate(user=customer_user)
+ response = client.post(reverse(PREVIEW), {"source": source_user.id, "target": target_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.FORBIDDEN
+
+
+# ---- Preview (savepoint 실행 후 롤백) ----------------------------------------
+
+
+@pytest.mark.django_db
+def test_preview_lists_actual_merge_objects(api_client, source_user, target_user):
+ order = Order.objects.create(user=source_user, name="src-order")
+
+ response = api_client.post(reverse(PREVIEW), {"source": source_user.id, "target": target_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.OK, response.json()
+ body = response.json()
+ assert body["source"]["id"] == source_user.id
+ assert body["target"]["id"] == target_user.id
+
+ assert body["is_self_merge"] is False
+
+ moved = next(m for m in body["merged_objects"] if m["target_id"] == str(order.id))
+ assert moved["target_type_app"] == "shop"
+ assert moved["target_type_resource"] == "order"
+ assert moved["field_names"] == ["user"]
+ assert body["source"]["is_active"] is False # post-merge 상태 노출(source 비활성)
+
+
+@pytest.mark.django_db
+def test_preview_rolls_back_and_persists_nothing(api_client, source_user, target_user):
+ Order.objects.create(user=source_user, name="src-order")
+
+ api_client.post(reverse(PREVIEW), {"source": source_user.id, "target": target_user.id}, format="json")
+
+ assert Order.objects.get(name="src-order").user_id == source_user.id
+ assert not UserMergeHistory.objects.exists()
+ source_user.refresh_from_db()
+ assert source_user.is_active is True
+ assert source_user.merged_to_id is None
+
+
+@pytest.mark.django_db
+def test_preview_same_account_rejected(api_client, source_user):
+ response = api_client.post(reverse(PREVIEW), {"source": source_user.id, "target": source_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+
+# ---- Create (execute merge) -------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_create_merges_and_repoints(api_client, superuser, source_user, target_user):
+ order = Order.objects.create(user=source_user, name="src-order")
+
+ response = api_client.post(reverse(LIST), {"source": source_user.id, "target": target_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.CREATED, response.json()
+ body = response.json()
+ assert body["source"]["id"] == source_user.id
+ assert body["target"]["id"] == target_user.id
+ assert body["is_self_merge"] is False
+ assert superuser.email in body["created_by"] # StringRelatedField → str(UserExt)
+ assert body["reverted_at"] is None
+
+ # Order 는 라우트 컨벤션상 shop/order.
+ [moved] = body["merged_objects"]
+ assert moved["target_type_app"] == "shop"
+ assert moved["target_type_resource"] == "order"
+ assert moved["target_id"] == str(order.id)
+ assert moved["field_names"] == ["user"]
+
+ order.refresh_from_db()
+ source_user.refresh_from_db()
+ assert order.user_id == target_user.id
+ assert source_user.is_active is False
+ assert source_user.merged_to_id == target_user.id
+
+
+@pytest.mark.django_db
+def test_create_same_account_rejected(api_client, source_user):
+ response = api_client.post(reverse(LIST), {"source": source_user.id, "target": source_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ assert UserMergeHistory.objects.count() == 0
+
+
+@pytest.mark.django_db
+def test_create_into_already_merged_target_rejected(api_client, source_user, target_user):
+ other = UserExt.objects.create_user(username="other", email="other@example.com")
+ UserMergeHistory.objects.create(source=target_user, target=other).merge() # target 이 이미 병합됨
+ EmailAddress.objects.create(user=target_user, email="again@example.com", verified=True, primary=True) # 검증 통과용
+
+ response = api_client.post(reverse(LIST), {"source": source_user.id, "target": target_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ # 실패한 병합 기록이 남으면 안 됨(atomic rollback).
+ assert not UserMergeHistory.objects.filter(source=source_user).exists()
+
+
+@pytest.mark.django_db
+def test_create_enforces_emails_mergeable(api_client, source_user, target_user):
+ # 운영자 병합도 assert_emails_mergeable 적용 — target 에 인증 이메일이 없으면 거부(운영자가 보고 설정 가능).
+ EmailAddress.objects.filter(user=target_user).delete()
+ response = api_client.post(reverse(LIST), {"source": source_user.id, "target": target_user.id}, format="json")
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ assert not UserMergeHistory.objects.filter(source=source_user).exists()
+
+
+# ---- List / Retrieve --------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_list_and_retrieve(api_client, source_user, target_user):
+ merge = UserMergeHistory.objects.create(source=source_user, target=target_user)
+ merge.merge()
+
+ response = api_client.get(reverse(LIST))
+ assert response.status_code == http.HTTPStatus.OK
+ rows = response.json()["results"]
+ assert any(row["id"] == str(merge.id) for row in rows)
+
+ response = api_client.get(reverse(DETAIL, kwargs={"pk": merge.id}))
+ assert response.status_code == http.HTTPStatus.OK
+ assert response.json()["source"]["id"] == source_user.id
+
+
+@pytest.mark.django_db
+def test_list_excludes_merged_objects_retrieve_includes(api_client, source_user, target_user):
+ Order.objects.create(user=source_user, name="src-order")
+ merge = UserMergeHistory.objects.create(source=source_user, target=target_user)
+ merge.merge()
+
+ [row] = api_client.get(reverse(LIST)).json()["results"]
+ assert "merged_objects" not in row # list 는 대량 가능성 때문에 제외
+
+ detail = api_client.get(reverse(DETAIL, kwargs={"pk": merge.id})).json()
+ assert len(detail["merged_objects"]) == 1
+
+
+@pytest.mark.django_db
+def test_list_filter_reverted(api_client, source_user, target_user):
+ merge = UserMergeHistory.objects.create(source=source_user, target=target_user)
+ merge.merge()
+
+ assert api_client.get(reverse(LIST), {"reverted": "true"}).json()["results"] == []
+ assert len(api_client.get(reverse(LIST), {"reverted": "false"}).json()["results"]) == 1
+
+
+# ---- Revert -----------------------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_revert_restores(api_client, source_user, target_user):
+ order = Order.objects.create(user=source_user, name="src-order")
+ merge = UserMergeHistory.objects.create(source=source_user, target=target_user)
+ merge.merge()
+
+ response = api_client.post(reverse(REVERT, kwargs={"pk": merge.id}))
+ assert response.status_code == http.HTTPStatus.OK, response.json()
+ body = response.json()
+ assert body["reverted_at"] is not None # 재조회 없이 in-place 갱신된 history 직렬화
+
+ order.refresh_from_db()
+ source_user.refresh_from_db()
+ assert order.user_id == source_user.id
+ assert source_user.is_active is True
+ assert source_user.merged_to_id is None
+
+
+@pytest.mark.django_db
+def test_double_revert_rejected(api_client, source_user, target_user):
+ merge = UserMergeHistory.objects.create(source=source_user, target=target_user)
+ merge.merge()
+ merge.unmerge()
+
+ response = api_client.post(reverse(REVERT, kwargs={"pk": merge.id}))
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+
+# ---- JSON Schema (form) -----------------------------------------------------
+
+
+@pytest.mark.django_db
+def test_json_schema_exposes_source_and_target(api_client):
+ response = api_client.get(reverse(LIST) + "json-schema/")
+ assert response.status_code == http.HTTPStatus.OK
+ props = response.json()["schema"]["properties"]
+ assert "source" in props and "target" in props
diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py
index 90b9e41..f0b0e7d 100644
--- a/app/admin_api/urls.py
+++ b/app/admin_api/urls.py
@@ -11,9 +11,12 @@
RoomScheduleAdminViewSet,
)
from admin_api.views.event.sponsor import SponsorAdminViewSet, SponsorTagAdminViewSet, SponsorTierAdminViewSet
+from admin_api.views.event.timetable import EventPresentationTimetableAdminViewSet
from admin_api.views.external_api.google_oauth2 import GoogleOAuth2AdminViewSet
from admin_api.views.file import PublicFileAdminViewSet
+from admin_api.views.internal_api import RegistrationDeskConfigAdminViewSet
from admin_api.views.mcp_token import McpTokenAdminViewSet
+from admin_api.views.merge import UserMergeAdminViewSet
from admin_api.views.modification_audit import ModificationAuditAdminViewSet
from admin_api.views.notification import (
EmailNotificationHistoryAdminViewSet,
@@ -23,8 +26,12 @@
NHNCloudSMSNotificationHistoryAdminViewSet,
NHNCloudSMSNotificationTemplateAdminViewSet,
)
-from admin_api.views.shop.order_notifications import OrderNotificationAdminViewSet
-from admin_api.views.shop.orders import OrderAdminViewSet
+from admin_api.views.proxy.dooray import DoorayProxyView
+from admin_api.views.shop.order_notifications import (
+ OrderNotificationAdminViewSet,
+ OrderProductNotificationAdminViewSet,
+)
+from admin_api.views.shop.orders import OrderAdminViewSet, OrderProductRelationTagAdminViewSet
from admin_api.views.shop.products import (
CategoryAdminViewSet,
CategoryGroupAdminViewSet,
@@ -49,6 +56,7 @@
admin_user_router.register("userext", UserAdminViewSet, basename="admin-user")
admin_user_router.register("organization", OrganizationAdminViewSet, basename="admin-organization")
admin_user_router.register("mcptoken", McpTokenAdminViewSet, basename="admin-mcp-token")
+admin_user_router.register("usermergehistory", UserMergeAdminViewSet, basename="admin-user-merge")
admin_cms_router = routers.SimpleRouter()
admin_cms_router.register("domaingroup", DomainGroupAdminViewSet, basename="admin-domain-group")
@@ -69,6 +77,11 @@
admin_event_router.register("presentationspeaker", PresentationSpeakerAdminViewSet)
admin_event_router.register("room", RoomAdminViewSet)
admin_event_router.register("roomschedule", RoomScheduleAdminViewSet)
+admin_event_router.register(
+ "presentation/timetable",
+ EventPresentationTimetableAdminViewSet,
+ basename="admin-event-presentation-timetable",
+)
admin_participant_portal_router = routers.SimpleRouter()
admin_participant_portal_router.register(
@@ -111,17 +124,30 @@
admin_shop_router.register(
"order-notifications", OrderNotificationAdminViewSet, basename="admin-shop-order-notification"
)
+admin_shop_router.register(
+ "order-product-notifications",
+ OrderProductNotificationAdminViewSet,
+ basename="admin-shop-order-product-notification",
+)
admin_shop_router.register("product", ProductAdminViewSet, basename="admin-shop-product")
admin_shop_router.register("category", CategoryAdminViewSet, basename="admin-shop-category")
admin_shop_router.register("tag", TagAdminViewSet, basename="admin-shop-tag")
admin_shop_router.register("categorygroup", CategoryGroupAdminViewSet, basename="admin-shop-category-group")
admin_shop_router.register("optiongroup", OptionGroupAdminViewSet, basename="admin-shop-option-group")
admin_shop_router.register("refund-authorizer", RefundAuthorizerAdminViewSet, basename="admin-shop-refund-authorizer")
+admin_shop_router.register(
+ "orderproductrelationtag", OrderProductRelationTagAdminViewSet, basename="admin-shop-order-product-relation-tag"
+)
admin_document_router = routers.SimpleRouter()
admin_document_router.register("documenttemplate", DocumentTemplateAdminViewSet, basename="admin-document-template")
admin_document_router.register("issueddocument", IssuedDocumentAdminViewSet, basename="admin-document-issued")
+admin_internal_api_router = routers.SimpleRouter()
+admin_internal_api_router.register(
+ "registrationdeskconfig", RegistrationDeskConfigAdminViewSet, basename="admin-registration-desk-config"
+)
+
admin_dashboard_router = routers.SimpleRouter()
admin_dashboard_router.register("charts", DashboardChartAdminViewSet, basename="admin-dashboard-chart")
@@ -139,7 +165,16 @@
path("notification/", include(admin_notification_router.urls)),
path("external_api/", include(admin_external_api_router.urls)),
path("shop/", include(admin_shop_router.urls)),
+ path("internal_api/", include(admin_internal_api_router.urls)),
path("document/", include(admin_document_router.urls)),
path("dashboard/", include(admin_dashboard_router.urls)),
path("allauth/", include(admin_allauth_router.urls)),
+ path(
+ "proxy/",
+ include(
+ [
+ path("dooray/", DoorayProxyView.as_view(), name="admin-dooray-proxy"),
+ ]
+ ),
+ ),
]
diff --git a/app/admin_api/views/event/presentation.py b/app/admin_api/views/event/presentation.py
index 6eea808..892303a 100644
--- a/app/admin_api/views/event/presentation.py
+++ b/app/admin_api/views/event/presentation.py
@@ -97,7 +97,9 @@ class RoomAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.ModelViewSet)
serializer_class = RoomAdminSerializer
permission_classes = [IsSuperUser]
filterset_class = RoomAdminFilterSet
- queryset = Room.objects.filter_active().select_related_with_user("event").order_by("-event__event_end_at", "pk")
+ queryset = (
+ Room.objects.filter_active().select_related_with_user("event").order_by("-event__event_end_at", "order", "pk")
+ )
@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION]) for m in ADMIN_METHODS})
diff --git a/app/admin_api/views/event/timetable.py b/app/admin_api/views/event/timetable.py
new file mode 100644
index 0000000..f5cefe4
--- /dev/null
+++ b/app/admin_api/views/event/timetable.py
@@ -0,0 +1,48 @@
+from __future__ import annotations
+
+from admin_api.serializers.event.timetable import TimetableAdminSerializer, timetable_version
+from core.authz import IsSuperUser
+from core.const.tag import OpenAPITag
+from drf_spectacular.utils import extend_schema
+from event.models import Event
+from rest_framework import status, viewsets
+from rest_framework.request import Request
+from rest_framework.response import Response
+
+
+class EventPresentationTimetableAdminViewSet(viewsets.GenericViewSet):
+ permission_classes = [IsSuperUser]
+ serializer_class = TimetableAdminSerializer
+ queryset = Event.objects.filter_active()
+
+ @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION])
+ def retrieve(self, request: Request, *args, **kwargs) -> Response:
+ event = self.get_object()
+ return Response(
+ data=self.get_serializer(event).data if request.method == "GET" else None,
+ status=status.HTTP_200_OK,
+ headers={"ETag": f'"{timetable_version(event)}"'},
+ )
+
+ @extend_schema(tags=[OpenAPITag.ADMIN_EVENT_PRESENTATION])
+ def update(self, request: Request, *args, **kwargs) -> Response:
+ event = self.get_object()
+ current = timetable_version(event)
+
+ if_match = request.headers.get("If-Match")
+ if if_match is not None and if_match.strip('"') != current:
+ return Response(
+ data=self.get_serializer(event).data,
+ status=status.HTTP_412_PRECONDITION_FAILED,
+ headers={"ETag": f'"{current}"'},
+ )
+
+ patch = TimetableAdminSerializer(data=request.data, context={"event": event})
+ patch.is_valid(raise_exception=True)
+ patch.save()
+
+ return Response(
+ data=self.get_serializer(event).data,
+ status=status.HTTP_200_OK,
+ headers={"ETag": f'"{timetable_version(event)}"'},
+ )
diff --git a/app/admin_api/views/internal_api.py b/app/admin_api/views/internal_api.py
new file mode 100644
index 0000000..c256d06
--- /dev/null
+++ b/app/admin_api/views/internal_api.py
@@ -0,0 +1,25 @@
+from admin_api.serializers.internal_api import RegistrationDeskConfigAdminSerializer
+from core.authz import IsSuperUser
+from core.const.tag import OpenAPITag
+from core.pagination import AdminPagination
+from core.viewset.json_schema_viewset import JsonSchemaMixin
+from core.viewset.selectables_viewset import SelectablesMixin
+from drf_spectacular.utils import extend_schema, extend_schema_view
+from internal_api.models import RegistrationDeskConfig
+from rest_framework import viewsets
+
+CRUD_METHODS = ["list", "retrieve", "create", "update", "partial_update", "destroy"]
+
+
+@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_REGISTRATION_DESK]) for m in CRUD_METHODS})
+class RegistrationDeskConfigAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.ModelViewSet):
+ pagination_class = AdminPagination
+ http_method_names = ["get", "post", "patch", "delete"]
+ serializer_class = RegistrationDeskConfigAdminSerializer
+ permission_classes = [IsSuperUser]
+ filterset_fields = ["event"]
+ queryset = (
+ RegistrationDeskConfig.objects.filter_active()
+ .select_related_with_user("event", "event__logo")
+ .prefetch_active_targets()
+ )
diff --git a/app/admin_api/views/merge.py b/app/admin_api/views/merge.py
new file mode 100644
index 0000000..ede74d9
--- /dev/null
+++ b/app/admin_api/views/merge.py
@@ -0,0 +1,84 @@
+from admin_api.filtersets.merge import UserMergeAdminFilterSet
+from admin_api.serializers.merge import UserMergeHistoryAdminSerializer, UserMergeHistoryListAdminSerializer
+from core.authz import IsSuperUser
+from core.const.tag import OpenAPITag
+from core.pagination import AdminPagination
+from core.viewset.json_schema_viewset import JsonSchemaMixin
+from core.viewset.selectables_viewset import SelectablesMixin
+from django.db.models import Prefetch
+from django.db.models.query import QuerySet
+from django.db.transaction import atomic, set_rollback
+from drf_spectacular.utils import extend_schema, extend_schema_view
+from rest_framework import decorators, mixins, request, response, status, viewsets
+from rest_framework.exceptions import ValidationError
+from user.models.merge import MergeError, UserMergeHistory, UserMergeObject
+
+
+@extend_schema_view(
+ list=extend_schema(tags=[OpenAPITag.ADMIN_USER]),
+ retrieve=extend_schema(tags=[OpenAPITag.ADMIN_USER]),
+ create=extend_schema(tags=[OpenAPITag.ADMIN_USER]),
+)
+class UserMergeAdminViewSet(
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ mixins.CreateModelMixin,
+ JsonSchemaMixin,
+ SelectablesMixin,
+ viewsets.GenericViewSet,
+):
+ pagination_class = AdminPagination
+ http_method_names = ["get", "post"]
+ permission_classes = [IsSuperUser]
+ serializer_class = UserMergeHistoryAdminSerializer
+ filterset_class = UserMergeAdminFilterSet
+ queryset = UserMergeHistory.objects.select_related_with_user("source", "target").order_by("-created_at", "-id")
+
+ def get_queryset(self) -> QuerySet:
+ qs = super().get_queryset()
+ return (
+ qs
+ if self.action == "list"
+ else qs.prefetch_related(
+ Prefetch(
+ lookup="merged_objects",
+ queryset=UserMergeObject.objects.select_related("target_type"),
+ ),
+ )
+ )
+
+ def get_serializer_class(self) -> type:
+ if self.action == "list":
+ return UserMergeHistoryListAdminSerializer
+ return UserMergeHistoryAdminSerializer
+
+ @extend_schema(
+ tags=[OpenAPITag.ADMIN_USER],
+ request=UserMergeHistoryAdminSerializer,
+ responses={status.HTTP_200_OK: UserMergeHistoryAdminSerializer},
+ )
+ @decorators.action(detail=False, methods=["POST"], url_path="preview")
+ def preview(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ with atomic():
+ serializer.save()
+ data = serializer.data
+ set_rollback(True)
+ return response.Response(data=data)
+
+ @extend_schema(
+ tags=[OpenAPITag.ADMIN_USER],
+ request=None,
+ responses={status.HTTP_200_OK: UserMergeHistoryAdminSerializer},
+ )
+ @decorators.action(detail=True, methods=["POST"], url_path="revert")
+ def revert(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
+ history: UserMergeHistory = self.get_object()
+ try:
+ history.unmerge()
+ except MergeError as e:
+ raise ValidationError({"detail": e.localized(en=False)}) from e
+
+ return response.Response(data=self.get_serializer(history).data)
diff --git a/app/admin_api/views/proxy/__init__.py b/app/admin_api/views/proxy/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/admin_api/views/proxy/dooray.py b/app/admin_api/views/proxy/dooray.py
new file mode 100644
index 0000000..641637e
--- /dev/null
+++ b/app/admin_api/views/proxy/dooray.py
@@ -0,0 +1,54 @@
+from __future__ import annotations
+
+import re
+
+import httpx
+from core.authz import IsSuperUser
+from core.external_apis.nhn_cloud.dooray import DoorayError, nhn_cloud_dooray_client
+from rest_framework import request as drf_request
+from rest_framework import response, status
+from rest_framework.views import APIView
+
+_ALLOWED_PATH = re.compile(r"^/(?:project/v1/|common/v1/members(?:/|$))")
+
+
+class DoorayProxyView(APIView):
+ permission_classes = [IsSuperUser]
+
+ def _proxy(self, request: drf_request.Request, route: str) -> response.Response:
+ if not (token := request.user.dooray_api_key):
+ return response.Response(
+ data={"detail": "먼저 Dooray 개인 토큰을 등록하세요."},
+ status=status.HTTP_409_CONFLICT,
+ )
+
+ path = "/" + route.lstrip("/")
+ if ".." in path or not _ALLOWED_PATH.match(path):
+ return response.Response(
+ data={"detail": f"허용되지 않은 Dooray 경로입니다: {path}"},
+ status=status.HTTP_403_FORBIDDEN,
+ )
+
+ try:
+ resp = nhn_cloud_dooray_client.forward(
+ token=token,
+ method=request.method,
+ path=path,
+ params=request.query_params.dict(),
+ json=request.data if request.method in ("POST", "PUT", "PATCH") and request.data else None,
+ )
+ except DoorayError as e:
+ resp = e.response # Dooray 4xx/5xx 는 그대로 통과
+ except httpx.HTTPError as e:
+ return response.Response(
+ data={"detail": f"Dooray 요청 실패: {e}"},
+ status=status.HTTP_502_BAD_GATEWAY,
+ )
+
+ try:
+ data = resp.json() if resp.content else None
+ except ValueError:
+ data = {"detail": resp.text}
+ return response.Response(data=data, status=resp.status_code)
+
+ get = post = put = patch = delete = _proxy
diff --git a/app/admin_api/views/shop/order_notifications.py b/app/admin_api/views/shop/order_notifications.py
index 78e24ff..780d74c 100644
--- a/app/admin_api/views/shop/order_notifications.py
+++ b/app/admin_api/views/shop/order_notifications.py
@@ -1,3 +1,4 @@
+from admin_api.filtersets.shop.order_products import OrderProductRelationAdminFilterSet
from admin_api.filtersets.shop.orders import OrderAdminFilterSet
from admin_api.serializers.notification import (
EmailNotificationHistoryAdminSerializer,
@@ -5,78 +6,143 @@
NHNCloudSMSNotificationHistoryAdminSerializer,
)
from admin_api.serializers.shop.orders import (
+ OrderProductSendNotificationSerializer,
OrderSendNotificationPreviewResponseSerializer,
OrderSendNotificationSerializer,
)
from core.authz import IsSuperUser
from core.const.tag import OpenAPITag
+from core.openapi.schemas import build_html_responses
from core.viewset.json_schema_viewset import JsonSchemaMixin
from core.viewset.selectables_viewset import SelectablesMixin
from django.db import models
from drf_spectacular.utils import PolymorphicProxySerializer, extend_schema, extend_schema_view
from rest_framework import request, response, status, viewsets
from rest_framework.decorators import action
-from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
-from shop.payment_history.models import REFUNDABLE_STATUSES, PaymentHistory
+from rest_framework.renderers import StaticHTMLRenderer
+from shop.order.models import Order, OrderProductRelation
+from shop.payment_history.models import PURCHASED_STATUSES, PaymentHistory
-ACTION_METHODS = ["preview", "send"]
+ACTION_METHODS = ["preview", "render_preview", "send"]
-@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER]) for m in ACTION_METHODS})
-class OrderNotificationAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.GenericViewSet):
+def _schema(summaries: dict[str, str]) -> dict:
+ return {m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER], summary=summaries[m]) for m in ACTION_METHODS}
+
+
+_SEND_HISTORY_RESPONSE = PolymorphicProxySerializer(
+ component_name="OrderSendNotificationHistory",
+ serializers=[
+ EmailNotificationHistoryAdminSerializer,
+ NHNCloudSMSNotificationHistoryAdminSerializer,
+ NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer,
+ ],
+ resource_type_field_name=None,
+)
+
+
+class _NotificationSendMixin(JsonSchemaMixin, SelectablesMixin, viewsets.GenericViewSet):
http_method_names = ["post"]
permission_classes = [IsSuperUser]
+
+ @extend_schema(responses={status.HTTP_200_OK: OrderSendNotificationPreviewResponseSerializer})
+ @action(detail=False, methods=["post"], url_path="preview")
+ def preview(self, request: request.Request) -> response.Response:
+ req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data)
+ req.is_valid(raise_exception=True)
+ return response.Response(data=req.build_preview_response().data, status=status.HTTP_200_OK)
+
+ @extend_schema(responses=build_html_responses(names=["Order Notification Render Preview"]))
+ @action(detail=False, methods=["post"], url_path="render", renderer_classes=[StaticHTMLRenderer])
+ def render_preview(self, request: request.Request) -> response.Response:
+ req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data)
+ req.is_valid(raise_exception=True)
+ return response.Response(data=req.build_rendered_html())
+
+ @extend_schema(responses={status.HTTP_201_CREATED: _SEND_HISTORY_RESPONSE})
+ @action(detail=False, methods=["post"], url_path="send")
+ def send(self, request: request.Request) -> response.Response:
+ req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data)
+ req.is_valid(raise_exception=True)
+ return response.Response(data=req.build_send_response().data, status=status.HTTP_201_CREATED)
+
+
+@extend_schema_view(
+ **_schema(
+ {
+ "preview": "주문 알림 발송 dry-run (recipient + context + missing_variables 조회)",
+ "render_preview": "주문 알림 렌더 미리보기 (첫 대상 기준 HTML)",
+ "send": "주문 알림 발송 (filterset 으로 대상 주문 지정, 결제까지 간 주문만)",
+ }
+ )
+)
+class OrderNotificationAdminViewSet(_NotificationSendMixin):
+ """주문 단위 발송 — 주문 1건당 알림 1건. 수신처는 주문자.
+
+ 구매자에게 보내는 알림은 전부 이쪽 담당 — 상품 단위 발송은 항상 참가자에게 간다.
+ """
+
filterset_class = OrderAdminFilterSet
serializer_class = OrderSendNotificationSerializer
- # 발송 가능 상태(REFUNDABLE_STATUSES) 만 baked-in — admin 이 `?status=refunded` 등을 넘겨도 교집합 0건으로 보호.
+ # 환불 주문도 포함 — 환불 안내처럼 환불 이후에 보내야 하는 알림이 있다.
queryset = (
Order.objects.filter_active()
.annotate(current_status=PaymentHistory.objects.latest_per_order_field("status"))
- .filter(current_status__in=REFUNDABLE_STATUSES)
+ .filter(current_status__in=PURCHASED_STATUSES)
.select_related("customer_info")
.prefetch_related(
Order.prefetchs["_active_payment_histories"],
models.Prefetch(
"products",
- queryset=OrderProductRelation.objects.filter_active().prefetch_related(
- models.Prefetch(
- "options",
- queryset=OrderProductOptionRelation.objects.filter_active().select_related(
- "product_option_group",
- "product_option",
- ),
- ),
- ),
+ queryset=OrderProductRelation.objects.filter_active().prefetch_active_options(),
),
)
)
- @extend_schema(
- summary="주문 알림 발송 dry-run (recipient + context + missing_variables 조회)",
- responses={status.HTTP_200_OK: OrderSendNotificationPreviewResponseSerializer},
+
+@extend_schema_view(
+ **_schema(
+ {
+ "preview": "주문 상품 알림 발송 dry-run (recipient + context + missing_variables 조회)",
+ "render_preview": "주문 상품 알림 렌더 미리보기 (첫 대상 기준 HTML)",
+ "send": "주문 상품 알림 발송 (filterset 으로 대상 상품 지정, 상품별 QR)",
+ }
)
- @action(detail=False, methods=["post"], url_path="preview")
- def preview(self, request: request.Request) -> response.Response:
- req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data)
- req.is_valid(raise_exception=True)
- return response.Response(data=req.build_preview_response().data, status=status.HTTP_200_OK)
+)
+class OrderProductNotificationAdminViewSet(_NotificationSendMixin):
+ """상품(OPR) 단위 발송 — 티켓 N 장이면 알림 N 건. 수신처는 참가자, `scancode_url` 은 상품 QR."""
- @extend_schema(
- summary="주문 알림 발송 (filterset 으로 대상 주문 지정, 환불 가능 상태만)",
- responses={
- status.HTTP_201_CREATED: PolymorphicProxySerializer(
- component_name="OrderSendNotificationHistory",
- serializers=[
- EmailNotificationHistoryAdminSerializer,
- NHNCloudSMSNotificationHistoryAdminSerializer,
- NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer,
- ],
- resource_type_field_name=None,
+ filterset_class = OrderProductRelationAdminFilterSet
+ serializer_class = OrderProductSendNotificationSerializer
+ queryset = (
+ OrderProductRelation.objects.filter_active()
+ .filter(order__isnull=False, status__in=OrderProductRelation.PURCHASED_OR_REFUNDED_STATUS)
+ .annotate(
+ order_current_status=PaymentHistory.objects.latest_per_order_field("status", outer_field="order_id"),
+ order_latest_imp_id=PaymentHistory.objects.latest_per_order_field("imp_id", outer_field="order_id"),
+ order_first_paid_at=(
+ PaymentHistory.objects.filter_active()
+ .filter(order_id=models.OuterRef("order_id"))
+ .order_by("created_at")
+ .values("created_at")[:1]
),
- },
+ )
+ # 상품 가드와 짝을 맞추지 않으면 환불 대상이 부분 환불 주문에만 걸린다.
+ .filter(order_current_status__in=PURCHASED_STATUSES)
+ .select_related("product__category__event", "order", "order__customer_info", "ticket_info")
+ .prefetch_active_options()
+ .prefetch_related(
+ # order.build_notification_context() 의 first_paid_at/first_paid_price 용.
+ models.Prefetch(
+ "order__payment_histories",
+ queryset=PaymentHistory.objects.filter_active(),
+ to_attr="_active_payment_histories",
+ ),
+ models.Prefetch(
+ "order__products",
+ queryset=OrderProductRelation.objects.filter_active(),
+ to_attr="_active_products",
+ ),
+ )
+ .order_by("order__created_at", "created_at", "pk")
)
- @action(detail=False, methods=["post"], url_path="send")
- def send(self, request: request.Request) -> response.Response:
- req = self.get_serializer(instance=self.filter_queryset(self.get_queryset()), data=request.data)
- req.is_valid(raise_exception=True)
- return response.Response(data=req.build_send_response().data, status=status.HTTP_201_CREATED)
diff --git a/app/admin_api/views/shop/orders.py b/app/admin_api/views/shop/orders.py
index 95f9f1a..247a517 100644
--- a/app/admin_api/views/shop/orders.py
+++ b/app/admin_api/views/shop/orders.py
@@ -1,25 +1,33 @@
import datetime
import io
-import json
import typing
+from codecs import BOM_UTF8
from logging import getLogger
import pandas
+from admin_api.filtersets.shop.order_products import OrderProductRelationAdminFilterSet
from admin_api.filtersets.shop.orders import OrderAdminFilterSet
-from admin_api.serializers.shop.orders import OrderAdminSerializer, OrderExportRequestSerializer
+from admin_api.serializers.shop.orders import (
+ OrderAdminSerializer,
+ OrderExportRequestSerializer,
+ OrderProductRelationTagAdminSerializer,
+ OrderProductRelationTagAssignResultSerializer,
+)
from core.authz import IsSuperUser
from core.const.tag import OpenAPITag
from core.pagination import AdminPagination
+from core.util.fileutil import read_uploaded_csv
from core.viewset.json_schema_viewset import JsonSchemaMixin
from core.viewset.selectables_viewset import SelectablesMixin
from django.core.files import File
from django.db import models, transaction
-from django.http.response import StreamingHttpResponse
+from django.http.response import HttpResponse, StreamingHttpResponse
from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view
+from drf_standardized_errors.openapi_serializers import ValidationErrorResponseSerializer
from rest_framework import exceptions, mixins, parsers, request, response, status, viewsets
from rest_framework.decorators import action
from shop.order import exports, imports
-from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
+from shop.order.models import Order, OrderProductRelation, OrderProductRelationTag
from shop.payment_history.models import PURCHASED_STATUSES, REFUNDABLE_STATUSES, PaymentHistory
from shop.product.models import Product
from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer
@@ -33,16 +41,30 @@
_OPR_PREFETCH_QS = (
OrderProductRelation.objects.filter_active()
.select_related("product", "ticket_info")
- .prefetch_related(
- models.Prefetch(
- "options",
- queryset=OrderProductOptionRelation.objects.filter_active().select_related(
- "product_option_group", "product_option"
- ),
+ .prefetch_active_options()
+ .prefetch_related(models.Prefetch("tags", queryset=OrderProductRelationTag.objects.filter_active()))
+)
+
+_TAGGABLE_ORDER_PRODUCT_QS = (
+ OrderProductRelation.objects.filter_active()
+ .filter(order__isnull=False)
+ .annotate(
+ order_current_status=PaymentHistory.objects.latest_per_order_field("status", outer_field="order_id"),
+ order_latest_imp_id=PaymentHistory.objects.latest_per_order_field("imp_id", outer_field="order_id"),
+ order_first_paid_at=(
+ PaymentHistory.objects.filter_active()
+ .filter(order_id=models.OuterRef("order_id"))
+ .order_by("created_at")
+ .values("created_at")[:1]
),
)
)
+_OPR_FILTER_PARAMETERS = [
+ OpenApiParameter(name=name, type=OpenApiTypes.STR, location=OpenApiParameter.QUERY)
+ for name in OrderProductRelationAdminFilterSet.Meta.fields
+]
+
# `Order.payment_histories` 용 prefetch — 최신순.
_PAYMENT_HISTORY_PREFETCH_QS = PaymentHistory.objects.filter_active().order_by("-created_at")
@@ -92,7 +114,10 @@ class OrderAdminViewSet(
@extend_schema(
summary="주문 전체 환불",
tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND],
- responses={status.HTTP_204_NO_CONTENT: None},
+ responses={
+ status.HTTP_204_NO_CONTENT: None,
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ },
)
@action(detail=True, methods=["post"], url_path="refund")
@transaction.atomic
@@ -109,7 +134,10 @@ def refund(self, request: request.Request, pk: typing.Any = None) -> response.Re
@extend_schema(
summary="주문 부분 환불",
tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND],
- responses={status.HTTP_204_NO_CONTENT: None},
+ responses={
+ status.HTTP_204_NO_CONTENT: None,
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ },
)
@action(detail=True, methods=["post"], url_path=r"products/(?P[^/.]+)/refund")
@transaction.atomic
@@ -139,7 +167,7 @@ def refund_product(
responses={status.HTTP_200_OK: OpenApiTypes.STR},
)
@action(detail=False, methods=["get"], url_path="import-template")
- def import_template(self, request: request.Request) -> response.Response:
+ def import_template(self, request: request.Request) -> HttpResponse:
if not (product_id := request.query_params.get("product_id")):
raise exceptions.ValidationError({"product_id": "이 값이 필요합니다."})
try:
@@ -147,9 +175,10 @@ def import_template(self, request: request.Request) -> response.Response:
except Product.DoesNotExist as e:
raise exceptions.NotFound("Product not found") from e
- return response.Response(
- data=imports.OrderProductImportSerializer.get_template_csv(product=product),
- content_type="text/csv",
+ csv_content = imports.OrderProductImportSerializer.get_template_csv(product=product)
+ return HttpResponse(
+ content=BOM_UTF8.decode("utf-8") + csv_content,
+ content_type="text/csv; charset=utf-8",
headers={"Content-Disposition": "attachment; filename=order_import_template.csv"},
)
@@ -162,7 +191,10 @@ def import_template(self, request: request.Request) -> response.Response:
"properties": {"csv_file": {"type": "string", "format": "binary"}},
}
},
- responses={status.HTTP_201_CREATED: None},
+ responses={
+ status.HTTP_201_CREATED: None,
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ },
)
@action(
detail=False,
@@ -175,18 +207,13 @@ def import_csv(self, request: request.Request) -> response.Response:
if not (csv_file := request.FILES.get("csv_file")):
raise exceptions.ValidationError({"csv_file": "이 값이 필요합니다."})
- csv_io = io.StringIO(csv_file.read().decode("utf-8"))
- csv_df = pandas.read_csv(csv_io)
+ csv_df = read_uploaded_csv(csv_file.read()).fillna("")
csv_serializers = [
imports.OrderProductImportSerializer(data=datum) for datum in csv_df.to_dict(orient="index").values()
]
# 모든 serializer 의 .is_valid() 를 호출하기 위해 list comprehension 사용 (all() 의 short-circuit 회피).
if not all([s.is_valid() for s in csv_serializers]):
- errors = [s.errors for s in csv_serializers]
- return response.Response(
- data=json.loads(json.dumps(errors, ensure_ascii=False)),
- status=status.HTTP_400_BAD_REQUEST,
- )
+ raise exceptions.ValidationError([s.errors for s in csv_serializers])
for s in csv_serializers:
s.save()
return response.Response(status=status.HTTP_201_CREATED)
@@ -230,14 +257,7 @@ def export(self, request: request.Request) -> StreamingHttpResponse:
OrderProductRelation.objects.filter_active()
.filter(order__in=order_qs)
.select_related("product")
- .prefetch_related(
- models.Prefetch(
- "options",
- queryset=OrderProductOptionRelation.objects.filter_active().select_related(
- "product_option_group", "product_option"
- ),
- ),
- )
+ .prefetch_active_options()
.distinct()
)
@@ -247,11 +267,66 @@ def export(self, request: request.Request) -> StreamingHttpResponse:
"주문": exports.OrderExportSerializer(instance=order_qs, many=True).export(),
"주문상품": exports.OrderProductExportSerializer(instance=order_product_qs, many=True).export(),
}
- with pandas.ExcelWriter(fileio) as writer:
+ # engine 명시 — pandas 의 "auto" 는 설치된 패키지에 따라 openpyxl 로 바뀔 수 있고,
+ # autofit_columns 는 xlsxwriter 의 set_column API 에 의존한다.
+ with pandas.ExcelWriter(fileio, engine="xlsxwriter") as writer:
for sheet_name, df in df_dict.items():
df.to_excel(writer, sheet_name=sheet_name, startrow=0, startcol=0)
+ exports.autofit_columns(writer.sheets[sheet_name], df)
return StreamingHttpResponse(
streaming_content=File(fileio),
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": f"attachment; filename={filename}"},
)
+
+
+@extend_schema_view(
+ **{
+ m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER_PRODUCT_TAG])
+ for m in ["list", "retrieve", "create", "update", "partial_update", "destroy"]
+ }
+)
+class OrderProductRelationTagAdminViewSet(JsonSchemaMixin, SelectablesMixin, viewsets.ModelViewSet):
+ pagination_class = AdminPagination
+ http_method_names = ["get", "post", "patch", "delete"]
+ serializer_class = OrderProductRelationTagAdminSerializer
+ permission_classes = [IsSuperUser]
+ queryset = OrderProductRelationTag.objects.filter_active().select_related_with_user()
+
+ def _target_order_product_ids(self, req: request.Request) -> list:
+ # 빈 값은 django-filter 가 무시하므로 `?id=` 같은 요청이 전체 태깅으로 이어지지 않도록 값까지 확인한다.
+ if not any(req.query_params.get(name, "").strip() for name in OrderProductRelationAdminFilterSet.base_filters):
+ joined = ", ".join(OrderProductRelationAdminFilterSet.base_filters)
+ raise exceptions.ValidationError(f"대상을 좁힐 조회 조건이 하나 이상 필요합니다: {joined}")
+ filterset = OrderProductRelationAdminFilterSet(
+ req.query_params, queryset=_TAGGABLE_ORDER_PRODUCT_QS, request=req
+ )
+ if not filterset.is_valid():
+ raise exceptions.ValidationError(filterset.errors)
+ return list(filterset.qs.values_list("id", flat=True))
+
+ @extend_schema(
+ summary="주문 상품에 태그 부착 (filterset 으로 대상 지정)",
+ tags=[OpenAPITag.ADMIN_SHOP_ORDER_PRODUCT_TAG],
+ parameters=_OPR_FILTER_PARAMETERS,
+ request=None,
+ responses={status.HTTP_200_OK: OrderProductRelationTagAssignResultSerializer},
+ )
+ @action(detail=True, methods=["post"])
+ def assign(self, request: request.Request, pk: str | None = None) -> response.Response:
+ target_ids = self._target_order_product_ids(request)
+ self.get_object().order_product_relations.add(*target_ids)
+ return response.Response({"affected": len(target_ids)})
+
+ @extend_schema(
+ summary="주문 상품에서 태그 해제 (filterset 으로 대상 지정)",
+ tags=[OpenAPITag.ADMIN_SHOP_ORDER_PRODUCT_TAG],
+ parameters=_OPR_FILTER_PARAMETERS,
+ request=None,
+ responses={status.HTTP_200_OK: OrderProductRelationTagAssignResultSerializer},
+ )
+ @action(detail=True, methods=["post"])
+ def unassign(self, request: request.Request, pk: str | None = None) -> response.Response:
+ target_ids = self._target_order_product_ids(request)
+ self.get_object().order_product_relations.remove(*target_ids)
+ return response.Response({"affected": len(target_ids)})
diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py
index 23660ee..b722c11 100644
--- a/app/admin_api/views/user.py
+++ b/app/admin_api/views/user.py
@@ -42,6 +42,18 @@ class UserAdminViewSet(
filterset_class = UserAdminFilterSet
queryset = UserExt.objects.filter(is_active=True).prefetch_related("emailaddress_set", "socialaccount_set")
+ def get_serializer_context(self) -> dict:
+ context = super().get_serializer_context()
+ if self.action == "list":
+ context["skip_dooray_connection_check"] = True
+ elif (
+ self.action == "retrieve"
+ and self.request.user.is_authenticated
+ and str(self.request.user.pk) == self.kwargs.get("pk")
+ ):
+ context["show_dooray_api_key"] = True
+ return context
+
def create(self, request: request.Request, *args: tuple, **kwargs: dict) -> response.Response:
serializer = self.get_serializer(data=request.data)
serializer.is_valid(raise_exception=True)
diff --git a/app/core/account_urls.py b/app/core/account_urls.py
new file mode 100644
index 0000000..07046cd
--- /dev/null
+++ b/app/core/account_urls.py
@@ -0,0 +1,7 @@
+from django.urls import include, path
+
+urlpatterns = [
+ path("accounts/", include("allauth.urls")),
+ path("authn/social/", include("allauth.headless.urls")),
+ path("", include("user.urls")),
+]
diff --git a/app/core/authn/allauth_adapter.py b/app/core/authn/allauth_adapter.py
index 3fee12d..0933f3d 100644
--- a/app/core/authn/allauth_adapter.py
+++ b/app/core/authn/allauth_adapter.py
@@ -4,13 +4,18 @@
from urllib.parse import urlparse
from allauth.account.adapter import DefaultAccountAdapter
+from allauth.core.exceptions import ImmediateHttpResponse
from allauth.headless.adapter import DefaultHeadlessAdapter
from allauth.socialaccount.adapter import DefaultSocialAccountAdapter
from allauth.socialaccount.models import SocialLogin
from allauth.socialaccount.providers.base import Provider
+from allauth.socialaccount.providers.base.constants import AuthProcess
+from core.const.account import MERGE_SOURCE_SESSION_KEY
from core.logger.util.django_helper import get_request_log_data
from django.conf import settings
from django.http.request import HttpRequest
+from django.shortcuts import redirect
+from django.urls import reverse
# allauth.socialaccount.providers.base.AuthError 상수의 가능한 값 (UNKNOWN / CANCELLED / DENIED)
SocialAuthError = Literal["unknown", "cancelled", "denied"]
@@ -22,11 +27,28 @@ class NoNewUsersAccountAdapter(DefaultAccountAdapter):
def is_open_for_signup(self, request: HttpRequest) -> bool:
return False
+ def get_email_confirmation_url(self, request: HttpRequest, emailconfirmation: Any) -> str:
+ return request.build_absolute_uri(reverse("account-email-confirm", kwargs={"key": emailconfirmation.key}))
+
+ def get_reset_password_from_key_url(self, key: str) -> str:
+ return self.request.build_absolute_uri(reverse("account-password-reset-from-key", kwargs={"key": key}))
+
class SocialAccountLoggingAdapter(DefaultSocialAccountAdapter):
def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool:
return True
+ def pre_social_login(self, request: HttpRequest, sociallogin: SocialLogin) -> None:
+ # 로그인 상태에서 계정 병합으로 다른 기존 계정의 소셜 로그인을 인증 시 병합 확인 페이지로.
+ if (
+ sociallogin.state.get("process") == AuthProcess.CONNECT
+ and request.user.is_authenticated
+ and sociallogin.is_existing
+ and sociallogin.user.pk != request.user.pk
+ ):
+ request.session[MERGE_SOURCE_SESSION_KEY] = sociallogin.user.pk
+ raise ImmediateHttpResponse(redirect(reverse("account-merge-confirm")))
+
def on_authentication_error(
self,
request: HttpRequest,
@@ -73,6 +95,10 @@ def get_frontend_url(self, urlname: str, **kwargs: Any) -> str | None:
if urlname != "socialaccount_login_error":
return super().get_frontend_url(urlname, **kwargs)
+ if getattr(self.request, "urlconf", None) == "core.account_urls":
+ self.request.session["login_error"] = "social_login_failed"
+ return reverse("account-login", urlconf=self.request.urlconf)
+
allowed = _allowed_frontend_origins()
origin: str | None = None
for header in ("HTTP_X_FRONTEND_DOMAIN", "HTTP_ORIGIN", "HTTP_REFERER"):
diff --git a/app/core/const/account.py b/app/core/const/account.py
index 28e72f0..b1e3a79 100644
--- a/app/core/const/account.py
+++ b/app/core/const/account.py
@@ -1,6 +1,127 @@
import secrets
import string
+MERGE_SOURCE_SESSION_KEY = "account_merge_source_user_id"
+MERGE_MESSAGES = {
+ "wrong_account_or_password": {
+ "ko": "이메일(또는 아이디)이나 비밀번호가 올바르지 않습니다.",
+ "en": "Your email/username or password is incorrect.",
+ },
+ "target_no_verified_email": {
+ "ko": "남길 계정에 인증된 이메일이 필요합니다. 이메일을 추가하고 인증한 뒤 다시 시도해 주세요.",
+ "en": "The account you keep needs a verified email. Add and verify an email, then try again.",
+ },
+ "target_unverified_email": {
+ "ko": "남길 계정에 인증되지 않은 이메일이 있습니다. 인증을 완료하거나 해당 이메일을 삭제한 뒤 다시 시도해 주세요.",
+ "en": "The account you keep has an unverified email. Verify it or delete that email, then try again.",
+ },
+ "source_unverified_email": {
+ "ko": "합칠 계정에 인증되지 않은 이메일이 있습니다. 해당 계정으로 로그인해 인증을 완료하거나 삭제한 뒤 다시 시도해 주세요.",
+ "en": (
+ "The account to merge has an unverified email. "
+ "Sign in to that account to verify or delete it, then try again."
+ ),
+ },
+ "same_account": {
+ "ko": "같은 계정끼리는 병합할 수 없습니다.",
+ "en": "You can't merge an account with itself.",
+ },
+ "target_already_merged": {
+ "ko": "남길 계정이 이미 다른 계정에 병합되어 있습니다.",
+ "en": "The account you keep has already been merged into another account.",
+ },
+ "source_already_merged": {
+ "ko": "합칠 계정이 이미 다른 계정에 병합되어 있습니다.",
+ "en": "The account to merge has already been merged into another account.",
+ },
+ "already_reverted": {
+ "ko": "이미 되돌린 병합입니다.",
+ "en": "This merge has already been reverted.",
+ },
+ "later_merge_first": {
+ "ko": "이 병합의 남긴 계정이 이후 다른 계정에 다시 병합되었습니다. 나중 병합부터 되돌려 주세요.",
+ "en": "The kept account was later merged again. Revert the more recent merge first.",
+ },
+ "no_source": {
+ "ko": "병합할 계정 정보를 찾을 수 없습니다. 다시 시도해 주세요.",
+ "en": "We couldn't find the account to merge. Please try again.",
+ },
+ "account_merged": {
+ "ko": "해당 계정은 다른 계정으로 병합되었습니다. 남기신 계정으로 로그인해 주세요.",
+ "en": "That account was merged into another. Please sign in with the account you kept.",
+ },
+ "social_login_failed": {
+ "ko": "소셜 로그인에 실패했습니다. 다시 시도해 주세요.",
+ "en": "Social sign-in failed. Please try again.",
+ },
+}
+EMAIL_MESSAGES = {
+ "verification_sent": {
+ "ko": "확인 이메일을 보냈습니다. 메일함을 확인해 주세요.",
+ "en": "A verification email has been sent. Please check your inbox.",
+ },
+ "resent": {
+ "ko": "확인 이메일을 다시 보냈습니다.",
+ "en": "The verification email has been resent.",
+ },
+ "verified": {
+ "ko": "이메일이 인증되었습니다.",
+ "en": "Your email has been verified.",
+ },
+ "already_verified": {
+ "ko": "이미 인증된 이메일입니다.",
+ "en": "This email is already verified.",
+ },
+ "invalid_link": {
+ "ko": "확인 링크가 유효하지 않거나 만료되었습니다.",
+ "en": "The verification link is invalid or has expired.",
+ },
+ "deleted": {
+ "ko": "이메일을 삭제했습니다.",
+ "en": "The email has been removed.",
+ },
+ "cannot_delete": {
+ "ko": "이 이메일은 삭제할 수 없습니다.",
+ "en": "This email can't be removed.",
+ },
+ "primary_set": {
+ "ko": "대표 이메일을 변경했습니다.",
+ "en": "Your primary email has been updated.",
+ },
+ "cannot_set_primary": {
+ "ko": "인증된 이메일만 대표로 지정할 수 있습니다.",
+ "en": "Only a verified email can be set as your primary email.",
+ },
+ "add_failed": {
+ "ko": "이 이메일은 추가할 수 없습니다. 형식이 올바르지 않거나 이미 사용 중일 수 있습니다.",
+ "en": "This email can't be added. It may be invalid or already in use.",
+ },
+ "taken_by_other": {
+ "ko": "이미 다른 계정에서 인증된 이메일입니다. 두 계정이 모두 본인 것이라면 계정 병합을 이용해 주세요.",
+ "en": (
+ "This email is already verified on another account. If both accounts are yours, use account merge instead."
+ ),
+ },
+ "confirm_failed": {
+ "ko": "이메일 인증에 실패했습니다. 잠시 후 다시 시도해 주세요.",
+ "en": "We couldn't verify this email. Please try again later.",
+ },
+ "not_found": {
+ "ko": "이메일을 찾을 수 없습니다.",
+ "en": "Email not found.",
+ },
+}
+PASSWORD_MESSAGES = {
+ "changed": {
+ "ko": "비밀번호를 변경했습니다.",
+ "en": "Your password has been changed.",
+ },
+ "reset_done": {
+ "ko": "비밀번호를 재설정했습니다. 이제 새 비밀번호로 로그인할 수 있습니다.",
+ "en": "Your password has been reset. You can now sign in with your new password.",
+ },
+}
+
def generate_random_password(length: int = 16) -> str:
alphabet = string.ascii_letters + string.digits + string.punctuation
diff --git a/app/core/const/regex.py b/app/core/const/regex.py
index d710969..5d7277f 100644
--- a/app/core/const/regex.py
+++ b/app/core/const/regex.py
@@ -18,3 +18,7 @@
# 전화번호
PHONE_PATTERN = r"^([\d]{3}-[\d]{3,4}-[\d]{4}|\+[\d]{9,14})$"
PHONE_REGEX = re.compile(PHONE_PATTERN)
+
+# 색상
+COLOR_PATTERN = r"^#[0-9a-fA-F]{6}$"
+COLOR_REGEX = re.compile(COLOR_PATTERN)
diff --git a/app/core/const/scancode.py b/app/core/const/scancode.py
new file mode 100644
index 0000000..7fc4403
--- /dev/null
+++ b/app/core/const/scancode.py
@@ -0,0 +1,35 @@
+SCANCODE_MESSAGES = {
+ "no_token": {
+ "ko": "유효하지 않은 URL입니다.",
+ "en": "This link is not valid.",
+ },
+ "invalid_token": {
+ "ko": "유효하지 않은 토큰입니다.",
+ "en": "This QR code is not valid.",
+ },
+ "user_not_found": {
+ "ko": "인증 정보를 찾을 수 없습니다.",
+ "en": "We couldn't find this account.",
+ },
+ "user_no_valid_order": {
+ "ko": "최근 6개월 이내에 결제된 유효한 주문이 없습니다 (환불 완료 또는 주문 없음).",
+ "en": "No valid order was paid in the last six months (fully refunded, or no order at all).",
+ },
+ "order_not_found": {
+ "ko": "주문을 찾을 수 없습니다.",
+ "en": "We couldn't find this order.",
+ },
+ "order_refunded": {
+ "ko": "전체 환불된 주문은 사용하실 수 없습니다.",
+ "en": "This order was fully refunded and can no longer be used.",
+ },
+ "opr_not_found": {
+ "ko": "티켓 정보를 찾을 수 없습니다.",
+ "en": "We couldn't find this ticket.",
+ },
+}
+
+SCANCODE_ERROR_GUIDE = {
+ "ko": "QR 코드를 다시 스캔해 주세요. 문제가 계속되면 행사 당일 등록 데스크의 스태프에게 문의해 주세요.",
+ "en": "Please scan the QR code again. If the problem continues, ask a staff member at the registration desk.",
+}
diff --git a/app/core/const/shop_error_messages.py b/app/core/const/shop_error_messages.py
index f80d187..90f908e 100644
--- a/app/core/const/shop_error_messages.py
+++ b/app/core/const/shop_error_messages.py
@@ -22,7 +22,7 @@ class ProductNotOrderableErrorMessages:
SOLDOUT = "{} 상품은 매진되었습니다."
ALREADY_ORDERED_TOO_MUCH = "{} 상품의 인당 최대 구매 수량 초과로 구매하실 수 없습니다."
TOO_MUCH_CART_PRODUCT = "{} 상품의 재고 수량을 초과하여 구매하실 수 없습니다. 장바구니에 담은 수량을 확인해주세요."
- PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 1원 이상으로 구매해주세요."
+ PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 0원 이상으로 구매해주세요."
PRICE_TOO_HIGH = "결제 금액이 너무 높습니다, 후원 금액 등을 줄여 100만원 미만으로 구매해주세요."
DONATION_NOT_ALLOWED = "{} 상품은 후원이 불가능한 상품입니다."
DONATION_PRICE_OUT_OF_RANGE = "{} 상품의 후원 금액이 범위를 벗어났습니다. {}원 이상 {}원 이하로 입력해주세요."
@@ -57,11 +57,17 @@ class CartNotOrderableErrorMessages:
ALREADY_ORDERED = "이미 결제한 장바구니입니다."
CONTAINS_PAID_PRODUCT = "결제한 상품이 포함되어 있습니다. PyCon 한국 준비 위원회에 문의해주세요."
EMPTY = "장바구니가 비어있습니다, 먼저 상품을 담아주세요."
- CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 1원 이상으로 구매해주세요."
+ CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 0원 이상으로 구매해주세요."
CART_PRICE_TOO_HIGH = "장바구니의 금액이 너무 높습니다. 일부 상품을 제거하여 100만원 미만으로 구매해주세요."
TICKET_INFO_REQUIRED = "참가자 정보가 입력되지 않은 티켓이 있습니다. 모든 티켓의 참가자 정보를 입력해주세요."
+class FreeCheckoutErrorMessages:
+ PRICE_NOT_ZERO = "무료 주문은 결제 준비 금액이 0원이어야 합니다."
+ TARGET_NOT_FOUND = "무료 주문 대상을 찾을 수 없습니다."
+ ILLEGAL_STATUS_TRANSITION = "이미 처리된 주문이거나 무료 완료로 전환할 수 없습니다."
+
+
class NotRefundableErrorMessages:
ONE_OF_PRODUCT_IS_USED = "주문 중 이미 사용한 상품이 존재합니다. 개별 환불을 진행해주세요."
ONE_OF_PRODUCT_IS_USED_TRY_AFTER_CHANGING_STATUS = (
@@ -99,6 +105,7 @@ class PortOneWebhookFailureCode(models.TextChoices):
UNEXPECTED_RETRIEVED_ORDER_STATUS = "UNEXPECTED_RETRIEVED_ORDER_STATUS", "예상한 결제 상태가 아닙니다."
UNEXPECTED_RETRIEVED_ORDER_ID = "UNEXPECTED_RETRIEVED_ORDER_ID", "결제 ID가 일치하지 않습니다."
UNEXPECTED_PAID_PRICE = "UNEXPECTED_PAID_PRICE", "결제 금액이 일치하지 않습니다."
+ ORDER_NOT_ORDERABLE = "ORDER_NOT_ORDERABLE", "주문 가능한 상태가 아니거나 재고가 부족합니다."
UNSUPPORTED_CURRENCY = "UNSUPPORTED_CURRENCY", "지원하지 않는 통화입니다."
ILLEGAL_STATUS_TRANSITION = "ILLEGAL_STATUS_TRANSITION", "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다."
CANCELLED_NOT_SUPPORTED = (
diff --git a/app/core/const/tag.py b/app/core/const/tag.py
index 5bb8452..4ae5006 100644
--- a/app/core/const/tag.py
+++ b/app/core/const/tag.py
@@ -3,6 +3,7 @@ class OpenAPITag:
EVENT = "Event"
EVENT_PRESENTATION = "Event > Presentation"
EVENT_SPONSOR = "Event > Sponsor"
+ EVENT_PRESENTATION_BOOKMARK = "Event > Presentation Bookmark"
SHOP_USER = "Shop > 고객"
SHOP_PRODUCT = "Shop > 상품"
@@ -29,12 +30,14 @@ class OpenAPITag:
ADMIN_NOTI_SMS = "Admin > Notification > SMS"
ADMIN_EXT_API_GOOGLE_OAUTH2 = "Admin > External API > Google OAuth2"
ADMIN_SHOP_ORDER = "Admin > Shop > 주문"
+ ADMIN_SHOP_ORDER_PRODUCT_TAG = "Admin > Shop > 주문상품 태그"
ADMIN_SHOP_ORDER_REFUND = "Admin > Shop > 환불"
ADMIN_SHOP_PRODUCT = "Admin > Shop > 상품"
ADMIN_SHOP_CATEGORY = "Admin > Shop > 카테고리"
ADMIN_SHOP_TAG = "Admin > Shop > 태그"
ADMIN_SHOP_REFUND_AUTHORIZER = "Admin > Shop > 환불 승인자"
ADMIN_DOCUMENT = "Admin > Document"
+ ADMIN_REGISTRATION_DESK = "Admin > 등록 데스크"
ADMIN_DASHBOARD = "Admin > Dashboard"
PARTICIPANT_PORTAL_USER = "Participant Portal > Sign-In & Sign-Out & My Profile"
diff --git a/app/core/email_backends.py b/app/core/email_backends.py
index b1b90d0..5ae6ff3 100644
--- a/app/core/email_backends.py
+++ b/app/core/email_backends.py
@@ -1,4 +1,4 @@
-from base64 import b64encode
+from base64 import b64decode, b64encode
from datetime import UTC, datetime, timedelta
from logging import getLogger
from smtplib import SMTPAuthenticationError
@@ -41,15 +41,20 @@ def open(self) -> bool:
self.password = saved_password
@property
- def _access_token(self) -> str:
+ def _refresh_token(self) -> str:
if not (
record := cast(GoogleOAuth2 | None, GoogleOAuth2.objects.filter_active().order_by("-created_at").first())
):
raise RuntimeError(
"No GoogleOAuth2 refresh token configured. Run /v1/external-api/google-oauth2/authorize first.",
)
- refresh_token = cast(str, record.refresh_token)
+ return cast(str, record.refresh_token)
+
+ @property
+ def _access_token(self) -> str:
+ return self._access_token_for(self._refresh_token)
+ def _access_token_for(self, refresh_token: str) -> str:
access_token, expires_at = _access_token_cache.get(refresh_token, (None, None))
if access_token and expires_at and expires_at > datetime.now(UTC):
return access_token
@@ -81,8 +86,18 @@ def _authenticate_xoauth2(self) -> None:
# STARTTLS 이후 smtplib가 ehlo_resp를 비워두기 때문에, docmd("AUTH") 전에 EHLO를 재전송해야 503을 피할 수 있다.
self.connection.ehlo_or_helo_if_needed()
- auth_payload = f"user={self.username}\x01auth=Bearer {self._access_token}\x01\x01"
+ refresh_token = self._refresh_token
+ auth_payload = f"user={self.username}\x01auth=Bearer {self._access_token_for(refresh_token)}\x01\x01"
auth_payload = f"XOAUTH2 {b64encode(auth_payload.encode()).decode()}"
code, response = self.connection.docmd("AUTH", auth_payload)
- if code != 235:
- raise SMTPAuthenticationError(code, response)
+ if code == 235:
+ return
+
+ # XOAUTH2 실패는 334 challenge로 오고, 빈 줄을 보내야 사유가 담긴 최종 응답을 받는다.
+ if code == 334:
+ logger.warning("Gmail rejected XOAUTH2: %s", b64decode(response).decode(errors="replace"))
+ code, response = self.connection.docmd("")
+
+ # 거부된 토큰을 캐시에 남겨두면 만료될 때까지 같은 토큰으로 계속 실패한다.
+ _access_token_cache.pop(refresh_token, None)
+ raise SMTPAuthenticationError(code, response)
diff --git a/app/core/exception_handler.py b/app/core/exception_handler.py
new file mode 100644
index 0000000..8a735f0
--- /dev/null
+++ b/app/core/exception_handler.py
@@ -0,0 +1,36 @@
+from logging import getLogger
+
+from django.db import IntegrityError
+from drf_standardized_errors.handler import ExceptionHandler
+from rest_framework import exceptions, status
+
+logger = getLogger(__name__)
+
+UNIQUE_VIOLATION = "23505"
+EXCLUSION_VIOLATION = "23P01"
+CHECK_VIOLATION = "23514"
+
+CHECK_VIOLATION_MESSAGE = "입력값이 데이터 제약 조건을 위반했습니다."
+
+
+class ConflictError(exceptions.APIException):
+ status_code = status.HTTP_409_CONFLICT
+ default_detail = "이미 있는 데이터와 충돌합니다."
+ default_code = "conflict"
+
+
+class DBConstraintExceptionHandler(ExceptionHandler):
+ def convert_known_exceptions(self, exc: Exception) -> Exception:
+ if isinstance(exc, IntegrityError) and (converted := self._convert_integrity_error(exc)) is not None:
+ logger.warning("DB constraint violation: %s", exc)
+ return converted
+ return super().convert_known_exceptions(exc)
+
+ @staticmethod
+ def _convert_integrity_error(exc: IntegrityError) -> exceptions.APIException | None:
+ sqlstate = getattr(exc.__cause__, "sqlstate", None)
+ if sqlstate in (UNIQUE_VIOLATION, EXCLUSION_VIOLATION):
+ return ConflictError()
+ if sqlstate == CHECK_VIOLATION:
+ return exceptions.ValidationError(CHECK_VIOLATION_MESSAGE)
+ return None
diff --git a/app/core/external_apis/__interface__.py b/app/core/external_apis/__interface__.py
index a97d0fd..04fe4db 100644
--- a/app/core/external_apis/__interface__.py
+++ b/app/core/external_apis/__interface__.py
@@ -1,5 +1,7 @@
from abc import ABC, abstractmethod
-from typing import Any, TypedDict
+from typing import Any, Literal, TypedDict
+
+HttpMethod = Literal["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS", "QUERY"]
class SendParameters(TypedDict):
diff --git a/app/core/external_apis/nhn_cloud/dooray.py b/app/core/external_apis/nhn_cloud/dooray.py
new file mode 100644
index 0000000..24c67c1
--- /dev/null
+++ b/app/core/external_apis/nhn_cloud/dooray.py
@@ -0,0 +1,67 @@
+# https://helpdesk.dooray.com/share/pages/9wWo-xwiR66BO5LGshgVTg/2939987647631384419 (NHN Dooray 서비스 API)
+from typing import TypedDict
+
+from core.external_apis.__interface__ import HttpMethod
+from django.conf import settings
+from httpx import Client, HTTPStatusError, Response
+
+
+class DoorayMemberOrganization(TypedDict):
+ id: str
+
+
+class DoorayMember(TypedDict, total=False):
+ id: str
+ idProviderType: str # sso | service
+ idProviderUserId: str
+ name: str
+ userCode: str
+ externalEmailAddress: str
+ defaultOrganization: DoorayMemberOrganization
+ locale: str
+ timezoneName: str
+ englishName: str
+ nativeName: str
+ nickname: str
+ displayMemberId: str
+
+
+class DoorayError(Exception):
+ def __init__(self, response: Response) -> None:
+ self.response = response
+ self.status_code = response.status_code
+ super().__init__(f"Dooray API error {response.status_code}")
+
+
+class NHNCloudDoorayClient:
+ session: Client
+
+ def __init__(self) -> None:
+ self.session = Client(base_url=settings.DOORAY.base_url, timeout=settings.DOORAY.timeout)
+
+ def forward(
+ self,
+ token: str,
+ method: HttpMethod,
+ path: str,
+ *,
+ params: dict | None = None,
+ json: object | None = None,
+ ) -> Response:
+ resp = self.session.request(
+ method,
+ path,
+ params=params,
+ json=json,
+ headers={"Authorization": f"dooray-api {token}"},
+ )
+ try:
+ return resp.raise_for_status()
+ except HTTPStatusError as exc:
+ raise DoorayError(resp) from exc
+
+ def members_me(self, token: str) -> DoorayMember:
+ return self.forward(token, "GET", "/common/v1/members/me").json().get("result", {})
+
+
+nhn_cloud_dooray_client = NHNCloudDoorayClient()
diff --git a/app/core/external_apis/nhn_cloud/kakao_alimtalk.py b/app/core/external_apis/nhn_cloud/kakao_alimtalk.py
index 7027d07..f77ec74 100644
--- a/app/core/external_apis/nhn_cloud/kakao_alimtalk.py
+++ b/app/core/external_apis/nhn_cloud/kakao_alimtalk.py
@@ -14,10 +14,13 @@ class NHNCloudKakaoAlimTalkClient(NotificationServiceInterface):
def __init__(self) -> None:
self.session = Client(
- base_url=f"{settings.NHN_CLOUD.kakao_alimtalk.base_url}/alimtalk/v2.3/appkeys/{settings.NHN_CLOUD.app_key}",
+ base_url=(
+ f"{settings.NHN_CLOUD.kakao_alimtalk.base_url}"
+ f"/alimtalk/v2.3/appkeys/{settings.NHN_CLOUD.kakao_alimtalk.app_key}"
+ ),
headers={
"Content-Type": "application/json",
- "X-Secret-Key": settings.NHN_CLOUD.secret_key,
+ "X-Secret-Key": settings.NHN_CLOUD.kakao_alimtalk.secret_key,
},
timeout=settings.NHN_CLOUD.kakao_alimtalk.timeout,
)
diff --git a/app/core/external_apis/nhn_cloud/sms.py b/app/core/external_apis/nhn_cloud/sms.py
index 94de1ec..3242944 100644
--- a/app/core/external_apis/nhn_cloud/sms.py
+++ b/app/core/external_apis/nhn_cloud/sms.py
@@ -19,10 +19,10 @@ class NHNCloudSMSClient(NotificationServiceInterface):
def __init__(self) -> None:
self.session = Client(
- base_url=f"{settings.NHN_CLOUD.sms.base_url}/sms/v3.0/appKeys/{settings.NHN_CLOUD.app_key}",
+ base_url=f"{settings.NHN_CLOUD.sms.base_url}/sms/v3.0/appKeys/{settings.NHN_CLOUD.sms.app_key}",
headers={
"Content-Type": "application/json;charset=UTF-8",
- "X-Secret-Key": settings.NHN_CLOUD.secret_key,
+ "X-Secret-Key": settings.NHN_CLOUD.sms.secret_key,
},
timeout=settings.NHN_CLOUD.sms.timeout,
)
@@ -40,8 +40,6 @@ def send_message(self, *, data: SendParameters) -> None:
"body": payload["body"],
"recipientList": [{"recipientNo": data["send_to"]}],
}
- if data["template_code"]:
- body["templateId"] = data["template_code"]
if title := payload.get("title"):
body["title"] = title
diff --git a/app/core/external_apis/smtp_email.py b/app/core/external_apis/smtp_email.py
index 088ea75..6558bd8 100644
--- a/app/core/external_apis/smtp_email.py
+++ b/app/core/external_apis/smtp_email.py
@@ -1,11 +1,33 @@
+from contextlib import suppress
+from email.policy import SMTP as SMTP_EMAIL_POLICY
from logging import getLogger
+from smtplib import SMTPException, SMTPSenderRefused, SMTPServerDisconnected
+from threading import Lock
from typing import TypedDict, cast
from core.external_apis.__interface__ import NotificationServiceInterface, SendParameters
-from django.core.mail import EmailMessage
+from django.core.mail import EmailMessage, get_connection
+from django.core.mail.backends.base import BaseEmailBackend
logger = getLogger(__name__)
+# 기본값 78이면 긴 한글 제목이 RFC 2047 상한(75자)을 넘는 encoded-word로 접혀 일부 클라이언트에서 깨진다.
+_EMAIL_POLICY = SMTP_EMAIL_POLICY.clone(max_line_length=76)
+
+
+def _is_stale_connection(error: SMTPException) -> bool:
+ # Gmail은 유휴 커넥션을 끊을 때 TCP를 그냥 닫기도 하고 MAIL FROM에 `451 4.4.2 Timeout - closing connection`을
+ # 응답하기도 한다. 둘 다 메시지가 수락되기 전이라 재연결 후 재시도해도 중복 발송되지 않는다.
+ if isinstance(error, SMTPServerDisconnected):
+ return True
+ return isinstance(error, SMTPSenderRefused) and 400 <= error.smtp_code < 500
+
+
+class _SafeHeaderEmailMessage(EmailMessage):
+ # SMTP 백엔드가 policy를 명시로 넘기므로, 기본 인자가 아니라 인자 자체를 무시해야 적용된다.
+ def message(self, *, policy=None): # type: ignore[no-untyped-def]
+ return super().message(policy=_EMAIL_POLICY)
+
class EmailPayload(TypedDict):
title: str
@@ -13,6 +35,11 @@ class EmailPayload(TypedDict):
class EmailClient(NotificationServiceInterface):
+ def __init__(self) -> None:
+ # Gmail은 메일마다 로그인하면 "454 Too many login attempts"로 차단하므로 커넥션을 프로세스 단위로 재사용한다.
+ self._connection: BaseEmailBackend | None = None
+ self._lock = Lock()
+
def send_message(self, *, data: SendParameters) -> None:
if not data["sent_from"]:
raise ValueError("sent_from is required to send Email.")
@@ -21,15 +48,37 @@ def send_message(self, *, data: SendParameters) -> None:
if not payload.get("title"):
raise ValueError("title is required in payload.")
- message = EmailMessage(
+ message = _SafeHeaderEmailMessage(
subject=payload["title"],
body=payload.get("body", ""),
from_email=data["sent_from"],
to=[data["send_to"]],
)
message.content_subtype = "html"
- sent_count = message.send(fail_silently=False)
+
+ with self._lock:
+ sent_count = self._send(message)
logger.info("Email send results: sent_count=%s to=%s", sent_count, data["send_to"])
+ def _send(self, message: EmailMessage) -> int:
+ # send_messages()는 이미 열린 커넥션이면 발송 후 닫지 않는다 — open()을 먼저 해야 재사용이 성립한다.
+ for is_last_attempt in (False, True):
+ if self._connection is None:
+ self._connection = get_connection()
+ self._connection.open()
+ try:
+ return self._connection.send_messages([message])
+ except (SMTPServerDisconnected, SMTPSenderRefused) as error:
+ if is_last_attempt or not _is_stale_connection(error):
+ raise
+ self._discard_connection()
+ return 0
+
+ def _discard_connection(self) -> None:
+ connection, self._connection = self._connection, None
+ if connection is not None:
+ with suppress(Exception):
+ connection.close()
+
email_client = EmailClient()
diff --git a/app/core/fields.py b/app/core/fields.py
index 45cff2a..e3a0b53 100644
--- a/app/core/fields.py
+++ b/app/core/fields.py
@@ -2,9 +2,14 @@
import contextlib
import typing
import uuid
+from functools import lru_cache
+from cryptography.fernet import Fernet, MultiFernet
+from django.conf import settings
+from django.core.checks import Error
+from django.core.exceptions import ImproperlyConfigured
from django.db.backends.base.operations import BaseDatabaseOperations
-from django.db.models import AutoField, UUIDField, expressions, fields
+from django.db.models import AutoField, TextField, UUIDField, expressions, fields
from django.db.models.fields.reverse_related import ForeignObjectRel
BaseDatabaseOperations.integer_field_ranges["UUIDField"] = (0, 0)
@@ -68,3 +73,43 @@ def get_prep_value(self, value: typing.Any) -> uuid.UUID | None:
with contextlib.suppress(ValueError):
return uuid.UUID(value)
return self.to_python(value)
+
+
+@lru_cache(maxsize=None)
+def _build_fernet(key_setting_name: str) -> MultiFernet:
+ raw = getattr(settings, key_setting_name, "") or ""
+ if not (keys := [k.strip() for k in raw.split(",") if k.strip()]):
+ raise ImproperlyConfigured(f"{key_setting_name} 미설정 (Fernet 키 필수).")
+ return MultiFernet([Fernet(k) for k in keys])
+
+
+class EncryptedTextField(TextField):
+ def __init__(self, *args: typing.Any, key_setting_name: str, **kwargs: typing.Any) -> None:
+ self.key_setting_name = key_setting_name
+ super().__init__(*args, **kwargs)
+
+ def deconstruct(self) -> tuple:
+ name, path, args, kwargs = super().deconstruct()
+ kwargs["key_setting_name"] = self.key_setting_name
+ return name, path, args, kwargs
+
+ def _fernet(self) -> MultiFernet:
+ return _build_fernet(self.key_setting_name)
+
+ def check(self, **kwargs: typing.Any) -> list:
+ errors = super().check(**kwargs)
+ if not (getattr(settings, self.key_setting_name, "") or ""):
+ errors.append(
+ Error(f"{self.key_setting_name} 미설정 — 암호화 키 필수", obj=self, id="core.E_encrypted_key")
+ )
+ return errors
+
+ def get_prep_value(self, value: str | None) -> str | None:
+ if not value:
+ return None
+ return self._fernet().encrypt(str(value).encode()).decode()
+
+ def from_db_value(self, value: str | None, expression: typing.Any, connection: typing.Any) -> str | None:
+ if not value:
+ return None
+ return self._fernet().decrypt(value.encode()).decode()
diff --git a/app/core/middleware/host_urlconf.py b/app/core/middleware/host_urlconf.py
new file mode 100644
index 0000000..389d8ab
--- /dev/null
+++ b/app/core/middleware/host_urlconf.py
@@ -0,0 +1,18 @@
+from collections.abc import Callable
+
+from django.conf import settings
+from django.http import HttpRequest, HttpResponse
+
+
+class HostUrlconfMiddleware:
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
+ self.get_response = get_response
+ self.rules = getattr(settings, "HOST_URLCONFS", [])
+
+ def __call__(self, request: HttpRequest) -> HttpResponse:
+ host = request.get_host().partition(":")[0]
+ for pattern, urlconf in self.rules:
+ if pattern.match(host):
+ request.urlconf = urlconf
+ break
+ return self.get_response(request)
diff --git a/app/core/middleware/response_exception.py b/app/core/middleware/response_exception.py
new file mode 100644
index 0000000..f8f4ad0
--- /dev/null
+++ b/app/core/middleware/response_exception.py
@@ -0,0 +1,24 @@
+from __future__ import annotations
+
+from collections.abc import Callable
+
+from django.http import HttpRequest, HttpResponse
+
+
+class ResponseException(Exception):
+ def __init__(self, response: HttpResponse) -> None:
+ super().__init__(response)
+ self.response = response
+
+
+class ResponseExceptionMiddleware:
+ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None:
+ self.get_response = get_response
+
+ def __call__(self, request: HttpRequest) -> HttpResponse:
+ return self.get_response(request)
+
+ def process_exception(self, request: HttpRequest, exception: Exception) -> HttpResponse | None:
+ if isinstance(exception, ResponseException):
+ return exception.response
+ return None
diff --git a/app/core/models.py b/app/core/models.py
index cec5fba..8dc2619 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -2,8 +2,10 @@
import typing
import uuid
+from core.const.regex import COLOR_REGEX
from core.util.thread_local import get_current_user
from django.contrib.auth import get_user_model
+from django.core.validators import RegexValidator
from django.db import models
from django.db.models.functions import Now
from django.utils import timezone
@@ -121,3 +123,17 @@ def delete(self, using: str | None = None) -> None:
class MarkdownField(models.TextField):
is_markdown = True
+
+
+class ColorField(models.CharField):
+ empty_strings_allowed = False
+ default_validators: typing.ClassVar = [
+ RegexValidator(regex=COLOR_REGEX, message="색상은 #RRGGBB 형식이어야 합니다.")
+ ]
+
+ def __init__(self, *args, **kwargs) -> None:
+ kwargs.setdefault("max_length", 7)
+ super().__init__(*args, **kwargs)
+
+ def get_prep_value(self, value: str | None) -> str | None:
+ return super().get_prep_value(value) or None
diff --git a/app/core/negotiation.py b/app/core/negotiation.py
new file mode 100644
index 0000000..69071b8
--- /dev/null
+++ b/app/core/negotiation.py
@@ -0,0 +1,18 @@
+from rest_framework import negotiation, parsers, renderers, request
+
+
+class IgnoreClientContentNegotiation(negotiation.BaseContentNegotiation):
+ """클라이언트의 Accept 헤더를 무시하고 뷰의 첫 렌더러를 강제한다.
+ QR 스캐너 인앱 브라우저처럼 Accept 를 이상하게 보내는 클라이언트에게 406 대신 정상 응답을 돌려주기 위함.
+ """
+
+ def select_parser(self, request: request.Request, parsers_: list[parsers.BaseParser]) -> parsers.BaseParser | None:
+ return parsers_[0] if parsers_ else None
+
+ def select_renderer(
+ self,
+ request: request.Request,
+ renderers_: list[renderers.BaseRenderer],
+ format_suffix: str | None = None,
+ ) -> tuple[renderers.BaseRenderer, str]:
+ return renderers_[0], renderers_[0].media_type
diff --git a/app/core/openapi/ui_hints.py b/app/core/openapi/ui_hints.py
index ec7368d..39e9ec7 100644
--- a/app/core/openapi/ui_hints.py
+++ b/app/core/openapi/ui_hints.py
@@ -1,18 +1,20 @@
-from core.models import MarkdownField
+from core.models import ColorField, MarkdownField
from django.db.models.fields import TextField
from django.db.models.fields.files import FileField
from django.db.models.fields.related import ForeignKey, ManyToManyField
from modeltranslation.fields import TranslationField
+def admin_route_for_model(model: type) -> dict:
+ return {"app": model._meta.app_config.name.split(".")[0], "resource": model._meta.model_name}
+
+
def ui_hints_for_model_field(model_field: object) -> dict:
hints: dict = {}
if isinstance(model_field, (ForeignKey, ManyToManyField)):
- hints["ui:options"] = {
- "choiceApp": model_field.related_model._meta.app_config.name.split(".")[0],
- "choiceResource": model_field.related_model._meta.model_name,
- }
+ route = admin_route_for_model(model_field.related_model)
+ hints["ui:options"] = {"choiceApp": route["app"], "choiceResource": route["resource"]}
if isinstance(model_field, ManyToManyField):
return hints | {"ui:field": "m2m_select"}
@@ -20,6 +22,8 @@ def ui_hints_for_model_field(model_field: object) -> dict:
return {"ui:field": "file"}
if isinstance(model_field, TranslationField):
model_field = model_field.translated_field
+ if isinstance(model_field, ColorField):
+ return {"ui:widget": "color"}
if isinstance(model_field, MarkdownField): # MarkdownField 는 TextField 하위 → 먼저 검사
return {"ui:widget": "textarea", "ui:field": "markdown"}
if isinstance(model_field, TextField):
diff --git a/app/core/scancode_mixin.py b/app/core/scancode_mixin.py
index ab96262..8abb61a 100644
--- a/app/core/scancode_mixin.py
+++ b/app/core/scancode_mixin.py
@@ -4,6 +4,7 @@
from contextlib import suppress
from functools import cached_property
from hashlib import sha256
+from hmac import compare_digest
from hmac import new as hmac_new
from typing import ClassVar, Self
from uuid import UUID
@@ -25,10 +26,17 @@ def _scancode_uuid(self) -> UUID:
def short_id(self) -> str:
return encode(self._scancode_uuid)
+ def _salt_with(self, secret: str) -> str:
+ hmac_result = hmac_new(secret.encode(), self._scancode_uuid.bytes, sha256).digest()
+ return urlsafe_b64encode(hmac_result).decode("utf-8").rstrip("=")
+
@cached_property
def salt(self) -> str:
- hmac_result = hmac_new(settings.SHOP.order_scancode_salt.encode(), self._scancode_uuid.bytes, sha256).digest()
- return urlsafe_b64encode(hmac_result).decode("utf-8").rstrip("=")
+ return self._salt_with(settings.SHOP.order_scancode_salts[0])
+
+ def verify_salt(self, salt: str) -> bool:
+ # rotation 중에는 과거 salt 로 발급된 토큰도 받아준다.
+ return any(compare_digest(self._salt_with(secret), salt) for secret in settings.SHOP.order_scancode_salts)
@cached_property
def scancode_token(self) -> str:
@@ -53,6 +61,6 @@ def from_scancode_token(cls, scancode_token: str) -> Self | None:
prefix, short_id, salt = parts
if prefix != cls.scancode_prefix or not (short_id and salt):
return None
- if (instance := cls.from_short_id(short_id)) and instance.salt == salt:
+ if (instance := cls.from_short_id(short_id)) and instance.verify_salt(salt):
return instance
return None
diff --git a/app/core/serializer/nested_model_serializer.py b/app/core/serializer/nested_model_serializer.py
index 1e66836..0c1eefd 100644
--- a/app/core/serializer/nested_model_serializer.py
+++ b/app/core/serializer/nested_model_serializer.py
@@ -120,9 +120,11 @@ def _update_list_instances(self, field: serializers.ListSerializer, data: list[d
def update(self, instance: Model, validated_data: dict) -> Model:
info: model_meta.FieldInfo = model_meta.get_field_info(instance.__class__)
m2m_fields: list[tuple[str, typing.Any]] = []
+ # validated_data 의 키는 field name 이 아니라 source — `source=` 를 지정한 필드도 찾을 수 있어야 한다.
+ fields_by_source = {field.source: field for field in self.fields.values()}
for field_name, value in validated_data.items():
- if (field := self.fields[field_name]).read_only:
+ if (field := fields_by_source[field_name]).read_only:
continue
if isinstance(field, serializers.BaseSerializer):
diff --git a/app/core/serializer/operation_serializer.py b/app/core/serializer/operation_serializer.py
new file mode 100644
index 0000000..6ccfd1c
--- /dev/null
+++ b/app/core/serializer/operation_serializer.py
@@ -0,0 +1,93 @@
+from __future__ import annotations
+
+from django.db.models import TextChoices
+from rest_framework import serializers
+
+
+class Operation(TextChoices):
+ CREATE = "create", "추가"
+ UPDATE = "update", "수정"
+ DELETE = "delete", "삭제"
+
+
+class OperationListSerializer(serializers.ListSerializer):
+ """`op` 필드로 항목별 추가/수정/삭제를 지시하는 부분반영 리스트.
+
+ 목록에 없는 항목은 건드리지 않으며, DRF 가 항목별 검증을 인덱스로 집계한다.
+ child 는 `Meta.model` 을 가져야 한다(soft-delete `filter_active()` 전제).
+ pk 는 서버가 소유한다 — create 는 서버가 pk 를 생성하고, update/delete 는 `id` 로 대상을 지정한다.
+ 실제 저장은 부모 직렬화기가 순서를 통제하며 `apply()` 를 호출한다.
+ """
+
+ def run_child_validation(self, data: dict) -> dict:
+ if not isinstance(data, dict):
+ raise serializers.ValidationError("객체 형식이어야 합니다.")
+
+ op = data.get("op")
+ if op not in Operation.values:
+ raise serializers.ValidationError({"op": f"{list(Operation.values)} 중 하나여야 합니다."})
+
+ pk = data.get("id")
+ if op == Operation.CREATE:
+ validated = dict(self.child.__class__(context=self.context).run_validation(data))
+ else:
+ if pk is None:
+ raise serializers.ValidationError({"id": "update/delete 에는 id 가 필요합니다."})
+ if not (instance := self.child.get_operation_queryset().filter(pk=pk).first()):
+ raise serializers.ValidationError({"id": "존재하지 않는 항목입니다."})
+ if op == Operation.DELETE:
+ return {"op": Operation.DELETE.value, "id": instance.pk}
+ validated = dict(
+ self.child.__class__(instance=instance, partial=True, context=self.context).run_validation(data)
+ )
+
+ validated["op"] = op
+ validated.setdefault("id", pk)
+ return validated
+
+ def apply(self, operations: list[dict], **create_defaults) -> list:
+ model = self.child.Meta.model
+ # name 과 attname(FK 의 `_id` 등) 을 모두 허용 — 도메인이 room_id 같은 attname 으로 값을 넘길 수 있다.
+ model_fields = {n for f in model._meta.get_fields() for n in (f.name, getattr(f, "attname", f.name))}
+ results = []
+ for attrs in operations:
+ op = attrs["op"]
+ pk = attrs.get("id")
+ fields = {key: value for key, value in attrs.items() if key != "id" and key in model_fields}
+ if op == Operation.CREATE:
+ results.append(model.objects.create(**create_defaults, **fields))
+ elif op == Operation.UPDATE:
+ instance = self.child.get_operation_queryset().get(pk=pk)
+ for field, value in fields.items():
+ setattr(instance, field, value)
+ instance.save()
+ results.append(instance)
+ else:
+ instance = self.child.get_operation_queryset().get(pk=pk)
+ instance.delete()
+ results.append(instance)
+ return results
+
+
+class OperationSerializer(serializers.Serializer):
+ """op 기반 부분반영 항목의 공통 필드(id·op)와 list_serializer_class 를 제공하는 믹스인.
+
+ 사용: `class Foo(OperationSerializer, serializers.ModelSerializer)` 로 ModelSerializer 와 함께 상속하고,
+ `class Meta(OperationSerializer.Meta): model = ...; fields = OperationSerializer.Meta.fields + (...)`.
+
+ - id: 이미 존재하는 항목의 pk. update/delete 대상 지정용(editable=False pk 는 기본 read-only 라 명시 선언).
+ - op: 검증·분기는 OperationListSerializer 가 담당하고, 이 필드는 문서화(spectacular)·validated_data 포함용.
+
+ create 항목을 같은 요청 안에서 참조해야 하는 링크 토큰(ref 등)은 도메인 하위 직렬화기에 직접 선언하고
+ 부모 직렬화기가 resolve 한다 — apply 는 모델 필드만 반영하므로 비모델 필드는 저장되지 않는다.
+ """
+
+ id = serializers.UUIDField(required=False)
+ op = serializers.ChoiceField(choices=Operation.choices, write_only=True)
+
+ def get_operation_queryset(self):
+ return self.Meta.model.objects.filter_active()
+
+ class Meta:
+ fields = ("id", "op")
+ list_serializer_class = OperationListSerializer
diff --git a/app/core/serializer/pk_related_serializer_field.py b/app/core/serializer/pk_related_serializer_field.py
new file mode 100644
index 0000000..21779e4
--- /dev/null
+++ b/app/core/serializer/pk_related_serializer_field.py
@@ -0,0 +1,15 @@
+from typing import Any
+
+from rest_framework import serializers
+
+
+class PrimaryKeyRelatedSerializerField(serializers.PrimaryKeyRelatedField):
+ def __init__(self, *args: Any, serializer: type[serializers.BaseSerializer], **kwargs: Any) -> None:
+ self.serializer = serializer
+ super().__init__(*args, **kwargs)
+
+ def use_pk_only_optimization(self) -> bool:
+ return False
+
+ def to_representation(self, value: Any) -> Any:
+ return self.serializer(value, context=self.context).data
diff --git a/app/core/settings.py b/app/core/settings.py
index 94dbf2d..00f9f38 100644
--- a/app/core/settings.py
+++ b/app/core/settings.py
@@ -1,5 +1,6 @@
import os
import pathlib
+import re
import types
import typing
@@ -92,7 +93,14 @@
for port in ("", ":3000", ":5173", ":5174")
]
CORS_ALLOW_CREDENTIALS = True
-CORS_ALLOW_HEADERS = [*corsheaders.defaults.default_headers, "accept-encoding", "origin", "x-csrftoken"]
+CORS_ALLOW_HEADERS = [
+ *corsheaders.defaults.default_headers,
+ "accept-encoding",
+ "origin",
+ "x-csrftoken",
+ "if-match",
+]
+CORS_EXPOSE_HEADERS = ["ETag"]
SECURE_CROSS_ORIGIN_OPENER_POLICY = None if DEBUG else "same-origin"
# Application definition
@@ -149,13 +157,14 @@
"participant_portal_api",
"external_api",
"external_api.google_oauth2",
+ "well_known",
# django-constance
"constance",
]
MIDDLEWARE = [
- # Django default middlewares
"django.middleware.security.SecurityMiddleware",
+ "core.middleware.host_urlconf.HostUrlconfMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
@@ -178,9 +187,13 @@
"core.middleware.thread_middleware.ThreadLocalMiddleware",
# Request Response Logger
"core.middleware.request_response_logger.RequestResponseLogger",
+ "core.middleware.response_exception.ResponseExceptionMiddleware",
]
ROOT_URLCONF = "core.urls"
+HOST_URLCONFS = [
+ (re.compile(r"^accounts"), "core.account_urls"),
+]
TEMPLATES = [
{
@@ -195,7 +208,7 @@
"django.contrib.messages.context_processors.messages",
"django.template.context_processors.request",
],
- "builtins": ["core.templatetags.data_uri"],
+ "builtins": ["core.templatetags.data_uri", "core.templatetags.i18n_extras"],
},
},
]
@@ -257,8 +270,10 @@
]
ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https"
-ACCOUNT_LOGIN_METHODS = {"username", "email"}
+ACCOUNT_LOGIN_METHODS = {"email"}
+ACCOUNT_SIGNUP_FIELDS = ["email*", "password1*", "password2*"]
ACCOUNT_EMAIL_VERIFICATION = "none"
+ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False
ACCOUNT_ADAPTER = "core.authn.allauth_adapter.NoNewUsersAccountAdapter"
SOCIALACCOUNT_ONLY = False
@@ -386,6 +401,8 @@
"URL_FORMAT_OVERRIDE": None,
}
+DRF_STANDARDIZED_ERRORS = {"EXCEPTION_HANDLER_CLASS": "core.exception_handler.DBConstraintExceptionHandler"}
+
# DRF Spectacular Settings
SPECTACULAR_SETTINGS = {
"TITLE": "PyCon KR Backend API",
@@ -415,21 +432,34 @@
# NHN Cloud Settings
# https://docs.nhncloud.com/ko/Notification/KakaoTalk%20Bizmessage/ko/alimtalk-api-guide/
# https://docs.nhncloud.com/ko/Notification/SMS/ko/api-guide/
+# 알림톡과 SMS는 NHN Cloud 상품이 달라 appKey/secretKey가 서로 다르다. 공통값은 서비스별 값이 없을 때의 기본값.
+_NHN_CLOUD_APP_KEY = env("NHN_CLOUD_APP_KEY", default="")
+_NHN_CLOUD_SECRET_KEY = env("NHN_CLOUD_SECRET_KEY", default="")
NHN_CLOUD = types.SimpleNamespace(
- app_key=env("NHN_CLOUD_APP_KEY", default=""),
- secret_key=env("NHN_CLOUD_SECRET_KEY", default=""),
kakao_alimtalk=types.SimpleNamespace(
+ app_key=env("NHN_CLOUD_KAKAO_ALIMTALK_APP_KEY", default=_NHN_CLOUD_APP_KEY),
+ secret_key=env("NHN_CLOUD_KAKAO_ALIMTALK_SECRET_KEY", default=_NHN_CLOUD_SECRET_KEY),
base_url=env(
"NHN_CLOUD_KAKAO_ALIMTALK_BASE_URL", default="https://kakaotalk-bizmessage.api.nhncloudservice.com"
),
timeout=env.float("NHN_CLOUD_KAKAO_ALIMTALK_TIMEOUT", default=30.0),
),
sms=types.SimpleNamespace(
+ app_key=env("NHN_CLOUD_SMS_APP_KEY", default=_NHN_CLOUD_APP_KEY),
+ secret_key=env("NHN_CLOUD_SMS_SECRET_KEY", default=_NHN_CLOUD_SECRET_KEY),
base_url=env("NHN_CLOUD_SMS_BASE_URL", default="https://sms.api.nhncloudservice.com"),
timeout=env.float("NHN_CLOUD_SMS_TIMEOUT", default=30.0),
),
)
+# NHN Dooray!
+DOORAY = types.SimpleNamespace(
+ base_url=env("DOORAY_API_BASE_URL", default="https://api.dooray.com"),
+ timeout=env.float("DOORAY_API_TIMEOUT", default=30.0),
+)
+# Dooray 개인 토큰 암호화용 Fernet 키(urlsafe base64 32B; 콤마로 여러 개=회전).
+DOORAY_CRED_ENC_KEY = env("DOORAY_CRED_ENC_KEY", default="")
+
# Celery Settings
CELERY_BROKER_URL = env("CELERY_BROKER_URL")
CELERY_RESULT_BACKEND = env("CELERY_RESULT_BACKEND")
@@ -464,7 +494,12 @@
# Shop Settings
SHOP = types.SimpleNamespace(
- order_scancode_salt=env("ORDER_SCANCODE_SALT", default="local_order_scancode_salt"),
+ # 쉼표로 여러 개 지정 가능 — 맨 앞이 발급용, 나머지는 rotation 기간 동안 검증만 허용.
+ order_scancode_salts=[
+ stripped
+ for salt in env.list("ORDER_SCANCODE_SALT", default=["local_order_scancode_salt"])
+ if (stripped := salt.strip())
+ ],
refund_authorizer_secret_key=env("REFUND_AUTHORIZER_SECRET_KEY", default="local_refund_authorizer_secret_key"),
)
diff --git a/app/core/templates/account/email/email_confirmation_message.html b/app/core/templates/account/email/email_confirmation_message.html
new file mode 100644
index 0000000..654afd1
--- /dev/null
+++ b/app/core/templates/account/email/email_confirmation_message.html
@@ -0,0 +1,25 @@
+{% is_english as en %}
+
+
+
+
+
+
+ {% if en %}Verify your email{% else %}이메일 인증{% endif %}
+
+ {% if en %}Please confirm this email address by clicking the button below.{% else %}아래 버튼을 눌러 이메일 주소를 인증해 주세요.{% endif %}
+
+
+ {% if en %}Verify email{% else %}이메일 인증하기{% endif %}
+
+ {% if en %}Or open this link:{% else %}또는 이 링크를 여세요:{% endif %}
+ {{ activate_url }}
+
+ {% if en %}If you didn't request this, you can safely ignore this email. — PyCon Korea{% else %}요청하지 않으셨다면 이 메일을 무시하셔도 됩니다. — 파이콘 한국{% endif %}
+
+
+
+
+
+
+
diff --git a/app/core/templates/account/email/email_confirmation_subject.txt b/app/core/templates/account/email/email_confirmation_subject.txt
new file mode 100644
index 0000000..4871fc5
--- /dev/null
+++ b/app/core/templates/account/email/email_confirmation_subject.txt
@@ -0,0 +1 @@
+{% is_english as en %}{% if en %}Verify your email · PyCon Korea{% else %}이메일 인증 · 파이콘 한국{% endif %}
diff --git a/app/core/templates/account/email/password_reset_key_message.html b/app/core/templates/account/email/password_reset_key_message.html
new file mode 100644
index 0000000..fb17615
--- /dev/null
+++ b/app/core/templates/account/email/password_reset_key_message.html
@@ -0,0 +1,25 @@
+{% is_english as en %}
+
+
+
+
+
+
+ {% if en %}Reset your password{% else %}비밀번호 재설정{% endif %}
+
+ {% if en %}We received a request to reset your password. Click the button below to set a new one.{% else %}비밀번호 재설정 요청을 받았습니다. 아래 버튼을 눌러 새 비밀번호를 설정해 주세요.{% endif %}
+
+
+ {% if en %}Reset password{% else %}비밀번호 재설정하기{% endif %}
+
+ {% if en %}Or open this link:{% else %}또는 이 링크를 여세요:{% endif %}
+ {{ password_reset_url }}
+
+ {% if en %}If you didn't request this, you can safely ignore this email. — PyCon Korea{% else %}요청하지 않으셨다면 이 메일을 무시하셔도 됩니다. — 파이콘 한국{% endif %}
+
+
+
+
+
+
+
diff --git a/app/core/templates/account/email/password_reset_key_subject.txt b/app/core/templates/account/email/password_reset_key_subject.txt
new file mode 100644
index 0000000..bc0fabd
--- /dev/null
+++ b/app/core/templates/account/email/password_reset_key_subject.txt
@@ -0,0 +1 @@
+{% is_english as en %}{% if en %}Reset your password · PyCon Korea{% else %}비밀번호 재설정 · 파이콘 한국{% endif %}
diff --git a/app/core/templatetags/i18n_extras.py b/app/core/templatetags/i18n_extras.py
new file mode 100644
index 0000000..2f36a3a
--- /dev/null
+++ b/app/core/templatetags/i18n_extras.py
@@ -0,0 +1,9 @@
+from django import template
+from django.utils.translation import get_language
+
+register = template.Library()
+
+
+@register.simple_tag
+def is_english() -> bool:
+ return (get_language() or "").lower().startswith("en")
diff --git a/app/core/test/django_orm_test.py b/app/core/test/django_orm_test.py
new file mode 100644
index 0000000..937ddd0
--- /dev/null
+++ b/app/core/test/django_orm_test.py
@@ -0,0 +1,116 @@
+import pytest
+from core.util.django_orm import (
+ apply_diff_to_model,
+ get_diff_data_from_jsonized_models,
+ model_to_identifier,
+ model_to_jsonable_dict,
+)
+from django.utils.translation import override
+from event.presentation.models import (
+ Presentation,
+ PresentationCategory,
+ PresentationCategoryRelation,
+ PresentationSpeaker,
+)
+from model_bakery import baker
+
+
+@pytest.mark.parametrize("active_language", ["ko", "en"], ids=["applied_in_ko", "applied_in_en"])
+def test_apply_diff_to_model_ignores_translated_original_field(db, active_language):
+ presentation = baker.make(Presentation, title_ko="한글제목", title_en="EnglishTitle")
+ diff = {model_to_identifier(presentation): {"title": "NewEnglishTitle", "title_en": "NewEnglishTitle"}}
+
+ with override(active_language):
+ apply_diff_to_model(diff)
+
+ presentation.refresh_from_db()
+ assert presentation.title_en == "NewEnglishTitle"
+ assert presentation.title_ko == "한글제목"
+
+
+def test_apply_diff_to_model_applies_plain_field(db):
+ presentation = baker.make(Presentation, slideshow_url="https://example.com/old")
+ diff = {model_to_identifier(presentation): {"slideshow_url": "https://example.com/new"}}
+
+ apply_diff_to_model(diff)
+
+ presentation.refresh_from_db()
+ assert presentation.slideshow_url == "https://example.com/new"
+
+
+def test_apply_diff_to_model_ignores_reordered_reverse_relation(db):
+ presentation = baker.make(Presentation)
+ speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=2)
+ diff = {model_to_identifier(presentation): {"speakers": [model_to_identifier(s) for s in reversed(speakers)]}}
+
+ apply_diff_to_model(diff)
+
+ assert set(presentation.speakers.values_list("id", flat=True)) == {s.pk for s in speakers}
+
+
+def test_apply_diff_to_model_rejects_member_change_on_non_nullable_reverse_relation(db):
+ presentation = baker.make(Presentation)
+ speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=2)
+ diff = {model_to_identifier(presentation): {"speakers": [model_to_identifier(speakers[0])]}}
+
+ with pytest.raises(ValueError, match="speakers"):
+ apply_diff_to_model(diff)
+
+ assert presentation.speakers.count() == 2
+
+
+def test_apply_diff_to_model_applies_many_to_many_members(db):
+ presentation = baker.make(Presentation)
+ old_category, new_category = baker.make(PresentationCategory, type=presentation.type, _quantity=2)
+ baker.make(PresentationCategoryRelation, presentation=presentation, category=old_category)
+ diff = {model_to_identifier(presentation): {"categories": [model_to_identifier(new_category)]}}
+
+ apply_diff_to_model(diff)
+
+ assert set(presentation.categories.values_list("id", flat=True)) == {new_category.pk}
+
+
+def test_model_to_jsonable_dict_sorts_reverse_relation_identifiers(db):
+ presentation = baker.make(Presentation)
+ speakers = baker.make(PresentationSpeaker, presentation=presentation, _quantity=3)
+
+ snapshot = model_to_jsonable_dict(presentation)["model_data"][model_to_identifier(presentation)]
+
+ assert snapshot["speakers"] == sorted(model_to_identifier(speaker) for speaker in speakers)
+
+
+def test_model_to_jsonable_dict_sorts_many_to_many_identifiers(db):
+ presentation = baker.make(Presentation)
+ categories = baker.make(PresentationCategory, type=presentation.type, _quantity=3)
+ for category in categories:
+ baker.make(PresentationCategoryRelation, presentation=presentation, category=category)
+
+ snapshot = model_to_jsonable_dict(presentation)["model_data"][model_to_identifier(presentation)]
+
+ assert snapshot["categories"] == sorted(model_to_identifier(category) for category in categories)
+
+
+PRESENTATION_KEY = "mdl:presentation:presentation:7dee621b-c3bb-4404-9900-fd796b03a5a0"
+SPEAKER_A = "mdl:presentation:presentationspeaker:aee66cdf-d110-43e5-904a-41b8a7910923"
+SPEAKER_B = "mdl:presentation:presentationspeaker:1367c0d5-312e-4606-b143-7d5ebbdafee1"
+
+
+def test_get_diff_data_ignores_reordered_relation_identifiers():
+ asis = {PRESENTATION_KEY: {"speakers": [SPEAKER_B, SPEAKER_A]}}
+ tobe = {PRESENTATION_KEY: {"speakers": [SPEAKER_A, SPEAKER_B]}}
+
+ assert get_diff_data_from_jsonized_models(asis, tobe) == {}
+
+
+def test_get_diff_data_detects_changed_relation_members():
+ asis = {PRESENTATION_KEY: {"speakers": [SPEAKER_B]}}
+ tobe = {PRESENTATION_KEY: {"speakers": [SPEAKER_A, SPEAKER_B]}}
+
+ assert get_diff_data_from_jsonized_models(asis, tobe) == {PRESENTATION_KEY: {"speakers": [SPEAKER_A, SPEAKER_B]}}
+
+
+def test_get_diff_data_keeps_order_significant_for_plain_lists():
+ asis = {PRESENTATION_KEY: {"tags": ["b", "a"]}}
+ tobe = {PRESENTATION_KEY: {"tags": ["a", "b"]}}
+
+ assert get_diff_data_from_jsonized_models(asis, tobe) == {PRESENTATION_KEY: {"tags": ["a", "b"]}}
diff --git a/app/core/test/email_backends_test.py b/app/core/test/email_backends_test.py
index 18d2257..12b88db 100644
--- a/app/core/test/email_backends_test.py
+++ b/app/core/test/email_backends_test.py
@@ -140,3 +140,28 @@ def test_missing_username_raises(self, backend):
backend.username = ""
with pytest.raises(SMTPAuthenticationError, match="EMAIL_HOST_USER"):
backend._authenticate_xoauth2()
+
+ def test_334_challenge_sends_empty_response_to_get_final_error(
+ self, backend, google_oauth_record, mock_token_endpoint
+ ):
+ backend.username = "user@example.com"
+ backend.connection = MagicMock()
+ challenge = base64.b64encode(b'{"status":"400","schemes":"Bearer"}')
+ backend.connection.docmd.side_effect = [(334, challenge), (535, b"5.7.8 Bad credentials")]
+
+ with pytest.raises(SMTPAuthenticationError) as e:
+ backend._authenticate_xoauth2()
+
+ assert e.value.smtp_code == 535
+ assert backend.connection.docmd.call_args.args == ("",)
+
+ def test_auth_failure_evicts_cached_token(self, backend, google_oauth_record, mock_token_endpoint):
+ backend.username = "user@example.com"
+ backend.connection = MagicMock()
+ backend.connection.docmd.return_value = (535, b"Auth failed")
+
+ with pytest.raises(SMTPAuthenticationError):
+ backend._authenticate_xoauth2()
+
+ # 캐시가 남아 있으면 만료될 때까지 같은 토큰으로 계속 실패한다.
+ assert google_oauth_record.refresh_token not in _access_token_cache
diff --git a/app/core/test/exception_handler_test.py b/app/core/test/exception_handler_test.py
new file mode 100644
index 0000000..d7d938b
--- /dev/null
+++ b/app/core/test/exception_handler_test.py
@@ -0,0 +1,81 @@
+import pytest
+from core.exception_handler import (
+ CHECK_VIOLATION,
+ EXCLUSION_VIOLATION,
+ UNIQUE_VIOLATION,
+ ConflictError,
+ DBConstraintExceptionHandler,
+)
+from django.db import IntegrityError
+from model_bakery import baker
+from rest_framework import exceptions
+from rest_framework.status import HTTP_409_CONFLICT
+from rest_framework.test import APIClient
+from rest_framework.validators import UniqueValidator
+from user.models import UserExt
+
+NOT_NULL_VIOLATION = "23502"
+
+# 어드민 Event 라우트는 basename 이 공개 Event 라우트("event")와 겹쳐 reverse 로 지목할 수 없다.
+EVENT_ADMIN_LIST_URL = "/v1/admin-api/event/event/"
+
+
+class _PgError(Exception):
+ def __init__(self, sqlstate: str) -> None:
+ super().__init__(sqlstate)
+ self.sqlstate = sqlstate
+
+
+def _integrity_error(sqlstate: str) -> IntegrityError:
+ exc = IntegrityError("constraint violation")
+ exc.__cause__ = _PgError(sqlstate)
+ return exc
+
+
+def _convert(exc: Exception) -> Exception:
+ return DBConstraintExceptionHandler(exc, {}).convert_known_exceptions(exc)
+
+
+@pytest.fixture
+def api_client(db) -> APIClient:
+ client = APIClient()
+ client.force_authenticate(user=UserExt.objects.create_superuser(username="admin", email="a@example.com"))
+ return client
+
+
+@pytest.mark.parametrize("sqlstate", [UNIQUE_VIOLATION, EXCLUSION_VIOLATION])
+def test_conflicting_constraints_become_409(sqlstate):
+ assert isinstance(_convert(_integrity_error(sqlstate)), ConflictError)
+
+
+def test_check_violation_becomes_400():
+ assert isinstance(_convert(_integrity_error(CHECK_VIOLATION)), exceptions.ValidationError)
+
+
+def test_other_violations_stay_unhandled():
+ # NOT NULL·FK 위반은 코드 결함이라 500 으로 노출돼야 한다.
+ exc = _integrity_error(NOT_NULL_VIOLATION)
+
+ assert _convert(exc) is exc
+
+
+def test_integrity_error_without_db_cause_stays_unhandled():
+ exc = IntegrityError("raised by hand")
+
+ assert _convert(exc) is exc
+
+
+@pytest.mark.django_db
+def test_violation_reaching_the_db_returns_409_instead_of_500(api_client, monkeypatch):
+ """검증 통과 후 INSERT 가 제약에 걸리는 경합을 재현 — 실제 psycopg 예외로 409 까지 확인한다."""
+ monkeypatch.setattr(UniqueValidator, "__call__", lambda *args, **kwargs: None)
+ event = baker.make("event.Event", name="파이콘 한국 2026")
+
+ response = api_client.post(
+ EVENT_ADMIN_LIST_URL,
+ {"organization": str(event.organization_id), "name_ko": event.name},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_409_CONFLICT
+ assert response.json()["type"] == "client_error"
diff --git a/app/core/test/fileutil_test.py b/app/core/test/fileutil_test.py
new file mode 100644
index 0000000..8e754b4
--- /dev/null
+++ b/app/core/test/fileutil_test.py
@@ -0,0 +1,38 @@
+import pytest
+from core.util.fileutil import read_uploaded_csv
+from rest_framework import exceptions
+
+_CSV = "name,phone\n홍길동,010-1234-5678\n"
+
+
+@pytest.mark.parametrize("encoding", ["utf-8", "utf-8-sig", "cp949"])
+def test_read_uploaded_csv_reads_supported_encodings(encoding):
+ df = read_uploaded_csv(_CSV.encode(encoding))
+ # utf-8-sig 로 먼저 디코딩하므로 BOM 이 첫 컬럼명에 남지 않는다.
+ assert list(df.columns) == ["name", "phone"]
+ assert df.iloc[0]["name"] == "홍길동"
+
+
+def test_read_uploaded_csv_rejects_unsupported_encoding():
+ # Excel 의 "유니코드 텍스트"(UTF-16) 저장 — UTF-8/CP949 둘 다 실패.
+ with pytest.raises(exceptions.ValidationError) as e:
+ read_uploaded_csv(_CSV.encode("utf-16"))
+ assert "csv_file" in e.value.detail
+
+
+def test_read_uploaded_csv_rejects_malformed_csv():
+ with pytest.raises(exceptions.ValidationError) as e:
+ read_uploaded_csv(b"a,b,c\n1,2,3\n4,5,6,7,8\n")
+ assert "csv_file" in e.value.detail
+
+
+def test_read_uploaded_csv_rejects_empty_file():
+ with pytest.raises(exceptions.ValidationError) as e:
+ read_uploaded_csv(b"")
+ assert "csv_file" in e.value.detail
+
+
+def test_read_uploaded_csv_reports_errors_under_given_field():
+ with pytest.raises(exceptions.ValidationError) as e:
+ read_uploaded_csv(b"", field="attachment")
+ assert "attachment" in e.value.detail
diff --git a/app/core/test/nhn_cloud_sms_test.py b/app/core/test/nhn_cloud_sms_test.py
index b748476..ac8513c 100644
--- a/app/core/test/nhn_cloud_sms_test.py
+++ b/app/core/test/nhn_cloud_sms_test.py
@@ -82,16 +82,10 @@ def test_send_message_long_mms_hits_sender_mms_endpoint_when_title_present(mock_
)
-def test_send_message_template_code_passed_as_template_id_when_truthy(mock_session):
+def test_send_message_never_sends_template_id(mock_session):
+ # 내부 template_code는 NHN SMS 템플릿 ID가 아니므로 전달하면 -2110 (Template is not exist).
nhn_cloud_sms_client.send_message(data=_params(template_code="TEMPLATE-1"))
- sent_body = mock_session.post.call_args.kwargs["json"]
- assert sent_body["templateId"] == "TEMPLATE-1"
-
-
-def test_send_message_template_id_omitted_when_template_code_empty(mock_session):
- nhn_cloud_sms_client.send_message(data=_params(template_code=""))
-
sent_body = mock_session.post.call_args.kwargs["json"]
assert "templateId" not in sent_body
diff --git a/app/core/test/smtp_email_test.py b/app/core/test/smtp_email_test.py
new file mode 100644
index 0000000..92c2710
--- /dev/null
+++ b/app/core/test/smtp_email_test.py
@@ -0,0 +1,130 @@
+import re
+from email.header import decode_header, make_header
+from email.policy import SMTP as SMTP_EMAIL_POLICY
+from smtplib import SMTPSenderRefused, SMTPServerDisconnected
+from unittest.mock import MagicMock, patch
+
+import pytest
+from core.external_apis.__interface__ import SendParameters
+from core.external_apis.smtp_email import EmailClient, email_client
+from django.core import mail
+
+_MAX_ENCODED_WORD_LENGTH = 75 # RFC 2047, 구분자 포함
+
+_LONG_KOREAN_SUBJECT = (
+ "[파이콘 한국] 행사 일주일 전 꼭 확인해 주세요. / [PyCon Korea] One week to go — please check before you come"
+)
+
+
+def _params(**overrides) -> SendParameters:
+ return SendParameters(
+ payload=overrides.pop("payload", {"title": "제목", "body": "본문
"}),
+ send_to=overrides.pop("send_to", "to@example.com"),
+ sent_from=overrides.pop("sent_from", "from@example.com"),
+ template_code=overrides.pop("template_code", ""),
+ )
+
+
+def _sent_subject_header() -> str:
+ # SMTP 백엔드(_send)와 동일하게 policy를 명시로 넘겨 실제 발송되는 헤더를 재현한다.
+ head = mail.outbox[0].message(policy=SMTP_EMAIL_POLICY).as_bytes().split(b"\r\n\r\n")[0].decode()
+ return re.search(r"^Subject:(.*?)(?=^\S+:)", head + "\nX:", re.S | re.M).group(1)
+
+
+@pytest.mark.parametrize(
+ "subject", [_LONG_KOREAN_SUBJECT, "파이콘 한국 티켓 결제가 완료되었습니다!", "ASCII only subject"]
+)
+def test_send_message_subject_encoded_words_are_rfc2047_compliant(subject):
+ email_client.send_message(data=_params(payload={"title": subject, "body": "본문
"}))
+
+ header = _sent_subject_header()
+ encoded_words = [word for word in header.split() if word.startswith("=?")]
+ assert all(len(word) <= _MAX_ENCODED_WORD_LENGTH for word in encoded_words)
+ assert len({word.split("?")[2] for word in encoded_words}) <= 1 # base64/quoted-printable 혼용 금지
+ assert str(make_header(decode_header(header.strip()))) == subject
+
+
+def test_send_message_sends_html_body():
+ email_client.send_message(data=_params(payload={"title": "제목", "body": "본문
"}))
+
+ message = mail.outbox[0]
+ assert message.content_subtype == "html"
+ assert message.body == "본문
"
+ assert message.to == ["to@example.com"]
+
+
+def test_send_message_requires_title():
+ with pytest.raises(ValueError, match="title"):
+ email_client.send_message(data=_params(payload={"body": "본문
"}))
+
+
+def test_send_message_requires_sent_from():
+ with pytest.raises(ValueError, match="sent_from"):
+ email_client.send_message(data=_params(sent_from=""))
+
+
+class TestConnectionReuse:
+ # 메일마다 로그인하면 Gmail이 "454 Too many login attempts"로 차단한다.
+ def test_connection_is_opened_once_and_reused(self):
+ client = EmailClient()
+ connection = MagicMock()
+ connection.send_messages.return_value = 1
+
+ with patch("core.external_apis.smtp_email.get_connection", return_value=connection) as factory:
+ for _ in range(3):
+ client.send_message(data=_params())
+
+ assert factory.call_count == 1
+ assert connection.open.call_count == 1
+ assert connection.send_messages.call_count == 3
+ assert connection.close.call_count == 0
+
+ def test_disconnected_connection_is_reopened_once(self):
+ client = EmailClient()
+ dropped, fresh = MagicMock(), MagicMock()
+ dropped.send_messages.side_effect = SMTPServerDisconnected("idle timeout")
+ fresh.send_messages.return_value = 1
+
+ with patch("core.external_apis.smtp_email.get_connection", side_effect=[dropped, fresh]):
+ client.send_message(data=_params())
+
+ assert fresh.send_messages.call_count == 1
+
+ def test_idle_timeout_on_mail_from_is_retried(self):
+ # Gmail이 유휴 커넥션에 `451 4.4.2 Timeout - closing connection`을 응답하는 경우.
+ client = EmailClient()
+ stale, fresh = MagicMock(), MagicMock()
+ stale.send_messages.side_effect = SMTPSenderRefused(451, b"4.4.2 Timeout - closing connection.", "a@b.c")
+ fresh.send_messages.return_value = 1
+
+ with patch("core.external_apis.smtp_email.get_connection", side_effect=[stale, fresh]):
+ client.send_message(data=_params())
+
+ assert stale.close.call_count == 1
+ assert fresh.send_messages.call_count == 1
+
+ def test_permanent_sender_rejection_is_not_retried(self):
+ client = EmailClient()
+ connection = MagicMock()
+ connection.send_messages.side_effect = SMTPSenderRefused(550, b"5.7.1 Sender denied", "a@b.c")
+
+ with (
+ patch("core.external_apis.smtp_email.get_connection", return_value=connection),
+ pytest.raises(SMTPSenderRefused),
+ ):
+ client.send_message(data=_params())
+
+ assert connection.send_messages.call_count == 1
+
+ def test_repeated_disconnect_propagates(self):
+ client = EmailClient()
+ connection = MagicMock()
+ connection.send_messages.side_effect = SMTPServerDisconnected("gone")
+
+ with (
+ patch("core.external_apis.smtp_email.get_connection", return_value=connection),
+ pytest.raises(SMTPServerDisconnected),
+ ):
+ client.send_message(data=_params())
+
+ assert connection.send_messages.call_count == 2
diff --git a/app/core/urls.py b/app/core/urls.py
index 844274e..f9e345f 100644
--- a/app/core/urls.py
+++ b/app/core/urls.py
@@ -23,6 +23,7 @@
from django.urls import include, path, re_path, resolvers
from django.views.decorators.cache import cache_page
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
+from event.presentation.urls import bookmark_urlpatterns
# type: ignore[assignment]
v1_apis: list[resolvers.URLPattern | resolvers.URLResolver] = [
@@ -30,6 +31,7 @@
path("admin-api/", include("admin_api.urls")),
path("participant-portal/", include("participant_portal_api.urls")),
path("event/presentation/", include("event.presentation.urls")),
+ path("events//presentation-bookmarks/", include(bookmark_urlpatterns)),
path("event/sponsor/", include("event.sponsor.urls")),
path("event/", include("event.urls")),
path("external-api/", include("external_api.urls")),
@@ -44,6 +46,8 @@
SCHEMA_CACHE_TIMEOUT = 60 * 60
urlpatterns = [
+ # Well-Known URI
+ path(".well-known/", include("well_known.urls")),
# Health Check
path("readyz/", readyz),
path("livez/", livez),
@@ -52,6 +56,8 @@
# Django-Allauth
path("accounts/", include("allauth.urls")),
path("authn/social/", include("allauth.headless.urls")),
+ # accounts.pycon.kr
+ path("account/", include("user.urls")),
# V1 API
re_path("^v1/", include((v1_apis, "v1"), namespace="v1")),
# API Docs
diff --git a/app/core/util/django_orm.py b/app/core/util/django_orm.py
index 93a2d5e..dd7a098 100644
--- a/app/core/util/django_orm.py
+++ b/app/core/util/django_orm.py
@@ -12,6 +12,8 @@
from django.db.models.fields.files import FieldFile
from django.forms import model_to_dict
from django.utils.functional import Promise
+from modeltranslation.translator import NotRegistered
+from modeltranslation.translator import translator as modeltranslation_translator
def arbitrary_value_to_basic_type(value: typing.Any) -> str | int | float | bool | None:
@@ -43,6 +45,11 @@ def is_identifier(identifier: str) -> bool:
return isinstance(identifier, str) and identifier.startswith("mdl:") and len(identifier.split(":")) == 4
+def is_identifier_list(value: typing.Any) -> bool:
+ """Check if the given value is a list of model identifiers (a serialized m2m/reverse relation)."""
+ return isinstance(value, list) and all(is_identifier(item) for item in value)
+
+
def identifier_to_model(identifier: str) -> models.Model | None:
if not is_identifier(identifier):
raise ValueError(f"Invalid model identifier: {identifier}")
@@ -124,7 +131,8 @@ def _model_to_jsonable_dict( # noqa: C901
jsonable_value.append(key)
if key not in converted_models:
converted_models[key] = _model_to_jsonable_dict(v, converted_models, None, True)
- jsonable_model_dict[field] = jsonable_value
+ # 관계 조회는 정렬이 없으면 DB 물리 순서를 따르므로, 순서 변동이 변경으로 오인되지 않게 정렬한다.
+ jsonable_model_dict[field] = sorted(jsonable_value)
else:
jsonable_model_dict[field] = [arbitrary_value_to_basic_type(v) for v in value]
else:
@@ -145,7 +153,7 @@ def _model_to_jsonable_dict( # noqa: C901
model_identifier_list.append(key)
if key not in converted_models:
converted_models[key] = _model_to_jsonable_dict(model, converted_models, None, True)
- jsonable_model_dict[leftover_field] = model_identifier_list
+ jsonable_model_dict[leftover_field] = sorted(model_identifier_list)
return jsonable_model_dict
@@ -182,6 +190,9 @@ def get_diff_data_from_jsonized_models(
value_b = model_tobe[field_name]
if value_a == value_b:
continue
+ # 관계 필드는 구성원만 의미가 있으므로, 순서만 다른 경우는 변경으로 보지 않는다.
+ if is_identifier_list(value_a) and is_identifier_list(value_b) and set(value_a) == set(value_b):
+ continue
if value_a is not None and value_b is not None and type(value_a) != type(value_b): # noqa: E721
raise TypeError(
f"Type mismatch for field '{field_name}' in model '{model_identifier}': "
@@ -251,6 +262,21 @@ def json_to_simplenamespace(model_data: dict[str, dict[str, typing.Any]], key: s
return resolved_models[key]
+def _apply_related_diff(manager: models.manager.BaseManager, field_name: str, identifiers: list[str]) -> None:
+ related_model_instances = [identifier_to_model(item) for item in identifiers]
+ if None in related_model_instances:
+ raise ValueError(f"One or more related models not found for identifiers: {identifiers}")
+
+ if {item.pk for item in manager.all()} == {item.pk for item in related_model_instances}:
+ return
+
+ # 역참조 FK의 remove()는 Django가 field.null=True일 때만 제공하므로, 그 외에는 구성원 변경을 적용할 수 없다.
+ if not hasattr(manager, "remove"):
+ raise ValueError(f"Cannot change members of the non-nullable reverse relation '{field_name}'")
+
+ manager.set(related_model_instances)
+
+
def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[models.Model]:
result_instances: list[models.Model] = []
@@ -258,27 +284,27 @@ def apply_diff_to_model(models_data: dict[str, dict[str, typing.Any]]) -> list[m
if not (model_instance := identifier_to_model(model_identifier)):
raise ValueError(f"Model class not found for identifier: {model_identifier}")
+ # 원본 필드는 활성 언어로 라우팅되는 descriptor라 적용 시점의 언어에 좌우되므로, 언어별 컬럼만 적용한다.
+ try:
+ skip_fields = set(modeltranslation_translator.get_options_for_model(type(model_instance)).fields)
+ except NotRegistered:
+ skip_fields = set()
+
# Apply the data to the model instance
for field_name, value in model_data.items():
+ if field_name in skip_fields:
+ continue
+
if is_identifier(value):
# If the value is a model identifier, resolve it to a model instance
if not (related_model_instance := identifier_to_model(value)):
raise ValueError(f"Related model not found for identifier: {value}")
setattr(model_instance, field_name, related_model_instance)
- elif isinstance(value, collections.abc.Iterable) and value and all(is_identifier(item) for item in value):
- # If the value is a list of model identifiers, resolve them to model instances
- related_model_instances = [identifier_to_model(item) for item in value]
- if None in related_model_instances:
- raise ValueError(f"One or more related models not found for identifiers: {value}")
-
- old_related_models = {item.pk: item for item in getattr(model_instance, field_name, [])}
- new_related_models = {item.pk: item for item in related_model_instances}
-
- field = getattr(model_instance, field_name)
- for del_pk in old_related_models.keys() - new_related_models.keys():
- field.remove(old_related_models[del_pk])
- for add_pk in new_related_models.keys() - old_related_models.keys():
- field.add(new_related_models[add_pk])
+ elif is_identifier_list(value) and isinstance(
+ related_manager := getattr(model_instance, field_name, None), models.manager.BaseManager
+ ):
+ # If the value is a list of model identifiers, it is an m2m/reverse relation
+ _apply_related_diff(related_manager, field_name, value)
else:
setattr(model_instance, field_name, value)
diff --git a/app/core/util/fileutil.py b/app/core/util/fileutil.py
new file mode 100644
index 0000000..76fc7eb
--- /dev/null
+++ b/app/core/util/fileutil.py
@@ -0,0 +1,27 @@
+import io
+
+import pandas
+from rest_framework import exceptions
+
+# 한국어 Windows Excel 의 "CSV (쉼표로 분리)" 저장은 CP949 이므로 UTF-8 만으로는 읽을 수 없다.
+# 순서 중요 — UTF-8 을 먼저 시도해야 CP949 가 UTF-8 본문을 깨진 글자로 잘못 읽는 것을 막는다.
+CSV_ENCODINGS = ("utf-8-sig", "cp949")
+
+
+def read_uploaded_csv(raw: bytes, *, field: str = "csv_file") -> pandas.DataFrame:
+ """업로드된 CSV 를 DataFrame 으로 파싱. 인코딩·파싱 실패를 500 대신 400 으로 돌려준다."""
+ for encoding in CSV_ENCODINGS:
+ try:
+ decoded = raw.decode(encoding)
+ break
+ except UnicodeDecodeError:
+ continue
+ else:
+ raise exceptions.ValidationError(
+ {field: "CSV 파일의 문자 인코딩을 인식할 수 없습니다. UTF-8 또는 CP949 로 저장해주세요."}
+ )
+
+ try:
+ return pandas.read_csv(io.StringIO(decoded))
+ except (pandas.errors.ParserError, pandas.errors.EmptyDataError) as e:
+ raise exceptions.ValidationError({field: f"CSV 파일을 읽을 수 없습니다: {e}"}) from e
diff --git a/app/core/util/strutil.py b/app/core/util/strutil.py
index 5b986ce..6f5acfb 100644
--- a/app/core/util/strutil.py
+++ b/app/core/util/strutil.py
@@ -19,6 +19,10 @@ def b64_to_uuid(in_str: str) -> UUID:
return UUID(bytes=urlsafe_b64decode(in_str + "=" * (-len(in_str) % 4)))
+def normalize_email(email: str | None) -> str:
+ return str(email or "").strip().lower()
+
+
def format_korean_date(value: datetime) -> str:
local = value.astimezone(KST)
return f"{local.year}년 {local.month}월 {local.day}일({KOREAN_WEEKDAYS[local.weekday()]})"
diff --git a/app/core/viewset/list_only_filter_viewset.py b/app/core/viewset/list_only_filter_viewset.py
new file mode 100644
index 0000000..29ae7a2
--- /dev/null
+++ b/app/core/viewset/list_only_filter_viewset.py
@@ -0,0 +1,17 @@
+from django.db.models.query import QuerySet
+from rest_framework import viewsets
+
+
+class ListOnlyFilterMixin(viewsets.GenericViewSet):
+ """목록용 필터를 list action 에만 적용한다.
+
+ DRF 의 get_object() 는 filter_queryset() 을 거치므로, 목록 기본 스코프
+ (EventFilterMixin 의 "최신 이벤트" 등)가 단건 조회까지 좁혀 PK 가 유효한데도
+ 404 가 난다. 단건 조회는 PK 로 지목하는 영구 링크이므로 스코프 밖이어도 접근 가능해야 한다.
+ """
+
+ def filter_queryset(self, queryset: QuerySet) -> QuerySet:
+ if self.action != "list":
+ return queryset
+
+ return super().filter_queryset(queryset)
diff --git a/app/event/presentation/filters.py b/app/event/presentation/filters.py
index ff4c3ce..9fb43f9 100644
--- a/app/event/presentation/filters.py
+++ b/app/event/presentation/filters.py
@@ -1,8 +1,16 @@
+from enum import StrEnum
+
from core.models import BaseAbstractModelQuerySet
from django.db.models import Q
from django_filters import rest_framework as filters
from django_filters.constants import EMPTY_VALUES
from event.filters import EventFilterMixin
+from event.presentation.models import PresentationBookmark
+from rest_framework import exceptions
+
+
+class PresentationBookmarkErrorCode(StrEnum):
+ EVENT_NOT_FOUND = "event_not_found"
class PresentationFilterSet(EventFilterMixin):
@@ -14,3 +22,22 @@ def filter_by_type_names(self, queryset: BaseAbstractModelQuerySet, name: str, v
return queryset
return queryset.filter(Q(type__name_ko__in=values) | Q(type__name_en__in=values))
+
+
+class PresentationBookmarkFilterSet(filters.FilterSet):
+ event = filters.UUIDFilter(method="filter_by_event")
+
+ class Meta:
+ model = PresentationBookmark
+ fields = ["event"]
+
+ def filter_by_event(
+ self, queryset: BaseAbstractModelQuerySet, name: str, values: list[str]
+ ) -> BaseAbstractModelQuerySet:
+ filtered_queryset = queryset.filter(presentation__type__event__id=values)
+ if not filtered_queryset.exists():
+ raise exceptions.NotFound(
+ detail="해당 행사 정보가 없습니다.",
+ code=PresentationBookmarkErrorCode.EVENT_NOT_FOUND,
+ )
+ return filtered_queryset
diff --git a/app/event/presentation/migrations/0016_room_order.py b/app/event/presentation/migrations/0016_room_order.py
new file mode 100644
index 0000000..a37310e
--- /dev/null
+++ b/app/event/presentation/migrations/0016_room_order.py
@@ -0,0 +1,6 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [("presentation", "0015_remove_presentationcategory_uq__prst_cat__type__name_name_ko_and_more")]
+ operations = [migrations.AddField(model_name="room", name="order", field=models.IntegerField(default=0))]
diff --git a/app/event/presentation/migrations/0017_presentationbookmark.py b/app/event/presentation/migrations/0017_presentationbookmark.py
new file mode 100644
index 0000000..30576d4
--- /dev/null
+++ b/app/event/presentation/migrations/0017_presentationbookmark.py
@@ -0,0 +1,56 @@
+# Generated by Django 6.0.6 on 2026-07-11 08:37
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("presentation", "0016_room_order"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="PresentationBookmark",
+ fields=[
+ (
+ "id",
+ models.UUIDField(
+ default=uuid.uuid4,
+ editable=False,
+ primary_key=True,
+ serialize=False,
+ ),
+ ),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ (
+ "presentation",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="bookmarks",
+ to="presentation.presentation",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="presentation_bookmarks",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "constraints": [
+ models.UniqueConstraint(
+ fields=("user", "presentation"),
+ name="uq__prst_bkmk__user__presentation",
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/app/event/presentation/models.py b/app/event/presentation/models.py
index e576ea1..e409d79 100644
--- a/app/event/presentation/models.py
+++ b/app/event/presentation/models.py
@@ -26,7 +26,7 @@ def get_all_nested_data(self):
),
models.Prefetch(
lookup="speakers",
- queryset=PresentationSpeaker.objects.filter_active().select_related("user", "image"),
+ queryset=PresentationSpeaker.objects.filter_active().select_related("user__image", "image"),
to_attr="_prefetched_active_speakers",
),
models.Prefetch(
@@ -159,6 +159,11 @@ class PresentationSpeaker(BaseAbstractModel):
image = models.ForeignKey(PublicFile, on_delete=models.PROTECT, null=True, blank=True)
biography = MarkdownField(blank=True, default="")
+ @property
+ def display_image(self) -> PublicFile | None:
+ """발표자 이미지를 따로 등록하지 않았다면 프로필 이미지로 대체한다."""
+ return self.image or self.user.image
+
class CallForPresentationSchedule(BaseAbstractModel):
presentation_type = models.ForeignKey(PresentationType, on_delete=models.PROTECT)
@@ -174,16 +179,18 @@ class Room(BaseAbstractModel):
choices_select_related = ("event",)
choices_meta_schema: ClassVar[dict] = {
"event": {"label": "이벤트", "type": "string", "filter": "select"},
+ "order": {"label": "순서", "type": "number"},
}
event = models.ForeignKey(Event, on_delete=models.PROTECT)
name = models.CharField(max_length=256)
+ order = models.IntegerField(default=0)
def __str__(self) -> str:
return f"[{self.event.name}] {self.name}"
def _choice_meta_fields(self) -> dict:
- return {"event": self.event.name}
+ return {"event": self.event.name, "order": self.order}
class RoomScheduleQuerySet(BaseAbstractModelQuerySet):
@@ -208,3 +215,21 @@ class RoomSchedule(BaseAbstractModel):
def __str__(self) -> str:
return f"[{self.room}] {self.start_at} - {self.end_at} ({self.presentation})"
+
+
+class PresentationBookmark(models.Model):
+ id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
+ user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="presentation_bookmarks")
+ presentation = models.ForeignKey(Presentation, on_delete=models.CASCADE, related_name="bookmarks")
+ created_at = models.DateTimeField(auto_now_add=True)
+
+ class Meta:
+ constraints = [
+ models.UniqueConstraint(
+ fields=["user", "presentation"],
+ name="uq__prst_bkmk__user__presentation",
+ )
+ ]
+
+ def __str__(self) -> str:
+ return f"[Bookmark] {self.user} - {self.presentation}"
diff --git a/app/event/presentation/serializers.py b/app/event/presentation/serializers.py
index 0e9a3b5..de67b10 100644
--- a/app/event/presentation/serializers.py
+++ b/app/event/presentation/serializers.py
@@ -1,13 +1,42 @@
+from typing import Any
+
from event.presentation.models import (
CallForPresentationSchedule,
Presentation,
+ PresentationBookmark,
PresentationCategory,
PresentationSpeaker,
PresentationType,
RoomSchedule,
)
from event.serializers import EventSerializer
-from rest_framework import serializers
+from rest_framework import exceptions, serializers
+
+
+class NotFoundPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
+ def fail(self, key, **kwargs):
+ if key == "does_not_exist":
+ raise exceptions.NotFound("해당 세션 정보가 없습니다.")
+ super().fail(key, **kwargs)
+
+
+class PresentationBookmarkRequestSerializer(serializers.Serializer):
+ presentation_id = NotFoundPrimaryKeyRelatedField(
+ queryset=Presentation.objects.filter(deleted_at__isnull=True),
+ source="presentation",
+ )
+
+ def create(self, validated_data: Any) -> tuple[PresentationBookmark, bool]:
+ validated_data["user"] = self.context["request"].user
+ return PresentationBookmark.objects.get_or_create(**validated_data)
+
+
+class PresentationBookmarkListResponseSerializer(serializers.Serializer):
+ presentation_ids = serializers.ListField(child=serializers.UUIDField())
+
+
+class PresentationBookmarkResponseSerializer(serializers.Serializer):
+ presentation_id = serializers.UUIDField()
class PresentationTypeSerializer(serializers.ModelSerializer):
@@ -26,7 +55,7 @@ class Meta:
class PresentationSpeakerSerializer(serializers.ModelSerializer):
nickname = serializers.CharField(source="user.nickname", read_only=True)
- image = serializers.FileField(source="image.file", read_only=True, allow_null=True)
+ image = serializers.FileField(source="display_image.file", read_only=True, allow_null=True)
class Meta:
model = PresentationSpeaker
@@ -35,10 +64,11 @@ class Meta:
class RoomScheduleSerializer(serializers.ModelSerializer):
room_name = serializers.CharField(source="room.name", read_only=True)
+ room_order = serializers.IntegerField(source="room.order", read_only=True)
class Meta:
model = RoomSchedule
- fields = ("id", "room_name", "start_at", "end_at")
+ fields = ("id", "room_name", "room_order", "start_at", "end_at")
class CallForPresentationScheduleSerializer(serializers.ModelSerializer):
diff --git a/app/event/presentation/test/api_test.py b/app/event/presentation/test/api_test.py
index d88793e..9814d49 100644
--- a/app/event/presentation/test/api_test.py
+++ b/app/event/presentation/test/api_test.py
@@ -7,6 +7,7 @@
from event.models import Event
from event.presentation.models import Presentation, PresentationType
from event.presentation.test.conftest import PresentationTestEntity
+from file.models import PublicFile
from rest_framework.test import APIClient
from user.models.organization import Organization
@@ -16,7 +17,13 @@ def test_presentation_api(api_client: APIClient, create_presentation_set: Presen
url = reverse("v1:presentation-list")
response = api_client.get(url)
assert response.status_code == http.HTTPStatus.OK
- assert len(response.json()) > 0
+ response_data = response.json()
+ assert len(response_data) > 0
+
+ # room_schedules 는 방 정렬(시간표 열 순서)을 위해 Room.order 를 포함한다.
+ room_schedule = response_data[0]["room_schedules"][0]
+ assert room_schedule["room_name"] == create_presentation_set.room.name
+ assert room_schedule["room_order"] == create_presentation_set.room.order
@pytest.mark.django_db
@@ -80,3 +87,76 @@ def test_presentation_defaults_to_latest_event(api_client: APIClient):
response_data = response.json()
assert len(response_data) == 1
assert response_data[0]["id"] == str(new_prst.id)
+
+
+@pytest.mark.django_db
+def test_presentation_retrieve_ignores_latest_event_default(api_client: APIClient):
+ # Given: 지난 행사의 발표가 있고, 그보다 최신인 행사가 존재함.
+ organization = Organization.objects.create(name="Test Organization")
+ old_event = Event.objects.create(
+ organization=organization, name="PyCon Korea 2025", event_start_at=datetime(2025, 8, 1)
+ )
+ Event.objects.create(organization=organization, name="PyCon Korea 2026", event_start_at=datetime(2026, 8, 1))
+
+ old_type = PresentationType.objects.create(event=old_event, name="Talk")
+ old_prst = Presentation.objects.create(type=old_type, title="Old Presentation")
+
+ # When: event 파라미터 없이 지난 행사의 발표를 단건 조회
+ response = api_client.get(reverse("v1:presentation-detail", kwargs={"pk": old_prst.id}))
+
+ # Then: 목록 기본 스코프(최신 행사)와 무관하게 조회된다.
+ assert response.status_code == http.HTTPStatus.OK
+ assert response.json()["id"] == str(old_prst.id)
+
+
+def _make_public_file(name: str) -> PublicFile:
+ return PublicFile.objects.create(file=f"public/{name}.png", mimetype="image/png", hash=name, size=0)
+
+
+def _get_speaker_image(api_client: APIClient) -> str | None:
+ response = api_client.get(reverse("v1:presentation-list"))
+ assert response.status_code == http.HTTPStatus.OK
+ return response.json()[0]["speakers"][0]["image"]
+
+
+@pytest.mark.django_db
+def test_speaker_image_falls_back_to_profile_image(
+ api_client: APIClient, create_presentation_set: PresentationTestEntity
+):
+ # Given: 발표자 이미지는 비어 있고 프로필 이미지만 등록된 발표자
+ user = create_presentation_set.user
+ user.image = _make_public_file("profile")
+ user.save()
+
+ # Then: 프로필 이미지가 대신 노출된다.
+ image_url = _get_speaker_image(api_client)
+ assert image_url is not None and image_url.endswith(user.image.file.url)
+
+
+@pytest.mark.django_db
+def test_speaker_image_takes_precedence_over_profile_image(
+ api_client: APIClient, create_presentation_set: PresentationTestEntity
+):
+ # Given: 발표자 이미지와 프로필 이미지가 모두 등록된 발표자
+ user = create_presentation_set.user
+ user.image = _make_public_file("profile")
+ user.save()
+
+ speaker = create_presentation_set.presentation_speaker
+ speaker.image = _make_public_file("speaker")
+ speaker.save()
+
+ # Then: 발표자 이미지가 우선한다.
+ image_url = _get_speaker_image(api_client)
+ assert image_url is not None and image_url.endswith(speaker.image.file.url)
+
+
+@pytest.mark.django_db
+def test_speaker_image_is_null_without_any_image(
+ api_client: APIClient, create_presentation_set: PresentationTestEntity
+):
+ # Given: 발표자 이미지도 프로필 이미지도 없는 발표자
+ assert create_presentation_set.presentation_speaker.image is None
+ assert create_presentation_set.user.image is None
+
+ assert _get_speaker_image(api_client) is None
diff --git a/app/event/presentation/test/bookmark_api_test.py b/app/event/presentation/test/bookmark_api_test.py
new file mode 100644
index 0000000..6ef3f11
--- /dev/null
+++ b/app/event/presentation/test/bookmark_api_test.py
@@ -0,0 +1,611 @@
+import http
+import uuid
+from datetime import datetime
+
+import pytest
+from django.urls import reverse
+from event.models import Event
+from event.presentation.models import Presentation, PresentationBookmark, PresentationType
+from model_bakery import baker
+from rest_framework.test import APIClient
+from user.models.organization import Organization
+from user.models.user import UserExt
+
+# ──────────────────────────────────────────────
+# Fixtures
+# ──────────────────────────────────────────────
+
+
+@pytest.fixture
+def user(db) -> UserExt:
+ return baker.make(UserExt)
+
+
+@pytest.fixture
+def other_user(db) -> UserExt:
+ return baker.make(UserExt)
+
+
+@pytest.fixture
+def authed_client(user: UserExt) -> APIClient:
+ client = APIClient()
+ client.force_authenticate(user=user)
+ return client
+
+
+@pytest.fixture
+def anon_client() -> APIClient:
+ return APIClient()
+
+
+@pytest.fixture
+def organization(db) -> Organization:
+ return baker.make(Organization)
+
+
+@pytest.fixture
+def event(organization: Organization) -> Event:
+ return Event.objects.create(
+ organization=organization,
+ name="파이콘 한국 2026",
+ event_start_at=datetime(2026, 8, 15),
+ )
+
+
+@pytest.fixture
+def other_event(organization: Organization) -> Event:
+ return Event.objects.create(
+ organization=organization,
+ name="파이콘 한국 2025",
+ event_start_at=datetime(2025, 8, 15),
+ )
+
+
+@pytest.fixture
+def presentation_type(event: Event) -> PresentationType:
+ return PresentationType.objects.create(event=event, name="Talk")
+
+
+@pytest.fixture
+def presentation(presentation_type: PresentationType) -> Presentation:
+ return Presentation.objects.create(type=presentation_type, title="Django 심화")
+
+
+@pytest.fixture
+def presentation_2(presentation_type: PresentationType) -> Presentation:
+ return Presentation.objects.create(type=presentation_type, title="FastAPI 입문")
+
+
+@pytest.fixture
+def other_event_presentation(other_event: Event) -> Presentation:
+ pt = PresentationType.objects.create(event=other_event, name="Talk")
+ return Presentation.objects.create(type=pt, title="작년 발표")
+
+
+# ──────────────────────────────────────────────
+# Helper
+# ──────────────────────────────────────────────
+
+
+def list_url(event_id: uuid.UUID | str) -> str:
+ return reverse("v1:presentation-bookmark-list", kwargs={"event_id": str(event_id)})
+
+
+def detail_url(event_id: uuid.UUID | str, presentation_id: uuid.UUID | str) -> str:
+ return reverse(
+ "v1:presentation-bookmark-detail",
+ kwargs={"event_id": str(event_id), "presentation_id": str(presentation_id)},
+ )
+
+
+# ══════════════════════════════════════════════
+# GET /v1/events/{event_id}/presentation-bookmarks/
+# ══════════════════════════════════════════════
+
+
+class TestBookmarkList:
+ """GET 북마크 목록 조회 API 테스트"""
+
+ @pytest.mark.django_db
+ def test_returns_empty_list_when_no_bookmarks(self, authed_client: APIClient, event: Event):
+ """
+ 북마크가 하나도 없을 때 빈 배열을 반환하는지 검증합니다.
+ 프론트에서 빈 상태 UI를 렌더링하기 위해 빈 배열이 정상 응답이어야 합니다.
+ """
+ response = authed_client.get(list_url(event.id))
+
+ assert response.status_code == http.HTTPStatus.OK
+ assert response.json()["presentation_ids"] == []
+
+ @pytest.mark.django_db
+ def test_returns_bookmarked_presentation_ids(
+ self,
+ authed_client: APIClient,
+ user: UserExt,
+ event: Event,
+ presentation: Presentation,
+ presentation_2: Presentation,
+ ):
+ """
+ 유저가 북마크한 세션의 presentation_id 목록이 정확히 반환되는지 검증합니다.
+ 프론트는 이 ID 목록을 전체 세션 목록과 매칭해서 내 시간표를 구성합니다.
+ """
+ PresentationBookmark.objects.create(user=user, presentation=presentation)
+ PresentationBookmark.objects.create(user=user, presentation=presentation_2)
+
+ response = authed_client.get(list_url(event.id))
+
+ assert response.status_code == http.HTTPStatus.OK
+ returned_ids = set(response.json()["presentation_ids"])
+ assert returned_ids == {str(presentation.id), str(presentation_2.id)}
+
+ @pytest.mark.django_db
+ def test_filters_by_event_id(
+ self,
+ authed_client: APIClient,
+ user: UserExt,
+ event: Event,
+ other_event: Event,
+ presentation: Presentation,
+ other_event_presentation: Presentation,
+ ):
+ """
+ event_id path로 필터링했을 때, 해당 행사의 북마크만 반환되는지 검증합니다.
+ 다른 행사의 북마크가 섞여 나오면 안 됩니다.
+ """
+ PresentationBookmark.objects.create(user=user, presentation=presentation)
+ PresentationBookmark.objects.create(user=user, presentation=other_event_presentation)
+
+ response = authed_client.get(list_url(event.id))
+
+ assert response.status_code == http.HTTPStatus.OK
+ returned_ids = response.json()["presentation_ids"]
+ assert len(returned_ids) == 1
+ assert returned_ids[0] == str(presentation.id)
+
+ @pytest.mark.django_db
+ def test_does_not_return_other_users_bookmarks(
+ self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 다른 유저가 북마크한 세션이 현재 유저의 목록에 포함되지 않는지 검증합니다.
+ 북마크는 유저별로 완전히 격리되어야 합니다.
+ """
+ PresentationBookmark.objects.create(user=other_user, presentation=presentation)
+
+ response = authed_client.get(list_url(event.id))
+
+ assert response.status_code == http.HTTPStatus.OK
+ assert response.json()["presentation_ids"] == []
+
+ @pytest.mark.django_db
+ def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event):
+ """
+ 로그인하지 않은 유저가 요청하면 403 Forbidden을 반환하는지 검증합니다.
+ DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고,
+ IsAuthenticated 권한 체크에서 403을 반환합니다.
+ 프론트는 403을 받으면 로그인 모달을 띄웁니다.
+ """
+ response = anon_client.get(list_url(event.id))
+
+ assert response.status_code == http.HTTPStatus.FORBIDDEN
+
+ @pytest.mark.django_db
+ def test_nonexistent_event_returns_404(self, authed_client: APIClient):
+ """
+ 존재하지 않는 event_id로 요청하면 404를 반환하는지 검증합니다.
+ """
+ response = authed_client.get(list_url(uuid.uuid4()))
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+
+
+# ══════════════════════════════════════════════
+# POST /v1/events/{event_id}/presentation-bookmarks/
+# ══════════════════════════════════════════════
+
+
+class TestBookmarkCreate:
+ """POST 북마크 추가 API 테스트"""
+
+ @pytest.mark.django_db
+ def test_creates_bookmark_and_returns_201(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 새로운 세션을 북마크하면 201 Created와 함께
+ 해당 presentation_id가 응답으로 반환되는지 검증합니다.
+ DB에 북마크 레코드가 실제로 생성되었는지도 확인합니다.
+ """
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": str(presentation.id)},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.CREATED
+ assert response.json()["presentation_id"] == str(presentation.id)
+ assert PresentationBookmark.objects.filter(user=user, presentation=presentation).exists()
+
+ @pytest.mark.django_db
+ def test_sets_event_from_presentation(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 북마크 생성 시 presentation의 type.event로부터 event를 자동 설정하는지 검증합니다.
+ 프론트는 POST 시 event를 명시적으로 보내지 않으므로, 서버가 presentation에서 파생해야 합니다.
+ """
+ authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json")
+
+ bookmark = PresentationBookmark.objects.get(user=user, presentation=presentation)
+ assert bookmark.presentation.type.event.id == event.id
+
+ @pytest.mark.django_db
+ def test_duplicate_bookmark_returns_200_idempotent(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 이미 북마크한 세션을 다시 추가하면 에러 대신 200 OK를 반환하는지 검증합니다.
+ (멱등성) 프론트의 되돌리기/연타로 중복 요청이 발생할 수 있으므로,
+ 두 번째 요청도 정상 응답이어야 합니다.
+ """
+ PresentationBookmark.objects.create(user=user, presentation=presentation)
+
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": str(presentation.id)},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.OK
+ assert response.json()["presentation_id"] == str(presentation.id)
+ # 중복 레코드가 생성되지 않았는지 확인
+ assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1
+
+ @pytest.mark.django_db
+ def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event):
+ """
+ 존재하지 않는 presentation_id로 북마크를 추가하면 404를 반환하는지 검증합니다.
+ """
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": str(uuid.uuid4())},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+
+ @pytest.mark.django_db
+ def test_soft_deleted_presentation_returns_404(
+ self, authed_client: APIClient, event: Event, presentation: Presentation
+ ):
+ """
+ 소프트 삭제된(deleted_at이 설정된) presentation을 북마크하려고 하면
+ 404를 반환하는지 검증합니다.
+ filter_active()가 삭제된 레코드를 제외해야 합니다.
+ """
+ presentation.delete()
+
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": str(presentation.id)},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+
+ @pytest.mark.django_db
+ def test_invalid_presentation_id_format_returns_400(self, authed_client: APIClient, event: Event):
+ """
+ presentation_id에 UUID가 아닌 값을 보내면 400 Bad Request를 반환하는지 검증합니다.
+ serializer의 UUIDField 유효성 검사가 동작해야 합니다.
+ """
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": "not-a-uuid"},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+ @pytest.mark.django_db
+ def test_missing_presentation_id_returns_400(self, authed_client: APIClient, event: Event):
+ """
+ 요청 바디에 presentation 필드가 없으면 400 Bad Request를 반환하는지 검증합니다.
+ """
+ response = authed_client.post(list_url(event.id), data={}, format="json")
+
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+
+ @pytest.mark.django_db
+ def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, presentation: Presentation):
+ """
+ 로그인하지 않은 유저가 북마크 추가를 시도하면 403 Forbidden을 반환하는지 검증합니다.
+ DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고,
+ IsAuthenticated 권한 체크에서 403을 반환합니다.
+ """
+ response = anon_client.post(
+ list_url(event.id),
+ data={"presentation_id": str(presentation.id)},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.FORBIDDEN
+
+ @pytest.mark.django_db
+ def test_allows_overlapping_time_sessions(
+ self,
+ authed_client: APIClient,
+ user: UserExt,
+ event: Event,
+ presentation: Presentation,
+ presentation_2: Presentation,
+ ):
+ """
+ 시간이 겹치는 세션들도 모두 북마크할 수 있는지 검증합니다.
+ UX 확정 사항: 겹침 경고는 프론트가 처리하고, 서버는 시간대 충돌을 검사하지 않습니다.
+ """
+ resp1 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json")
+ resp2 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation_2.id)}, format="json")
+
+ assert resp1.status_code == http.HTTPStatus.CREATED
+ assert resp2.status_code == http.HTTPStatus.CREATED
+ assert PresentationBookmark.objects.filter(user=user).count() == 2
+
+ @pytest.mark.django_db
+ def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentation: Presentation):
+ """
+ 존재하지 않는 event_id로 북마크를 추가하면 404를 반환하는지 검증합니다.
+ """
+ response = authed_client.post(
+ list_url(uuid.uuid4()),
+ data={"presentation_id": str(presentation.id)},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+
+
+# ══════════════════════════════════════════════
+# DELETE /v1/events/{event_id}/presentation-bookmarks/{presentation_id}/
+# ══════════════════════════════════════════════
+
+
+class TestBookmarkDestroy:
+ """DELETE 북마크 삭제 API 테스트"""
+
+ @pytest.mark.django_db
+ def test_deletes_bookmark_and_returns_204(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 북마크된 세션을 삭제하면 204 No Content를 반환하고,
+ DB에서 실제로 레코드가 삭제(hard delete)되는지 검증합니다.
+ """
+ PresentationBookmark.objects.create(user=user, presentation=presentation)
+
+ response = authed_client.delete(detail_url(event.id, presentation.id))
+
+ assert response.status_code == http.HTTPStatus.NO_CONTENT
+ assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists()
+
+ @pytest.mark.django_db
+ def test_not_bookmarked_returns_204_idempotent(
+ self, authed_client: APIClient, event: Event, presentation: Presentation
+ ):
+ """
+ 유저가 북마크하지 않은 세션에 대해 삭제를 요청해도
+ 에러 대신 204를 반환하는지 검증합니다. (멱등성)
+ 프론트의 되돌리기 UX에서 DELETE 직후 같은 세션에 POST가 오고,
+ 다시 DELETE가 올 수 있으므로 멱등이어야 합니다.
+ """
+ response = authed_client.delete(detail_url(event.id, presentation.id))
+
+ assert response.status_code == http.HTTPStatus.NO_CONTENT
+
+ @pytest.mark.django_db
+ def test_nonexistent_presentation_returns_404(self, authed_client: APIClient, event: Event):
+ """
+ 존재하지 않는 presentation_id로 삭제를 요청하면 404를 반환하는지 검증합니다.
+ presentation 자체가 DB에 없는 경우만 404이고,
+ presentation은 있지만 북마크가 없는 경우는 204입니다.
+ """
+ response = authed_client.delete(detail_url(event.id, uuid.uuid4()))
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+
+ @pytest.mark.django_db
+ def test_does_not_delete_other_users_bookmark(
+ self, authed_client: APIClient, other_user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 삭제 요청이 다른 유저의 북마크에 영향을 주지 않는지 검증합니다.
+ user.presentation 필터로 현재 유저의 북마크만 삭제해야 합니다.
+ """
+ PresentationBookmark.objects.create(user=other_user, presentation=presentation)
+
+ response = authed_client.delete(detail_url(event.id, presentation.id))
+
+ assert response.status_code == http.HTTPStatus.NO_CONTENT
+ # 다른 유저의 북마크는 그대로 남아 있어야 함
+ assert PresentationBookmark.objects.filter(user=other_user, presentation=presentation).exists()
+
+ @pytest.mark.django_db
+ def test_unauthenticated_returns_403(self, anon_client: APIClient, event: Event, presentation: Presentation):
+ """
+ 로그인하지 않은 유저가 북마크 삭제를 시도하면 403 Forbidden을 반환하는지 검증합니다.
+ DRF SessionAuthentication은 쿠키 없는 요청을 AnonymousUser로 통과시키고,
+ IsAuthenticated 권한 체크에서 403을 반환합니다.
+ """
+ response = anon_client.delete(detail_url(event.id, presentation.id))
+
+ assert response.status_code == http.HTTPStatus.FORBIDDEN
+
+ @pytest.mark.django_db
+ def test_only_deletes_specified_bookmark(
+ self,
+ authed_client: APIClient,
+ user: UserExt,
+ event: Event,
+ presentation: Presentation,
+ presentation_2: Presentation,
+ ):
+ """
+ 특정 세션 하나를 삭제할 때, 유저의 다른 북마크는 유지되는지 검증합니다.
+ 삭제 범위가 정확히 요청된 presentation에만 한정되어야 합니다.
+ """
+ PresentationBookmark.objects.create(user=user, presentation=presentation)
+ PresentationBookmark.objects.create(user=user, presentation=presentation_2)
+
+ authed_client.delete(detail_url(event.id, presentation.id))
+
+ assert not PresentationBookmark.objects.filter(user=user, presentation=presentation).exists()
+ assert PresentationBookmark.objects.filter(user=user, presentation=presentation_2).exists()
+
+ @pytest.mark.django_db
+ def test_nonexistent_event_returns_404(self, authed_client: APIClient, presentation: Presentation):
+ """
+ 존재하지 않는 event_id로 삭제를 요청하면 404를 반환하는지 검증합니다.
+ """
+ response = authed_client.delete(detail_url(uuid.uuid4(), presentation.id))
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+
+
+# ══════════════════════════════════════════════
+# 통합 시나리오: 담기 → 빼기 → 되돌리기 (POST → DELETE → POST)
+# ══════════════════════════════════════════════
+
+
+class TestBookmarkUndoFlow:
+ """프론트의 '담기 → 빼기 → 되돌리기' 시나리오 통합 테스트"""
+
+ @pytest.mark.django_db
+ def test_add_remove_re_add_flow(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 프론트의 실제 사용 시나리오를 재현합니다:
+ 1. 세션 담기 (POST) → 201
+ 2. 세션 빼기 (DELETE) → 204
+ 3. 되돌리기 (POST 재전송) → 201
+ 각 단계에서 GET으로 목록을 조회해 상태가 올바른지 확인합니다.
+ """
+ # 1단계: 담기
+ resp = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json")
+ assert resp.status_code == http.HTTPStatus.CREATED
+
+ # GET으로 확인: 1개
+ resp = authed_client.get(list_url(event.id))
+ assert len(resp.json()["presentation_ids"]) == 1
+
+ # 2단계: 빼기
+ resp = authed_client.delete(detail_url(event.id, presentation.id))
+ assert resp.status_code == http.HTTPStatus.NO_CONTENT
+
+ # GET으로 확인: 0개
+ resp = authed_client.get(list_url(event.id))
+ assert len(resp.json()["presentation_ids"]) == 0
+
+ # 3단계: 되돌리기 (다시 POST)
+ resp = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json")
+ assert resp.status_code == http.HTTPStatus.CREATED
+
+ # GET으로 확인: 다시 1개
+ resp = authed_client.get(list_url(event.id))
+ assert len(resp.json()["presentation_ids"]) == 1
+
+ @pytest.mark.django_db
+ def test_rapid_double_delete_is_safe(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 같은 세션에 대해 DELETE가 빠르게 2번 연속 호출되어도
+ 두 번째 요청이 에러 없이 204를 반환하는지 검증합니다.
+ 네트워크 재시도나 프론트 연타로 발생할 수 있는 시나리오입니다.
+ """
+ PresentationBookmark.objects.create(user=user, presentation=presentation)
+
+ resp1 = authed_client.delete(detail_url(event.id, presentation.id))
+ resp2 = authed_client.delete(detail_url(event.id, presentation.id))
+
+ assert resp1.status_code == http.HTTPStatus.NO_CONTENT
+ assert resp2.status_code == http.HTTPStatus.NO_CONTENT
+
+ @pytest.mark.django_db
+ def test_rapid_double_post_is_safe(
+ self, authed_client: APIClient, user: UserExt, event: Event, presentation: Presentation
+ ):
+ """
+ 같은 세션에 대해 POST가 빠르게 2번 연속 호출되어도
+ 두 번째 요청이 에러 없이 200을 반환하고 중복 레코드가 생기지 않는지 검증합니다.
+ """
+ resp1 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json")
+ resp2 = authed_client.post(list_url(event.id), data={"presentation_id": str(presentation.id)}, format="json")
+
+ assert resp1.status_code == http.HTTPStatus.CREATED
+ assert resp2.status_code == http.HTTPStatus.OK
+ assert PresentationBookmark.objects.filter(user=user, presentation=presentation).count() == 1
+
+
+# ══════════════════════════════════════════════
+# 에러 응답 포맷 검증
+# ══════════════════════════════════════════════
+
+
+class TestErrorResponseFormat:
+ """에러 응답이 drf-standardized-errors의 공통 envelope 포맷을 따르는지 검증"""
+
+ @pytest.mark.django_db
+ def test_403_follows_error_envelope(self, anon_client: APIClient, event: Event):
+ """
+ 403 응답이 프론트의 ErrorResponseSchema와 호환되는
+ { "type": "...", "errors": [{ "code": "...", "detail": "...", "attr": ... }] }
+ 포맷인지 검증합니다.
+ 프론트는 errors[0].code로 에러 종류를 분기합니다.
+ DRF SessionAuthentication + IsAuthenticated 조합에서
+ 미인증 요청은 403 + code: "not_authenticated"로 응답합니다.
+ """
+ response = anon_client.get(list_url(event.id))
+
+ assert response.status_code == http.HTTPStatus.FORBIDDEN
+ body = response.json()
+ assert "type" in body
+ assert "errors" in body
+ assert len(body["errors"]) > 0
+ assert "code" in body["errors"][0]
+ assert "detail" in body["errors"][0]
+ assert body["errors"][0]["code"] == "not_authenticated"
+
+ @pytest.mark.django_db
+ def test_404_follows_error_envelope(self, authed_client: APIClient, event: Event):
+ """
+ 404 응답이 공통 에러 envelope 포맷을 따르는지 검증합니다.
+ """
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": str(uuid.uuid4())},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.NOT_FOUND
+ body = response.json()
+ assert body["type"] == "client_error"
+ assert "errors" in body
+ assert body["errors"][0]["code"] == "not_found"
+
+ @pytest.mark.django_db
+ def test_400_validation_error_follows_envelope(self, authed_client: APIClient, event: Event):
+ """
+ 400 유효성 검사 에러가 공통 에러 envelope 포맷을 따르는지 검증합니다.
+ """
+ response = authed_client.post(
+ list_url(event.id),
+ data={"presentation_id": "invalid"},
+ format="json",
+ )
+
+ assert response.status_code == http.HTTPStatus.BAD_REQUEST
+ body = response.json()
+ assert body["type"] == "validation_error"
+ assert "errors" in body
diff --git a/app/event/presentation/urls.py b/app/event/presentation/urls.py
index d47dec0..7118cb6 100644
--- a/app/event/presentation/urls.py
+++ b/app/event/presentation/urls.py
@@ -1,20 +1,3 @@
-"""
-URL configuration for core project.
-
-The `urlpatterns` list routes URLs to views. For more information please see:
- https://docs.djangoproject.com/en/5.2/topics/http/urls/
-Examples:
-Function views
- 1. Add an import: from my_app import views
- 2. Add a URL to urlpatterns: path('', views.home, name='home')
-Class-based views
- 1. Add an import: from other_app.views import Home
- 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
-Including another URLconf
- 1. Import the include() function: from django.urls import include, path
- 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
-"""
-
from django.urls import include, path
from event.presentation import views
from rest_framework import routers
@@ -23,4 +6,13 @@
cms_router.register("category", views.PresentationCategoryViewSet, basename="presentation-category")
cms_router.register("", views.PresentationViewSet, basename="presentation")
-urlpatterns = [path("", include(cms_router.urls))]
+bookmark_router = routers.SimpleRouter()
+bookmark_router.register("", views.PresentationBookmarkViewSet, basename="presentation-bookmark")
+
+urlpatterns = [
+ path("", include(cms_router.urls)),
+]
+
+bookmark_urlpatterns = [
+ path("", include(bookmark_router.urls)),
+]
diff --git a/app/event/presentation/views.py b/app/event/presentation/views.py
index 130e94d..619cd56 100644
--- a/app/event/presentation/views.py
+++ b/app/event/presentation/views.py
@@ -1,10 +1,24 @@
from core.const.tag import OpenAPITag
+from core.viewset.list_only_filter_viewset import ListOnlyFilterMixin
+from django.db.models.query import QuerySet
+from django.shortcuts import get_object_or_404
from django.utils.decorators import method_decorator
from drf_spectacular.utils import extend_schema
+from drf_standardized_errors.openapi_serializers import (
+ ErrorResponse401Serializer,
+ ErrorResponse404Serializer,
+)
+from event.models import Event
from event.presentation.filters import PresentationFilterSet
-from event.presentation.models import Presentation, PresentationCategory
-from event.presentation.serializers import PresentationCategorySerializer, PresentationSerializer
-from rest_framework import mixins, viewsets
+from event.presentation.models import Presentation, PresentationBookmark, PresentationCategory
+from event.presentation.serializers import (
+ PresentationBookmarkListResponseSerializer,
+ PresentationBookmarkRequestSerializer,
+ PresentationBookmarkResponseSerializer,
+ PresentationCategorySerializer,
+ PresentationSerializer,
+)
+from rest_framework import exceptions, mixins, permissions, request, response, status, viewsets
@method_decorator(name="list", decorator=extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION]))
@@ -15,7 +29,71 @@ class PresentationCategoryViewSet(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):
+class PresentationViewSet(
+ ListOnlyFilterMixin,
+ mixins.ListModelMixin,
+ mixins.RetrieveModelMixin,
+ viewsets.GenericViewSet,
+):
queryset = Presentation.objects.get_all_nested_data()
serializer_class = PresentationSerializer
filterset_class = PresentationFilterSet
+
+
+@extend_schema(tags=[OpenAPITag.EVENT_PRESENTATION_BOOKMARK])
+class PresentationBookmarkViewSet(viewsets.GenericViewSet):
+ queryset = PresentationBookmark.objects.all()
+ permission_classes = [permissions.IsAuthenticated]
+ serializer_class = PresentationBookmarkRequestSerializer
+ lookup_field = "presentation_id"
+
+ def initial(self, request, *args, **kwargs):
+ super().initial(request, *args, **kwargs)
+ get_object_or_404(Event, id=self.kwargs["event_id"])
+
+ def get_queryset(self) -> QuerySet:
+ return (
+ super().get_queryset().filter(user=self.request.user, presentation__type__event_id=self.kwargs["event_id"])
+ )
+
+ def destroy(self, request: request.Request, **kwargs) -> response.Response:
+ presentation_id = self.kwargs["presentation_id"]
+ if not Presentation.objects.filter(id=presentation_id, deleted_at__isnull=True).exists():
+ raise exceptions.NotFound("해당 세션 정보가 없습니다.")
+ self.get_queryset().filter(presentation_id=presentation_id).delete()
+ return response.Response(status=status.HTTP_204_NO_CONTENT)
+
+ @extend_schema(
+ summary="북마크 목록 조회",
+ parameters=[],
+ responses={
+ status.HTTP_200_OK: PresentationBookmarkListResponseSerializer,
+ status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer,
+ status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer,
+ },
+ )
+ def list(self, request: request.Request, **kwargs) -> response.Response:
+ queryset = self.get_queryset()
+ presentation_ids = list(queryset.values_list("presentation_id", flat=True))
+ return response.Response({"presentation_ids": presentation_ids})
+
+ @extend_schema(
+ summary="북마크 추가",
+ request=PresentationBookmarkRequestSerializer,
+ responses={
+ status.HTTP_201_CREATED: PresentationBookmarkResponseSerializer,
+ status.HTTP_200_OK: PresentationBookmarkResponseSerializer,
+ status.HTTP_401_UNAUTHORIZED: ErrorResponse401Serializer,
+ status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer,
+ },
+ )
+ def create(self, request: request.Request, **kwargs) -> response.Response:
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ instance, created = serializer.create(serializer.validated_data)
+ serializer.save()
+
+ return response.Response(
+ {"presentation_id": str(instance.presentation_id)},
+ status=status.HTTP_201_CREATED if created else status.HTTP_200_OK,
+ )
diff --git a/app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py b/app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py
new file mode 100644
index 0000000..21899d0
--- /dev/null
+++ b/app/event/sponsor/migrations/0019_historicalsponsortag_color_sponsortag_color.py
@@ -0,0 +1,30 @@
+import core.models
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+ dependencies = [("sponsor", "0018_remove_sponsor_uq__spsr__name_name_ko_and_more")]
+ operations = [
+ migrations.AddField(
+ model_name="historicalsponsortag",
+ name="color",
+ field=core.models.ColorField(
+ blank=True,
+ default=None,
+ help_text="태그 표시 색상 (예: #3498db). 지정하지 않으면 프론트엔드 기본색을 사용합니다.",
+ max_length=7,
+ null=True,
+ ),
+ ),
+ migrations.AddField(
+ model_name="sponsortag",
+ name="color",
+ field=core.models.ColorField(
+ blank=True,
+ default=None,
+ help_text="태그 표시 색상 (예: #3498db). 지정하지 않으면 프론트엔드 기본색을 사용합니다.",
+ max_length=7,
+ null=True,
+ ),
+ ),
+ ]
diff --git a/app/event/sponsor/models.py b/app/event/sponsor/models.py
index 87b5146..319d466 100644
--- a/app/event/sponsor/models.py
+++ b/app/event/sponsor/models.py
@@ -3,7 +3,7 @@
import functools
import typing
-from core.models import BaseAbstractModel, MarkdownField
+from core.models import BaseAbstractModel, ColorField, MarkdownField
from django.db import models
from event.models import Event
@@ -95,6 +95,12 @@ class SponsorTag(BaseAbstractModel):
event = models.ForeignKey(Event, on_delete=models.PROTECT)
name = models.CharField(max_length=256)
+ color = ColorField(
+ null=True,
+ blank=True,
+ default=None,
+ help_text="태그 표시 색상 (예: #3498db). 지정하지 않으면 프론트엔드 기본색을 사용합니다.",
+ )
class Meta:
ordering = ["name"]
diff --git a/app/event/sponsor/serializers.py b/app/event/sponsor/serializers.py
index 03aa076..0ad1f72 100644
--- a/app/event/sponsor/serializers.py
+++ b/app/event/sponsor/serializers.py
@@ -1,10 +1,16 @@
-from event.sponsor.models import Sponsor, SponsorTier
+from event.sponsor.models import Sponsor, SponsorTag, SponsorTier
from rest_framework import serializers
+class SponsorTagSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = SponsorTag
+ fields = ("id", "name", "color")
+
+
class SponsorSerializer(serializers.ModelSerializer):
logo = serializers.FileField(source="logo.file", read_only=True)
- tags = serializers.StringRelatedField(many=True, read_only=True)
+ tags = SponsorTagSerializer(many=True, read_only=True)
class Meta:
model = Sponsor
diff --git a/app/event/sponsor/test/api_test.py b/app/event/sponsor/test/api_test.py
index 1baf037..5fe6380 100644
--- a/app/event/sponsor/test/api_test.py
+++ b/app/event/sponsor/test/api_test.py
@@ -4,7 +4,7 @@
import pytest
from django.urls import reverse
from event.models import Event
-from event.sponsor.models import Sponsor, SponsorTier, SponsorTierSponsorRelation
+from event.sponsor.models import Sponsor, SponsorTag, SponsorTagRelation, SponsorTier, SponsorTierSponsorRelation
from file.models import PublicFile
from rest_framework.test import APIClient
from user.models.organization import Organization
@@ -80,6 +80,32 @@ def test_sponsor_filter_by_event_name(api_client: APIClient, two_events):
assert response_data[0]["id"] == str(old_tier.id)
+@pytest.mark.django_db
+def test_sponsor_tags_include_color(api_client: APIClient, two_events):
+ _, new_event = two_events
+ tier = SponsorTier.objects.create(event=new_event, name="Gold", order=0)
+ sponsor = _make_sponsor(new_event, "New Sponsor", tier)
+
+ # Given: 색상이 있는 태그와 없는 태그, 그리고 삭제된 태그가 붙어있음
+ colored_tag = SponsorTag.objects.create(event=new_event, name="Keynote", color="#3498db")
+ plain_tag = SponsorTag.objects.create(event=new_event, name="Community")
+ deleted_tag = SponsorTag.objects.create(event=new_event, name="Deleted")
+ for tag in (colored_tag, plain_tag, deleted_tag):
+ SponsorTagRelation.objects.create(sponsor=sponsor, tag=tag)
+ SponsorTag.objects.filter(pk=deleted_tag.pk).delete()
+
+ # When: 후원사 목록을 조회
+ response = api_client.get(reverse("v1:sponsor-list"))
+
+ # Then: 살아있는 태그만 id/name/color 형태로 반환
+ assert response.status_code == http.HTTPStatus.OK
+ tags = response.json()[0]["sponsors"][0]["tags"]
+ assert sorted(tags, key=lambda t: t["name"]) == [
+ {"id": str(plain_tag.id), "name": "Community", "color": None},
+ {"id": str(colored_tag.id), "name": "Keynote", "color": "#3498db"},
+ ]
+
+
@pytest.mark.django_db
def test_sponsor_no_events_returns_empty(api_client: APIClient):
# When: 이벤트가 없을 때 요청
diff --git a/app/event/sponsor/views.py b/app/event/sponsor/views.py
index 7bd3b1e..8fd23fc 100644
--- a/app/event/sponsor/views.py
+++ b/app/event/sponsor/views.py
@@ -1,19 +1,22 @@
from core.const.tag import OpenAPITag
+from core.viewset.list_only_filter_viewset import ListOnlyFilterMixin
from django.db import models
from django.utils.decorators import method_decorator
from drf_spectacular.utils import extend_schema
from event.sponsor.filters import SponsorTierFilterSet
-from event.sponsor.models import Sponsor, SponsorTier
+from event.sponsor.models import Sponsor, SponsorTag, 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):
+class SponsorTierViewSet(ListOnlyFilterMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
queryset = SponsorTier.objects.filter_active().prefetch_related(
models.Prefetch(
lookup="sponsors",
- queryset=Sponsor.objects.filter_active().select_related("logo"),
+ queryset=Sponsor.objects.filter_active()
+ .select_related("logo")
+ .prefetch_related(models.Prefetch(lookup="tags", queryset=SponsorTag.objects.filter_active())),
to_attr="_prefetched_active_sponsors",
)
)
diff --git a/app/internal_api/filters.py b/app/internal_api/filters.py
deleted file mode 100644
index 476566f..0000000
--- a/app/internal_api/filters.py
+++ /dev/null
@@ -1,86 +0,0 @@
-from django.db import models
-from django_filters import rest_framework as filters
-from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, OrderQuerySet
-from user.models import UserExt
-
-
-class DeskSupportFilterSet(filters.FilterSet):
- category_groups = filters.BaseCSVFilter(method="filter_by_category_groups")
- categories = filters.BaseCSVFilter(method="filter_by_categories")
- keywords = filters.BaseCSVFilter(method="filter_by_keywords")
-
- user_unique_id = filters.UUIDFilter(field_name="user__unique_id", lookup_expr="exact")
- order_product_relation_id = filters.UUIDFilter(method="filter_by_order_product_relation_id")
- order_id = filters.UUIDFilter(field_name="id", lookup_expr="exact")
-
- class Meta:
- model = Order
- fields = [
- "category_groups",
- "categories",
- "keywords",
- "user_unique_id",
- "order_product_relation_id",
- "order_id",
- ]
-
- def filter_by_category_groups(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet:
- if not (filtered_values := [v.strip() for v in values if v.strip()]):
- return qs
-
- opor_order_qs = (
- OrderProductRelation.objects.filter_active()
- .filter(
- product__category__group__name__in=filtered_values,
- )
- .values_list("order_id", flat=True)
- )
-
- return qs.filter(id__in=opor_order_qs)
-
- def filter_by_categories(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet:
- if not (filtered_values := [v.strip() for v in values if v.strip()]):
- return qs
-
- opor_order_qs = (
- OrderProductRelation.objects.filter_active()
- .filter(
- product__category__name__in=filtered_values,
- )
- .values_list("order_id", flat=True)
- )
-
- return qs.filter(id__in=opor_order_qs)
-
- def filter_by_order_product_relation_id(self, qs: OrderQuerySet, name: str, value: str) -> OrderQuerySet:
- if not value:
- return qs
-
- return qs.filter(
- id__in=OrderProductRelation.objects.filter_active().filter(id=value).values_list("order_id", flat=True)
- )
-
- def filter_by_keywords(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet:
- if not (filtered_values := [v.strip() for v in values if v.strip()]):
- return qs
-
- opor_order_qs = (
- OrderProductOptionRelation.objects.filter_active()
- .filter(
- custom_response__in=filtered_values,
- )
- .values_list("order_product_relation__order_id", flat=True)
- )
- ci_order_qs = CustomerInfo.objects.filter(
- models.Q(name__in=filtered_values)
- | models.Q(email__in=filtered_values)
- | models.Q(phone__in=filtered_values)
- | models.Q(organization__in=filtered_values)
- ).values_list("order_id", flat=True)
-
- user_subquery = models.Q()
- for value in filtered_values:
- user_subquery |= models.Q(username__icontains=value) | models.Q(email__icontains=value)
- user_order_qs = qs.filter(user__in=UserExt.objects.filter(user_subquery)).values_list("id", flat=True)
-
- return qs.filter(models.Q(id__in=opor_order_qs) | models.Q(id__in=ci_order_qs) | models.Q(id__in=user_order_qs))
diff --git a/app/internal_api/migrations/0001_initial.py b/app/internal_api/migrations/0001_initial.py
new file mode 100644
index 0000000..ca90083
--- /dev/null
+++ b/app/internal_api/migrations/0001_initial.py
@@ -0,0 +1,91 @@
+# Generated by Django 6.0.6 on 2026-08-13 00:46
+
+import datetime
+import uuid
+
+import django.contrib.postgres.constraints
+import django.contrib.postgres.fields.ranges
+import django.db.models.deletion
+import internal_api.models
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ("event", "0006_event_logo_historicalevent_logo"),
+ ("product", "0009_historicaloptiongroup_placeholder_mode_and_more"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="RegistrationDeskConfig",
+ fields=[
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
+ ("name", models.CharField(max_length=100)),
+ ("start_date", models.DateField(default=datetime.date(1, 1, 1))),
+ ("end_date", models.DateField(default=datetime.date(9999, 12, 31))),
+ ("categories", models.ManyToManyField(related_name="+", to="product.category")),
+ (
+ "created_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_created_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "deleted_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_deleted_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "event",
+ models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name="+", to="event.event"),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_updated_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("start_date", "end_date"),
+ "constraints": [
+ models.CheckConstraint(
+ condition=models.Q(("start_date__lte", models.F("end_date"))),
+ name="registration_desk_config_period_order",
+ ),
+ django.contrib.postgres.constraints.ExclusionConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ expressions=[
+ (
+ internal_api.models.DateRange(
+ "start_date",
+ "end_date",
+ django.contrib.postgres.fields.ranges.RangeBoundary(inclusive_upper=True),
+ ),
+ "&&",
+ )
+ ],
+ name="registration_desk_config_period_overlap",
+ ),
+ ],
+ },
+ ),
+ ]
diff --git a/app/internal_api/migrations/__init__.py b/app/internal_api/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/internal_api/models.py b/app/internal_api/models.py
new file mode 100644
index 0000000..51879c4
--- /dev/null
+++ b/app/internal_api/models.py
@@ -0,0 +1,91 @@
+from __future__ import annotations
+
+import uuid
+from datetime import date
+
+from core.models import BaseAbstractModel, BaseAbstractModelQuerySet
+from core.util.dateutil import now_aware
+from django.contrib.postgres.constraints import ExclusionConstraint
+from django.contrib.postgres.fields import DateRangeField, RangeBoundary, RangeOperators
+from django.db import models
+from shop.product.models import Category
+
+
+class DateRange(models.Func):
+ function = "daterange"
+ output_field = DateRangeField()
+
+
+class RegistrationDeskConfigQuerySet(BaseAbstractModelQuerySet):
+ def prefetch_active_targets(self) -> models.QuerySet[RegistrationDeskConfig]:
+ return self.prefetch_related(
+ models.Prefetch(
+ "categories",
+ queryset=Category.objects.filter_active().select_related("group"),
+ ),
+ )
+
+ def filter_by_date(self, on_date: date | None = None) -> models.QuerySet[RegistrationDeskConfig]:
+ on_date = on_date or now_aware().date()
+ return self.filter(start_date__lte=on_date, end_date__gte=on_date)
+
+ def filter_by_overlap(
+ self,
+ *,
+ start_date: date,
+ end_date: date,
+ exclude_pk: uuid.UUID | None = None,
+ ) -> models.QuerySet[RegistrationDeskConfig]:
+ queryset = self.filter(start_date__lte=end_date, end_date__gte=start_date)
+ return queryset.exclude(pk=exclude_pk) if exclude_pk else queryset
+
+
+class RegistrationDeskConfig(BaseAbstractModel):
+ """등록 데스크 운영 설정. 날짜만으로 "오늘의 설정" 이 하나로 정해져야 해서 기간 중복을 금지한다."""
+
+ DEFAULT_START_DATE = date(1, 1, 1)
+ DEFAULT_END_DATE = date(9999, 12, 31)
+
+ name = models.CharField(max_length=100)
+ event = models.ForeignKey("event.Event", on_delete=models.PROTECT, related_name="+")
+
+ start_date = models.DateField(default=DEFAULT_START_DATE)
+ end_date = models.DateField(default=DEFAULT_END_DATE)
+
+ categories = models.ManyToManyField("product.Category", related_name="+")
+
+ objects: RegistrationDeskConfigQuerySet = RegistrationDeskConfigQuerySet.as_manager() # type: ignore
+
+ class Meta:
+ ordering = ("start_date", "end_date")
+ constraints = [
+ models.CheckConstraint(
+ condition=models.Q(start_date__lte=models.F("end_date")),
+ name="registration_desk_config_period_order",
+ ),
+ ExclusionConstraint(
+ name="registration_desk_config_period_overlap",
+ expressions=[
+ (DateRange("start_date", "end_date", RangeBoundary(inclusive_upper=True)), RangeOperators.OVERLAPS),
+ ],
+ condition=models.Q(deleted_at__isnull=True),
+ ),
+ ]
+
+ def __str__(self) -> str: # pragma: no cover
+ return self.name
+
+ def build_query(self) -> models.Q:
+ category_ids = list(self.categories.filter_active().values_list("id", flat=True))
+ return models.Q(product__category_id__in=category_ids)
+
+ def covers(self, order_product_relation_ids: list[uuid.UUID]) -> bool:
+ from shop.order.models import OrderProductRelation
+
+ covered = (
+ OrderProductRelation.objects.filter_active()
+ .filter(self.build_query())
+ .filter(id__in=order_product_relation_ids)
+ .count()
+ )
+ return covered == len(set(order_product_relation_ids))
diff --git a/app/internal_api/registration_desk/__init__.py b/app/internal_api/registration_desk/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/internal_api/registration_desk/filters.py b/app/internal_api/registration_desk/filters.py
new file mode 100644
index 0000000..2c2cff6
--- /dev/null
+++ b/app/internal_api/registration_desk/filters.py
@@ -0,0 +1,139 @@
+from datetime import date
+
+from core.util.dateutil import now_aware
+from django.db import models
+from django.db.models.functions import Lower, Trim
+from django_filters import rest_framework as filters
+from internal_api.models import RegistrationDeskConfig
+from rest_framework import serializers
+from shop.order.models import (
+ CustomerInfo,
+ Order,
+ OrderProductOptionRelation,
+ OrderProductRelation,
+ OrderQuerySet,
+ TicketInfo,
+)
+from user.models import UserExt
+
+INVALID_SCANCODE_MESSAGE = "스캔코드 형식이 올바르지 않습니다."
+
+# 2026 프로그램 등록일에만 쓰는 임시 묶음 조회다. 행사 종료 뒤 제거한다.
+TEMPORARY_RELATED_TICKET_DATE = date(2026, 8, 17)
+TEMPORARY_RELATED_TICKET_CATEGORIES = frozenset({"스프린트", "튜토리얼", "딥다이브"})
+
+
+class RegistrationDeskOrderProductFilterSet(filters.FilterSet):
+ order_product_relation_id = filters.UUIDFilter(field_name="id", lookup_expr="exact")
+ scancode = filters.CharFilter(method="filter_by_scancode")
+ include_related_ticketinfo = filters.BooleanFilter(method="filter_related_ticketinfo")
+
+ class Meta:
+ model = OrderProductRelation
+ fields = ["order_product_relation_id", "scancode", "include_related_ticketinfo"]
+
+ def filter_by_scancode(self, qs: models.QuerySet, name: str, value: str) -> models.QuerySet:
+ parts = value.split(":")
+ if len(parts) != 3 or parts[0] != OrderProductRelation.scancode_prefix or not all(parts[1:]):
+ # 문법 오류는 조회 실패가 아니라 잘못된 요청.
+ raise serializers.ValidationError({"scancode": INVALID_SCANCODE_MESSAGE})
+ scanned = OrderProductRelation.from_scancode_token(value)
+ return qs.filter(pk=scanned.pk) if scanned else qs.none()
+
+ def filter_related_ticketinfo(self, qs: models.QuerySet, name: str, value: bool) -> models.QuerySet:
+ """오늘 프로그램 티켓 QR 하나로 같은 참가자의 오늘 티켓을 함께 조회한다.
+
+ 명시적인 opt-in 파라미터와 날짜/카테고리 가드를 모두 만족해야 확장한다.
+ 일반 주문상품 조회, 다른 행사일 및 대상 밖 QR은 기존 단건 동작을 유지한다.
+ """
+ if not value or not self.data.get("scancode") or now_aware().date() != TEMPORARY_RELATED_TICKET_DATE:
+ return qs
+
+ scanned = qs.select_related("product__category", "ticket_info").first()
+ if not scanned or not (ticket_info := scanned.ticket_info_or_none):
+ return qs
+
+ config = RegistrationDeskConfig.objects.filter_active().filter_by_date(TEMPORARY_RELATED_TICKET_DATE).first()
+ if (
+ not config
+ or not config.categories.filter(
+ id=scanned.product.category_id,
+ name__in=TEMPORARY_RELATED_TICKET_CATEGORIES,
+ deleted_at__isnull=True,
+ ).exists()
+ ):
+ return qs
+
+ normalized_email = ticket_info.email.strip().lower()
+ if not normalized_email:
+ return qs
+
+ return (
+ self.queryset.filter(
+ config.build_query(),
+ status__in=OrderProductRelation.PURCHASED_STOCK_STATUS,
+ product__category__name__in=TEMPORARY_RELATED_TICKET_CATEGORIES,
+ ticket_info__deleted_at__isnull=True,
+ )
+ .annotate(_normalized_ticket_email=Lower(Trim("ticket_info__email")))
+ .filter(_normalized_ticket_email=normalized_email)
+ )
+
+
+class RegistrationDeskOrderFilterSet(filters.FilterSet):
+ keywords = filters.BaseCSVFilter(method="filter_by_keywords")
+
+ user_unique_id = filters.UUIDFilter(field_name="user__unique_id", lookup_expr="exact")
+ order_product_relation_id = filters.UUIDFilter(method="filter_by_order_product_relation_id")
+ order_id = filters.UUIDFilter(field_name="id", lookup_expr="exact")
+
+ class Meta:
+ model = Order
+ fields = [
+ "keywords",
+ "user_unique_id",
+ "order_product_relation_id",
+ "order_id",
+ ]
+
+ def filter_by_order_product_relation_id(self, qs: OrderQuerySet, name: str, value: str) -> OrderQuerySet:
+ if not value:
+ return qs
+
+ return qs.filter(
+ id__in=OrderProductRelation.objects.filter_active().filter(id=value).values_list("order_id", flat=True)
+ )
+
+ def filter_by_keywords(self, qs: OrderQuerySet, name: str, values: list[str]) -> OrderQuerySet:
+ if not (filtered_values := [v.strip() for v in values if v.strip()]):
+ return qs
+
+ participant_query = (
+ models.Q(name__in=filtered_values)
+ | models.Q(email__in=filtered_values)
+ | models.Q(phone__in=filtered_values)
+ | models.Q(organization__in=filtered_values)
+ )
+
+ opor_order_qs = (
+ OrderProductOptionRelation.objects.filter_active()
+ .filter(custom_response__in=filtered_values)
+ .values_list("order_product_relation__order_id", flat=True)
+ )
+ ci_order_qs = CustomerInfo.objects.filter(participant_query).values_list("order_id", flat=True)
+ ti_order_qs = (
+ TicketInfo.objects.filter_active()
+ .filter(participant_query, order_product_relation__deleted_at__isnull=True)
+ .values_list("order_product_relation__order_id", flat=True)
+ )
+
+ user_query = models.Q()
+ for value in filtered_values:
+ user_query |= models.Q(username__icontains=value) | models.Q(email__icontains=value)
+
+ return qs.filter(
+ models.Q(id__in=opor_order_qs)
+ | models.Q(id__in=ci_order_qs)
+ | models.Q(id__in=ti_order_qs)
+ | models.Q(user__in=UserExt.objects.filter(user_query))
+ )
diff --git a/app/internal_api/registration_desk/serializers.py b/app/internal_api/registration_desk/serializers.py
new file mode 100644
index 0000000..86e137e
--- /dev/null
+++ b/app/internal_api/registration_desk/serializers.py
@@ -0,0 +1,318 @@
+import re
+import typing
+
+from core.serializer.nested_model_serializer import InstanceListSerializer, NestedModelSerializer
+from drf_spectacular.utils import extend_schema_field
+from event.models import Event
+from internal_api.models import RegistrationDeskConfig
+from rest_framework import exceptions, serializers
+from shop.order.models import (
+ CustomerInfo,
+ Order,
+ OrderProductOptionRelation,
+ OrderProductRelation,
+ OrderProductRelationTag,
+ TicketInfo,
+)
+from shop.order.serializers.validator import TicketInfoSerializer, validate_ticket_info_against_product
+from shop.payment_history.models import PaymentHistory
+from shop.product.models import Category, Option, OptionGroup, Product
+from shop.serializers.refund import _REFUND_DATE_OVERRIDABLE_REASONS as REFUND_DATE_OVERRIDABLE_REASONS
+from user.models import UserExt
+
+OrderProductStatus = OrderProductRelation.OrderProductStatus
+
+PossibleStatusFSM: dict[OrderProductStatus, set[OrderProductStatus]] = {
+ OrderProductStatus.pending: set(),
+ OrderProductStatus.paid: {OrderProductStatus.used},
+ OrderProductStatus.used: {OrderProductStatus.paid},
+ OrderProductStatus.refunded: set(),
+}
+
+TICKET_INFO_EDITABLE_STATUSES = frozenset({OrderProductStatus.paid, OrderProductStatus.used})
+OPTION_EDITABLE_STATUSES = frozenset({OrderProductStatus.paid})
+OUT_OF_SCOPE_MESSAGE = "오늘 등록 데스크 설정의 대상 카테고리가 아닙니다."
+
+
+def desk_refund_reason(reason: str | None) -> str | None:
+ """등록 데스크 환불은 `check_refundable_date=False` 라 일자 관련 사유는 실제로 막지 않는다."""
+ return None if reason in REFUND_DATE_OVERRIDABLE_REASONS else reason
+
+
+class RegistrationDeskTagDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("id", "code", "name", "priority")
+ model = OrderProductRelationTag
+
+
+class RegistrationDeskProductDto(serializers.ModelSerializer):
+ class RegistrationDeskProductCategoryDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("id", "name")
+ model = Category
+
+ category = RegistrationDeskProductCategoryDto(read_only=True)
+
+ class Meta:
+ fields = ("id", "name", "price", "category")
+ model = Product
+
+
+class RegistrationDeskTicketInfoDto(TicketInfoSerializer):
+ class Meta(TicketInfoSerializer.Meta):
+ fields = ("name", "phone", "email", "organization")
+
+ def get_attribute(self, instance: OrderProductRelation) -> TicketInfo | None:
+ # 소프트 삭제된 참가자 정보를 None 으로 흡수. 쓰기는 실제 관계(`ticket_info`)로 들어간다.
+ return instance.ticket_info_or_none
+
+
+class RegistrationDeskOrderProductOptionDto(NestedModelSerializer):
+ class RegistrationDeskOptionGroupDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("id", "name", "is_custom_response", "custom_response_pattern", "placeholder_mode")
+ model = OptionGroup
+
+ class RegistrationDeskOptionDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("id", "name", "additional_price")
+ model = Option
+
+ id = serializers.UUIDField(required=True)
+ product_option_group = RegistrationDeskOptionGroupDto(read_only=True)
+ product_option = RegistrationDeskOptionDto(allow_null=True, read_only=True)
+ custom_response = serializers.CharField(required=False, allow_null=True, allow_blank=True)
+
+ class Meta:
+ fields = ("id", "product_option_group", "product_option", "custom_response")
+ model = OrderProductOptionRelation
+ list_serializer_class = InstanceListSerializer
+
+ def validate_id(self, value: str) -> str:
+ if value != typing.cast(OrderProductOptionRelation, self.instance).id:
+ raise serializers.ValidationError("id must not be modified")
+ return value
+
+ def validate_custom_response(self, value: str | None) -> str | None:
+ option_group: OptionGroup = typing.cast(OrderProductOptionRelation, self.instance).product_option_group
+ if not option_group.is_custom_response:
+ raise serializers.ValidationError("cannot set custom response to non-custom-response option group")
+ if option_group.placeholder_mode == OptionGroup.PlaceholderMode.REQUIRED and not value:
+ raise serializers.ValidationError("응답이 필수인 옵션입니다.")
+ if value is None:
+ return value
+ if not option_group.custom_response_pattern:
+ raise serializers.ValidationError("custom response pattern is not set, please contact the administrator")
+ if not re.match(option_group.custom_response_pattern, value):
+ raise serializers.ValidationError("custom response does not match the pattern")
+
+ return value
+
+
+class RegistrationDeskOrderProductFieldsMixin(serializers.Serializer):
+ """주문 검색과 주문 상품 조회가 공통으로 내려주는 읽기 전용 필드."""
+
+ scancode_token = serializers.CharField(read_only=True)
+ is_ticket = serializers.BooleanField(source="product.category.is_ticket", read_only=True)
+ not_refundable_reason = serializers.SerializerMethodField()
+ product = RegistrationDeskProductDto(read_only=True)
+ tags = RegistrationDeskTagDto(many=True, read_only=True)
+
+ @staticmethod
+ @extend_schema_field(serializers.CharField(allow_null=True))
+ def get_not_refundable_reason(obj: OrderProductRelation) -> str | None:
+ return desk_refund_reason(obj.not_refundable_reason)
+
+
+class RegistrationDeskOrderProductDto(RegistrationDeskOrderProductFieldsMixin, NestedModelSerializer):
+ id = serializers.UUIDField(required=True)
+ price = serializers.IntegerField(read_only=True)
+ donation_price = serializers.IntegerField(read_only=True)
+ status = serializers.ChoiceField(choices=OrderProductStatus.choices, required=False)
+
+ options = RegistrationDeskOrderProductOptionDto(many=True, required=False)
+ ticket_info = RegistrationDeskTicketInfoDto(required=False)
+
+ class Meta:
+ fields = (
+ "id",
+ "scancode_token",
+ "is_ticket",
+ "price",
+ "donation_price",
+ "status",
+ "not_refundable_reason",
+ "product",
+ "options",
+ "tags",
+ "ticket_info",
+ )
+ model = OrderProductRelation
+ list_serializer_class = InstanceListSerializer
+
+ def validate_status(self, value: OrderProductStatus) -> OrderProductStatus:
+ current_status = typing.cast(OrderProductRelation, self.instance).status
+ if value == current_status:
+ return value
+ if value not in PossibleStatusFSM[typing.cast(OrderProductStatus, current_status)]:
+ raise serializers.ValidationError("해당 상태로 변경할 수 없습니다.")
+ return value
+
+ def validate(self, attrs: dict) -> dict:
+ instance = typing.cast(OrderProductRelation, self.instance)
+ if "ticket_info" in attrs:
+ if instance.status not in TICKET_INFO_EDITABLE_STATUSES:
+ msg = "결제 완료 또는 사용 상태에서만 참가자 정보를 수정할 수 있습니다."
+ raise serializers.ValidationError({"ticket_info": msg})
+ validate_ticket_info_against_product(instance.product, attrs["ticket_info"])
+ if "options" in attrs and instance.status not in OPTION_EDITABLE_STATUSES:
+ raise serializers.ValidationError({"options": "결제 완료 상태에서만 옵션을 수정할 수 있습니다."})
+ return attrs
+
+ def update(self, instance: OrderProductRelation, validated_data: dict) -> OrderProductRelation:
+ # 부모의 nested 처리는 미존재 시 생성을 못 한다.
+ ticket_info_data = validated_data.pop("ticket_info", None)
+ instance = typing.cast(OrderProductRelation, super().update(instance, validated_data))
+ if ticket_info_data is not None:
+ self._sync_ticket_info(instance, ticket_info_data)
+ return instance
+
+ @staticmethod
+ def _sync_ticket_info(instance: OrderProductRelation, data: dict) -> None:
+ # OneToOne 이라 소프트 삭제된 row 가 있으면 새로 만들 수 없다.
+ ticket_info = TicketInfo.objects.filter(order_product_relation=instance).first() or TicketInfo(
+ order_product_relation=instance
+ )
+ for field, value in data.items():
+ setattr(ticket_info, field, value)
+ ticket_info.deleted_at = ticket_info.deleted_by = None
+ ticket_info.save()
+ # 캐시된 이전 값이 응답에 새어 나가지 않도록 갱신.
+ instance.ticket_info = ticket_info
+
+
+class RegistrationDeskOrderSerializer(NestedModelSerializer):
+ class RegistrationDeskPaymentHistoryDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("status", "price", "created_at")
+ model = PaymentHistory
+
+ class RegistrationDeskUserDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("id", "username", "email", "unique_id")
+ model = UserExt
+
+ class RegistrationDeskCustomerInfoDto(serializers.ModelSerializer):
+ class Meta:
+ fields = ("name", "email", "phone", "organization")
+ model = CustomerInfo
+
+ id = serializers.UUIDField(read_only=True)
+ # 데스크가 바꿀 수 있는 건 상품(체크인·참가자 정보·옵션)뿐 — 나머지가 열려 있으면 범위 검증을 우회한다.
+ name = serializers.CharField(read_only=True)
+ first_paid_price = serializers.IntegerField(read_only=True)
+ first_paid_at = serializers.DateTimeField(read_only=True)
+ current_paid_price = serializers.IntegerField(read_only=True)
+ current_status = serializers.CharField(read_only=True)
+
+ created_at = serializers.DateTimeField(read_only=True)
+ not_fully_refundable_reason = serializers.SerializerMethodField()
+
+ payment_histories = RegistrationDeskPaymentHistoryDto(many=True, read_only=True)
+ products = RegistrationDeskOrderProductDto(many=True, required=False, source="active_products")
+ user = RegistrationDeskUserDto(read_only=True)
+ customer_info = RegistrationDeskCustomerInfoDto(read_only=True)
+
+ class Meta:
+ fields = (
+ "id",
+ "name",
+ "first_paid_price",
+ "first_paid_at",
+ "current_paid_price",
+ "current_status",
+ "created_at",
+ "not_fully_refundable_reason",
+ "payment_histories",
+ "products",
+ "user",
+ "customer_info",
+ )
+ model = Order
+
+ @staticmethod
+ @extend_schema_field(serializers.CharField(allow_null=True))
+ def get_not_fully_refundable_reason(obj: Order) -> str | None:
+ return desk_refund_reason(obj.not_fully_refundable_reason)
+
+ def validate(self, attrs: dict) -> dict:
+ config = self.context.get("desk_config")
+ target_ids = [item["id"] for item in attrs.get("active_products", []) if item.get("id")]
+ if config and target_ids and not config.covers(target_ids):
+ raise exceptions.PermissionDenied(OUT_OF_SCOPE_MESSAGE)
+ return attrs
+
+
+class RegistrationDeskOrderProductSerializer(RegistrationDeskOrderProductFieldsMixin, serializers.ModelSerializer):
+ class _OrderDto(serializers.ModelSerializer):
+ current_status = serializers.CharField(read_only=True)
+ first_paid_at = serializers.DateTimeField(read_only=True)
+
+ class Meta:
+ fields = ("id", "name", "current_status", "first_paid_at")
+ model = Order
+
+ options = RegistrationDeskOrderProductOptionDto(many=True, read_only=True)
+ order = _OrderDto(read_only=True)
+ ticket_info = RegistrationDeskTicketInfoDto(read_only=True)
+
+ class Meta:
+ fields = (
+ "id",
+ "short_id",
+ "scancode_token",
+ "is_ticket",
+ "status",
+ "price",
+ "donation_price",
+ "not_refundable_reason",
+ "product",
+ "options",
+ "tags",
+ "order",
+ "ticket_info",
+ )
+ model = OrderProductRelation
+
+
+class RegistrationDeskStatisticsSerializer(serializers.Serializer):
+ registration_target_count = serializers.IntegerField()
+ registered_count = serializers.IntegerField()
+ waiting_count = serializers.IntegerField()
+
+
+class RegistrationDeskSessionSerializer(serializers.ModelSerializer):
+ class Meta:
+ fields = ("id", "unique_id", "username", "nickname", "email")
+ model = UserExt
+
+
+class RegistrationDeskConfigurationSerializer(serializers.ModelSerializer):
+ class RegistrationDeskEventDto(serializers.ModelSerializer):
+ logo_url = serializers.CharField(source="logo.file.url", read_only=True, allow_null=True)
+
+ class Meta:
+ fields = ("id", "name", "event_start_at", "event_end_at", "logo_url")
+ model = Event
+
+ event = RegistrationDeskEventDto(read_only=True)
+ available_tags = serializers.SerializerMethodField()
+
+ class Meta:
+ fields = ("id", "name", "start_date", "end_date", "event", "available_tags")
+ model = RegistrationDeskConfig
+
+ @staticmethod
+ @extend_schema_field(RegistrationDeskTagDto(many=True))
+ def get_available_tags(obj: RegistrationDeskConfig) -> list[dict]:
+ return RegistrationDeskTagDto(OrderProductRelationTag.objects.filter_active(), many=True).data
diff --git a/app/internal_api/registration_desk/urls.py b/app/internal_api/registration_desk/urls.py
new file mode 100644
index 0000000..6dba116
--- /dev/null
+++ b/app/internal_api/registration_desk/urls.py
@@ -0,0 +1,12 @@
+from django.urls import include, path
+from internal_api.registration_desk import views
+from rest_framework import routers
+
+router = routers.SimpleRouter()
+router.register("orders", views.RegistrationDeskOrderViewSet, basename="orders")
+router.register("order-products", views.RegistrationDeskOrderProductViewSet, basename="order-products")
+router.register("", views.RegistrationDeskViewSet, basename="desk")
+
+app_name = "registration_desk"
+
+urlpatterns = [path("", include(router.urls))]
diff --git a/app/internal_api/registration_desk/views.py b/app/internal_api/registration_desk/views.py
new file mode 100644
index 0000000..86eac28
--- /dev/null
+++ b/app/internal_api/registration_desk/views.py
@@ -0,0 +1,262 @@
+import functools
+
+from core.authz import IsSuperUser
+from core.const.tag import OpenAPITag
+from core.pagination import AdminPagination
+from django.db import models, transaction
+from django.utils.decorators import method_decorator
+from django.views.decorators.csrf import ensure_csrf_cookie
+from drf_spectacular.utils import extend_schema, extend_schema_view
+from drf_standardized_errors.openapi_serializers import (
+ Error403Serializer,
+ Error404Serializer,
+ ValidationErrorResponseSerializer,
+)
+from internal_api.models import RegistrationDeskConfig
+from internal_api.registration_desk.filters import (
+ RegistrationDeskOrderFilterSet,
+ RegistrationDeskOrderProductFilterSet,
+)
+from internal_api.registration_desk.serializers import (
+ OUT_OF_SCOPE_MESSAGE,
+ RegistrationDeskConfigurationSerializer,
+ RegistrationDeskOrderProductSerializer,
+ RegistrationDeskOrderSerializer,
+ RegistrationDeskSessionSerializer,
+ RegistrationDeskStatisticsSerializer,
+)
+from rest_framework import decorators, exceptions, mixins, permissions, request, response, status, viewsets
+from shop.order.models import Order, OrderProductRelation, OrderProductRelationTag
+from shop.payment_history.models import PaymentHistory
+from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer
+
+NO_DESK_CONFIG_MESSAGE = "오늘 적용되는 등록 데스크 설정이 없습니다."
+
+
+class CurrentDeskConfigMixin:
+ """조회는 열어 두되 변경은 오늘 설정의 범위로 제한한다."""
+
+ @functools.cached_property
+ def current_config(self) -> RegistrationDeskConfig:
+ queryset = RegistrationDeskConfig.objects.filter_active().select_related("event__logo")
+ if not (config := queryset.filter_by_date().first()):
+ raise exceptions.PermissionDenied(NO_DESK_CONFIG_MESSAGE)
+ return config
+
+ def assert_in_scope(self, order_product_relation_ids: list) -> None:
+ if not self.current_config.covers(order_product_relation_ids):
+ raise exceptions.PermissionDenied(OUT_OF_SCOPE_MESSAGE)
+
+
+class RequiredFilterMixin:
+ required_filter_params: tuple[str, ...] = ()
+ exclusive_filter_params = False
+
+ def list(self, request: request.Request, *args: object, **kwargs: object) -> response.Response:
+ if not (provided := [p for p in self.required_filter_params if request.query_params.get(p, "").strip()]):
+ joined = ", ".join(self.required_filter_params)
+ raise exceptions.ValidationError(f"다음 조회 조건 중 하나 이상이 필요합니다: {joined}")
+ if self.exclusive_filter_params and len(provided) > 1:
+ joined = ", ".join(provided)
+ raise exceptions.ValidationError(f"다음 조회 조건은 동시에 사용할 수 없습니다: {joined}")
+ return super().list(request, *args, **kwargs)
+
+
+@method_decorator(
+ ensure_csrf_cookie, name="dispatch"
+) # 4xx 응답에도 CSRF 쿠키를 실어야 로그인 후 곧바로 PATCH/DELETE 를 보낼 수 있음
+class RegistrationDeskViewSet(CurrentDeskConfigMixin, viewsets.ViewSet):
+ permission_classes = [IsSuperUser]
+
+ @extend_schema(
+ summary="세션 조회",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_200_OK: RegistrationDeskSessionSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ },
+ )
+ @decorators.action(detail=False, methods=["get"])
+ def session(self, req: request.Request) -> response.Response:
+ return response.Response(RegistrationDeskSessionSerializer(req.user).data)
+
+ @extend_schema(
+ summary="데스크 설정 조회",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_200_OK: RegistrationDeskConfigurationSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ },
+ )
+ @decorators.action(detail=False, methods=["get"])
+ def configuration(self, req: request.Request) -> response.Response:
+ return response.Response(RegistrationDeskConfigurationSerializer(self.current_config).data)
+
+ @extend_schema(
+ summary="등록 통계 조회",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_200_OK: RegistrationDeskStatisticsSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ },
+ )
+ @decorators.action(detail=False, methods=["get"])
+ def statistics(self, req: request.Request) -> response.Response:
+ counts = (
+ OrderProductRelation.objects.filter_active()
+ .filter(status__in=OrderProductRelation.PURCHASED_STOCK_STATUS)
+ .filter(
+ order__in=Order.objects.filter_has_payment_histories(),
+ product__deleted_at__isnull=True,
+ product__category__deleted_at__isnull=True,
+ product__category__is_ticket=True,
+ )
+ .filter(self.current_config.build_query())
+ .aggregate(
+ registration_target_count=models.Count("id"),
+ registered_count=models.Count(
+ "id", filter=models.Q(status=OrderProductRelation.OrderProductStatus.used)
+ ),
+ waiting_count=models.Count("id", filter=models.Q(status=OrderProductRelation.OrderProductStatus.paid)),
+ )
+ )
+ return response.Response(RegistrationDeskStatisticsSerializer(counts).data)
+
+
+@extend_schema_view(
+ list=extend_schema(
+ summary="주문 검색",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_200_OK: RegistrationDeskOrderSerializer(many=True),
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ },
+ ),
+ partial_update=extend_schema(
+ summary="주문 수정",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_200_OK: RegistrationDeskOrderSerializer,
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ status.HTTP_404_NOT_FOUND: Error404Serializer,
+ },
+ ),
+)
+@method_decorator(name="partial_update", decorator=transaction.atomic)
+class RegistrationDeskOrderViewSet(
+ CurrentDeskConfigMixin,
+ RequiredFilterMixin,
+ mixins.ListModelMixin,
+ mixins.UpdateModelMixin,
+ viewsets.GenericViewSet,
+):
+ queryset = (
+ Order.objects.filter_has_payment_histories()
+ .select_related("customer_info")
+ .with_dto_prefetches()
+ .order_by("-created_at")
+ )
+ filterset_class = RegistrationDeskOrderFilterSet
+ serializer_class = RegistrationDeskOrderSerializer
+ pagination_class = AdminPagination
+ permission_classes = [IsSuperUser]
+ http_method_names = ["get", "patch", "delete"]
+ required_filter_params = ("keywords", "order_id", "order_product_relation_id", "user_unique_id")
+
+ def get_serializer_context(self) -> dict:
+ context = super().get_serializer_context()
+ if self.request.method not in permissions.SAFE_METHODS:
+ context["desk_config"] = self.current_config
+ return context
+
+ @extend_schema(
+ summary="주문 전체 환불",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_204_NO_CONTENT: None,
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ status.HTTP_404_NOT_FOUND: Error404Serializer,
+ },
+ )
+ @decorators.action(detail=True, methods=["delete"], url_path="refund")
+ @transaction.atomic
+ def refund(self, req: request.Request, pk: str | None = None) -> response.Response:
+ order = self.get_object()
+ # 전체 환불은 활성 상품 전부를 건드린다.
+ self.assert_in_scope([product.id for product in order.active_products])
+ serializer = OrderTotalRefundSerializer(
+ instance=order,
+ data={},
+ context={"check_refundable_date": False, "check_totp": False},
+ )
+ serializer.is_valid(raise_exception=True)
+ serializer.refund()
+ return response.Response(status=status.HTTP_204_NO_CONTENT)
+
+
+@extend_schema_view(
+ list=extend_schema(
+ summary="주문 상품 조회",
+ description=(
+ "`order_product_relation_id` 또는 `scancode` 중 정확히 하나를 전달해야 한다. "
+ "2026-08-17 프로그램 등록에 한해 `scancode`와 `include_related_ticketinfo=true`를 함께 보내면 "
+ "같은 참가자 이메일의 스프린트/튜토리얼/딥다이브 티켓을 모두 반환한다."
+ ),
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_200_OK: RegistrationDeskOrderProductSerializer(many=True),
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ },
+ ),
+)
+class RegistrationDeskOrderProductViewSet(
+ CurrentDeskConfigMixin, RequiredFilterMixin, mixins.ListModelMixin, viewsets.GenericViewSet
+):
+ queryset = (
+ OrderProductRelation.objects.filter_active()
+ .filter(order__in=Order.objects.filter_has_payment_histories())
+ .select_related("product", "product__category", "order", "ticket_info")
+ .prefetch_active_options()
+ .prefetch_related(
+ models.Prefetch(
+ "order__payment_histories",
+ queryset=PaymentHistory.objects.filter_active(),
+ to_attr="_active_payment_histories",
+ ),
+ models.Prefetch("tags", queryset=OrderProductRelationTag.objects.filter_active()),
+ )
+ .order_by("-created_at")
+ )
+ serializer_class = RegistrationDeskOrderProductSerializer
+ filterset_class = RegistrationDeskOrderProductFilterSet
+ pagination_class = AdminPagination
+ permission_classes = [IsSuperUser]
+ required_filter_params = ("order_product_relation_id", "scancode")
+ exclusive_filter_params = True
+
+ @extend_schema(
+ summary="주문 상품 부분 환불",
+ tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
+ responses={
+ status.HTTP_204_NO_CONTENT: None,
+ status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
+ status.HTTP_403_FORBIDDEN: Error403Serializer,
+ status.HTTP_404_NOT_FOUND: Error404Serializer,
+ },
+ )
+ @decorators.action(detail=True, methods=["delete"], url_path="refund")
+ @transaction.atomic
+ def refund(self, req: request.Request, pk: str | None = None) -> response.Response:
+ order_product = self.get_object()
+ self.assert_in_scope([order_product.id])
+ serializer = OrderProductRefundSerializer(
+ instance=order_product,
+ data={},
+ context={"check_refundable_date": False, "check_totp": False},
+ )
+ serializer.is_valid(raise_exception=True)
+ serializer.refund()
+ return response.Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/app/internal_api/serializers.py b/app/internal_api/serializers.py
deleted file mode 100644
index 022a837..0000000
--- a/app/internal_api/serializers.py
+++ /dev/null
@@ -1,147 +0,0 @@
-import re
-import typing
-
-from core.serializer.nested_model_serializer import InstanceListSerializer, NestedModelSerializer
-from rest_framework import serializers
-from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation
-from shop.payment_history.models import PaymentHistory
-from shop.product.models import Option, OptionGroup, Product
-from user.models import UserExt
-
-PossibleStatusFSM: dict[OrderProductRelation.OrderProductStatus, set[OrderProductRelation.OrderProductStatus]] = {
- # 접수 데스크에서는 결제 완료나 그 이후의 상태로 변경할 수 없음
- OrderProductRelation.OrderProductStatus.pending: set(),
- OrderProductRelation.OrderProductStatus.paid: {OrderProductRelation.OrderProductStatus.used},
- OrderProductRelation.OrderProductStatus.used: {OrderProductRelation.OrderProductStatus.paid},
- # 이미 환불된 상품은 사용 또는 결제 완료로 변경할 수 없음
- OrderProductRelation.OrderProductStatus.refunded: set(),
-}
-
-
-class SimplePaymentHistoryDeskSupportDto(serializers.ModelSerializer):
- class Meta:
- fields = ("status", "price", "created_at")
- model = PaymentHistory
-
-
-class SimpleProductDeskSupportDto(serializers.ModelSerializer):
- class Meta:
- fields = ("id", "name", "price")
- model = Product
-
-
-class SimpleOptionGroupDeskSupportDto(serializers.ModelSerializer):
- class Meta:
- fields = ("id", "name", "is_custom_response", "custom_response_pattern", "placeholder_mode")
- model = OptionGroup
-
-
-class SimpleOptionDeskSupportDto(serializers.ModelSerializer):
- class Meta:
- fields = ("id", "name", "additional_price")
- model = Option
-
-
-class SimpleOrderProductOptionRelationDeskSupportDto(NestedModelSerializer):
- id = serializers.UUIDField(required=True)
- product_option_group = SimpleOptionGroupDeskSupportDto(read_only=True)
- product_option = SimpleOptionDeskSupportDto(allow_null=True, read_only=True)
- custom_response = serializers.CharField(allow_null=False, allow_blank=True) # Modifiable
-
- class Meta:
- fields = ("id", "product_option_group", "product_option", "custom_response")
- model = OrderProductOptionRelation
- list_serializer_class = InstanceListSerializer
-
- def validate_id(self, value: str) -> str:
- if value != typing.cast(OrderProductOptionRelation, self.instance).id:
- raise serializers.ValidationError("id must not be modified")
- return value
-
- def validate_custom_response(self, value: str) -> str:
- option_group: OptionGroup = typing.cast(OrderProductOptionRelation, self.instance).product_option_group
- if not option_group.is_custom_response:
- raise serializers.ValidationError("cannot set custom response to non-custom-response option group")
- if not option_group.custom_response_pattern:
- raise serializers.ValidationError("custom response pattern is not set, please contact the administrator")
- if not re.match(option_group.custom_response_pattern, value):
- raise serializers.ValidationError("custom response does not match the pattern")
-
- return value
-
-
-class SimpleOrderProductRelationDeskSupportDto(NestedModelSerializer):
- id = serializers.UUIDField(required=True)
- price = serializers.IntegerField(read_only=True)
- donation_price = serializers.IntegerField(read_only=True)
- status = serializers.ChoiceField(
- choices=OrderProductRelation.OrderProductStatus.choices,
- required=False,
- ) # Modifiable
-
- product = SimpleProductDeskSupportDto(read_only=True)
- options = SimpleOrderProductOptionRelationDeskSupportDto(many=True, required=False) # Modifiable
-
- class Meta:
- fields = (
- "id",
- "price",
- "donation_price",
- "status",
- # Related fields
- "product",
- "options",
- )
- model = OrderProductRelation
- list_serializer_class = InstanceListSerializer
-
- def validate_status(
- self, value: OrderProductRelation.OrderProductStatus
- ) -> OrderProductRelation.OrderProductStatus:
- instance = typing.cast(OrderProductRelation, self.instance)
- if value == instance.status:
- return value
- if value not in PossibleStatusFSM[typing.cast(OrderProductRelation.OrderProductStatus, instance.status)]:
- raise serializers.ValidationError("해당 상태로 변경할 수 없습니다.")
- return value
-
-
-class SimpleUserDeskSupportDto(serializers.ModelSerializer):
- class Meta:
- fields = ("id", "username", "email", "unique_id")
- model = UserExt
-
-
-class SimpleCustomerInfoDeskSupportDto(serializers.ModelSerializer):
- class Meta:
- fields = ("name", "email", "phone", "organization")
- model = CustomerInfo
-
-
-class DeskSupportSerializer(NestedModelSerializer):
- id = serializers.UUIDField(read_only=True)
- first_paid_price = serializers.IntegerField(read_only=True)
- first_paid_at = serializers.DateTimeField(read_only=True)
- current_paid_price = serializers.IntegerField(read_only=True)
- current_status = serializers.CharField(read_only=True)
-
- payment_histories = SimplePaymentHistoryDeskSupportDto(many=True, read_only=True)
- products = SimpleOrderProductRelationDeskSupportDto(many=True, required=False) # Modifiable
- user = SimpleUserDeskSupportDto(read_only=True)
- customer_info = SimpleCustomerInfoDeskSupportDto(read_only=True)
-
- class Meta:
- fields = (
- "id",
- "name",
- "first_paid_price",
- "first_paid_at",
- "current_paid_price",
- "current_status",
- # Related fields
- "payment_histories",
- "products",
- "user",
- "customer_info",
- )
- model = Order
diff --git a/app/internal_api/test/__init__.py b/app/internal_api/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/internal_api/test/config_model_test.py b/app/internal_api/test/config_model_test.py
new file mode 100644
index 0000000..06dc6af
--- /dev/null
+++ b/app/internal_api/test/config_model_test.py
@@ -0,0 +1,132 @@
+from datetime import date
+
+import pytest
+from django.db.utils import IntegrityError
+from internal_api.models import RegistrationDeskConfig
+
+FOREVER_START = RegistrationDeskConfig.DEFAULT_START_DATE
+FOREVER_END = RegistrationDeskConfig.DEFAULT_END_DATE
+
+
+def _config(event, name: str, start: date = FOREVER_START, end: date = FOREVER_END) -> RegistrationDeskConfig:
+ return RegistrationDeskConfig.objects.create(name=name, event=event, start_date=start, end_date=end)
+
+
+def _overlapping(start: date, end: date, exclude_pk=None):
+ return RegistrationDeskConfig.objects.filter_active().filter_by_overlap(
+ start_date=start, end_date=end, exclude_pk=exclude_pk
+ )
+
+
+def _for_date(on_date: date | None = None) -> RegistrationDeskConfig | None:
+ return RegistrationDeskConfig.objects.filter_active().filter_by_date(on_date).first()
+
+
+@pytest.mark.django_db
+def test_period_defaults_to_unbounded_range(desk_event):
+ config = RegistrationDeskConfig.objects.create(name="상시", event=desk_event)
+
+ assert (config.start_date, config.end_date) == (date(1, 1, 1), date(9999, 12, 31))
+
+
+@pytest.mark.django_db
+def test_db_constraint_rejects_end_date_before_start_date(desk_event):
+ with pytest.raises(IntegrityError):
+ _config(desk_event, "역전", date(2026, 8, 16), date(2026, 8, 15))
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ ("start", "end"),
+ [
+ (date(2026, 8, 15), date(2026, 8, 15)), # 완전히 동일
+ (date(2026, 8, 14), date(2026, 8, 15)), # 앞쪽 걸침
+ (date(2026, 8, 16), date(2026, 8, 20)), # 뒤쪽 걸침
+ (date(2026, 8, 10), date(2026, 8, 20)), # 포함
+ (FOREVER_START, FOREVER_END), # 무기한
+ (FOREVER_START, date(2026, 8, 15)), # 시작 무제한
+ (date(2026, 8, 16), FOREVER_END), # 종료 무제한
+ ],
+)
+def test_filter_by_overlap_detects_conflicting_period(desk_event, start, end):
+ existing = _config(desk_event, "기존", date(2026, 8, 15), date(2026, 8, 16))
+
+ assert _overlapping(start, end).first() == existing
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize(
+ ("start", "end"),
+ [
+ (date(2026, 8, 17), date(2026, 8, 18)), # 직후
+ (date(2026, 8, 13), date(2026, 8, 14)), # 직전
+ (FOREVER_START, date(2026, 8, 14)), # 시작 무제한, 겹치지 않음
+ (date(2026, 8, 17), FOREVER_END), # 종료 무제한, 겹치지 않음
+ ],
+)
+def test_filter_by_overlap_allows_disjoint_period(desk_event, start, end):
+ _config(desk_event, "기존", date(2026, 8, 15), date(2026, 8, 16))
+
+ assert not _overlapping(start, end).exists()
+
+
+@pytest.mark.django_db
+def test_filter_by_overlap_excludes_given_pk(desk_event):
+ config = _config(desk_event, "기존", date(2026, 8, 15), date(2026, 8, 16))
+
+ assert not _overlapping(config.start_date, config.end_date, exclude_pk=config.pk).exists()
+
+
+@pytest.mark.django_db
+def test_filter_by_overlap_ignores_soft_deleted_config(desk_event):
+ _config(desk_event, "삭제됨", date(2026, 8, 15), date(2026, 8, 16)).delete()
+
+ assert not _overlapping(date(2026, 8, 15), date(2026, 8, 16)).exists()
+
+
+@pytest.mark.django_db
+def test_filter_by_date_picks_config_covering_the_date(desk_event):
+ _config(desk_event, "Day 1", date(2026, 8, 15), date(2026, 8, 15))
+ day2 = _config(desk_event, "Day 2", date(2026, 8, 16), date(2026, 8, 16))
+
+ assert _for_date(date(2026, 8, 16)) == day2
+
+
+@pytest.mark.django_db
+def test_filter_by_date_returns_nothing_when_no_config_covers_the_date(desk_event):
+ _config(desk_event, "Day 1", date(2026, 8, 15), date(2026, 8, 15))
+
+ assert _for_date(date(2026, 8, 20)) is None
+
+
+@pytest.mark.django_db
+def test_filter_by_date_matches_unbounded_config(desk_event):
+ config = _config(desk_event, "상시")
+
+ assert _for_date(date(2026, 8, 20)) == config
+
+
+@pytest.mark.django_db
+def test_filter_by_date_defaults_to_today(desk_event):
+ config = _config(desk_event, "상시")
+
+ assert _for_date() == config
+
+
+@pytest.mark.django_db
+def test_prefetch_active_targets_excludes_soft_deleted_categories(desk_event, ticket_product, non_ticket_product):
+ config = _config(desk_event, "상시")
+ config.categories.add(ticket_product.category, non_ticket_product.category)
+ non_ticket_product.category.delete()
+
+ fetched = RegistrationDeskConfig.objects.filter_active().prefetch_active_targets().get(pk=config.pk)
+
+ assert list(fetched.categories.all()) == [ticket_product.category]
+
+
+@pytest.mark.django_db
+def test_ordering_is_by_period(desk_event):
+ day2 = _config(desk_event, "Day 2", date(2026, 8, 16), date(2026, 8, 16))
+ day1 = _config(desk_event, "Day 1", date(2026, 8, 15), date(2026, 8, 15))
+
+ assert list(RegistrationDeskConfig.objects.filter_active()) == [day1, day2]
diff --git a/app/internal_api/test/configuration_test.py b/app/internal_api/test/configuration_test.py
new file mode 100644
index 0000000..6b9c35e
--- /dev/null
+++ b/app/internal_api/test/configuration_test.py
@@ -0,0 +1,81 @@
+from datetime import timedelta
+
+import pytest
+from core.util.dateutil import now_aware
+from django.urls import reverse
+from file.models import PublicFile
+from model_bakery import baker
+from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN
+from shop.order.models import OrderProductRelationTag
+
+CONFIGURATION_URL = reverse("v1:registration_desk:desk-configuration")
+
+
+@pytest.mark.django_db
+def test_configuration_rejects_anonymous(anon_client):
+ assert anon_client.get(CONFIGURATION_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_configuration_rejects_non_superuser(customer_client):
+ assert customer_client.get(CONFIGURATION_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_configuration_returns_config_of_today(staff_client, ticket_config, desk_event):
+ response = staff_client.get(CONFIGURATION_URL)
+
+ assert response.status_code == HTTP_200_OK
+ body = response.json()
+ assert set(body) == {"id", "name", "start_date", "end_date", "event", "available_tags"}
+ assert body["id"] == str(ticket_config.id)
+ assert body["name"] == "티켓"
+ assert (body["start_date"], body["end_date"]) == ("0001-01-01", "9999-12-31")
+ assert set(body["event"]) == {"id", "name", "event_start_at", "event_end_at", "logo_url"}
+ assert body["event"]["id"] == str(desk_event.id)
+ assert body["event"]["logo_url"] is None
+ assert body["available_tags"] == []
+
+
+@pytest.mark.django_db
+def test_configuration_rejects_when_no_config_applies_today(staff_client, ticket_config):
+ ticket_config.start_date = ticket_config.end_date = now_aware().date() + timedelta(days=1)
+ ticket_config.save()
+
+ response = staff_client.get(CONFIGURATION_URL)
+
+ assert response.status_code == HTTP_403_FORBIDDEN
+ assert "등록 데스크 설정" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_configuration_exposes_event_logo(staff_client, ticket_config, desk_event):
+ desk_event.logo = PublicFile.objects.create(
+ file="public/desk-logo.png", mimetype="image/png", hash="desk-logo", size=0
+ )
+ desk_event.save()
+
+ assert staff_client.get(CONFIGURATION_URL).json()["event"]["logo_url"] == desk_event.logo.file.url
+
+
+@pytest.mark.django_db
+def test_configuration_uses_event_of_current_desk_config(staff_client, ticket_config):
+ config_event = baker.make("event.Event", name="2025", event_start_at="2025-08-01T00:00:00Z")
+ baker.make("event.Event", name="2027", event_start_at="2027-08-01T00:00:00Z")
+ ticket_config.event = config_event
+ ticket_config.save()
+
+ assert staff_client.get(CONFIGURATION_URL).json()["event"]["id"] == str(config_event.id)
+
+
+@pytest.mark.django_db
+def test_configuration_lists_available_tags(staff_client, ticket_config):
+ OrderProductRelationTag.objects.create(code="volunteer", name="자원봉사자", priority=2)
+ OrderProductRelationTag.objects.create(code="speaker", name="발표자", priority=1)
+ OrderProductRelationTag.objects.create(code="deleted", name="삭제됨").delete()
+
+ tags = staff_client.get(CONFIGURATION_URL).json()["available_tags"]
+
+ # 소프트 삭제 태그는 빠지고, priority 순서로 고정된다.
+ assert [tag["code"] for tag in tags] == ["speaker", "volunteer"]
+ assert [tag["priority"] for tag in tags] == [1, 2]
diff --git a/app/internal_api/test/conftest.py b/app/internal_api/test/conftest.py
new file mode 100644
index 0000000..1c92bfc
--- /dev/null
+++ b/app/internal_api/test/conftest.py
@@ -0,0 +1,42 @@
+import pytest
+from event.models import Event
+from internal_api.models import RegistrationDeskConfig
+from model_bakery import baker
+from shop.conftest import ( # noqa: F401
+ anon_client,
+ customer_client,
+ customer_user,
+ donation_product,
+ mock_portone_req_cancel_payment,
+ modifiable_option_relation,
+ non_ticket_product,
+ option,
+ option_group,
+ order_factory,
+ staff_client,
+ staff_user,
+ tag,
+ ticket_product,
+)
+from shop.order.models import OrderProductRelation
+
+
+@pytest.fixture
+def used_opr(order_factory): # noqa: F811
+ """체크인(사용) 완료된 티켓 OPR."""
+ order = order_factory(status="completed")
+ order.products.update(status=OrderProductRelation.OrderProductStatus.used)
+ return order.products.get()
+
+
+@pytest.fixture
+def desk_event(db) -> Event:
+ return baker.make("event.Event", name="파이콘 한국 2026", event_start_at="2026-08-01T00:00:00Z")
+
+
+@pytest.fixture
+def ticket_config(ticket_product, desk_event) -> RegistrationDeskConfig: # noqa: F811
+ """`ticket_product` 의 카테고리만 집계하는, 기간 제한 없는 데스크 설정."""
+ config = RegistrationDeskConfig.objects.create(name="티켓", event=desk_event)
+ config.categories.add(ticket_product.category)
+ return config
diff --git a/app/internal_api/test/order_products_test.py b/app/internal_api/test/order_products_test.py
new file mode 100644
index 0000000..8662931
--- /dev/null
+++ b/app/internal_api/test/order_products_test.py
@@ -0,0 +1,268 @@
+import uuid
+
+import pytest
+from django.urls import reverse
+from freezegun import freeze_time
+from rest_framework.status import (
+ HTTP_200_OK,
+ HTTP_204_NO_CONTENT,
+ HTTP_400_BAD_REQUEST,
+ HTTP_403_FORBIDDEN,
+)
+from shop.order.models import OrderProductRelation, TicketInfo
+
+LIST_URL = reverse("v1:registration_desk:order-products-list")
+
+
+def _refund_url(opr_id) -> str:
+ return reverse("v1:registration_desk:order-products-refund", args=[opr_id])
+
+
+def _results(response) -> list[dict]:
+ return response.json()["results"]
+
+
+@pytest.mark.django_db
+def test_order_product_list_rejects_anonymous(anon_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+ assert anon_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_order_product_list_rejects_non_superuser(customer_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+ assert customer_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_order_product_list_requires_a_filter(staff_client):
+ response = staff_client.get(LIST_URL)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "order_product_relation_id" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_order_product_list_rejects_both_filters(staff_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+
+ response = staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id), "scancode": opr.scancode_token})
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_order_product_list_returns_detail_by_id(staff_client, order_factory, ticket_product):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+
+ response = staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["count"] == 1
+ [body] = _results(response)
+ assert body["id"] == str(opr.id)
+ assert body["short_id"] == opr.short_id
+ assert body["scancode_token"] == opr.scancode_token
+ assert body["is_ticket"] is True
+ assert body["status"] == OrderProductRelation.OrderProductStatus.paid
+ assert body["product"]["name"] == ticket_product.name
+ assert body["product"]["category"] == {"id": str(ticket_product.category_id), "name": "티켓"}
+ assert body["order"]["id"] == str(order.id)
+ assert body["ticket_info"] is None
+
+
+@pytest.mark.django_db
+def test_order_product_list_marks_non_ticket_product(staff_client, order_factory):
+ opr = order_factory(status="completed", is_ticket=False).products.get()
+
+ [body] = _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}))
+
+ assert body["is_ticket"] is False
+
+
+@pytest.mark.django_db
+def test_order_product_list_returns_ticket_info(staff_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+ TicketInfo.objects.create(
+ order_product_relation=opr,
+ name="김참가",
+ phone="010-9999-8888",
+ email="attendee@example.com",
+ organization="PSK",
+ )
+
+ [body] = _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}))
+
+ assert body["ticket_info"] == {
+ "name": "김참가",
+ "email": "attendee@example.com",
+ "phone": "010-9999-8888",
+ "organization": "PSK",
+ }
+
+
+@pytest.mark.django_db
+def test_order_product_list_returns_null_ticket_info_when_soft_deleted(staff_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+ TicketInfo.objects.create(
+ order_product_relation=opr, name="김참가", phone="010-9999-8888", email="attendee@example.com"
+ ).delete()
+
+ [body] = _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)}))
+
+ assert body["ticket_info"] is None
+
+
+@pytest.mark.django_db
+def test_order_product_list_returns_empty_for_unknown_id(staff_client):
+ response = staff_client.get(LIST_URL, {"order_product_relation_id": str(uuid.uuid4())})
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json() == {"count": 0, "next": None, "previous": None, "results": []}
+
+
+@pytest.mark.django_db
+def test_order_product_list_returns_empty_for_soft_deleted_relation(staff_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+ opr.delete()
+
+ assert _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) == []
+
+
+@pytest.mark.django_db
+def test_order_product_list_excludes_cart_without_payment(staff_client, order_factory):
+ opr = order_factory(status="cart").products.get()
+
+ assert _results(staff_client.get(LIST_URL, {"order_product_relation_id": str(opr.id)})) == []
+
+
+@pytest.mark.django_db
+def test_order_product_list_finds_by_scancode(staff_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+
+ [body] = _results(staff_client.get(LIST_URL, {"scancode": opr.scancode_token}))
+
+ assert body["id"] == str(opr.id)
+
+
+@pytest.mark.django_db
+@freeze_time("2026-08-17 09:00:00+09:00")
+def test_order_product_scancode_can_include_same_participants_program_tickets(
+ staff_client, order_factory, ticket_product, ticket_config
+):
+ ticket_product.category.name = "튜토리얼"
+ ticket_product.category.save(update_fields={"name"})
+ first = order_factory(status="completed").products.get()
+ second = order_factory(status="completed").products.get()
+ other = order_factory(status="completed").products.get()
+ TicketInfo.objects.create(
+ order_product_relation=first,
+ name="김참가",
+ phone="010-9999-8888",
+ email=" Attendee@Example.com ",
+ )
+ TicketInfo.objects.create(
+ order_product_relation=second,
+ name="김참가",
+ phone="010-9999-8888",
+ email="attendee@example.com",
+ )
+ TicketInfo.objects.create(
+ order_product_relation=other,
+ name="다른 참가자",
+ phone="010-1111-2222",
+ email="other@example.com",
+ )
+
+ response = staff_client.get(
+ LIST_URL,
+ {"scancode": first.scancode_token, "include_related_ticketinfo": "true"},
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert {result["id"] for result in _results(response)} == {str(first.id), str(second.id)}
+ assert [result["id"] for result in _results(staff_client.get(LIST_URL, {"scancode": first.scancode_token}))] == [
+ str(first.id)
+ ]
+
+
+@pytest.mark.django_db
+@freeze_time("2026-08-18 09:00:00+09:00")
+def test_related_ticketinfo_scancode_falls_back_to_one_ticket_after_program_day(
+ staff_client, order_factory, ticket_product, ticket_config
+):
+ ticket_product.category.name = "튜토리얼"
+ ticket_product.category.save(update_fields={"name"})
+ first = order_factory(status="completed").products.get()
+ second = order_factory(status="completed").products.get()
+ common = {"name": "김참가", "phone": "010-9999-8888", "email": "attendee@example.com"}
+ TicketInfo.objects.create(order_product_relation=first, **common)
+ TicketInfo.objects.create(order_product_relation=second, **common)
+
+ response = staff_client.get(
+ LIST_URL,
+ {"scancode": first.scancode_token, "include_related_ticketinfo": "true"},
+ )
+
+ assert [result["id"] for result in _results(response)] == [str(first.id)]
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("token", ["그냥문자열", "opr:short", "order:AbCdEf:salt"])
+def test_order_product_list_rejects_malformed_scancode(staff_client, token):
+ response = staff_client.get(LIST_URL, {"scancode": token})
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "스캔코드" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_order_product_list_returns_empty_for_tampered_salt(staff_client, order_factory):
+ opr = order_factory(status="completed").products.get()
+ prefix, short_id, _salt = opr.scancode_token.split(":")
+
+ response = staff_client.get(LIST_URL, {"scancode": f"{prefix}:{short_id}:tampered"})
+
+ assert response.status_code == HTTP_200_OK
+ assert _results(response) == []
+
+
+@pytest.mark.django_db
+def test_order_product_refund_rejects_non_superuser(customer_client, order_factory, mock_portone_req_cancel_payment):
+ opr = order_factory(status="completed").products.get()
+
+ response = customer_client.delete(_refund_url(opr.id))
+
+ assert response.status_code == HTTP_403_FORBIDDEN
+ mock_portone_req_cancel_payment.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_order_product_refund_marks_only_that_product_refunded(
+ ticket_config, staff_client, order_factory, ticket_product, mock_portone_req_cancel_payment
+):
+ order = order_factory(status="completed")
+ target = order.products.get()
+ other = OrderProductRelation.objects.create(
+ order=order, product=ticket_product, price=ticket_product.price, status=target.status
+ )
+
+ response = staff_client.delete(_refund_url(target.id))
+
+ assert response.status_code == HTTP_204_NO_CONTENT
+ mock_portone_req_cancel_payment.assert_called_once()
+ target.refresh_from_db()
+ other.refresh_from_db()
+ assert target.status == OrderProductRelation.OrderProductStatus.refunded
+ assert other.status == OrderProductRelation.OrderProductStatus.paid
+
+
+@pytest.mark.django_db
+def test_order_product_refund_rejects_used_product(
+ ticket_config, staff_client, used_opr, mock_portone_req_cancel_payment
+):
+ response = staff_client.delete(_refund_url(used_opr.id))
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ mock_portone_req_cancel_payment.assert_not_called()
diff --git a/app/internal_api/test/orders_test.py b/app/internal_api/test/orders_test.py
new file mode 100644
index 0000000..9d125fa
--- /dev/null
+++ b/app/internal_api/test/orders_test.py
@@ -0,0 +1,509 @@
+from datetime import timedelta
+
+import pytest
+from core.util.dateutil import now_aware
+from django.urls import reverse
+from rest_framework.status import HTTP_200_OK, HTTP_204_NO_CONTENT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN
+from shop.order.models import OrderProductRelation, OrderProductRelationTag, TicketInfo
+from shop.product.models import OptionGroup
+
+ORDERS_URL = reverse("v1:registration_desk:orders-list")
+SEARCH = {"keywords": "홍길동"}
+
+
+def _detail_url(order_id) -> str:
+ return reverse("v1:registration_desk:orders-detail", args=[order_id])
+
+
+def _refund_url(order_id) -> str:
+ return reverse("v1:registration_desk:orders-refund", args=[order_id])
+
+
+@pytest.mark.django_db
+def test_orders_rejects_anonymous(anon_client):
+ assert anon_client.get(ORDERS_URL, SEARCH).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_orders_rejects_non_superuser(customer_client, order_factory):
+ order_factory(status="completed")
+ assert customer_client.get(ORDERS_URL, SEARCH).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_orders_list_returns_paginated_paid_orders(staff_client, order_factory):
+ order = order_factory(status="completed")
+ order_factory(status="cart") # 결제 이력 없음 → 제외
+
+ response = staff_client.get(ORDERS_URL, SEARCH)
+
+ assert response.status_code == HTTP_200_OK
+ body = response.json()
+ assert body["count"] == 1
+ assert [result["id"] for result in body["results"]] == [str(order.id)]
+ assert body["results"][0]["customer_info"]["name"] == "홍길동"
+
+
+@pytest.mark.django_db
+def test_orders_list_exposes_ticket_info_and_product_category(staff_client, order_factory, ticket_product):
+ order = order_factory(status="completed")
+ TicketInfo.objects.create(
+ order_product_relation=order.products.get(),
+ name="김참가",
+ phone="010-9999-8888",
+ email="attendee@example.com",
+ organization="PSK",
+ )
+
+ product = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]["products"][0]
+
+ assert product["ticket_info"]["name"] == "김참가"
+ assert product["scancode_token"] == order.products.get().scancode_token
+ assert product["is_ticket"] is True
+ assert product["product"]["category"] == {"id": str(ticket_product.category_id), "name": "티켓"}
+
+
+@pytest.mark.django_db
+def test_orders_list_returns_null_ticket_info_when_absent(staff_client, order_factory):
+ order_factory(status="completed")
+
+ product = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]["products"][0]
+
+ assert product["ticket_info"] is None
+
+
+@pytest.mark.django_db
+def test_orders_list_excludes_soft_deleted_products(staff_client, order_factory):
+ order = order_factory(status="completed")
+ order.products.get().delete()
+
+ assert staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]["products"] == []
+
+
+@pytest.mark.django_db
+def test_orders_list_filters_by_keyword(staff_client, order_factory):
+ order = order_factory(status="completed")
+
+ assert staff_client.get(ORDERS_URL, {"keywords": "홍길동"}).json()["count"] == 1
+ assert staff_client.get(ORDERS_URL, {"keywords": "없는사람"}).json()["count"] == 0
+ assert staff_client.get(ORDERS_URL, {"order_id": str(order.id)}).json()["count"] == 1
+
+
+@pytest.mark.django_db
+def test_orders_list_filters_by_user_unique_id(staff_client, order_factory):
+ orders = [order_factory(status="completed"), order_factory(status="completed")]
+
+ response = staff_client.get(ORDERS_URL, {"user_unique_id": str(orders[0].user.unique_id)})
+
+ assert {result["id"] for result in response.json()["results"]} == {str(order.id) for order in orders}
+
+
+@pytest.mark.django_db
+@pytest.mark.parametrize("keyword", ["김참가", "attendee@example.com", "010-9999-8888", "PSK"])
+def test_orders_list_filters_by_ticket_info(staff_client, order_factory, keyword):
+ order = order_factory(status="completed")
+ TicketInfo.objects.create(
+ order_product_relation=order.products.get(),
+ name="김참가",
+ phone="010-9999-8888",
+ email="attendee@example.com",
+ organization="PSK",
+ )
+ order_factory(status="completed") # 참가자 정보 없는 다른 주문
+
+ response = staff_client.get(ORDERS_URL, {"keywords": keyword})
+
+ assert [result["id"] for result in response.json()["results"]] == [str(order.id)]
+
+
+@pytest.mark.django_db
+def test_orders_list_ignores_ticket_info_of_soft_deleted_product(staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+ TicketInfo.objects.create(
+ order_product_relation=opr, name="김참가", phone="010-9999-8888", email="attendee@example.com"
+ )
+ opr.delete()
+
+ assert staff_client.get(ORDERS_URL, {"keywords": "김참가"}).json()["count"] == 0
+
+
+@pytest.mark.django_db
+def test_orders_patch_checks_in_paid_product(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+
+ response = staff_client.patch(
+ _detail_url(order.id),
+ {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.used}]},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_200_OK
+ opr.refresh_from_db()
+ assert opr.status == OrderProductRelation.OrderProductStatus.used
+
+
+@pytest.mark.django_db
+def test_orders_patch_can_revert_check_in(ticket_config, staff_client, used_opr):
+ response = staff_client.patch(
+ _detail_url(used_opr.order_id),
+ {"products": [{"id": str(used_opr.id), "status": OrderProductRelation.OrderProductStatus.paid}]},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_200_OK
+ used_opr.refresh_from_db()
+ assert used_opr.status == OrderProductRelation.OrderProductStatus.paid
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_forbidden_status_transition(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+
+ response = staff_client.patch(
+ _detail_url(order.id),
+ {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.refunded}]},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ opr.refresh_from_db()
+ assert opr.status == OrderProductRelation.OrderProductStatus.paid
+
+
+@pytest.mark.django_db
+def test_orders_refund_cancels_payment_and_marks_products_refunded(
+ ticket_config, staff_client, order_factory, mock_portone_req_cancel_payment
+):
+ order = order_factory(status="completed")
+
+ response = staff_client.delete(_refund_url(order.id))
+
+ assert response.status_code == HTTP_204_NO_CONTENT
+ mock_portone_req_cancel_payment.assert_called_once()
+ assert order.products.get().status == OrderProductRelation.OrderProductStatus.refunded
+
+
+@pytest.mark.django_db
+def test_orders_refund_rejects_non_superuser(customer_client, order_factory, mock_portone_req_cancel_payment):
+ order = order_factory(status="completed")
+
+ response = customer_client.delete(_refund_url(order.id))
+
+ assert response.status_code == HTTP_403_FORBIDDEN
+ mock_portone_req_cancel_payment.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_orders_does_not_expose_destroy(staff_client, order_factory):
+ order = order_factory(status="completed")
+
+ assert staff_client.delete(_detail_url(order.id)).status_code == 405
+
+
+def _patch_ticket_info(client, order, opr, ticket_info):
+ return client.patch(
+ _detail_url(order.id),
+ {"products": [{"id": str(opr.id), "ticket_info": ticket_info}]},
+ format="json",
+ )
+
+
+VALID_TICKET_INFO = {
+ "name": "김참가",
+ "phone": "010-9999-8888",
+ "email": "attendee@example.com",
+ "organization": "PSK",
+}
+
+
+@pytest.mark.django_db
+def test_orders_patch_creates_ticket_info(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+
+ response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO)
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["products"][0]["ticket_info"] == VALID_TICKET_INFO
+ assert TicketInfo.objects.get(order_product_relation=opr).name == "김참가"
+
+
+@pytest.mark.django_db
+def test_orders_patch_updates_existing_ticket_info(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+ ticket_info = TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO)
+
+ response = _patch_ticket_info(staff_client, order, opr, {**VALID_TICKET_INFO, "name": "이참가"})
+
+ assert response.status_code == HTTP_200_OK
+ ticket_info.refresh_from_db()
+ assert ticket_info.name == "이참가"
+ assert TicketInfo.objects.filter(order_product_relation=opr).count() == 1
+
+
+@pytest.mark.django_db
+def test_orders_patch_restores_soft_deleted_ticket_info(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+ ticket_info = TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO)
+ ticket_info.delete()
+
+ response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO)
+
+ # OneToOne 이라 새 row 를 만들 수 없어, 삭제된 row 를 되살려 덮어쓴다.
+ assert response.status_code == HTTP_200_OK
+ ticket_info.refresh_from_db()
+ assert ticket_info.deleted_at is None
+ assert TicketInfo.objects.filter(order_product_relation=opr).count() == 1
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_null_ticket_info(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+ ticket_info = TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO)
+
+ response = _patch_ticket_info(staff_client, order, opr, None)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ ticket_info.refresh_from_db()
+ assert ticket_info.deleted_at is None
+
+
+@pytest.mark.django_db
+def test_orders_patch_keeps_ticket_info_when_key_is_omitted(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+ TicketInfo.objects.create(order_product_relation=opr, **VALID_TICKET_INFO)
+
+ response = staff_client.patch(
+ _detail_url(order.id),
+ {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.used}]},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json()["products"][0]["ticket_info"] == VALID_TICKET_INFO
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_ticket_info_for_non_ticket_product(
+ ticket_config, staff_client, order_factory, non_ticket_product
+):
+ order = order_factory(status="completed", is_ticket=False)
+ opr = order.products.get()
+ # 범위 검증(403)이 아니라 티켓 여부 검증(400)에 걸리도록 굿즈 카테고리도 설정에 포함시킨다.
+ ticket_config.categories.add(non_ticket_product.category)
+
+ response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert not TicketInfo.objects.filter(order_product_relation=opr).exists()
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_malformed_phone(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ opr = order.products.get()
+
+ response = _patch_ticket_info(staff_client, order, opr, {**VALID_TICKET_INFO, "phone": "전화번호아님"})
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert not TicketInfo.objects.filter(order_product_relation=opr).exists()
+
+
+@pytest.mark.django_db
+def test_orders_list_requires_a_filter(staff_client, order_factory):
+ order_factory(status="completed")
+
+ response = staff_client.get(ORDERS_URL)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert "keywords" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_orders_list_rejects_pagination_only_request(staff_client, order_factory):
+ order_factory(status="completed")
+
+ assert staff_client.get(ORDERS_URL, {"page": 1, "page_size": 10}).status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_orders_list_rejects_blank_filter(staff_client, order_factory):
+ order_factory(status="completed")
+
+ assert staff_client.get(ORDERS_URL, {"keywords": " "}).status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_ticket_info_for_refunded_product(ticket_config, staff_client, order_factory):
+ order = order_factory(status="refunded")
+ opr = order.products.get()
+
+ response = _patch_ticket_info(staff_client, order, opr, VALID_TICKET_INFO)
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+ assert not TicketInfo.objects.filter(order_product_relation=opr).exists()
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_out_of_scope_product(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed", is_ticket=False)
+ opr = order.products.get()
+
+ response = staff_client.patch(
+ _detail_url(order.id),
+ {"products": [{"id": str(opr.id), "status": OrderProductRelation.OrderProductStatus.used}]},
+ format="json",
+ )
+
+ assert response.status_code == HTTP_403_FORBIDDEN
+ opr.refresh_from_db()
+ assert opr.status == OrderProductRelation.OrderProductStatus.paid
+
+
+@pytest.mark.django_db
+def test_orders_patch_allows_reading_out_of_scope_order(ticket_config, staff_client, order_factory):
+ order_factory(status="completed", is_ticket=False)
+
+ assert staff_client.get(ORDERS_URL, SEARCH).json()["count"] == 1
+
+
+@pytest.mark.django_db
+def test_orders_refund_rejects_out_of_scope_order(
+ ticket_config, staff_client, order_factory, mock_portone_req_cancel_payment
+):
+ order = order_factory(status="completed", is_ticket=False)
+
+ response = staff_client.delete(_refund_url(order.id))
+
+ assert response.status_code == HTTP_403_FORBIDDEN
+ mock_portone_req_cancel_payment.assert_not_called()
+
+
+@pytest.mark.django_db
+def test_orders_exposes_desk_refund_reasons(ticket_config, staff_client, order_factory):
+ order_factory(status="completed")
+
+ body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]
+
+ assert body["not_fully_refundable_reason"] is None
+ assert body["products"][0]["not_refundable_reason"] is None
+ assert body["created_at"] is not None
+
+
+@pytest.mark.django_db
+def test_orders_refund_reason_ignores_date_limit(ticket_config, staff_client, order_factory, ticket_product):
+ # 데스크 환불은 환불 기한을 우회하므로, 기한 만료는 "환불 불가 사유" 로 응답하지 않는다.
+ ticket_product.refundable_ends_at = now_aware() - timedelta(days=1)
+ ticket_product.save()
+ order_factory(status="completed")
+
+ body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]
+
+ assert body["not_fully_refundable_reason"] is None
+ assert body["products"][0]["not_refundable_reason"] is None
+
+
+@pytest.mark.django_db
+def test_orders_refund_reason_reports_used_product(ticket_config, staff_client, used_opr):
+ body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]
+
+ assert body["products"][0]["not_refundable_reason"] is not None
+ assert body["not_fully_refundable_reason"] is not None
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_option_change_for_used_product(ticket_config, staff_client, modifiable_option_relation):
+ opr = modifiable_option_relation.order_product_relation
+ opr.status = OrderProductRelation.OrderProductStatus.used
+ opr.save()
+
+ response = staff_client.patch(
+ _detail_url(opr.order_id),
+ {
+ "products": [
+ {
+ "id": str(opr.id),
+ "options": [{"id": str(modifiable_option_relation.id), "custom_response": "변경"}],
+ }
+ ]
+ },
+ format="json",
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_orders_patch_allows_null_custom_response(ticket_config, staff_client, modifiable_option_relation):
+ opr = modifiable_option_relation.order_product_relation
+
+ response = staff_client.patch(
+ _detail_url(opr.order_id),
+ {
+ "products": [
+ {
+ "id": str(opr.id),
+ "options": [{"id": str(modifiable_option_relation.id), "custom_response": None}],
+ }
+ ]
+ },
+ format="json",
+ )
+
+ assert response.status_code == HTTP_200_OK
+ modifiable_option_relation.refresh_from_db()
+ assert modifiable_option_relation.custom_response is None
+
+
+@pytest.mark.django_db
+def test_orders_patch_rejects_null_custom_response_when_required(
+ ticket_config, staff_client, modifiable_option_relation
+):
+ group = modifiable_option_relation.product_option_group
+ group.placeholder_mode = OptionGroup.PlaceholderMode.REQUIRED
+ group.save()
+
+ response = staff_client.patch(
+ _detail_url(modifiable_option_relation.order_product_relation.order_id),
+ {
+ "products": [
+ {
+ "id": str(modifiable_option_relation.order_product_relation_id),
+ "options": [{"id": str(modifiable_option_relation.id), "custom_response": None}],
+ }
+ ]
+ },
+ format="json",
+ )
+
+ assert response.status_code == HTTP_400_BAD_REQUEST
+
+
+@pytest.mark.django_db
+def test_orders_list_exposes_tags(ticket_config, staff_client, order_factory):
+ order = order_factory(status="completed")
+ tag = OrderProductRelationTag.objects.create(code="speaker", name="발표자", priority=1)
+ tag.order_product_relations.add(order.products.get())
+
+ body = staff_client.get(ORDERS_URL, SEARCH).json()["results"][0]
+
+ assert body["products"][0]["tags"] == [{"id": str(tag.id), "code": "speaker", "name": "발표자", "priority": 1}]
+
+
+@pytest.mark.django_db
+def test_orders_patch_ignores_order_name(ticket_config, staff_client, order_factory):
+ # 상품 외 필드가 열려 있으면 범위 검증 없이 주문을 바꿀 수 있다.
+ order = order_factory(status="completed")
+
+ response = staff_client.patch(_detail_url(order.id), {"name": "바뀐 이름"}, format="json")
+
+ assert response.status_code == HTTP_200_OK
+ order.refresh_from_db()
+ assert order.name != "바뀐 이름"
diff --git a/app/internal_api/test/session_test.py b/app/internal_api/test/session_test.py
new file mode 100644
index 0000000..0a1bb68
--- /dev/null
+++ b/app/internal_api/test/session_test.py
@@ -0,0 +1,70 @@
+import pytest
+from django.conf import settings
+from django.urls import reverse
+from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN, HTTP_405_METHOD_NOT_ALLOWED
+from rest_framework.test import APIClient
+
+SESSION_URL = reverse("v1:registration_desk:desk-session")
+LOGIN_URL = reverse("headless:browser:account:login")
+
+
+@pytest.mark.django_db
+def test_session_rejects_anonymous(anon_client):
+ assert anon_client.get(SESSION_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_session_rejects_non_superuser(customer_client):
+ assert customer_client.get(SESSION_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_session_returns_staff_profile(staff_client, staff_user):
+ staff_user.nickname = "등록데스크"
+ staff_user.save()
+
+ response = staff_client.get(SESSION_URL)
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json() == {
+ "id": staff_user.id,
+ "unique_id": str(staff_user.unique_id),
+ "username": staff_user.username,
+ "nickname": "등록데스크",
+ "email": staff_user.email,
+ }
+
+
+@pytest.mark.django_db
+def test_session_sets_csrf_cookie(staff_client, settings):
+ assert settings.CSRF_COOKIE_NAME in staff_client.get(SESSION_URL).cookies
+
+
+@pytest.mark.django_db
+def test_session_sets_csrf_cookie_even_when_forbidden(anon_client, settings):
+ # 로그인 직후 곧바로 PATCH 를 보낼 수 있어야 하므로 403 응답에도 쿠키를 실는다.
+ assert settings.CSRF_COOKIE_NAME in anon_client.get(SESSION_URL).cookies
+
+
+@pytest.mark.django_db
+def test_session_does_not_handle_logout(staff_client):
+ assert staff_client.delete(SESSION_URL).status_code == HTTP_405_METHOD_NOT_ALLOWED
+
+
+@pytest.mark.django_db
+def test_email_password_login_creates_registration_desk_session(staff_user):
+ staff_user.set_password("registration-desk-password")
+ staff_user.save()
+ client = APIClient(enforce_csrf_checks=True)
+ csrf_response = client.get(SESSION_URL)
+ csrf_token = csrf_response.cookies[settings.CSRF_COOKIE_NAME].value
+
+ login_response = client.post(
+ LOGIN_URL,
+ {"email": staff_user.email, "password": "registration-desk-password"},
+ format="json",
+ HTTP_X_CSRFTOKEN=csrf_token,
+ )
+
+ assert login_response.status_code == HTTP_200_OK
+ assert client.get(SESSION_URL).status_code == HTTP_200_OK
diff --git a/app/internal_api/test/statistics_test.py b/app/internal_api/test/statistics_test.py
new file mode 100644
index 0000000..0d29c31
--- /dev/null
+++ b/app/internal_api/test/statistics_test.py
@@ -0,0 +1,122 @@
+from datetime import timedelta
+
+import pytest
+from core.util.dateutil import now_aware
+from django.urls import reverse
+from internal_api.models import RegistrationDeskConfig
+from rest_framework.status import HTTP_200_OK, HTTP_403_FORBIDDEN
+from shop.order.models import OrderProductRelation
+
+STATISTICS_URL = reverse("v1:registration_desk:desk-statistics")
+
+
+@pytest.mark.django_db
+def test_statistics_rejects_anonymous(anon_client):
+ assert anon_client.get(STATISTICS_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_statistics_rejects_non_superuser(customer_client):
+ assert customer_client.get(STATISTICS_URL).status_code == HTTP_403_FORBIDDEN
+
+
+@pytest.mark.django_db
+def test_statistics_rejects_when_no_config_applies_today(staff_client, order_factory, ticket_config):
+ order_factory(status="completed")
+ ticket_config.start_date = ticket_config.end_date = now_aware().date() + timedelta(days=1)
+ ticket_config.save()
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.status_code == HTTP_403_FORBIDDEN
+ assert "등록 데스크 설정" in str(response.json())
+
+
+@pytest.mark.django_db
+def test_statistics_counts_used_as_registered_and_paid_as_waiting(staff_client, order_factory, ticket_config, used_opr):
+ order_factory(status="completed") # paid → 대기
+ order_factory(status="refunded") # 환불 → 집계 제외
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.status_code == HTTP_200_OK
+ assert response.json() == {
+ "registration_target_count": 2,
+ "registered_count": 1,
+ "waiting_count": 1,
+ }
+
+
+@pytest.mark.django_db
+def test_statistics_excludes_pending_cart_products(staff_client, order_factory, ticket_config):
+ order_factory(status="cart")
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0}
+
+
+@pytest.mark.django_db
+def test_statistics_counts_only_config_categories(staff_client, order_factory, ticket_config):
+ order_factory(status="completed")
+ order_factory(status="completed", is_ticket=False) # 설정(티켓 카테고리) 밖
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.json() == {"registration_target_count": 1, "registered_count": 0, "waiting_count": 1}
+
+
+@pytest.mark.django_db
+def test_statistics_counts_nothing_when_config_has_no_categories(staff_client, order_factory, desk_event):
+ # 빈 카테고리를 "전체" 로 해석하지 않는다 — 설정 실수의 영향을 줄이기 위한 의도적 동작.
+ RegistrationDeskConfig.objects.create(name="빈 설정", event=desk_event)
+ order_factory(status="completed")
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0}
+
+
+@pytest.mark.django_db
+def test_statistics_excludes_non_ticket_category(staff_client, order_factory, non_ticket_product, desk_event):
+ config = RegistrationDeskConfig.objects.create(name="굿즈", event=desk_event)
+ config.categories.add(non_ticket_product.category)
+ order_factory(status="completed", is_ticket=False)
+
+ assert staff_client.get(STATISTICS_URL).json()["registration_target_count"] == 0
+
+
+@pytest.mark.django_db
+def test_statistics_uses_config_matching_today(staff_client, order_factory, ticket_config, ticket_product, desk_event):
+ order_factory(status="completed")
+ yesterday = now_aware().date() - timedelta(days=1)
+ ticket_config.start_date = ticket_config.end_date = yesterday
+ ticket_config.save()
+ # 어제 설정은 티켓을 담고 있지만, 오늘 설정은 비어 있어 아무것도 집계되지 않아야 한다.
+ RegistrationDeskConfig.objects.create(
+ name="오늘", event=desk_event, start_date=now_aware().date(), end_date=now_aware().date()
+ )
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0}
+
+
+@pytest.mark.django_db
+def test_statistics_ignores_soft_deleted_order_products(staff_client, order_factory, ticket_config):
+ order = order_factory(status="completed")
+ OrderProductRelation.objects.filter(order=order).delete()
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.json() == {"registration_target_count": 0, "registered_count": 0, "waiting_count": 0}
+
+
+@pytest.mark.django_db
+def test_statistics_ignores_soft_deleted_config_categories(staff_client, order_factory, ticket_config, ticket_product):
+ order_factory(status="completed")
+ ticket_product.category.delete()
+
+ response = staff_client.get(STATISTICS_URL)
+
+ assert response.json()["registration_target_count"] == 0
diff --git a/app/internal_api/urls.py b/app/internal_api/urls.py
index 8140361..17029e0 100644
--- a/app/internal_api/urls.py
+++ b/app/internal_api/urls.py
@@ -1,8 +1,3 @@
from django.urls import include, path
-from internal_api import views
-from rest_framework import routers
-router = routers.SimpleRouter()
-router.register("desk-support", views.DeskSupportViewSet, basename="desk-support")
-
-urlpatterns = [path("", include(router.urls))]
+urlpatterns = [path("registration-desk/", include("internal_api.registration_desk.urls"))]
diff --git a/app/internal_api/views.py b/app/internal_api/views.py
deleted file mode 100644
index 17a7de4..0000000
--- a/app/internal_api/views.py
+++ /dev/null
@@ -1,121 +0,0 @@
-import typing
-
-from core.authn.api_key import APIKeyAuthentication
-from core.authz.api_key import RegistrationDeskAPIKeyPermission
-from core.const.tag import OpenAPITag
-from django.db import models, transaction
-from django.utils.decorators import method_decorator
-from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema
-from drf_standardized_errors.openapi_serializers import (
- Error403Serializer,
- Error404Serializer,
- ValidationErrorResponseSerializer,
-)
-from internal_api.filters import DeskSupportFilterSet
-from internal_api.serializers import DeskSupportSerializer
-from rest_framework import mixins, request, response, status, viewsets
-from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
-from shop.payment_history.models import PaymentHistory
-from shop.serializers.refund import OrderTotalRefundSerializer
-
-
-@method_decorator(
- name="list",
- decorator=extend_schema(
- summary="주문 검색",
- tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
- responses={
- status.HTTP_200_OK: DeskSupportSerializer(many=True),
- status.HTTP_403_FORBIDDEN: Error403Serializer,
- },
- ),
-)
-@method_decorator(
- name="partial_update",
- decorator=extend_schema(
- summary="주문 수정",
- tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
- responses={
- status.HTTP_200_OK: DeskSupportSerializer,
- status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
- status.HTTP_403_FORBIDDEN: Error403Serializer,
- status.HTTP_404_NOT_FOUND: Error404Serializer,
- },
- ),
-)
-@method_decorator(name="partial_update", decorator=transaction.atomic)
-class DeskSupportViewSet(
- mixins.ListModelMixin,
- mixins.UpdateModelMixin,
- mixins.DestroyModelMixin,
- viewsets.GenericViewSet,
-):
- queryset = (
- Order.objects.filter_has_payment_histories()
- .select_related("customer_info")
- .prefetch_related(
- models.Prefetch(
- lookup="products",
- queryset=(
- OrderProductRelation.objects.filter_active()
- .select_related("product")
- .prefetch_related(
- models.Prefetch(
- lookup="options",
- queryset=OrderProductOptionRelation.objects.filter_active().select_related(
- "product_option_group", "product_option"
- ),
- )
- )
- ),
- ),
- models.Prefetch(
- "payment_histories",
- queryset=PaymentHistory.objects.filter_active(),
- to_attr="_active_payment_histories",
- ),
- )
- .order_by("-created_at")
- )
- filterset_class = DeskSupportFilterSet
- serializer_class = DeskSupportSerializer
- authentication_classes = [APIKeyAuthentication]
- permission_classes = [RegistrationDeskAPIKeyPermission]
- http_method_names = ["get", "patch", "delete"]
-
- @extend_schema(
- summary="주문 전체 환불",
- tags=[OpenAPITag.EXT_REGISTRATION_DESK_API],
- parameters=[
- OpenApiParameter(
- name="otp",
- type=OpenApiTypes.STR,
- location=OpenApiParameter.QUERY,
- allow_blank=False,
- required=True,
- description="환불 승인자의 6자리 TOTP 코드",
- ),
- ],
- responses={
- status.HTTP_204_NO_CONTENT: None,
- status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer,
- status.HTTP_403_FORBIDDEN: Error403Serializer,
- status.HTTP_404_NOT_FOUND: Error404Serializer,
- },
- )
- @transaction.atomic
- def destroy(
- self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any]
- ) -> response.Response:
- """
- Order의 사용 및 환불하지 않은 상품을 refunded 상태로 변경하고, 결제 취소를 요청합니다.
- 일반 전체 환불 API와의 차이점은, 환불 시간에 대한 제약이 없고, 환불 승인자의 OTP 코드가 필요하다는 점입니다.
- """
- serializer = OrderTotalRefundSerializer(
- instance=self.get_object(),
- data={"totp": request.query_params.get("otp")},
- context={"check_refundable_date": False},
- )
- serializer.is_valid(raise_exception=True)
- serializer.refund()
- return response.Response(status=status.HTTP_204_NO_CONTENT)
diff --git a/app/notification/migrations/0005_sent_to_dedupe_key.py b/app/notification/migrations/0005_sent_to_dedupe_key.py
new file mode 100644
index 0000000..0892cff
--- /dev/null
+++ b/app/notification/migrations/0005_sent_to_dedupe_key.py
@@ -0,0 +1,65 @@
+# Generated by Django 6.0.6 on 2026-08-09 05:49
+
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("notification", "0004_seed_payment_completed_email_template"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.RemoveConstraint(
+ model_name="emailnotificationhistorysentto",
+ name="uq_notification_emailnotificationhistorysentto_history_recipient",
+ ),
+ migrations.RemoveConstraint(
+ model_name="nhncloudkakaoalimtalknotificationhistorysentto",
+ name="uq_notification_nhncloudkakaoalimtalknotificationhistorysentto_history_recipient",
+ ),
+ migrations.RemoveConstraint(
+ model_name="nhncloudsmsnotificationhistorysentto",
+ name="uq_notification_nhncloudsmsnotificationhistorysentto_history_recipient",
+ ),
+ migrations.AddField(
+ model_name="emailnotificationhistorysentto",
+ name="dedupe_key",
+ field=models.CharField(blank=True, default="", max_length=64),
+ ),
+ migrations.AddField(
+ model_name="nhncloudkakaoalimtalknotificationhistorysentto",
+ name="dedupe_key",
+ field=models.CharField(blank=True, default="", max_length=64),
+ ),
+ migrations.AddField(
+ model_name="nhncloudsmsnotificationhistorysentto",
+ name="dedupe_key",
+ field=models.CharField(blank=True, default="", max_length=64),
+ ),
+ migrations.AddConstraint(
+ model_name="emailnotificationhistorysentto",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("history", "recipient", "dedupe_key"),
+ name="uq_notification_emailnotificationhistorysentto_history_recipient",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="nhncloudkakaoalimtalknotificationhistorysentto",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("history", "recipient", "dedupe_key"),
+ name="uq_notification_nhncloudkakaoalimtalknotificationhistorysentto_history_recipient",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="nhncloudsmsnotificationhistorysentto",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("history", "recipient", "dedupe_key"),
+ name="uq_notification_nhncloudsmsnotificationhistorysentto_history_recipient",
+ ),
+ ),
+ ]
diff --git a/app/notification/models/base.py b/app/notification/models/base.py
index 00ac9ad..9df4cbf 100644
--- a/app/notification/models/base.py
+++ b/app/notification/models/base.py
@@ -35,6 +35,7 @@ class NotificationStatus(models.TextChoices):
class Recipient(TypedDict):
recipient: str
context: NotRequired[dict[str, Any]]
+ dedupe_key: NotRequired[str]
def _walk_strings(value: Any, fn: Any) -> Any:
@@ -52,6 +53,7 @@ class NotificationTemplateBase(BaseAbstractModel):
variable_start: ClassVar[str] = "{{"
variable_end: ClassVar[str] = "}}"
html_template_name: ClassVar[str]
+ required_data_keys: ClassVar[tuple[str, ...]] = ()
choices_meta_schema: ClassVar[dict] = {
"code": {"label": "코드", "type": "string", "filter": "search"},
@@ -216,6 +218,8 @@ class NotificationHistorySentToBase(BaseAbstractModel):
history: models.ForeignKey[NotificationHistoryBase]
recipient = models.CharField(max_length=256)
+ # null 이 아닌 "" 기본값 — 안 쓰는 발송은 (history, recipient) 중복 방지가 그대로 유지된다.
+ dedupe_key = models.CharField(max_length=64, blank=True, default="")
context = models.JSONField(default=dict)
status = models.CharField(
max_length=16,
@@ -229,7 +233,7 @@ class Meta:
abstract = True
constraints = [
models.UniqueConstraint(
- fields=["history", "recipient"],
+ fields=["history", "recipient", "dedupe_key"],
condition=models.Q(deleted_at__isnull=True),
name="uq_%(app_label)s_%(class)s_history_recipient",
),
@@ -238,11 +242,20 @@ class Meta:
def __str__(self) -> str:
return f"{self.recipient} ({self.get_status_display()})"
- def _parsed_template_data(self) -> Any:
+ def _parsed_template_data(self) -> dict[str, Any]:
+ # render 결과가 그대로 채널 payload가 되므로 JSON object가 아니면 여기서 fail-fast.
+ # (평문/HTML이 저장된 경우 Django template context나 채널 client에서 TypeError로 뒤늦게 터진다.)
try:
- return json_loads(self.history.template_data)
+ parsed = json_loads(self.history.template_data)
except ValueError:
- return self.history.template_data
+ parsed = None
+
+ if not isinstance(parsed, dict):
+ raise ValueError(
+ f"Notification (template_code={self.history.template_code or '-'}) has invalid template_data: "
+ f"expected a JSON object, got {type(parsed).__name__ if parsed is not None else 'non-JSON text'}.",
+ )
+ return parsed
def _required_template_variables(self, payload: Any) -> set[str]:
template_class = self.history.template_class
@@ -259,11 +272,16 @@ def assert_context_complete(self) -> None:
f"without required context variables: {sorted(missing)}",
)
- def render(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RAISE) -> dict[str, Any]:
+ def render(
+ self,
+ undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RAISE,
+ *,
+ autoescape: bool = False,
+ ) -> dict[str, Any]:
# template_data를 JSON으로 먼저 파싱한 뒤 string value에만 Django Template을 적용 →
# context가 JSON-special char(`"`, `\`, 줄바꿈 등)를 포함해도 결과 JSON 구조가 깨지지 않음.
- # autoescape=False — 외부 채널(SMS, Kakao templateParameter)은 raw text 기대. HTML escape이 필요한 경우는
- # template 작성자가 명시적으로 |escape 필터를 사용해야 함.
+ # autoescape 기본 False — 외부 채널(SMS, Kakao templateParameter)은 raw text 기대.
+ # HTML 로 출력하는 경로는 반드시 autoescape=True (render_as_html 참고).
template_class = self.history.template_class
payload = self._parsed_template_data()
@@ -285,11 +303,15 @@ def render(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandlin
case UnhandledVariableHandling.REMOVE:
rendered_context[key] = ""
- ctx = Context(rendered_context, autoescape=False)
+ ctx = Context(rendered_context, autoescape=autoescape)
return _walk_strings(payload, lambda s: Template(template_class._to_dtl(s)).render(ctx))
def render_as_html(self, undef_var: UnhandledVariableHandling = UnhandledVariableHandling.RANDOM) -> str:
- return get_template(self.history.template_class.html_template_name).render(self.render(undef_var=undef_var))
+ # autoescape=True 필수 — Template.render() 결과는 SafeString 이라 바깥 HTML 템플릿이 재이스케이프하지 않는다.
+ # 여기서 escape 하지 않으면 구매자가 입력한 이름/소속이 admin 브라우저에서 그대로 실행된다.
+ return get_template(self.history.template_class.html_template_name).render(
+ self.render(undef_var=undef_var, autoescape=True),
+ )
@property
def payload(self) -> dict[str, Any]:
diff --git a/app/notification/models/email.py b/app/notification/models/email.py
index 27bc524..90f86db 100644
--- a/app/notification/models/email.py
+++ b/app/notification/models/email.py
@@ -12,6 +12,7 @@
class EmailNotificationTemplate(NotificationTemplateBase):
html_template_name: ClassVar[str] = "email_preview.html"
+ required_data_keys: ClassVar[tuple[str, ...]] = ("title", "body")
class EmailNotificationHistorySentTo(NotificationHistorySentToBase):
@@ -19,8 +20,9 @@ class EmailNotificationHistorySentTo(NotificationHistorySentToBase):
@property
def payload(self) -> dict[str, Any]:
+ # body는 HTML이라 context를 escape, title은 메일 제목(plain text)이라 그대로 둔다.
rendered = self.render()
- rendered["body"] = self.render_as_html()
+ rendered["body"] = self.render(autoescape=True).get("body", "")
return rendered
diff --git a/app/notification/models/nhn_cloud_sms.py b/app/notification/models/nhn_cloud_sms.py
index 44c5bb4..15a7032 100644
--- a/app/notification/models/nhn_cloud_sms.py
+++ b/app/notification/models/nhn_cloud_sms.py
@@ -12,6 +12,7 @@
class NHNCloudSMSNotificationTemplate(NotificationTemplateBase):
html_template_name: ClassVar[str] = "nhn_cloud_sms_preview.html"
+ required_data_keys: ClassVar[tuple[str, ...]] = ("body",) # title은 MMS 전용이라 선택
class NHNCloudSMSNotificationHistorySentTo(NotificationHistorySentToBase):
diff --git a/app/notification/tasks.py b/app/notification/tasks.py
index f5f7746..27cb264 100644
--- a/app/notification/tasks.py
+++ b/app/notification/tasks.py
@@ -1,3 +1,4 @@
+from smtplib import SMTPAuthenticationError, SMTPConnectError
from traceback import format_exc
from celery import shared_task
@@ -5,7 +6,19 @@
from notification.models.base import NotificationStatus, slack_logger
-@shared_task(ignore_result=True)
+# 연결/인증 단계 실패만 재시도한다 — 메시지 전송이 시작된 뒤의 예외는 중복 발송 위험이 있다.
+# Gmail은 짧은 시간에 로그인이 몰리면 XOAUTH2를 일시 거부하므로 백오프가 필요하다.
+# rate_limit은 worker 단위이고 채널 공통 — 분당 90건대에서 Gmail이 차단했던 이력을 기준으로 여유를 뒀다.
+# 운영 중 조정은 배포 없이 `app.control.rate_limit(task_name, "30/m")`로 가능하다.
+@shared_task(
+ ignore_result=True,
+ rate_limit="60/m",
+ autoretry_for=(SMTPAuthenticationError, SMTPConnectError),
+ retry_backoff=30,
+ retry_backoff_max=600,
+ retry_jitter=True,
+ max_retries=5,
+)
def send_notification_to_recipient(model_label: str, sent_to_id: str, force: bool = False) -> None:
sent_to_class = apps.get_model(model_label)
sent_to = sent_to_class.objects.select_related("history").get(pk=sent_to_id)
diff --git a/app/notification/test/history_send_test.py b/app/notification/test/history_send_test.py
index 006d831..79a3e92 100644
--- a/app/notification/test/history_send_test.py
+++ b/app/notification/test/history_send_test.py
@@ -127,13 +127,13 @@ def test_history_send_parameters_uses_rendered_payload(system_user):
@pytest.mark.django_db
-def test_email_payload_body_is_html_rendered(system_user):
- # 이메일 발송 시 payload["body"]는 HTML 템플릿으로 렌더링된 결과여야 함.
+def test_email_payload_body_is_template_body_without_preview_chrome(system_user):
+ # 어드민 미리보기 껍데기(email_preview.html)의 아바타/날짜/"…에게" UI가 실제 메일에 섞이면 안 된다.
tpl = EmailNotificationTemplate.objects.create(
code="html-body",
title="t",
sent_from="a@b.c",
- data='{"title":"안녕 {{ name }}","body":"본문 {{ name }}"}',
+ data='{"title":"안녕 {{ name }}","body":"본문 {{ name }}
"}',
created_by=system_user,
updated_by=system_user,
)
@@ -141,13 +141,27 @@ def test_email_payload_body_is_html_rendered(system_user):
sent_to = history.sent_to_list.get()
payload = sent_to.payload
- # title은 plain text
assert payload["title"] == "안녕 길동"
- assert not payload["title"].strip().startswith("<")
+ assert payload["body"] == "본문 길동
"
+ assert "email-main" not in payload["body"]
+ assert "에게" not in payload["body"]
- # body는 HTML 렌더링 결과
- assert payload["body"].strip().startswith("<")
- assert "길동" in payload["body"]
+
+@pytest.mark.django_db
+def test_email_payload_escapes_context_in_body(system_user):
+ tpl = EmailNotificationTemplate.objects.create(
+ code="escape-body",
+ title="t",
+ sent_from="a@b.c",
+ data='{"title":"안녕 {{ name }}","body":"{{ name }}
"}',
+ created_by=system_user,
+ updated_by=system_user,
+ )
+ history = _create_history(tpl, context={"name": ""})
+ payload = history.sent_to_list.get().payload
+
+ assert payload["body"] == "<script>alert(1)</script>
"
+ assert payload["title"] == "안녕 " # 제목은 plain text
@pytest.mark.django_db
diff --git a/app/notification/test/template_test.py b/app/notification/test/template_test.py
index 285711b..553c1b9 100644
--- a/app/notification/test/template_test.py
+++ b/app/notification/test/template_test.py
@@ -130,6 +130,24 @@ def test_render_as_html_kakao_preview_renders_buttons():
assert "가기" in html
+# ---- template_data 형식 검증 --------------------------------------------------
+
+
+def test_render_raises_on_non_json_template_data():
+ # 에디터가 컴파일된 HTML을 data에 통째로 저장한 경우 — Django template context TypeError 대신
+ # 발송 실패 사유로 읽히는 ValueError로 fail-fast.
+ tpl = EmailNotificationTemplate(data="Hello {{ name }}")
+ sent_to = tpl.build_preview_sent_to({"name": "길동"})
+ with pytest.raises(ValueError, match="JSON object"):
+ sent_to.render_as_html()
+
+
+def test_render_raises_on_json_non_object_template_data():
+ tpl = EmailNotificationTemplate(data='["body"]')
+ with pytest.raises(ValueError, match="JSON object"):
+ tpl.build_preview_sent_to({}).render()
+
+
# ---- JSON-unsafe context (per-string substitution 검증) -----------------------
diff --git a/app/participant_portal_api/permissions/__init__.py b/app/participant_portal_api/permissions/__init__.py
deleted file mode 100644
index 92a6e74..0000000
--- a/app/participant_portal_api/permissions/__init__.py
+++ /dev/null
@@ -1,20 +0,0 @@
-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/user.py b/app/participant_portal_api/serializers/user.py
index 43ee8c1..94e679e 100644
--- a/app/participant_portal_api/serializers/user.py
+++ b/app/participant_portal_api/serializers/user.py
@@ -1,8 +1,6 @@
-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 participant_portal_api.serializers.modification_audit import ModificationAuditCreationPortalSerializer
@@ -52,89 +50,3 @@ def validate(self, attrs: dict[str, typing.Any]) -> dict[str, typing.Any]:
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/test/__init__.py b/app/participant_portal_api/test/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/app/participant_portal_api/test/modification_audit_approval_test.py b/app/participant_portal_api/test/modification_audit_approval_test.py
new file mode 100644
index 0000000..0b509bb
--- /dev/null
+++ b/app/participant_portal_api/test/modification_audit_approval_test.py
@@ -0,0 +1,105 @@
+from contextlib import contextmanager
+from types import SimpleNamespace
+
+import pytest
+from core.util.django_orm import model_to_identifier
+from core.util.thread_local import thread_local
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import override
+from event.presentation.models import Presentation, PresentationSpeaker
+from model_bakery import baker
+from participant_portal_api.models import ModificationAudit
+from participant_portal_api.serializers.user import UserPortalSerializer
+from rest_framework import status
+from rest_framework.test import APIClient
+from user.models import UserExt
+
+APPROVE_URL = "/v1/admin-api/participant_portal_api/modificationaudit/{audit_id}/approve/"
+
+
+@contextmanager
+def _acting_as(user: UserExt):
+ thread_local.current_request = SimpleNamespace(user=user)
+ try:
+ yield
+ finally:
+ if hasattr(thread_local, "current_request"):
+ del thread_local.current_request
+
+
+@pytest.fixture
+def requester(db) -> UserExt:
+ return UserExt.objects.create_user(
+ username="requester", email="requester@example.com", nickname_ko="한글이름", nickname_en="EnglishName"
+ )
+
+
+@pytest.fixture
+def superuser(db) -> UserExt:
+ return UserExt.objects.create_superuser(username="admin", email="admin@example.com")
+
+
+@pytest.fixture
+def admin_client(superuser) -> APIClient:
+ client = APIClient()
+ client.force_authenticate(user=superuser)
+ return client
+
+
+def _request_profile_modification(requester: UserExt, payload: dict, language: str) -> ModificationAudit:
+ with override(language), _acting_as(requester):
+ serializer = UserPortalSerializer(instance=requester, data=payload, partial=True)
+ assert serializer.is_valid(), serializer.errors
+ serializer.save()
+
+ return ModificationAudit.objects.get(status=ModificationAudit.Status.REQUESTED)
+
+
+def test_approve_en_nickname_request_under_ko_locale_keeps_ko_nickname(requester, admin_client):
+ audit = _request_profile_modification(requester, {"nickname_en": "NewEnglish"}, language="en")
+
+ response = admin_client.patch(
+ APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="ko"
+ )
+
+ assert response.status_code == status.HTTP_200_OK, response.data
+ requester.refresh_from_db()
+ assert requester.nickname_en == "NewEnglish"
+ assert requester.nickname_ko == "한글이름"
+
+
+def test_approve_ko_nickname_request_under_en_locale_keeps_en_nickname(requester, admin_client):
+ audit = _request_profile_modification(requester, {"nickname_ko": "새한글이름"}, language="ko")
+
+ response = admin_client.patch(
+ APPROVE_URL.format(audit_id=audit.id), data={}, format="json", HTTP_ACCEPT_LANGUAGE="en"
+ )
+
+ assert response.status_code == status.HTTP_200_OK, response.data
+ requester.refresh_from_db()
+ assert requester.nickname_ko == "새한글이름"
+ assert requester.nickname_en == "EnglishName"
+
+
+def test_approve_presentation_audit_with_reordered_speakers(db, admin_client):
+ """발표자 순서만 뒤집힌 채 저장된 과거 수정 요청도 승인할 수 있어야 한다."""
+ presentation = baker.make(Presentation)
+ speakers = baker.make(PresentationSpeaker, presentation=presentation, biography_ko="이전 소개", _quantity=2)
+ audit = ModificationAudit.objects.create(
+ instance_type=ContentType.objects.get_for_model(Presentation),
+ instance_id=str(presentation.pk),
+ original_data={model_to_identifier(presentation): {}},
+ modification_data={
+ model_to_identifier(presentation): {
+ "speakers": [model_to_identifier(speaker) for speaker in reversed(speakers)]
+ },
+ model_to_identifier(speakers[0]): {"biography_ko": "새 소개"},
+ },
+ )
+
+ response = admin_client.patch(APPROVE_URL.format(audit_id=audit.id), data={}, format="json")
+
+ assert response.status_code == status.HTTP_200_OK, response.data
+ speakers[0].refresh_from_db()
+ assert speakers[0].biography_ko == "새 소개"
+ assert set(presentation.speakers.values_list("id", flat=True)) == {speaker.pk for speaker in speakers}
diff --git a/app/participant_portal_api/views/file.py b/app/participant_portal_api/views/file.py
index ce5b12d..7000085 100644
--- a/app/participant_portal_api/views/file.py
+++ b/app/participant_portal_api/views/file.py
@@ -2,7 +2,6 @@
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
@@ -11,7 +10,6 @@
class PublicFilePortalViewSet(mixins.ListModelMixin, viewsets.GenericViewSet):
serializer_class = PublicFilePortalSerializer
queryset = PublicFile.objects.filter_active().select_related_with_user()
- permission_classes = [IsSessionSpeaker]
def get_queryset(self) -> models.QuerySet[PublicFile]:
"""본인이 업로드한 파일만 조회 가능하도록 필터링"""
diff --git a/app/participant_portal_api/views/modification_audit.py b/app/participant_portal_api/views/modification_audit.py
index 4f9509a..080e734 100644
--- a/app/participant_portal_api/views/modification_audit.py
+++ b/app/participant_portal_api/views/modification_audit.py
@@ -8,7 +8,6 @@
)
from event.presentation.models import Presentation
from participant_portal_api.models import ModificationAudit, ModificationAuditComment
-from participant_portal_api.permissions import IsSessionSpeaker
from participant_portal_api.serializers.modification_audit import (
ModificationAuditCancelPortalSerializer,
ModificationAuditResponsePortalSerializer,
@@ -32,7 +31,6 @@
)
class ModificationAuditPortalViewSet(mixins.RetrieveModelMixin, mixins.ListModelMixin, viewsets.GenericViewSet):
serializer_class = ModificationAuditResponsePortalSerializer
- permission_classes = [IsSessionSpeaker]
queryset = (
ModificationAudit.objects.filter_active()
.prefetch_related(
diff --git a/app/participant_portal_api/views/presentation.py b/app/participant_portal_api/views/presentation.py
index 68b0d9b..1601646 100644
--- a/app/participant_portal_api/views/presentation.py
+++ b/app/participant_portal_api/views/presentation.py
@@ -2,7 +2,6 @@
from drf_spectacular import utils
from event.presentation.models import Presentation, PresentationSpeaker
from participant_portal_api.models import ModificationAudit
-from participant_portal_api.permissions import IsSessionSpeaker
from participant_portal_api.serializers.presentation import PresentationPortalSerializer
from rest_framework import mixins, response, viewsets
@@ -20,7 +19,6 @@ class PresentationPortalViewSet(
):
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):
@@ -33,10 +31,7 @@ def get_queryset(self):
.get_queryset()
.filter(
id__in=PresentationSpeaker.objects.filter_active()
- .filter(
- user=self.request.user,
- presentation__deleted_at__isnull=True,
- )
+ .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
index 7e37612..21f99c0 100644
--- a/app/participant_portal_api/views/user.py
+++ b/app/participant_portal_api/views/user.py
@@ -1,27 +1,18 @@
from core.const.tag import OpenAPITag
-from django.contrib.auth import login, logout
+from django.contrib.auth import logout
from drf_spectacular.utils import extend_schema
from participant_portal_api.models import ModificationAudit
-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 participant_portal_api.serializers.user import UserPortalSerializer
+from rest_framework import decorators, permissions, 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")
+ @extend_schema(tags=[OpenAPITag.PARTICIPANT_PORTAL_USER], responses={status.HTTP_200_OK: UserPortalSerializer})
+ @decorators.action(detail=False, methods=["get"], url_path="me", permission_classes=[permissions.IsAuthenticated])
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)
@@ -36,10 +27,7 @@ def retrieve_profile(self, request: request.Request, *args: tuple, **kwargs: dic
return response.Response(data=data)
- @extend_schema(
- tags=[OpenAPITag.PARTICIPANT_PORTAL_USER],
- responses={status.HTTP_200_OK: UserPortalSerializer},
- )
+ @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:
@@ -51,33 +39,8 @@ def patch_profile(self, request: request.Request, *args: tuple, **kwargs: dict)
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", permission_classes=[])
- 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, backend="django.contrib.auth.backends.ModelBackend")
- 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/shop/conftest.py b/app/shop/conftest.py
index 436bef8..7cf548b 100644
--- a/app/shop/conftest.py
+++ b/app/shop/conftest.py
@@ -243,7 +243,14 @@ def order_factory(request, customer_user):
``donation>0`` 이면 종류와 무관하게 ``donation_product`` 사용.
"""
- def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = True) -> Order:
+ def make(
+ *,
+ status: OrderStatus = "cart",
+ donation: int = 0,
+ is_ticket: bool = True,
+ product_price: int | None = None,
+ imp_id: str | None = _COMPLETED_ORDER_IMP_ID,
+ ) -> Order:
if status == "empty":
return Order.objects.create(user=customer_user, name="cart")
@@ -256,7 +263,10 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T
name_en=used_product.name_en,
)
OrderProductRelation.objects.create(
- order=order, product=used_product, price=used_product.price, donation_price=donation
+ order=order,
+ product=used_product,
+ price=used_product.price if product_price is None else product_price,
+ donation_price=donation,
)
CustomerInfo.objects.create(order=order, name="홍길동", phone="01012345678", email="customer@example.com")
@@ -270,7 +280,7 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T
order.products.update(status=OrderProductRelation.OrderProductStatus.paid)
PaymentHistory.objects.create(
order=order,
- imp_id=_COMPLETED_ORDER_IMP_ID,
+ imp_id=imp_id,
status=PaymentHistoryStatus.completed,
price=order.first_paid_price,
)
@@ -287,14 +297,14 @@ def make(*, status: OrderStatus = "cart", donation: int = 0, is_ticket: bool = T
if status == "refunded":
order.products.update(status=OrderProductRelation.OrderProductStatus.refunded)
second_ph = PaymentHistory.objects.create(
- order=order, imp_id=_COMPLETED_ORDER_IMP_ID, status=PaymentHistoryStatus.refunded, price=0
+ order=order, imp_id=imp_id, status=PaymentHistoryStatus.refunded, price=0
)
PaymentHistory.objects.filter(id=second_ph.id).update(created_at=later_at)
return order
if status == "partial_refunded":
second_ph = PaymentHistory.objects.create(
order=order,
- imp_id=_COMPLETED_ORDER_IMP_ID,
+ imp_id=imp_id,
status=PaymentHistoryStatus.partial_refunded,
price=order.first_paid_price // 2,
)
diff --git a/app/shop/order/exports.py b/app/shop/order/exports.py
index dd656f2..3ee78e5 100644
--- a/app/shop/order/exports.py
+++ b/app/shop/order/exports.py
@@ -1,16 +1,62 @@
import collections.abc
+import re
import typing
+import unicodedata
import pandas
from rest_framework import serializers
from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation
from shop.product.models import Option, OptionGroup
+MIN_COLUMN_WIDTH = 8
+MAX_COLUMN_WIDTH = 50
+COLUMN_WIDTH_PADDING = 2
+
+# 한 주문 상품이 같은 옵션 그룹을 여러 번 고른 경우의 2번째 이후 컬럼명 — `검은색 티셔츠 (2)`.
+NUMBERED_OPTION_COLUMN = re.compile(r"^(?P.+) \((?P\d+)\)$")
+
+
+def _display_width(value: object) -> int:
+ # 엑셀 열 너비는 글자 수 기준이라 한글/전각은 두 칸으로 세야 실제 표시 폭에 맞는다.
+ return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in str(value))
+
+
+def autofit_columns(worksheet: typing.Any, df: pandas.DataFrame, *, index: bool = True) -> None:
+ """헤더/값의 표시 폭에 맞춰 각 열 너비를 지정한다. `engine="xlsxwriter"` 전용."""
+ offset = 1 if index else 0
+ if index:
+ worksheet.set_column(0, 0, MIN_COLUMN_WIDTH)
+
+ for position, column in enumerate(df.columns):
+ content_width = max(
+ [_display_width(column), *(_display_width(value) for value in df[column].dropna())],
+ )
+ width = min(max(content_width + COLUMN_WIDTH_PADDING, MIN_COLUMN_WIDTH), MAX_COLUMN_WIDTH)
+ worksheet.set_column(position + offset, position + offset, width)
+
+
+def _option_column_sort_key(column: str) -> tuple[str, int]:
+ match = NUMBERED_OPTION_COLUMN.match(column)
+ return (match["name"], int(match["nth"])) if match else (column, 1)
+
+
+def _ordered_columns(df: pandas.DataFrame, fixed_labels: collections.abc.Sequence[str]) -> list[str]:
+ """고정 컬럼은 field_def 순서, 옵션 그룹 컬럼은 이름순으로 배치한다.
+
+ 옵션 컬럼은 행마다 동적으로 붙어 기본 순서가 "먼저 등장한 순" — 데이터가 바뀌면 열 순서도 바뀌고
+ `그룹명 (2)` 가 다른 그룹 뒤로 밀려 수기 집계에서 누락되기 쉽다. (그룹명, n) 정렬로 항상 붙여 둔다.
+ """
+ present = [str(column) for column in df.columns]
+ fixed = [label for label in fixed_labels if label in present]
+ dynamic = sorted(set(present) - set(fixed), key=_option_column_sort_key)
+ return fixed + dynamic
+
class ListExportSerializer(serializers.ListSerializer):
def export(self) -> pandas.DataFrame:
field_def = self.child.Meta.field_def # type: ignore[attr-defined,union-attr]
- return pandas.DataFrame(data=self.data).rename(columns=dict(field_def))
+ df = pandas.DataFrame(data=self.data).rename(columns=dict(field_def))
+ return df[_ordered_columns(df, [label for _, label in field_def])]
class OrderExportSerializer(serializers.ModelSerializer):
@@ -66,14 +112,28 @@ class Meta:
def to_representation(self, instance: OrderProductRelation) -> dict[str, typing.Any]:
result: dict[str, typing.Any] = super().to_representation(instance)
- options: collections.abc.Iterable[OrderProductOptionRelation] = instance.options.filter_active()
+ options: collections.abc.Iterable[OrderProductOptionRelation] = (
+ instance.options.filter_active()
+ .select_related("product_option_group", "product_option")
+ .order_by("product_option__priority", "created_at", "id")
+ )
+ seen_per_group: collections.Counter[str] = collections.Counter()
for option in options:
option_group: OptionGroup = option.product_option_group
- selected_option: Option = option.product_option
+ selected_option: Option | None = option.product_option
name: str = option_group.name
- value: str | None = option.custom_response if option_group.is_custom_response else selected_option.name
- result[name] = value
+ value: str | None = (
+ option.custom_response
+ if option_group.is_custom_response
+ else (selected_option.name if selected_option else None)
+ )
+
+ seen_per_group[name] += 1
+ # 같은 그룹 옵션을 여러 개 고른 주문은 `그룹명`, `그룹명 (2)` … 로 컬럼을 나눈다.
+ # 한 키에 덮어쓰면 두 번째부터가 사라져 사이즈별 수량 집계가 어긋난다.
+ nth = seen_per_group[name]
+ result[name if nth == 1 else f"{name} ({nth})"] = value
return result
diff --git a/app/shop/order/imports.py b/app/shop/order/imports.py
index 94b8238..e048447 100644
--- a/app/shop/order/imports.py
+++ b/app/shop/order/imports.py
@@ -51,7 +51,7 @@ class OrderProductImportSerializer(serializers.ModelSerializer):
product_id = serializers.PrimaryKeyRelatedField(queryset=Product.objects.filter_active(), source="id")
donation_price = serializers.IntegerField(required=True)
- options = serializers.DictField(child=serializers.CharField(), required=True)
+ options = serializers.DictField(child=serializers.CharField(allow_blank=True), required=True)
class Meta:
model = OrderProductRelation
@@ -65,8 +65,14 @@ def get_template_csv(cls, product: Product) -> str:
return pandas.DataFrame(columns=serializer_fields + option_fields).to_csv(index=False)
@functools.cached_property
- def user(self) -> UserExt | None:
- return UserExt.objects.filter(email=self.initial_data.get("email", "")).first()
+ def user(self) -> UserExt:
+ name: str = self.initial_data["name"]
+ user, _ = UserExt.objects.get_or_create_by_email(
+ email=self.initial_data["email"],
+ nickname_ko=name,
+ nickname_en=name,
+ )
+ return user
@functools.cached_property
def product(self) -> Product | None:
@@ -113,9 +119,6 @@ def to_internal_value(self, data: dict[str, str]) -> dict[str, typing.Any]:
return super().to_internal_value(data | {"options": options})
def validate(self, data: dict) -> dict:
- if not self.user:
- raise serializers.ValidationError("User does not exists")
-
check_data = {
"product": self.product.id,
"donation_price": data["donation_price"],
diff --git a/app/shop/order/migrations/0006_orderproductrelationtag.py b/app/shop/order/migrations/0006_orderproductrelationtag.py
new file mode 100644
index 0000000..87772d1
--- /dev/null
+++ b/app/shop/order/migrations/0006_orderproductrelationtag.py
@@ -0,0 +1,70 @@
+# Generated by Django 6.0.6 on 2026-08-13 00:46
+
+import uuid
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("order", "0005_backfill_ticketinfo"),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="OrderProductRelationTag",
+ fields=[
+ ("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
+ ("created_at", models.DateTimeField(auto_now_add=True)),
+ ("updated_at", models.DateTimeField(auto_now=True)),
+ ("deleted_at", models.DateTimeField(blank=True, null=True)),
+ ("code", models.CharField(max_length=50)),
+ ("name", models.CharField(max_length=100)),
+ ("priority", models.IntegerField(default=0)),
+ (
+ "created_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_created_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "deleted_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_deleted_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ (
+ "order_product_relations",
+ models.ManyToManyField(blank=True, related_name="tags", to="order.orderproductrelation"),
+ ),
+ (
+ "updated_by",
+ models.ForeignKey(
+ null=True,
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="%(class)s_updated_by",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ("priority", "code"),
+ "constraints": [
+ models.UniqueConstraint(
+ condition=models.Q(("deleted_at__isnull", True)),
+ fields=("code",),
+ name="uq__order_product_relation_tag__code",
+ )
+ ],
+ },
+ ),
+ ]
diff --git a/app/shop/order/models.py b/app/shop/order/models.py
index 9bfe017..4662bdb 100644
--- a/app/shop/order/models.py
+++ b/app/shop/order/models.py
@@ -32,6 +32,16 @@
PAYMENT_HASH_LENGTH = 16
+def korean_alias_context(*, name: str, organization: str, year: int | None = None) -> dict:
+ """승인된 카카오 알림톡 템플릿의 한글 변수(`#{성함}` 등)용 별칭 — 변수명 변경이 재검수 대상이라 백엔드가 맞춘다.
+
+ 수신자마다 다른 값이라 `context_override`(전 수신자 공통)로는 못 채운다.
+ `연도` 는 없으면 키를 뺀다 — 빈 문자열로 채우면 preview 의 누락 검사를 통과해 빈 채로 발송된다.
+ """
+ aliases = {"성함": name, "성명": name, "소속": organization}
+ return aliases if year is None else aliases | {"연도": year}
+
+
class PaymentPreparationMixin:
id: UUID
prepared_cart_snapshot: dict[str, typing.Any] | None
@@ -155,14 +165,8 @@ def with_dto_prefetches(self) -> models.QuerySet[Order]:
queryset=(
OrderProductRelation.objects.filter_active()
.select_related("product__category", "ticket_info")
+ .prefetch_active_options()
.prefetch_related(
- models.Prefetch(
- "options",
- queryset=OrderProductOptionRelation.objects.filter_active().select_related(
- "product_option_group",
- "product_option",
- ),
- ),
models.Prefetch("issued_documents", queryset=IssuedDocument.objects.filter_active()),
)
),
@@ -269,8 +273,8 @@ def latest_imp_id(self) -> str | None:
def build_notification_context(self) -> dict:
"""결제 완료 알림 (auto + admin manual) 에서 공통으로 사용하는 Order-derived context.
- `first_paid_at` 은 isoformat 문자열로 변환 — JSONField 저장 시 psycopg `json.dumps` 가
- datetime 을 직접 직렬화 못함. 호출자는 customer_info 존재를 사전 검증해야 함.
+ `first_paid_at` 은 isoformat 문자열로 변환 — JSONField 저장 시 psycopg `json.dumps` 가 datetime 을 직접 직렬화 못함.
+ 호출자는 customer_info 존재를 사전 검증해야 함.
"""
customer_info = self.customer_info
return {
@@ -280,7 +284,9 @@ def build_notification_context(self) -> dict:
"customer_name": customer_info.name,
"customer_phone": customer_info.phone,
"customer_email": customer_info.email,
- "scancode_url": urljoin(settings.BACKEND_DOMAIN, self.scancode_path),
+ "customer_organization": customer_info.organization or "",
+ # 주문은 event 를 몰라 연도는 빠진다.
+ **korean_alias_context(name=customer_info.name, organization=customer_info.organization or ""),
}
@functools.cached_property
@@ -343,6 +349,19 @@ def not_fully_refundable_reason(self) -> str | None:
return None
+class OrderProductRelationQuerySet(BaseAbstractModelQuerySet):
+ def prefetch_active_options(self) -> models.QuerySet[OrderProductRelation]:
+ return self.prefetch_related(
+ models.Prefetch(
+ "options",
+ queryset=OrderProductOptionRelation.objects.filter_active().select_related(
+ "product_option_group",
+ "product_option",
+ ),
+ ),
+ )
+
+
class OrderProductRelation(ScanCodeMixin, IssuableMixin, BaseAbstractModel):
ISSUED_DOCUMENT_TYPE = DocumentType.confirmation_of_participation
scancode_prefix = "opr"
@@ -366,6 +385,8 @@ class OrderProductStatus(models.TextChoices):
single_product_cart: SingleProductCart | None
options: BaseManager[OrderProductOptionRelation]
+ objects: OrderProductRelationQuerySet = OrderProductRelationQuerySet.as_manager() # type: ignore[assignment, misc]
+
issued_documents = GenericRelation(
"document.IssuedDocument",
content_type_field="issuable_content_type",
@@ -440,6 +461,47 @@ def not_refundable_reason(self) -> str | None:
return None
+ @property
+ def ticket_info_or_none(self) -> TicketInfo | None:
+ try:
+ ticket_info = self.ticket_info
+ except TicketInfo.DoesNotExist:
+ return None
+ return ticket_info if ticket_info.deleted_at is None else None
+
+ @property
+ def customer_info_or_none(self) -> CustomerInfo | None:
+ customer_info = getattr(self.order or self.single_product_cart, "customer_info", None)
+ return customer_info if customer_info and customer_info.deleted_at is None else None
+
+ @property
+ def participant_info(self) -> TicketInfo | CustomerInfo | None:
+ return self.ticket_info_or_none or self.customer_info_or_none
+
+ def build_notification_context(self, participant: TicketInfo | CustomerInfo | None = None) -> dict:
+ if participant is None:
+ participant = self.participant_info
+ name = (getattr(participant, "name", "")) or ""
+ organization = (getattr(participant, "organization", "")) or ""
+ event = self.product.category.event
+ return {
+ "scancode_url": urljoin(settings.BACKEND_DOMAIN, self.scancode_path),
+ "product_name": self.product.name,
+ "product_price": self.price,
+ "product_donation_price": self.donation_price,
+ "product_status": self.get_status_display(),
+ "participant_name": name,
+ "participant_phone": (getattr(participant, "phone", "")) or "",
+ "participant_email": (getattr(participant, "email", "")) or "",
+ "participant_organization": organization,
+ "contribution_message": (getattr(participant, "contribution_message", "")) or "",
+ **korean_alias_context(
+ name=name,
+ organization=organization,
+ year=event.event_start_at.year if event and event.event_start_at else None,
+ ),
+ }
+
def build_document_context(self) -> dict:
try:
participant = self.ticket_info
@@ -622,6 +684,29 @@ class Meta:
]
+class OrderProductRelationTag(BaseAbstractModel):
+ """주문상품 운영 태그. `code` 는 ROSA 의 템플릿 매핑 키라 변경하지 않는다."""
+
+ code = models.CharField(max_length=50)
+ name = models.CharField(max_length=100)
+ priority = models.IntegerField(default=0)
+
+ order_product_relations = models.ManyToManyField(OrderProductRelation, blank=True, related_name="tags")
+
+ class Meta:
+ ordering = ("priority", "code")
+ constraints = [
+ models.UniqueConstraint(
+ fields=("code",),
+ condition=models.Q(deleted_at__isnull=True),
+ name="uq__order_product_relation_tag__code",
+ ),
+ ]
+
+ def __str__(self) -> str: # pragma: no cover
+ return f"{self.name} ({self.code})"
+
+
class TicketInfo(BaseAbstractModel):
order_product_relation = models.OneToOneField(
OrderProductRelation, on_delete=models.PROTECT, related_name="ticket_info"
diff --git a/app/shop/order/serializers/dto.py b/app/shop/order/serializers/dto.py
index 56b69a0..87255b5 100644
--- a/app/shop/order/serializers/dto.py
+++ b/app/shop/order/serializers/dto.py
@@ -156,3 +156,21 @@ class Meta:
"merchant_uid",
)
model = SingleProductCart
+
+
+class CreateSingleProductOrderResponseDto(serializers.Serializer):
+ id = serializers.UUIDField()
+ name = serializers.CharField()
+ payment_histories = PaymentHistoryDto(many=True)
+ products = OrderProductRelationDto(many=True)
+ first_paid_price = serializers.IntegerField()
+ current_paid_price = serializers.IntegerField()
+ current_status = serializers.ChoiceField(choices=PaymentHistoryStatus.choices)
+ created_at = serializers.DateTimeField()
+ customer_info = CustomerInfoDto(allow_null=True)
+ merchant_uid = serializers.CharField(allow_null=True)
+
+ # 0원 즉시 완료 응답(OrderDto)에만 존재하는 필드. 유료 SingleProductCart 응답에는 없음.
+ scancode_url = serializers.URLField(required=False, allow_null=True)
+ first_paid_at = serializers.DateTimeField(required=False, allow_null=True)
+ not_fully_refundable_reason = serializers.CharField(required=False, allow_null=True)
diff --git a/app/shop/order/serializers/scancode.py b/app/shop/order/serializers/scancode.py
index e482942..c617cb8 100644
--- a/app/shop/order/serializers/scancode.py
+++ b/app/shop/order/serializers/scancode.py
@@ -40,7 +40,7 @@ class Meta:
class OrderProductScanCodeSerializer(_OrderProductRelationSerializer):
- """단일 OrderProductRelation (티켓) 의 QR 페이지용 응답 — base + id/short_id/order context."""
+ """단일 OrderProductRelation (티켓) 의 QR 페이지용 응답 — base + id/scancode_token/order context."""
class _OrderSerializer(serializers.ModelSerializer):
class Meta:
@@ -50,7 +50,7 @@ class Meta:
order = _OrderSerializer()
class Meta(_OrderProductRelationSerializer.Meta):
- fields = ("id", "short_id", "order", *_OrderProductRelationSerializer.Meta.fields)
+ fields = ("id", "scancode_token", "order", *_OrderProductRelationSerializer.Meta.fields)
class OrderScanCodeSerializer(serializers.ModelSerializer):
@@ -68,7 +68,7 @@ class Meta:
model = Order
fields = (
"id",
- "short_id",
+ "scancode_token",
"name",
"created_at",
"first_paid_at",
@@ -84,4 +84,4 @@ class UserScanCodeSerializer(serializers.ModelSerializer):
class Meta:
model = UserExt
- fields = ("unique_id", "short_id")
+ fields = ("unique_id", "scancode_token")
diff --git a/app/shop/order/templates/scancode_base.html b/app/shop/order/templates/scancode_base.html
index dfd4193..ad5fff4 100644
--- a/app/shop/order/templates/scancode_base.html
+++ b/app/shop/order/templates/scancode_base.html
@@ -1,5 +1,5 @@
-
-
+{% is_english as en %}
+
@@ -232,6 +232,18 @@
li::before {
content: "- ";
}
+
+ .error-sub {
+ margin-top: 0.35rem;
+ text-align: center;
+ color: #888;
+ }
+
+ .error-guide {
+ font-size: 0.85rem;
+ color: #333;
+ text-align: center;
+ }
@@ -246,6 +258,7 @@
{% block content %}{% endblock %}
+ {% block notice %}
안내
@@ -254,6 +267,7 @@
자세한 주문 정보는 파이콘 한국 의 구매 내역에서 조회할 수 있습니다.
+ {% endblock %}
diff --git a/app/shop/order/templates/scancode_error.html b/app/shop/order/templates/scancode_error.html
index bcc92f9..0702edc 100644
--- a/app/shop/order/templates/scancode_error.html
+++ b/app/shop/order/templates/scancode_error.html
@@ -3,4 +3,24 @@
{{ error_msg }}
+{{ error_msg_sub }}
+
+{{ error_guide }}
+{{ error_guide_sub }}
+{% endblock %}
+{% block notice %}
+
+
{% if en %}Need help?{% else %}도움이 필요하신가요?{% endif %}
+
+ {% if en %}
+ Check that you opened the full link from your ticket email or the PyCon Korea website.
+ A QR code becomes invalid once the order is fully refunded.
+ You can look up your order at PyCon Korea after signing in.
+ {% else %}
+ 티켓 안내 메일이나 파이콘 한국 홈페이지의 링크를 그대로 열었는지 확인해 주세요.
+ 전체 환불된 주문의 QR 코드는 더 이상 사용할 수 없습니다.
+ 주문 내역은 파이콘 한국 에 로그인 후 조회할 수 있습니다.
+ {% endif %}
+
+
{% endblock %}
diff --git a/app/shop/order/templates/scancode_view_opr.html b/app/shop/order/templates/scancode_view_opr.html
index 3aa1542..bfb6fbc 100644
--- a/app/shop/order/templates/scancode_view_opr.html
+++ b/app/shop/order/templates/scancode_view_opr.html
@@ -36,7 +36,7 @@ 상품 정보