216 lines
8.2 KiB
Python
216 lines
8.2 KiB
Python
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/<gateway>/`, 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")
|
|
)
|