Ilo/apps/payments/api/views.py
2026-05-02 20:01:30 +03:30

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")
)