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='해당 비디오')), + ], + ), + ] diff --git a/payments/mixins.py b/payments/mixins.py index 10e6df2..6378e8d 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() @@ -138,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: @@ -203,8 +211,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..b37f78e 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,12 @@ 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 +295,28 @@ 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) + payment = ( + Payment.objects.filter(order=order, payment_status="pending") + .order_by("-created_at") + .first() + ) + if not payment: + return Response( + {"detail": "해당 주문에 대한 대기 중인 결제를 찾을 수 없습니다."}, + status=status.HTTP_404_NOT_FOUND, + ) result = request.GET.get("result") pg_token = request.GET.get("pg_token") @@ -339,11 +358,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, )