From b6fcba20ffca9287cbfadc8eb04c98d04b2c5198 Mon Sep 17 00:00:00 2001 From: Yoo117 Date: Sun, 13 Oct 2024 14:33:16 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=9E=A5=EB=B0=94=EA=B5=AC?= =?UTF-8?q?=EB=8B=88=20=EC=A4=91=EB=B3=B5=20=EC=B6=94=EA=B0=80=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- payments/mixins.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/payments/mixins.py b/payments/mixins.py index 10e6df2..bb7fd3a 100644 --- a/payments/mixins.py +++ b/payments/mixins.py @@ -41,12 +41,15 @@ def add_to_cart(self, cart, serializer): {"detail": "이 상품은 이미 장바구니에 있습니다."}, status=status.HTTP_400_BAD_REQUEST, ) - - serializer.save(cart=cart) - return Response( - {"detail": "상품이 장바구니에 추가되었습니다.", "data": serializer.data}, - status=status.HTTP_201_CREATED, - ) + else: + serializer.save(cart=cart) + return Response( + { + "detail": "상품이 장바구니에 추가되었습니다.", + "data": serializer.data, + }, + status=status.HTTP_201_CREATED, + ) def remove_from_cart(self, cart_item): cart_item.delete() From a250aa8287af28b38383bfe35ccba93c4659a898 Mon Sep 17 00:00:00 2001 From: Yoo117 Date: Sun, 13 Oct 2024 15:29:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20materials=20migration=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._image_user_alter_image_course_and_more.py | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 materials/migrations/0003_image_image_image_user_alter_image_course_and_more.py diff --git a/materials/migrations/0003_image_image_image_user_alter_image_course_and_more.py b/materials/migrations/0003_image_image_image_user_alter_image_course_and_more.py new file mode 100644 index 0000000..08324e9 --- /dev/null +++ b/materials/migrations/0003_image_image_image_user_alter_image_course_and_more.py @@ -0,0 +1,48 @@ +# Generated by Django 5.1.1 on 2024-10-13 06:00 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('courses', '0007_alter_course_category'), + ('materials', '0002_image_video_delete_blacklistedtoken'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='image', + name='image', + field=models.ImageField(blank=True, null=True, upload_to='images/'), + ), + migrations.AddField( + model_name='image', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='image', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='image', + name='course', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='image', to='courses.course'), + ), + migrations.AlterField( + model_name='video', + name='topic', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='video', to='courses.topic'), + ), + migrations.CreateModel( + name='VideoEventData', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('pause', 'Paused'), ('ended', 'Ended'), ('leave', 'Left Page')], max_length=20, verbose_name='이벤트 유형')), + ('duration', models.FloatField(verbose_name='비디오 전체 길이')), + ('current_time', models.FloatField(verbose_name='현재 재생 위치')), + ('timestamp', models.DateTimeField(auto_now_add=True, verbose_name='이벤트 발생 시간')), + ('video', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='video_event_datas', to='materials.video', verbose_name='해당 비디오')), + ], + ), + ] From d4e173cb9a8853433e39efaffca00642ec006ca4 Mon Sep 17 00:00:00 2001 From: Yoo117 Date: Sun, 13 Oct 2024 15:29:35 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- payments/mixins.py | 5 ++- payments/tests/test_payments_views.py | 46 +++++++++++++++++++++++++-- payments/urls.py | 5 +++ payments/views.py | 42 ++++++++++++++++++------ 4 files changed, 86 insertions(+), 12 deletions(-) diff --git a/payments/mixins.py b/payments/mixins.py index bb7fd3a..c0cfbe8 100644 --- a/payments/mixins.py +++ b/payments/mixins.py @@ -206,8 +206,11 @@ def refund_payment(self, order, payment): if not payment or payment.payment_status != "completed": raise ValidationError("해당 주문에 대한 완료된 결제를 찾을 수 없습니다.") + if payment.paid_at is None: + raise ValidationError("결제 완료 시간이 기록되지 않았습니다.") + if timezone.now() - payment.paid_at > timezone.timedelta(days=7): - raise ValidationError("결제 후 7일이 지나 취소할 수 없습니다.") + raise ValidationError("결제 후 7일이 지난 주문은 환불할 수 없습니다.") try: self.kakao_pay_service.refund_payment(payment) diff --git a/payments/tests/test_payments_views.py b/payments/tests/test_payments_views.py index bba6c5b..d56b3cb 100644 --- a/payments/tests/test_payments_views.py +++ b/payments/tests/test_payments_views.py @@ -1,8 +1,8 @@ import pytest +from django.utils import timezone from django.urls import reverse from rest_framework import status from unittest.mock import patch -from payments.models import CartItem @pytest.mark.django_db @@ -41,12 +41,35 @@ def test_주문_생성_실패_빈_장바구니(self, api_client, user): @pytest.mark.django_db class Test결제뷰: + @patch("payments.mixins.KakaoPayService.request_payment") + def test_결제_요청(self, mock_request_payment, api_client, user, order): + mock_request_payment.return_value = { + "next_redirect_pc_url": "http://test-redirect-url.com", + "next_redirect_mobile_url": "http://test-redirect-url.com", + "next_redirect_app_url": "http://test-redirect-url.com", + "tid": "test_transaction_id", + } + api_client.force_authenticate(user=user) + url = reverse("payments:payment") + response = api_client.post(url) + print(f"\n결제 요청 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") + assert response.status_code == status.HTTP_201_CREATED + assert "payment" in response.data + assert "next_redirect_pc_url" in response.data + @patch("payments.mixins.KakaoPayService.approve_payment") def test_결제_승인_성공(self, mock_approve_payment, api_client, user, payment): - api_client.force_authenticate(user=user) mock_approve_payment.return_value = {"amount": {"total": 10000}} + api_client.force_authenticate(user=user) url = reverse("payments:payment") response = api_client.get(url, {"result": "success", "pg_token": "test_token"}) + print(f"\n결제 승인 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") assert response.status_code == status.HTTP_200_OK assert response.data["detail"] == "결제가 성공적으로 완료되었습니다." @@ -54,9 +77,28 @@ def test_결제_취소(self, api_client, user, payment): api_client.force_authenticate(user=user) url = reverse("payments:payment") response = api_client.get(url, {"result": "cancel"}) + print(f"\n결제 취소 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") assert response.status_code == status.HTTP_200_OK assert "결제 과정이 취소되었습니다" in response.data["detail"] + @patch("payments.mixins.KakaoPayService.refund_payment") + def test_결제_환불(self, mock_refund_payment, api_client, user, completed_payment): + mock_refund_payment.return_value = {"status": "CANCEL_PAYMENT"} + completed_payment.paid_at = timezone.now() + completed_payment.save() + api_client.force_authenticate(user=user) + url = reverse("payments:payment-cancel", args=[completed_payment.order.id]) + response = api_client.delete(url) + print(f"\n결제 환불 테스트") + print(f"URL: {url}") + print(f"응답 상태 코드: {response.status_code}") + print(f"응답 데이터: {response.data}") + assert response.status_code == status.HTTP_200_OK + assert "결제가 성공적으로 환불되었습니다" in response.data["detail"] + @pytest.mark.django_db class Test영수증뷰: diff --git a/payments/urls.py b/payments/urls.py index 9c59162..0a1bd00 100644 --- a/payments/urls.py +++ b/payments/urls.py @@ -32,6 +32,11 @@ PaymentView.as_view(), name="payment", ), + path( + "payments//cancel/", + PaymentView.as_view(), + name="payment-cancel", + ), # 영수증 관련 URLs path("receipts/", ReceiptView.as_view(), name="receipt-list"), path( diff --git a/payments/views.py b/payments/views.py index fba85d7..5db952a 100644 --- a/payments/views.py +++ b/payments/views.py @@ -6,7 +6,7 @@ from rest_framework.permissions import IsAuthenticated from drf_spectacular.utils import extend_schema, extend_schema_view -from .models import CartItem, Order, UserBillingAddress +from .models import CartItem, Order, UserBillingAddress, Payment from .serializers import ( CartItemSerializer, CartSerializer, @@ -254,7 +254,7 @@ class PaymentView(PaymentMixin, OrderMixin, generics.GenericAPIView): [POST /payments/]: 현재 진행 중인 주문에 대한 결제를 생성하고 카카오페이 결제를 요청합니다. [GET /payments/]: 카카오페이 결제 결과를 처리합니다. - [DELETE /payments/]: 결제를 취소하고 환불을 처리합니다. + [DELETE /payments//cancel/]: 결제를 취소하고 환불을 처리합니다. """ serializer_class = PaymentSerializer @@ -265,7 +265,7 @@ def get_queryset(self): @transaction.atomic def post(self, request): - order = self.get_queryset().select_for_update().first() + order = self.get_queryset().filter(order_status="pending").select_for_update().first() if not order: return Response( {"detail": "진행 중인 주문이 없습니다."}, @@ -290,14 +290,35 @@ def post(self, request): @transaction.atomic def get(self, request): - order = self.get_queryset().select_for_update().first() + order = ( + self.get_queryset() + .filter(order_status="pending") + .select_for_update() + .first() + ) if not order: return Response( {"detail": "진행 중인 주문이 없습니다."}, status=status.HTTP_404_NOT_FOUND, ) - payment = self.get_payment(request.user, order=order) + # 가장 최근의 pending payment를 가져오고 나머지는 취소 처리 + payments = list( + Payment.objects.filter(order=order, payment_status="pending").order_by( + "-created_at" + ) + ) + if not payments: + return Response( + {"detail": "해당 주문에 대한 대기 중인 결제를 찾을 수 없습니다."}, + status=status.HTTP_404_NOT_FOUND, + ) + payment = payments[0] + # 가장 최근의 pending payment를 제외한 나머지 payment를 취소 처리 + if len(payments) > 1: + Payment.objects.filter(id__in=[p.id for p in payments[1:]]).update( + payment_status="cancelled" + ) result = request.GET.get("result") pg_token = request.GET.get("pg_token") @@ -339,11 +360,14 @@ def get(self, request): ) @transaction.atomic - def delete(self, request): - order = self.get_queryset().select_for_update().first() - if not order: + def delete(self, request, order_id): + try: + order = ( + self.get_queryset().filter(order_status="completed").get(id=order_id) + ) + except Order.DoesNotExist: return Response( - {"detail": "진행 중인 주문이 없습니다."}, + {"detail": "결제된 주문을 찾을 수 없습니다."}, status=status.HTTP_404_NOT_FOUND, ) From 817285cff562602b051a024e2f0c31db07211f88 Mon Sep 17 00:00:00 2001 From: Yoo117 Date: Sun, 13 Oct 2024 17:43:15 +0900 Subject: [PATCH 4/4] =?UTF-8?q?refactor:=20payments=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD,=20post=EC=9D=98=20c?= =?UTF-8?q?reatpayment=EC=97=90=EC=84=9C=20=EC=B2=98=EB=A6=AC=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- payments/mixins.py | 9 +++++++-- payments/views.py | 24 +++++++++++------------- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/payments/mixins.py b/payments/mixins.py index c0cfbe8..6378e8d 100644 --- a/payments/mixins.py +++ b/payments/mixins.py @@ -141,12 +141,17 @@ def validate_order(self, order): raise ValidationError("결제 가능한 상태의 주문이 아닙니다.") if order.get_total_price() > 50000: raise ValidationError("결제 금액이 50,000원을 초과할 수 없습니다.") - if order.payments.filter(payment_status__in=["completed", "pending"]).exists(): - raise ValidationError("이미 결제가 완료되었거나 진행 중인 주문입니다.") def create_payment(self, order, user): self.validate_order(order) + existing_payments = Payment.objects.filter( + order=order, payment_status="pending" + ) + if existing_payments.exists(): + # 모든 기존 pending payment를 취소 처리 + existing_payments.update(payment_status="cancelled") + try: kakao_response = self.kakao_pay_service.request_payment(order) except Exception as e: diff --git a/payments/views.py b/payments/views.py index 5db952a..b37f78e 100644 --- a/payments/views.py +++ b/payments/views.py @@ -265,7 +265,12 @@ def get_queryset(self): @transaction.atomic def post(self, request): - order = self.get_queryset().filter(order_status="pending").select_for_update().first() + order = ( + self.get_queryset() + .filter(order_status="pending") + .select_for_update() + .first() + ) if not order: return Response( {"detail": "진행 중인 주문이 없습니다."}, @@ -302,23 +307,16 @@ def get(self, request): status=status.HTTP_404_NOT_FOUND, ) - # 가장 최근의 pending payment를 가져오고 나머지는 취소 처리 - payments = list( - Payment.objects.filter(order=order, payment_status="pending").order_by( - "-created_at" - ) + payment = ( + Payment.objects.filter(order=order, payment_status="pending") + .order_by("-created_at") + .first() ) - if not payments: + if not payment: return Response( {"detail": "해당 주문에 대한 대기 중인 결제를 찾을 수 없습니다."}, status=status.HTTP_404_NOT_FOUND, ) - payment = payments[0] - # 가장 최근의 pending payment를 제외한 나머지 payment를 취소 처리 - if len(payments) > 1: - Payment.objects.filter(id__in=[p.id for p in payments[1:]]).update( - payment_status="cancelled" - ) result = request.GET.get("result") pg_token = request.GET.get("pg_token")