Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
433 lines
17 KiB
Python
433 lines
17 KiB
Python
from django.conf import settings
|
|
from django.shortcuts import redirect, get_object_or_404
|
|
from django.utils import timezone
|
|
from django.db.models import Count, Q
|
|
|
|
from ninja import Query, Router
|
|
from ninja.errors import HttpError
|
|
import requests
|
|
|
|
from apps.payments.models import Payment, DiscountCode
|
|
from apps.events.models import Event, Registration
|
|
from apps.notifications.services import notify_user
|
|
from apps.users.tasks import send_critical_sms
|
|
from core.authentication import jwt_auth
|
|
from core.api.schemas import ErrorSchema, MessageSchema
|
|
from apps.payments.api.schemas import (
|
|
CouponVerifyIn,
|
|
CouponVerifyOut,
|
|
CreatePaymentIn,
|
|
CreatePaymentOut,
|
|
DiscountCodeSchema,
|
|
DiscountCodeWriteSchema,
|
|
PagedDiscountCodeSchema,
|
|
PaymentDetailOut,
|
|
)
|
|
|
|
payments_router = Router(tags=["Payments"])
|
|
|
|
|
|
def _staff_required(user):
|
|
return bool(user and (user.is_staff or user.is_superuser))
|
|
|
|
|
|
def _discount_payload(code: DiscountCode):
|
|
return {
|
|
"id": code.id,
|
|
"code": code.code,
|
|
"type": code.type,
|
|
"value": code.value,
|
|
"max_discount": code.max_discount,
|
|
"is_active": code.is_active,
|
|
"starts_at": code.starts_at,
|
|
"ends_at": code.ends_at,
|
|
"usage_limit_total": code.usage_limit_total,
|
|
"usage_limit_per_user": code.usage_limit_per_user,
|
|
"min_amount": code.min_amount,
|
|
"applicable_event_ids": list(code.applicable_events.values_list("id", flat=True)),
|
|
"usage_count": getattr(code, "usage_count", None) or code.payments.filter(
|
|
status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING]
|
|
).count(),
|
|
"created_at": code.created_at,
|
|
"updated_at": code.updated_at,
|
|
}
|
|
|
|
|
|
def _apply_discount_payload(instance: DiscountCode, payload: DiscountCodeWriteSchema):
|
|
data = payload.dict()
|
|
event_ids = data.pop("applicable_event_ids", [])
|
|
for field, value in data.items():
|
|
setattr(instance, field, value)
|
|
instance.code = instance.code.strip().upper()
|
|
instance.full_clean()
|
|
instance.save()
|
|
instance.applicable_events.set(Event.objects.filter(id__in=event_ids, is_deleted=False))
|
|
return instance
|
|
|
|
|
|
@payments_router.get("/admin/discount-codes", response={200: PagedDiscountCodeSchema, 403: ErrorSchema}, auth=jwt_auth)
|
|
def admin_list_discount_codes(
|
|
request,
|
|
search: str | None = Query(None),
|
|
is_active: bool | None = Query(None),
|
|
type: str | None = Query(None),
|
|
limit: int = Query(20, ge=1, le=100),
|
|
offset: int = Query(0, ge=0),
|
|
):
|
|
if not _staff_required(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
queryset = DiscountCode.objects.annotate(
|
|
usage_count=Count(
|
|
"payments",
|
|
filter=Q(
|
|
payments__status__in=[
|
|
Payment.OrderStatusChoices.PAID,
|
|
Payment.OrderStatusChoices.PENDING,
|
|
]
|
|
),
|
|
)
|
|
).prefetch_related("applicable_events").order_by("-created_at")
|
|
if search:
|
|
queryset = queryset.filter(code__icontains=search)
|
|
if is_active is not None:
|
|
queryset = queryset.filter(is_active=is_active)
|
|
if type:
|
|
queryset = queryset.filter(type=type)
|
|
count = queryset.count()
|
|
return 200, {"count": count, "results": [_discount_payload(item) for item in queryset[offset : offset + limit]]}
|
|
|
|
|
|
@payments_router.post("/admin/discount-codes", response={201: DiscountCodeSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def admin_create_discount_code(request, payload: DiscountCodeWriteSchema):
|
|
if not _staff_required(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
if DiscountCode.all_objects.filter(code=payload.code.strip().upper()).exists():
|
|
return 400, {"error": "Discount code already exists"}
|
|
try:
|
|
code = _apply_discount_payload(DiscountCode(), payload)
|
|
except Exception as exc:
|
|
return 400, {"error": str(exc)}
|
|
return 201, _discount_payload(code)
|
|
|
|
|
|
@payments_router.put("/admin/discount-codes/{int:code_id}", response={200: DiscountCodeSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def admin_update_discount_code(request, code_id: int, payload: DiscountCodeWriteSchema):
|
|
if not _staff_required(request.auth):
|
|
return 403, {"error": "Permission denied"}
|
|
code = get_object_or_404(DiscountCode, id=code_id, is_deleted=False)
|
|
normalized = payload.code.strip().upper()
|
|
if DiscountCode.all_objects.filter(code=normalized).exclude(id=code_id).exists():
|
|
return 400, {"error": "Discount code already exists"}
|
|
try:
|
|
code = _apply_discount_payload(code, payload)
|
|
except Exception as exc:
|
|
return 400, {"error": str(exc)}
|
|
return 200, _discount_payload(code)
|
|
|
|
|
|
@payments_router.delete("/admin/discount-codes/{int:code_id}", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def admin_delete_discount_code(request, code_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Only superusers can delete discount codes"}
|
|
code = get_object_or_404(DiscountCode, id=code_id, is_deleted=False)
|
|
code.delete()
|
|
return 200, {"message": "Discount code deleted"}
|
|
|
|
|
|
@payments_router.post("/admin/discount-codes/{int:code_id}/restore", response={200: MessageSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth)
|
|
def admin_restore_discount_code(request, code_id: int):
|
|
if not request.auth.is_superuser:
|
|
return 403, {"error": "Only superusers can restore discount codes"}
|
|
try:
|
|
code = DiscountCode.deleted_objects.get(id=code_id)
|
|
except DiscountCode.DoesNotExist:
|
|
return 400, {"error": "Discount code not found"}
|
|
code.restore()
|
|
return 200, {"message": "Discount code restored"}
|
|
|
|
|
|
def _event_action_url(event: Event) -> str:
|
|
root = getattr(settings, "FRONTEND_ROOT", "/") or "/"
|
|
if not root.endswith("/"):
|
|
root = f"{root}/"
|
|
return f"{root}events/{event.slug or event.id}"
|
|
|
|
|
|
def _notify_payment_status(payment: Payment, *, title: str, message: str, level: str):
|
|
notify_user(
|
|
payment.user_id,
|
|
{
|
|
"type": "payment_status",
|
|
"title": title,
|
|
"message": message,
|
|
"level": level,
|
|
"action_url": _event_action_url(payment.event),
|
|
"entity_type": "payment",
|
|
"entity_id": payment.id,
|
|
"meta": {
|
|
"event_id": payment.event_id,
|
|
"payment_id": payment.id,
|
|
"ref_id": payment.ref_id,
|
|
"status": payment.status,
|
|
},
|
|
},
|
|
)
|
|
if payment.user.mobile and payment.user.is_mobile_verified:
|
|
send_critical_sms.delay(payment.user.mobile, "payment_status", payment.event.title)
|
|
|
|
|
|
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
|
def create_payment(request, payload: CreatePaymentIn):
|
|
event = get_object_or_404(Event, pk=payload.event_id)
|
|
|
|
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
|
|
raise HttpError(400, "You have already registered in this event")
|
|
|
|
registration = (
|
|
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
|
|
.order_by("-registered_at")
|
|
.first()
|
|
)
|
|
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
|
registration = Registration.objects.create(
|
|
event=event,
|
|
user=request.auth,
|
|
status=Registration.StatusChoices.PENDING,
|
|
final_price=event.price,
|
|
)
|
|
elif registration.final_price is None:
|
|
registration.final_price = event.price
|
|
registration.save(update_fields=["final_price"])
|
|
|
|
discount_code = None
|
|
discount_amount = 0
|
|
final_amount = event.price
|
|
|
|
if payload.discount_code:
|
|
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
|
|
|
|
if discount_code:
|
|
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
|
|
|
registration_updates = []
|
|
if discount_code and registration.discount_code_id != discount_code.id:
|
|
registration.discount_code = discount_code
|
|
registration_updates.append("discount_code")
|
|
if registration.discount_amount != discount_amount:
|
|
registration.discount_amount = discount_amount
|
|
registration_updates.append("discount_amount")
|
|
if registration.final_price != final_amount:
|
|
registration.final_price = final_amount
|
|
registration_updates.append("final_price")
|
|
|
|
if final_amount == 0:
|
|
if registration.status != Registration.StatusChoices.CONFIRMED:
|
|
registration.status = Registration.StatusChoices.CONFIRMED
|
|
registration_updates.append("status")
|
|
if registration_updates:
|
|
registration.save(update_fields=list(set(registration_updates)))
|
|
else:
|
|
registration.save(update_fields=["status"])
|
|
|
|
return {
|
|
"start_pay_url": None,
|
|
"authority": None,
|
|
"base_amount": event.price,
|
|
"discount_amount": discount_amount if discount_amount else 0,
|
|
"amount": 0,
|
|
}
|
|
|
|
if registration_updates:
|
|
registration.save(update_fields=list(set(registration_updates)))
|
|
|
|
pay = Payment.objects.create(
|
|
user=request.auth,
|
|
event=event,
|
|
base_amount=event.price,
|
|
discount_code=discount_code,
|
|
discount_amount=discount_amount,
|
|
amount=final_amount,
|
|
status=Payment.OrderStatusChoices.INIT,
|
|
registration=registration,
|
|
)
|
|
|
|
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
|
|
body = {
|
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
|
"amount": final_amount,
|
|
"callback_url": callback_url,
|
|
"description": payload.description,
|
|
"metadata": {
|
|
k: v for k, v in {
|
|
"mobile": payload.mobile,
|
|
"email": payload.email,
|
|
"event_id": event.id,
|
|
"user_id": request.auth.id,
|
|
"payment_id": pay.id,
|
|
"discount_code": discount_code.code if discount_code else None,
|
|
}.items() if v
|
|
}
|
|
}
|
|
|
|
try:
|
|
response = requests.post(
|
|
settings.ZARINPAL_REQUEST_URL,
|
|
json=body,
|
|
headers={"accept":"application/json","content-type":"application/json"},
|
|
timeout=15
|
|
)
|
|
jd = response.json()
|
|
except Exception as e:
|
|
pay.delete()
|
|
raise HttpError(502, f"Gateway request failed: {e}")
|
|
|
|
code = (jd.get("data") or {}).get("code")
|
|
if code != 100:
|
|
pay.delete()
|
|
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
|
|
|
|
authority = jd["data"]["authority"]
|
|
pay.authority = authority
|
|
pay.status = Payment.OrderStatusChoices.PENDING
|
|
pay.save(update_fields=["authority","status"])
|
|
|
|
return {
|
|
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
|
|
"authority": authority,
|
|
"base_amount": event.price,
|
|
"discount_amount": discount_amount if discount_amount else 0,
|
|
"amount": final_amount,
|
|
}
|
|
|
|
@payments_router.get("callback")
|
|
def callback(request, Authority: str | None = None, Status: str | None = None):
|
|
if not Authority:
|
|
raise HttpError(400, "Missing Authority")
|
|
|
|
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
|
if not pay:
|
|
raise HttpError(404, "Payment not found")
|
|
previous_status = pay.status
|
|
|
|
if Status != "OK":
|
|
pay.status = Payment.OrderStatusChoices.CANCELED
|
|
pay.save(update_fields=["status"])
|
|
if previous_status != Payment.OrderStatusChoices.CANCELED:
|
|
_notify_payment_status(
|
|
pay,
|
|
title=f"پرداخت رویداد «{pay.event.title}» لغو شد",
|
|
message="پرداخت شما کامل نشد و ثبتنام نهایی انجام نگرفت.",
|
|
level="warning",
|
|
)
|
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
|
|
|
verify_body = {
|
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
|
"amount": pay.amount,
|
|
"authority": Authority,
|
|
}
|
|
|
|
try:
|
|
vresp = requests.post(
|
|
settings.ZARINPAL_VERIFY_URL,
|
|
json=verify_body,
|
|
headers={"accept":"application/json","content-type":"application/json"},
|
|
timeout=15
|
|
)
|
|
vjd = vresp.json()
|
|
except Exception:
|
|
pay.status = Payment.OrderStatusChoices.FAILED
|
|
pay.save(update_fields=["status"])
|
|
if previous_status != Payment.OrderStatusChoices.FAILED:
|
|
_notify_payment_status(
|
|
pay,
|
|
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
|
|
message="خطا در تأیید پرداخت رخ داد. در صورت نیاز دوباره تلاش کنید.",
|
|
level="error",
|
|
)
|
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
|
|
|
vcode = (vjd.get("data") or {}).get("code")
|
|
if vcode in (100, 101):
|
|
data = vjd.get("data") or {}
|
|
pay.status = Payment.OrderStatusChoices.PAID
|
|
pay.ref_id = data.get("ref_id")
|
|
pay.card_pan = data.get("card_pan")
|
|
pay.card_hash = data.get("card_hash")
|
|
pay.verified_at = timezone.now()
|
|
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
|
|
|
|
registration = pay.registration or Registration.objects.filter(
|
|
user=pay.user,
|
|
event=pay.event,
|
|
status=Registration.StatusChoices.PENDING,
|
|
).first()
|
|
if registration:
|
|
registration.status = Registration.StatusChoices.CONFIRMED
|
|
updates = ["status"]
|
|
if registration.final_price is None:
|
|
registration.final_price = pay.amount
|
|
updates.append("final_price")
|
|
registration.save(update_fields=updates)
|
|
|
|
if previous_status != Payment.OrderStatusChoices.PAID:
|
|
_notify_payment_status(
|
|
pay,
|
|
title=f"پرداخت رویداد «{pay.event.title}» تأیید شد",
|
|
message="پرداخت شما با موفقیت ثبت شد و ثبتنام رویداد تکمیل شده است.",
|
|
level="success",
|
|
)
|
|
|
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
|
|
|
|
pay.status = Payment.OrderStatusChoices.FAILED
|
|
pay.save(update_fields=["status"])
|
|
if previous_status != Payment.OrderStatusChoices.FAILED:
|
|
_notify_payment_status(
|
|
pay,
|
|
title=f"پرداخت رویداد «{pay.event.title}» ناموفق بود",
|
|
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
|
|
level="error",
|
|
)
|
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
|
|
|
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
|
def payment_by_ref(request, ref_id: str):
|
|
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
|
ev = pay.event
|
|
return {
|
|
"ref_id": pay.ref_id,
|
|
"authority": pay.authority,
|
|
"base_amount": pay.base_amount,
|
|
"discount_amount": pay.discount_amount or 0,
|
|
"amount": pay.amount,
|
|
"status": pay.get_status_display(),
|
|
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
|
|
"event": {
|
|
"id": ev.id,
|
|
"title": ev.title,
|
|
"slug": ev.slug,
|
|
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
|
|
"success_markdown": ev.registration_success_markdown,
|
|
},
|
|
}
|
|
|
|
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
|
def check_coupon(request, payload: CouponVerifyIn):
|
|
event = get_object_or_404(Event, id=payload.event_id)
|
|
code = payload.code
|
|
|
|
if not code:
|
|
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
|
|
|
|
try:
|
|
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
|
|
final_price, disc = c.calculate_discount(event, request.auth)
|
|
return {
|
|
"discount_amount": disc,
|
|
"final_price": final_price,
|
|
}
|
|
|
|
except DiscountCode.DoesNotExist:
|
|
raise HttpError(404, "کد تخفیف معتبر نیست")
|