from urllib.parse import urlencode from django.conf import settings from django.db import transaction from django.shortcuts import get_object_or_404, redirect from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema from rest_framework import status from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.generics import ListAPIView, RetrieveAPIView from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView from apps.courses.models import Course, CourseStatus from apps.enrollments.api.permissions import IsStudent from apps.payments.models import Order, OrderStatus from apps.payments.services.gateway import PaymentGatewayError from apps.payments.services.orders import ( OrderError, complete_or_fail, start_checkout, ) from .serializers import ( CheckoutResponseSerializer, InstructorSaleSerializer, OrderSerializer, ) class CheckoutView(APIView): permission_classes = [IsAuthenticated, IsStudent] @extend_schema( tags=["payments"], summary="Start checkout for a paid course", description=( "Creates a pending Order, opens an intent on the configured payment gateway, " "and returns the URL the user's browser must be redirected to in order to pay. " "After the user finishes (or abandons) at the gateway, they are bounced back to " "`/api/payments/callback//`, which verifies the payment, marks the order " "paid, and creates the Enrollment." ), request=None, responses={ 201: CheckoutResponseSerializer, 400: OpenApiResponse(description="Course is free, or you're already enrolled."), 402: OpenApiResponse(description="Gateway refused the request (auto-failed order)."), 403: OpenApiResponse(description="Not a student."), 404: OpenApiResponse(description="Course not found or not published."), }, ) def post(self, request, slug): course = get_object_or_404(Course, slug=slug, status=CourseStatus.PUBLISHED) promo = (request.data or {}).get("promo_code") if hasattr(request, "data") else None ref = (request.data or {}).get("referral_code") if hasattr(request, "data") else None try: order, redirect_url = start_checkout( request.user, course, promo_code=promo, referral_code=ref, ) except OrderError as e: raise ValidationError({"detail": str(e)}) except PaymentGatewayError as e: return Response( {"detail": "Could not start payment", "error": str(e)}, status=status.HTTP_402_PAYMENT_REQUIRED, ) body = { "order_id": order.pk, "redirect_url": redirect_url, "amount_toman": order.amount_toman, "gateway": order.gateway, } return Response(CheckoutResponseSerializer(body).data, status=status.HTTP_201_CREATED) class GatewayCallbackView(APIView): """Public endpoint the gateway redirects the browser back to after a payment. Always responds with an HTTP redirect to the configured success/failure URL — those are frontend pages, not API endpoints. """ permission_classes = [AllowAny] authentication_classes = [] # gateway-redirected; no JWT in flight @extend_schema( tags=["payments"], summary="Gateway callback (server-to-browser-to-server)", description=( "ZarinPal (or the configured gateway) redirects the user's browser here after they " "complete or cancel payment. We look up the Order by `order_id`, verify with the " "gateway using the stored `authority`, mark the order as paid or failed, create the " "Enrollment on success, and finally HTTP-redirect the browser to the configured " "success or failure URL." ), parameters=[ OpenApiParameter(name="order_id", required=True, type=int, location=OpenApiParameter.QUERY), OpenApiParameter(name="Authority", required=False, type=str, location=OpenApiParameter.QUERY), OpenApiParameter(name="Status", required=False, type=str, location=OpenApiParameter.QUERY), ], responses={302: OpenApiResponse(description="Redirect to success/failure URL.")}, ) def get(self, request, gateway): order_id = request.GET.get("order_id") if not order_id: return self._fail_redirect("missing order_id") gateway_status = request.GET.get("Status", "") with transaction.atomic(): try: order = Order.objects.select_for_update().get(pk=order_id, gateway=gateway) except Order.DoesNotExist: return self._fail_redirect("unknown order") ok, msg = complete_or_fail(order, gateway_status) if ok: return self._success_redirect(order) return self._fail_redirect(msg, order=order) @staticmethod def _success_redirect(order: Order): url = getattr(settings, "PAYMENT_SUCCESS_REDIRECT_URL", "/payment/success") qs = urlencode({"order_id": order.pk, "ref_id": order.ref_id or ""}) return redirect(f"{url}?{qs}" if "?" not in url else f"{url}&{qs}") @staticmethod def _fail_redirect(reason: str, order: Order | None = None): url = getattr(settings, "PAYMENT_FAILURE_REDIRECT_URL", "/payment/failed") params = {"reason": reason} if order is not None: params["order_id"] = order.pk qs = urlencode(params) return redirect(f"{url}?{qs}" if "?" not in url else f"{url}&{qs}") class MyOrdersView(ListAPIView): permission_classes = [IsAuthenticated, IsStudent] serializer_class = OrderSerializer pagination_class = None @extend_schema( tags=["payments"], summary="My order history", description="Lists the current student's orders (any status), most recent first.", ) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_queryset(self): return ( Order.objects.filter(student=self.request.user) .select_related("course") .order_by("-created_at") ) class MyOrderDetailView(RetrieveAPIView): """One Order's full receipt — used by the checkout success page.""" permission_classes = [IsAuthenticated, IsStudent] serializer_class = OrderSerializer lookup_field = "pk" @extend_schema( tags=["payments"], summary="Order detail (receipt)", description=( "Returns the order's amount, fees, instructor share, gateway, ref id, status and " "course summary. Owner-only — students can only fetch their own orders." ), responses={ 200: OrderSerializer, 404: OpenApiResponse(description="Not found or not owned."), }, ) def get(self, request, *args, **kwargs): return super().get(request, *args, **kwargs) def get_queryset(self): return ( Order.objects.filter(student=self.request.user) .select_related("course") ) class InstructorSalesView(ListAPIView): """Instructor's paid sales across all their courses.""" permission_classes = [IsAuthenticated] serializer_class = InstructorSaleSerializer pagination_class = None filterset_fields = ["status", "course"] @extend_schema( tags=["payments"], summary="My sales (instructor)", description=( "Lists orders for the instructor's own courses. Defaults to all statuses; pass " "`?status=paid` to filter to settled sales." ), ) def get(self, request, *args, **kwargs): from apps.accounts.models import Role if request.user.role != Role.TEACHER: raise PermissionDenied("Only teachers can view sales.") return super().get(request, *args, **kwargs) def get_queryset(self): return ( Order.objects.filter(course__instructor=self.request.user) .select_related("student", "course") .order_by("-created_at") )