diff --git a/app/admin_api/filtersets/shop/__init__.py b/app/admin_api/filtersets/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_api/filtersets/shop/orders.py b/app/admin_api/filtersets/shop/orders.py new file mode 100644 index 0000000..a74df3b --- /dev/null +++ b/app/admin_api/filtersets/shop/orders.py @@ -0,0 +1,57 @@ +from core.filter.multi_field import MultiFieldOrCharInFilter +from django_filters import rest_framework as filters +from shop.order.models import Order + + +class OrderAdminFilterSet(filters.FilterSet): + """admin 운영자 검색. CSV (콤마 구분) 다중 값 지원: `?name=철수,영희&status=completed,refunded`""" + + id = filters.BaseInFilter(field_name="id") + user_id = filters.BaseInFilter(field_name="user_id") + user_unique_id = filters.BaseInFilter(field_name="user__unique_id") + name = MultiFieldOrCharInFilter( + field_names=["user__nickname_ko", "user__nickname_en", "user__username", "customer_info__name"], + lookup_expr="icontains", + ) + email = MultiFieldOrCharInFilter(field_names=["user__email", "customer_info__email"], lookup_expr="icontains") + imp_id = MultiFieldOrCharInFilter(field_names=["latest_imp_id"], lookup_expr="icontains") + status = filters.BaseCSVFilter(field_name="current_status", lookup_expr="in") + + created_at_after = filters.DateTimeFilter(field_name="created_at", lookup_expr="gte") + created_at_before = filters.DateTimeFilter(field_name="created_at", lookup_expr="lte") + + first_paid_at_after = filters.DateTimeFilter(field_name="first_paid_at", lookup_expr="gte") + first_paid_at_before = filters.DateTimeFilter(field_name="first_paid_at", lookup_expr="lte") + + status_changed_at_after = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="gte") + status_changed_at_before = filters.DateTimeFilter(field_name="status_changed_at", lookup_expr="lte") + + product_id = filters.BaseInFilter(field_name="products__product_id", distinct=True) + category_id = filters.BaseInFilter(field_name="products__product__category_id", distinct=True) + category_group_id = filters.BaseInFilter(field_name="products__product__category__group_id", distinct=True) + + price_min = filters.NumberFilter(field_name="latest_price", lookup_expr="gte") + price_max = filters.NumberFilter(field_name="latest_price", lookup_expr="lte") + + class Meta: + model = Order + fields = [ + "id", + "user_id", + "user_unique_id", + "name", + "email", + "imp_id", + "status", + "created_at_after", + "created_at_before", + "first_paid_at_after", + "first_paid_at_before", + "status_changed_at_after", + "status_changed_at_before", + "product_id", + "category_id", + "category_group_id", + "price_min", + "price_max", + ] diff --git a/app/admin_api/filtersets/shop/products.py b/app/admin_api/filtersets/shop/products.py new file mode 100644 index 0000000..be4cef8 --- /dev/null +++ b/app/admin_api/filtersets/shop/products.py @@ -0,0 +1,61 @@ +from core.filter.multi_field import MultiFieldOrCharInFilter +from core.util.dateutil import now_aware +from django.db.models import Q +from django_filters import rest_framework as filters +from shop.product.models import Product + + +class ProductAdminFilterSet(filters.FilterSet): + id = filters.BaseInFilter(field_name="id") + name = MultiFieldOrCharInFilter(field_names=["name_ko", "name_en"], lookup_expr="icontains") + category = filters.BaseInFilter(field_name="category_id") + category_group = filters.BaseInFilter(field_name="category__group_id") + hidden = filters.BooleanFilter(field_name="hidden") + tag = filters.BaseInFilter(field_name="tag_set", distinct=True) + + price_min = filters.NumberFilter(field_name="price", lookup_expr="gte") + price_max = filters.NumberFilter(field_name="price", lookup_expr="lte") + + status = filters.BaseCSVFilter(method="filter_by_status") + + def filter_by_status(self, queryset, name, values): + if not values: + return queryset + + now = now_aware() + q = Q() + for value in values: + if value == Product.CurrentStatus.HIDDEN: + q |= Q(hidden=True) + elif value == Product.CurrentStatus.OUT_OF_VISIBLE_PERIOD: + q |= Q(hidden=False) & (Q(visible_starts_at__gt=now) | Q(visible_ends_at__lt=now)) + elif value == Product.CurrentStatus.OUT_OF_ORDERABLE_PERIOD: + q |= ( + Q(hidden=False) + & Q(visible_starts_at__lte=now) + & Q(visible_ends_at__gte=now) + & (Q(orderable_starts_at__gt=now) | Q(orderable_ends_at__lt=now)) + ) + elif value == Product.CurrentStatus.ACTIVE: + q |= ( + Q(hidden=False) + & Q(visible_starts_at__lte=now) + & Q(visible_ends_at__gte=now) + & Q(orderable_starts_at__lte=now) + & Q(orderable_ends_at__gte=now) + ) + return queryset.filter(q).distinct() + + class Meta: + model = Product + fields = [ + "id", + "name", + "category", + "category_group", + "hidden", + "tag", + "price_min", + "price_max", + "status", + ] diff --git a/app/admin_api/serializers/notification.py b/app/admin_api/serializers/notification.py index 1bf8f10..0a69f86 100644 --- a/app/admin_api/serializers/notification.py +++ b/app/admin_api/serializers/notification.py @@ -3,6 +3,7 @@ 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 notification.channels import NotificationChannel from notification.models import ( EmailNotificationHistory, EmailNotificationHistorySentTo, @@ -185,3 +186,12 @@ class NotificationHistoryRetryRequestAdminSerializer(serializers.Serializer): required=False, default=[NotificationStatus.FAILED], ) + + +# ---- Channel → response serializer 매핑 ------------------------------------- + +HISTORY_ADMIN_SERIALIZER_BY_CHANNEL: dict[NotificationChannel, type[_NotiHistoryAdminSerializerBase]] = { + NotificationChannel.EMAIL: EmailNotificationHistoryAdminSerializer, + NotificationChannel.NHN_CLOUD_SMS: NHNCloudSMSNotificationHistoryAdminSerializer, + NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer, +} diff --git a/app/admin_api/serializers/shop/__init__.py b/app/admin_api/serializers/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_api/serializers/shop/orders.py b/app/admin_api/serializers/shop/orders.py new file mode 100644 index 0000000..868719e --- /dev/null +++ b/app/admin_api/serializers/shop/orders.py @@ -0,0 +1,217 @@ +from typing import Any +from urllib.parse import urljoin + +from admin_api.serializers.notification import HISTORY_ADMIN_SERIALIZER_BY_CHANNEL +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.skip_none_list_serializer import SkipNoneListSerializer +from django.conf import settings +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 +from shop.payment_history.models import PaymentHistory +from shop.product.models import Product +from user.models import UserExt + +CUSTOMER_INFO_RECIPIENT_ATTR_BY_CHANNEL = { + NotificationChannel.EMAIL: "email", + NotificationChannel.NHN_CLOUD_SMS: "phone", + NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: "phone", +} + + +class OrderAdminSerializer( + BaseAbstractSerializer, + JsonSchemaSerializer, + serializers.ModelSerializer, +): + class SimpleUserSerializer(serializers.ModelSerializer): + class Meta: + model = UserExt + read_only_fields = fields = ("id", "username", "email", "unique_id") + + class SimpleCustomerInfoSerializer(serializers.ModelSerializer): + class Meta: + model = CustomerInfo + fields = ("name", "phone", "email", "organization") + + class SimplePaymentHistorySerializer(serializers.ModelSerializer): + class Meta: + model = PaymentHistory + read_only_fields = fields = ("id", "imp_id", "status", "price", "created_at") + + class SimpleOrderProductRelationSerializer(serializers.ModelSerializer): + class SimpleProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + read_only_fields = fields = ("id", "name_ko", "name_en", "price") + + class SimpleOrderProductOptionRelationSerializer(serializers.ModelSerializer): + option_group_name_ko = serializers.CharField(source="product_option_group.name_ko", read_only=True) + option_group_name_en = serializers.CharField(source="product_option_group.name_en", read_only=True) + option_name_ko = serializers.CharField(source="product_option.name_ko", read_only=True, allow_null=True) + option_name_en = serializers.CharField(source="product_option.name_en", read_only=True, allow_null=True) + + class Meta: + model = OrderProductOptionRelation + read_only_fields = fields = ( + "id", + "option_group_name_ko", + "option_group_name_en", + "option_name_ko", + "option_name_en", + "custom_response", + ) + + product = SimpleProductSerializer(read_only=True) + options = SimpleOrderProductOptionRelationSerializer(many=True, read_only=True) + + class Meta: + model = OrderProductRelation + fields = ("id", "product", "status", "price", "donation_price", "options") + read_only_fields = ("id", "product", "price", "donation_price", "options") + + user = SimpleUserSerializer(read_only=True) + customer_info = SimpleCustomerInfoSerializer(required=False, allow_null=True) + products = SimpleOrderProductRelationSerializer(many=True, read_only=True) + payment_histories = SimplePaymentHistorySerializer(many=True, read_only=True) + first_paid_price = serializers.IntegerField(read_only=True) + 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) + + class Meta: + model = Order + fields = COMMON_ADMIN_FIELDS + ( + "name_ko", + "name_en", + "user", + "customer_info", + "products", + "payment_histories", + "first_paid_price", + "current_paid_price", + "current_status", + "first_paid_at", + "latest_imp_id", + ) + read_only_fields = ("name_ko", "name_en") + + def update(self, instance: Order, validated_data: dict) -> Order: + customer_info_data = validated_data.pop("customer_info", None) + order = super().update(instance, validated_data) + + if customer_info_data is not None: + if order.customer_info: + for field, value in customer_info_data.items(): + setattr(order.customer_info, field, value) + order.customer_info.save() + else: + CustomerInfo.objects.create(order=order, **customer_info_data) + + return order + + +class OrderExportRequestSerializer(JsonSchemaSerializer, serializers.Serializer): + product_ids = serializers.ListField(child=serializers.UUIDField(), required=True, min_length=1) + include_refunded = serializers.BooleanField(default=False) + + +class _OrderRecipientItemSerializer(serializers.Serializer): + """Order → Recipient ({recipient, context}) 변환. + + customer_info / 첫 상품 / recipient 부재 시 None 반환. None-skip 의미를 가지므로 + 반드시 `SkipNoneListSerializer` (Meta.list_serializer_class) 와 함께 `many=True` 로 사용 — 단독 사용 시 호출자가 None 처리 책임. + """ + + recipient = serializers.CharField() + context = serializers.JSONField() + + class Meta: + list_serializer_class = SkipNoneListSerializer + + def to_representation(self, order: Order) -> Recipient | None: + channel: NotificationChannel = self.context["channel"] + + if not (customer_info := getattr(order, "customer_info", 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.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.all() + } + ctx["scancode_url"] = urljoin(settings.BACKEND_DOMAIN, order.scancode_path) + + return {"recipient": recipient, "context": ctx | self.context["context_override"]} + + +class OrderSendNotificationPreviewResponseSerializer(JsonSchemaSerializer, serializers.Serializer): + class RecipientItemSerializer(JsonSchemaSerializer, serializers.Serializer): + recipient = serializers.CharField() + context = serializers.JSONField() + missing_variables = serializers.ListField(child=serializers.CharField()) + + template_variables = serializers.ListField(child=serializers.CharField()) + recipients = RecipientItemSerializer(many=True) + + +class OrderSendNotificationSerializer(JsonSchemaSerializer, serializers.Serializer): + channel = serializers.ChoiceField(choices=NotificationChannel.choices) + template_id = serializers.UUIDField() + context_override = serializers.JSONField(required=False, default=dict) + + def validate_channel(self, value: str) -> NotificationChannel: + return NotificationChannel(value) + + def validate(self, attrs: dict) -> dict: + if not (t := attrs["channel"].template_class.objects.filter_active().filter(pk=attrs["template_id"]).first()): + raise serializers.ValidationError({"template_id": "Template not found."}) + # validated_data 에 template_id (UUID) 와 template (instance) 가 공존. + # downstream 은 template 만 사용; template_id 는 input round-trip 용으로 남김. + return {**attrs, "template": t} + + def _build_recipient_items(self) -> list[Recipient]: + return _OrderRecipientItemSerializer(instance=self.instance, many=True, context=self.validated_data).data + + def build_preview_response(self) -> OrderSendNotificationPreviewResponseSerializer: + template_vars = self.validated_data["template"].template_variables + return OrderSendNotificationPreviewResponseSerializer( + instance={ + "template_variables": sorted(template_vars), + "recipients": [ + {**i, "missing_variables": sorted(template_vars - i["context"].keys())} + for i in self._build_recipient_items() + ], + }, + ) + + def build_send_response(self) -> serializers.Serializer: + if not (items := self._build_recipient_items()): + raise serializers.ValidationError( + "발송 대상이 없습니다 (filterset 결과 0건 또는 customer_info/첫 상품 부재)." + ) + channel: NotificationChannel = self.validated_data["channel"] + template = self.validated_data["template"] + if invalid := [ + {**i, "missing_variables": missing} + for i in items + if (missing := sorted(template.template_variables - i["context"].keys())) + ]: + raise serializers.ValidationError({"missing_context_variables": invalid}) + + # create_for_recipients (DB write) + history.send() (Celery dispatch on commit). + history = channel.history_class.objects.create_for_recipients(template=template, recipients=items) + history.send() + history.refresh_from_db() + return HISTORY_ADMIN_SERIALIZER_BY_CHANNEL[channel](instance=history) diff --git a/app/admin_api/serializers/shop/products.py b/app/admin_api/serializers/shop/products.py new file mode 100644 index 0000000..86aebf4 --- /dev/null +++ b/app/admin_api/serializers/shop/products.py @@ -0,0 +1,164 @@ +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.nested_model_serializer import ( + InstanceListSerializer, + NestedFieldModelSerializer, + NestedFieldSpec, + NestedModelSerializer, +) +from rest_framework import serializers +from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, Tag + + +class CategoryGroupAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedFieldModelSerializer): + class CategoryAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, NestedModelSerializer): + id = serializers.UUIDField(required=False, help_text="기존 Category 수정 시 PK 전달, 새로 추가 시 생략") + + class Meta: + model = Category + fields = COMMON_ADMIN_FIELDS + ("group", "name", "priority") + # group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능. + # validators=[] — auto UniqueTogetherValidator(group, name) 가 group 누락 시 required 로 막음. + # DB unique constraint(uq__cat__grp_nm) 가 여전히 enforce. + extra_kwargs = {"group": {"required": False}} + validators: list = [] + list_serializer_class = InstanceListSerializer + + categories = CategoryAdminSerializer(many=True, required=False, source="category_set") + category_count = serializers.IntegerField(read_only=True) + + class Meta: + model = CategoryGroup + fields = COMMON_ADMIN_FIELDS + ("name", "priority", "categories", "category_count") + nested_fields = { + "category_set": NestedFieldSpec( + related_manager_name="category_set", + child_model=Category, + parent_fk_name="group", + ), + } + + +class TagAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + leftover_stock = serializers.IntegerField(read_only=True, allow_null=True) + + class Meta: + model = Tag + fields = COMMON_ADMIN_FIELDS + ("name_ko", "name_en", "stock", "max_quantity_per_user", "leftover_stock") + + +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) + + class Meta: + model = Option + fields = COMMON_ADMIN_FIELDS + ( + "group", + "priority", + "name_ko", + "name_en", + "max_quantity_per_user", + "additional_price", + "stock", + "leftover_stock", + ) + # group 은 NestedFieldSpec.parent_fk_name 으로 부모 인스턴스에서 주입되므로 입력 시 생략 가능. + extra_kwargs = {"group": {"required": False}} + list_serializer_class = InstanceListSerializer + + options = OptionAdminSerializer(many=True, required=False) + + class Meta: + model = OptionGroup + fields = COMMON_ADMIN_FIELDS + ( + "product", + "priority", + "name_ko", + "name_en", + "min_quantity_per_product", + "max_quantity_per_product", + "is_custom_response", + "custom_response_pattern", + "response_modifiable_ends_at", + "options", + ) + nested_fields = { + "options": NestedFieldSpec( + related_manager_name="options", + child_model=Option, + parent_fk_name="group", + ), + } + + def validate(self, attrs: dict) -> dict: + # is_custom_response=True 면 패턴이 admin 계약 — 빈 답변 허용은 ".*", 비공란 강제는 ".+" 등으로 명시. + is_custom_response = attrs.get("is_custom_response", getattr(self.instance, "is_custom_response", False)) + custom_response_pattern = attrs.get( + "custom_response_pattern", getattr(self.instance, "custom_response_pattern", None) + ) + if is_custom_response and not custom_response_pattern: + raise serializers.ValidationError( + {"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."} + ) + return attrs + + +class ProductAdminSerializer(BaseAbstractSerializer, JsonSchemaSerializer, serializers.ModelSerializer): + option_groups = OptionGroupAdminSerializer(many=True, read_only=True) + 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) + current_status = serializers.ChoiceField(choices=Product.CurrentStatus.choices, read_only=True) + + class Meta: + model = Product + fields = COMMON_ADMIN_FIELDS + ( + "name_ko", + "name_en", + "description_ko", + "description_en", + "image", + "price", + "stock", + "hidden", + "max_quantity_per_user", + "visible_starts_at", + "visible_ends_at", + "orderable_starts_at", + "orderable_ends_at", + "refundable_ends_at", + "category", + "priority", + "donation_allowed", + "donation_min_price", + "donation_max_price", + "option_groups", + "tag_set", + "tag_set_detail", + "leftover_stock", + "current_status", + ) + + def validate(self, attrs: dict) -> dict: + merged = {**attrs} + if self.instance is not None: + for field in ("visible_starts_at", "visible_ends_at", "orderable_starts_at", "orderable_ends_at"): + merged.setdefault(field, getattr(self.instance, field, None)) + + v_start = merged.get("visible_starts_at") + v_end = merged.get("visible_ends_at") + o_start = merged.get("orderable_starts_at") + o_end = merged.get("orderable_ends_at") + + errors: dict[str, str] = {} + if v_start and o_start and o_start < v_start: + errors["orderable_starts_at"] = "판매 시작은 노출 시작 이후여야 합니다." + if v_end and o_end and o_end > v_end: + errors["orderable_ends_at"] = "판매 종료는 노출 종료 이전이어야 합니다." + + if errors: + raise serializers.ValidationError(errors) + return attrs diff --git a/app/admin_api/test/cms_test.py b/app/admin_api/test/cms_test.py index 74dbc94..b0d590d 100644 --- a/app/admin_api/test/cms_test.py +++ b/app/admin_api/test/cms_test.py @@ -9,12 +9,15 @@ @pytest.fixture def domain_group(superuser): - return DomainGroup.objects.create( + obj, _ = DomainGroup.objects.get_or_create( name="2025년 PyConKR 홈페이지", - domains=["2025.pycon.kr"], - created_by=superuser, - updated_by=superuser, + defaults={ + "domains": ["2025.pycon.kr"], + "created_by": superuser, + "updated_by": superuser, + }, ) + return obj # ---- Auth ------------------------------------------------------------------- diff --git a/app/admin_api/urls.py b/app/admin_api/urls.py index 376fd81..e151483 100644 --- a/app/admin_api/urls.py +++ b/app/admin_api/urls.py @@ -20,6 +20,15 @@ NHNCloudSMSNotificationHistoryAdminViewSet, NHNCloudSMSNotificationTemplateAdminViewSet, ) +from admin_api.views.shop.order_notifications import OrderNotificationAdminViewSet +from admin_api.views.shop.orders import OrderAdminViewSet +from admin_api.views.shop.products import ( + CategoryGroupAdminViewSet, + OptionGroupAdminViewSet, + ProductAdminViewSet, + TagAdminViewSet, +) +from admin_api.views.shop.refund_authorizer import RefundAuthorizerAdminViewSet from admin_api.views.user import OrganizationAdminViewSet, UserAdminViewSet from django.urls import include, path from rest_framework import routers @@ -84,6 +93,17 @@ admin_external_api_google_router = routers.SimpleRouter() admin_external_api_google_router.register("oauth2", GoogleOAuth2AdminViewSet, basename="admin-google-oauth2") +admin_shop_router = routers.SimpleRouter() +admin_shop_router.register("orders", OrderAdminViewSet, basename="admin-shop-order") +admin_shop_router.register( + "order-notifications", OrderNotificationAdminViewSet, basename="admin-shop-order-notification" +) +admin_shop_router.register("products", ProductAdminViewSet, basename="admin-shop-product") +admin_shop_router.register("tags", TagAdminViewSet, basename="admin-shop-tag") +admin_shop_router.register("category-groups", CategoryGroupAdminViewSet, basename="admin-shop-category-group") +admin_shop_router.register("option-groups", OptionGroupAdminViewSet, basename="admin-shop-option-group") +admin_shop_router.register("refund-authorizer", RefundAuthorizerAdminViewSet, basename="admin-shop-refund-authorizer") + urlpatterns = [ path("cms/", include(admin_cms_router.urls)), path("file/", include(admin_file_router.urls)), @@ -94,4 +114,5 @@ path("notification/kakao-alimtalk/", include(admin_notification_kakao_router.urls)), path("notification/sms/", include(admin_notification_sms_router.urls)), path("external-api/google/", include(admin_external_api_google_router.urls)), + path("shop/", include(admin_shop_router.urls)), ] diff --git a/app/admin_api/views/cms.py b/app/admin_api/views/cms.py index 362c38e..85a8777 100644 --- a/app/admin_api/views/cms.py +++ b/app/admin_api/views/cms.py @@ -9,8 +9,8 @@ SitemapAdminSerializer, ) from cms.models import DomainGroup, Page, Section, Sitemap +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from django.db import transaction from drf_spectacular.utils import extend_schema, extend_schema_view diff --git a/app/admin_api/views/event/event.py b/app/admin_api/views/event/event.py index 759d47c..85cbfc7 100644 --- a/app/admin_api/views/event/event.py +++ b/app/admin_api/views/event/event.py @@ -1,8 +1,8 @@ from __future__ import annotations from admin_api.serializers.event.event import EventAdminSerializer +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from drf_spectacular.utils import extend_schema, extend_schema_view from event.models import Event diff --git a/app/admin_api/views/event/presentation.py b/app/admin_api/views/event/presentation.py index 2fa645f..6b6e16e 100644 --- a/app/admin_api/views/event/presentation.py +++ b/app/admin_api/views/event/presentation.py @@ -14,8 +14,8 @@ RoomAdminSerializer, RoomScheduleAdminSerializer, ) +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from drf_spectacular.utils import extend_schema, extend_schema_view from event.presentation.models import ( diff --git a/app/admin_api/views/event/sponsor.py b/app/admin_api/views/event/sponsor.py index 2b6872f..f730ed3 100644 --- a/app/admin_api/views/event/sponsor.py +++ b/app/admin_api/views/event/sponsor.py @@ -5,8 +5,8 @@ SponsorTagAdminSerializer, SponsorTierAdminSerializer, ) +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from django.db import models from drf_spectacular.utils import extend_schema, extend_schema_view diff --git a/app/admin_api/views/external_api/google_oauth2.py b/app/admin_api/views/external_api/google_oauth2.py index 317654b..b814e70 100644 --- a/app/admin_api/views/external_api/google_oauth2.py +++ b/app/admin_api/views/external_api/google_oauth2.py @@ -2,8 +2,8 @@ GoogleOAuth2AdminAccessTokenSerializer, GoogleOAuth2AdminSerializer, ) +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from drf_spectacular.utils import extend_schema, extend_schema_view from external_api.google_oauth2.models import GoogleOAuth2 diff --git a/app/admin_api/views/file.py b/app/admin_api/views/file.py index f043f8e..2906b57 100644 --- a/app/admin_api/views/file.py +++ b/app/admin_api/views/file.py @@ -1,6 +1,6 @@ from admin_api.serializers.file import PublicFileAdmimUploadSerializer, PublicFileAdminSerializer +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from drf_spectacular import utils from file.models import PublicFile diff --git a/app/admin_api/views/modification_audit.py b/app/admin_api/views/modification_audit.py index aca3ac4..a21e6eb 100644 --- a/app/admin_api/views/modification_audit.py +++ b/app/admin_api/views/modification_audit.py @@ -5,8 +5,8 @@ PresentationModificationAuditPreviewAdminSerializer, UserModificationAuditPreviewAdminSerializer, ) +from core.authz import IsSuperUser from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from django.db import models from drf_spectacular import utils from drf_standardized_errors.openapi_serializers import ( @@ -25,10 +25,7 @@ } -@utils.extend_schema_view( - list=utils.extend_schema(tags=[OpenAPITag.ADMIN_MODIFICATION_AUDIT]), - retrieve=utils.extend_schema(tags=[OpenAPITag.ADMIN_MODIFICATION_AUDIT]), -) +@utils.extend_schema_view(list=utils.extend_schema(tags=[OpenAPITag.ADMIN_MODIFICATION_AUDIT])) class ModificationAuditAdminViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): serializer_class = ModificationAuditResponseAdminSerializer permission_classes = [IsSuperUser] diff --git a/app/admin_api/views/notification.py b/app/admin_api/views/notification.py index 2d56623..3300b6f 100644 --- a/app/admin_api/views/notification.py +++ b/app/admin_api/views/notification.py @@ -11,9 +11,9 @@ NotificationHistoryRetryRequestAdminSerializer, NotificationTemplateRenderRequestAdminSerializer, ) +from core.authz import IsSuperUser from core.const.tag import OpenAPITag from core.openapi.schemas import build_html_responses -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from drf_spectacular.utils import extend_schema, extend_schema_view from notification.models import ( diff --git a/app/admin_api/views/shop/__init__.py b/app/admin_api/views/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/admin_api/views/shop/order_notifications.py b/app/admin_api/views/shop/order_notifications.py new file mode 100644 index 0000000..961d890 --- /dev/null +++ b/app/admin_api/views/shop/order_notifications.py @@ -0,0 +1,80 @@ +from admin_api.filtersets.shop.orders import OrderAdminFilterSet +from admin_api.serializers.notification import ( + EmailNotificationHistoryAdminSerializer, + NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer, + NHNCloudSMSNotificationHistoryAdminSerializer, +) +from admin_api.serializers.shop.orders import ( + OrderSendNotificationPreviewResponseSerializer, + OrderSendNotificationSerializer, +) +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.viewset.json_schema_viewset import JsonSchemaViewSet +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 + +ACTION_METHODS = ["preview", "send"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER]) for m in ACTION_METHODS}) +class OrderNotificationAdminViewSet(JsonSchemaViewSet, viewsets.GenericViewSet): + http_method_names = ["post"] + permission_classes = [IsSuperUser] + 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) + .select_related("customer_info") + .prefetch_related( + 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", + ), + ), + ), + ), + ) + ) + + @extend_schema( + summary="주문 알림 발송 dry-run (recipient + context + missing_variables 조회)", + 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( + summary="주문 알림 발송 (filterset 으로 대상 주문 지정, 환불 가능 상태만)", + responses={ + status.HTTP_201_CREATED: PolymorphicProxySerializer( + component_name="OrderSendNotificationHistory", + serializers=[ + EmailNotificationHistoryAdminSerializer, + NHNCloudSMSNotificationHistoryAdminSerializer, + NHNCloudKakaoAlimTalkNotificationHistoryAdminSerializer, + ], + resource_type_field_name=None, + ), + }, + ) + @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 new file mode 100644 index 0000000..9d02ea2 --- /dev/null +++ b/app/admin_api/views/shop/orders.py @@ -0,0 +1,236 @@ +import datetime +import io +import json +import typing +from logging import getLogger + +import pandas +from admin_api.filtersets.shop.orders import OrderAdminFilterSet +from admin_api.serializers.shop.orders import OrderAdminSerializer, OrderExportRequestSerializer +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.pagination import AdminPagination +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from django.core.files import File +from django.db import models, transaction +from django.http.response import StreamingHttpResponse +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema, extend_schema_view +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.payment_history.models import PURCHASED_STATUSES, REFUNDABLE_STATUSES, PaymentHistory +from shop.product.models import Product +from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer + +logger = getLogger(__name__) + +ADMIN_METHODS = ["list", "retrieve", "partial_update"] + + +# OrderProductRelation + nested Options prefetch — `Order.products` 용. +_OPR_PREFETCH_QS = ( + OrderProductRelation.objects.filter_active() + .select_related("product") + .prefetch_related( + models.Prefetch( + "options", + queryset=OrderProductOptionRelation.objects.filter_active().select_related( + "product_option_group", "product_option" + ), + ), + ) +) + +# `Order.payment_histories` 용 prefetch — 최신순. +_PAYMENT_HISTORY_PREFETCH_QS = PaymentHistory.objects.filter_active().order_by("-created_at") + + +def _payment_history_created_at_subquery(*, latest: bool) -> models.Subquery: + """Order 별 첫/마지막 PaymentHistory.created_at scalar subquery (filter/annotate 양쪽 용).""" + return models.Subquery( + PaymentHistory.objects.filter(order_id=models.OuterRef("pk")) + .order_by("-created_at" if latest else "created_at") + .values("created_at")[:1] + ) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_ORDER]) for m in ADMIN_METHODS}) +class OrderAdminViewSet( + JsonSchemaViewSet, + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + viewsets.GenericViewSet, +): + http_method_names = ["get", "post", "patch"] + serializer_class = OrderAdminSerializer + filterset_class = OrderAdminFilterSet + pagination_class = AdminPagination + permission_classes = [IsSuperUser] + queryset = ( + Order.objects.filter_has_payment_histories() + .filter(models.Exists(OrderProductRelation.objects.filter(order=models.OuterRef("pk")))) + .select_related_with_user("user", "customer_info") + .prefetch_related( + models.Prefetch("products", queryset=_OPR_PREFETCH_QS), + models.Prefetch("payment_histories", queryset=_PAYMENT_HISTORY_PREFETCH_QS), + ) + .annotate( + current_status=PaymentHistory.objects.latest_per_order_field("status"), + latest_imp_id=PaymentHistory.objects.latest_per_order_field("imp_id"), + latest_price=PaymentHistory.objects.latest_per_order_field("price"), + first_paid_at=_payment_history_created_at_subquery(latest=False), + status_changed_at=_payment_history_created_at_subquery(latest=True), + ) + .order_by("-created_at") + ) + + @extend_schema( + summary="주문 전체 환불 (환불 승인자 TOTP 필수)", + tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND], + parameters=[OpenApiParameter(name="totp", location=OpenApiParameter.QUERY, required=True)], + responses={status.HTTP_204_NO_CONTENT: None}, + ) + @action(detail=True, methods=["post"], url_path="refund") + @transaction.atomic + def refund(self, request: request.Request, pk: typing.Any = None) -> response.Response: + serializer = OrderTotalRefundSerializer( + instance=self.get_object(), + data={"totp": request.query_params.get("totp")}, + context={"check_refundable_date": False}, + ) + serializer.is_valid(raise_exception=True) + serializer.refund() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="주문 부분 환불 (환불 승인자 TOTP 필수)", + tags=[OpenAPITag.ADMIN_SHOP_ORDER_REFUND], + parameters=[OpenApiParameter(name="totp", location=OpenApiParameter.QUERY, required=True)], + responses={status.HTTP_204_NO_CONTENT: None}, + ) + @action(detail=True, methods=["post"], url_path=r"products/(?P[^/.]+)/refund") + @transaction.atomic + def refund_product( + self, + request: request.Request, + pk: typing.Any = None, + rel_id: typing.Any = None, + ) -> response.Response: + order_product_rel = OrderProductRelation.objects.filter(order_id=pk, id=rel_id).first() + if not order_product_rel: + raise exceptions.NotFound("OrderProductRelation not found.") + + serializer = OrderProductRefundSerializer( + instance=order_product_rel, + data={"totp": request.query_params.get("totp")}, + context={"check_refundable_date": False}, + ) + serializer.is_valid(raise_exception=True) + serializer.refund() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="주문 CSV 가져오기 템플릿 다운로드", + tags=[OpenAPITag.ADMIN_SHOP_ORDER], + parameters=[ + OpenApiParameter(name="product_id", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=True), + ], + 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: + if not (product_id := request.query_params.get("product_id")): + raise exceptions.ValidationError({"product_id": "이 값이 필요합니다."}) + try: + product = Product.objects.get(id=product_id) + 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", + headers={"Content-Disposition": "attachment; filename=order_import_template.csv"}, + ) + + @extend_schema( + summary="주문 CSV 가져오기", + tags=[OpenAPITag.ADMIN_SHOP_ORDER], + request={ + "multipart/form-data": { + "type": "object", + "properties": {"csv_file": {"type": "string", "format": "binary"}}, + } + }, + responses={status.HTTP_201_CREATED: None}, + ) + @action( + detail=False, + methods=["post"], + url_path="import", + parser_classes=[parsers.MultiPartParser], + ) + @transaction.atomic + 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_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, + ) + for s in csv_serializers: + s.save() + return response.Response(status=status.HTTP_201_CREATED) + + @extend_schema( + summary="주문 XLSX 내보내기", + tags=[OpenAPITag.ADMIN_SHOP_ORDER], + request=OrderExportRequestSerializer, + responses={status.HTTP_200_OK: OpenApiTypes.BINARY}, + ) + @action(detail=False, methods=["post"], url_path="export") + def export(self, request: request.Request) -> StreamingHttpResponse: + req = OrderExportRequestSerializer(data=request.data) + req.is_valid(raise_exception=True) + product_ids = req.validated_data["product_ids"] + include_refunded = req.validated_data["include_refunded"] + + statuses = PURCHASED_STATUSES if include_refunded else REFUNDABLE_STATUSES + + order_qs = ( + Order.objects.annotate(current_status=PaymentHistory.objects.latest_per_order_field("status")) + .select_related("user") + .prefetch_related("products", "payment_histories") + .filter(products__product_id__in=product_ids, current_status__in=statuses) + ) + order_product_qs = ( + OrderProductRelation.objects.filter(order__in=order_qs) + .select_related("product") + .prefetch_related("options__product_option_group", "options__product_option") + .distinct() + ) + + filename = f"order_export_{datetime.datetime.now().strftime('%Y-%m-%d_%H-%M-%S')}.xlsx" + fileio = io.BytesIO() + df_dict: dict[str, pandas.DataFrame] = { + "주문": exports.OrderExportSerializer(instance=order_qs, many=True).export(), + "주문상품": exports.OrderProductExportSerializer(instance=order_product_qs, many=True).export(), + } + with pandas.ExcelWriter(fileio) as writer: + for sheet_name, df in df_dict.items(): + df.to_excel(writer, sheet_name=sheet_name, startrow=0, startcol=0) + return StreamingHttpResponse( + streaming_content=File(fileio), + content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": f"attachment; filename={filename}"}, + ) diff --git a/app/admin_api/views/shop/products.py b/app/admin_api/views/shop/products.py new file mode 100644 index 0000000..cae501f --- /dev/null +++ b/app/admin_api/views/shop/products.py @@ -0,0 +1,75 @@ +from admin_api.filtersets.shop.products import ProductAdminFilterSet +from admin_api.serializers.shop.products import ( + CategoryGroupAdminSerializer, + OptionGroupAdminSerializer, + ProductAdminSerializer, + TagAdminSerializer, +) +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.viewset.json_schema_viewset import JsonSchemaViewSet +from django.db.models import Count, Prefetch, Q +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import viewsets +from shop.product.models import Category, CategoryGroup, Option, OptionGroup, Product, ProductTagRelation, Tag + +READONLY_METHODS = ["list", "retrieve"] +CRUD_METHODS = READONLY_METHODS + ["create", "update", "partial_update", "destroy"] + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_CATEGORY]) for m in CRUD_METHODS}) +class CategoryGroupAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = CategoryGroupAdminSerializer + permission_classes = [IsSuperUser] + queryset = ( + CategoryGroup.objects.filter_active() + .select_related_with_user() + .prefetch_related( + Prefetch("category_set", queryset=Category.objects.filter_active().select_related_with_user()), + ) + .annotate(category_count=Count("category", filter=Q(category__deleted_at__isnull=True))) + ) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_TAG]) for m in CRUD_METHODS}) +class TagAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = TagAdminSerializer + permission_classes = [IsSuperUser] + queryset = Tag.objects.filter_active().select_related_with_user() + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_PRODUCT]) for m in CRUD_METHODS}) +class ProductAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = ProductAdminSerializer + permission_classes = [IsSuperUser] + filterset_class = ProductAdminFilterSet + queryset = ( + Product.objects.filter_active() + .select_related_with_user("category", "category__group") + .prefetch_related( + Prefetch("tags", queryset=ProductTagRelation.objects.filter_active().select_related("tag")), + Prefetch( + "option_groups", + queryset=OptionGroup.objects.filter_active() + .select_related_with_user() + .prefetch_related( + Prefetch("options", queryset=Option.objects.filter_active().select_related_with_user()), + ), + ), + ) + ) + + +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_SHOP_PRODUCT]) for m in CRUD_METHODS}) +class OptionGroupAdminViewSet(JsonSchemaViewSet, viewsets.ModelViewSet): + http_method_names = ["get", "post", "patch", "delete"] + serializer_class = OptionGroupAdminSerializer + permission_classes = [IsSuperUser] + queryset = ( + OptionGroup.objects.filter_active() + .select_related_with_user("product") + .prefetch_related(Prefetch("options", queryset=Option.objects.filter_active().select_related_with_user())) + ) diff --git a/app/admin_api/views/shop/refund_authorizer.py b/app/admin_api/views/shop/refund_authorizer.py new file mode 100644 index 0000000..6b725e3 --- /dev/null +++ b/app/admin_api/views/shop/refund_authorizer.py @@ -0,0 +1,45 @@ +from core.authz import IsSuperUser +from core.const.tag import OpenAPITag +from core.util.totp import TOTPInfo +from django.conf import settings +from drf_spectacular.utils import OpenApiParameter, OpenApiTypes, extend_schema +from rest_framework import permissions, request, response, status, viewsets +from rest_framework.decorators import action + + +class RefundAuthorizerAdminViewSet(viewsets.ViewSet): + permission_classes = [IsSuperUser] + totp = TOTPInfo(key=settings.SHOP.refund_authorizer_secret_key.encode()) + + @extend_schema( + summary="TOTP otpauth URI 발급 (Google Authenticator 등에 등록)", + tags=[OpenAPITag.ADMIN_SHOP_REFUND_AUTHORIZER], + responses={status.HTTP_200_OK: {"type": "object", "properties": {"otpauth_url": {"type": "string"}}}}, + ) + @action(detail=False, methods=["get"], url_path="setup-qr") + def setup_qr(self, request: request.Request) -> response.Response: + issuer = f"PyConKR{':Local' if settings.IS_LOCAL else ':Dev' if settings.DEBUG else ':Prod'}" + return response.Response({"otpauth_url": self.totp.get_otpauth_uri(issuer=issuer, username="Refund")}) + + @extend_schema( + summary="TOTP 코드 검증", + tags=[OpenAPITag.ADMIN_SHOP_REFUND_AUTHORIZER], + parameters=[ + OpenApiParameter( + name="otp", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + required=True, + description="Google Authenticator 등에서 발급된 6자리 OTP 코드", + ), + ], + responses={ + status.HTTP_200_OK: {"type": "object", "properties": {"valid": {"type": "boolean"}}}, + status.HTTP_400_BAD_REQUEST: {"type": "object", "properties": {"detail": {"type": "string"}}}, + }, + ) + @action(detail=False, methods=["post"], url_path="verify", permission_classes=[permissions.IsAuthenticated]) + def verify(self, request: request.Request) -> response.Response: + if not (otp := request.query_params.get("otp", "")): + return response.Response({"detail": "otp 가 필요합니다."}, status=status.HTTP_400_BAD_REQUEST) + return response.Response({"valid": self.totp.check(otp)}) diff --git a/app/admin_api/views/user.py b/app/admin_api/views/user.py index 142b36a..26be40f 100644 --- a/app/admin_api/views/user.py +++ b/app/admin_api/views/user.py @@ -5,9 +5,9 @@ UserAdminSerializer, UserAdminSignInSerializer, ) +from core.authz import IsSuperUser from core.const.account import generate_random_password from core.const.tag import OpenAPITag -from core.permissions import IsSuperUser from core.viewset.json_schema_viewset import JsonSchemaViewSet from django.contrib.auth import login, logout from drf_spectacular.utils import extend_schema, extend_schema_view @@ -15,10 +15,11 @@ from user.models import UserExt from user.models.organization import Organization -ADMIN_METHODS = ["list", "retrieve", "create", "partial_update", "destroy"] +USER_ADMIN_METHODS = ["list", "retrieve", "create", "partial_update"] +ADMIN_METHODS = USER_ADMIN_METHODS + ["destroy"] -@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_USER]) for m in ADMIN_METHODS}) +@extend_schema_view(**{m: extend_schema(tags=[OpenAPITag.ADMIN_USER]) for m in USER_ADMIN_METHODS}) class UserAdminViewSet( mixins.RetrieveModelMixin, mixins.ListModelMixin, @@ -60,7 +61,7 @@ def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> resp serializer = UserAdminSignInSerializer(data=request.data) serializer.is_valid(raise_exception=True) - login(request=request, user=serializer.user) + login(request=request, user=serializer.user, backend="django.contrib.auth.backends.ModelBackend") return response.Response(data=UserAdminSerializer(serializer.user).data) @extend_schema(tags=[OpenAPITag.ADMIN_ACCOUNT], responses={status.HTTP_204_NO_CONTENT: None}) diff --git a/app/cms/test/sitemap_api_test.py b/app/cms/test/sitemap_api_test.py index 49595d1..97e33eb 100644 --- a/app/cms/test/sitemap_api_test.py +++ b/app/cms/test/sitemap_api_test.py @@ -24,7 +24,7 @@ def test_list_view(api_client, create_sitemap): @pytest.mark.django_db def test_list_view_returns_only_matching_domain(api_client): group_main = DomainGroup.objects.create(name="main", domains=["pycon.kr"]) - group_legacy = DomainGroup.objects.create(name="legacy", domains=["2025.pycon.kr"]) + group_legacy = DomainGroup.objects.create(name="legacy", domains=["legacy.pycon.kr"]) _create_sitemap("main_about", group=group_main) _create_sitemap("legacy_about", group=group_legacy) diff --git a/app/core/authn/__init__.py b/app/core/authn/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/authn/allauth_adapter.py b/app/core/authn/allauth_adapter.py new file mode 100644 index 0000000..c1f505c --- /dev/null +++ b/app/core/authn/allauth_adapter.py @@ -0,0 +1,50 @@ +import logging +import traceback +from typing import Literal + +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter +from allauth.socialaccount.models import SocialLogin +from allauth.socialaccount.providers.base import Provider +from core.logger.util.django_helper import get_request_log_data +from django.http.request import HttpRequest + +# allauth.socialaccount.providers.base.AuthError 상수의 가능한 값 (UNKNOWN / CANCELLED / DENIED) +SocialAuthError = Literal["unknown", "cancelled", "denied"] + +request_logger = logging.getLogger("request_logger") + + +class NoNewUsersAccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request: HttpRequest) -> bool: + return False + + +class SocialAccountLoggingAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request: HttpRequest, sociallogin: SocialLogin) -> bool: + return True + + def on_authentication_error( + self, + request: HttpRequest, + provider: Provider, + error: SocialAuthError | None = None, + exception: Exception | None = None, + extra_context: dict | None = None, + ) -> None: + request_logger.info( + msg="allauth_authentication_error", + extra={ + "data": { + "request": get_request_log_data(request), + "provider": { + "id": provider.id, + "name": provider.name, + "slug": provider.get_slug(), + }, + "error": error, + "exception": "".join(traceback.format_exception(exception)), + "extra_context_keys": extra_context.keys() if extra_context else None, + }, + }, + ) diff --git a/app/core/authn/api_key.py b/app/core/authn/api_key.py new file mode 100644 index 0000000..f2169d4 --- /dev/null +++ b/app/core/authn/api_key.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from hmac import compare_digest +from typing import TYPE_CHECKING + +from django.conf import settings +from django.contrib.auth.hashers import make_password +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from drf_spectacular.openapi import AutoSchema +from rest_framework.authentication import BaseAuthentication +from rest_framework.request import Request + +if TYPE_CHECKING: + from user.models import UserExt + + +class APIKeyAuthentication(BaseAuthentication): + @staticmethod + def _get_or_create_api_key_user(api_key: str) -> "UserExt": + from user.models import UserExt + + username = f"API_KEY_USER_{api_key.upper()}" + email = f"api_key_user_{api_key.lower()}@pycon.kr" + + return UserExt.objects.get_or_create( + username=username, + defaults={"email": email, "password": make_password(None)}, + )[0] + + def authenticate(self, request: Request) -> tuple["UserExt", None] | None: + api_key = request.headers.get("x-api-key", "") + api_secret = request.headers.get("x-api-secret", "") + + if not (expected_secret := settings.EXT_API_KEYS.get(api_key.lower())): + return None + if not compare_digest(api_secret.encode(), expected_secret.encode()): + return None + return self._get_or_create_api_key_user(api_key), None + + +class APIKeyAuthenticationScheme(OpenApiAuthenticationExtension): # type: ignore[no-untyped-call] + target_class = APIKeyAuthentication + name: list[str] = ["API Key", "API Secret"] + + def get_security_definition(self, auto_schema: AutoSchema) -> list[dict[str, str]]: + return [ + {"type": "apiKey", "in": "header", "name": "x-api-key"}, + {"type": "apiKey", "in": "header", "name": "x-api-secret"}, + ] diff --git a/app/core/permissions/__init__.py b/app/core/authz/__init__.py similarity index 100% rename from app/core/permissions/__init__.py rename to app/core/authz/__init__.py diff --git a/app/core/authz/api_key.py b/app/core/authz/api_key.py new file mode 100644 index 0000000..8542a63 --- /dev/null +++ b/app/core/authz/api_key.py @@ -0,0 +1,23 @@ +from typing import Any + +from rest_framework.permissions import BasePermission +from rest_framework.request import Request +from rest_framework.views import APIView + +INVALID_API_KEY_MESSAGE = "API Key가 올바르지 않습니다." + + +class APIKeyPermission(BasePermission): + name: str = "" + message = INVALID_API_KEY_MESSAGE + + def has_permission(self, request: Request, view: APIView) -> bool: + api_key = request.headers.get("x-api-key", "") + return api_key.lower() == self.name and request.user.is_authenticated + + def has_object_permission(self, request: Request, view: APIView, obj: Any) -> bool: + return self.has_permission(request, view) + + +class RegistrationDeskAPIKeyPermission(APIKeyPermission): + name = "registration_desk" diff --git a/app/core/const/regex.py b/app/core/const/regex.py index ba41be0..d710969 100644 --- a/app/core/const/regex.py +++ b/app/core/const/regex.py @@ -1,8 +1,20 @@ import re -UUID_V4_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}" +UUID_V4_PATTERN = "[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}" UUID_V4_REGEX = re.compile(f"^{UUID_V4_PATTERN}$", re.IGNORECASE) # 호스트 형식 — RFC 1035 기반 HOSTNAME_PATTERN = r"^(?=.{1,253}$)([a-z0-9](-?[a-z0-9])*)(\.[a-z0-9](-?[a-z0-9])*)*$" HOSTNAME_REGEX = re.compile(HOSTNAME_PATTERN) + +# 자유 입력 +ALLOW_ALL_PATTERN = r"^(.*)$" +ALLOW_ALL_REGEX = re.compile(ALLOW_ALL_PATTERN) + +# 이메일 +EMAIL_PATTERN = r"^[\w\-\.]+@([\w\-]+\.)+[\w\-]{2,4}$" +EMAIL_REGEX = re.compile(EMAIL_PATTERN) + +# 전화번호 +PHONE_PATTERN = r"^([\d]{3}-[\d]{3,4}-[\d]{4}|\+[\d]{9,14})$" +PHONE_REGEX = re.compile(PHONE_PATTERN) diff --git a/app/core/const/shop_error_messages.py b/app/core/const/shop_error_messages.py new file mode 100644 index 0000000..a1fcfcb --- /dev/null +++ b/app/core/const/shop_error_messages.py @@ -0,0 +1,94 @@ +class CriticalErrorMessages: + INVALID_LOGIC = "발생하면 안 되는 오류가 발생했습니다. PyCon 한국 준비 위원회에 문의해주세요.\n{}" + + +class SignInErrorMessages: + USER_NOT_SIGNED_IN = "로그인 후 이용해주세요." + + +class PermissionErrorMessages: + INVALID_API_KEY = "API Key가 올바르지 않습니다." + INVALID_OTP_CODE = "OTP 코드가 올바르지 않습니다." + OTP_REQUIRED = "환불 승인자의 OTP 코드가 필요합니다." + + +class ProductNotOrderableErrorMessages: + ALREADY_ORDERED = "이미 결제한 상품입니다. 다시 장바구니에 담아주세요." + NOT_ORDERABLE_TIME = "{} 상품은 현재 구매하실 수 없습니다." + SOLDOUT = "{} 상품은 매진되었습니다." + ALREADY_ORDERED_TOO_MUCH = "{} 상품의 인당 최대 구매 수량 초과로 구매하실 수 없습니다." + TOO_MUCH_CART_PRODUCT = "{} 상품의 재고 수량을 초과하여 구매하실 수 없습니다. 장바구니에 담은 수량을 확인해주세요." + PRICE_IS_MINUS = "결제 금액이 너무 낮습니다, PyCon 한국 준비 위원회에 문의해주세요." + PRICE_TOO_LOW = "결제 금액이 너무 낮습니다, 최소한 1원 이상으로 구매해주세요." + PRICE_TOO_HIGH = "결제 금액이 너무 높습니다, 후원 금액 등을 줄여 100만원 미만으로 구매해주세요." + DONATION_NOT_ALLOWED = "{} 상품은 후원이 불가능한 상품입니다." + DONATION_PRICE_OUT_OF_RANGE = "{} 상품의 후원 금액이 범위를 벗어났습니다. {}원 이상 {}원 이하로 입력해주세요." + + +class TagNotOrderableErrorMessages: + SOLDOUT = "{} 상품군은 매진되었습니다." + ALREADY_ORDERED_TOO_MUCH_RELATED_PRODUCTS = "{} 상품군의 인당 최대 구매 수량 초과로 구매하실 수 없습니다." + + +class OptionGroupNotOrderableErrorMessages: + CUSTOM_RESPONSE_PATTERN_MISMATCH = "옵션의 추가 정보를 올바른 형식으로 입력해주세요." + OPTION_NOT_MATCH_PRODUCT = "{} 상품의 옵션이 아닌 옵션이 포함되어 있습니다." + OPTION_NOT_SELECTED = "옵션을 선택해주세요." + SOLDOUT = "{} 상품의 필수 구매 옵션인 '{}' 옵션이 매진되어 상품을 구매하실 수 없습니다." + NOT_ENOUGH_OPTION = "{} 상품의 필수 구매 옵션인 '{}' 옵션을 선택해주세요." + TOO_MUCH_OPTION = "{} 상품의 '{}' 옵션을 너무 많이 선택하셨습니다." + + +class OptionNotOrderableErrorMessages: + SOLDOUT = "{} 상품의 '{}' 옵션은 매진되었습니다." + ALREADY_ORDERED_TOO_MUCH = "{} 상품 '{}' 옵션의 인당 최대 구매 수량 초과로 구매하실 수 없습니다." + TOO_MUCH_CART_OPTION = "{} 상품의 '{}' 옵션을 너무 많이 선택하셨습니다. 장바구니에 담은 수량을 확인해주세요." + + +class CartNotOrderableErrorMessages: + ALREADY_ORDERED = "이미 결제한 장바구니입니다." + CONTAINS_PAID_PRODUCT = "결제한 상품이 포함되어 있습니다. PyCon 한국 준비 위원회에 문의해주세요." + EMPTY = "장바구니가 비어있습니다, 먼저 상품을 담아주세요." + CART_PRICE_TOO_LOW = "장바구니의 금액이 너무 낮습니다. 최소한 1원 이상으로 구매해주세요." + CART_PRICE_TOO_HIGH = "장바구니의 금액이 너무 높습니다. 일부 상품을 제거하여 100만원 미만으로 구매해주세요." + + +class NotRefundableErrorMessages: + ONE_OF_PRODUCT_IS_USED = "주문 중 이미 사용한 상품이 존재합니다. 개별 환불을 진행해주세요." + ONE_OF_PRODUCT_IS_USED_TRY_AFTER_CHANGING_STATUS = ( + "주문 중 사용한 상품이 존재합니다. 상태를 변경한 후 다시 시도해주세요." + ) + ONE_OF_PRODUCT_REFUND_TIME_EXPIRED = "주문 중 환불 가능 기간이 지난 상품이 존재합니다. 개별 환불을 진행해주세요." + PRODUCT_REFUND_TIME_EXPIRED = "상품의 환불 가능 기간이 지났습니다. PyCon 한국 준비 위원회에 문의해주세요." + PRODUCT_PRICE_IS_ZERO = "환불 가능한 상품이 아닙니다. (환불할 금액이 없습니다.)" + PRODUCT_STATUS_IS_NOT_PAID = ( + "결제하지 않았거나, 이미 사용했거나 환불한 상품입니다. PyCon 한국 준비 위원회에 문의해주세요." + ) + ORDER_NOT_REFUNDABLE = "결제 내역 문제로 환불이 불가능한 주문입니다. PyCon 한국 준비 위원회에 문의해주세요." + ORDER_NOT_REFUNDABLE_STATUS = "환불이 불가능한 주문 상태입니다. PyCon 한국 준비 위원회에 문의해주세요." + ORDER_REFUNDABLE_PRODUCT_NOT_FOUND = "환불 가능한 상품이 없습니다." + ORDER_REFUNDABLE_PRICE_NOT_FOUND = "환불 가능한 금액이 없습니다." + ORDER_REFUND_TARGET_PRICE_IS_MISMATCH = ( + "환불할 금액이 남은 결제 금액과 일치하지 않습니다. PyCon 한국 준비 위원회에 문의해주세요." + ) + ORDER_REFUND_TARGET_PRICE_IS_NEGATIVE = "환불할 금액이 이상합니다. PyCon 한국 준비 위원회에 문의해주세요." + ORDER_IMP_ID_NOT_EXIST = "환불이 불가능한 주문입니다. PyCon 한국 준비 위원회에 문의해주세요." + + +class OptionGroupNotModifiableErrorMessages: + ORDER_PRODUCT_OPTION_RELATION_MISMATCH = "해당 옵션을 찾을 수 없습니다." + CUSTOM_RESPONSE_PATTERN_MISMATCH = "옵션의 추가 정보를 올바른 형식으로 입력해주세요." + RESPONSE_MODIFIABLE_ENDS_AT = "옵션의 추가 정보 수정 기간이 지났습니다." + RESPONSE_NOT_MODIFIABLE = "해당 옵션은 수정할 수 없습니다. PyCon 한국 준비 위원회에 문의해주세요." + + +class PortOneWebhookFailureMessages: + ORDER_NOT_FOUND = "주문 정보가 존재하지 않습니다." + PURCHASE_FAILED = "결제에 실패했습니다." + VIRTUAL_ACCOUNT_NOT_SUPPORTED = "가상계좌 결제는 지원하지 않습니다." + UNEXPECTED_RETRIEVED_ORDER_STATUS = "예상한 결제 상태가 아닙니다." + UNEXPECTED_RETRIEVED_ORDER_ID = "결제 ID가 일치하지 않습니다." + UNEXPECTED_PAID_PRICE = "결제 금액이 일치하지 않습니다." + UNSUPPORTED_CURRENCY = "지원하지 않는 통화입니다." + ILLEGAL_STATUS_TRANSITION = "이미 처리된 결제이거나 허용되지 않는 상태 전환입니다." + CANCELLED_NOT_SUPPORTED = "관리자 콘솔에서 결제 취소된 webhook 의 자동 처리는 아직 지원하지 않습니다." diff --git a/app/core/const/tag.py b/app/core/const/tag.py index d906051..9968341 100644 --- a/app/core/const/tag.py +++ b/app/core/const/tag.py @@ -3,6 +3,13 @@ class OpenAPITag: EVENT_PRESENTATION = "Event > Presentation" EVENT_SPONSOR = "Event > Sponsor" + SHOP_USER = "Shop > 고객" + SHOP_PRODUCT = "Shop > 상품" + SHOP_CART = "Shop > 장바구니" + SHOP_ORDER = "Shop > 주문" + SHOP_ORDER_REFUND = "Shop > 환불" + SHOP_PORTONE_WEBHOOK = "Shop > PortOne 결제 Webhook" + ADMIN_ACCOUNT = "Admin > Sign-In & Sign-Out" ADMIN_USER = "Admin > User" ADMIN_CMS = "Admin > CMS" @@ -16,8 +23,17 @@ class OpenAPITag: ADMIN_NOTI_KAKAO_ALIMTALK = "Admin > Notification > Kakao Alimtalk" ADMIN_NOTI_SMS = "Admin > Notification > SMS" ADMIN_EXT_API_GOOGLE_OAUTH2 = "Admin > External API > Google OAuth2" + ADMIN_SHOP_ORDER = "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 > 환불 승인자" PARTICIPANT_PORTAL_USER = "Participant Portal > Sign-In & Sign-Out & My Profile" PARTICIPANT_PORTAL_PUBLIC_FILE = "Participant Portal > Public File" PARTICIPANT_PORTAL_PRESENTATION = "Participant Portal > Presentation" PARTICIPANT_PORTAL_MODIFICATION_AUDIT = "Participant Portal > Modification Audit" + + EXT_REGISTRATION_DESK_API = "EXT: 등록 데스크 API" + EXT_PATRON_API = "EXT: 개인 후원자 API" diff --git a/app/core/external_apis/portone/__init__.py b/app/core/external_apis/portone/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/external_apis/portone/client.py b/app/core/external_apis/portone/client.py new file mode 100644 index 0000000..bef2b4e --- /dev/null +++ b/app/core/external_apis/portone/client.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +from datetime import datetime, timedelta +from logging import getLogger +from time import time +from traceback import format_exception +from typing import Any, Literal + +from django.conf import settings +from httpx import Client +from httpx._types import TimeoutTypes + +from .serializers import NHNKCPReceiptContext, PortOneV1ResponseSerializer + +logger = getLogger(__name__) + +DEFAULT_TIMEOUT = 5 +# PortOne v1 access_token 의 공식 TTL 은 발행 시점 +30분 (developers.portone.io 명시). +# 응답에 `expired_at` (unix epoch sec) 가 포함되며, 만료 직전 재발급 race 를 막기 위한 안전 마진. +TOKEN_REFRESH_MARGIN = timedelta(seconds=30) +# `expired_at` 가 응답에 누락된 비정상 케이스의 보수적 fallback (공식 TTL 30분보다 짧게). +TOKEN_FALLBACK_TTL = timedelta(minutes=5) +RequestMethodType = Literal["GET", "OPTIONS", "HEAD", "POST", "PUT", "PATCH", "DELETE"] + + +class PortOneException(Exception): + pass + + +class PortOneExceptionGroup(ExceptionGroup): + pass + + +class PortOneClient: + def __init__(self, timeout: TimeoutTypes = DEFAULT_TIMEOUT) -> None: + self._timeout = timeout + self._client: Client | None = None + self._cached_token: str | None = None + self._cached_token_expires_at: float = 0.0 + + @property + def client(self) -> Client: + # httpx.Client 는 settings 를 참조하므로 첫 요청 시점까지 생성을 지연한다. + if self._client is None: + self._client = Client(base_url=settings.PORTONE.api_url, timeout=self._timeout) + return self._client + + @property + def _access_token(self) -> str: + # 만료 직전 안전 마진까지는 캐시 재사용 (PortOne 토큰 TTL 30분). + if self._cached_token and time() < self._cached_token_expires_at - TOKEN_REFRESH_MARGIN.total_seconds(): + return self._cached_token + + response = self.client.post( + url="/users/getToken", json={"imp_key": settings.PORTONE.imp_key, "imp_secret": settings.PORTONE.imp_secret} + ) + + try: + resp_serializer = PortOneV1ResponseSerializer.from_response(response) + resp_serializer.is_valid(raise_exception=True) + + resp = resp_serializer.validated_data["response"] + if not (access_token := resp.get("access_token")): + raise ValueError("PortOne access_token 값이 존재하지 않습니다.") + + except Exception as e: + logger.error(format_exception(e)) + raise PortOneException("PortOne AccessToken 획득에 실패했습니다.") from e + + self._cached_token = access_token + self._cached_token_expires_at = float(resp.get("expired_at") or (time() + TOKEN_FALLBACK_TTL.total_seconds())) + return access_token + + def _request( + self, + method: RequestMethodType, + route: str, + json: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, + timeout: TimeoutTypes = DEFAULT_TIMEOUT, + action_desc: str = "UNDEFINED_ACTION", + ) -> dict: + response = self.client.request( + method=method, + url=route, + json=json, + headers={"Authorization": self._access_token, "Content-Type": "application/json"} | (headers or {}), + timeout=timeout, + ) + + try: + resp_serializer = PortOneV1ResponseSerializer.from_response(response) + resp_serializer.is_valid(raise_exception=True) + return resp_serializer.validated_data + except Exception as e: + logger.error(format_exception(e)) + raise PortOneException(f"PortOne API 요청이 실패했습니다. [{action_desc}]") from e + + def register_prepared_payment(self, merchant_id: str, price: int) -> dict: + """결제 금액 사전 등록 요청 + Args: + merchant_id (str): 결제 번호 + price (int): 결제 금액 + Returns: + dict: 결제 사전 등록 응답 + """ + return self._request( + method="POST", + route="/payments/prepare", + json={"merchant_uid": merchant_id, "amount": price}, + action_desc="결제 금액 사전 등록", + ) + + def update_prepared_payment(self, merchant_id: str, price: int) -> dict: + """결제 금액 사전 수정 요청 + Args: + merchant_id (str): 결제 번호 + price (int): 수정된 결제 금액 + Returns: + dict: 결제 사전 수정 응답 + """ + return self._request( + method="PUT", + route="/payments/prepare", + json={"merchant_uid": merchant_id, "amount": price}, + action_desc="결제 금액 사전 수정", + ) + + def register_or_update_prepared_payment(self, merchant_id: str, price: int) -> dict: + """결제 금액 사전 등록 또는 수정 요청 + Args: + merchant_id (str): 결제 번호 + price (int): 결제 금액 + Returns: + dict: 결제 사전 등록 또는 수정 응답 + """ + try: + return self.register_prepared_payment(merchant_id, price) + except PortOneException as e1: + try: + return self.update_prepared_payment(merchant_id, price) + except PortOneException as e2: + raise PortOneExceptionGroup(f"결제금액 사전 등록 또는 수정에 실패했습니다. {merchant_id=}", [e1, e2]) + + def find_payment_info(self, imp_uid: str) -> dict: + if payment_data := self._request(method="GET", route=f"/payments/{imp_uid}").get("response"): + return payment_data + + raise PortOneException(f"결제 정보를 찾을 수 없습니다. {imp_uid=}") + + def req_cancel_payment( + self, merchant_id: str, refund_request_price: int, current_leftover_price: int, reason: str | None = None + ) -> dict: + """결제 환불 요청 + Args: + merchant_id (str): 결제 번호 + refund_request_price (int): 환불 요청 금액 + current_leftover_price (int): 현재 환불 가능한 남은 금액 + reason (str | None): 환불 사유 + Returns: + dict: 결제 사전 등록 또는 수정 응답 + """ + request_dto = { + "merchant_uid": merchant_id, + "amount": refund_request_price, + "checksum": current_leftover_price, + "reason": reason, + } + + return self._request( + method="POST", + route="/payments/cancel", + json={k: v for k, v in request_dto.items() if v is not None}, + action_desc="환불 요청", + ) + + def get_kcp_receipt_search_data(self, imp_uid: str) -> NHNKCPReceiptContext: + """KCP 영수증 조회 시 필요 데이터""" + payment_data = self.find_payment_info(imp_uid) + return NHNKCPReceiptContext( + instance={ + "cmd": "card_bill", + "order_no": imp_uid, + "tno": payment_data["pg_tid"], + "trade_mony": payment_data["amount"], + "req_dt": datetime.now(), + } + ) + + +portone_client = PortOneClient() diff --git a/app/core/external_apis/portone/serializers.py b/app/core/external_apis/portone/serializers.py new file mode 100644 index 0000000..81ed95b --- /dev/null +++ b/app/core/external_apis/portone/serializers.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +from base64 import b64encode + +from cryptography.hazmat.backends import default_backend +from cryptography.hazmat.primitives.asymmetric.padding import PKCS1v15 +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey +from cryptography.hazmat.primitives.hashes import SHA256 +from cryptography.hazmat.primitives.serialization import load_pem_private_key +from httpx import Response +from rest_framework import serializers + + +class PortOneV1ResponseSerializer(serializers.Serializer): + code = serializers.IntegerField(required=True) + message = serializers.CharField(required=True, allow_blank=True, allow_null=True) + response = serializers.DictField(required=True, allow_null=True) + + @classmethod + def from_response(cls, response: Response) -> PortOneV1ResponseSerializer: + return cls(data=response.raise_for_status().json()) + + def validate_code(self, value: int) -> int: + # PortOne API 응답 코드가 0이 아닌 경우는 문제가 있는 경우입니다. + # https://developers.portone.io/docs/ko/auth/guide-2/readme?v=v1#step-04-%ED%99%98%EB%B6%88-%EA%B2%B0%EA%B3%BC-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0 + if value != 0: + raise serializers.ValidationError(f"PortOne API 응답 코드가 0이 아닙니다. {self.initial_data=}") + return value + + +class NHNKCPReceiptContext(serializers.Serializer): + cmd = serializers.ChoiceField(choices=["mcash_bill", "card_bill"], required=True) + order_no = serializers.CharField(required=True, help_text="PortOne 주문ID (ex: imp_123456789012)") + tno = serializers.CharField(required=True, help_text="KCP 주문ID (ex: 24902225098168)") + trade_mony = serializers.IntegerField(required=True, help_text="First Paid Price") + req_dt = serializers.DateTimeField(required=True, help_text="Request Datetime", format="%Y%m%d%H%M%S") + + def to_search_data(self) -> str: + return "^".join(f"{k}={v}" for k, v in self.data.items()) + + def to_kcp_signed_search_data(self, private_key: str, password: str) -> str: + pem_private_key = load_pem_private_key( + data=private_key.encode(), + password=password.encode(), + backend=default_backend(), + ) + assert isinstance(pem_private_key, RSAPrivateKey) # nosec: B101 + signed_data = pem_private_key.sign( + self.to_search_data().encode(), + padding=PKCS1v15(), + algorithm=SHA256(), + ) + return b64encode(signed_data).decode() diff --git a/app/core/filter/__init__.py b/app/core/filter/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/core/filter/multi_field.py b/app/core/filter/multi_field.py new file mode 100644 index 0000000..a6c127c --- /dev/null +++ b/app/core/filter/multi_field.py @@ -0,0 +1,44 @@ +import operator +from functools import reduce + +from django.core.validators import EMPTY_VALUES +from django.db.models import Q +from django_filters import rest_framework as filters + + +class MultiFieldOrFilterMixin: + """`field_names` 의 여러 필드에 같은 lookup 을 OR 로 적용. + + BaseCSVFilter 와 함께 상속하면 CSV 입력 (`"a,b,c"` → `["a", "b", "c"]`) 도 자동 처리되어, + 각 값마다 모든 필드에 lookup → 전체 OR 매칭. + """ + + def __init__(self, *args: tuple, field_names: list[str] | None = None, **kwargs: dict) -> None: + self.field_names: list[str] = field_names or [] + super().__init__(*args, **kwargs) + + def filter(self, qs, value): # type: ignore[no-untyped-def] + if value in EMPTY_VALUES: + return qs + if not self.field_names: + return super().filter(qs, value) + if self.distinct: + qs = qs.distinct() + + # CSV 입력은 list, 단일 입력은 scalar + values = value if isinstance(value, list) else [value] + conditions = [ + Q(**{f"{field}__{self.lookup_expr}": v}) + for v in values + if v not in EMPTY_VALUES + for field in self.field_names + ] + return qs.filter(reduce(operator.or_, conditions)) if conditions else qs + + +class MultiFieldOrCharFilter(MultiFieldOrFilterMixin, filters.CharFilter): + """단일 value, multi-field OR.""" + + +class MultiFieldOrCharInFilter(MultiFieldOrFilterMixin, filters.BaseCSVFilter, filters.CharFilter): + """CSV value (콤마 구분 list), multi-field OR. `lookup_expr` (예: `icontains`) 로 부분 매칭.""" diff --git a/app/core/pagination.py b/app/core/pagination.py new file mode 100644 index 0000000..f18dcce --- /dev/null +++ b/app/core/pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class AdminPagination(PageNumberPagination): + page_size = 50 + page_size_query_param = "page_size" + max_page_size = 200 diff --git a/app/core/scancode_mixin.py b/app/core/scancode_mixin.py new file mode 100644 index 0000000..ccdf793 --- /dev/null +++ b/app/core/scancode_mixin.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from base64 import urlsafe_b64encode +from contextlib import suppress +from functools import cached_property +from hashlib import sha256 +from hmac import new as hmac_new +from typing import ClassVar, Self +from uuid import UUID + +from django.conf import settings +from rest_framework.reverse import reverse +from shortuuid import decode, encode + + +class ScanCodeMixin: + scancode_prefix: ClassVar[str] + scancode_uuid_field: ClassVar[str] = "id" + + @property + def _scancode_uuid(self) -> UUID: + return getattr(self, self.scancode_uuid_field) + + @cached_property + def short_id(self) -> str: + return encode(self._scancode_uuid) + + @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("=") + + @cached_property + def scancode_token(self) -> str: + return f"{self.scancode_prefix}:{self.short_id}:{self.salt}" + + @cached_property + def scancode_path(self) -> str: + return f"{reverse('v1:scancode-list')}?token={self.scancode_token}" + + @classmethod + def from_short_id(cls, short_id: str) -> Self | None: + with suppress(ValueError): + return cls.objects.filter(**{cls.scancode_uuid_field: decode(short_id)}).first() + return None + + @classmethod + def from_scancode_token(cls, scancode_token: str) -> Self | None: + parts = scancode_token.split(":") + if len(parts) != 3: + return 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: + return instance + return None diff --git a/app/core/serializer/nested_model_serializer.py b/app/core/serializer/nested_model_serializer.py new file mode 100644 index 0000000..1e66836 --- /dev/null +++ b/app/core/serializer/nested_model_serializer.py @@ -0,0 +1,212 @@ +import typing + +from django.core.exceptions import FieldDoesNotExist +from django.db.models import Model +from django.db.models.manager import BaseManager +from rest_framework import serializers, settings +from rest_framework.utils import model_meta + +if typing.TYPE_CHECKING: + from django_stubs_ext import StrOrPromise +else: + StrOrPromise = str | typing.Callable[[], str] + + +class InstanceListSerializer(serializers.ListSerializer): + error_messages: dict[str, StrOrPromise] + allow_empty: bool + max_length: int | None + min_length: int | None + default_error_messages: dict[str, StrOrPromise] = ( # type: ignore[misc] + serializers.ListSerializer.default_error_messages + | {"data_and_instance_not_equal_length": "The length of data and instance must be equal."} + ) + + def _validate_data(self, data: list[dict] | typing.Any) -> list[dict]: + message: StrOrPromise + if not isinstance(data, list): + message = self.error_messages["not_a_list"].format(input_type=type(data).__name__) + raise serializers.ValidationError( + {settings.api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="not_a_list" + ) + + if not self.allow_empty and not data: + message = self.error_messages["empty"] + raise serializers.ValidationError({settings.api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="empty") + + if self.max_length is not None and len(data) > self.max_length: + message = self.error_messages["max_length"].format(max_length=self.max_length) + raise serializers.ValidationError( + {settings.api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="max_length" + ) + + if self.min_length is not None and len(data) < self.min_length: + message = self.error_messages["min_length"].format(min_length=self.min_length) + raise serializers.ValidationError( + {settings.api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="min_length" + ) + + if self.instance and len(data) != len(self.instance): + message = self.error_messages["data_and_instance_not_equal_length"] + raise serializers.ValidationError( + {settings.api_settings.NON_FIELD_ERRORS_KEY: [message]}, code="not_equal_length" + ) + + return data + + def to_internal_value(self, data: list[dict]) -> list[dict]: + assert isinstance(self.child, serializers.BaseSerializer) # nosec: B101 + + data = self._validate_data(data) + ret, errors = [], [] + + # self.instance 가 명시적으로 set 된 경우(예: instance=tags) length 가 _validate_data 에서 검증됨 → + # id 미지정 시 위치 기반 매칭이 안전. parent 에서 fetch 한 경우는 length 검증 없으므로 위치 매칭 금지. + has_explicit_instance = self.instance is not None + child_instances = self.instance or ( + self.parent and self.parent.instance and getattr(self.parent.instance, self.source or self.field_name) + ) + if isinstance(child_instances, BaseManager): + child_instances = list(child_instances.all()) + + for index, item in enumerate(data): + try: + self.child.initial_data = item + self.child.context["index"] = index + # 매 iteration 마다 초기화 — id 없는 항목은 create 모드로 검증. + self.child.instance = None + if child_instances and "id" in item: + target_instance = next((i for i in child_instances if str(i.id) == str(item["id"])), None) + if not target_instance: + raise serializers.ValidationError("유효하지 않은 ID입니다.", code="not_found") + self.child.instance = target_instance + elif child_instances and has_explicit_instance: + self.child.instance = child_instances[index] + validated = self.run_child_validation(item) + except serializers.ValidationError as exc: + errors.append(exc.detail) + else: + ret.append(validated) + errors.append({}) + + if any(errors): + raise serializers.ValidationError(errors) + + return ret + + +class NestedModelSerializer(serializers.ModelSerializer): + list_serializer_class = InstanceListSerializer + default_error_messages: dict[str, StrOrPromise] = ( # type: ignore[misc] + serializers.ModelSerializer.default_error_messages | {"not_found": "The ID is not found."} + ) + + def _update_child_instance(self, serializer_obj: serializers.BaseSerializer, instance: Model, data: dict) -> None: + child_serializer = serializer_obj.__class__(instance, data=data) + child_serializer.is_valid(raise_exception=True) + child_serializer.save() + + def _update_list_instances(self, field: serializers.ListSerializer, data: list[dict]) -> None: + if (instances := getattr(self.instance, field.source or field.field_name)) and isinstance( + instances, BaseManager + ): + instances = list(instances.all()) + + instance_dict = {str(i.pk): i for i in instances} + for datum in data: + if child_instance := instance_dict.get(str(datum.get("id"))): + self._update_child_instance(typing.cast(serializers.BaseSerializer, field.child), child_instance, datum) + + 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]] = [] + + for field_name, value in validated_data.items(): + if (field := self.fields[field_name]).read_only: + continue + + if isinstance(field, serializers.BaseSerializer): + if isinstance(field, serializers.ListSerializer): + self._update_list_instances(field, value) + elif instance := getattr(instance, field_name): + self._update_child_instance(field, instance, value) + elif field_name in info.relations and info.relations[field_name].to_many: + m2m_fields.append((field_name, value)) + else: + setattr(instance, field_name, value) + instance.save() + + for attr, value in m2m_fields: + field_name = getattr(instance, attr) + field_name.set(value) + + return instance + + +class NestedFieldSpec(typing.NamedTuple): + related_manager_name: str # parent 의 reverse manager 속성명 (예: "category_set", "options") + child_model: type[Model] # 자식 모델 클래스 + parent_fk_name: str # 자식 모델에서 parent 를 가리키는 FK 필드명 (예: "group") + + +class NestedFieldModelSerializer(NestedModelSerializer): + def __init_subclass__(cls, **kwargs: typing.Any) -> None: + super().__init_subclass__(**kwargs) + if (meta := getattr(cls, "Meta", None)) is None: + return + + nested_fields = getattr(meta, "nested_fields", None) + if not isinstance(nested_fields, dict): + raise TypeError(f"{cls.__name__}.Meta must define `nested_fields: dict[str, NestedFieldSpec]`.") + + parent_model: type[Model] = meta.model + for key, spec in nested_fields.items(): + if not hasattr(parent_model, spec.related_manager_name): + raise TypeError( + f"{cls.__name__}.Meta.nested_fields[{key!r}]: " + f"{parent_model.__name__} has no attribute {spec.related_manager_name!r}." + ) + try: + spec.child_model._meta.get_field(spec.parent_fk_name) + except FieldDoesNotExist as e: + raise TypeError( + f"{cls.__name__}.Meta.nested_fields[{key!r}]: " + f"{spec.child_model.__name__} has no field {spec.parent_fk_name!r}." + ) from e + + def create(self, validated_data: dict) -> Model: + nested_data = {k: validated_data.pop(k, []) or [] for k in self.Meta.nested_fields} + instance = super().create(validated_data) + self._apply_nested_sync(instance, nested_data) + return instance + + def update(self, instance: Model, validated_data: dict) -> Model: + nested_data = {k: validated_data.pop(k, None) for k in self.Meta.nested_fields} + instance = super().update(instance, validated_data) + self._apply_nested_sync(instance, nested_data) + return instance + + def _apply_nested_sync(self, instance: Model, nested_data: dict[str, list[dict] | None]) -> None: + for key, children_data in nested_data.items(): + if children_data is None: + continue + spec = self.Meta.nested_fields[key] + rel_mgr = getattr(instance, spec.related_manager_name) + active_children_qs = rel_mgr.filter_active() if hasattr(rel_mgr, "filter_active") else rel_mgr.all() + existing = {child.id: child for child in active_children_qs} + provided_ids: set = set() + + for child_data in (dict(d) for d in children_data): + child_id = child_data.pop("id", None) + child_data.pop(spec.parent_fk_name, None) # FK 는 parent 로 고정, 입력값 무시 + if child_id and (existing_child := existing.get(child_id)): + for k, v in child_data.items(): + setattr(existing_child, k, v) + existing_child.save() + provided_ids.add(child_id) + else: + spec.child_model.objects.create(**{spec.parent_fk_name: instance, **child_data}) + + for child_id, child in existing.items(): + if child_id not in provided_ids: + child.delete() diff --git a/app/core/serializer/skip_none_list_serializer.py b/app/core/serializer/skip_none_list_serializer.py new file mode 100644 index 0000000..fa1b564 --- /dev/null +++ b/app/core/serializer/skip_none_list_serializer.py @@ -0,0 +1,11 @@ +import typing + +from rest_framework import serializers + + +class SkipNoneListSerializer(serializers.ListSerializer): + """child.to_representation 결과 중 None 은 결과에서 제외 — child 가 row-skip 의미론을 갖는 경우 사용.""" + + def to_representation(self, data: typing.Any) -> list[typing.Any]: + iterable = data.all() if hasattr(data, "all") else data + return [item for item in (self.child.to_representation(o) for o in iterable) if item is not None] diff --git a/app/core/settings.py b/app/core/settings.py index ca1e8a6..655ecc1 100644 --- a/app/core/settings.py +++ b/app/core/settings.py @@ -109,6 +109,16 @@ "simple_history", # For Shell Plus "django_extensions", + # Django-Allauth + "allauth", + "allauth.account", + "allauth.headless", + "allauth.socialaccount", + "allauth.usersessions", + "allauth.socialaccount.providers.github", + "allauth.socialaccount.providers.google", + "allauth.socialaccount.providers.kakao", + "allauth.socialaccount.providers.naver", # django-app "user", "file", @@ -116,8 +126,12 @@ "event", "event.presentation", "event.sponsor", + "shop.order", + "shop.product", + "shop.payment_history", "notification", "admin_api", + "internal_api", "participant_portal_api", "external_api", "external_api.google_oauth2", @@ -139,6 +153,8 @@ "corsheaders.middleware.CorsMiddleware", # simple-history "simple_history.middleware.HistoryRequestMiddleware", + # Django-Allauth + "allauth.account.middleware.AccountMiddleware", # Thread Local Middleware "core.middleware.thread_middleware.ThreadLocalMiddleware", # Request Response Logger @@ -199,6 +215,48 @@ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"}, ] +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.Argon2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2PasswordHasher", + "django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher", + "django.contrib.auth.hashers.BCryptSHA256PasswordHasher", + "django.contrib.auth.hashers.ScryptPasswordHasher", +] + +# Django-Allauth +AUTHENTICATION_BACKENDS = [ + # Django admin / 기본 로그인 + "django.contrib.auth.backends.ModelBackend", + # allauth (email login 등) + "allauth.account.auth_backends.AuthenticationBackend", + # 외부 등록 데스크 등 API key 인증 + "core.authn.api_key.APIKeyAuthentication", +] + +ACCOUNT_DEFAULT_HTTP_PROTOCOL = "https" +ACCOUNT_LOGIN_METHODS = {"username", "email"} +ACCOUNT_EMAIL_VERIFICATION = "none" +ACCOUNT_ADAPTER = "core.authn.allauth_adapter.NoNewUsersAccountAdapter" + +SOCIALACCOUNT_ONLY = False +SOCIALACCOUNT_AUTO_SIGNUP = True +SOCIALACCOUNT_EMAIL_AUTHENTICATION = True +SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True +SOCIALACCOUNT_EMAIL_REQUIRED = True +SOCIALACCOUNT_ADAPTER = "core.authn.allauth_adapter.SocialAccountLoggingAdapter" +SOCIALACCOUNT_LOGIN_ON_GET = True +SOCIALACCOUNT_PROVIDERS = { + "google": { + "SCOPE": ["profile", "email"], + "AUTH_PARAMS": {"access_type": "online"}, + }, +} + +HEADLESS_ONLY = True +HEADLESS_FRONTEND_URLS = { + "socialaccount_login_error": env("HEADLESS_SOCIALACCOUNT_LOGIN_ERROR_URL", default=""), +} + # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ @@ -370,6 +428,39 @@ CELERY_TASK_SOFT_TIME_LIMIT = 60 CELERY_TASK_TIME_LIMIT = 90 +# PortOne Settings +PORTONE = types.SimpleNamespace( + api_url=env("PORTONE_API_URL", default="https://api.iamport.kr"), + ip_list=env.list( + "PORTONE_IP_LIST", + default=[ + "52.78.100.19", + "52.78.48.223", + "52.78.5.241", # (Webhook Test Only) + ], + ), + imp_key=env("PORTONE_IMP_KEY", default="imp_key"), + imp_secret=env("PORTONE_IMP_SECRET", default="imp_secret"), +) + +# NHN KCP Settings +NHN_KCP = types.SimpleNamespace( + pg_api_cert=env.str("NHN_KCP_PG_API_CERT", default=""), + pg_api_private_key=env.str("NHN_KCP_PG_API_PRIVATE_KEY", default=""), + pg_api_password=env.str("NHN_KCP_PG_API_PASSWORD", default=""), +) + +# Shop Settings +SHOP = types.SimpleNamespace( + order_scancode_salt=env("ORDER_SCANCODE_SALT", default="local_order_scancode_salt"), + refund_authorizer_secret_key=env("REFUND_AUTHORIZER_SECRET_KEY", default="local_refund_authorizer_secret_key"), +) + +# External API Key Settings (등록 데스크 등) +EXT_API_KEYS = { + "registration_desk": env("API_KEY_REGISTRATION_DESK", default=None), +} + # Sentry Settings if SENTRY_DSN := env("SENTRY_DSN", default=""): SENTRY_TRACES_SAMPLE_RATE = env.float("SENTRY_TRACES_SAMPLE_RATE", default=1.0) diff --git a/app/core/urls.py b/app/core/urls.py index 9243f34..f0c1d5f 100644 --- a/app/core/urls.py +++ b/app/core/urls.py @@ -30,6 +30,11 @@ path("event/presentation/", include("event.presentation.urls")), path("event/sponsor/", include("event.sponsor.urls")), path("external-api/", include("external_api.urls")), + path("internal-api/", include("internal_api.urls")), + path("shop/orders/", include("shop.order.urls")), + path("shop/products/", include("shop.product.urls")), + path("shop/payment-histories/", include("shop.payment_history.urls")), + path("shop/patron/", include("shop.patron")), ] urlpatterns = [ @@ -38,6 +43,9 @@ path("livez/", core.health_check.livez), # Admin path("admin/", admin.site.urls), + # Django-Allauth + path("accounts/", include("allauth.urls")), + path("authn/social/", include("allauth.headless.urls")), # V1 API re_path("^v1/", include((v1_apis, "v1"), namespace="v1")), # API Docs diff --git a/app/core/util/dateutil.py b/app/core/util/dateutil.py index 6a2c336..7479432 100644 --- a/app/core/util/dateutil.py +++ b/app/core/util/dateutil.py @@ -2,6 +2,11 @@ from typing import Any +def now_aware() -> datetime: + """현재 시각을 로컬 타임존이 반영된 timezone-aware datetime 으로 반환.""" + return datetime.now().astimezone() + + def any_to_datetime(value: Any, tzinfo: timezone | None = None, raise_exception: bool = True) -> datetime | None: """Convert a string or datetime to a datetime object.""" if not value: diff --git a/app/core/util/grouper.py b/app/core/util/grouper.py new file mode 100644 index 0000000..6198cf3 --- /dev/null +++ b/app/core/util/grouper.py @@ -0,0 +1,32 @@ +from collections.abc import Generator, Iterable, Iterator +from itertools import islice +from typing import Any, TypeVar + +from django.db import models + +T = TypeVar("T") +Q = TypeVar("Q", bound=models.QuerySet[models.Model]) + + +def grouper(iterable: Iterable[T], n: int) -> Iterator[tuple[T, ...]]: + iterator = iter(iterable) + while True: + if not (elements := tuple(islice(iterator, n))): + return + yield elements + + +def query_grouper(qs: Q, chunk_len: int) -> Generator[list, Any, None]: + """`(created_at, id)` 복합 커서로 chunk 단위 list 를 yield. 동일 created_at 인 row 도 누락 없이 처리.""" + qs = qs.order_by("created_at", "id") + last_created_at, last_id = None, None + while True: + chunk_qs = qs + if last_created_at is not None: + chunk_qs = qs.filter( + models.Q(created_at__gt=last_created_at) | models.Q(created_at=last_created_at, id__gt=last_id), + ) + if not (chunk := list(chunk_qs[:chunk_len])): + return + yield chunk + last_created_at, last_id = chunk[-1].created_at, chunk[-1].id diff --git a/app/core/util/strutil.py b/app/core/util/strutil.py new file mode 100644 index 0000000..8f0f7ca --- /dev/null +++ b/app/core/util/strutil.py @@ -0,0 +1,17 @@ +from base64 import urlsafe_b64decode, urlsafe_b64encode +from uuid import UUID + +from core.const.regex import UUID_V4_REGEX + + +def uuid_to_b64(in_str: UUID | str) -> str: + if isinstance(in_str, str): + if not UUID_V4_REGEX.match(in_str): + raise ValueError(f"Invalid UUID string: {in_str}") + in_str = UUID(in_str) + + return urlsafe_b64encode(in_str.bytes).decode("utf-8").rstrip("=") + + +def b64_to_uuid(in_str: str) -> UUID: + return UUID(bytes=urlsafe_b64decode(in_str + "=" * (-len(in_str) % 4))) diff --git a/app/core/util/totp.py b/app/core/util/totp.py new file mode 100644 index 0000000..37c7518 --- /dev/null +++ b/app/core/util/totp.py @@ -0,0 +1,63 @@ +import warnings +from base64 import b32encode +from dataclasses import dataclass +from hmac import new as hmac_new +from struct import pack, unpack +from time import time +from typing import Literal +from urllib.parse import quote, urlencode + +ALLOWED_DIGESTS = {"sha1", "sha256", "sha512"} + + +@dataclass(frozen=True) +class TOTPInfo: + key: bytes + time_step: int = 30 + digits: int = 6 + digest: Literal["sha1", "sha256", "sha512"] = "sha1" + window: int = 1 + + def __post_init__(self) -> None: + if self.digest not in ALLOWED_DIGESTS: + raise ValueError(f"Unsupported digest algorithm: {self.digest}") + if self.digest != "sha1": + warnings.warn( + f"Using {self.digest} is not recommended as Google Authenticator does not support it.", + stacklevel=2, + ) + if self.time_step != 30: + warnings.warn( + f"Using time_step={self.time_step} is not recommended as Google Authenticator does not support it.", + stacklevel=2, + ) + + def get_hotp(self, counter: int) -> str: + mac = hmac_new(self.key, pack(">Q", counter), self.digest).digest() + offset = mac[-1] & 0x0F + binary = unpack(">L", mac[offset : offset + 4])[0] & 0x7FFFFFFF # noqa: E203 + return str(binary)[-self.digits :].zfill(self.digits) # noqa: E203 + + def get_totp(self, current_time: float | None = None) -> tuple[str, int]: + counter = int((current_time or time()) / self.time_step) + leftover_sec = self.time_step - int((current_time or time()) % self.time_step) + return self.get_hotp(counter=counter), leftover_sec + + def get_allowed_totps(self, current_time: float | None = None) -> list[str]: + base_counter = int((current_time or time()) / self.time_step) + return [self.get_hotp(counter=counter) for counter in range(base_counter - self.window, base_counter + 1)] + + def check(self, totp_input: str) -> bool: + return totp_input.isdigit() and totp_input in self.get_allowed_totps() + + def get_otpauth_uri(self, issuer: str, username: str) -> str: + encoded_data: str = urlencode( + query={ + "secret": b32encode(self.key).decode("utf-8"), + "issuer": issuer, + "algorithm": self.digest.upper(), + "digits": self.digits, + "period": self.time_step, + } + ) + return f"otpauth://totp/{quote(string=issuer)}:{quote(string=username)}?{encoded_data}" diff --git a/app/event/migrations/0004_remove_event_uq__evt__name_name_ko_and_more.py b/app/event/migrations/0004_remove_event_uq__evt__name_name_ko_and_more.py new file mode 100644 index 0000000..100dbda --- /dev/null +++ b/app/event/migrations/0004_remove_event_uq__evt__name_name_ko_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 6.0.4 on 2026-05-11 04:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("event", "0003_alter_event_name_alter_event_name_en_and_more"), + ("user", "0009_alter_historicaluserext_options_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + operations = [ + migrations.RemoveConstraint(model_name="event", name="uq__evt__name-name_ko"), + migrations.RemoveConstraint(model_name="event", name="uq__evt__name-name_en"), + migrations.AddConstraint( + model_name="event", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("name_ko",), name="uq__evt__name-ko" + ), + ), + migrations.AddConstraint( + model_name="event", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("name_en",), name="uq__evt__name-en" + ), + ), + ] diff --git a/app/event/presentation/migrations/0015_remove_presentationcategory_uq__prst_cat__type__name_name_ko_and_more.py b/app/event/presentation/migrations/0015_remove_presentationcategory_uq__prst_cat__type__name_name_ko_and_more.py new file mode 100644 index 0000000..e3bceb8 --- /dev/null +++ b/app/event/presentation/migrations/0015_remove_presentationcategory_uq__prst_cat__type__name_name_ko_and_more.py @@ -0,0 +1,50 @@ +# Generated by Django 6.0.4 on 2026-05-11 04:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("event", "0004_remove_event_uq__evt__name_name_ko_and_more"), + ("presentation", "0014_historicalpresentation_public_slideshow_file_and_more"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + operations = [ + migrations.RemoveConstraint(model_name="presentationcategory", name="uq__prst_cat__type__name-name_ko"), + migrations.RemoveConstraint(model_name="presentationcategory", name="uq__prst_cat__type__name-name_en"), + migrations.RemoveConstraint(model_name="presentationtype", name="uq__prst_type__event__name-name_ko"), + migrations.RemoveConstraint(model_name="presentationtype", name="uq__prst_type__event__name-name_en"), + migrations.AddConstraint( + model_name="presentationcategory", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("type", "name_ko"), + name="uq__prst_cat__type__name-ko", + ), + ), + migrations.AddConstraint( + model_name="presentationcategory", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("type", "name_en"), + name="uq__prst_cat__type__name-en", + ), + ), + migrations.AddConstraint( + model_name="presentationtype", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("event", "name_ko"), + name="uq__prst_type__event__name-ko", + ), + ), + migrations.AddConstraint( + model_name="presentationtype", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("event", "name_en"), + name="uq__prst_type__event__name-en", + ), + ), + ] diff --git a/app/event/sponsor/migrations/0018_remove_sponsor_uq__spsr__name_name_ko_and_more.py b/app/event/sponsor/migrations/0018_remove_sponsor_uq__spsr__name_name_ko_and_more.py new file mode 100644 index 0000000..5bffeda --- /dev/null +++ b/app/event/sponsor/migrations/0018_remove_sponsor_uq__spsr__name_name_ko_and_more.py @@ -0,0 +1,61 @@ +# Generated by Django 6.0.4 on 2026-05-11 04:44 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("event", "0004_remove_event_uq__evt__name_name_ko_and_more"), + ("file", "0001_initial"), + ("sponsor", "0017_alter_sponsor_options"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + operations = [ + migrations.RemoveConstraint(model_name="sponsor", name="uq__spsr__name-name_ko"), + migrations.RemoveConstraint(model_name="sponsor", name="uq__spsr__name-name_en"), + migrations.RemoveConstraint(model_name="sponsortag", name="uq__spsr_tag__name-name_ko"), + migrations.RemoveConstraint(model_name="sponsortag", name="uq__spsr_tag__name-name_en"), + migrations.RemoveConstraint(model_name="sponsortier", name="uq__spsr_tier__name-name_ko"), + migrations.RemoveConstraint(model_name="sponsortier", name="uq__spsr_tier__name-name_en"), + migrations.AddConstraint( + model_name="sponsor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("event", "name_ko"), name="uq__spsr__name-ko" + ), + ), + migrations.AddConstraint( + model_name="sponsor", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("event", "name_en"), name="uq__spsr__name-en" + ), + ), + migrations.AddConstraint( + model_name="sponsortag", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("name_ko",), name="uq__spsr_tag__name-ko" + ), + ), + migrations.AddConstraint( + model_name="sponsortag", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("name_en",), name="uq__spsr_tag__name-en" + ), + ), + migrations.AddConstraint( + model_name="sponsortier", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("event", "name_ko"), + name="uq__spsr_tier__name-ko", + ), + ), + migrations.AddConstraint( + model_name="sponsortier", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), + fields=("event", "name_en"), + name="uq__spsr_tier__name-en", + ), + ), + ] diff --git a/app/internal_api/__init__.py b/app/internal_api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/internal_api/apps.py b/app/internal_api/apps.py new file mode 100644 index 0000000..094edd2 --- /dev/null +++ b/app/internal_api/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class InternalApiConfig(AppConfig): + name = "internal_api" diff --git a/app/internal_api/filters.py b/app/internal_api/filters.py new file mode 100644 index 0000000..924c6f0 --- /dev/null +++ b/app/internal_api/filters.py @@ -0,0 +1,72 @@ +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( + 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( + 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(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( + 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/serializers.py b/app/internal_api/serializers.py new file mode 100644 index 0000000..2d4d4c9 --- /dev/null +++ b/app/internal_api/serializers.py @@ -0,0 +1,147 @@ +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") + 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/urls.py b/app/internal_api/urls.py new file mode 100644 index 0000000..8140361 --- /dev/null +++ b/app/internal_api/urls.py @@ -0,0 +1,8 @@ +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))] diff --git a/app/internal_api/views.py b/app/internal_api/views.py new file mode 100644 index 0000000..d494ed5 --- /dev/null +++ b/app/internal_api/views.py @@ -0,0 +1,121 @@ +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().order_by("-created_at"), + to_attr="_payment_histories_by_latest", + ), + ) + .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/channels.py b/app/notification/channels.py new file mode 100644 index 0000000..d78c79d --- /dev/null +++ b/app/notification/channels.py @@ -0,0 +1,25 @@ +from django.db import models +from notification.models.base import NotificationHistoryBase, NotificationTemplateBase +from notification.models.email import EmailNotificationTemplate +from notification.models.nhn_cloud_kakao_alimtalk import NHNCloudKakaoAlimTalkNotificationTemplate +from notification.models.nhn_cloud_sms import NHNCloudSMSNotificationTemplate + + +class NotificationChannel(models.TextChoices): + EMAIL = "email", "Email" + NHN_CLOUD_SMS = "nhn_cloud_sms", "NHN Cloud SMS" + NHN_CLOUD_KAKAO_ALIMTALK = "nhn_cloud_kakao_alimtalk", "NHN Cloud Kakao Alimtalk" + + @property + def template_class(self) -> type[NotificationTemplateBase]: + return { + NotificationChannel.EMAIL: EmailNotificationTemplate, + NotificationChannel.NHN_CLOUD_SMS: NHNCloudSMSNotificationTemplate, + NotificationChannel.NHN_CLOUD_KAKAO_ALIMTALK: NHNCloudKakaoAlimTalkNotificationTemplate, + }[self] + + @property + def history_class(self) -> type[NotificationHistoryBase]: + # Template → History reverse relation 의 related_name 이 "histories" 라는 컨벤션에 의존. + # NotificationHistoryBase 서브클래스는 `template = ForeignKey(..., related_name="histories")` 패턴. + return self.template_class._meta.get_field("histories").related_model diff --git a/app/participant_portal_api/views/user.py b/app/participant_portal_api/views/user.py index 845a3a7..7e37612 100644 --- a/app/participant_portal_api/views/user.py +++ b/app/participant_portal_api/views/user.py @@ -61,7 +61,7 @@ def signin(self, request: request.Request, *args: tuple, **kwargs: dict) -> resp serializer = UserPortalSignInSerializer(data=request.data) serializer.is_valid(raise_exception=True) - login(request=request, user=serializer.user) + 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}) diff --git a/app/shop/__init__.py b/app/shop/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/order/__init__.py b/app/shop/order/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/order/apps.py b/app/shop/order/apps.py new file mode 100644 index 0000000..4766972 --- /dev/null +++ b/app/shop/order/apps.py @@ -0,0 +1,10 @@ +import importlib + +from django.apps import AppConfig + + +class OrderConfig(AppConfig): + name = "shop.order" + + def ready(self): + importlib.import_module("shop.order.translation") diff --git a/app/shop/order/exports.py b/app/shop/order/exports.py new file mode 100644 index 0000000..4283ec9 --- /dev/null +++ b/app/shop/order/exports.py @@ -0,0 +1,81 @@ +import collections.abc +import typing + +import pandas +from rest_framework import serializers +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation +from shop.product.models import Option, OptionGroup + + +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)) + + +class OrderExportSerializer(serializers.ModelSerializer): + user_email = serializers.EmailField(source="user.email") + customer_name = serializers.CharField(source="customer_info.name", allow_null=True) + customer_phone = serializers.CharField(source="customer_info.phone", allow_null=True) + customer_email = serializers.EmailField(source="customer_info.email", allow_null=True) + customer_organization = serializers.CharField(source="customer_info.organization", allow_null=True) + + first_paid_at = serializers.DateTimeField() + + class Meta: + model = Order + list_serializer_class = ListExportSerializer + field_def: collections.abc.Sequence[tuple[str, str]] = ( + ("id", "주문 번호"), + ("user_email", "주문 계정 이메일"), + ("customer_name", "고객명"), + ("customer_phone", "고객 전화번호"), + ("customer_email", "고객 이메일"), + ("customer_organization", "고객 소속"), + ("name", "주문명"), + ("first_paid_at", "첫 결제 시간"), + ("first_paid_price", "첫 결제 금액"), + ("current_paid_price", "현재 결제 금액"), + ("current_status", "현재 상태"), + ("latest_imp_id", "PortOne ID"), + ) + fields: list[str] = [field[0] for field in field_def] + field_names: list[str] = [field[1] for field in field_def] + + def export(self) -> pandas.DataFrame: + raise NotImplementedError(".export method is implemented in ListExportSerializer") + + +class OrderProductExportSerializer(serializers.ModelSerializer): + product_name = serializers.CharField(source="product.name") + + class Meta: + model = OrderProductRelation + field_def: collections.abc.Sequence[tuple[str, str]] = ( + ("order_id", "주문 번호"), + ("product_id", "상품 ID"), + ("product_name", "상품명"), + ("status", "상태"), + ("price", "결제 금액"), + ("donation_price", "추가 기부액"), + ) + list_serializer_class = ListExportSerializer + fields: list[str] = [field[0] for field in field_def] + field_names: list[str] = [field[1] for field in field_def] + + 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.all() + for option in options: + option_group: OptionGroup = option.product_option_group + selected_option: Option = 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 + + return result + + def export(self) -> pandas.DataFrame: + raise NotImplementedError(".export method is implemented in ListExportSerializer") diff --git a/app/shop/order/imports.py b/app/shop/order/imports.py new file mode 100644 index 0000000..09963c0 --- /dev/null +++ b/app/shop/order/imports.py @@ -0,0 +1,162 @@ +import dataclasses +import functools +import types +import typing +import uuid + +import pandas +from core.const.regex import ALLOW_ALL_PATTERN, PHONE_PATTERN +from django.db import transaction +from django.db.models import Prefetch +from rest_framework import serializers +from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.product.models import Option, OptionGroup, Product +from shop.serializers.cart_validation import OrderableCheckSerializerMode, ProductOrderableCheckSerializer +from user.models import UserExt + +OPTION_GROUP_PREFETCH = "_prefetched_all_option_groups" +CUSTOMER_INFO_FIELDS = ("name", "phone", "email", "organization") + + +class SerializerDataOptions(typing.TypedDict): + product_option_group: uuid.UUID | str + product_option: uuid.UUID | str | None + custom_response: str | None + + +@dataclasses.dataclass +class OptionInputData: + option_group: OptionGroup + option: Option | None + custom_response: str | None + + @functools.cached_property + def resp_mode(self) -> bool: + return self.option_group.is_custom_response or not self.option + + def to_dict(self) -> SerializerDataOptions: + return SerializerDataOptions( + product_option_group=self.option_group.id, + product_option=self.option.id if not self.resp_mode else None, + custom_response=self.custom_response if self.resp_mode else None, + ) + + +class OrderProductImportSerializer(serializers.ModelSerializer): + name = serializers.RegexField(ALLOW_ALL_PATTERN, required=True, allow_null=False, allow_blank=False) + phone = serializers.RegexField(PHONE_PATTERN, required=True, allow_null=False, allow_blank=False) + email = serializers.EmailField(required=True, allow_null=False, allow_blank=False) + organization = serializers.RegexField(ALLOW_ALL_PATTERN, required=True, allow_null=False, allow_blank=True) + + product_id = serializers.PrimaryKeyRelatedField(queryset=Product.objects.all(), source="id") + donation_price = serializers.IntegerField(required=True) + options = serializers.DictField(child=serializers.CharField(), required=True) + + class Meta: + model = OrderProductRelation + fields_without_options: list[str] = ["name", "phone", "email", "organization", "product_id", "donation_price"] + fields: list[str] = fields_without_options + ["options"] + + @classmethod + def get_template_csv(cls, product: Product) -> str: + serializer_fields: list[str] = cls.Meta.fields_without_options + option_fields: list[str] = list(group.name for group in product.option_groups.all()) + 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() + + @functools.cached_property + def product(self) -> Product | None: + prod_id = self.initial_data.get("product_id", "") + prefetch = Prefetch( + lookup="option_groups", + queryset=OptionGroup.objects.prefetch_related("options"), + to_attr=OPTION_GROUP_PREFETCH, + ) + return Product.objects.prefetch_related(prefetch).filter(id=prod_id).first() + + @functools.cached_property + def option_input_data(self) -> list[OptionInputData]: + if not self.product: + return [] + + input_options: dict[str, str] = {k: v for k, v in self.initial_data.items() if k not in self.Meta.fields} + result: list[OptionInputData] = [] + groups: list[OptionGroup] = list( + getattr(self.product, OPTION_GROUP_PREFETCH, None) or self.product.option_groups.all() + ) + for group in groups: + if not (value := input_options.get(group.name)): + continue + + if group.is_custom_response: + result.append(OptionInputData(option_group=group, option=None, custom_response=value)) + continue + + if not (option := Option.objects.filter(group=group, name=value).first()): + raise serializers.ValidationError(detail=f"Invalid option: '{group.name}' - {value}") + + result.append(OptionInputData(option_group=group, option=option, custom_response=None)) + + return result + + def to_internal_value(self, data: dict[str, str]) -> dict[str, typing.Any]: + serializer_fields: list[str] = self.Meta.fields_without_options + option_fields: list[str] = [name for name in data.keys() if name not in serializer_fields] + + options: dict[str, str] = {name: data[name] for name in option_fields} + 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") + + ProductOrderableCheckSerializer( + context={ + "mode": OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT, + "request": types.SimpleNamespace(user=self.user), + "is_free_product_allowed": True, + }, + data={ + "product": self.product.id, + "donation_price": data["donation_price"], + "options": [d.to_dict() for d in self.option_input_data], + }, + ).is_valid(raise_exception=True) + return data + + @transaction.atomic + def create(self, validated_data: dict[str, typing.Any]) -> OrderProductRelation: + additional_price: int = sum(od.option.additional_price for od in self.option_input_data if not od.resp_mode) + total_price: int = self.product.price + additional_price + + order_product: OrderProductRelation = OrderProductRelation.objects.create( + order=Order.objects.create(user=self.user, name=self.product.name), + product=self.product, + status=OrderProductRelation.OrderProductStatus.paid, + price=total_price, + donation_price=validated_data["donation_price"], + ) + CustomerInfo.objects.create( + order=order_product.order, **{field: validated_data[field] for field in CUSTOMER_INFO_FIELDS} + ) + + for data in self.option_input_data: + OrderProductOptionRelation.objects.create( + order_product_relation=order_product, + product_option_group=data.option_group, + product_option=data.option, + custom_response=data.custom_response, + ) + + PaymentHistory.objects.create( + order=order_product.order, + imp_id=None, + status=PaymentHistoryStatus.completed, + price=total_price, + ) + + return order_product diff --git a/app/shop/order/migrations/0001_initial.py b/app/shop/order/migrations/0001_initial.py new file mode 100644 index 0000000..29479a2 --- /dev/null +++ b/app/shop/order/migrations/0001_initial.py @@ -0,0 +1,753 @@ +# Generated by Django 6.0.4 on 2026-05-09 06:54 + +import uuid + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + dependencies = [("product", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name="HistoricalOrder", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.TextField()), + ("name_ko", models.TextField(null=True)), + ("name_en", models.TextField(null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical order", + "verbose_name_plural": "historical orders", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="Order", + 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.TextField()), + ("name_ko", models.TextField(null=True)), + ("name_en", models.TextField(null=True)), + ( + "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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + "ordering": ("-created_at",), + }, + ), + migrations.CreateModel( + name="HistoricalOrderProductRelation", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "결제 대기 중"), + ("paid", "결제 완료"), + ("used", "사용함"), + ("refunded", "환불함"), + ], + default="pending", + max_length=32, + ), + ), + ("price", models.PositiveIntegerField()), + ("donation_price", models.PositiveIntegerField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.product", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="order.order", + ), + ), + ], + options={ + "verbose_name": "historical order product relation", + "verbose_name_plural": "historical order product relations", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="OrderProductRelation", + 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)), + ( + "status", + models.CharField( + choices=[ + ("pending", "결제 대기 중"), + ("paid", "결제 완료"), + ("used", "사용함"), + ("refunded", "환불함"), + ], + default="pending", + max_length=32, + ), + ), + ("price", models.PositiveIntegerField()), + ("donation_price", models.PositiveIntegerField(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", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="products", + to="order.order", + ), + ), + ("product", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="product.product")), + ( + "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={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="HistoricalSingleProductCart", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order_product_relation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="order.orderproductrelation", + ), + ), + ], + options={ + "verbose_name": "historical single product cart", + "verbose_name_plural": "historical single product carts", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalOrderProductOptionRelation", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("custom_response", models.TextField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "product_option", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.option", + ), + ), + ( + "product_option_group", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.optiongroup", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order_product_relation", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="order.orderproductrelation", + ), + ), + ], + options={ + "verbose_name": "historical order product option relation", + "verbose_name_plural": "historical order product option relations", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="SingleProductCart", + 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)), + ( + "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_relation", + models.OneToOneField( + on_delete=django.db.models.deletion.PROTECT, + related_name="single_product_cart", + 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, + ), + ), + ("user", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + "abstract": False, + }, + ), + migrations.CreateModel( + name="HistoricalCustomerInfo", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.TextField()), + ("phone", models.TextField()), + ("email", models.TextField()), + ("organization", models.TextField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="order.order", + ), + ), + ( + "single_product_cart", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="order.singleproductcart", + ), + ), + ], + options={ + "verbose_name": "historical customer info", + "verbose_name_plural": "historical customer infos", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="OrderProductOptionRelation", + 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)), + ("custom_response", models.TextField(blank=True, null=True)), + ( + "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, + ), + ), + ( + "product_option", + models.ForeignKey( + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to="product.option" + ), + ), + ( + "product_option_group", + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="product.optiongroup"), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order_product_relation", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="options", + to="order.orderproductrelation", + ), + ), + ], + options={ + "indexes": [models.Index(fields=["custom_response"], name="order_order_custom__904569_idx")], + }, + ), + migrations.CreateModel( + name="CustomerInfo", + 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.TextField()), + ("phone", models.TextField()), + ("email", models.TextField()), + ("organization", models.TextField(blank=True, null=True)), + ( + "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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "order", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="customer_info", + to="order.order", + ), + ), + ( + "single_product_cart", + models.OneToOneField( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="customer_info", + to="order.singleproductcart", + ), + ), + ], + options={ + "indexes": [ + models.Index(fields=["name"], name="order_custo_name_8b311e_idx"), + models.Index(fields=["phone"], name="order_custo_phone_3e3e52_idx"), + models.Index(fields=["email"], name="order_custo_email_db8542_idx"), + models.Index(fields=["organization"], name="order_custo_organiz_a51754_idx"), + ], + }, + ), + ] diff --git a/app/shop/order/migrations/__init__.py b/app/shop/order/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/order/models.py b/app/shop/order/models.py new file mode 100644 index 0000000..6751884 --- /dev/null +++ b/app/shop/order/models.py @@ -0,0 +1,349 @@ +from __future__ import annotations + +import datetime +import functools +import typing + +from core.const.shop_error_messages import NotRefundableErrorMessages +from core.models import BaseAbstractModel, BaseAbstractModelQuerySet +from core.scancode_mixin import ScanCodeMixin +from core.util.dateutil import now_aware +from django.contrib.auth import get_user_model +from django.db import models +from django.db.models.manager import BaseManager +from shop.payment_history.models import PURCHASED_STATUSES, PaymentHistory +from simple_history.models import HistoricalRecords + +UserModel = get_user_model() + + +class OrderQuerySet(BaseAbstractModelQuerySet): + def filter_has_payment_histories(self) -> models.QuerySet[Order]: + return self.filter_active().filter(models.Exists(PaymentHistory.objects.filter(order=models.OuterRef("id")))) + + def filter_has_no_payment_histories(self) -> models.QuerySet[Order]: + return self.filter_active().filter(~models.Exists(PaymentHistory.objects.filter(order=models.OuterRef("id")))) + + def filter_purchased_by(self, user: UserModel) -> models.QuerySet[Order]: + """결제 완료/부분환불/환불된 (terminal status) 주문을 user 별로 필터.""" + return ( + self.filter_active() + .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().order_by("-created_at"), + to_attr="_payment_histories_by_latest", + ), + ) + .annotate( + current_status=( + PaymentHistory.objects.filter(order_id=models.OuterRef("id"), status__in=PURCHASED_STATUSES) + .order_by("-created_at") + .values_list("status", flat=True)[:1] + ), + ) + .filter(user=user, current_status__in=PURCHASED_STATUSES) + .order_by("-created_at") + ) + + def filter_in_last_six_months(self) -> models.QuerySet[Order]: + return self.filter(created_at__gte=datetime.date.today() - datetime.timedelta(days=183)) + + +class Order(ScanCodeMixin, BaseAbstractModel): + scancode_prefix = "order" + + user = models.ForeignKey(UserModel, on_delete=models.PROTECT) + name = models.TextField() + + payment_histories: BaseManager[PaymentHistory] + products: BaseManager[OrderProductRelation] + + objects: OrderQuerySet = OrderQuerySet.as_manager() # type: ignore[assignment, misc] + prefetchs = { + "_payment_histories_by_latest": models.Prefetch( + "payment_histories", + queryset=PaymentHistory.objects.filter_active().order_by("-created_at"), + to_attr="_payment_histories_by_latest", + ), + } + + class Meta: + ordering = ("-created_at",) + + def __str__(self) -> str: + cart_or_order = "CART" if self.is_cart else "ORDER" + created_at = self.created_at.isoformat() + return f"{self.user}의 {cart_or_order} <{self.current_status}> [{created_at}]" + + @functools.cached_property + def first_paid_price(self) -> int: + return sum(product.price + product.donation_price for product in self.products.all()) + + @functools.cached_property + def first_payment_history(self) -> PaymentHistory | None: + if hasattr(self, "_payment_histories_by_latest") and self._payment_histories_by_latest: + return self._payment_histories_by_latest[-1] + + if not (payment_histories := self.payment_histories.all()): + return None + + return min(payment_histories, key=lambda payment_history: payment_history.created_at) + + @functools.cached_property + def first_paid_at(self) -> datetime.datetime | None: + return self.first_payment_history.created_at if self.first_payment_history else None + + @functools.cached_property + def current_payment_history(self) -> PaymentHistory | None: + if hasattr(self, "_payment_histories_by_latest") and self._payment_histories_by_latest: + return self._payment_histories_by_latest[0] + + if not (payment_histories := self.payment_histories.all()): + return None + + return max(payment_histories, key=lambda payment_history: payment_history.created_at) + + @functools.cached_property + def current_paid_price(self) -> int: + return self.current_payment_history.price if self.current_payment_history else 0 + + @functools.cached_property + def current_status(self) -> str: + from shop.payment_history.models import PaymentHistoryStatus + + return self.current_payment_history.status if self.current_payment_history else PaymentHistoryStatus.pending + + @functools.cached_property + def latest_imp_id(self) -> str | None: + return self.current_payment_history.imp_id if self.current_payment_history else None + + @functools.cached_property + def is_cart(self) -> bool: + from shop.payment_history.models import PaymentHistoryStatus + + return self.current_status == PaymentHistoryStatus.pending + + @property + def not_fully_refundable_reason(self) -> str | None: + """ + 주문 전체의 환불이 불가능한 사유를 반환합니다. + 만약 환불이 가능하다면 None을 반환합니다. + 환불이 불가능한 경우는 다음과 같습니다. + - 주문에 PortOne ID가 없는 경우 (보통 결제가 완료되지 않았거나 주문 불러오기로 생성한 주문인 경우입니다.) + - 이미 사용한 상품이 있는 경우 + - 환불할 상품이 없는 경우 + - 환불할 금액이 없는 경우 + - 환불할 금액이 음수인 경우 + - 환불할 금액이 남은 결제 금액과 일치하지 않는 경우 + - 환불 가능한 일자를 지난 상품이 있는 경우 + """ + from shop.payment_history.models import REFUNDABLE_STATUSES + from shop.product.models import Product + + NOT_REFUNDABLE_PRODUCT_RELATION_STATUSES = { + OrderProductRelation.OrderProductStatus.pending, + OrderProductRelation.OrderProductStatus.used, + } + + if not self.latest_imp_id: + return NotRefundableErrorMessages.ORDER_IMP_ID_NOT_EXIST + if self.current_status not in REFUNDABLE_STATUSES: + return NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE_STATUS + + product_relations = list[OrderProductRelation](self.products.all()) + if any(rel.status in NOT_REFUNDABLE_PRODUCT_RELATION_STATUSES for rel in product_relations): + return NotRefundableErrorMessages.ONE_OF_PRODUCT_IS_USED_TRY_AFTER_CHANGING_STATUS + + refund_target_product_relations = [ + rel for rel in product_relations if rel.status == OrderProductRelation.OrderProductStatus.paid + ] + if not refund_target_product_relations: + return NotRefundableErrorMessages.ORDER_REFUNDABLE_PRODUCT_NOT_FOUND + + expected_refund_price = sum(rel.price + rel.donation_price for rel in refund_target_product_relations) + if expected_refund_price == 0: + return NotRefundableErrorMessages.ORDER_REFUNDABLE_PRICE_NOT_FOUND + if expected_refund_price < 0: + return NotRefundableErrorMessages.ORDER_REFUND_TARGET_PRICE_IS_NEGATIVE + if self.current_paid_price != expected_refund_price: + return NotRefundableErrorMessages.ORDER_REFUND_TARGET_PRICE_IS_MISMATCH + + now = now_aware() + if any(typing.cast(Product, rel.product).refundable_ends_at < now for rel in refund_target_product_relations): + return NotRefundableErrorMessages.ONE_OF_PRODUCT_REFUND_TIME_EXPIRED + + return None + + +class OrderProductRelation(ScanCodeMixin, BaseAbstractModel): + scancode_prefix = "opr" + + class OrderProductStatus(models.TextChoices): + pending = "pending", "결제 대기 중" + paid = "paid", "결제 완료" + used = "used", "사용함" + refunded = "refunded", "환불함" + + PURCHASED_STOCK_STATUS = {OrderProductStatus.paid, OrderProductStatus.used} + + order = models.ForeignKey(Order, on_delete=models.PROTECT, related_name="products", null=True, blank=True) + product = models.ForeignKey("product.Product", on_delete=models.PROTECT) + + status = models.CharField(max_length=32, choices=OrderProductStatus.choices, default=OrderProductStatus.pending) + price = models.PositiveIntegerField() + donation_price = models.PositiveIntegerField(default=0) + + single_product_cart: SingleProductCart | None + options: BaseManager[OrderProductOptionRelation] + + history = HistoricalRecords() + + def __str__(self) -> str: + return f"[{self.order}] {self.product} ({self.get_status_display()})" + + @property + def not_refundable_reason(self) -> str | None: + """ + 상품 환불이 불가능한 사유를 반환합니다. + 만약 환불이 가능하다면 None을 반환합니다. + 환불이 불가능한 경우는 다음과 같습니다. + - 주문에 PortOne ID가 없는 경우 (보통 결제가 완료되지 않았거나 주문 불러오기로 생성한 주문인 경우입니다.) + - 이미 사용했거나 결제 전, 또는 환불된 상품인 경우 + - 환불 가능한 일자를 지난 상품이 있는 경우 + - 환불 금액이 없는 경우 + """ + from shop.payment_history.models import REFUNDABLE_STATUSES + from shop.product.models import Product + + order = typing.cast(Order | None, self.order) + if not (order and order.latest_imp_id): + return NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE + if order.current_status not in REFUNDABLE_STATUSES: + return NotRefundableErrorMessages.ORDER_NOT_REFUNDABLE_STATUS + if self.status != OrderProductRelation.OrderProductStatus.paid: + return NotRefundableErrorMessages.PRODUCT_STATUS_IS_NOT_PAID + + if typing.cast(Product, self.product).refundable_ends_at < now_aware(): + return NotRefundableErrorMessages.PRODUCT_REFUND_TIME_EXPIRED + + if (self.price + self.donation_price) == 0: + return NotRefundableErrorMessages.PRODUCT_PRICE_IS_ZERO + + return None + + +class OrderProductOptionRelation(BaseAbstractModel): + order_product_relation = models.ForeignKey(OrderProductRelation, on_delete=models.CASCADE, related_name="options") + product_option_group = models.ForeignKey("product.OptionGroup", on_delete=models.PROTECT) + product_option = models.ForeignKey("product.Option", on_delete=models.PROTECT, null=True, blank=True) + custom_response = models.TextField(null=True, blank=True) + + history = HistoricalRecords() + + class Meta: + indexes = [models.Index(fields=["custom_response"])] + + def __str__(self) -> str: + name = self.product_option.name if self.product_option else self.custom_response + return f"{self.product_option_group.name} - {name}" + + +class SingleProductCart(BaseAbstractModel): + user = models.ForeignKey(UserModel, on_delete=models.PROTECT) + order_product_relation = models.OneToOneField( + OrderProductRelation, + on_delete=models.PROTECT, + related_name="single_product_cart", + ) + + history = HistoricalRecords() + + def to_order(self) -> Order: + order = Order.objects.create( + id=self.id, + user=self.user, + name=self.order_product_relation.product.name, + name_ko=self.order_product_relation.product.name_ko, + name_en=self.order_product_relation.product.name_en, + ) + self.order_product_relation.order = order + self.order_product_relation.save() + + CustomerInfo.objects.filter(single_product_cart=self).update(order=order, single_product_cart=None) + + # cart 가 order 로 전환되면 cart 자체는 사라져야 하므로 hard delete (의도적). + SingleProductCart.objects.filter(id=self.id).hard_delete() + + return order + + @functools.cached_property + def first_paid_price(self) -> int: + return self.order_product_relation.price + self.order_product_relation.donation_price + + @functools.cached_property + def current_payment_history(self) -> None: + return None + + @functools.cached_property + def current_paid_price(self) -> typing.Literal[0]: + return 0 + + @functools.cached_property + def current_status(self) -> str: + from shop.payment_history.models import PaymentHistoryStatus + + return PaymentHistoryStatus.pending + + @functools.cached_property + def is_cart(self) -> typing.Literal[True]: + return True + + @functools.cached_property + def payment_histories(self) -> list[PaymentHistory]: + return [] + + @functools.cached_property + def products(self) -> list[OrderProductRelation]: + return [self.order_product_relation] + + @functools.cached_property + def name(self) -> str: + return self.order_product_relation.product.name + + +class CustomerInfo(BaseAbstractModel): + order = models.OneToOneField(Order, on_delete=models.PROTECT, related_name="customer_info", null=True) + single_product_cart = models.OneToOneField( + SingleProductCart, on_delete=models.PROTECT, related_name="customer_info", null=True + ) + + name = models.TextField() + phone = models.TextField() + email = models.TextField() + organization = models.TextField(null=True, blank=True) + + history = HistoricalRecords() + + class Meta: + indexes = [ + models.Index(fields=["name"]), + models.Index(fields=["phone"]), + models.Index(fields=["email"]), + models.Index(fields=["organization"]), + ] diff --git a/app/shop/order/serializers/__init__.py b/app/shop/order/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/order/serializers/dto.py b/app/shop/order/serializers/dto.py new file mode 100644 index 0000000..8063fc9 --- /dev/null +++ b/app/shop/order/serializers/dto.py @@ -0,0 +1,128 @@ +from urllib.parse import urljoin + +from django.conf import settings +from rest_framework import serializers +from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.product.models import Option, OptionGroup, Product + + +class PaymentHistoryDto(serializers.ModelSerializer): + class Meta: + fields = ("status", "price") + model = PaymentHistory + + +class SimpleProductDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "name", "price", "image") + model = Product + + +class SimpleOptionDto(serializers.ModelSerializer): + class Meta: + fields = ("id", "name", "additional_price") + model = Option + + +class SimpleOptionGroupDto(serializers.ModelSerializer): + class Meta: + fields = ( + "id", + "name", + "is_custom_response", + "custom_response_pattern", + "response_modifiable_ends_at", + ) + model = OptionGroup + + +class OrderProductOptionRelationDto(serializers.ModelSerializer): + product_option_group = SimpleOptionGroupDto() + product_option = SimpleOptionDto(allow_null=True) + + class Meta: + fields = ("id", "product_option", "product_option_group", "custom_response") + model = OrderProductOptionRelation + + +class OrderProductRelationDto(serializers.ModelSerializer): + product = SimpleProductDto() + options = OrderProductOptionRelationDto(many=True) + scancode_url = serializers.SerializerMethodField() + + class Meta: + fields = ( + "id", + "product", + "options", + "status", + "price", + "donation_price", + "not_refundable_reason", + "scancode_url", + ) + model = OrderProductRelation + + def get_scancode_url(self, obj: OrderProductRelation) -> str | None: + if "티켓" not in obj.product.category.name: + return None + + return urljoin(settings.BACKEND_DOMAIN, obj.scancode_path) + + +class CustomerInfoDto(serializers.ModelSerializer): + class Meta: + fields = ("name", "phone", "email", "organization") + model = CustomerInfo + + +class OrderDto(serializers.ModelSerializer): + payment_histories = PaymentHistoryDto(many=True) + products = OrderProductRelationDto(many=True) + current_status = serializers.ChoiceField(choices=PaymentHistoryStatus.choices) + scancode_url = serializers.SerializerMethodField() + + customer_info = CustomerInfoDto(allow_null=True) + + class Meta: + fields = ( + "id", + "name", + "payment_histories", + "products", + "scancode_url", + "first_paid_price", + "first_paid_at", + "current_paid_price", + "current_status", + "created_at", + "not_fully_refundable_reason", + "customer_info", + ) + model = Order + + def get_scancode_url(self, obj: Order) -> str: + return urljoin(settings.BACKEND_DOMAIN, obj.scancode_path) + + +class SingleProductCartDto(serializers.ModelSerializer): + payment_histories = PaymentHistoryDto(many=True) + products = OrderProductRelationDto(many=True) + current_status = serializers.ChoiceField(choices=PaymentHistoryStatus.choices) + + customer_info = CustomerInfoDto(allow_null=True) + + class Meta: + fields = ( + "id", + "name", + "payment_histories", + "products", + "first_paid_price", + "current_paid_price", + "current_status", + "created_at", + "customer_info", + ) + model = SingleProductCart diff --git a/app/shop/order/serializers/scancode.py b/app/shop/order/serializers/scancode.py new file mode 100644 index 0000000..e482942 --- /dev/null +++ b/app/shop/order/serializers/scancode.py @@ -0,0 +1,87 @@ +from rest_framework import serializers +from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation +from shop.product.models import Product +from user.models import UserExt + + +class _ProductSerializer(serializers.ModelSerializer): + class Meta: + model = Product + fields = ("id", "name") + + +class _OrderProductOptionRelationSerializer(serializers.ModelSerializer): + """OrderProductOptionRelation → `{name, value}`. is_custom_response 분기에 따라 value 결정.""" + + name = serializers.CharField(source="product_option_group.name") + value = serializers.SerializerMethodField() + + class Meta: + model = OrderProductOptionRelation + fields = ("name", "value") + + def get_value(self, obj: OrderProductOptionRelation) -> str: + if obj.product_option_group.is_custom_response: + return obj.custom_response or "-" + if option := obj.product_option: + return option.name + (f" (+{option.additional_price}원)" if option.additional_price > 0 else "") + return "-" + + +class _OrderProductRelationSerializer(serializers.ModelSerializer): + """OrderProductRelation 공통 nested — product + options + 금액/상태.""" + + product = _ProductSerializer() + options = _OrderProductOptionRelationSerializer(many=True) + + class Meta: + model = OrderProductRelation + fields = ("product", "options", "price", "donation_price", "status") + + +class OrderProductScanCodeSerializer(_OrderProductRelationSerializer): + """단일 OrderProductRelation (티켓) 의 QR 페이지용 응답 — base + id/short_id/order context.""" + + class _OrderSerializer(serializers.ModelSerializer): + class Meta: + fields = ("id", "first_paid_at") + model = Order + + order = _OrderSerializer() + + class Meta(_OrderProductRelationSerializer.Meta): + fields = ("id", "short_id", "order", *_OrderProductRelationSerializer.Meta.fields) + + +class OrderScanCodeSerializer(serializers.ModelSerializer): + """주문 QR 페이지 — 단일 주문 (order scancode page) + User scancode 의 order list 양쪽에서 사용.""" + + class _CustomerInfoSerializer(serializers.ModelSerializer): + class Meta: + model = CustomerInfo + fields = ("name", "phone", "email", "organization") + + customer_info = _CustomerInfoSerializer() + items = _OrderProductRelationSerializer(many=True, source="products") + + class Meta: + model = Order + fields = ( + "id", + "short_id", + "name", + "created_at", + "first_paid_at", + "current_status", + "current_paid_price", + "customer_info", + "items", + ) + + +class UserScanCodeSerializer(serializers.ModelSerializer): + """User 의 QR 페이지용 응답 — 식별 정보만. 주문 목록은 별도로 OrderScanCodeRowSerializer 사용.""" + + class Meta: + model = UserExt + fields = ("unique_id", "short_id") diff --git a/app/shop/order/serializers/validator.py b/app/shop/order/serializers/validator.py new file mode 100644 index 0000000..872777e --- /dev/null +++ b/app/shop/order/serializers/validator.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import re +import typing + +from core.const.shop_error_messages import OptionGroupNotModifiableErrorMessages +from core.util.dateutil import now_aware +from rest_framework import serializers +from shop.order.models import OrderProductOptionRelation, OrderProductRelation +from shop.product.models import OptionGroup + + +class OptionProductOptionCustomResponseModifyRequestSerializer(serializers.Serializer): + order_product_option_relation = serializers.PrimaryKeyRelatedField( + queryset=OrderProductOptionRelation.objects.filter_active().filter( + order_product_relation__status=OrderProductRelation.OrderProductStatus.paid, + product_option_group__is_custom_response=True, + ), + required=True, + allow_null=False, + ) + custom_response = serializers.CharField(required=True) + + def validate(self, data: dict[str, str]) -> dict[str, str]: + data = super().validate(data) + order_product_rel = typing.cast(OrderProductRelation, self.context["order_product_rel"]) + order_product_option_relation = typing.cast(OrderProductOptionRelation, data["order_product_option_relation"]) + product_option_group: OptionGroup = order_product_option_relation.product_option_group + custom_response = data["custom_response"] + + if order_product_option_relation.order_product_relation != order_product_rel: + raise serializers.ValidationError( + OptionGroupNotModifiableErrorMessages.ORDER_PRODUCT_OPTION_RELATION_MISMATCH + ) + + if not product_option_group.response_modifiable_ends_at: + raise serializers.ValidationError(OptionGroupNotModifiableErrorMessages.RESPONSE_NOT_MODIFIABLE) + + if product_option_group.response_modifiable_ends_at < now_aware(): + raise serializers.ValidationError(OptionGroupNotModifiableErrorMessages.RESPONSE_MODIFIABLE_ENDS_AT) + + if product_option_group.custom_response_pattern and not re.match( + product_option_group.custom_response_pattern, custom_response + ): + raise serializers.ValidationError(OptionGroupNotModifiableErrorMessages.CUSTOM_RESPONSE_PATTERN_MISMATCH) + + return data + + def save(self) -> OrderProductOptionRelation: # type: ignore[override] + data = self.validated_data + order_product_option_relation: OrderProductOptionRelation = data["order_product_option_relation"] + order_product_option_relation.custom_response = data["custom_response"] + order_product_option_relation.save() + return order_product_option_relation diff --git a/app/shop/order/templates/receipt_kcp.html b/app/shop/order/templates/receipt_kcp.html new file mode 100644 index 0000000..71dd7ed --- /dev/null +++ b/app/shop/order/templates/receipt_kcp.html @@ -0,0 +1,20 @@ + + + + + + NHN KCP + + + + +
+ + + +
+ + + diff --git a/app/shop/order/templates/scancode_base.html b/app/shop/order/templates/scancode_base.html new file mode 100644 index 0000000..dfd4193 --- /dev/null +++ b/app/shop/order/templates/scancode_base.html @@ -0,0 +1,260 @@ + + + + + + + + + + + PyCon Korea + + + + + + + +
+ +
+ 안내 +
    +
  • QR 코드가 타인에게 노출되지 않도록 주의해주세요!
  • +
  • 티셔츠, 티켓을 포함한 주문 상품의 양도는 불가능하며, 양도로 인한 문제 발생은 구매자에게 귀책사유가 있습니다.
  • +
  • 자세한 주문 정보는 파이콘 한국의 구매 내역에서 조회할 수 있습니다.
  • +
