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 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 @@
상품 정보