feat(events): harden registration checkout lifecycle

This commit is contained in:
2026-10-01 12:26:49 +03:30
parent 1daa755543
commit 962238c32c
29 changed files with 3048 additions and 207 deletions

View File

@@ -1,7 +1,11 @@
from django.conf import settings
from django.core.files.base import ContentFile
from django.shortcuts import get_object_or_404
from django.db.models import Q, Case, When, IntegerField, F
from django.db import transaction
from datetime import date, datetime, time as datetime_time, timedelta
from math import ceil
from django.db.models import Q, Case, When, IntegerField, F, Count, Max
from django.db.models.functions import Lower
from django.utils.text import slugify
from django.utils import timezone
@@ -17,6 +21,7 @@ from apps.events.api.schemas import (
EventCreateSchema,
EventGallerySchema,
EventListSchema,
PaginatedEventBrowseSchema,
EventSchema,
EventUpdateSchema,
MyEventRegistrationOut,
@@ -26,14 +31,22 @@ from apps.events.api.schemas import (
RegistrationCreateSchema,
RegistrationSchema,
RegistrationStatusOut,
RegistrationHubOut,
RegistrationStatusUpdateSchema,
)
from core.authentication import jwt_auth
from apps.events.models import Event, Registration
from apps.events.checkout import (
CHECKOUT_TIMEOUT,
cancel_event_checkouts,
expire_stale_registration_checkouts,
mark_checkout_cancelled,
queue_checkout_reconciliation,
)
from apps.gallery.models import Gallery
from apps.gallery.tasks import process_uploaded_image
from apps.notifications.services import notify_user
from apps.payments.models import DiscountCode
from apps.payments.models import DiscountCode, Payment
from apps.users.tasks import send_critical_sms
from core.api.schemas import ErrorSchema, MessageSchema
@@ -138,6 +151,143 @@ def list_events(
events = queryset[offset:offset + limit]
return events
def _event_registration_open_query(now):
return (
Q(status=Event.StatusChoices.PUBLISHED)
& (Q(registration_start_date__isnull=True) | Q(registration_start_date__lte=now))
& (Q(registration_end_date__isnull=True) | Q(registration_end_date__gte=now))
& (Q(capacity__isnull=True) | Q(capacity__gt=F("browse_registration_count")))
)
def _parse_browse_date(value: Optional[str], field_name: str):
if not value:
return None
try:
parsed = date.fromisoformat(value)
except ValueError as error:
raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.") from error
if parsed.isoformat() != value:
raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.")
return timezone.make_aware(
datetime.combine(parsed, datetime_time.min),
timezone.get_current_timezone(),
)
@events_router.get("/browse/", response=PaginatedEventBrowseSchema)
def browse_events(
request,
state: Literal["all", "completed", "upcoming", "open"] = "all",
event_type: Optional[str] = None,
search: Optional[str] = None,
date_from: Optional[str] = None,
date_to: Optional[str] = None,
price_min: Optional[int] = None,
price_max: Optional[int] = None,
free_only: bool = False,
page: int = 1,
):
"""Browse public events; price bounds and ceiling are in tomans, stored event prices are rials."""
page_size = 12
if page < 1:
raise HttpError(400, "شماره صفحه باید بزرگ‌تر از صفر باشد.")
if event_type and event_type not in Event.TypeChoices.values:
raise HttpError(400, "نوع رویداد معتبر نیست.")
if price_min is not None and price_min < 0:
raise HttpError(400, "حداقل قیمت نمی‌تواند منفی باشد.")
if price_max is not None and price_max < 0:
raise HttpError(400, "حداکثر قیمت نمی‌تواند منفی باشد.")
if price_min is not None and price_max is not None and price_min > price_max:
raise HttpError(400, "حداقل قیمت نمی‌تواند از حداکثر قیمت بیشتر باشد.")
selected_from = _parse_browse_date(date_from, "date_from")
selected_to = _parse_browse_date(date_to, "date_to")
if selected_from and selected_to and selected_from > selected_to:
raise HttpError(400, "تاریخ شروع نمی‌تواند بعد از تاریخ پایان باشد.")
public_statuses = [Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED]
public_events = Event.objects.filter(is_deleted=False, status__in=public_statuses)
highest_price_rial = public_events.aggregate(value=Max("price"))["value"] or 0
price_ceiling = ceil(highest_price_rial / 10 / 1_000_000) * 1_000_000
now = timezone.now()
queryset = (
public_events
.annotate(
browse_registration_count=Count(
"registrations",
filter=Q(
registrations__is_deleted=False,
) & (
Q(registrations__status__in=[
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
])
| Q(
registrations__status=Registration.StatusChoices.PENDING,
registrations__checkout_expires_at__gt=now,
)
),
distinct=True,
)
)
.prefetch_related("gallery_images")
)
if state == "completed":
queryset = queryset.filter(
Q(status=Event.StatusChoices.COMPLETED) | Q(end_time__lte=now)
)
elif state == "upcoming":
queryset = queryset.filter(
status=Event.StatusChoices.PUBLISHED,
start_time__gt=now,
).exclude(_event_registration_open_query(now))
elif state == "open":
queryset = queryset.filter(_event_registration_open_query(now))
if event_type:
queryset = queryset.filter(event_type=event_type)
if search and search.strip():
queryset = queryset.filter(
Q(title__icontains=search.strip()) | Q(description__icontains=search.strip())
)
if selected_from:
queryset = queryset.filter(start_time__gte=selected_from)
if selected_to:
queryset = queryset.filter(start_time__lt=selected_to + timedelta(days=1))
if free_only:
queryset = queryset.filter(Q(price__isnull=True) | Q(price__lte=0))
else:
if price_min is not None:
queryset = queryset.filter(price__gte=price_min * 10)
if price_max is not None:
queryset = queryset.filter(price__lte=price_max * 10)
queryset = queryset.annotate(
published_first=Case(
When(status=Event.StatusChoices.PUBLISHED, then=0),
default=1,
output_field=IntegerField(),
)
).order_by("published_first", "-start_time", "-id")
count = queryset.count()
total_pages = ceil(count / page_size) if count else 0
current_page = min(page, total_pages) if total_pages else 1
results = queryset[(current_page - 1) * page_size:current_page * page_size]
return {
"count": count,
"page": current_page,
"page_size": page_size,
"total_pages": total_pages,
"price_ceiling": price_ceiling,
"results": results,
}
@events_router.get("/{int:event_id}", response=EventSchema)
def get_event(request, event_id: int):
"""Get event details by ID"""
@@ -224,6 +374,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
)
if cancelled_now:
cancel_event_checkouts(event.id)
_notify_event_update(
event,
notification_type="event_cancelled",
@@ -244,6 +395,45 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
return 200, event
@events_router.post("/{int:event_id}/checkout/cancel", response=MessageSchema, auth=jwt_auth)
def cancel_registration_checkout(request, event_id: int):
"""Cancel the user's active paid checkout and release its seat."""
with transaction.atomic():
event = get_object_or_404(
Event.objects.select_for_update(),
id=event_id,
is_deleted=False,
)
registration = (
Registration.objects.select_for_update()
.filter(
event=event,
user=request.auth,
status=Registration.StatusChoices.PENDING,
checkout_expires_at__isnull=False,
is_deleted=False,
)
.order_by("-registered_at", "-id")
.first()
)
if registration is None:
raise HttpError(409, "ثبت‌نام فعالی برای لغو وجود ندارد.")
payment = Payment.objects.select_for_update().filter(
registration=registration,
).order_by("-created_at", "-id").first()
if payment and payment.status == Payment.OrderStatusChoices.PAID:
raise HttpError(409, "پرداخت تأیید شده است و امکان لغو پرداخت وجود ندارد.")
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.USER_CANCELLED,
)
queue_checkout_reconciliation(registration)
return {"message": "درخواست پرداخت لغو شد و ظرفیت آزاد شد."}
@events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth)
def delete_event(request, event_id: int):
"""Soft delete an event"""
@@ -407,79 +597,109 @@ def register_for_event(
payload: RegistrationCreateSchema | None = None,
):
"""Register current user for an event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
user = request.auth
now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=event_id)
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.")
with transaction.atomic():
event = get_object_or_404(
Event.objects.select_for_update(),
id=event_id,
is_deleted=False,
)
if Registration.objects.filter(
event=event,
user=user,
status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
is_deleted=False,
).exists():
raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.")
if event.registration_end_date and event.registration_end_date < timezone.now():
raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است")
if event.registration_start_date and event.registration_start_date > timezone.now():
raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است")
registration = (
Registration.objects.select_for_update()
.filter(event=event, user=user, is_deleted=False)
.order_by("-registered_at", "-id")
.first()
)
if registration and registration.status == Registration.StatusChoices.PENDING:
if registration.checkout_expires_at is None:
# Preserve pre-timeout rows without backfilling or expiring them.
if registration.final_price is None:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
return registration
if registration.checkout_expires_at > now:
# Existing checkouts may finish through their original window,
# even if registration has since closed or its held seat is full.
return registration
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
)
if not event.has_available_slots:
raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است")
unresolved_attempt = Payment.objects.filter(
user=user,
event=event,
registration__cancellation_reason__in=[
Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
Registration.CancellationReasonChoices.USER_CANCELLED,
Registration.CancellationReasonChoices.EVENT_CANCELLED,
],
).filter(
Q(status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING])
| Q(
status=Payment.OrderStatusChoices.PAID,
refund_status__in=[
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
],
)
).exists()
if unresolved_attempt:
raise HttpError(409, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.")
# Create or get existing registration
discount_code = None
if payload and payload.discount_code:
discount_code = payload.discount_code
elif request.GET.get("discount_code"):
discount_code = request.GET.get("discount_code")
if event.status != Event.StatusChoices.PUBLISHED:
raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.")
if event.registration_end_date and event.registration_end_date < now:
raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است")
if event.registration_start_date and event.registration_start_date > now:
raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است")
if not event.has_available_slots:
raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است")
registration, created = Registration.objects.get_or_create(
event=event,
user=user,
status=Registration.StatusChoices.PENDING,
defaults={"final_price": event.price},
)
discount_code_value = (
payload.discount_code if payload and payload.discount_code
else request.GET.get("discount_code")
)
applied_code = None
discount_amount = 0
final_price = event.price or 0
if discount_code_value:
applied_code = DiscountCode.objects.filter(
code=discount_code_value,
applicable_events=event,
is_active=True,
).first()
if not applied_code:
raise HttpError(400, "کد تخفیف برای این رویداد معتبر نیست.")
final_price, discount_amount = applied_code.calculate_discount(event, user)
if registration.status == Registration.StatusChoices.CONFIRMED:
return HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید")
if registration.status == Registration.StatusChoices.CANCELLED:
is_free = final_price == 0
registration = Registration.objects.create(
event=event,
user=user,
status=Registration.StatusChoices.PENDING,
final_price=event.price,
status=(
Registration.StatusChoices.CONFIRMED
if is_free
else Registration.StatusChoices.PENDING
),
checkout_started_at=None if is_free else now,
checkout_expires_at=None if is_free else now + CHECKOUT_TIMEOUT,
discount_code=applied_code,
discount_amount=discount_amount,
final_price=final_price,
)
elif not created and registration.final_price is None:
registration.final_price = event.price
registration.save(update_fields=["final_price"])
applied_code = None
discount_amount = 0
final_price = event.price
fields_to_update = []
if discount_code:
applied_code = DiscountCode.objects.filter(
code=discount_code,
applicable_events=event,
is_active=True,
).first()
if not applied_code:
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
final_price, discount_amount = applied_code.calculate_discount(event, user)
registration.discount_code = applied_code
registration.discount_amount = discount_amount
fields_to_update.extend(["discount_code", "discount_amount"])
if registration.final_price != final_price:
registration.final_price = final_price
fields_to_update.append("final_price")
if not event.price or final_price == 0:
registration.status = Registration.StatusChoices.CONFIRMED
fields_to_update.append("status")
if fields_to_update:
registration.save(update_fields=list(set(fields_to_update)))
return registration
return registration
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema):
@@ -488,11 +708,36 @@ def update_registration_status(request, registration_id: int, payload: Registrat
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
requested_status = payload.dict(exclude_unset=True).get('status')
if requested_status == Registration.StatusChoices.ATTENDED:
raise HttpError(403, "حضور رویداد فقط توسط برگزارکننده تأیید می‌شود.")
registration.status = requested_status
registration.full_clean()
registration.save()
if requested_status != Registration.StatusChoices.CANCELLED:
raise HttpError(403, "وضعیت ثبت‌نام فقط پس از تأیید پرداخت توسط سامانه تغییر می‌کند.")
with transaction.atomic():
Event.objects.select_for_update().get(pk=registration.event_id)
registration = Registration.objects.select_for_update().get(pk=registration.pk)
payment = Payment.objects.select_for_update().filter(
registration=registration,
).order_by("-created_at", "-id").first()
if payment and payment.status == Payment.OrderStatusChoices.PAID:
raise HttpError(409, "پرداخت تأیید شده است؛ امکان لغو این ثبت‌نام وجود ندارد.")
if payment and payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
) and not registration.checkout_expires_at:
raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.")
if registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at:
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.USER_CANCELLED,
)
queue_checkout_reconciliation(registration)
elif payment and payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
):
raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.")
else:
registration.status = requested_status
registration.full_clean()
registration.save()
return registration
@@ -501,8 +746,33 @@ def cancel_registration(request, registration_id: int):
"""Cancel a registration"""
user = request.auth
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
registration.delete()
registration = get_object_or_404(
Registration,
id=registration_id,
user=user,
is_deleted=False,
)
with transaction.atomic():
Event.objects.select_for_update().get(pk=registration.event_id)
registration = Registration.objects.select_for_update().get(pk=registration.pk)
payment = Payment.objects.select_for_update().filter(
registration=registration,
).order_by("-created_at", "-id").first()
if payment and payment.status == Payment.OrderStatusChoices.PAID:
raise HttpError(409, "پرداخت این ثبت‌نام تأیید شده است؛ برای پیگیری با پشتیبانی تماس بگیرید.")
if registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at:
mark_checkout_cancelled(
registration,
Registration.CancellationReasonChoices.USER_CANCELLED,
)
queue_checkout_reconciliation(registration)
elif payment and payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
):
raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.")
else:
registration.delete()
return {"message": "ثبت‌نام شما لغو شد :("}
@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth)
@@ -523,6 +793,53 @@ def verify_my_registration(request, ticket_id: UUID):
def _my_registration_checkout_fields(registration, now):
payment = registration.payments.order_by("-created_at", "-id").first()
checkout_state = "none"
can_resume = False
if registration.status == Registration.StatusChoices.PENDING:
if payment and payment.status == Payment.OrderStatusChoices.PAID:
checkout_state = "unresolved"
elif registration.checkout_expires_at is None:
checkout_state = "legacy_pending"
elif registration.checkout_expires_at > now:
checkout_state = "active"
can_resume = not bool(payment and payment.verification_manual_review_at)
else:
checkout_state = "expired"
elif registration.status == Registration.StatusChoices.CANCELLED:
checkout_state = "cancelled"
if payment and (
payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING)
or (
payment.status == Payment.OrderStatusChoices.PAID
and payment.refund_status in (
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
)
)
):
checkout_state = "unresolved"
payment_status = None
if payment:
payment_status = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}[payment.status]
return {
"checkout_state": checkout_state,
"checkout_expires_at": registration.checkout_expires_at,
"payment_id": payment.id if payment else None,
"payment_status": payment_status,
"refund_status": payment.refund_status if payment else None,
"can_resume": can_resume,
}
@events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth)
def my_registrations_paginated(
request,
@@ -542,11 +859,14 @@ def my_registrations_paginated(
count = queryset.count()
total_pages = max(1, (count + page_size - 1) // page_size)
start = (page - 1) * page_size
now = timezone.now()
results = [
MyEventRegistrationOut(
id=registration.id,
ticket_id=registration.ticket_id,
created_at=registration.created_at,
status=registration.status,
**_my_registration_checkout_fields(registration, now),
event=EventBriefSchema(
id=registration.event.id,
title=registration.event.title,
@@ -582,12 +902,15 @@ def my_registrations(request):
.order_by("-created_at")
)
out: List[MyEventRegistrationOut] = []
now = timezone.now()
for r in qs:
out.append(
MyEventRegistrationOut(
id=r.id,
ticket_id=r.ticket_id,
created_at=r.created_at,
status=r.status,
**_my_registration_checkout_fields(r, now),
event=EventBriefSchema(
id=r.event.id,
title=r.event.title,
@@ -604,12 +927,337 @@ def my_registrations(request):
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
def is_registered(request, event_id: int):
exists = Registration.objects.filter(
user=request.auth,
event_id=event_id,
status=Registration.StatusChoices.CONFIRMED
).exists()
return {"is_registered": exists}
now = timezone.now()
registration = (
Registration.objects.filter(
user=request.auth,
event_id=event_id,
is_deleted=False,
)
.order_by("-registered_at", "-id")
.first()
)
if registration and registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at and registration.checkout_expires_at <= now:
expire_stale_registration_checkouts(now=now, event_id=event_id)
registration.refresh_from_db()
payment = None
if registration:
payment = registration.payments.order_by("-created_at", "-id").first()
is_confirmed = bool(
registration
and registration.status in [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]
)
checkout_state = "none"
can_resume = False
can_cancel = False
if is_confirmed:
checkout_state = "confirmed"
elif registration and registration.status == Registration.StatusChoices.PENDING:
if payment and payment.status == Payment.OrderStatusChoices.PAID:
checkout_state = "unresolved"
elif registration.checkout_expires_at is None:
checkout_state = "legacy_pending"
elif registration.checkout_expires_at > now:
checkout_state = "active"
can_resume = not bool(payment and payment.verification_manual_review_at)
can_cancel = True
else:
checkout_state = "expired"
elif registration and registration.status == Registration.StatusChoices.CANCELLED:
checkout_state = "cancelled"
if payment and (
payment.status in [Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING]
or (
payment.status == Payment.OrderStatusChoices.PAID
and payment.refund_status in [
Payment.RefundStatusChoices.PENDING,
Payment.RefundStatusChoices.MANUAL_REVIEW,
]
)
):
checkout_state = "unresolved"
payment_status = None
if payment:
payment_status = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}[payment.status]
return {
"is_registered": is_confirmed,
"checkout_state": checkout_state,
"registration_id": registration.id if registration else None,
"registration_status": registration.status if registration else None,
"ticket_id": str(registration.ticket_id) if registration else None,
"checkout_expires_at": registration.checkout_expires_at if registration else None,
"payment_id": payment.id if payment else None,
"payment_status": payment_status,
"refund_status": payment.refund_status if payment else None,
"can_resume": can_resume,
"can_cancel": can_cancel,
}
def _registration_hub_state(registration, payment, now):
if payment:
if payment.refund_status == Payment.RefundStatusChoices.PENDING:
return "refund_pending"
if payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW:
return "refund_manual_review"
if payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED:
return "refund_succeeded"
if payment.status == Payment.OrderStatusChoices.FAILED:
return "failed"
if payment.status == Payment.OrderStatusChoices.CANCELED:
return "cancelled"
if payment.verification_manual_review_at:
return "manual_review"
if payment.status == Payment.OrderStatusChoices.PAID:
if registration and registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
):
return "paid_success"
# A charge without a confirmed registration must not be presented as
# a successful registration. Keep the saved transaction visible for review.
return "manual_review"
if payment.status in (
Payment.OrderStatusChoices.INIT,
Payment.OrderStatusChoices.PENDING,
):
if registration and registration.status == Registration.StatusChoices.PENDING:
if registration.checkout_expires_at is None:
return "legacy_pending"
if registration.checkout_expires_at > now:
return "active_checkout"
return "verification_pending"
if registration:
if registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
):
return "free_success" if (registration.final_price or 0) <= 0 else "manual_review"
if registration.status == Registration.StatusChoices.PENDING:
if registration.checkout_expires_at is None:
return "legacy_pending"
if registration.checkout_expires_at > now:
return "active_checkout"
return "expired"
if registration.status == Registration.StatusChoices.CANCELLED:
if registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED:
return "expired"
if registration.cancellation_reason == Registration.CancellationReasonChoices.PAYMENT_FAILED:
return "failed"
return "cancelled"
return "ready"
def _registration_hub_records(event, user, registration_id, payment_id, now):
if registration_id:
registration = Registration.objects.filter(
ticket_id=registration_id,
event=event,
user=user,
is_deleted=False,
).first()
if registration is None:
return None, None
payment = registration.payments.order_by("-created_at", "-id").first()
return registration, payment
if payment_id:
payment = Payment.objects.select_related("registration").filter(
pk=payment_id,
event=event,
user=user,
).first()
if payment is None:
return None, None
registration = payment.registration
if registration is not None and (
registration.event_id != event.id or registration.user_id != user.id
):
registration = None
return registration, payment
registrations = list(
Registration.objects.filter(event=event, user=user, is_deleted=False)
.prefetch_related("payments")
.order_by("-registered_at", "-id")
)
registration_payments = {
registration.id: registration.payments.order_by("-created_at", "-id").first()
for registration in registrations
}
payments = list(
Payment.objects.filter(event=event, user=user)
.select_related("registration")
.order_by("-created_at", "-id")
)
unresolved = []
for registration in registrations:
payment = registration_payments[registration.id]
state = _registration_hub_state(registration, payment, now)
if state in {
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
"refund_pending", "refund_manual_review",
}:
unresolved.append((registration.registered_at, registration, payment))
linked_registration_ids = set(registration_payments)
for payment in payments:
if payment.registration_id in linked_registration_ids:
continue
state = _registration_hub_state(payment.registration, payment, now)
if state in {
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
"refund_pending", "refund_manual_review",
}:
unresolved.append((payment.created_at, payment.registration, payment))
if unresolved:
_, registration, payment = max(unresolved, key=lambda item: item[0])
return registration, payment
if registrations:
registration = registrations[0]
return registration, registration_payments[registration.id]
if payments:
return payments[0].registration, payments[0]
return None, None
@events_router.get("/{str:slug}/registration/status", response=RegistrationHubOut, auth=jwt_auth)
def registration_hub_status(
request,
slug: str,
registration_id: Optional[UUID] = None,
payment_id: Optional[int] = None,
):
"""Return the authenticated user's authoritative registration/payment view for an event."""
if registration_id and payment_id:
raise HttpError(400, "فقط یکی از شناسه‌های ثبت‌نام یا پرداخت را ارسال کنید.")
event = Event.objects.filter(slug=slug, is_deleted=False).first()
if event is None:
lookup = {"pk": int(slug)} if slug.isdecimal() else {"slug": slug}
event = get_object_or_404(Event, is_deleted=False, **lookup)
now = timezone.now()
expire_stale_registration_checkouts(now=now, event_id=event.id)
registration, payment = _registration_hub_records(
event,
request.auth,
registration_id,
payment_id,
now,
)
if (registration_id or payment_id) and registration is None and payment is None:
raise HttpError(404, "وضعیت ثبت‌نام یا پرداخت پیدا نشد.")
state = _registration_hub_state(registration, payment, now)
unresolved = state in {
"active_checkout", "legacy_pending", "verification_pending", "manual_review",
"refund_pending", "refund_manual_review",
}
can_resume = bool(
state == "active_checkout"
and registration
and registration.status == Registration.StatusChoices.PENDING
and (payment is None or not payment.verification_manual_review_at)
)
can_cancel = bool(
state == "active_checkout"
and registration
and registration.status == Registration.StatusChoices.PENDING
and registration.checkout_expires_at
and registration.checkout_expires_at > now
)
registration_is_settled = bool(
registration
and registration.status in (
Registration.StatusChoices.CONFIRMED,
Registration.StatusChoices.ATTENDED,
)
)
registration_window_open = bool(
event.status == Event.StatusChoices.PUBLISHED
and event.is_registration_open
and event.has_available_slots
)
can_register = bool(registration_window_open and not unresolved and not registration_is_settled)
image_url = None
if event.featured_image and hasattr(event.featured_image, "url"):
image_url = request.build_absolute_uri(event.featured_image.url)
payment_status = None
if payment:
payment_status = {
Payment.OrderStatusChoices.INIT: "initiated",
Payment.OrderStatusChoices.PENDING: "pending",
Payment.OrderStatusChoices.PAID: "paid",
Payment.OrderStatusChoices.FAILED: "failed",
Payment.OrderStatusChoices.CANCELED: "canceled",
}[payment.status]
return {
"state": state,
"event": {
"id": event.id,
"title": event.title,
"slug": event.slug,
"description": event.description,
"event_type": event.event_type,
"address": event.address,
"location": event.location,
"start_time": event.start_time,
"end_time": event.end_time,
"registration_start_date": event.registration_start_date,
"registration_end_date": event.registration_end_date,
"capacity": event.capacity,
"price": event.price,
"status": event.status,
"registration_count": event.current_attendees_count,
"image_url": image_url,
"registration_success_markdown": event.registration_success_markdown,
},
"registration": {
"ticket_id": registration.ticket_id,
"status": registration.status,
"registered_at": registration.registered_at,
"checkout_started_at": registration.checkout_started_at,
"checkout_expires_at": registration.checkout_expires_at,
"cancellation_reason": registration.cancellation_reason,
"final_price": registration.final_price,
"discount_amount": registration.discount_amount,
} if registration else None,
"payment": {
"id": payment.id,
"status": payment_status,
"verification_state": payment.verification_state,
"refund_status": payment.refund_status,
"base_amount": payment.base_amount,
"discount_amount": payment.discount_amount,
"amount": payment.amount,
"ref_id": payment.ref_id,
"created_at": payment.created_at,
"gateway_paid_at": payment.gateway_paid_at,
} if payment else None,
"can_register": can_register,
"can_resume": can_resume,
"can_cancel": can_cancel,
}
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
def event_admin_detail(request, event_id: int):
user = request.auth