+
+
+ + + diff --git a/app/shop/order/templates/scancode_error.html b/app/shop/order/templates/scancode_error.html new file mode 100644 index 0000000..bcc92f9 --- /dev/null +++ b/app/shop/order/templates/scancode_error.html @@ -0,0 +1,6 @@ +{% extends 'scancode_base.html' %} +{% block content %} +

+ {{ error_msg }} +

+{% endblock %} diff --git a/app/shop/order/templates/scancode_view_opr.html b/app/shop/order/templates/scancode_view_opr.html new file mode 100644 index 0000000..3aa1542 --- /dev/null +++ b/app/shop/order/templates/scancode_view_opr.html @@ -0,0 +1,47 @@ +{% extends 'scancode_base.html' %} +{% block content %} +

+ 티켓 조회 +

+
+
+ {{ order_product.id }} +
+행사 당일 등록 데스크에 위 QR코드를 제시해주세요. +
+
상품 정보
+ + + + + + + + + + + + +
상품명{{ order_product.product.name }}
주문 일시{{ order_product.order.first_paid_at }}
+ + + {% for option in order_product.options %} + + + + + {% endfor %} + +
{{ option.name }}{{ option.value }}
+
+ +{% endblock %} diff --git a/app/shop/order/templates/scancode_view_order.html b/app/shop/order/templates/scancode_view_order.html new file mode 100644 index 0000000..58fc305 --- /dev/null +++ b/app/shop/order/templates/scancode_view_order.html @@ -0,0 +1,60 @@ +{% extends 'scancode_base.html' %} +{% block content %} +

+ 주문 조회 +

+
+
+ {{ order.id }} +
+행사 당일 등록 데스크에 위 QR코드를 제시해주세요. +
+
주문 정보
+ + + + + + + + + + + + + +
주문 일시{{ order.created_at }}
주문 상태{{ order.current_status }}
결제 금액{{ order.current_paid_price }}원
+
+
상품 정보
+ + {% for item in order.items %} + + + + {% endfor %} +
+ {{ item.product.name }} + + + + + + {% for option in item.options %} + + + + + {% endfor %} +
옵션선택
{{ option.name }}{{ option.value }}
+
+ +{% endblock %} diff --git a/app/shop/order/templates/scancode_view_user.html b/app/shop/order/templates/scancode_view_user.html new file mode 100644 index 0000000..22666b2 --- /dev/null +++ b/app/shop/order/templates/scancode_view_user.html @@ -0,0 +1,110 @@ +{% extends 'scancode_base.html' %} +{% block content %} +

+ 등록 입장 QR 코드 +

+
+
+
+ {{ user.unique_id }} +
+ 행사 당일 등록 데스크에 위 QR코드를 제시해주세요. +
+
고객 정보 (가장 최근 주문 기준)
+ + + + + + + + + +
성함{{ orders.0.customer_info.name }}
E-Mail{{ orders.0.customer_info.email }}
+
+
6개월 내 주문 정보
+
+ {% for order in orders %} +
+ {{ order.name }} +
+ + + + + + + + + + + + + + + +
주문 ID{{ order.id }}
주문일{{ order.first_paid_at|date:"Y-m-d H:i:s" }}
주문 상태{{ order.current_status }}
+
+
+ 고객 정보 + + + + + + {% if order.customer_info.organization %} + + {% endif %} + +
성함{{ order.customer_info.name }}
E-Mail{{ order.customer_info.email }}
연락처{{ order.customer_info.phone }}
소속{{ order.customer_info.organization }}
+
+
+ 주문 상품 정보 + + + {% for item in order.items %} + + + + + {% if item.status != "refunded" %} + {% if item.options %} + {% for option in item.options %} + + + + {% endfor %} + {% endif %} + {% endif %} + {% endfor %} + +
+ {{ item.product.name }} + {% if item.status == "refunded" %} (환불됨){% endif %} + + {{ item.price }}원 + {% if item.donation_price %}(+ {{ item.donation_price }}원){% endif %} +
+  └─  + {{ option.name }} +  :  + {{ option.value }} +
+
+
+ +
+ {% endfor %} +
+
+ +{% endblock %} diff --git a/app/shop/order/translation.py b/app/shop/order/translation.py new file mode 100644 index 0000000..dc27556 --- /dev/null +++ b/app/shop/order/translation.py @@ -0,0 +1,11 @@ +from modeltranslation.translator import TranslationOptions, register +from shop.order.models import Order +from simple_history import register as history_register + + +@register(Order) +class OrderTranslationOptions(TranslationOptions): + fields = ("name",) + + +history_register(Order) diff --git a/app/shop/order/urls.py b/app/shop/order/urls.py new file mode 100644 index 0000000..2403cc0 --- /dev/null +++ b/app/shop/order/urls.py @@ -0,0 +1,13 @@ +from core.const.regex import UUID_V4_PATTERN +from django.urls import include, path +from rest_framework import routers +from shop.order import views + +router = routers.SimpleRouter() +router.register("cart", views.CartViewSet, basename="cart") +router.register("cart/products", views.CartProductViewSet, basename="cart-products") +router.register("", views.OrderViewSet, basename="orders") +router.register(f"(?P{UUID_V4_PATTERN})/products", views.OrderProductViewSet, basename="order-products") +router.register("scancode", views.ScanCodeViewSet, basename="scancode") + +urlpatterns = [path("", include(router.urls))] diff --git a/app/shop/order/views/__init__.py b/app/shop/order/views/__init__.py new file mode 100644 index 0000000..18b68ae --- /dev/null +++ b/app/shop/order/views/__init__.py @@ -0,0 +1,11 @@ +from .carts import CartProductViewSet, CartViewSet +from .orders import OrderProductViewSet, OrderViewSet +from .scancode import ScanCodeViewSet + +__all__ = [ + "CartProductViewSet", + "CartViewSet", + "OrderProductViewSet", + "OrderViewSet", + "ScanCodeViewSet", +] diff --git a/app/shop/order/views/carts.py b/app/shop/order/views/carts.py new file mode 100644 index 0000000..13f9f58 --- /dev/null +++ b/app/shop/order/views/carts.py @@ -0,0 +1,87 @@ +import typing + +from core.const.tag import OpenAPITag +from django.db.models import Exists, OuterRef, QuerySet +from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ErrorResponse403Serializer, ValidationErrorResponseSerializer +from rest_framework import mixins, request, response, status, viewsets +from shop.order.models import Order, OrderProductRelation +from shop.order.serializers.dto import OrderDto +from shop.payment_history.models import PaymentHistory +from shop.serializers.cart_validation import ProductOrderableCheckSerializer +from user.models import UserExt + + +@extend_schema_view( + list=extend_schema( + summary="장바구니 정보 조회", + tags=[OpenAPITag.SHOP_CART], + responses={ + status.HTTP_200_OK: OrderDto, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ), +) +class CartViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + serializer_class = OrderDto + + def get_queryset(self) -> QuerySet[Order]: + if not isinstance(self.request.user, UserExt): + return Order.objects.none() + + return Order.objects.filter_has_no_payment_histories().filter(user=self.request.user).distinct() + + def list( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + # 사용자 당 장바구니는 하나만 존재하므로, 장바구니가 여러 개일 경우 가장 최근에 생성된 장바구니를 가져옵니다. + if cart := self.get_queryset().first(): + return response.Response(data=self.get_serializer(cart).data) + return response.Response(data={}) + + +@extend_schema_view( + create=extend_schema( + summary="장바구니에 상품 추가", + tags=[OpenAPITag.SHOP_CART], + responses={ + status.HTTP_201_CREATED: ProductOrderableCheckSerializer(many=True), + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ), + destroy=extend_schema( + summary="장바구니에서 상품 제거", + tags=[OpenAPITag.SHOP_CART], + parameters=[ + OpenApiParameter( + name="order_product_rel_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ), +) +class CartProductViewSet(mixins.CreateModelMixin, mixins.DestroyModelMixin, viewsets.GenericViewSet): + lookup_url_kwarg = "order_product_rel_id" + serializer_class = ProductOrderableCheckSerializer + + def get_queryset(self) -> QuerySet[Order]: + if not isinstance(self.request.user, UserExt): + return OrderProductRelation.objects.none() + + return OrderProductRelation.objects.filter( + ~Exists(PaymentHistory.objects.filter(order=OuterRef("order"))), + order__deleted_at__isnull=True, + order__user=self.request.user, + # 숨겨진 상품(추가 후원, 배송비 등)은 별도의 API를 통해서만 추가/삭제가 가능합니다. + product__hidden=False, + single_product_cart__isnull=True, + status=OrderProductRelation.OrderProductStatus.pending, + ).all() diff --git a/app/shop/order/views/orders.py b/app/shop/order/views/orders.py new file mode 100644 index 0000000..2627b59 --- /dev/null +++ b/app/shop/order/views/orders.py @@ -0,0 +1,345 @@ +import typing + +from core.const.regex import UUID_V4_PATTERN +from core.const.shop_error_messages import CartNotOrderableErrorMessages +from core.const.tag import OpenAPITag +from core.external_apis.portone.client import PortOneException, portone_client +from core.openapi.schemas import build_html_responses +from django.conf import settings +from django.db import models, transaction +from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ErrorResponse403Serializer, ValidationErrorResponseSerializer +from rest_framework import mixins, renderers, request, response, serializers, status, viewsets +from rest_framework.decorators import action +from shop.order.models import CustomerInfo, Order, OrderProductOptionRelation, OrderProductRelation, OrderQuerySet +from shop.order.serializers.dto import OrderDto, SingleProductCartDto +from shop.order.serializers.validator import OptionProductOptionCustomResponseModifyRequestSerializer +from shop.payment_history.models import PaymentHistory +from shop.serializers.cart_validation import ( + CartOrderableCheckSerializer, + CustomerInfoCheckSerializer, + OrderableCheckSerializerMode, + ProductOrderableCheckSerializer, + SingleProductCartOrderableCheckSerializer, +) +from shop.serializers.refund import OrderProductRefundSerializer, OrderTotalRefundSerializer +from user.models import UserExt + + +@extend_schema_view( + list=extend_schema( + summary="주문 이력 목록 조회", + tags=[OpenAPITag.SHOP_ORDER], + responses={ + status.HTTP_200_OK: OrderDto(many=True), + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ), + retrieve=extend_schema( + summary="주문 이력 상세 조회", + tags=[OpenAPITag.SHOP_ORDER], + parameters=[ + OpenApiParameter( + name="order_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + status.HTTP_200_OK: OrderDto, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ), +) +class OrderViewSet( + mixins.ListModelMixin, + mixins.RetrieveModelMixin, + mixins.CreateModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + lookup_url_kwarg = "order_id" + lookup_value_regex = UUID_V4_PATTERN + serializer_class = OrderDto + queryset = Order.objects.select_related("customer_info") + + def get_queryset(self) -> models.QuerySet[Order]: + base_qs = typing.cast(OrderQuerySet, self.queryset) + + if not isinstance(self.request.user, UserExt): + return Order.objects.none() + + if self.action == "create": + # Cart -> Order로 전환 시에는 payment_histories가 없는 Order만 가져와야 합니다. + return base_qs.filter_has_no_payment_histories().filter(user=self.request.user).distinct() + if self.action == "retrieve_receipt": + user_filter = {} if self.request.user.is_staff else {"user": self.request.user} + return base_qs.filter_has_payment_histories().filter(**user_filter).distinct() + return ( + base_qs.prefetch_related( + models.Prefetch("payment_histories"), + models.Prefetch( + "products", + queryset=( + OrderProductRelation.objects.select_related("product").prefetch_related( + models.Prefetch( + "options", + queryset=OrderProductOptionRelation.objects.select_related( + "product_option_group", + "product_option", + ), + ), + ) + ), + ), + ) + .filter_has_payment_histories() + .filter(user=self.request.user) + .distinct() + ) + + @extend_schema( + summary="단건 주문 프로세스 시작", + tags=[OpenAPITag.SHOP_ORDER], + request=SingleProductCartOrderableCheckSerializer, + responses={ + status.HTTP_201_CREATED: SingleProductCartDto, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ) + @action(detail=False, methods=["POST"], url_path="single") + def create_single_product_order( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + """단일 상품 주문을 생성합니다.""" + context = self.get_serializer_context() | { + "mode": OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT, + "is_free_product_allowed": False, + } + serializer = SingleProductCartOrderableCheckSerializer(data=request.data, context=context) + serializer.is_valid(raise_exception=True) + order_product_rel: OrderProductRelation = serializer.save() + + assert order_product_rel.single_product_cart # nosec: B101 + cart = order_product_rel.single_product_cart + + portone_client.register_or_update_prepared_payment(merchant_id=str(cart.id), price=cart.first_paid_price) + + return response.Response(data=SingleProductCartDto(instance=cart).data, status=status.HTTP_201_CREATED) + + @extend_schema( + summary="주문 프로세스 시작", + tags=[OpenAPITag.SHOP_ORDER], + request=CustomerInfoCheckSerializer, + responses={ + status.HTTP_201_CREATED: OrderDto, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ) + @transaction.atomic + def create( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + """아직 결제하지 않은 Order(Cart)를 가져온 후, PortOne에 결제 금액 사전 등록을 하고, Order 데이터를 응답합니다.""" + if not ((cart := self.get_queryset().first()) and cart.products.exists()): + raise serializers.ValidationError(CartNotOrderableErrorMessages.EMPTY) + + customer_info = CustomerInfo.objects.filter(order=cart).first() + customer_info_serializer = CustomerInfoCheckSerializer(instance=customer_info, data=request.data) + customer_info_serializer.is_valid(raise_exception=True) + customer_info_serializer.save(order=cart) + + context = self.get_serializer_context() | { + "mode": OrderableCheckSerializerMode.CHECKOUT_CART, + "is_free_product_allowed": False, + } + cart_product_rels = sorted(cart.products.all(), key=lambda x: x.price, reverse=True) + ProductOrderableCheckSerializer( + data=[ + { + "product": product_rel.product_id, + "options": [ + { + "product_option_group": product_option_rel.product_option_group_id, + "product_option": product_option_rel.product_option_id, + "custom_response": product_option_rel.custom_response, + } + for product_option_rel in product_rel.options.all() + ], + } + for product_rel in cart_product_rels + ], + context=context, + many=True, + ).is_valid(raise_exception=True) + CartOrderableCheckSerializer(data={"cart": cart.id}, context=context).is_valid(raise_exception=True) + + cart.name = cart_product_rels[0].product.name + cart.name_ko = cart_product_rels[0].product.name_ko + cart.name_en = cart_product_rels[0].product.name_en + if len(cart_product_rels) > 1: + cart.name += f" 외 {len(cart_product_rels) - 1}개" + cart.name_ko += f" 외 {len(cart_product_rels) - 1}개" + cart.name_en += f" and {len(cart_product_rels) - 1} more" + cart.save() + + # idempotent + forward-only — DB commit 후 비동기 호출. 실패 시 클라이언트 retry 가 자연 보상. + transaction.on_commit( + lambda: portone_client.register_or_update_prepared_payment( + merchant_id=str(cart.id), price=cart.first_paid_price + ) + ) + + return response.Response(data=OrderDto(instance=cart).data, status=status.HTTP_201_CREATED) + + @extend_schema( + summary="해당 주문에 남아있는 환불 가능한 모든 상품 환불 (주문 전체 환불)", + tags=[OpenAPITag.SHOP_ORDER_REFUND], + parameters=[ + OpenApiParameter( + name="order_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ) + @transaction.atomic + def destroy( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + """Order의 사용 및 환불하지 않은 상품을 refunded 상태로 변경하고, 결제 취소를 요청합니다.""" + serializer = OrderTotalRefundSerializer(instance=self.get_object(), data={}, context={"check_totp": False}) + serializer.is_valid(raise_exception=True) + serializer.refund() + return response.Response(status=status.HTTP_204_NO_CONTENT) + + @extend_schema( + summary="NHN KCP 영수증 페이지", + tags=[OpenAPITag.SHOP_ORDER], + responses=( + build_html_responses(names=["NHN KCP의 영수증 페이지로 redirect하는 HTML"], status_code=status.HTTP_200_OK) + | build_html_responses(names=["주문을 찾을 수 없는 경우"], status_code=status.HTTP_404_NOT_FOUND) + ), + ) + @action(detail=True, methods=["GET"], url_path="receipt", renderer_classes=[renderers.TemplateHTMLRenderer]) + def retrieve_receipt( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + """NHN KCP의 영수증 페이지로 redirect하는 HTML을 응답합니다.""" + order: Order = self.get_object() + if not order.latest_imp_id: + return response.Response( + data={"error_msg": "본 주문은 영수증을 조회할 수 없습니다.\n파이콘 준비 위원회에 문의해주세요."}, + status=status.HTTP_404_NOT_FOUND, + template_name="scancode_error.html", + ) + + try: + receipt_serializer = portone_client.get_kcp_receipt_search_data(imp_uid=order.latest_imp_id) + except PortOneException: + return response.Response( + data={ + "error_msg": "지금은 영수증을 조회할 수 없습니다, 잠시 후 다시 시도해주세요.\n문제가 지속되면 파이콘 준비 위원회에 문의 부탁드립니다." + }, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + template_name="scancode_error.html", + ) + + return response.Response( + template_name="receipt_kcp.html", + data={ + "search_data": receipt_serializer.to_search_data(), + "sign_data": receipt_serializer.to_kcp_signed_search_data( + private_key=settings.NHN_KCP.pg_api_private_key, + password=settings.NHN_KCP.pg_api_password, + ), + "cert_info": settings.NHN_KCP.pg_api_cert, + }, + ) + + +class OrderProductViewSet(mixins.DestroyModelMixin, viewsets.GenericViewSet): + lookup_url_kwarg = "order_product_rel_id" + serializer_class = None + + def get_queryset(self) -> models.QuerySet[OrderProductRelation]: + if not isinstance(self.request.user, UserExt): + return OrderProductRelation.objects.none() + + return OrderProductRelation.objects.filter( + models.Exists(PaymentHistory.objects.filter(order=models.OuterRef("order"))), + order__deleted_at__isnull=True, + order__user=self.request.user, + single_product_cart__isnull=True, + status=OrderProductRelation.OrderProductStatus.paid, + ).distinct() + + @extend_schema( + summary="주문 중 특정 상품의 옵션 수정", + tags=[OpenAPITag.SHOP_ORDER], + parameters=[ + OpenApiParameter( + name="order_product_rel_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + request=OptionProductOptionCustomResponseModifyRequestSerializer(many=True), + responses={ + status.HTTP_200_OK: OrderDto, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ) + @action(detail=True, methods=["PATCH"], url_path="options") + @transaction.atomic + def modify_options( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + order_product_rel: OrderProductRelation = self.get_object() + for datum in request.data: + serializer = OptionProductOptionCustomResponseModifyRequestSerializer( + data=datum, + context={"order_product_rel": order_product_rel}, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return response.Response(data=OrderDto(instance=order_product_rel.order).data) + + @extend_schema( + summary="주문의 특정 상품 환불 (주문 부분 환불)", + tags=[OpenAPITag.SHOP_ORDER_REFUND], + parameters=[ + OpenApiParameter( + name="order_product_rel_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + status.HTTP_204_NO_CONTENT: None, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + status.HTTP_403_FORBIDDEN: ErrorResponse403Serializer, + }, + ) + @transaction.atomic + def destroy( + self, request: request.Request, *args: tuple[typing.Any], **kwargs: dict[str, typing.Any] + ) -> response.Response: + """부분 환불을 진행합니다.""" + serializer = OrderProductRefundSerializer(instance=self.get_object(), data={}, context={"check_totp": False}) + serializer.is_valid(raise_exception=True) + serializer.refund() + return response.Response(status=status.HTTP_204_NO_CONTENT) diff --git a/app/shop/order/views/scancode.py b/app/shop/order/views/scancode.py new file mode 100644 index 0000000..7c0750e --- /dev/null +++ b/app/shop/order/views/scancode.py @@ -0,0 +1,111 @@ +from collections.abc import Callable +from re import compile +from typing import Any + +from core.const.tag import OpenAPITag +from core.openapi.schemas import build_html_responses +from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes +from drf_spectacular.utils import extend_schema +from rest_framework import permissions, renderers, request, response, status, viewsets +from shop.order.models import Order, OrderProductRelation +from shop.order.serializers.scancode import ( + OrderProductScanCodeSerializer, + OrderScanCodeSerializer, + UserScanCodeSerializer, +) +from shop.payment_history.models import PaymentHistoryStatus +from user.models import UserExt + + +class _ScanCodeError(Exception): + def __init__(self, msg: str, code: int) -> None: + super().__init__(msg, code) + self.msg = msg + self.code = code + + def to_response(self) -> response.Response: + return response.Response( + data={"error_msg": self.msg}, + status=self.code, + template_name="scancode_error.html", + ) + + +def _render_user(token: str) -> response.Response: + if not (user := UserExt.from_scancode_token(token)): + raise _ScanCodeError(msg="인증 정보를 찾을 수 없습니다.", code=status.HTTP_403_FORBIDDEN) + # 환불된 주문도 응답에 포함(현장 스태프의 이력 확인용). 단 모두 refunded 면 게이트. + orders = list(Order.objects.filter_purchased_by(user).filter_in_last_six_months()) + if not any(o.current_status != PaymentHistoryStatus.refunded for o in orders): + raise _ScanCodeError( + msg="최근 6개월 이내에 결제된 유효한 주문이 없습니다 (환불 완료 또는 주문 없음).", + code=status.HTTP_403_FORBIDDEN, + ) + return response.Response( + data={ + "user": UserScanCodeSerializer(instance=user).data, + "orders": OrderScanCodeSerializer(instance=orders, many=True).data, + }, + status=status.HTTP_200_OK, + template_name="scancode_view_user.html", + ) + + +def _render_order(token: str) -> response.Response: + if not (order := Order.from_scancode_token(token)): + raise _ScanCodeError(msg="주문을 찾을 수 없습니다.", code=status.HTTP_404_NOT_FOUND) + if order.current_status == PaymentHistoryStatus.refunded: + raise _ScanCodeError(msg="전체 환불된 주문은 사용하실 수 없습니다.", code=status.HTTP_404_NOT_FOUND) + return response.Response( + data={"order": OrderScanCodeSerializer(instance=order).data}, + status=status.HTTP_200_OK, + template_name="scancode_view_order.html", + ) + + +def _render_opr(token: str) -> response.Response: + if not (opr := OrderProductRelation.from_scancode_token(token)): + raise _ScanCodeError(msg="티켓 정보를 찾을 수 없습니다.", code=status.HTTP_403_FORBIDDEN) + return response.Response( + data={"order_product": OrderProductScanCodeSerializer(instance=opr).data}, + status=status.HTTP_200_OK, + template_name="scancode_view_opr.html", + ) + + +_DISPATCH: dict[str, Callable[[str], response.Response]] = { + "user": _render_user, + "order": _render_order, + "opr": _render_opr, +} +_SCANCODE_REGEX = compile(rf"^(?P{'|'.join(_DISPATCH)}):(?P[A-Za-z0-9]+):(?P[A-Za-z0-9_-]+)$") + + +class ScanCodeViewSet(viewsets.GenericViewSet): + queryset = Order.objects.none() # router 등록용 placeholder — 실제 lookup 은 token 으로. + serializer_class = None + permission_classes = [permissions.AllowAny] + authentication_classes: list = [] + renderer_classes = [renderers.TemplateHTMLRenderer] + + @extend_schema( + summary="QR 코드 페이지 (token 으로 dispatch)", + tags=[OpenAPITag.SHOP_ORDER], + parameters=[ + OpenApiParameter(name="token", type=OpenApiTypes.STR, location=OpenApiParameter.QUERY, required=True) + ], + responses=( + build_html_responses(names=["QR 코드가 포함된 HTML"], status_code=status.HTTP_200_OK) + | build_html_responses(names=["인증 실패 HTML"], status_code=status.HTTP_403_FORBIDDEN) + | build_html_responses(names=["대상을 찾을 수 없는 경우"], status_code=status.HTTP_404_NOT_FOUND) + ), + ) + def list(self, request: request.Request, *args: tuple[Any], **kwargs: dict[str, Any]) -> response.Response: + try: + if not (token := request.query_params.get("token")): + raise _ScanCodeError(msg="유효하지 않은 URL입니다.", code=status.HTTP_404_NOT_FOUND) + if not (match := _SCANCODE_REGEX.match(token)): + raise _ScanCodeError(msg="유효하지 않은 토큰입니다.", code=status.HTTP_404_NOT_FOUND) + return _DISPATCH[match["prefix"]](token) + except _ScanCodeError as e: + return e.to_response() diff --git a/app/shop/patron.py b/app/shop/patron.py new file mode 100644 index 0000000..dc551ae --- /dev/null +++ b/app/shop/patron.py @@ -0,0 +1,93 @@ +from core.const.tag import OpenAPITag +from django.db.models import CharField, DecimalField, Exists, F, OuterRef, Q, Subquery, Sum, Value +from django.db.models.functions import Coalesce +from django_filters import rest_framework as filters +from drf_spectacular.utils import extend_schema, extend_schema_view +from rest_framework import mixins, routers, serializers, status, viewsets +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation +from shop.payment_history.models import REFUNDABLE_STATUSES, PaymentHistory + + +class PatronFilterSet(filters.FilterSet): + year = filters.NumberFilter(field_name="created_at__year") + + class Meta: + model = Order + fields = ["year"] + + +class PatronSerializer(serializers.ModelSerializer): + name = serializers.CharField(source="customer_info.name", allow_null=True) + + class Meta: + fields: list[str] = ["name"] + model = Order + + def to_representation(self, instance: Order) -> dict[str, str]: + result = super().to_representation(instance) + + opor: OrderProductOptionRelation = OrderProductOptionRelation.objects.filter( + Q(product_option_group__name__contains="후원자") | Q(product_option_group__name__contains="message"), + order_product_relation__order=instance, + product_option_group__name__contains="후원자", + product_option_group__is_custom_response=True, + ).first() + return result | {"contribution_message": opor.custom_response if opor else ""} + + +@extend_schema_view( + list=extend_schema( + summary="개인 후원자 목록", + tags=[OpenAPITag.EXT_PATRON_API], + responses={status.HTTP_200_OK: PatronSerializer(many=True)}, + ), +) +class PatronViewSet(mixins.ListModelMixin, viewsets.GenericViewSet): + latest_status_sq = ( + PaymentHistory.objects.filter(order_id=OuterRef("id")).order_by("-created_at").values("status")[:1] + ) + total_paid_sq = ( + OrderProductRelation.objects.filter( + order_id=OuterRef("id"), + status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, + ) + .values("order_id") + .annotate(total=Sum(F("price") + F("donation_price"))) + .values("total") + ) + + queryset = ( + Order.objects.filter_active() + .annotate( + current_status=Subquery(latest_status_sq, output_field=CharField()), + has_donation_product=Exists( + OrderProductRelation.objects.filter( + order_id=OuterRef("id"), + product__donation_allowed=True, + status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, + ) + ), + total_paid_price=Coalesce( + Subquery( + total_paid_sq, + output_field=DecimalField(max_digits=18, decimal_places=2), + ), + Value(0), + output_field=DecimalField(max_digits=18, decimal_places=2), + ), + ) + .filter( + has_donation_product=True, + current_status__in=REFUNDABLE_STATUSES, + ) + .order_by("-total_paid_price", "created_at") + ) + + filterset_class = PatronFilterSet + serializer_class = PatronSerializer + authentication_classes = [] + + +router = routers.SimpleRouter() +router.register("", PatronViewSet, basename="patron") +urlpatterns = router.urls diff --git a/app/shop/payment_history/__init__.py b/app/shop/payment_history/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/payment_history/apps.py b/app/shop/payment_history/apps.py new file mode 100644 index 0000000..c6a9f59 --- /dev/null +++ b/app/shop/payment_history/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class PaymentHistoryConfig(AppConfig): + name = "shop.payment_history" diff --git a/app/shop/payment_history/migrations/0001_initial.py b/app/shop/payment_history/migrations/0001_initial.py new file mode 100644 index 0000000..95e671b --- /dev/null +++ b/app/shop/payment_history/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 6.0.4 on 2026-05-09 06:54 + +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + dependencies = [("order", "0001_initial"), migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name="PaymentHistory", + 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)), + ("imp_id", models.CharField(blank=True, max_length=256, null=True)), + ( + "status", + models.CharField( + choices=[ + ("pending", "결제 대기 중"), + ("completed", "결제 완료"), + ("partial_refunded", "부분 환불함"), + ("refunded", "전액 환불함"), + ], + default="completed", + max_length=32, + ), + ), + ("price", models.IntegerField()), + ( + "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", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="payment_histories", to="order.order" + ), + ), + ( + "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": ("-created_at",), + "indexes": [ + models.Index(fields=["imp_id"], name="payment_his_imp_id_d24e6c_idx"), + models.Index(fields=["status"], name="payment_his_status_c7da1b_idx"), + ], + }, + ), + ] diff --git a/app/shop/payment_history/migrations/__init__.py b/app/shop/payment_history/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/payment_history/models.py b/app/shop/payment_history/models.py new file mode 100644 index 0000000..e1016f6 --- /dev/null +++ b/app/shop/payment_history/models.py @@ -0,0 +1,55 @@ +from core.models import BaseAbstractModel, BaseAbstractModelQuerySet +from django.db import models + + +class PaymentHistoryStatus(models.TextChoices): + pending = "pending", "결제 대기 중" + completed = "completed", "결제 완료" + partial_refunded = "partial_refunded", "부분 환불함" + refunded = "refunded", "전액 환불함" + + +REFUNDABLE_STATUSES: set[PaymentHistoryStatus] = { + PaymentHistoryStatus.completed, + PaymentHistoryStatus.partial_refunded, +} +PURCHASED_STATUSES: set[PaymentHistoryStatus] = REFUNDABLE_STATUSES | {PaymentHistoryStatus.refunded} +LEGAL_PAYMENT_STATUS_TRANSITIONS: dict[PaymentHistoryStatus, set[PaymentHistoryStatus]] = { + PaymentHistoryStatus.pending: {PaymentHistoryStatus.completed}, + PaymentHistoryStatus.completed: {PaymentHistoryStatus.partial_refunded, PaymentHistoryStatus.refunded}, + PaymentHistoryStatus.partial_refunded: {PaymentHistoryStatus.partial_refunded, PaymentHistoryStatus.refunded}, + PaymentHistoryStatus.refunded: set(), # terminal +} + + +def is_legal_payment_status_transition(current: PaymentHistoryStatus, next_: PaymentHistoryStatus) -> bool: + return next_ in LEGAL_PAYMENT_STATUS_TRANSITIONS.get(current, set()) + + +class PaymentHistoryQuerySet(BaseAbstractModelQuerySet): + def latest_per_order_field(self, field_name: str, *, outer_field: str = "id") -> "PaymentHistoryQuerySet": + return ( + self.order_by("order_id", "-created_at") + .distinct("order_id") + .filter(order_id=models.OuterRef(outer_field)) + .values(field_name)[:1] + ) + + +class PaymentHistory(BaseAbstractModel): + order = models.ForeignKey("order.Order", on_delete=models.PROTECT, related_name="payment_histories") + imp_id = models.CharField(max_length=256, null=True, blank=True) + + status = models.CharField( + max_length=32, choices=PaymentHistoryStatus.choices, default=PaymentHistoryStatus.completed + ) + price = models.IntegerField() + + objects: PaymentHistoryQuerySet = PaymentHistoryQuerySet.as_manager() # type: ignore[assignment, misc] + + def __str__(self) -> str: + return f"{self.order} <{self.get_status_display()}> ({self.price}원) [{self.created_at.isoformat()}]" + + class Meta: + ordering = ("-created_at",) + indexes = (models.Index(fields=["imp_id"]), models.Index(fields=["status"])) diff --git a/app/shop/payment_history/serializers.py b/app/shop/payment_history/serializers.py new file mode 100644 index 0000000..f12394c --- /dev/null +++ b/app/shop/payment_history/serializers.py @@ -0,0 +1,176 @@ +import functools + +from core.const.shop_error_messages import PortOneWebhookFailureMessages +from core.external_apis.portone.client import PortOneException, PortOneExceptionGroup, portone_client +from django.db import models, transaction +from rest_framework import serializers +from shop.order.models import Order, OrderProductRelation, SingleProductCart +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus, is_legal_payment_status_transition + + +class PortOneV1PaymentStatus(models.TextChoices): + READY = "ready", "미결제" + PAID = "paid", "결제완료" + CANCELLED = "cancelled", "결제취소" + FAILED = "failed", "결제실패" + + +class PortOneV1PaymentCancelHistorySerializer(serializers.Serializer): + pg_tid = serializers.CharField(required=True, allow_blank=False, allow_null=False, help_text="PG사 승인 취소 번호") + cancellation_id = serializers.CharField(required=True, help_text="결제 취소 ID") + + amount = serializers.FloatField(required=True, help_text="결제 취소 금액") + + cancelled_at = serializers.IntegerField(required=True, help_text="결제 취소 시각(UNIX timestamp)") + reason = serializers.CharField(required=True, help_text="취소 사유") + + +class PortOneV1PaymentDetailSerializer(serializers.Serializer): + imp_uid = serializers.CharField(required=True, allow_blank=False, allow_null=False, help_text="포트원 거래고유번호") + merchant_uid = serializers.CharField( + required=True, allow_blank=False, allow_null=False, help_text="가맹점 주문번호" + ) + + amount = serializers.FloatField(required=True, help_text="결제 금액") + cancel_amount = serializers.FloatField(required=True, help_text="결제건의 누적 취소 금액") + currency = serializers.CharField(required=True, help_text="결제통화 구분코드") + + status = serializers.ChoiceField(choices=PortOneV1PaymentStatus.choices, required=True, help_text="결제 상태") + + started_at = serializers.IntegerField(required=False, help_text="결제 요청 시각(UNIX timestamp)") + paid_at = serializers.IntegerField(required=False, help_text="결제 성공 시각(UNIX timestamp)") + failed_at = serializers.IntegerField(required=False, help_text="결제 실패 시각(UNIX timestamp)") + cancelled_at = serializers.IntegerField(required=False, help_text="결제 취소 시각(UNIX timestamp)") + fail_reason = serializers.CharField(required=False, allow_null=True, help_text="결제실패 사유") + + cancel_history = PortOneV1PaymentCancelHistorySerializer(many=True, required=False, help_text="결제취소 이력") + + +class PortOneV1WebhookRequestStatus(models.TextChoices): + PAID = "paid", "결제 승인" + READY = "ready", "가상 계좌 발급 완료" + FAILED = "failed", "결제 실패" + CANCELLED = "cancelled", "관리자 콘솔에서 결제 취소" + + +class PortOneV1WebhookRequestSerializer(serializers.Serializer): + imp_uid = serializers.CharField(required=True, help_text="PortOne 결제 고유번호") + merchant_uid = serializers.CharField(required=True, help_text="Order or SingleProductCart ID") + status = serializers.ChoiceField( + choices=PortOneV1WebhookRequestStatus.choices, + required=True, + help_text="결제 결과", + ) + cancellation_id = serializers.CharField(required=False, help_text="취소내역 ID") + + @functools.cached_property + def portone_payment_info(self) -> dict: + return portone_client.find_payment_info(self.initial_data["imp_uid"]) + + @functools.cached_property + def cart_or_order(self) -> Order | SingleProductCart | None: + obj_id: str = self.initial_data["merchant_uid"] + if order := Order.objects.filter_active().filter(id=obj_id).first(): + return order + if cart := SingleProductCart.objects.filter_active().filter(id=obj_id).first(): + return cart + return None + + def validate_status(self, value: str) -> str: + if value == PortOneV1WebhookRequestStatus.READY: + raise serializers.ValidationError( + detail=PortOneWebhookFailureMessages.VIRTUAL_ACCOUNT_NOT_SUPPORTED, code="unsupported" + ) + elif value == PortOneV1WebhookRequestStatus.FAILED: + raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.PURCHASE_FAILED, code="forgery") + elif value == PortOneV1WebhookRequestStatus.CANCELLED: + # TODO: 관리자 콘솔 취소 자동 처리는 미구현 — 우선 거부하고 운영자가 수동으로 환불 처리. + raise serializers.ValidationError( + detail=PortOneWebhookFailureMessages.CANCELLED_NOT_SUPPORTED, code="unsupported" + ) + return value + + def validate(self, data: dict) -> dict: + order: Order | SingleProductCart | None = self.cart_or_order + + if not order: + raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.ORDER_NOT_FOUND, code="forgery") + + try: + payment_serializer = PortOneV1PaymentDetailSerializer(data=self.portone_payment_info) + payment_serializer.is_valid(raise_exception=True) + retrieved_order_data = payment_serializer.validated_data + except (PortOneException, PortOneExceptionGroup) as e: + raise serializers.ValidationError(detail=str(e), code="portone_error") from e + + if retrieved_order_data["status"] != PortOneV1PaymentStatus.PAID: + raise serializers.ValidationError( + detail=PortOneWebhookFailureMessages.UNEXPECTED_RETRIEVED_ORDER_STATUS, code="forgery" + ) + + if retrieved_order_data["currency"] != "KRW": + raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.UNSUPPORTED_CURRENCY, code="forgery") + + if retrieved_order_data["merchant_uid"] != str(order.id): + raise serializers.ValidationError( + detail=PortOneWebhookFailureMessages.UNEXPECTED_RETRIEVED_ORDER_ID, code="forgery" + ) + + if ( + data["status"] == PortOneV1WebhookRequestStatus.PAID + and order.first_paid_price != retrieved_order_data["amount"] + ): + raise serializers.ValidationError( + detail=PortOneWebhookFailureMessages.UNEXPECTED_PAID_PRICE, code="forgery" + ) + + return data + + @transaction.atomic + def create(self, validated_data: dict) -> PaymentHistory: + # CANCELLED webhook (관리자 콘솔 취소) 자동 처리는 미구현 — validate_status 에서 거부됨. + payment_info = PortOneV1PaymentDetailSerializer(instance=self.portone_payment_info).data + order = self._lock_or_promote_order(validated_data["merchant_uid"]) + + # State machine — webhook retry 등 중복/불법 전이는 거부. + next_status = PaymentHistoryStatus.completed + if not is_legal_payment_status_transition(order.current_status, next_status): + raise serializers.ValidationError( + detail=PortOneWebhookFailureMessages.ILLEGAL_STATUS_TRANSITION, code="illegal_transition" + ) + + for product_rel in order.products.all(): + product_rel.status = OrderProductRelation.OrderProductStatus.paid + product_rel.save() + + return PaymentHistory.objects.create( + order=order, + imp_id=validated_data["imp_uid"], + status=next_status, + price=payment_info["amount"], + ) + + @staticmethod + def _lock_or_promote_order(obj_id: str) -> Order: + """Order 가 있으면 lock 하여 반환. SingleProductCart 만 있으면 lock + to_order() 로 승격. + + 모델 관계: `SingleProductCart` 는 단일 상품 장바구니의 결제 전 임시 상태이고, + 결제가 성공하면 `to_order()` 로 같은 PK 의 `Order` 로 승격되며 cart 자체는 hard delete 된다. + webhook 은 `merchant_uid` (= cart/order PK) 로 호출되므로, 같은 PK 의 두 모델 중 현재 살아있는 쪽을 lock 한다. + + 동시 webhook race 시: 첫 호출이 cart lock + to_order() commit 후, 두 번째 호출은 + cart 가 hard_delete 된 상태로 lock 해제됨 → Order 재조회에서 승격된 Order 발견. + """ + if order := Order.objects.select_for_update().filter_active().filter(id=obj_id).first(): + return order + if cart := SingleProductCart.objects.select_for_update().filter_active().filter(id=obj_id).first(): + return cart.to_order() + # 첫 lock 시 cart 가 다른 webhook 에 의해 promote 된 경우 — Order 재조회. + if order := Order.objects.select_for_update().filter_active().filter(id=obj_id).first(): + return order + raise serializers.ValidationError(detail=PortOneWebhookFailureMessages.ORDER_NOT_FOUND, code="forgery") + + +class PortOneV1WebhookResponseSerializer(serializers.Serializer): + status = serializers.CharField(default="success", read_only=True) + message = serializers.CharField(default="일반 결제 성공", read_only=True) diff --git a/app/shop/payment_history/urls.py b/app/shop/payment_history/urls.py new file mode 100644 index 0000000..d1a12f0 --- /dev/null +++ b/app/shop/payment_history/urls.py @@ -0,0 +1,8 @@ +from django.urls import include, path +from rest_framework import routers +from shop.payment_history import views + +router = routers.SimpleRouter() +router.register("", views.PaymentHistoryViewSet, basename="payment_histories") + +urlpatterns = [path("", include(router.urls))] diff --git a/app/shop/payment_history/views.py b/app/shop/payment_history/views.py new file mode 100644 index 0000000..7d13f8d --- /dev/null +++ b/app/shop/payment_history/views.py @@ -0,0 +1,44 @@ +import logging +import typing + +from core.const.tag import OpenAPITag +from core.logger.util.decorator import bad_response_slack_logger +from django.conf import settings +from django.db import transaction +from drf_spectacular.utils import extend_schema +from drf_standardized_errors.openapi_serializers import ValidationErrorResponseSerializer +from rest_framework import exceptions, mixins, permissions, request, response, status, viewsets +from shop.payment_history.models import PaymentHistory +from shop.payment_history.serializers import PortOneV1WebhookRequestSerializer, PortOneV1WebhookResponseSerializer + +logger = logging.getLogger(__name__) + + +class PaymentHistoryViewSet(mixins.CreateModelMixin, viewsets.GenericViewSet): + queryset = PaymentHistory.objects.all() + serializer_class = PortOneV1WebhookRequestSerializer + permission_classes = [permissions.AllowAny] + + @extend_schema( + summary="PortOne 결제 Webhook", + tags=[OpenAPITag.SHOP_PORTONE_WEBHOOK], + responses={ + status.HTTP_200_OK: PortOneV1WebhookResponseSerializer, + status.HTTP_400_BAD_REQUEST: ValidationErrorResponseSerializer, + }, + ) + @bad_response_slack_logger(tag="PortOne 결제 결과 Webhook") + @transaction.atomic + def create( # type: ignore[override] + self, request: request.Request, *args: typing.Any, **kwargs: typing.Any + ) -> response.Response: + if not (settings.DEBUG or request.META.get("REMOTE_ADDR") in settings.PORTONE.ip_list): + raise exceptions.PermissionDenied() + + logger.info(f"PortOne Webhook Request: {request.data}") + + serializer = self.get_serializer(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.create(serializer.data) + + return response.Response(data={"status": "success", "message": "일반 결제 성공"}) diff --git a/app/shop/product/__init__.py b/app/shop/product/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/product/apps.py b/app/shop/product/apps.py new file mode 100644 index 0000000..ea4ee70 --- /dev/null +++ b/app/shop/product/apps.py @@ -0,0 +1,10 @@ +import importlib + +from django.apps import AppConfig + + +class ProductConfig(AppConfig): + name = "shop.product" + + def ready(self): + importlib.import_module("shop.product.translation") diff --git a/app/shop/product/filtersets.py b/app/shop/product/filtersets.py new file mode 100644 index 0000000..b7626d6 --- /dev/null +++ b/app/shop/product/filtersets.py @@ -0,0 +1,11 @@ +from django_filters import rest_framework as filters +from shop.product.models import Product + + +class ProductFilterSet(filters.FilterSet): + category_group = filters.CharFilter(field_name="category__group__name", lookup_expr="exact") + category = filters.CharFilter(field_name="category__name", lookup_expr="exact") + + class Meta: + model = Product + fields = ["category_group", "category"] diff --git a/app/shop/product/migrations/0001_initial.py b/app/shop/product/migrations/0001_initial.py new file mode 100644 index 0000000..4df28dc --- /dev/null +++ b/app/shop/product/migrations/0001_initial.py @@ -0,0 +1,1009 @@ +# Generated by Django 6.0.4 on 2026-05-09 06:54 + +import datetime +import uuid + +import django.db.models.deletion +import simple_history.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + dependencies = [migrations.swappable_dependency(settings.AUTH_USER_MODEL)] + operations = [ + migrations.CreateModel( + name="CategoryGroup", + 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)), + ("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, + ), + ), + ( + "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", "-created_at"], + }, + ), + migrations.CreateModel( + name="Category", + 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)), + ("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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ("group", models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to="product.categorygroup")), + ], + options={ + "ordering": ["group__priority", "priority", "-created_at"], + }, + ), + migrations.CreateModel( + name="HistoricalCategory", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField(max_length=100)), + ("priority", models.IntegerField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.categorygroup", + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical category", + "verbose_name_plural": "historical categorys", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalCategoryGroup", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField(max_length=100)), + ("priority", models.IntegerField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical category group", + "verbose_name_plural": "historical category groups", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalProduct", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.TextField()), + ("name_ko", models.TextField(null=True)), + ("name_en", models.TextField(null=True)), + ("description", models.TextField(blank=True, null=True)), + ("description_ko", models.TextField(blank=True, null=True)), + ("description_en", models.TextField(blank=True, null=True)), + ("image", models.URLField(blank=True, null=True)), + ("price", models.PositiveIntegerField()), + ("stock", models.IntegerField(default=0)), + ("hidden", models.BooleanField(default=False)), + ("max_quantity_per_user", models.IntegerField(default=0)), + ("visible_starts_at", models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0))), + ("visible_ends_at", models.DateTimeField(default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999))), + ("orderable_starts_at", models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0))), + ( + "orderable_ends_at", + models.DateTimeField(default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999)), + ), + ( + "refundable_ends_at", + models.DateTimeField(default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999)), + ), + ("priority", models.IntegerField(default=0)), + ("donation_allowed", models.BooleanField(default=False)), + ("donation_min_price", models.PositiveIntegerField(default=0)), + ("donation_max_price", models.PositiveIntegerField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "category", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.category", + ), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical product", + "verbose_name_plural": "historical products", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="HistoricalTag", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("name", models.CharField(max_length=100)), + ("name_ko", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("stock", models.IntegerField(default=0)), + ("max_quantity_per_user", models.IntegerField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "verbose_name": "historical tag", + "verbose_name_plural": "historical tags", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="OptionGroup", + 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)), + ("priority", models.IntegerField(default=0)), + ("name", models.CharField(max_length=100)), + ("name_ko", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("min_quantity_per_product", models.IntegerField(default=0)), + ("max_quantity_per_product", models.IntegerField(default=0)), + ("is_custom_response", models.BooleanField(default=False)), + ("custom_response_pattern", models.TextField(blank=True, null=True)), + ( + "response_modifiable_ends_at", + models.DateTimeField( + default=None, help_text="답변 수정 마감 시간. None인 경우 수정 불가.", null=True + ), + ), + ( + "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, + ), + ), + ( + "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", "-created_at"], + }, + ), + migrations.CreateModel( + name="Option", + 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)), + ("priority", models.IntegerField(default=0)), + ("name", models.CharField(max_length=100)), + ("name_ko", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("max_quantity_per_user", models.IntegerField(default=0)), + ("additional_price", models.PositiveIntegerField(default=0)), + ("stock", 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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "group", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="options", to="product.optiongroup" + ), + ), + ], + options={ + "ordering": ["priority", "-created_at"], + }, + ), + migrations.CreateModel( + name="HistoricalOption", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("priority", models.IntegerField(default=0)), + ("name", models.CharField(max_length=100)), + ("name_ko", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("max_quantity_per_user", models.IntegerField(default=0)), + ("additional_price", models.PositiveIntegerField(default=0)), + ("stock", models.IntegerField(default=0)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.optiongroup", + ), + ), + ], + options={ + "verbose_name": "historical option", + "verbose_name_plural": "historical options", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="Product", + 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.TextField()), + ("name_ko", models.TextField(null=True)), + ("name_en", models.TextField(null=True)), + ("description", models.TextField(blank=True, null=True)), + ("description_ko", models.TextField(blank=True, null=True)), + ("description_en", models.TextField(blank=True, null=True)), + ("image", models.URLField(blank=True, null=True)), + ("price", models.PositiveIntegerField()), + ("stock", models.IntegerField(default=0)), + ("hidden", models.BooleanField(default=False)), + ("max_quantity_per_user", models.IntegerField(default=0)), + ("visible_starts_at", models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0))), + ("visible_ends_at", models.DateTimeField(default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999))), + ("orderable_starts_at", models.DateTimeField(default=datetime.datetime(1, 1, 1, 0, 0))), + ( + "orderable_ends_at", + models.DateTimeField(default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999)), + ), + ( + "refundable_ends_at", + models.DateTimeField(default=datetime.datetime(9999, 12, 31, 23, 59, 59, 999999)), + ), + ("priority", models.IntegerField(default=0)), + ("donation_allowed", models.BooleanField(default=False)), + ("donation_min_price", models.PositiveIntegerField(default=0)), + ("donation_max_price", models.PositiveIntegerField(default=0)), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, related_name="products", 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, + ), + ), + ( + "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": ["category__group__priority", "category__priority", "priority", "-created_at"], + }, + ), + migrations.AddField( + model_name="optiongroup", + name="product", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="option_groups", to="product.product" + ), + ), + migrations.CreateModel( + name="HistoricalOptionGroup", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("priority", models.IntegerField(default=0)), + ("name", models.CharField(max_length=100)), + ("name_ko", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("min_quantity_per_product", models.IntegerField(default=0)), + ("max_quantity_per_product", models.IntegerField(default=0)), + ("is_custom_response", models.BooleanField(default=False)), + ("custom_response_pattern", models.TextField(blank=True, null=True)), + ( + "response_modifiable_ends_at", + models.DateTimeField( + default=None, help_text="답변 수정 마감 시간. None인 경우 수정 불가.", null=True + ), + ), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.product", + ), + ), + ], + options={ + "verbose_name": "historical option group", + "verbose_name_plural": "historical option groups", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name="Tag", + 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)), + ("name_ko", models.CharField(max_length=100, null=True)), + ("name_en", models.CharField(max_length=100, null=True)), + ("stock", models.IntegerField(default=0)), + ("max_quantity_per_user", 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, + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="ProductTagRelation", + 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)), + ( + "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, + ), + ), + ( + "product", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="tags", to="product.product" + ), + ), + ( + "updated_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name="%(class)s_updated_by", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "tag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="products", to="product.tag" + ), + ), + ], + ), + migrations.CreateModel( + name="HistoricalProductTagRelation", + fields=[ + ("id", models.UUIDField(db_index=True, default=uuid.uuid4, editable=False)), + ("created_at", models.DateTimeField(blank=True, editable=False)), + ("updated_at", models.DateTimeField(blank=True, editable=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("history_id", models.AutoField(primary_key=True, serialize=False)), + ("history_date", models.DateTimeField(db_index=True)), + ("history_change_reason", models.CharField(max_length=100, null=True)), + ( + "history_type", + models.CharField(choices=[("+", "Created"), ("~", "Changed"), ("-", "Deleted")], max_length=1), + ), + ( + "created_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "deleted_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "history_user", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "product", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.product", + ), + ), + ( + "tag", + models.ForeignKey( + blank=True, + db_constraint=False, + null=True, + on_delete=django.db.models.deletion.DO_NOTHING, + related_name="+", + to="product.tag", + ), + ), + ], + options={ + "verbose_name": "historical product tag relation", + "verbose_name_plural": "historical product tag relations", + "ordering": ("-history_date", "-history_id"), + "get_latest_by": ("history_date", "history_id"), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.AddField( + model_name="product", + name="tag_set", + field=models.ManyToManyField( + related_name="product_set", + through="product.ProductTagRelation", + to="product.tag", + ), + ), + migrations.AddConstraint( + model_name="categorygroup", + constraint=models.UniqueConstraint(fields=("name",), name="uq__cat_grp__nm"), + ), + migrations.AddConstraint( + model_name="category", + constraint=models.UniqueConstraint(fields=("group", "name"), name="uq__cat__grp_nm"), + ), + migrations.AddIndex( + model_name="option", + index=models.Index(fields=["name"], name="product_opt_name_95549e_idx"), + ), + migrations.AddIndex( + model_name="option", + index=models.Index(fields=["name_ko"], name="product_opt_name_95549e_idx-ko"), + ), + migrations.AddIndex( + model_name="option", + index=models.Index(fields=["name_en"], name="product_opt_name_95549e_idx-en"), + ), + migrations.AddIndex( + model_name="product", + index=models.Index(fields=["name"], name="product_pro_name_b60cd1_idx"), + ), + migrations.AddIndex( + model_name="product", + index=models.Index(fields=["name_ko"], name="product_pro_name_b60cd1_idx-ko"), + ), + migrations.AddIndex( + model_name="product", + index=models.Index(fields=["name_en"], name="product_pro_name_b60cd1_idx-en"), + ), + migrations.AlterUniqueTogether( + name="optiongroup", + unique_together={("product", "name"), ("product", "name_en"), ("product", "name_ko")}, + ), + migrations.AddConstraint( + model_name="tag", + constraint=models.UniqueConstraint(fields=("name",), name="uq__tag__nm"), + ), + migrations.AddConstraint( + model_name="tag", + constraint=models.UniqueConstraint(fields=("name_ko",), name="uq__tag__nm-ko"), + ), + migrations.AddConstraint( + model_name="tag", + constraint=models.UniqueConstraint(fields=("name_en",), name="uq__tag__nm-en"), + ), + migrations.AlterUniqueTogether( + name="producttagrelation", + unique_together={("product", "tag")}, + ), + ] diff --git a/app/shop/product/migrations/__init__.py b/app/shop/product/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/product/models.py b/app/shop/product/models.py new file mode 100644 index 0000000..4d9a178 --- /dev/null +++ b/app/shop/product/models.py @@ -0,0 +1,331 @@ +from __future__ import annotations + +import datetime +import functools +import typing + +from core.models import BaseAbstractModel +from core.util.dateutil import now_aware +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models.manager import BaseManager +from simple_history.models import HistoricalRecords + +if typing.TYPE_CHECKING: + from user.models import UserExt + + +class CategoryGroup(BaseAbstractModel): + name = models.CharField(max_length=100) + priority = models.IntegerField(default=0) + + history = HistoricalRecords() + + class Meta: + ordering = ["priority", "-created_at"] + constraints = [models.UniqueConstraint(fields=["name"], name="uq__cat_grp__nm")] + + def __str__(self) -> str: + return self.name + + +class Category(BaseAbstractModel): + group = models.ForeignKey(CategoryGroup, on_delete=models.PROTECT) + name = models.CharField(max_length=100) + priority = models.IntegerField(default=0) + + history = HistoricalRecords() + + class Meta: + ordering = ["group__priority", "priority", "-created_at"] + constraints = [models.UniqueConstraint(fields=["group", "name"], name="uq__cat__grp_nm")] + + def __str__(self) -> str: + return f"{self.group.name} > {self.name}" + + +class Tag(BaseAbstractModel): + name = models.CharField(max_length=100) + stock = models.IntegerField(default=0) + max_quantity_per_user = models.IntegerField(default=0) + + products: BaseManager[Product] + + class Meta: + constraints = [models.UniqueConstraint(fields=["name"], name="uq__tag__nm")] + + def __str__(self) -> str: + return self.name + + @functools.cached_property + def leftover_stock(self) -> int | None: + """해당 태그에 속한 상품들의 재고를 반환합니다.""" + from shop.order.models import OrderProductRelation + + return ( + ( + self.stock + - OrderProductRelation.objects.filter( + product__tags__tag=self, + single_product_cart__isnull=True, + status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, + ).count() + ) + if self.stock + else None + ) + + def get_user_taken_stock_count( + self, + *, + user: "UserExt", + include_cart: bool, + include_purchased: bool, + ) -> int: + """해당 유저가 담거나 구매한 상품군 상품의 수량을 반환합니다.""" + from shop.order.models import OrderProductRelation + + target_status = [] + if include_cart: + target_status.append(OrderProductRelation.OrderProductStatus.pending) + if include_purchased: + target_status.extend( + [ + OrderProductRelation.OrderProductStatus.paid, + OrderProductRelation.OrderProductStatus.used, + ] + ) + + return OrderProductRelation.objects.filter( + order__user=user, + product__tags__tag=self, + single_product_cart__isnull=True, + status__in=target_status, + ).count() + + +class Product(BaseAbstractModel): + class CurrentStatus(models.TextChoices): + HIDDEN = "hidden", "비공개" + OUT_OF_VISIBLE_PERIOD = "out_of_visible_period", "노출 기간 아님" + OUT_OF_ORDERABLE_PERIOD = "out_of_orderable_period", "판매 기간 아님" + ACTIVE = "active", "노출 중" + + name = models.TextField() + description = models.TextField(null=True, blank=True) + image = models.URLField(null=True, blank=True) + + price = models.PositiveIntegerField() + stock = models.IntegerField(default=0) + hidden = models.BooleanField(default=False) + + max_quantity_per_user = models.IntegerField(default=0) + visible_starts_at = models.DateTimeField(default=datetime.datetime.min) + visible_ends_at = models.DateTimeField(default=datetime.datetime.max) + orderable_starts_at = models.DateTimeField(default=datetime.datetime.min) + orderable_ends_at = models.DateTimeField(default=datetime.datetime.max) + refundable_ends_at = models.DateTimeField(default=datetime.datetime.max) + + category = models.ForeignKey(Category, on_delete=models.PROTECT, related_name="products") + priority = models.IntegerField(default=0) + + donation_allowed = models.BooleanField(default=False) + donation_min_price = models.PositiveIntegerField(default=0) + donation_max_price = models.PositiveIntegerField(default=0) + + tag_set = models.ManyToManyField(to="Tag", through="ProductTagRelation", related_name="product_set") + + tags: BaseManager[ProductTagRelation] + option_groups: BaseManager[OptionGroup] + + class Meta: + ordering = ["category__group__priority", "category__priority", "priority", "-created_at"] + indexes = [models.Index(fields=["name"])] + + def __str__(self) -> str: + return f"{self.category} > {self.name} ({self.price}원)" + + @property + def current_status(self) -> "Product.CurrentStatus": + if self.hidden: + return self.CurrentStatus.HIDDEN + + now = now_aware() + if (self.visible_starts_at and now < self.visible_starts_at) or ( + self.visible_ends_at and now > self.visible_ends_at + ): + return self.CurrentStatus.OUT_OF_VISIBLE_PERIOD + + if (self.orderable_starts_at and now < self.orderable_starts_at) or ( + self.orderable_ends_at and now > self.orderable_ends_at + ): + return self.CurrentStatus.OUT_OF_ORDERABLE_PERIOD + + return self.CurrentStatus.ACTIVE + + @functools.cached_property + def leftover_stock(self) -> int | None: + """해당 상품의 재고를 반환합니다.""" + from shop.order.models import OrderProductRelation + + return ( + ( + self.stock + - OrderProductRelation.objects.filter( + product=self, + single_product_cart__isnull=True, + status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, + ).count() + ) + if self.stock + else None + ) + + def get_user_taken_stock_count( + self, + *, + user: "UserExt", + include_cart: bool, + include_purchased: bool, + ) -> int: + """해당 유저가 담거나 구매한 상품의 수량을 반환합니다.""" + from shop.order.models import OrderProductRelation + + target_status = [] + if include_cart: + target_status.append(OrderProductRelation.OrderProductStatus.pending) + if include_purchased: + target_status.extend( + [ + OrderProductRelation.OrderProductStatus.paid, + OrderProductRelation.OrderProductStatus.used, + ] + ) + + return OrderProductRelation.objects.filter( + order__user=user, + product=self, + single_product_cart__isnull=True, + status__in=target_status, + ).count() + + +class ProductTagRelation(BaseAbstractModel): + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="tags") + tag = models.ForeignKey(Tag, on_delete=models.CASCADE, related_name="products") + + class Meta: + unique_together = ["product", "tag"] + + history = HistoricalRecords() + + +class OptionGroup(BaseAbstractModel): + product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name="option_groups") + priority = models.IntegerField(default=0) + + name = models.CharField(max_length=100) + min_quantity_per_product = models.IntegerField(default=0) + max_quantity_per_product = models.IntegerField(default=0) + + is_custom_response = models.BooleanField(default=False) + custom_response_pattern = models.TextField(null=True, blank=True) + response_modifiable_ends_at = models.DateTimeField( + default=None, + null=True, + help_text="답변 수정 마감 시간. None인 경우 수정 불가.", + ) + + options: BaseManager[Option] + + class Meta: + ordering = ["priority", "-created_at"] + unique_together = ["product", "name"] + + def __str__(self) -> str: + return f"[{self.product.name}] {self.name}" + + def clean(self) -> None: + # is_custom_response=True 시 패턴이 admin 계약 — 빈 답변 허용은 ".*", 비공란 강제는 ".+" 등으로 명시. + if self.is_custom_response and not self.custom_response_pattern: + raise ValidationError( + {"custom_response_pattern": "is_custom_response=True 일 때 custom_response_pattern 은 필수입니다."} + ) + super().clean() + + def is_group_stock_available(self) -> bool: + """해당 옵션 그룹의 재고가 있는지 확인합니다.""" + if ( + self.is_custom_response + or not self.min_quantity_per_product + or any(option.leftover_stock is None for option in self.options.all()) + ): + # 주문당 필수 구매 개수가 없거나 재고가 무한대인 옵션이 하나라도 있으면 재고가 충분하다고 판단합니다. + return True + + # 모든 옵션의 재고를 합쳐서 해당 옵션 그룹의 최소 구매 수량과 비교했을 시, + # 최소 구매 수량보다 크거나 같으면 재고가 충분하다고 판단합니다. + return self.min_quantity_per_product <= sum(option.leftover_stock for option in self.options.all()) + + +class Option(BaseAbstractModel): + group = models.ForeignKey(OptionGroup, on_delete=models.CASCADE, related_name="options") + priority = models.IntegerField(default=0) + + name = models.CharField(max_length=100) + max_quantity_per_user = models.IntegerField(default=0) + + additional_price = models.PositiveIntegerField(default=0) + stock = models.IntegerField(default=0) + + class Meta: + ordering = ["priority", "-created_at"] + indexes = [models.Index(fields=["name"])] + + def __str__(self) -> str: + return f"{self.name} ({self.additional_price}원)" + + @functools.cached_property + def leftover_stock(self) -> int | None: + """해당 옵션의 재고를 반환합니다.""" + from shop.order.models import OrderProductOptionRelation, OrderProductRelation + + return ( + ( + self.stock + - OrderProductOptionRelation.objects.filter( + product_option=self, + order_product_relation__status__in=OrderProductRelation.PURCHASED_STOCK_STATUS, + ).count() + ) + if self.stock + else None + ) + + def get_user_taken_stock_count( + self, + *, + user: "UserExt", + include_cart: bool, + include_purchased: bool, + ) -> int: + """해당 유저가 담거나 구매한 옵션의 수량을 반환합니다.""" + from shop.order.models import OrderProductOptionRelation, OrderProductRelation + + target_status = [] + if include_cart: + target_status.append(OrderProductRelation.OrderProductStatus.pending) + if include_purchased: + target_status.extend( + [ + OrderProductRelation.OrderProductStatus.paid, + OrderProductRelation.OrderProductStatus.used, + ] + ) + + return OrderProductOptionRelation.objects.filter( + order_product_relation__order__user=user, + order_product_relation__single_product_cart__isnull=True, + order_product_relation__status__in=target_status, + product_option=self, + ).count() diff --git a/app/shop/product/serializers/__init__.py b/app/shop/product/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/product/serializers/dto.py b/app/shop/product/serializers/dto.py new file mode 100644 index 0000000..8e1bbce --- /dev/null +++ b/app/shop/product/serializers/dto.py @@ -0,0 +1,58 @@ +from rest_framework import serializers +from shop.product.models import Option, OptionGroup, Product + + +class OptionDto(serializers.ModelSerializer): + class Meta: + fields = ( + "id", + "name", + "additional_price", + "max_quantity_per_user", + "leftover_stock", + ) + model = Option + + +class OptionGroupDto(serializers.ModelSerializer): + options = OptionDto(many=True) + + class Meta: + fields = ( + "id", + "name", + "min_quantity_per_product", + "max_quantity_per_product", + "is_custom_response", + "custom_response_pattern", + "options", + ) + model = OptionGroup + + +class ProductDto(serializers.ModelSerializer): + category_group = serializers.CharField(source="category.group.name") + category = serializers.CharField(source="category.name") + option_groups = OptionGroupDto(many=True) + tag_names: serializers.StringRelatedField = serializers.StringRelatedField(source="tags", many=True) + + class Meta: + fields = ( + "id", + "name", + "description", + "image", + "price", + "donation_allowed", + "donation_min_price", + "donation_max_price", + "orderable_starts_at", + "orderable_ends_at", + "refundable_ends_at", + "category_group", + "category", + "option_groups", + "leftover_stock", + "tag_names", + ) + model = Product diff --git a/app/shop/product/translation.py b/app/shop/product/translation.py new file mode 100644 index 0000000..485d877 --- /dev/null +++ b/app/shop/product/translation.py @@ -0,0 +1,29 @@ +from modeltranslation.translator import TranslationOptions, register +from shop.product.models import Option, OptionGroup, Product, Tag +from simple_history import register as history_register + + +@register(Tag) +class TagTranslationOptions(TranslationOptions): + fields = ("name",) + + +@register(Product) +class ProductTranslationOptions(TranslationOptions): + fields = ("name", "description") + + +@register(OptionGroup) +class OptionGroupTranslationOptions(TranslationOptions): + fields = ("name",) + + +@register(Option) +class OptionTranslationOptions(TranslationOptions): + fields = ("name",) + + +history_register(Tag) +history_register(Product) +history_register(OptionGroup) +history_register(Option) diff --git a/app/shop/product/urls.py b/app/shop/product/urls.py new file mode 100644 index 0000000..9850155 --- /dev/null +++ b/app/shop/product/urls.py @@ -0,0 +1,8 @@ +from django.urls import include, path +from rest_framework import routers +from shop.product import views + +router = routers.SimpleRouter() +router.register("", views.ProductViewSet, basename="products") + +urlpatterns = [path("", include(router.urls))] diff --git a/app/shop/product/views.py b/app/shop/product/views.py new file mode 100644 index 0000000..b49c894 --- /dev/null +++ b/app/shop/product/views.py @@ -0,0 +1,67 @@ +from core.const.tag import OpenAPITag +from core.util.dateutil import now_aware +from django.db.models import Prefetch, Q, QuerySet +from drf_spectacular.openapi import OpenApiParameter, OpenApiTypes +from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_standardized_errors.openapi_serializers import ErrorResponse404Serializer +from rest_framework import mixins, status, viewsets +from shop.order.models import OrderProductRelation +from shop.product.filtersets import ProductFilterSet +from shop.product.models import OptionGroup, Product +from shop.product.serializers.dto import ProductDto +from user.models import UserExt + + +@extend_schema_view( + list=extend_schema( + summary="상품 목록 조회", + tags=[OpenAPITag.SHOP_PRODUCT], + responses={status.HTTP_200_OK: ProductDto(many=True)}, + ), + retrieve=extend_schema( + summary="상품 상세 조회", + tags=[OpenAPITag.SHOP_PRODUCT], + parameters=[ + OpenApiParameter( + name="product_id", + type=OpenApiTypes.UUID, + location=OpenApiParameter.PATH, + required=True, + ) + ], + responses={ + status.HTTP_200_OK: ProductDto, + status.HTTP_404_NOT_FOUND: ErrorResponse404Serializer, + }, + ), +) +class ProductViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + lookup_url_kwarg = "product_id" + authentication_classes = [] + permission_classes = [] + serializer_class = ProductDto + filterset_class = ProductFilterSet + + def get_queryset(self) -> QuerySet[Product]: + # 현재 노출 가능한 상품만 보여야 합니다. + now = now_aware() + filter = Q(visible_starts_at__lte=now, visible_ends_at__gte=now, hidden=False) + + if self.action == "retrieve" and isinstance(self.request.user, UserExt): + # 단, 사용자가 구매한 상품인 경우, 노출 기간에 상관없이 상세 정보를 조회할 수 있어야 합니다. + purchased_product_ids = OrderProductRelation.objects.filter( + order__user=self.request.user, + single_product_cart__isnull=True, + status=OrderProductRelation.OrderProductStatus.paid, + ).values_list("product_id", flat=True) + filter |= Q(id__in=purchased_product_ids) + + return ( + Product.objects.filter_active() + .filter(filter) + .select_related("category", "category__group") + .prefetch_related( + "tags", + Prefetch("option_groups", queryset=OptionGroup.objects.prefetch_related("options")), + ) + ) diff --git a/app/shop/serializers/__init__.py b/app/shop/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/shop/serializers/cart_validation/__init__.py b/app/shop/serializers/cart_validation/__init__.py new file mode 100644 index 0000000..e75ac46 --- /dev/null +++ b/app/shop/serializers/cart_validation/__init__.py @@ -0,0 +1,34 @@ +"""cart_validation 패키지 — 도메인별 파일 분리. 기존 `from shop.serializers.cart_validation import X` 경로 유지.""" + +from shop.serializers.cart_validation._base import CustomerInfoCheckSerializer, OrderableCheckSerializerMode +from shop.serializers.cart_validation.cart import CartOrderableCheckSerializer +from shop.serializers.cart_validation.option import ( + OptionOrderableCheckSerializer, + OptionOrderableCheckTypedDict, +) +from shop.serializers.cart_validation.product import ( + ProductOrderableCheckAfterValidationDataType, + ProductOrderableCheckBeforeValidationDataType, + ProductOrderableCheckSerializer, +) +from shop.serializers.cart_validation.single_product_cart import ( + CustomerInfoType, + SingleProductCartOrderableCheckDataType, + SingleProductCartOrderableCheckSerializer, +) +from shop.serializers.cart_validation.tag import TagOrderableCheckSerializer + +__all__ = [ + "CartOrderableCheckSerializer", + "CustomerInfoCheckSerializer", + "CustomerInfoType", + "OptionOrderableCheckSerializer", + "OptionOrderableCheckTypedDict", + "OrderableCheckSerializerMode", + "ProductOrderableCheckAfterValidationDataType", + "ProductOrderableCheckBeforeValidationDataType", + "ProductOrderableCheckSerializer", + "SingleProductCartOrderableCheckDataType", + "SingleProductCartOrderableCheckSerializer", + "TagOrderableCheckSerializer", +] diff --git a/app/shop/serializers/cart_validation/_base.py b/app/shop/serializers/cart_validation/_base.py new file mode 100644 index 0000000..1eaab27 --- /dev/null +++ b/app/shop/serializers/cart_validation/_base.py @@ -0,0 +1,24 @@ +import enum + +from core.const.regex import ALLOW_ALL_PATTERN, PHONE_PATTERN +from rest_framework import serializers +from shop.order.models import CustomerInfo + + +class CustomerInfoCheckSerializer(serializers.ModelSerializer): + """고객 정보가 Regex에 맞는지 확인합니다.""" + + name = serializers.RegexField(ALLOW_ALL_PATTERN, required=True, allow_null=False, allow_blank=False) + phone = serializers.RegexField(PHONE_PATTERN, required=True, allow_null=False, allow_blank=False) + email = serializers.EmailField(required=True, allow_null=False, allow_blank=False) + organization = serializers.RegexField(ALLOW_ALL_PATTERN, required=True, allow_null=False, allow_blank=True) + + class Meta: + model = CustomerInfo + fields = ("name", "phone", "email", "organization") + + +class OrderableCheckSerializerMode(str, enum.Enum): + ADD_SINGLE_PRODUCT_TO_CART = enum.auto() + CHECKOUT_SINGLE_PRODUCT = enum.auto() + CHECKOUT_CART = enum.auto() diff --git a/app/shop/serializers/cart_validation/cart.py b/app/shop/serializers/cart_validation/cart.py new file mode 100644 index 0000000..1d3f8f1 --- /dev/null +++ b/app/shop/serializers/cart_validation/cart.py @@ -0,0 +1,52 @@ +import typing + +from core.const.shop_error_messages import CartNotOrderableErrorMessages +from rest_framework import serializers +from shop.order.models import Order, OrderProductRelation +from shop.payment_history.models import PaymentHistoryStatus +from user.models import UserExt + + +class CartOrderableCheckSerializer(serializers.Serializer): + """ + 장바구니가 주문 가능한지 확인합니다. + 아래 조건에 해당될 경우 주문 불가능합니다. + - 이미 결제한 장바구니인 경우 주문 불가능 + - 장바구니 내에 이미 결제한 상품이 있는 경우 주문 불가능 + - 장바구니 내의 상품들 주문 금액 합계가 0원 미만이거나 100만원 이상인 경우 주문 불가능 + """ + + cart = serializers.PrimaryKeyRelatedField(queryset=Order.objects.filter_has_no_payment_histories(), required=True) + + class Meta: + fields = ("cart",) + + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + super().__init__(*args, **kwargs) + # 타인의 미결제 cart 로 결제 흐름 트리거를 방어 — request.user 의 cart 로만 lookup 한정. + request = self.context.get("request") + user = request.user if request is not None else None + self.fields["cart"].queryset = ( + Order.objects.filter_has_no_payment_histories().filter(user=user) + if isinstance(user, UserExt) + else Order.objects.none() + ) + + def validate(self, data: dict) -> dict: + cart: Order = data["cart"] + + # 이미 결제한 장바구니인 경우 주문 불가능 + if cart.current_status != PaymentHistoryStatus.pending or cart.payment_histories.exists(): + raise serializers.ValidationError(CartNotOrderableErrorMessages.ALREADY_ORDERED) + + # 장바구니 내에 이미 결제한 상품이 있는 경우 주문 불가능 + if cart.products.exclude(status=OrderProductRelation.OrderProductStatus.pending).exists(): + raise serializers.ValidationError(CartNotOrderableErrorMessages.CONTAINS_PAID_PRODUCT) + + # 장바구니 내의 상품들 주문 금액 합계가 0원 이하거나 100만원 이상인 경우 주문 불가능 + if cart.first_paid_price <= 0: + raise serializers.ValidationError(CartNotOrderableErrorMessages.CART_PRICE_TOO_LOW) + if cart.first_paid_price >= 1_000_000: + raise serializers.ValidationError(CartNotOrderableErrorMessages.CART_PRICE_TOO_HIGH) + + return data diff --git a/app/shop/serializers/cart_validation/option.py b/app/shop/serializers/cart_validation/option.py new file mode 100644 index 0000000..516f942 --- /dev/null +++ b/app/shop/serializers/cart_validation/option.py @@ -0,0 +1,169 @@ +import re +import typing +import uuid + +from core.const.shop_error_messages import ( + OptionGroupNotOrderableErrorMessages, + OptionNotOrderableErrorMessages, + SignInErrorMessages, +) +from core.serializer.nested_model_serializer import InstanceListSerializer +from django.contrib.auth.models import AnonymousUser +from rest_framework import request, serializers +from shop.product.models import Option, OptionGroup, Product +from shop.serializers.cart_validation._base import OrderableCheckSerializerMode +from user.models import UserExt + + +class OptionOrderableCheckTypedDict(typing.TypedDict): + product_option_group: OptionGroup + product_option: Option | None + custom_response: str | None + + +class OptionOrderableCheckSerializer(serializers.Serializer): + """ + 장바구니에 담긴 상품의 옵션이 주문 가능한지 확인합니다. + 아래 조건에 해당될 경우 주문 불가능합니다. + + ==================== 상품 옵션 그룹 (OptionGroup) ==================== + - 상품 옵션 중 필수 옵션이 매진된 경우 주문 불가능 + - 옵션 그룹이 custom_response를 받는 경우, custom_response가 올바른 형식으로 입력되지 않은 경우 주문 불가능 + - 옵션 그룹이 custom_response를 받지 않는 경우, option이 선택되지 않았거나 옵션 그룹에 속하지 않은 경우 주문 불가능 + + ==================== 상품 옵션 (Option) ==================== + - 옵션의 재고가 없는 경우 주문 불가능 + - 옵션의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능 + - 고객이 옵션의 재고를 초과하여 옵션을 장바구니에 담으면 주문 불가능 + """ + + product_option_group = serializers.PrimaryKeyRelatedField( + queryset=OptionGroup.objects.filter(deleted_at__isnull=True), + required=True, + allow_null=False, + ) + product_option = serializers.PrimaryKeyRelatedField( + queryset=Option.objects.filter(deleted_at__isnull=True), + required=True, + allow_null=True, + ) + custom_response = serializers.CharField(required=True, allow_null=True, allow_blank=True) + + class Meta: + list_serializer_class = InstanceListSerializer + fields = "__all__" + + @property + def validation_mode(self) -> OrderableCheckSerializerMode: + if not (mode := self.context.get("mode")): + return OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART + return mode + + @property + def group(self) -> OptionGroup | None: + group: OptionGroup | str | uuid.UUID | None = self.initial_data.get("product_option_group") + if not group: + return None + if isinstance(group, (str, uuid.UUID)): + return OptionGroup.objects.filter(pk=group).first() + return group + + def validate_product_option_group(self, group: OptionGroup) -> OptionGroup: + # 상품 옵션 중 필수 옵션이 매진된 경우 주문 불가능 + if not group.is_group_stock_available(): + raise serializers.ValidationError( + OptionGroupNotOrderableErrorMessages.SOLDOUT.format(group.product.name, group.name) + ) + + return group + + def validate_product_option(self, option: Option | None) -> Option | None: + user: UserExt | AnonymousUser = typing.cast(request.Request, self.context["request"]).user + if not isinstance(user, UserExt): + raise serializers.ValidationError(SignInErrorMessages.USER_NOT_SIGNED_IN) + + if not self.group or self.group.is_custom_response: + return None + + # 옵션 그룹이 custom_response를 받지 않는 경우, option이 선택되지 않았거나 옵션 그룹에 속하지 않은 경우 주문 불가능 + if not (option and option.group_id == self.group.id): + raise serializers.ValidationError(OptionGroupNotOrderableErrorMessages.OPTION_NOT_SELECTED) + + product: Product = option.group.product + if option.leftover_stock is not None: + # 옵션의 재고가 없는 경우 주문 불가능 + if option.leftover_stock <= 0: + raise serializers.ValidationError( + OptionNotOrderableErrorMessages.SOLDOUT.format(product.name, option.name) + ) + + # 고객이 옵션의 재고를 초과하여 옵션을 장바구니에 담으면 주문 불가능 + user_option_cart_included_count = option.get_user_taken_stock_count( + user=user, + include_cart=True, + include_purchased=False, + ) + match self.validation_mode: + case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: + user_option_cart_included_count += 1 + case OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + # 이미 재고 체크를 위에서 했으므로, 단일 주문의 경우 확인할 필요가 없습니다. + user_option_cart_included_count = 0 + case OrderableCheckSerializerMode.CHECKOUT_CART: + pass + case _: + raise ValueError("Invalid validation mode") + + if user_option_cart_included_count > option.leftover_stock: + raise serializers.ValidationError( + OptionNotOrderableErrorMessages.TOO_MUCH_CART_OPTION.format(product.name, option.name) + ) + + # 옵션의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능 + if option.max_quantity_per_user > 0: # 0 = 무제한 sentinel + match self.validation_mode: + case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: + user_option_taken_count = ( + option.get_user_taken_stock_count( + user=user, + include_cart=True, + include_purchased=True, + ) + + 1 + ) + case OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + user_option_taken_count = ( + option.get_user_taken_stock_count( + user=user, + include_cart=False, + include_purchased=True, + ) + + 1 + ) + case OrderableCheckSerializerMode.CHECKOUT_CART: + user_option_taken_count = option.get_user_taken_stock_count( + user=user, + include_cart=True, + include_purchased=True, + ) + case _: + raise ValueError("Invalid validation mode") + + if user_option_taken_count > option.max_quantity_per_user: + raise serializers.ValidationError( + OptionNotOrderableErrorMessages.ALREADY_ORDERED_TOO_MUCH.format(product.name, option.name) + ) + + return option + + def validate_custom_response(self, custom_response: str | None) -> str | None: + if not (self.group and self.group.is_custom_response): + return None + + # 옵션 그룹이 custom_response를 받는 경우, custom_response가 올바른 형식으로 입력되지 않은 경우 주문 불가능 + if self.group.custom_response_pattern and not re.match( + self.group.custom_response_pattern, custom_response or "" + ): + raise serializers.ValidationError(OptionGroupNotOrderableErrorMessages.CUSTOM_RESPONSE_PATTERN_MISMATCH) + + return custom_response diff --git a/app/shop/serializers/cart_validation/product.py b/app/shop/serializers/cart_validation/product.py new file mode 100644 index 0000000..296d1ba --- /dev/null +++ b/app/shop/serializers/cart_validation/product.py @@ -0,0 +1,276 @@ +import functools +import typing + +from core.const.shop_error_messages import ( + CriticalErrorMessages, + OptionGroupNotOrderableErrorMessages, + ProductNotOrderableErrorMessages, + SignInErrorMessages, +) +from core.serializer.nested_model_serializer import InstanceListSerializer +from core.util.dateutil import now_aware +from django.db import transaction +from rest_framework import request, serializers +from shop.order.models import Order, OrderProductOptionRelation, OrderProductRelation, SingleProductCart +from shop.product.models import Product +from shop.serializers.cart_validation._base import OrderableCheckSerializerMode +from shop.serializers.cart_validation.option import OptionOrderableCheckSerializer, OptionOrderableCheckTypedDict +from shop.serializers.cart_validation.tag import TagOrderableCheckSerializer +from user.models import UserExt + + +class ProductOrderableCheckBeforeValidationDataType(typing.TypedDict): + product: Product + options: list[OptionOrderableCheckTypedDict] + donation_price: typing.NotRequired[int] + + +class ProductOrderableCheckAfterValidationDataType(typing.TypedDict): + product: Product + options: list[OptionOrderableCheckTypedDict] + donation_price: int + + +class ProductOrderableCheckSerializer(serializers.ModelSerializer): + """ + 장바구니에 담긴 상품이 주문 가능한지 확인합니다. + 아래 조건에 해당될 경우 주문 불가능합니다. + + ==================== 상품(Product) ==================== + - 판매 시작일 & 판매 종료일 사이가 아닌 경우 주문 불가능 + - 상품의 재고가 없는 경우 주문 불가능 + - 상품의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능 + - 고객이 상품의 재고를 초과하여 상품을 장바구니에 담으면 주문 불가능 + - 후원 가능 상품일 경우에만, 후원 가능 금액 범위 내에서 후원 금액을 입력받을 수 있음 + - 후원 금액 포함 단일 상품 금액이 0원 미만인 경우 주문 불가능 + - 상품이 0원이거나 0원을 별도로 허용한 경우를 제외하면, 후원 금액 포함 단일 상품 금액이 0원인 경우 주문 불가능 + - 후원 금액 포함 단일 상품 금액이 100만원 이상인 경우 주문 불가능 + + ==================== 상품군 (Tag) ==================== + - 상품군이 주문 불가능한 경우 주문 불가능 + + ==================== 상품 옵션 (Option) ==================== + - 상품 옵션 중 필수 옵션이 매진된 경우 주문 불가능 + - 옵션의 필수 선택 수량이 충족되지 않은 경우 주문 불가능 + - 옵션의 최대 선택 수량을 초과한 경우 주문 불가능 + - 선택한 상품 옵션들 중 주문 불가능한 옵션이 있는 경우 주문 불가능 + """ + + product = serializers.PrimaryKeyRelatedField( + queryset=Product.objects.filter(deleted_at__isnull=True), + required=True, + allow_null=False, + ) + options = OptionOrderableCheckSerializer(many=True, required=True, allow_empty=True, allow_null=False) + donation_price = serializers.IntegerField(min_value=0, required=False) + + class Meta: + model = OrderProductRelation + list_serializer_class = InstanceListSerializer + fields = ("product", "options", "donation_price") + + @functools.cached_property + def validation_mode(self) -> OrderableCheckSerializerMode: + if not (mode := self.context.get("mode")): + return OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART + return mode + + @functools.cached_property + def request(self) -> request.Request: + return typing.cast(request.Request, self.context["request"]) + + @functools.cached_property + def user(self) -> UserExt: + if not isinstance(self.request.user, UserExt): + raise serializers.ValidationError(SignInErrorMessages.USER_NOT_SIGNED_IN) + + return self.request.user + + @property + def is_free_product_allowed(self) -> bool: + return self.context.get("is_free_product_allowed", False) + + def validate_product(self, product: Product) -> Product: + # 판매 시작일 & 판매 종료일 사이가 아닌 경우 주문 불가능 + now = now_aware() + if not (product.orderable_starts_at <= now <= product.orderable_ends_at): + raise serializers.ValidationError(ProductNotOrderableErrorMessages.NOT_ORDERABLE_TIME.format(product.name)) + + if product.leftover_stock is not None: + # 상품의 재고가 없는 경우 주문 불가능 + if product.leftover_stock <= 0: + raise serializers.ValidationError(ProductNotOrderableErrorMessages.SOLDOUT.format(product.name)) + + # 고객이 상품의 재고를 초과하여 상품을 장바구니에 담으면 주문 불가능 + user_product_cart_included_count = product.get_user_taken_stock_count( + user=self.user, + include_cart=True, + include_purchased=False, + ) + match self.validation_mode: + case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: + user_product_cart_included_count += 1 + case OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + # 이미 재고 체크를 위에서 했으므로, 단일 주문의 경우 확인할 필요가 없습니다. + user_product_cart_included_count = 0 + case OrderableCheckSerializerMode.CHECKOUT_CART: + pass + case _: + raise ValueError("Invalid validation mode") + + if user_product_cart_included_count > product.leftover_stock: + raise serializers.ValidationError( + ProductNotOrderableErrorMessages.TOO_MUCH_CART_PRODUCT.format(product.name) + ) + + # 상품의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 담거나 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능 + if product.max_quantity_per_user > 0: # 0 = 무제한 sentinel + match self.validation_mode: + case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: + user_product_taken_count = ( + product.get_user_taken_stock_count( + user=self.user, + include_cart=True, + include_purchased=True, + ) + + 1 + ) + case OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + user_product_taken_count = ( + product.get_user_taken_stock_count( + user=self.user, + include_cart=False, + include_purchased=True, + ) + + 1 + ) + case OrderableCheckSerializerMode.CHECKOUT_CART: + user_product_taken_count = product.get_user_taken_stock_count( + user=self.user, + include_cart=True, + include_purchased=True, + ) + case _: + raise ValueError("Invalid validation mode") + + if user_product_taken_count > product.max_quantity_per_user: + raise serializers.ValidationError( + ProductNotOrderableErrorMessages.ALREADY_ORDERED_TOO_MUCH.format(product.name) + ) + + # 상품군이 주문 불가능한 경우 주문 불가능 + tags = [tag_rel.tag for tag_rel in product.tags.select_related("tag").all()] + TagOrderableCheckSerializer( + instance=tags, + data=[{} for _ in tags], + context=self.context, + many=True, + partial=True, + ).is_valid(raise_exception=True) + + return product + + def validate( + self, data: ProductOrderableCheckBeforeValidationDataType + ) -> ProductOrderableCheckAfterValidationDataType: + product: Product = data["product"] + options: list[OptionOrderableCheckTypedDict] = data["options"] + donation_price: int = data.get("donation_price", 0) + + if any(o["product_option_group"].product != product for o in options): + raise serializers.ValidationError( + OptionGroupNotOrderableErrorMessages.OPTION_NOT_MATCH_PRODUCT.format(product.name) + ) + + for group in product.option_groups.all(): + option_selected_count = len([o for o in options if o["product_option_group"] == group]) + # 옵션의 필수 선택 수량이 충족되지 않은 경우 주문 불가능 + if group.min_quantity_per_product and group.min_quantity_per_product > option_selected_count: + raise serializers.ValidationError( + OptionGroupNotOrderableErrorMessages.NOT_ENOUGH_OPTION.format(product.name, group.name) + ) + # 옵션의 최대 선택 수량을 초과한 경우 주문 불가능 + if group.max_quantity_per_product and option_selected_count > group.max_quantity_per_product: + raise serializers.ValidationError( + OptionGroupNotOrderableErrorMessages.TOO_MUCH_OPTION.format(product.name, group.name) + ) + + # 후원 가능 상품일 경우에만, 후원 가능 금액 범위 내에서 후원 금액을 입력받을 수 있음 + if donation_price: + if not product.donation_allowed: + raise serializers.ValidationError( + ProductNotOrderableErrorMessages.DONATION_NOT_ALLOWED.format(product.name) + ) + if not (product.donation_min_price <= donation_price <= product.donation_max_price): + raise serializers.ValidationError( + ProductNotOrderableErrorMessages.DONATION_PRICE_OUT_OF_RANGE.format( + product.name, + product.donation_min_price, + product.donation_max_price, + ) + ) + + # 후원 금액 포함 단일 상품 금액이 0원 미만인 경우 주문 불가능 + total_price = ( + product.price + + donation_price + + sum(o["product_option"].additional_price for o in options if o["product_option"]) + ) + if total_price < 0: + raise serializers.ValidationError(ProductNotOrderableErrorMessages.PRICE_IS_MINUS) + + # 상품이 0원이거나 0원을 별도로 허용한 경우를 제외하면, 후원 금액 포함 단일 상품 금액이 0원인 경우 주문 불가능 + elif not (self.is_free_product_allowed or product.price == 0) and total_price == 0: + raise serializers.ValidationError(ProductNotOrderableErrorMessages.PRICE_TOO_LOW) + + # 후원 금액 포함 단일 상품 금액이 100만원 이상인 경우 주문 불가능 + if total_price >= 1_000_000: + raise serializers.ValidationError(ProductNotOrderableErrorMessages.PRICE_TOO_HIGH) + + return typing.cast(ProductOrderableCheckAfterValidationDataType, data | {"donation_price": donation_price}) + + @transaction.atomic + def create(self, validated_data: ProductOrderableCheckAfterValidationDataType) -> OrderProductRelation: + product: Product = validated_data["product"] + + # - 만약 단일 상품을 장바구니에 담는 경우(validation_mode == ADD_SINGLE_PRODUCT_TO_CART)라면, 장바구니에 해당 상품을 담습니다. + # 이때, 먼저 유저에 결제하지 않은 Order(장바구니)가 있는지 확인하고, 없으면 생성합니다. + # - 단일 상품을 바로 결제하는 경우(validation_mode == CHECKOUT_SINGLE_PRODUCT)라면, + # 먼저 OrderProductRelation을 생성 후, SingleProductCart를 생성하면서 해당 상품과 연결합니다. + option_price = sum( + option_group_data["product_option"].additional_price + for option_group_data in validated_data["options"] + if option_group_data["product_option"] + ) + order_product_rel_create_kwargs = { + "product": product, + "price": product.price + option_price, + "donation_price": validated_data.get("donation_price", 0), + } + + if self.validation_mode == OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: + if not (cart := Order.objects.filter(user=self.user).filter_has_no_payment_histories().first()): + cart = Order.objects.create(user=self.user) + order_product_rel_create_kwargs["order"] = cart + elif self.validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + pass + else: + raise serializers.ValidationError( + CriticalErrorMessages.INVALID_LOGIC.format("주문 검증 후 호출되면 안 되는 로직이 호출되었습니다.") + ) + + order_product_rel = OrderProductRelation.objects.create(**order_product_rel_create_kwargs) + + if self.validation_mode == OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + SingleProductCart.objects.create(user=self.user, order_product_relation=order_product_rel) + + # 카트에 담은 상품에 대한 옵션 정보를 저장합니다. + for option_group_data in validated_data["options"]: + OrderProductOptionRelation.objects.create( + order_product_relation=order_product_rel, + product_option_group=option_group_data["product_option_group"], + product_option=option_group_data["product_option"], + custom_response=option_group_data["custom_response"], + ) + + return order_product_rel diff --git a/app/shop/serializers/cart_validation/single_product_cart.py b/app/shop/serializers/cart_validation/single_product_cart.py new file mode 100644 index 0000000..1908c76 --- /dev/null +++ b/app/shop/serializers/cart_validation/single_product_cart.py @@ -0,0 +1,44 @@ +import functools +import typing + +from django.db import transaction +from shop.order.models import CustomerInfo, OrderProductRelation +from shop.serializers.cart_validation._base import CustomerInfoCheckSerializer, OrderableCheckSerializerMode +from shop.serializers.cart_validation.product import ( + ProductOrderableCheckAfterValidationDataType, + ProductOrderableCheckSerializer, +) + + +class CustomerInfoType(typing.TypedDict): + name: str + phone: str + email: str + organization: str + + +class SingleProductCartOrderableCheckDataType(ProductOrderableCheckAfterValidationDataType): + customer_info: CustomerInfoType + + +class SingleProductCartOrderableCheckSerializer(ProductOrderableCheckSerializer): + customer_info = CustomerInfoCheckSerializer(required=True) + + class Meta(ProductOrderableCheckSerializer.Meta): + fields = ProductOrderableCheckSerializer.Meta.fields + ("customer_info",) # type: ignore[assignment] + + @functools.cached_property + def validation_mode(self) -> OrderableCheckSerializerMode: + return OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT + + @transaction.atomic + def create( # type: ignore[override] + self, + validated_data: SingleProductCartOrderableCheckDataType, # type: ignore[arg-type] + ) -> OrderProductRelation: + order_product_rel = super().create(validated_data) + assert (single_product_cart := order_product_rel.single_product_cart) # nosec: B101 + + CustomerInfo.objects.create(**validated_data["customer_info"], single_product_cart=single_product_cart) + + return order_product_rel diff --git a/app/shop/serializers/cart_validation/tag.py b/app/shop/serializers/cart_validation/tag.py new file mode 100644 index 0000000..824e610 --- /dev/null +++ b/app/shop/serializers/cart_validation/tag.py @@ -0,0 +1,81 @@ +import functools +import typing + +from core.const.shop_error_messages import SignInErrorMessages, TagNotOrderableErrorMessages +from core.serializer.nested_model_serializer import InstanceListSerializer +from rest_framework import request, serializers +from shop.product.models import Tag +from shop.serializers.cart_validation._base import OrderableCheckSerializerMode +from user.models import UserExt + + +class TagOrderableCheckSerializer(serializers.ModelSerializer): + """ + 태그가 주문 가능한지 확인합니다. + 아래 조건에 해당될 경우 주문 불가능합니다. + - 태그의 재고가 없는 경우 주문 불가능 + - 태그의 최대 구매 수량이 정해져있으면, 해당 사용자가 이미 구매한 수량이 최대 구매 수량을 초과하는 경우 주문 불가능 + """ + + class Meta: + model = Tag + list_serializer_class = InstanceListSerializer + fields = "__all__" + + @functools.cached_property + def validation_mode(self) -> OrderableCheckSerializerMode: + if not (mode := self.context.get("mode")): + return OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART + return mode + + @functools.cached_property + def request(self) -> request.Request: + return typing.cast(request.Request, self.context["request"]) + + @functools.cached_property + def user(self) -> UserExt: + if not isinstance(self.request.user, UserExt): + raise serializers.ValidationError(SignInErrorMessages.USER_NOT_SIGNED_IN) + + return self.request.user + + def validate(self, data: dict) -> dict: + tag = typing.cast(Tag, self.instance) + if tag.leftover_stock is not None and tag.leftover_stock <= 0: + raise serializers.ValidationError(TagNotOrderableErrorMessages.SOLDOUT.format(tag.name)) + + if tag.max_quantity_per_user > 0: # 0 = 무제한 sentinel + match self.validation_mode: + case OrderableCheckSerializerMode.ADD_SINGLE_PRODUCT_TO_CART: + user_tagproduct_taken_count = ( + tag.get_user_taken_stock_count( + user=self.user, + include_cart=True, + include_purchased=True, + ) + + 1 + ) + case OrderableCheckSerializerMode.CHECKOUT_SINGLE_PRODUCT: + user_tagproduct_taken_count = ( + tag.get_user_taken_stock_count( + user=self.user, + include_cart=False, + include_purchased=True, + ) + + 1 + ) + case OrderableCheckSerializerMode.CHECKOUT_CART: + user_tagproduct_taken_count = tag.get_user_taken_stock_count( + user=self.user, + include_cart=True, + include_purchased=True, + ) + case _: + raise ValueError("Invalid validation mode") + + if user_tagproduct_taken_count > tag.max_quantity_per_user: + raise serializers.ValidationError( + TagNotOrderableErrorMessages.ALREADY_ORDERED_TOO_MUCH_RELATED_PRODUCTS.format(tag.name) + ) + + return data diff --git a/app/shop/serializers/refund.py b/app/shop/serializers/refund.py new file mode 100644 index 0000000..a3a7664 --- /dev/null +++ b/app/shop/serializers/refund.py @@ -0,0 +1,188 @@ +import functools +import typing +import uuid + +from core.const.shop_error_messages import NotRefundableErrorMessages, PermissionErrorMessages +from core.external_apis.portone.client import portone_client +from core.util.totp import TOTPInfo +from django.conf import settings +from django.db import transaction +from rest_framework import serializers +from shop.order.models import Order, OrderProductRelation +from shop.payment_history.models import PaymentHistory, PaymentHistoryStatus +from shop.product.models import Product +from simple_history.utils import bulk_update_with_history + + +def _check_totp(context: dict, totp_value: str | None) -> None: + if not context.get("check_totp", True): + return + totp = totp_value or "" + if not totp: + raise serializers.ValidationError({"totp": [PermissionErrorMessages.OTP_REQUIRED]}) + if not (totp.isdigit() and TOTPInfo(key=settings.SHOP.refund_authorizer_secret_key.encode()).check(totp)): + raise serializers.ValidationError({"totp": [PermissionErrorMessages.INVALID_OTP_CODE]}) + + +class OrderTotalRefundSerializerAttributeType(typing.TypedDict): + id: str | uuid.UUID + totp: typing.NotRequired[str] + + +class OrderTotalRefundSerializer(serializers.ModelSerializer): + """ + Order의 사용 및 환불하지 않은 상품을 refunded 상태로 변경하고, 결제 취소를 요청합니다. + 아래의 경우에는 ValidationError를 발생시킵니다. + - 주문에 PortOne ID가 없는 경우 (보통 결제가 완료되지 않았거나 주문 불러오기로 생성한 주문인 경우입니다.) + - 이미 사용한 상품이 있는 경우 + - 환불할 상품이 없는 경우 + - 환불할 금액이 없는 경우 + - 환불할 금액이 음수인 경우 + - 환불할 금액이 남은 결제 금액과 일치하지 않는 경우 + - 환불 가능한 일자를 지난 상품이 있는 경우 + + Context: + - check_refundable_date (default True): 환불 가능 일자를 지난 상품이 있어도 환불 허용 시 False. + - check_totp (default True): TOTP 검증 강제 여부. False 시 totp 입력 무시. + """ + + totp = serializers.CharField(required=False, allow_blank=True, allow_null=True, write_only=True) + + class Meta: + model = Order + fields = ("id", "totp") + + @functools.cached_property + def refund_target_prod_rels(self) -> list[OrderProductRelation]: + return list( + typing.cast(Order, self.instance).products.filter(status=OrderProductRelation.OrderProductStatus.paid) + ) + + @functools.cached_property + def expected_refund_price(self) -> int: + if not self.refund_target_prod_rels: + return 0 + return sum(prod.price + prod.donation_price for prod in self.refund_target_prod_rels) + + def validate(self, attrs: OrderTotalRefundSerializerAttributeType) -> OrderTotalRefundSerializerAttributeType: + _check_totp(self.context, attrs.get("totp")) + + check_refundable_date = self.context.get("check_refundable_date", True) + order: Order = typing.cast(Order, self.instance) + if reason := order.not_fully_refundable_reason: + if not ( + reason == NotRefundableErrorMessages.ONE_OF_PRODUCT_REFUND_TIME_EXPIRED and not check_refundable_date + ): + raise serializers.ValidationError(reason) + + return attrs + + @transaction.atomic + def refund(self) -> None: + # Order 를 aggregate root 로 lock — 동일 Order 의 동시 refund (total/partial) 직렬화. + self.instance = Order.objects.select_for_update().get(id=typing.cast(Order, self.instance).id) + for attr in ("refund_target_prod_rels", "expected_refund_price"): + self.__dict__.pop(attr, None) + + # validate() 가 lock 전 stale 상태를 봤을 수 있어 lock 후 invariant 재검사. + order = typing.cast(Order, self.instance) + if reason := order.not_fully_refundable_reason: + check_refundable_date = self.context.get("check_refundable_date", True) + if not ( + reason == NotRefundableErrorMessages.ONE_OF_PRODUCT_REFUND_TIME_EXPIRED and not check_refundable_date + ): + raise serializers.ValidationError(reason) + + portone_client.req_cancel_payment( + merchant_id=str(order.id), + refund_request_price=self.expected_refund_price, + current_leftover_price=self.expected_refund_price, + ) + + for rel in self.refund_target_prod_rels: + rel.status = OrderProductRelation.OrderProductStatus.refunded + bulk_update_with_history(self.refund_target_prod_rels, OrderProductRelation, fields=["status"]) + + PaymentHistory.objects.create( + order=order, + imp_id=order.latest_imp_id, + status=PaymentHistoryStatus.refunded, + price=0, + ) + + +class OrderProductRefundSerializerAttributeType(typing.TypedDict): + id: str | uuid.UUID + totp: typing.NotRequired[str] + + +class OrderProductRefundSerializer(serializers.ModelSerializer): + """ + 주문에서 특정 상품에 대한 부분 환불을 진행합니다. + 아래의 경우에는 ValidationError를 발생시킵니다. + - 주문에 PortOne ID가 없는 경우 (보통 결제가 완료되지 않았거나 주문 불러오기로 생성한 주문인 경우입니다.) + - 이미 사용했거나 결제 전, 환불된 상품인 경우 + - 환불 가능한 일자를 지난 상품이 있는 경우 + - 환불 금액이 없는 경우 + + Context: + - check_refundable_date (default True): 환불 가능 일자를 지난 상품이어도 환불 허용 시 False. + - check_totp (default True): TOTP 검증 강제 여부. False 시 totp 입력 무시. + """ + + totp = serializers.CharField(required=False, allow_blank=True, allow_null=True, write_only=True) + + class Meta: + model = OrderProductRelation + fields = ("id", "totp") + + @functools.cached_property + def product(self) -> Product: + return typing.cast(OrderProductRelation, self.instance).product + + def validate(self, attrs: OrderProductRefundSerializerAttributeType) -> OrderProductRefundSerializerAttributeType: + _check_totp(self.context, attrs.get("totp")) + + check_refundable_date = self.context.get("check_refundable_date", True) + order_product_rel = typing.cast(OrderProductRelation, self.instance) + + if reason := order_product_rel.not_refundable_reason: + if not (reason == NotRefundableErrorMessages.PRODUCT_REFUND_TIME_EXPIRED and not check_refundable_date): + raise serializers.ValidationError(reason) + + return attrs + + @transaction.atomic + def refund(self) -> None: + order_product_rel = typing.cast(OrderProductRelation, self.instance) + # 부모 Order 를 aggregate root 로 lock — 같은 Order 의 동시 refund 직렬화. + order = Order.objects.select_for_update().get(id=order_product_rel.order_id) + order_product_rel.refresh_from_db() + + # validate() 가 lock 전 stale 상태를 봤을 수 있어 lock 후 invariant 재검사. + if reason := order_product_rel.not_refundable_reason: + check_refundable_date = self.context.get("check_refundable_date", True) + if not (reason == NotRefundableErrorMessages.PRODUCT_REFUND_TIME_EXPIRED and not check_refundable_date): + raise serializers.ValidationError(reason) + + refund_request_price = order_product_rel.price + order_product_rel.donation_price + before_leftover_price = order.current_paid_price + after_leftover_price = before_leftover_price - refund_request_price + + portone_client.req_cancel_payment( + merchant_id=str(order.id), + refund_request_price=refund_request_price, + current_leftover_price=before_leftover_price, + ) + + order_product_rel.status = OrderProductRelation.OrderProductStatus.refunded + order_product_rel.save() + + active_statuses = (OrderProductRelation.OrderProductStatus.paid, OrderProductRelation.OrderProductStatus.used) + next_status = ( + PaymentHistoryStatus.partial_refunded + if OrderProductRelation.objects.filter(order_id=order.id, status__in=active_statuses).exists() + else PaymentHistoryStatus.refunded + ) + imp_id = order.latest_imp_id + PaymentHistory.objects.create(order=order, imp_id=imp_id, status=next_status, price=after_leftover_price) diff --git a/app/user/migrations/0009_alter_historicaluserext_options_and_more.py b/app/user/migrations/0009_alter_historicaluserext_options_and_more.py new file mode 100644 index 0000000..fa6e321 --- /dev/null +++ b/app/user/migrations/0009_alter_historicaluserext_options_and_more.py @@ -0,0 +1,74 @@ +# Generated by Django 6.0.4 on 2026-05-11 04:32 + +import uuid + +from django.db import migrations, models + + +def backfill_unique_id(apps, schema_editor): + UserExt = apps.get_model("user", "UserExt") + for user in UserExt.objects.filter(unique_id__isnull=True).only("id"): + UserExt.objects.filter(pk=user.pk).update(unique_id=uuid.uuid4()) + + +class Migration(migrations.Migration): + dependencies = [ + ("auth", "0012_alter_user_first_name_max_length"), + ("file", "0001_initial"), + ("user", "0008_remove_userext_uq__userext__nickname_and_more"), + ] + operations = [ + migrations.AlterModelOptions( + name="historicaluserext", + options={ + "get_latest_by": ("history_date", "history_id"), + "ordering": ("-history_date", "-history_id"), + "verbose_name": "historical user", + "verbose_name_plural": "historical users", + }, + ), + migrations.AlterModelOptions( + name="userext", + options={"ordering": ["-date_joined"], "verbose_name": "user", "verbose_name_plural": "users"}, + ), + # historicaluserext 는 unique 가 없으므로 한 번에 추가 가능 (default=uuid4 callable 은 INSERT 시 평가) + migrations.AddField( + model_name="historicaluserext", + name="unique_id", + field=models.UUIDField(db_index=True, default=uuid.uuid4, editable=False), + ), + # userext.unique_id 는 unique=True 라 3단계로 추가: + # (1) default 없는 nullable 컬럼 (PostgreSQL 이 ALTER TABLE 시 single literal 로 default 평가해 모든 행을 같은 값으로 채우는 문제 회피) + # (2) RunPython 으로 행마다 uuid4 backfill + # (3) NOT NULL + UNIQUE + default=uuid4 적용 + migrations.AddField( + model_name="userext", + name="unique_id", + field=models.UUIDField(editable=False, null=True), + ), + migrations.RunPython(backfill_unique_id, migrations.RunPython.noop), + migrations.AlterField( + model_name="userext", + name="unique_id", + field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True), + ), + migrations.AddIndex( + model_name="userext", + index=models.Index(fields=["unique_id"], name="userext_unique_id_idx"), + ), + # PR A 베이스라인부터 누적된 modeltranslation constraint 이름 패턴 변경 (`*_name_ko` → `*-ko`) + migrations.RemoveConstraint(model_name="organization", name="uq__org__name-name_ko"), + migrations.RemoveConstraint(model_name="organization", name="uq__org__name-name_en"), + migrations.AddConstraint( + model_name="organization", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("name_ko",), name="uq__org__name-ko" + ), + ), + migrations.AddConstraint( + model_name="organization", + constraint=models.UniqueConstraint( + condition=models.Q(("deleted_at__isnull", True)), fields=("name_en",), name="uq__org__name-en" + ), + ), + ] diff --git a/app/user/models/user.py b/app/user/models/user.py index 85f1674..9ff665b 100644 --- a/app/user/models/user.py +++ b/app/user/models/user.py @@ -1,16 +1,24 @@ from __future__ import annotations +from uuid import uuid4 + from core.const.system import SYSTEM_EMAIL, SYSTEM_USERNAME +from core.scancode_mixin import ScanCodeMixin from django.contrib.auth.models import AbstractUser from django.db import models -class UserExt(AbstractUser): +class UserExt(ScanCodeMixin, AbstractUser): + scancode_prefix = "user" + scancode_uuid_field = "unique_id" + image = models.ForeignKey("file.PublicFile", on_delete=models.PROTECT, null=True, blank=True) nickname = models.CharField(max_length=128, null=True, blank=True) + unique_id = models.UUIDField(unique=True, editable=False, null=False, blank=False, default=uuid4) - class Meta: + class Meta(AbstractUser.Meta): ordering = ["-date_joined"] + indexes = [models.Index(fields=["unique_id"], name="userext_unique_id_idx")] def __str__(self): return f"[User] {self.nickname} <{self.email}>" diff --git a/docs/migration_payment.md b/docs/migration_payment.md new file mode 100644 index 0000000..2934a40 --- /dev/null +++ b/docs/migration_payment.md @@ -0,0 +1,622 @@ +# `python-korea-payment` → `backend` 통합 plan + +## 1. 개요 + +`/Users/musoftware/workspace_pycon/python-korea-payment`(이하 `payment`)을 본 +`backend` 저장소의 sub-app으로 흡수한다. 양쪽 모두 같은 손이 만든 Django 5/6 +프로젝트라 호환성은 매우 높고, 데이터는 **동일 host의 다른 database**로 분리되어 +있으며 현재 `payment`에는 신규 데이터가 들어가지 않는 상태다. + +## 2. 결정 사항 (확정) + +| 항목 | 결정 | +|---|---| +| 흡수 위치 | `app/shop/`(부모는 빈 패키지) 하위로 `order`, `product`, `payment_history` sub-app | +| `purchase_shared` 처리 | 별도 앱으로 두지 않고 `core`(인프라)와 `shop`(도메인)로 분해 흡수 | +| `BaseAbstractModel` | `core.models.BaseAbstractModel` 단일 사용. payment 코드는 import 교체만 | +| Django admin | 드롭. `admin_api` viewset으로 재구현 | +| 데이터 이관 | 동일 host 내 `pg_dump`/`psql` 작업 (database 분리이므로 cross-DB INSERT 불가) | +| 마이그레이션 | 신규 마이그레이션을 작성하고 cutover 시 `migrate --fake`로 history 정합 | +| `historical_*` | 통째 TRUNCATE 후 새로 시작 | +| User 모델 | backend `user.UserExt`로 병합 (payment 측 데이터를 흡수). `unique_id` 필드 추가 필요 | +| Allauth | 도입. 양쪽 쿠키/세션 정책은 backend 기준으로 통일 | +| Celery/Redis | 이미 이관 완료. 별도 작업 없음 | +| `shop_*` 테이블 prefix | **불필요** — `shop.order` AppConfig은 `app_label="order"`로 자동 생성되어 db_table은 `order_*` 그대로 유지 (event.presentation 패턴과 동일) | + +## 3. 최종 구조 + +``` +app/ +├── core/ +│ ├── external_apis/ +│ │ ├── slack/ # 기존 +│ │ ├── nhn_cloud_*.py # 기존 +│ │ ├── smtp_email.py # 기존 +│ │ └── portone/ # ← purchase_shared/external_apis/portone +│ ├── permissions/ +│ │ └── api_key.py # ← purchase_shared/auth/api_key.py +│ ├── const/ +│ │ ├── regex.py # 기존 + ALLOW_ALL/EMAIL/PHONE 추가 (← purchase_shared/consts/regex.py) +│ │ ├── tag.py # 기존 + SHOP_*/EXT_* OpenAPITag 추가 (← purchase_shared/consts/tag.py) +│ │ └── shop_error_messages.py # ← purchase_shared/consts/error_messages.py (이름 명확화) +│ ├── serializer/ +│ │ └── nested_model_serializer.py # ← purchase_shared/serializers/common.py (InstanceListSerializer, NestedModelSerializer) +│ ├── util/ +│ │ ├── strutil.py # ← purchase_shared/utils/str_utils.py (uuid_to_b64, b64_to_uuid). UUID regex 는 core/const/regex 재사용 +│ │ ├── totp.py # ← purchase_shared/utils/totp.py +│ │ ├── grouper.py # ← purchase_shared/utils/django.py (grouper, query_grouper) +│ │ └── thread_local.py # 기존 +│ └── models.py # 기존 (BaseAbstractModel 단일 출처) +├── shop/ +│ ├── __init__.py # 빈 패키지 +│ ├── serializers/ +│ │ ├── cart_validation.py # ← purchase_shared/serializers/cart_validation.py +│ │ └── refund.py +│ ├── order/ +│ │ ├── apps.py # name="shop.order" +│ │ ├── models.py +│ │ ├── views/, serializers/, urls.py, translation.py +│ ├── product/ +│ │ ├── apps.py # name="shop.product" +│ │ └── ... +│ ├── payment_history/ +│ │ ├── apps.py # name="shop.payment_history" +│ │ └── ... # PortOne webhook viewset 포함 +│ └── external_api/ # 외부에서 호출되는 API (sub-app 아닌 모듈 묶음) +│ ├── filters.py # desk_support, patron filterset +│ ├── serializers.py # desk_support, patron serializer +│ ├── views.py # DeskSupportExternalAPIViewSet, PatronExternalAPIViewSet +│ └── urls.py # v1/external-api/{desk-support,patron}/ 로 노출 +├── admin_api/ +│ ├── views/ +│ │ ├── shop_orders.py # 신규 (admin.py 의 actions를 viewset으로) +│ │ ├── shop_products.py +│ │ ├── shop_payment_histories.py +│ │ └── shop_refund_authorizer.py # TOTP +│ ├── serializers/, urls.py, ... +└── user/ # 기존 + payment 측 user 흡수 + └── models/ + └── user.py # UserExt 에 unique_id 필드 추가 + scancode 메서드 이식 +``` + +## 4. `purchase_shared` 분해 매핑 + +| from | to | 비고 | +|---|---|---| +| `purchase_shared/models.py` | **폐기** | `core.models` 사용 | +| `purchase_shared/auth/api_key.py` | `core/permissions/api_key.py` | | +| `purchase_shared/consts/error_messages.py` | `core/const/shop_error_messages.py` | 파일명에 `shop_` 명시. PR A 의 `INVALID_API_KEY_MESSAGE` inline 정의도 그대로 유지 | +| `purchase_shared/consts/regex.py` | `core/const/regex.py` 에 병합 | `ALLOW_ALL`, `EMAIL`, `PHONE` 추가 | +| `purchase_shared/consts/tag.py` | `core/const/tag.py` 에 병합 | `OpenAPITag.SHOP_*` (USER/PRODUCT/CART/ORDER/ORDER_REFUND/PORTONE_WEBHOOK), `EXT_REGISTRATION_DESK_API`, `EXT_PATRON_API` 추가 | +| `purchase_shared/external_apis/portone/` | `core/external_apis/portone/` | | +| `purchase_shared/external_apis/slack/` | **폐기** | backend에 동일 모듈 존재 | +| `purchase_shared/middleware/request_response_logger.py` | **폐기** | backend에 존재 | +| `purchase_shared/logger/` | **폐기** | backend에 존재 | +| `purchase_shared/utils/django.py` | `core/util/grouper.py` | `grouper`, `query_grouper` 함수 둘 다 | +| `purchase_shared/utils/openapi.py` | `core/openapi/` 합치기 | backend 동명 모듈 충돌 확인 | +| `purchase_shared/utils/str_utils.py` | `core/util/strutil.py` | `UUID_PATTERN`/`UUID_REGEX` 정의는 `core/const/regex.py` 의 `UUID_V4_*` 로 통합 (사용처는 `from core.const.regex import UUID_V4_REGEX`). 파일명은 backend `dateutil.py` 컨벤션 따름 | +| `purchase_shared/utils/totp.py` | `core/util/totp.py` | | +| `purchase_shared/serializers/common.py` | `core/serializer/nested_model_serializer.py` | `InstanceListSerializer`, `NestedModelSerializer` (도메인 무관) | +| `purchase_shared/serializers/{cart_validation,refund}.py` | `shop/serializers/*` | shop 도메인 로직 | +| `purchase_shared/serializers/notification.py` | **폐기** | NotiCo SQS 직접 호출 흐름 폐기 예정. PR C 에서 backend `notification` 앱 (Celery + 템플릿 시스템) 활용해 재구현 | +| `purchase_shared/admin_views/`, `admin_urls.py`, `templates/` | **폐기** | admin_api로 재구현 | +| `purchase_shared/apps.py` | **폐기** | `purchase_shared` 자체 사라짐 | + +## 5. UserExt 비교 결과 및 병합 전략 + +### 5.1 코드 차원 차이 + +| 항목 | backend | payment | +|---|---|---| +| 상속 | `AbstractUser` | `AbstractUser` | +| PK | `id` (BigAuto) | `id` (BigAuto) | +| 추가 필드 | `image` (FK→`file.PublicFile`), `nickname` | `unique_id` (UUIDField, unique) | +| 인덱스 | — | `userext_unique_id_idx` | +| simple-history | 적용 | 미적용 | +| 도메인 메서드 | `get_system_user()` | `scancode_*`, `purchased_orders`, `purchased_orders_in_last_six_months` | + +### 5.2 병합 후 모델 (PR D에서 작업) + +backend `user.UserExt`에 다음 추가: +- `unique_id = models.UUIDField(unique=True, editable=False, default=uuid4)` +- 인덱스 `userext_unique_id_idx` +- payment의 scancode 메서드 4개 + `purchased_orders` 2개 이식 +- 기존 backend user 행은 마이그레이션 시 `default=uuid4`로 자동 채움 + +### 5.3 검증 결과 (확정) + +PR D 작업 전 양쪽 DB에서 사전 검증한 결과: + +**양쪽 user 통계** + +| | payment (`pyconkr_purchase_prod_db`) | backend (`pyconkr_2025_prod_db`) | +|---|---|---| +| total | 2,299 | 72 | +| min_id ~ max_id | 1 ~ 2,299 | 0 ~ 75 | +| empty_email | 0 | 0 | +| duplicate_email | 4 | 0 | + +**backend `id=0` = SYSTEM_USER** (`system@python.or.kr`, super). 침범 금지. + +**email 매칭 (도메인 정규화 `@pycon.kr` → `@python.or.kr` 적용)** + +| | 값 | +|---|---| +| 자동 매칭 distinct email | 42 | +| 자동 매칭 payment row | 45 | +| 1:N 매칭 케이스 | 3 (`cpprhtn`, `emscb`, `ppiyakk2`) | + +**도메인 분포 (payment)**: `@pycon.kr` 12 / `@python.or.kr` 16 / `@gmail.com` 1,170 + +**확정 정책**: +- `@pycon.kr` ↔ `@python.or.kr` 동일인 (내부 운영자) → 자동 매칭 시 정규화로 처리 +- 그 외 도메인(`@gmail.com` 등) → 자동 매칭 안 함. 동일인은 매뉴얼 매핑 테이블에 명시 +- 매뉴얼 매핑 1건 확정: `darjeeling@gmail.com` (payment id 5, 1135) → backend `darjeeling@python.or.kr` + +### 5.4 cutover 시 user 병합 절차 + +오프셋: `OFFSET = MAX(backend.id) + 100 = 175`. payment.id 1~2,299 → 새 id 176~2,474. backend 0~75과 충돌 없음. + +```sql +-- payment DB에서 실행 (dump 직전) +-- 매핑 테이블 생성 + +CREATE TABLE _user_id_mapping ( + payment_id INT PRIMARY KEY, + backend_id INT NOT NULL, + source TEXT NOT NULL -- 'auto' | 'manual' | 'shifted' +); + +-- (1) 자동 매칭: 정규화 후 email JOIN +-- backend (email, id) 페어 72개는 Appendix A 참조 +INSERT INTO _user_id_mapping (payment_id, backend_id, source) +SELECT u.id, be.backend_id, 'auto' +FROM user_userext u +JOIN (VALUES + -- ⬇ Appendix A 의 72개 페어를 그대로 붙여넣음 + ('system@python.or.kr', 0), + ('musoftware@python.or.kr', 1), + -- ... + ('session@email.com', 75) +) AS be(email, backend_id) + ON LOWER(REGEXP_REPLACE(u.email, '@pycon\.kr$', '@python.or.kr')) = LOWER(be.email); + +-- (2) 매뉴얼 매핑 (darjeeling@gmail.com → backend darjeeling@python.or.kr id=5) +INSERT INTO _user_id_mapping (payment_id, backend_id, source) +VALUES + (5, 5, 'manual'), + (1135, 5, 'manual'); + +-- (3) 매핑 안 된 나머지 → PK 시프트 +INSERT INTO _user_id_mapping (payment_id, backend_id, source) +SELECT p.id, p.id + 175, 'shifted' +FROM user_userext p +WHERE p.id NOT IN (SELECT payment_id FROM _user_id_mapping); + +-- (4) 정합성 검증 +SELECT source, COUNT(*) FROM _user_id_mapping GROUP BY source; +-- 기대: auto=45, manual=2, shifted=2252, 합계=2299 +``` + +**dump 변환 흐름**: +1. `_user_id_mapping` 을 활용해 payment dump의 다음 컬럼을 모두 갱신 + - `user_userext.id`, `auth_user_groups.user_id`, `auth_user_user_permissions.user_id` + - 모든 shop 테이블의 `user_id`, `created_by_id`, `updated_by_id`, `deleted_by_id` +2. `source IN ('auto', 'manual')` 인 payment user는 새 user INSERT 안 함 (이미 backend에 있음) +3. `source = 'shifted'` 인 payment user (2,252건)만 backend `user_userext` 에 INSERT +4. backend `user_userext.image_id`, `user_userext.nickname` 컬럼은 NULL로 채움 (payment 측에 없는 컬럼) +5. `user_userext.unique_id` 는 payment 측 값 그대로 (PR D에서 추가된 신규 컬럼이지만 payment 측에도 동일 컬럼 존재) + +## 6. 단계별 작업 (PR 5개) + +각 PR은 단독 머지 가능해야 하며 머지 후에도 기존 backend 기능은 그대로 동작해야 +한다. cutover 직전까지 `shop`은 비활성 상태로 둔다. + +### PR A — 의존성·settings + `core` 인프라 흡수 + +**범위** +- `pyproject.toml`: `django-allauth[openid,socialaccount]`, `shortuuid`, `cryptography`, 기타 누락분 추가 +- `core/settings.py`: `PORTONE_*`, `NHN_KCP_*`, `ORDER_SCANCODE_SALT`, `REFUND_AUTHORIZER_SECRET_KEY`, `EXT_API_KEYS`, `SHOP_DOMAIN`, allauth 관련 설정 추가 (단 `INSTALLED_APPS`에 allauth 등록은 PR D에서) +- `envfile/.env.local` 샘플 갱신 +- `core/external_apis/portone/` 추가 +- `core/permissions/api_key.py` 추가 +- `core/util/str_utils.py`, `core/util/totp.py`, `core/util/query_grouper.py` 추가 +- `core/openapi/` 의 기존 모듈과 payment `utils/openapi.py` 충돌 확인 후 병합 + +**검증** +- `python manage.py check` 통과 +- 기존 backend 테스트 회귀 통과 +- shell에서 `from core.external_apis.portone.client import portone_client` 등 import 동작 확인 + +### PR B — `shop` sub-app + URL/view + 템플릿 + +**범위** +- `app/shop/__init__.py` (빈 패키지) +- `shop/const/`, `shop/serializers/` 추가 +- `shop/order/`, `shop/product/`, `shop/payment_history/` 디렉토리에 payment 코드 이식 + - 모든 모델의 `BaseAbstractModel` import를 `core.models`로 교체 + - `purchase_shared.*` import 전부 새 위치로 갱신 + - `SingleProductCart.to_order()` 의 `self.delete()` → `SingleProductCart.objects.filter(id=self.id).hard_delete()` 로 의도적 hard delete 명시화 + - **settings 참조 갱신** (PR A 에서 namespace 화 됨): + - `settings.SHOP_API_DOMAIN` / `settings.SHOP_DOMAIN` → `settings.BACKEND_DOMAIN` + (사용처 4건: `order/serializers/dto.py` 2회, `purchase_shared/serializers/notification.py`, `user/serializers.py`) + - `settings.PORTONE_API_URL` / `PORTONE_IMP_KEY` / `PORTONE_IMP_SECRET` / `PORTONE_IP_LIST` → `settings.PORTONE.{api_url,imp_key,imp_secret,ip_list}` + - `settings.NHN_KCP_PG_API_CERT` / `PRIVATE_KEY` / `PASSWORD` → `settings.NHN_KCP.{pg_api_cert,pg_api_private_key,pg_api_password}` + (PR A 에서 PEM header/footer 자동 wrapping 제거됨 → 환경변수에 PEM 형식 그대로 넣어야 함) + - `settings.ORDER_SCANCODE_SALT` → `settings.SHOP.order_scancode_salt` + - `settings.REFUND_AUTHORIZER_SECRET_KEY` → `settings.SHOP.refund_authorizer_secret_key` + - **import 경로 갱신** (PR A 통합 결과 반영): + - `from purchase_shared.utils.str_utils import UUID_REGEX` → `from core.const.regex import UUID_V4_REGEX as UUID_REGEX` (또는 사용처에서 직접 `UUID_V4_REGEX` 사용) + - `from purchase_shared.utils.openapi import build_html_responses` → `from core.openapi.schemas import build_html_responses` +- 각 sub-app `apps.py`: `name="shop.order"` 등 +- `INSTALLED_APPS`에 `shop.order`, `shop.product`, `shop.payment_history` 추가 +- 마이그레이션 신규 생성 (`makemigrations`) +- SYSTEM_USER 별도 seeding 불필요 — backend 운영 DB에 이미 존재 (id=0). dev/staging fresh DB 는 `core.util.thread_local.get_current_user()` 가 SYSTEM_USER 없으면 `None` 반환 (audit FK 가 null=True 라 OK), 또는 `UserExt.get_system_user()` 의 lazy `get_or_create` 패턴 활용 +- `core/urls.py`의 `v1_apis`에 라우팅 추가 (별도 namespace 없이 v1 직속, 다른 sub-app 컨벤션과 일관): + - `v1/shop/orders/`, `v1/shop/products/`, `v1/shop/payment-histories/` + - reverse 경로는 `v1:` (예: `v1:orders-retrieve-scancode`) +- `shop/order/templates/` 의 사용자용 HTML 이식 (scancode_*.html, receipt_kcp.html — admin 템플릿은 제외) +- PortOne webhook 은 `payment_history/views.py` 에 정의되어 있으므로 `shop.payment_history` 로 자연스럽게 이전됨 (별도 작업 없음) +- `bad_response_slack_logger` import 경로: `purchase_shared.logger.util.decorator` → `core.logger.util.decorator` (backend 동일 정의 존재) +- **shop/external_api/ (`desk_support` + `patron`) 는 PR D 로 이동** — `UserExt.unique_id` 의존이 있어 UserExt 갱신과 함께 들어가야 함 + +**검증** +- `python manage.py makemigrations` 재실행 시 0개 변경 +- `python manage.py migrate` 로컬 통과 +- `python manage.py show_urls` 에서 `v1/shop/orders/`, `v1/shop/products/` 노출 +- shell: `Order.objects.create(user=u, name="t")` 호출 시 `created_by`가 thread-local 의 인증 user / SYSTEM_USER (있으면) / `None` 으로 채워짐 +- swagger UI에서 신규 endpoint 노출 확인 + +### PR C — `admin_api` shop endpoints + +**범위** +- `admin_api/views/shop_orders.py`: list/retrieve/patch + refund + send_notification + bulk_send_notification + import_template + import + export +- `admin_api/views/shop_products.py`: Product/Category/CategoryGroup/Tag CRUD (+ OptionGroup/Option nested) +- `admin_api/views/shop_payment_histories.py`: read-only +- `admin_api/views/shop_refund_authorizer.py`: TOTP setup_qr + verify +- viewset에서 `shop/serializers/refund.py`, payment의 `imports.py`, `exports.py` 그대로 재사용 +- TOTP 검증을 환불 endpoint 권한 클래스로 부착 +- `admin_api/urls.py`에 라우팅 추가 +- **알림 발송 (`send_notification`, `bulk_send_notification`)**: payment 의 `NotiCoMessageSerializer` (SQS) 흐름은 폐기. backend `notification` 앱 (Celery + 템플릿 시스템) 사용해 재구현. payment 의 `from_orders` 컨텍스트 빌드 로직은 참고만 하고 신규 작성 + +**검증** +- staging에서 골든패스 dry-run: 주문 조회 → 환불 → PaymentHistory 갱신 확인 (sandbox 키) +- CSV 가져오기/내보내기 1건 검증 +- TOTP setup → verify → refund 흐름 1회 통과 + +### PR D — User 병합 + Allauth 통합 + UserExt 모델 갱신 + +**범위** +- `user.UserExt` 모델 갱신: + - `unique_id` 필드 추가 + - 인덱스 추가 + - scancode 메서드 4개 (`scancode_token`, `scancode_path`, `from_short_unique_id`, `from_scancode_token`) 이식 + - `purchased_orders`, `purchased_orders_in_last_six_months` 이식 + - 기존 backend user 행에 `unique_id` 자동 채우는 마이그레이션 +- `INSTALLED_APPS`에 `allauth`, `allauth.account`, `allauth.headless`, `allauth.socialaccount`, 12개 provider 추가 +- `MIDDLEWARE`에 `allauth.account.middleware.AccountMiddleware` 추가 +- `AUTHENTICATION_BACKENDS`에 allauth + `core.permissions.api_key.APIKeyAuthentication` 추가 +- `ACCOUNT_*`, `SOCIALACCOUNT_*`, `HEADLESS_*` 설정 활성화 +- 쿠키 정책 충돌 확인 (`COOKIE_PREFIX`, `SESSION_COOKIE_*`, CSRF는 backend 기준 유지) +- `accounts/`, `authn/social/` URL 추가 +- `PASSWORD_HASHERS`에 Argon2 추가 +- payment 측 social adapter (`NoNewUsersAccountAdapter`, `SocialAccountLoggingAdapter`) 이식 +- `shop/external_api/` 추가 (PR B 에서 미뤄둔 `desk_support` + `patron`): + - viewset, serializer, filterset 이식 + - `UserExt.unique_id` 사용처 활성화 (`SimpleUserDeskSupportDto.fields` 의 `unique_id`, `DeskSupportExternalAPIFilterSet.user_unique_id`) + - `core/urls.py` 에 `v1/external-api/desk-support/`, `v1/external-api/patron/` 라우팅 추가 + +**검증** +- 로컬에서 Google OAuth 로그인 1회 통과 +- 기존 backend Django 기본 login 회귀 동작 확인 +- shell: 임의 user의 `scancode_token`/`purchased_orders` 동작 확인 +- swagger UI 에서 `external-api/desk-support`, `external-api/patron` 노출 확인 + +**비고**: 데이터 차원의 user 병합은 cutover 단계에서 SQL로 진행 + +### PR E — 테스트 정비 + cutover 문서/SQL + +**범위** +- payment 측에 있던 단위 테스트들 위치 이동 후 동작 확인 +- 신규 admin_api endpoint 통합 테스트 작성 (최소 골든패스) +- PortOne mock fixture 정리 +- `docs/cutover_payment.md` 작성: 아래 §7 절차를 그대로 옮김 +- `infra/sql/cutover_payment.sql` 등 SQL 스니펫 +- 운영 절차 (PortOne webhook URL 변경, allowlist 등) 체크리스트화 + +**검증** +- `pytest` 로컬 통과 +- CI 통과 +- §7.0 사전 dry-run 1회 성공 (모든 검증 통과 기준 충족, 실측값 기록 완료) + +## 7. Cutover 절차 (database 분리 기준) + +전제: PR A~E 머지 완료, **§7.0 dry-run 1회 이상 성공**. + +### 7.0 사전 dry-run 검증 (production 적용 전 필수) + +production payment/backend DB 를 손대기 전, 동일 절차를 격리 환경에서 1회 이상 +완주하여 결과를 사전 검증한다. + +**환경 준비** + +| 항목 | 권장 | +|---|---| +| dry-run용 payment DB | production payment DB 의 **read-only 복제 또는 dump 복원본** | +| dry-run용 backend DB | production backend DB 의 **dump 복원본** (별도 인스턴스 / 별도 schema) | +| 실행 위치 | 로컬 또는 staging. **production 자격증명은 사용 금지** | +| PortOne | sandbox 키 사용 | + +**수행할 단계**: 아래 §7.1 ~ §7.7 을 그대로 1회 완주. + +**검증 통과 기준** + +| 항목 | 기대값 / 확인 방법 | +|---|---| +| 매핑 테이블 분포 | `_user_id_mapping` source 별: `auto=45`, `manual=2`, `shifted=2252`, 합계 `2299` | +| 변환 후 user_userext INSERT 행 수 | 2,252 | +| 변환 후 shop 테이블 user FK 의 distinct 값 | 모두 backend `user_userext` 에 존재해야 함 | +| `python manage.py makemigrations` | 0 건 변경 (모델과 스키마가 일치) | +| `python manage.py check` | 통과 | +| `historical_*` 테이블 | 모두 0 row | +| 골든패스 1 — 주문 조회 | 임의 payment 주문이 새 admin_api 로 조회 가능 | +| 골든패스 2 — sandbox 환불 | 환불 1건 성공, `PaymentHistory` 신규 row 생성 | +| 골든패스 3 — scancode URL | reverse 결과가 `v1/shop/orders/...` 형식이고 200 응답 | +| 1:N 매핑 FK 정합성 | `cpprhtn`, `emscb`, `ppiyakk2` 의 payment 시절 주문이 backend 의 단일 user 로 모두 귀속됨 | + +**기록**: dry-run 실행 결과 (각 검증 항목의 실측값) 를 PR E 또는 별도 issue 에 기록. +실패 항목이 있으면 plan/스크립트 보강 후 재실행. + +**소요 시간**: 1회 완주 약 1~2시간 (dump 추출/적용이 대부분). + +### 7.1 사전 점검 + +```sql +-- 양쪽 DB에서 §5.3의 검증 쿼리 1회 더 실행 +-- payment 측 신규 가입자가 정말 없는지 (frozen 상태 재확인): MAX(id), COUNT(*) 변동 없음 확인 +-- backend 측 staff 추가가 있었다면 매핑 테이블 갱신 +``` + +### 7.2 user 매핑 테이블 생성 (payment DB) + +§5.4의 SQL을 그대로 실행. `_user_id_mapping` 의 source 분포가 +`auto=45, manual=2, shifted=2252` 인지 확인. + +### 7.3 데이터 dump (payment DB) + +```bash +# payment DB에서 shop 도메인 + user만 dump (data-only, --inserts로 멱등성 확보) +pg_dump --data-only --inserts \ + -t order_order \ + -t order_orderproductrelation \ + -t order_orderproductoptionrelation \ + -t order_singleproductcart \ + -t order_customerinfo \ + -t product_product \ + -t product_category \ + -t product_categorygroup \ + -t product_tag \ + -t product_producttagrelation \ + -t product_optiongroup \ + -t product_option \ + -t payment_history_paymenthistory \ + -t user_userext \ + -t auth_user_groups -t auth_user_user_permissions \ + -h -U -d \ + > /tmp/payment_dump.sql + +# (history 테이블은 dump 대상에서 제외 — backend에서 새로 생성, TRUNCATE 상태로 시작) +``` + +### 7.4 dump 변환 (`_user_id_mapping` 적용) + +권장: PR E에 `scripts/cutover_transform.py` 스크립트로 보관. + +```python +# 1) payment DB에서 _user_id_mapping export +mapping = {row.payment_id: (row.backend_id, row.source) for row in fetchall()} + +# 2) /tmp/payment_dump.sql 의 INSERT 문 파싱 +# - user_userext: source ∈ {'auto', 'manual'} 인 payment_id 의 row 는 건너뜀 +# - user_userext: source == 'shifted' 인 row 만 mapping[id]=backend_id 로 치환 후 보존 +# - shop 테이블의 user_id / created_by_id / updated_by_id / deleted_by_id 는 +# mapping 으로 일괄 치환 +# - auth_user_groups.user_id, auth_user_user_permissions.user_id 도 동일 + +# 3) /tmp/payment_dump_transformed.sql 로 출력 +``` + +검증 포인트: +- 변환 전후 row 수: user_userext 만 2,299 → 2,252 (auto 45 + manual 2 = 47 감소) +- 변환 후 INSERT 문에 등장하는 user_id 의 distinct 개수가 backend 신규 + 매핑된 backend id 합과 일치 + +### 7.5 backend DB에 적용 + +```bash +# 0. 다운타임 시작 (backend 측만 — payment는 이미 frozen) + +# 1. dump 적용 (변환된 SQL) +psql -h -U -d < /tmp/payment_dump_transformed.sql + +# 2. Django 마이그레이션 history 정합 (테이블은 dump로 이미 생성/채워짐) +python manage.py migrate --fake shop_order +python manage.py migrate --fake shop_product +python manage.py migrate --fake shop_payment_history + +# 3. simple-history 테이블 비우기 (이미 빈 상태일 텐데 보험) +psql -h -U -d -c " + TRUNCATE order_historicalorderproductrelation, + order_historicalcustomerinfo, + order_historicalsingleproductcart, + product_historicalcategory, + product_historicalcategorygroup, + product_historicalproducttagrelation, + product_historicaloptiongroup + RESTART IDENTITY; +" + +# 4. 정합성 검증 +python manage.py shell -c " +from shop.order.models import Order +from shop.payment_history.models import PaymentHistory +print(f'Orders: {Order.objects.count()}') +print(f'PaymentHistories: {PaymentHistory.objects.count()}') +" +python manage.py check +``` + +### 7.6 PortOne / 프론트엔드 전환 + +- PortOne 콘솔에서 webhook URL을 `https://shop-api.pycon.kr/...` 에서 + `https://rest-api.pycon.kr/v1/shop/...` 로 변경 +- PortOne IP allowlist 가 backend 서버 IP를 포함하는지 확인 +- 프론트엔드 (shop) 의 API base URL 도 backend로 전환 (배포는 별도) + +### 7.7 골든패스 검증 + +- 어드민에서 임의 주문 조회 / sandbox 환불 dry-run +- 사용자 페이지에서 scancode/receipt URL 1건 확인 + +### 7.8 롤백 + +문제 발생 시: +- backend 측 `pg_restore --clean` 으로 dump 적용 전 상태 복구 +- `migrate --fake zero shop_*` 로 마이그레이션 history 정리 +- PortOne webhook URL 원복 +- payment 측 데이터는 손대지 않았으므로 그대로 살아 있음 + +## 8. 위험 요소 및 대응 + +| 위험 | 영향 | 대응 | +|---|---|---| +| Allauth 쿠키/세션 정책이 backend 기존 정책과 충돌 | 로그인 회귀 | PR D 머지 전 staging에서 양쪽 로그인 플로우 모두 검증 | +| `core.openapi`와 `purchase_shared.utils.openapi` 충돌 | 빌드 실패 | PR A에서 한 번에 정리, diff 확인 | +| `migrate --fake` 후 `makemigrations`가 dirty diff 생성 | 마이그레이션 깨짐 | PR B에서 모델 정의를 payment와 100% 일치시키고 sandbox에서 사전 검증 | +| PortOne webhook IP allowlist | 환불 webhook 실패 | cutover 전 PortOne 콘솔에서 backend 서버 IP 등록 확인 | +| User PK 충돌 / email duplicate | INSERT 실패 또는 잘못된 매핑 | §5.3 검증 쿼리 결과 확인 후 변환 스크립트 보강 | +| simple-history `history_user_id` 가 가리키는 user 누락 | TRUNCATE으로 회피 (어차피 비울 예정) | 별도 대응 불필요 | +| `unique_id` 중복 (extremely 낮은 확률) | UNIQUE constraint 위반 | dump 변환 시 backend 기존 unique_id와 교집합 검사 → 충돌 시 재생성 | +| pg_dump의 INSERT 순서가 FK 의존성을 위반 | 적용 실패 | `pg_dump --disable-triggers` 또는 명시적 `SET session_replication_role = replica;` 사용 | +| 변환 스크립트 버그가 production 에서 발견 | 데이터 손상, 롤백 비용 | §7.0 dry-run 절차로 사전 차단. dry-run 실패 시 production 진입 금지 | + +## 9. 작업 추정 + +| PR | 시간 | 난이도 | +|---|---|---| +| PR A 의존성·settings·core 인프라 | 1일 | 쉬움 | +| PR B shop sub-app + URL + 템플릿 + BaseAbstractModel 통합 | 1.5일 | 중간 | +| PR C admin_api endpoints | 1.5~2일 | 중간 | +| PR D User 병합 + Allauth + UserExt 갱신 | 1일 | 중간 | +| PR E 테스트·cutover 문서·SQL 변환 스크립트 | 1일 | 중간 | +| Cutover 실행 (다운타임) | 0.25~0.5일 | 중간 | +| **합계** | **6.25~7.25일** | **쉬움~중간** | + +## 10. 진행 순서 + +PR A → PR B → (PR C ⊥ PR D 병행 가능) → PR E → Cutover. + +PR C와 PR D는 PR B 완료 후 동시 진행 가능 (의존성 없음). + +## Appendix A — backend `user_userext` (email, id) 페어 + +PR D 작업 / cutover 시점의 backend (`pyconkr_2025_prod_db`) staff/user 72명. §5.4의 +자동 매칭 SQL `VALUES` 절에 그대로 사용. cutover 직전에 한 번 더 backend DB에서 +재추출하여 변동분이 있으면 갱신할 것. + +```sql +SELECT '(' || quote_literal(email) || ', ' || id || '),' +FROM user_userext ORDER BY id; +``` + +``` +('system@python.or.kr', 0), +('musoftware@python.or.kr', 1), +('aineok0227@python.or.kr', 2), +('soyoung@python.or.kr', 3), +('jaehyuck.sa@python.or.kr', 4), +('darjeeling@python.or.kr', 5), +('golony6449@python.or.kr', 6), +('klou@python.or.kr', 7), +('lye@python.or.kr', 8), +('hanlee@python.or.kr', 9), +('cpprhtn@python.or.kr', 10), +('bluepicture08@python.or.kr', 11), +('steve.lee.dev@python.or.kr', 12), +('sudosubin@python.or.kr', 13), +('jungmir@python.or.kr', 14), +('jkyoon@python.or.kr', 15), +('pysong218@python.or.kr', 16), +('tiaz@python.or.kr', 17), +('hexff0000@python.or.kr', 18), +('kwanok@python.or.kr', 19), +('smkim12@python.or.kr', 20), +('hanuri714@python.or.kr', 21), +('hanjoo0211@python.or.kr', 22), +('simple-is-great@python.or.kr', 23), +('youn7054@python.or.kr', 24), +('sl@python.or.kr', 25), +('ppiyakk2@python.or.kr', 26), +('joongi@lablup.com', 27), +('importyha@gmail.com', 28), +('madsyntst@gmail.com', 29), +('bbchip13@gmail.com', 32), +('djccnt15@gmail.com', 33), +('channprj@gmail.com', 34), +('joeunpark@gmail.com', 35), +('oymggg@gmail.com', 36), +('yesys7777@gmail.com', 37), +('byundojin0216@gmail.com', 38), +('tmp_2@example.com', 39), +('nicebug@naver.com', 40), +('sytyactfhaha@gmail.com', 41), +('kyungjunlee.me@gmail.com', 42), +('ca3rot@gmail.com', 43), +('ksw@sionic.ai', 45), +('allen.k1m@kakaocorp.com', 46), +('world@worldsw.dev', 47), +('kdh1834@hufs.ac.kr', 48), +('jaewon.james.choi@gmail.com', 49), +('o3omoomin@gmail.com', 50), +('suitbread@gmail.com', 51), +('hightwinkle@naver.com', 52), +('s2460@e-mirim.hs.kr', 53), +('tmp_1@example.com', 54), +('jaeyeol.lee@hey.com', 55), +('nnoadev@gmail.com', 56), +('is9117@me.com', 57), +('gurwls223@apache.org', 58), +('hyewon.k.developer@gmail.com', 59), +('emscb@python.or.kr', 61), +('sniper45han@gmail.com', 62), +('donghee.na@python.org', 63), +('me@pyhub.kr', 64), +('haesunrpark@gmail.com', 65), +('krisnawatimelisa@gmail.com', 66), +('younghyun7248@gmail.com', 67), +('2chaes@gmail.com', 68), +('yssong@lablup.com', 69), +('jskang@lablup.com', 70), +('yonghoch@amazon.com', 71), +('yechoi@amazon.com', 72), +('bien@daangn.com', 73), +('johan@daangn.com', 74), +('session@email.com', 75) +``` + +note: id `30, 31, 44, 60` 은 결번 (이전 삭제). PK 시프트 offset 175 와 충돌하지 +않으므로 영향 없음. + +## Appendix B — 매뉴얼 매핑 테이블 + +도메인 변경 등으로 자동 매칭에서 누락된 동일인 매핑. + +| payment_id | payment_email | payment_username | backend_id | backend_email | 비고 | +|---|---|---|---|---|---| +| 5 | darjeeling@gmail.com | kwon-han | 5 | darjeeling@python.or.kr | 동일인 (운영자 도메인 변경) | +| 1135 | darjeeling@gmail.com | darjeeling | 5 | darjeeling@python.or.kr | 동일인 (위와 같은 사람의 또 다른 계정) | + +추가 케이스가 발생하면 cutover 전에 이 표에 행을 추가하고 §5.4의 (2) 매뉴얼 매핑 INSERT 에 반영할 것. + +--- + +**다음 단계**: 본 plan에 대한 검토 후 OK 신호를 받으면 PR A 부터 작업을 시작한다. diff --git a/envfile/.env.local b/envfile/.env.local index 40abd7c..b01b8c1 100644 --- a/envfile/.env.local +++ b/envfile/.env.local @@ -11,3 +11,13 @@ IS_LOCAL=True REDIS_PORT=46379 CELERY_BROKER_URL=redis://127.0.0.1:46379/0 CELERY_RESULT_BACKEND=redis://127.0.0.1:46379/1 + +# Shop / PortOne / NHN KCP +# PORTONE_IMP_KEY= +# PORTONE_IMP_SECRET= +# NHN_KCP_PG_API_CERT= +# NHN_KCP_PG_API_PRIVATE_KEY= +# NHN_KCP_PG_API_PASSWORD= +# ORDER_SCANCODE_SALT= +# REFUND_AUTHORIZER_SECRET_KEY= +# API_KEY_REGISTRATION_DESK= diff --git a/pyproject.toml b/pyproject.toml index 621bd42..3a8e799 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ dependencies = [ "argon2-cffi>=25.1.0", "boto3 (>=1.37.37,<2.0.0)", "celery[redis]>=5.5.0", + "cryptography>=43.0.0", "django>=6.0.4", + "django-allauth[openid,socialaccount]>=65.0.0", "django-constance>=4.3.5", "django-cors-headers>=4.9.0", "django-environ>=0.13.0", @@ -30,10 +32,12 @@ dependencies = [ "jinja2>=3.1.6", "model-bakery>=1.23.4", "packaging>=26.0", + "pandas[excel, performance]>=2.2.3", "psycopg[binary]>=3.3.3", "py-openapi-schema-to-json-schema>=0.0.3", "sentry-sdk[django]>=2.57.0", "setuptools>=82.0.1", + "shortuuid>=1.0.13", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 986b2a1..d9855ce 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,11 @@ version = 1 revision = 3 requires-python = "==3.12.*" +resolution-markers = [ + "sys_platform == 'win32'", + "sys_platform == 'emscripten'", + "sys_platform != 'emscripten' and sys_platform != 'win32'", +] [[package]] name = "amqp" @@ -95,7 +100,9 @@ dependencies = [ { name = "argon2-cffi" }, { name = "boto3" }, { name = "celery", extra = ["redis"] }, + { name = "cryptography" }, { name = "django" }, + { name = "django-allauth", extra = ["openid", "socialaccount"] }, { name = "django-constance" }, { name = "django-cors-headers" }, { name = "django-environ" }, @@ -117,10 +124,12 @@ dependencies = [ { name = "jinja2" }, { name = "model-bakery" }, { name = "packaging" }, + { name = "pandas", extra = ["excel", "performance"] }, { name = "psycopg", extra = ["binary"] }, { name = "py-openapi-schema-to-json-schema" }, { name = "sentry-sdk", extra = ["django"] }, { name = "setuptools" }, + { name = "shortuuid" }, ] [package.dev-dependencies] @@ -143,7 +152,9 @@ requires-dist = [ { name = "argon2-cffi", specifier = ">=25.1.0" }, { name = "boto3", specifier = ">=1.37.37,<2.0.0" }, { name = "celery", extras = ["redis"], specifier = ">=5.5.0" }, + { name = "cryptography", specifier = ">=43.0.0" }, { name = "django", specifier = ">=6.0.4" }, + { name = "django-allauth", extras = ["openid", "socialaccount"], specifier = ">=65.0.0" }, { name = "django-constance", specifier = ">=4.3.5" }, { name = "django-cors-headers", specifier = ">=4.9.0" }, { name = "django-environ", specifier = ">=0.13.0" }, @@ -165,10 +176,12 @@ requires-dist = [ { name = "jinja2", specifier = ">=3.1.6" }, { name = "model-bakery", specifier = ">=1.23.4" }, { name = "packaging", specifier = ">=26.0" }, + { name = "pandas", extras = ["excel", "performance"], specifier = ">=2.2.3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.3.3" }, { name = "py-openapi-schema-to-json-schema", specifier = ">=0.0.3" }, { name = "sentry-sdk", extras = ["django"], specifier = ">=2.57.0" }, { name = "setuptools", specifier = ">=82.0.1" }, + { name = "shortuuid", specifier = ">=1.0.13" }, ] [package.metadata.requires-dev] @@ -260,6 +273,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/76/cab7af7f16c0b09347f2ebe7ffda7101132f786acb767666dce43055faab/botocore_stubs-1.42.41-py3-none-any.whl", hash = "sha256:9423110fb0e391834bd2ed44ae5f879d8cb370a444703d966d30842ce2bcb5f0", size = 66759, upload-time = "2026-02-03T20:46:13.02Z" }, ] +[[package]] +name = "bottleneck" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/d8/6d641573e210768816023a64966d66463f2ce9fc9945fa03290c8a18f87c/bottleneck-1.6.0.tar.gz", hash = "sha256:028d46ee4b025ad9ab4d79924113816f825f62b17b87c9e1d0d8ce144a4a0e31", size = 104311, upload-time = "2025-09-08T16:30:38.617Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/72/7e3593a2a3dd69ec831a9981a7b1443647acb66a5aec34c1620a5f7f8498/bottleneck-1.6.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3bb16a16a86a655fdbb34df672109a8a227bb5f9c9cf5bb8ae400a639bc52fa3", size = 100515, upload-time = "2025-09-08T16:29:55.141Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d4/e7bbea08f4c0f0bab819d38c1a613da5f194fba7b19aae3e2b3a27e78886/bottleneck-1.6.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0fbf5d0787af9aee6cef4db9cdd14975ce24bd02e0cc30155a51411ebe2ff35f", size = 377451, upload-time = "2025-09-08T16:29:56.718Z" }, + { url = "https://files.pythonhosted.org/packages/fe/80/a6da430e3b1a12fd85f9fe90d3ad8fe9a527ecb046644c37b4b3f4baacfc/bottleneck-1.6.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d08966f4a22384862258940346a72087a6f7cebb19038fbf3a3f6690ee7fd39f", size = 368303, upload-time = "2025-09-08T16:29:57.834Z" }, + { url = "https://files.pythonhosted.org/packages/30/11/abd30a49f3251f4538430e5f876df96f2b39dabf49e05c5836820d2c31fe/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:604f0b898b43b7bc631c564630e936a8759d2d952641c8b02f71e31dbcd9deaa", size = 361232, upload-time = "2025-09-08T16:29:59.104Z" }, + { url = "https://files.pythonhosted.org/packages/1d/ac/1c0e09d8d92b9951f675bd42463ce76c3c3657b31c5bf53ca1f6dd9eccff/bottleneck-1.6.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d33720bad761e642abc18eda5f188ff2841191c9f63f9d0c052245decc0faeb9", size = 373234, upload-time = "2025-09-08T16:30:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ea/382c572ae3057ba885d484726bb63629d1f63abedf91c6cd23974eb35a9b/bottleneck-1.6.0-cp312-cp312-win32.whl", hash = "sha256:a1e5907ec2714efbe7075d9207b58c22ab6984a59102e4ecd78dced80dab8374", size = 108020, upload-time = "2025-09-08T16:30:01.773Z" }, + { url = "https://files.pythonhosted.org/packages/48/ad/d71da675eef85ac153eef5111ca0caa924548c9591da00939bcabba8de8e/bottleneck-1.6.0-cp312-cp312-win_amd64.whl", hash = "sha256:81e3822499f057a917b7d3972ebc631ac63c6bbcc79ad3542a66c4c40634e3a6", size = 113493, upload-time = "2025-09-08T16:30:02.872Z" }, +] + [[package]] name = "celery" version = "5.6.3" @@ -457,6 +488,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] +[[package]] +name = "defusedxml" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, +] + [[package]] name = "distlib" version = "0.4.0" @@ -480,6 +520,29 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/47/3d61d611609764aa71a37f7037b870e7bfb22937366974c4fd46cada7bab/django-6.0.4-py3-none-any.whl", hash = "sha256:14359c809fc16e8f81fd2b59d7d348e4d2d799da6840b10522b6edf7b8afc1da", size = 8368342, upload-time = "2026-04-07T13:55:37.999Z" }, ] +[[package]] +name = "django-allauth" +version = "65.16.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/df/357187dfff18c7783e4911827a6c69437e290d7259a32a99c23fcd85997f/django_allauth-65.16.1.tar.gz", hash = "sha256:4425ac3088541c4c54983e16e08f6e3eb9f438dc1b1009534fa51c8bb739ed31", size = 2232835, upload-time = "2026-04-17T18:53:59.475Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/58/d95b6c3088d83697bfd93782ee57bc6a6462e41eb19121a947b8a015396a/django_allauth-65.16.1-py3-none-any.whl", hash = "sha256:e49df24056bf37c44e56aaad1e51f78994b7d175bc3476d65e8f8f58390a8ce8", size = 2051868, upload-time = "2026-04-17T18:54:12.032Z" }, +] + +[package.optional-dependencies] +openid = [ + { name = "python3-openid" }, +] +socialaccount = [ + { name = "oauthlib" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, +] + [[package]] name = "django-constance" version = "4.3.5" @@ -690,6 +753,15 @@ openapi = [ { name = "inflection" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "executing" version = "2.2.1" @@ -1034,6 +1106,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/7c/c614252f9acda59b01a66e2ddfd243ed1c7e1deab0293332dfbccf862808/librt-0.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:0f2ae3725904f7377e11cc37722d5d401e8b3d5851fb9273d7f4fe04f6b3d37d", size = 52441, upload-time = "2026-02-17T16:11:56.801Z" }, ] +[[package]] +name = "llvmlite" +version = "0.47.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/48/4b7fe0e34c169fa2f12532916133e0b219d2823b540733651b34fdac509a/llvmlite-0.47.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:306a265f408c259067257a732c8e159284334018b4083a9e35f67d19792b164f", size = 37232769, upload-time = "2026-03-31T18:28:43.735Z" }, + { url = "https://files.pythonhosted.org/packages/e6/4b/e3f2cd17822cf772a4a51a0a8080b0032e6d37b2dbe8cfb724eac4e31c52/llvmlite-0.47.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5853bf26160857c0c2573415ff4efe01c4c651e59e2c55c2a088740acfee51cd", size = 56275178, upload-time = "2026-03-31T18:28:48.342Z" }, + { url = "https://files.pythonhosted.org/packages/b6/55/a3b4a543185305a9bdf3d9759d53646ed96e55e7dfd43f53e7a421b8fbae/llvmlite-0.47.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:003bcf7fa579e14db59c1a1e113f93ab8a06b56a4be31c7f08264d1d4072d077", size = 55128632, upload-time = "2026-03-31T18:28:52.901Z" }, + { url = "https://files.pythonhosted.org/packages/2f/f5/d281ae0f79378a5a91f308ea9fdb9f9cc068fddd09629edc0725a5a8fde1/llvmlite-0.47.0-cp312-cp312-win_amd64.whl", hash = "sha256:f3079f25bdc24cd9d27c4b2b5e68f5f60c4fdb7e8ad5ee2b9b006007558f9df7", size = 38138692, upload-time = "2026-03-31T18:28:57.147Z" }, +] + [[package]] name = "markupsafe" version = "3.0.3" @@ -1180,6 +1264,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] +[[package]] +name = "numba" +version = "0.65.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f6/c5/db2ac3685833d626c0dcae6bd2330cd68433e1fd248d15f70998160d3ad7/numba-0.65.1.tar.gz", hash = "sha256:19357146c32fe9ed25059ab915e8465fb13951cf6b0aace3826b76886373ab23", size = 2765600, upload-time = "2026-04-24T02:02:56.551Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/bc/76f8f8c5cf9adee47fdb7bbb03be8900f76f902d451d7477cf12b845e1de/numba-0.65.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:ac3f1e77c352dd0ea9712732c2d8f9ca507717435eec5b5013bf138ac33c4a08", size = 2681371, upload-time = "2026-04-24T02:02:26.105Z" }, + { url = "https://files.pythonhosted.org/packages/69/47/a415af0283e4db0398104c6d1c11c9861a98dc67a7aa442a7769ed5d6196/numba-0.65.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:52bc6f3ceb8fcaff9b2ae26b4c6b1e9fee39db8d355534c0fe4f39a901246b84", size = 3802467, upload-time = "2026-04-24T02:02:27.712Z" }, + { url = "https://files.pythonhosted.org/packages/46/36/246f73ec99cfeab2f2cb2ce7d4218766cc36a2da418901223f4f4da9c813/numba-0.65.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90ca10b3463bae0bd70589726fe3c77d01d6b5fc86bee54bcdf9fb6b47c28977", size = 3502628, upload-time = "2026-04-24T02:02:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/db/9e/3c679b2ee078425b9e99a91e44f8d132a6830d8ccce5227bc5e9181aeed8/numba-0.65.1-cp312-cp312-win_amd64.whl", hash = "sha256:5971c632be2a2351500431f46213821dba8d02b18a9f7d02fd36bd2743e41a6a", size = 2750611, upload-time = "2026-04-24T02:02:31.477Z" }, +] + +[[package]] +name = "numexpr" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cb/2f/fdba158c9dbe5caca9c3eca3eaffffb251f2fb8674bf8e2d0aed5f38d319/numexpr-2.14.1.tar.gz", hash = "sha256:4be00b1086c7b7a5c32e31558122b7b80243fe098579b170967da83f3152b48b", size = 119400, upload-time = "2025-10-13T16:17:27.351Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/20/c473fc04a371f5e2f8c5749e04505c13e7a8ede27c09e9f099b2ad6f43d6/numexpr-2.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:91ebae0ab18c799b0e6b8c5a8d11e1fa3848eb4011271d99848b297468a39430", size = 162790, upload-time = "2025-10-13T16:16:34.903Z" }, + { url = "https://files.pythonhosted.org/packages/45/93/b6760dd1904c2a498e5f43d1bb436f59383c3ddea3815f1461dfaa259373/numexpr-2.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47041f2f7b9e69498fb311af672ba914a60e6e6d804011caacb17d66f639e659", size = 152196, upload-time = "2025-10-13T16:16:36.593Z" }, + { url = "https://files.pythonhosted.org/packages/72/94/cc921e35593b820521e464cbbeaf8212bbdb07f16dc79fe283168df38195/numexpr-2.14.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d686dfb2c1382d9e6e0ee0b7647f943c1886dba3adbf606c625479f35f1956c1", size = 452468, upload-time = "2025-10-13T16:13:29.531Z" }, + { url = "https://files.pythonhosted.org/packages/d9/43/560e9ba23c02c904b5934496486d061bcb14cd3ebba2e3cf0e2dccb6c22b/numexpr-2.14.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee6d4fbbbc368e6cdd0772734d6249128d957b3b8ad47a100789009f4de7083", size = 443631, upload-time = "2025-10-13T16:15:02.473Z" }, + { url = "https://files.pythonhosted.org/packages/7b/6c/78f83b6219f61c2c22d71ab6e6c2d4e5d7381334c6c29b77204e59edb039/numexpr-2.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3a2839efa25f3c8d4133252ea7342d8f81226c7c4dda81f97a57e090b9d87a48", size = 1417670, upload-time = "2025-10-13T16:13:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/0e/bb/1ccc9dcaf46281568ce769888bf16294c40e98a5158e4b16c241de31d0d3/numexpr-2.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9f9137f1351b310436662b5dc6f4082a245efa8950c3b0d9008028df92fefb9b", size = 1466212, upload-time = "2025-10-13T16:15:12.828Z" }, + { url = "https://files.pythonhosted.org/packages/31/9f/203d82b9e39dadd91d64bca55b3c8ca432e981b822468dcef41a4418626b/numexpr-2.14.1-cp312-cp312-win32.whl", hash = "sha256:36f8d5c1bd1355df93b43d766790f9046cccfc1e32b7c6163f75bcde682cda07", size = 166996, upload-time = "2025-10-13T16:17:10.369Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/ffe750b5452eb66de788c34e7d21ec6d886abb4d7c43ad1dc88ceb3d998f/numexpr-2.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:fdd886f4b7dbaf167633ee396478f0d0aa58ea2f9e7ccc3c6431019623e8d68f", size = 160187, upload-time = "2025-10-13T16:17:11.974Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/9f/b8cef5bffa569759033adda9481211426f12f53299629b410340795c2514/numpy-2.4.4.tar.gz", hash = "sha256:2d390634c5182175533585cc89f3608a4682ccb173cc9bb940b2881c8d6f8fa0", size = 20731587, upload-time = "2026-03-29T13:22:01.298Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/05/32396bec30fb2263770ee910142f49c1476d08e8ad41abf8403806b520ce/numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:15716cfef24d3a9762e3acdf87e27f58dc823d1348f765bbea6bef8c639bfa1b", size = 16689272, upload-time = "2026-03-29T13:18:49.223Z" }, + { url = "https://files.pythonhosted.org/packages/c5/f3/a983d28637bfcd763a9c7aafdb6d5c0ebf3d487d1e1459ffdb57e2f01117/numpy-2.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23cbfd4c17357c81021f21540da84ee282b9c8fba38a03b7b9d09ba6b951421e", size = 14699573, upload-time = "2026-03-29T13:18:52.629Z" }, + { url = "https://files.pythonhosted.org/packages/9b/fd/e5ecca1e78c05106d98028114f5c00d3eddb41207686b2b7de3e477b0e22/numpy-2.4.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:8b3b60bb7cba2c8c81837661c488637eee696f59a877788a396d33150c35d842", size = 5204782, upload-time = "2026-03-29T13:18:55.579Z" }, + { url = "https://files.pythonhosted.org/packages/de/2f/702a4594413c1a8632092beae8aba00f1d67947389369b3777aed783fdca/numpy-2.4.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:e4a010c27ff6f210ff4c6ef34394cd61470d01014439b192ec22552ee867f2a8", size = 6552038, upload-time = "2026-03-29T13:18:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/7f/37/eed308a8f56cba4d1fdf467a4fc67ef4ff4bf1c888f5fc980481890104b1/numpy-2.4.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f9e75681b59ddaa5e659898085ae0eaea229d054f2ac0c7e563a62205a700121", size = 15670666, upload-time = "2026-03-29T13:19:00.341Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0d/0e3ecece05b7a7e87ab9fb587855548da437a061326fff64a223b6dcb78a/numpy-2.4.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:81f4a14bee47aec54f883e0cad2d73986640c1590eb9bfaaba7ad17394481e6e", size = 16645480, upload-time = "2026-03-29T13:19:03.63Z" }, + { url = "https://files.pythonhosted.org/packages/34/49/f2312c154b82a286758ee2f1743336d50651f8b5195db18cdb63675ff649/numpy-2.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62d6b0f03b694173f9fcb1fb317f7222fd0b0b103e784c6549f5e53a27718c44", size = 17020036, upload-time = "2026-03-29T13:19:07.428Z" }, + { url = "https://files.pythonhosted.org/packages/7b/e9/736d17bd77f1b0ec4f9901aaec129c00d59f5d84d5e79bba540ef12c2330/numpy-2.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fbc356aae7adf9e6336d336b9c8111d390a05df88f1805573ebb0807bd06fd1d", size = 18368643, upload-time = "2026-03-29T13:19:10.775Z" }, + { url = "https://files.pythonhosted.org/packages/63/f6/d417977c5f519b17c8a5c3bc9e8304b0908b0e21136fe43bf628a1343914/numpy-2.4.4-cp312-cp312-win32.whl", hash = "sha256:0d35aea54ad1d420c812bfa0385c71cd7cc5bcf7c65fed95fc2cd02fe8c79827", size = 5961117, upload-time = "2026-03-29T13:19:13.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/5b/e1deebf88ff431b01b7406ca3583ab2bbb90972bbe1c568732e49c844f7e/numpy-2.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:b5f0362dc928a6ecd9db58868fca5e48485205e3855957bdedea308f8672ea4a", size = 12320584, upload-time = "2026-03-29T13:19:16.155Z" }, + { url = "https://files.pythonhosted.org/packages/58/89/e4e856ac82a68c3ed64486a544977d0e7bdd18b8da75b78a577ca31c4395/numpy-2.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:846300f379b5b12cc769334464656bc882e0735d27d9726568bc932fdc49d5ec", size = 10221450, upload-time = "2026-03-29T13:19:18.994Z" }, +] + [[package]] name = "oauthlib" version = "3.3.1" @@ -1189,6 +1327,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, ] +[[package]] +name = "odfpy" +version = "1.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/97/73/8ade73f6749177003f7ce3304f524774adda96e6aaab30ea79fd8fda7934/odfpy-1.4.1.tar.gz", hash = "sha256:db766a6e59c5103212f3cc92ec8dd50a0f3a02790233ed0b52148b70d3c438ec", size = 717045, upload-time = "2020-01-18T16:55:48.852Z" } + +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -1198,6 +1357,42 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, ] +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/b0/c20bd4d6d3f736e6bd6b55794e9cd0a617b858eaad27c8f410ea05d953b7/pandas-3.0.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:232a70ebb568c0c4d2db4584f338c1577d81e3af63292208d615907b698a0f18", size = 10347921, upload-time = "2026-03-31T06:46:33.36Z" }, + { url = "https://files.pythonhosted.org/packages/35/d0/4831af68ce30cc2d03c697bea8450e3225a835ef497d0d70f31b8cdde965/pandas-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:970762605cff1ca0d3f71ed4f3a769ea8f85fc8e6348f6e110b8fea7e6eb5a14", size = 9888127, upload-time = "2026-03-31T06:46:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/61/a9/16ea9346e1fc4a96e2896242d9bc674764fb9049b0044c0132502f7a771e/pandas-3.0.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aff4e6f4d722e0652707d7bcb190c445fe58428500c6d16005b02401764b1b3d", size = 10399577, upload-time = "2026-03-31T06:46:39.224Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a8/3a61a721472959ab0ce865ef05d10b0d6bfe27ce8801c99f33d4fa996e65/pandas-3.0.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ef8b27695c3d3dc78403c9a7d5e59a62d5464a7e1123b4e0042763f7104dc74f", size = 10880030, upload-time = "2026-03-31T06:46:42.412Z" }, + { url = "https://files.pythonhosted.org/packages/da/65/7225c0ea4d6ce9cb2160a7fb7f39804871049f016e74782e5dade4d14109/pandas-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f8d68083e49e16b84734eb1a4dcae4259a75c90fb6e2251ab9a00b61120c06ab", size = 11409468, upload-time = "2026-03-31T06:46:45.2Z" }, + { url = "https://files.pythonhosted.org/packages/fa/5b/46e7c76032639f2132359b5cf4c785dd8cf9aea5ea64699eac752f02b9db/pandas-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:32cc41f310ebd4a296d93515fcac312216adfedb1894e879303987b8f1e2b97d", size = 11936381, upload-time = "2026-03-31T06:46:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/8b/721a9cff6fa6a91b162eb51019c6243b82b3226c71bb6c8ef4a9bd65cbc6/pandas-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:a4785e1d6547d8427c5208b748ae2efb64659a21bd82bf440d4262d02bfa02a4", size = 9744993, upload-time = "2026-03-31T06:46:51.488Z" }, + { url = "https://files.pythonhosted.org/packages/d5/18/7f0bd34ae27b28159aa80f2a6799f47fda34f7fb938a76e20c7b7fe3b200/pandas-3.0.2-cp312-cp312-win_arm64.whl", hash = "sha256:08504503f7101300107ecdc8df73658e4347586db5cfdadabc1592e9d7e7a0fd", size = 9056118, upload-time = "2026-03-31T06:46:54.548Z" }, +] + +[package.optional-dependencies] +excel = [ + { name = "odfpy" }, + { name = "openpyxl" }, + { name = "python-calamine" }, + { name = "pyxlsb" }, + { name = "xlrd" }, + { name = "xlsxwriter" }, +] +performance = [ + { name = "bottleneck" }, + { name = "numba" }, + { name = "numexpr" }, +] + [[package]] name = "parso" version = "0.8.6" @@ -1221,7 +1416,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess" }, + { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -1403,6 +1598,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/27/a3b6e5bf6ff856d2509292e95c8f57f0df7017cf5394921fc4e4ef40308a/pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b", size = 102564, upload-time = "2026-03-13T19:27:37.25Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/7a/8dd906bd22e79e47397a61742927f6747fe93242ef86645ee9092e610244/pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c", size = 29726, upload-time = "2026-03-13T19:27:35.677Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1452,6 +1661,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/19/58/5d14cb5cb59409e491ebe816c47bf81423cd03098ea92281336320ae5681/pytest_socket-0.7.0-py3-none-any.whl", hash = "sha256:7e0f4642177d55d317bbd58fc68c6bd9048d6eadb2d46a89307fa9221336ce45", size = 6754, upload-time = "2024-01-28T20:17:22.105Z" }, ] +[[package]] +name = "python-calamine" +version = "0.6.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/18/e1e53ade001b30a3c6642d876e5defe8431da8c31fb7798909e6c8ab8c34/python_calamine-0.6.2.tar.gz", hash = "sha256:2c90e5224c5e92db9fcd8f22b6085ce63b935cfe7a893ac9a1c3c56793bafd9d", size = 138000, upload-time = "2026-02-18T13:38:17.389Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/ec/e111c1a3a4c138ebc41e416e33730ee6d7c54e714af21c2a4e59b41715a5/python_calamine-0.6.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:857e4cddadba9b55c76dc583c58c5dc101a6cd5320190c10f8b2ab98d66c9040", size = 879539, upload-time = "2026-02-18T13:36:21.674Z" }, + { url = "https://files.pythonhosted.org/packages/53/26/fe4c2138ff21542e2f1130a4d83c330d7f9486b62775196e998b88a03de6/python_calamine-0.6.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:cd89d6a53e4b22328cd685fc054c31d359cb3ae67bd24bc57e1c1db62a4cfc97", size = 858642, upload-time = "2026-02-18T13:36:23.847Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/bfeaf45ac5e2f6553723dd2fbe127d1d17c6f26496db5781de42a933776a/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d6c9af39db39e0c70710ae79cd1b5d980f9c0aea55fc16d194460c1561a0c6a", size = 925242, upload-time = "2026-02-18T13:36:25.236Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6e/81106aa80609075015d400584030605b05f5e12931717160dcc58fdc4980/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9a2382dbc410dd48c99d89ee460662cc70892fe1b2901ab982604b923e8eb8f6", size = 905295, upload-time = "2026-02-18T13:36:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/4d/ba/6311b24f9889246be63b664630c5601039ef771f7ed04c8f51aace39b7a9/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ebb93255709874ede5b5e62828cb5758e60097e5390b6c9a3eb7751b617b12e", size = 1063473, upload-time = "2026-02-18T13:36:29.226Z" }, + { url = "https://files.pythonhosted.org/packages/23/e4/027a1b046d30768872307ebe808dc4cdc5357295cdcda98b30b3ea924904/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:837bca19bd945cb83aded433f4cf76e80d70a5400404d876400ca7e88e5ea311", size = 965355, upload-time = "2026-02-18T13:36:31.376Z" }, + { url = "https://files.pythonhosted.org/packages/5c/4d/da8716a1b3a66938aaabe36873f6fa210fa063bab1b20c2ec236013de6b3/python_calamine-0.6.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:723990a47668cb819f307ccc634741370d3cd3804a0ee8cda392a522ae6d5016", size = 935091, upload-time = "2026-02-18T13:36:32.777Z" }, + { url = "https://files.pythonhosted.org/packages/36/40/9521e8da5496cbc4b18027626a40018301f546b3e9802ca2f3a6cb5b4739/python_calamine-0.6.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b067630d693e1d7de41e3d44a99c7dd3feebb52db8dda8636ac3f70d8b6a4ad6", size = 974070, upload-time = "2026-02-18T13:36:34.055Z" }, + { url = "https://files.pythonhosted.org/packages/cd/b0/7a63963512c5ba7e9539b7452e2b1561625e63e4e29c044e487e2e93dcbe/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:6ab09c9da53a2b33633e9f940aed11c08e083810a0fd6885826cdc52ba4f86a5", size = 1100321, upload-time = "2026-02-18T13:36:35.475Z" }, + { url = "https://files.pythonhosted.org/packages/22/81/e2bc38a5cf9629f656adcdabe8e134028f60c236e4bb96375dda90db3fdd/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:ae08e1308a0d0c6b8b4cc0a039ed8a85fc9ee2f8a3ca9ea57b1af9f97ed68fe4", size = 1181039, upload-time = "2026-02-18T13:36:37.195Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ea/513117015fd5903ca6dde9c8fb8502af60af6965642f4e3311623943e673/python_calamine-0.6.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c441a20c7aff0e904ca01b5cdc1e5be2c6d4a41a24a0ea4d5ea6d211343bb95f", size = 1144843, upload-time = "2026-02-18T13:36:38.393Z" }, + { url = "https://files.pythonhosted.org/packages/a2/14/8846478dacf31535f5f15448ade3bc688b51f3183f1b52844451aa27b0e6/python_calamine-0.6.2-cp312-cp312-win32.whl", hash = "sha256:39cae8e66f8bce499f5f965f4575ddf61e30184cc97f02e1c7031a57abe0903b", size = 692411, upload-time = "2026-02-18T13:36:39.741Z" }, + { url = "https://files.pythonhosted.org/packages/bb/e2/2d2dcf4ec7e5ec08e33bf966ab010a7be178a4b623bd5f7601d47f2c734c/python_calamine-0.6.2-cp312-cp312-win_amd64.whl", hash = "sha256:1617efa24532f2420934a8cf77e6d33ff1740cae1d39355cab4f4cf141fdab49", size = 748960, upload-time = "2026-02-18T13:36:40.922Z" }, + { url = "https://files.pythonhosted.org/packages/f1/eb/2f50f3395c0435e6186cab56c36d04c06581ba827264bca1f1acae523aa3/python_calamine-0.6.2-cp312-cp312-win_arm64.whl", hash = "sha256:c2b378db494740e540e8157a7e5fe61dadae69ad2d988a7c80f9583f434acf07", size = 718992, upload-time = "2026-02-18T13:36:42.671Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -1477,6 +1708,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, ] +[[package]] +name = "python3-openid" +version = "3.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "defusedxml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5f/4a/29feb8da6c44f77007dcd29518fea73a3d5653ee02a587ae1f17f1f5ddb5/python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf", size = 305600, upload-time = "2020-06-29T12:15:49.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/a5/c6ba13860bdf5525f1ab01e01cc667578d6f1efc8a1dba355700fb04c29b/python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b", size = 133681, upload-time = "2020-06-29T12:15:47.502Z" }, +] + +[[package]] +name = "pyxlsb" +version = "1.0.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/13/eebaeb7a40b062d1c6f7f91d09e73d30a69e33e4baa7cbe4b7658548b1cd/pyxlsb-1.0.10.tar.gz", hash = "sha256:8062d1ea8626d3f1980e8b1cfe91a4483747449242ecb61013bc2df85435f685", size = 22424, upload-time = "2022-10-14T19:17:47.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/92/345823838ae367c59b63e03aef9c331f485370f9df6d049256a61a28f06d/pyxlsb-1.0.10-py2.py3-none-any.whl", hash = "sha256:87c122a9a622e35ca5e741d2e541201d28af00fb46bec492cfa9586890b120b4", size = 23849, upload-time = "2022-10-14T19:17:46.079Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1608,6 +1860,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] +[[package]] +name = "shortuuid" +version = "1.0.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/e2/bcf761f3bff95856203f9559baf3741c416071dd200c0fc19fad7f078f86/shortuuid-1.0.13.tar.gz", hash = "sha256:3bb9cf07f606260584b1df46399c0b87dd84773e7b25912b7e391e30797c5e72", size = 9662, upload-time = "2024-03-11T20:11:06.879Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/44/21d6bf170bf40b41396480d8d49ad640bca3f2b02139cd52aa1e272830a5/shortuuid-1.0.13-py3-none-any.whl", hash = "sha256:a482a497300b49b4953e15108a7913244e1bb0d41f9d332f5e9925dba33a3c5a", size = 10529, upload-time = "2024-03-11T20:11:04.807Z" }, +] + [[package]] name = "six" version = "1.17.0" @@ -1756,3 +2017,21 @@ sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a3 wheels = [ { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, ] + +[[package]] +name = "xlrd" +version = "2.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/07/5a/377161c2d3538d1990d7af382c79f3b2372e880b65de21b01b1a2b78691e/xlrd-2.0.2.tar.gz", hash = "sha256:08b5e25de58f21ce71dc7db3b3b8106c1fa776f3024c54e45b45b374e89234c9", size = 100167, upload-time = "2025-06-14T08:46:39.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/62/c8d562e7766786ba6587d09c5a8ba9f718ed3fa8af7f4553e8f91c36f302/xlrd-2.0.2-py2.py3-none-any.whl", hash = "sha256:ea762c3d29f4cca48d82df517b6d89fbce4db3107f9d78713e48cd321d5c9aa9", size = 96555, upload-time = "2025-06-14T08:46:37.766Z" }, +] + +[[package]] +name = "xlsxwriter" +version = "3.2.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/46/2c/c06ef49dc36e7954e55b802a8b231770d286a9758b3d936bd1e04ce5ba88/xlsxwriter-3.2.9.tar.gz", hash = "sha256:254b1c37a368c444eac6e2f867405cc9e461b0ed97a3233b2ac1e574efb4140c", size = 215940, upload-time = "2025-09-16T00:16:21.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/0c/3662f4a66880196a590b202f0db82d919dd2f89e99a27fadef91c4a33d41/xlsxwriter-3.2.9-py3-none-any.whl", hash = "sha256:9a5db42bc5dff014806c58a20b9eae7322a134abb6fce3c92c181bfb275ec5b3", size = 175315, upload-time = "2025-09-16T00:16:20.108Z" }, +]