feat(events): harden registration checkout lifecycle
This commit is contained in:
@@ -7,6 +7,7 @@ from core.templatetags.jalali import jdate
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.events.resources import EventResource, RegistrationResource
|
||||
from apps.events.services import verify_registration_attendance
|
||||
from apps.events.checkout import cancel_event_checkouts
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
@@ -79,8 +80,14 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
|
||||
@admin.action(description='Mark selected events as cancelled')
|
||||
def make_cancelled(self, request, queryset):
|
||||
queryset.update(status=Event.StatusChoices.CANCELLED)
|
||||
self.message_user(request, f'Cancelled {queryset.count()} events.')
|
||||
count = 0
|
||||
for event in queryset:
|
||||
if event.status != Event.StatusChoices.CANCELLED:
|
||||
event.status = Event.StatusChoices.CANCELLED
|
||||
event.save(update_fields=['status'])
|
||||
cancel_event_checkouts(event.pk)
|
||||
count += 1
|
||||
self.message_user(request, f'Cancelled {count} events.')
|
||||
|
||||
@admin.action(description='Mark selected events as completed')
|
||||
def make_completed(self, request, queryset):
|
||||
@@ -93,15 +100,27 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
event.restore()
|
||||
self.message_user(request, f'Restored {queryset.count()} events.')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
previous_status = None
|
||||
if change and obj.pk:
|
||||
previous_status = Event.all_objects.filter(pk=obj.pk).values_list("status", flat=True).first()
|
||||
super().save_model(request, obj, form, change)
|
||||
if previous_status != Event.StatusChoices.CANCELLED and obj.status == Event.StatusChoices.CANCELLED:
|
||||
cancel_event_checkouts(obj.pk)
|
||||
|
||||
|
||||
@admin.register(Registration)
|
||||
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
resource_class = RegistrationResource
|
||||
list_display = (
|
||||
'user', 'event', 'status', 'registered_at', 'ticket_id',
|
||||
'user', 'event', 'status', 'registered_at', 'checkout_expires_at',
|
||||
'cancellation_reason', 'ticket_id',
|
||||
'discount_code', 'discount_amount', 'final_price',
|
||||
)
|
||||
list_filter = ('status', 'event', 'is_deleted', 'registered_at', SoftDeleteListFilter)
|
||||
list_filter = (
|
||||
'status', 'cancellation_reason', 'event', 'is_deleted', 'registered_at',
|
||||
SoftDeleteListFilter,
|
||||
)
|
||||
search_fields = (
|
||||
'user__username', 'user__email', 'user__first_name', 'user__last_name',
|
||||
'event__title', 'ticket_id',
|
||||
@@ -109,13 +128,15 @@ class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
readonly_fields = (
|
||||
'ticket_id', 'registered_at', 'confirmation_notification_sent_at',
|
||||
'cancellation_notification_sent_at', 'discount_code', 'discount_amount',
|
||||
'final_price', 'deleted_at', 'attendance_verified_at',
|
||||
'final_price', 'checkout_started_at', 'checkout_expires_at',
|
||||
'cancellation_reason', 'deleted_at', 'attendance_verified_at',
|
||||
'attendance_verified_by', 'attendance_verification_source',
|
||||
)
|
||||
fieldsets = (
|
||||
('Registration Details', {'fields': (
|
||||
'user', 'event', 'status', 'registered_at', 'ticket_id',
|
||||
'confirmation_notification_sent_at', 'cancellation_notification_sent_at',
|
||||
'checkout_started_at', 'checkout_expires_at', 'cancellation_reason',
|
||||
)}),
|
||||
('Attendance verification', {'fields': (
|
||||
'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source',
|
||||
|
||||
@@ -5,6 +5,8 @@ from ninja import ModelSchema, Schema
|
||||
from pydantic import field_validator
|
||||
from typing import Literal, Optional, List
|
||||
from datetime import datetime
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.blog.api.schemas import AuthorSchema
|
||||
from apps.events.models import Event, Registration
|
||||
@@ -85,7 +87,11 @@ class EventSchema(ModelSchema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
now = timezone.now()
|
||||
return obj.registrations.filter(is_deleted=False).filter(
|
||||
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||
| Q(status=Registration.StatusChoices.PENDING, checkout_expires_at__gt=now)
|
||||
).count()
|
||||
|
||||
@staticmethod
|
||||
def resolve_description_html(obj):
|
||||
@@ -102,6 +108,7 @@ class EventListSchema(Schema):
|
||||
absolute_featured_image_thumbnail_url: Optional[str] = None
|
||||
absolute_featured_image_preview_url: Optional[str] = None
|
||||
event_type: str
|
||||
address: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
@@ -133,7 +140,22 @@ class EventListSchema(Schema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
if hasattr(obj, "browse_registration_count"):
|
||||
return obj.browse_registration_count
|
||||
now = timezone.now()
|
||||
return obj.registrations.filter(is_deleted=False).filter(
|
||||
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||
| Q(status=Registration.StatusChoices.PENDING, checkout_expires_at__gt=now)
|
||||
).count()
|
||||
|
||||
|
||||
class PaginatedEventBrowseSchema(Schema):
|
||||
count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
price_ceiling: int
|
||||
results: List[EventListSchema]
|
||||
|
||||
class EventCreateSchema(Schema):
|
||||
"""Payload for creating events via the API."""
|
||||
@@ -185,6 +207,9 @@ class RegistrationSchema(ModelSchema):
|
||||
'id',
|
||||
'status',
|
||||
'registered_at',
|
||||
'checkout_started_at',
|
||||
'checkout_expires_at',
|
||||
'cancellation_reason',
|
||||
'ticket_id',
|
||||
'discount_amount',
|
||||
'final_price',
|
||||
@@ -326,9 +351,16 @@ class EventBriefSchema(Schema):
|
||||
class MyEventRegistrationOut(Schema):
|
||||
"""Registration information as returned to authenticated users."""
|
||||
id: int
|
||||
ticket_id: UUID
|
||||
created_at: datetime
|
||||
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||
event: EventBriefSchema
|
||||
checkout_state: Literal["none", "active", "legacy_pending", "expired", "unresolved", "cancelled"] = "none"
|
||||
checkout_expires_at: Optional[datetime] = None
|
||||
payment_id: Optional[int] = None
|
||||
payment_status: Optional[Literal["initiated", "pending", "paid", "failed", "canceled"]] = None
|
||||
refund_status: Optional[Literal["none", "pending", "succeeded", "manual_review"]] = None
|
||||
can_resume: bool = False
|
||||
|
||||
|
||||
class PaginatedMyEventRegistrationsSchema(Schema):
|
||||
@@ -340,6 +372,79 @@ class PaginatedMyEventRegistrationsSchema(Schema):
|
||||
|
||||
class RegistrationStatusOut(Schema):
|
||||
is_registered: bool
|
||||
checkout_state: Literal[
|
||||
"none", "confirmed", "active", "legacy_pending", "expired", "unresolved", "cancelled"
|
||||
] = "none"
|
||||
registration_id: Optional[int] = None
|
||||
registration_status: Optional[str] = None
|
||||
ticket_id: Optional[UUID] = None
|
||||
checkout_expires_at: Optional[datetime] = None
|
||||
payment_id: Optional[int] = None
|
||||
payment_status: Optional[Literal["initiated", "pending", "paid", "failed", "canceled"]] = None
|
||||
refund_status: Optional[Literal["none", "pending", "succeeded", "manual_review"]] = None
|
||||
can_resume: bool = False
|
||||
can_cancel: bool = False
|
||||
|
||||
|
||||
class RegistrationHubEventOut(Schema):
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
description: str
|
||||
event_type: str
|
||||
address: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: int
|
||||
status: str
|
||||
registration_count: int
|
||||
image_url: Optional[str] = None
|
||||
registration_success_markdown: Optional[str] = None
|
||||
|
||||
|
||||
class RegistrationHubRegistrationOut(Schema):
|
||||
ticket_id: UUID
|
||||
status: str
|
||||
registered_at: datetime
|
||||
checkout_started_at: Optional[datetime] = None
|
||||
checkout_expires_at: Optional[datetime] = None
|
||||
cancellation_reason: str = ""
|
||||
final_price: Optional[int] = None
|
||||
discount_amount: int = 0
|
||||
|
||||
|
||||
class RegistrationHubPaymentOut(Schema):
|
||||
id: int
|
||||
status: Literal["initiated", "pending", "paid", "failed", "canceled"]
|
||||
verification_state: Literal[
|
||||
"awaiting_verification", "checking", "retry_scheduled", "manual_review",
|
||||
"verified", "failed", "canceled",
|
||||
]
|
||||
refund_status: Literal["none", "pending", "succeeded", "manual_review"]
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
ref_id: Optional[str] = None
|
||||
created_at: datetime
|
||||
gateway_paid_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class RegistrationHubOut(Schema):
|
||||
state: Literal[
|
||||
"ready", "active_checkout", "legacy_pending", "verification_pending",
|
||||
"manual_review", "paid_success", "free_success", "failed", "cancelled",
|
||||
"expired", "refund_pending", "refund_manual_review", "refund_succeeded",
|
||||
]
|
||||
event: RegistrationHubEventOut
|
||||
registration: Optional[RegistrationHubRegistrationOut] = None
|
||||
payment: Optional[RegistrationHubPaymentOut] = None
|
||||
can_register: bool = False
|
||||
can_resume: bool = False
|
||||
can_cancel: bool = False
|
||||
|
||||
|
||||
class RegistrationCreateSchema(Schema):
|
||||
|
||||
@@ -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
|
||||
|
||||
136
apps/events/checkout.py
Normal file
136
apps/events/checkout.py
Normal file
@@ -0,0 +1,136 @@
|
||||
"""Registration checkout lifecycle rules shared by API views and workers."""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.events.models import Event, Registration
|
||||
|
||||
|
||||
CHECKOUT_TIMEOUT = timedelta(minutes=15)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _dispatch_reconciliation(payment_id):
|
||||
from apps.payments.tasks import reconcile_payment
|
||||
|
||||
try:
|
||||
reconcile_payment.apply_async(args=(payment_id,), countdown=0)
|
||||
except Exception:
|
||||
logger.exception("Unable to dispatch checkout payment %s for reconciliation", payment_id)
|
||||
|
||||
|
||||
def mark_checkout_cancelled(registration, reason):
|
||||
"""Cancel a pending checkout without deleting its historical registration."""
|
||||
if registration.status != Registration.StatusChoices.PENDING:
|
||||
return False
|
||||
|
||||
registration.status = Registration.StatusChoices.CANCELLED
|
||||
registration.cancellation_reason = reason
|
||||
registration.save(update_fields=[
|
||||
"status",
|
||||
"cancellation_reason",
|
||||
"updated_at",
|
||||
])
|
||||
return True
|
||||
|
||||
|
||||
def queue_checkout_reconciliation(registration):
|
||||
"""Reconcile each unresolved payment after a checkout is cancelled."""
|
||||
from apps.payments.models import Payment
|
||||
|
||||
payments = list(
|
||||
Payment.objects.filter(
|
||||
registration=registration,
|
||||
status__in=[
|
||||
Payment.OrderStatusChoices.INIT,
|
||||
Payment.OrderStatusChoices.PENDING,
|
||||
],
|
||||
).only("id", "authority")
|
||||
)
|
||||
for payment in payments:
|
||||
if payment.authority:
|
||||
transaction.on_commit(
|
||||
lambda payment_id=payment.id: _dispatch_reconciliation(payment_id)
|
||||
)
|
||||
else:
|
||||
# The payment request may have reached the gateway before its
|
||||
# authority was persisted. Keep it visible for review until its
|
||||
# request response arrives or an administrator resolves it.
|
||||
Payment.objects.filter(
|
||||
pk=payment.id,
|
||||
status__in=[
|
||||
Payment.OrderStatusChoices.INIT,
|
||||
Payment.OrderStatusChoices.PENDING,
|
||||
],
|
||||
authority__isnull=True,
|
||||
).update(verification_manual_review_at=timezone.now())
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def expire_stale_registration_checkouts(*, now=None, event_id=None):
|
||||
"""Release seats for new-format pending checkouts whose deadline passed.
|
||||
|
||||
Legacy registrations have a null checkout deadline and are intentionally
|
||||
excluded from this timeout sweep.
|
||||
"""
|
||||
now = now or timezone.now()
|
||||
candidates = Registration.objects.filter(
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
is_deleted=False,
|
||||
checkout_expires_at__lte=now,
|
||||
)
|
||||
if event_id is not None:
|
||||
candidates = candidates.filter(event_id=event_id)
|
||||
|
||||
expired_count = 0
|
||||
for registration_id in candidates.values_list("id", flat=True).iterator():
|
||||
registration = (
|
||||
Registration.objects.select_for_update()
|
||||
.filter(pk=registration_id)
|
||||
.first()
|
||||
)
|
||||
if registration is None or registration.status != Registration.StatusChoices.PENDING:
|
||||
continue
|
||||
|
||||
reason = (
|
||||
Registration.CancellationReasonChoices.EVENT_CANCELLED
|
||||
if registration.event.status == Event.StatusChoices.CANCELLED
|
||||
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||
)
|
||||
if mark_checkout_cancelled(registration, reason):
|
||||
expired_count += 1
|
||||
queue_checkout_reconciliation(registration)
|
||||
|
||||
return expired_count
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def cancel_event_checkouts(event_id):
|
||||
"""Cancel active new-format checkouts when an organizer cancels an event."""
|
||||
event = Event.objects.select_for_update().filter(pk=event_id).first()
|
||||
if event is None:
|
||||
return 0
|
||||
|
||||
now = timezone.now()
|
||||
registrations = (
|
||||
Registration.objects.select_for_update()
|
||||
.filter(
|
||||
event=event,
|
||||
is_deleted=False,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
checkout_expires_at__isnull=False,
|
||||
)
|
||||
.order_by("pk")
|
||||
)
|
||||
cancelled_count = 0
|
||||
for registration in registrations:
|
||||
if mark_checkout_cancelled(
|
||||
registration,
|
||||
Registration.CancellationReasonChoices.EVENT_CANCELLED,
|
||||
):
|
||||
cancelled_count += 1
|
||||
queue_checkout_reconciliation(registration)
|
||||
return cancelled_count
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.5 on 2026-09-30 19:44
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0015_registration_attendance_verification_source_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='cancellation_reason',
|
||||
field=models.CharField(blank=True, choices=[('checkout_expired', 'Checkout expired'), ('user_cancelled', 'User cancelled checkout'), ('event_cancelled', 'Event cancelled'), ('payment_failed', 'Payment failed')], default='', editable=False, max_length=32),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='checkout_expires_at',
|
||||
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='checkout_started_at',
|
||||
field=models.DateTimeField(blank=True, editable=False, null=True),
|
||||
),
|
||||
]
|
||||
@@ -108,8 +108,15 @@ class Event(BaseModel):
|
||||
|
||||
@property
|
||||
def current_attendees_count(self):
|
||||
"""Count confirmed attendees"""
|
||||
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count()
|
||||
"""Count confirmed attendees and currently held paid-checkout seats."""
|
||||
now = timezone.now()
|
||||
return self.registrations.filter(is_deleted=False).filter(
|
||||
models.Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||
| models.Q(
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
checkout_expires_at__gt=now,
|
||||
)
|
||||
).count()
|
||||
|
||||
@property
|
||||
def has_available_slots(self):
|
||||
@@ -132,9 +139,24 @@ class Registration(BaseModel):
|
||||
SYSTEM = 'system', 'System'
|
||||
HISTORICAL = 'historical', 'Historical backfill'
|
||||
|
||||
class CancellationReasonChoices(models.TextChoices):
|
||||
CHECKOUT_EXPIRED = 'checkout_expired', 'Checkout expired'
|
||||
USER_CANCELLED = 'user_cancelled', 'User cancelled checkout'
|
||||
EVENT_CANCELLED = 'event_cancelled', 'Event cancelled'
|
||||
PAYMENT_FAILED = 'payment_failed', 'Payment failed'
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
||||
registered_at = models.DateTimeField(auto_now_add=True)
|
||||
checkout_started_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
checkout_expires_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
cancellation_reason = models.CharField(
|
||||
max_length=32,
|
||||
choices=CancellationReasonChoices.choices,
|
||||
blank=True,
|
||||
default='',
|
||||
editable=False,
|
||||
)
|
||||
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
||||
default=StatusChoices.PENDING)
|
||||
attendance_verified_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
@@ -5,12 +5,19 @@ import logging
|
||||
from celery import shared_task
|
||||
|
||||
from apps.events.models import Registration
|
||||
from apps.events.checkout import expire_stale_registration_checkouts
|
||||
from apps.notifications.services import notify_user
|
||||
from apps.users.tasks import send_critical_sms
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@shared_task(name="apps.events.expire_stale_registration_checkouts")
|
||||
def expire_stale_registration_checkouts_task():
|
||||
"""Release seats held by new checkout attempts that reached their deadline."""
|
||||
return expire_stale_registration_checkouts()
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_registration_confirmation_notification(self, registration_pk: str):
|
||||
try:
|
||||
|
||||
@@ -3,7 +3,7 @@ import io
|
||||
import json
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from datetime import datetime, time as datetime_time, timedelta
|
||||
from types import SimpleNamespace
|
||||
from PIL import Image
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
@@ -110,6 +110,115 @@ class TestEventsAPIIntegrationTests:
|
||||
assert response.status_code == 200
|
||||
assert any((item['id'] == event.id for item in response.json()))
|
||||
|
||||
def test_browse_events_returns_numbered_pages_and_global_price_ceiling(self):
|
||||
self._create_event(title='Price ceiling 650k toman', price=6500000)
|
||||
self._create_event(title='Price ceiling 4.3m toman', price=43000000)
|
||||
for index in range(13):
|
||||
self._create_event(title=f'Browse page event {index}', price=100000)
|
||||
|
||||
first_page = self.client.get('/api/events/browse/', {'page': 1})
|
||||
second_page = self.client.get('/api/events/browse/', {'page': 2})
|
||||
|
||||
assert first_page.status_code == 200
|
||||
assert first_page.json()['count'] == 17
|
||||
assert first_page.json()['page'] == 1
|
||||
assert first_page.json()['page_size'] == 12
|
||||
assert first_page.json()['total_pages'] == 2
|
||||
assert len(first_page.json()['results']) == 12
|
||||
assert second_page.json()['page'] == 2
|
||||
assert len(second_page.json()['results']) == 5
|
||||
assert first_page.json()['price_ceiling'] == 5000000
|
||||
assert 'address' in first_page.json()['results'][0]
|
||||
|
||||
def test_browse_events_upcoming_includes_future_events_that_cannot_register(self):
|
||||
now = timezone.now()
|
||||
open_event = self._create_event(
|
||||
title='Browse open event',
|
||||
start_time=now + timedelta(days=4),
|
||||
end_time=now + timedelta(days=4, hours=2),
|
||||
registration_start_date=now - timedelta(days=1),
|
||||
registration_end_date=now + timedelta(days=2),
|
||||
)
|
||||
not_started = self._create_event(
|
||||
title='Browse not started event',
|
||||
start_time=now + timedelta(days=5),
|
||||
end_time=now + timedelta(days=5, hours=2),
|
||||
registration_start_date=now + timedelta(days=1),
|
||||
)
|
||||
closed = self._create_event(
|
||||
title='Browse closed event',
|
||||
start_time=now + timedelta(days=6),
|
||||
end_time=now + timedelta(days=6, hours=2),
|
||||
registration_end_date=now - timedelta(minutes=1),
|
||||
)
|
||||
full = self._create_event(
|
||||
title='Browse full event',
|
||||
start_time=now + timedelta(days=7),
|
||||
end_time=now + timedelta(days=7, hours=2),
|
||||
capacity=1,
|
||||
)
|
||||
self._create_registration(full, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
|
||||
upcoming = self.client.get('/api/events/browse/', {'state': 'upcoming', 'search': 'Browse'}).json()
|
||||
open_results = self.client.get('/api/events/browse/', {'state': 'open', 'search': 'Browse'}).json()
|
||||
upcoming_ids = {item['id'] for item in upcoming['results']}
|
||||
open_ids = {item['id'] for item in open_results['results']}
|
||||
|
||||
assert {not_started.id, closed.id, full.id}.issubset(upcoming_ids)
|
||||
assert open_event.id not in upcoming_ids
|
||||
assert open_ids == {open_event.id}
|
||||
|
||||
def test_browse_events_filters_start_dates_inclusive_and_combines_type_price_and_free(self):
|
||||
selected_day = timezone.localdate()
|
||||
local_timezone = timezone.get_current_timezone()
|
||||
|
||||
def at(day, hour, minute):
|
||||
return timezone.make_aware(datetime.combine(day, datetime_time(hour, minute)), local_timezone)
|
||||
|
||||
start_day = self._create_event(
|
||||
title='Date range probe free',
|
||||
event_type=Event.TypeChoices.ONLINE,
|
||||
price=0,
|
||||
start_time=at(selected_day, 0, 0),
|
||||
end_time=at(selected_day, 2, 0),
|
||||
)
|
||||
end_day = self._create_event(
|
||||
title='Date range probe paid online',
|
||||
event_type=Event.TypeChoices.ONLINE,
|
||||
price=300000,
|
||||
start_time=at(selected_day, 23, 59),
|
||||
end_time=at(selected_day + timedelta(days=1), 1, 0),
|
||||
)
|
||||
self._create_event(
|
||||
title='Date range probe paid onsite',
|
||||
event_type=Event.TypeChoices.ON_SITE,
|
||||
price=300000,
|
||||
start_time=at(selected_day, 12, 0),
|
||||
end_time=at(selected_day, 14, 0),
|
||||
)
|
||||
outside_day = self._create_event(
|
||||
title='Date range probe outside',
|
||||
event_type=Event.TypeChoices.ONLINE,
|
||||
price=300000,
|
||||
start_time=at(selected_day + timedelta(days=1), 0, 0),
|
||||
end_time=at(selected_day + timedelta(days=1), 2, 0),
|
||||
)
|
||||
|
||||
params = {
|
||||
'search': 'Date range probe',
|
||||
'date_from': selected_day.isoformat(),
|
||||
'date_to': selected_day.isoformat(),
|
||||
'event_type': Event.TypeChoices.ONLINE,
|
||||
'price_min': 10000,
|
||||
'price_max': 40000,
|
||||
}
|
||||
paid_results = self.client.get('/api/events/browse/', params).json()
|
||||
free_results = self.client.get('/api/events/browse/', {**params, 'free_only': 'true'}).json()
|
||||
|
||||
assert {item['id'] for item in paid_results['results']} == {end_day.id}
|
||||
assert {item['id'] for item in free_results['results']} == {start_day.id}
|
||||
assert outside_day.id not in {item['id'] for item in paid_results['results']}
|
||||
|
||||
def test_list_events_handles_comma_status_parameter(self):
|
||||
event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED)
|
||||
results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0)
|
||||
|
||||
480
apps/events/tests/integration/test_registration_checkout.py
Normal file
480
apps/events/tests/integration/test_registration_checkout.py
Normal file
@@ -0,0 +1,480 @@
|
||||
import json
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
from django.test import Client
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.events.checkout import cancel_event_checkouts, expire_stale_registration_checkouts
|
||||
from apps.payments.models import Payment
|
||||
from apps.users.models import User
|
||||
from core.authentication import create_jwt_token
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@pytest.mark.integration
|
||||
class TestRegistrationCheckout:
|
||||
def setup_method(self):
|
||||
self.client = Client()
|
||||
suffix = uuid.uuid4().hex[:10]
|
||||
self.user = User.objects.create_user(
|
||||
username=f"checkout-{suffix}",
|
||||
email=f"checkout-{suffix}@example.com",
|
||||
mobile=f"0912{suffix[:7]}",
|
||||
password="CheckoutPass!123",
|
||||
)
|
||||
self.token = create_jwt_token(self.user)
|
||||
now = timezone.now()
|
||||
self.event = Event.objects.create(
|
||||
title="Checkout event",
|
||||
description="Paid checkout behavior test",
|
||||
start_time=now + timedelta(days=2),
|
||||
end_time=now + timedelta(days=2, hours=1),
|
||||
registration_start_date=now - timedelta(days=1),
|
||||
registration_end_date=now + timedelta(days=1),
|
||||
slug=f"checkout-{suffix}",
|
||||
price=1250000,
|
||||
capacity=1,
|
||||
status=Event.StatusChoices.PUBLISHED,
|
||||
)
|
||||
|
||||
def auth_headers(self):
|
||||
return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"}
|
||||
|
||||
def create_pending_checkout(self, *, expires_at=None):
|
||||
now = timezone.now()
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=self.event.price,
|
||||
checkout_started_at=now,
|
||||
checkout_expires_at=expires_at or now + timedelta(minutes=15),
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=registration,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
idempotency_key=uuid.uuid4(),
|
||||
)
|
||||
return registration, payment
|
||||
|
||||
def test_active_checkout_reserves_capacity_but_can_be_resumed_by_its_owner(self):
|
||||
registration, _ = self.create_pending_checkout()
|
||||
|
||||
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 1
|
||||
resume_response = self.client.post(
|
||||
f"/api/events/{self.event.id}/register",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
other = User.objects.create_user(
|
||||
username=f"other-{uuid.uuid4().hex[:8]}",
|
||||
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
|
||||
password="CheckoutPass!123",
|
||||
)
|
||||
other_token = create_jwt_token(other)
|
||||
other_response = self.client.post(
|
||||
f"/api/events/{self.event.id}/register",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {other_token}",
|
||||
)
|
||||
|
||||
assert resume_response.status_code == 200
|
||||
assert resume_response.json()["id"] == registration.id
|
||||
assert other_response.status_code == 400
|
||||
assert "ظرفیت" in other_response.json().get("detail", "")
|
||||
|
||||
def test_active_checkout_remains_resumable_after_registration_closes(self):
|
||||
registration, _ = self.create_pending_checkout()
|
||||
self.event.registration_end_date = timezone.now() - timedelta(minutes=1)
|
||||
self.event.save(update_fields=["registration_end_date"])
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/events/{self.event.id}/register",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["id"] == registration.id
|
||||
|
||||
def test_registration_status_returns_resumable_payment_without_changing_legacy_flag(self):
|
||||
registration, payment = self.create_pending_checkout()
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.id}/is-registered",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["is_registered"] is False
|
||||
assert payload["checkout_state"] == "active"
|
||||
assert payload["can_resume"] is True
|
||||
assert payload["registration_id"] == registration.id
|
||||
assert payload["payment_id"] == payment.id
|
||||
expected_expiry = registration.checkout_expires_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
assert payload["checkout_expires_at"] == expected_expiry
|
||||
|
||||
def test_registration_hub_returns_ready_state_for_user_without_registration(self):
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["state"] == "ready"
|
||||
assert payload["event"]["slug"] == self.event.slug
|
||||
assert payload["registration"] is None
|
||||
assert payload["payment"] is None
|
||||
assert payload["can_register"] is True
|
||||
|
||||
def test_registration_hub_requires_authentication(self):
|
||||
response = self.client.get(f"/api/events/{self.event.slug}/registration/status")
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_registration_hub_does_not_trust_a_status_query_parameter(self):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=self.event.price,
|
||||
)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=registration,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status?status=failed",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "paid_success"
|
||||
|
||||
def test_registration_hub_shows_confirmed_free_registration(self):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=0,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
{"registration_id": str(registration.ticket_id)},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["state"] == "free_success"
|
||||
|
||||
def test_registration_hub_returns_404_for_foreign_payment(self):
|
||||
other = User.objects.create_user(
|
||||
username=f"other-{uuid.uuid4().hex[:8]}",
|
||||
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
|
||||
password="CheckoutPass!123",
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
user=other,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
{"payment_id": payment.id},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_registration_hub_uses_registration_ticket_uuid_and_exposes_active_payment(self):
|
||||
registration, payment = self.create_pending_checkout()
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
{"registration_id": str(registration.ticket_id)},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["state"] == "active_checkout"
|
||||
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
|
||||
assert payload["payment"]["id"] == payment.id
|
||||
assert payload["can_resume"] is True
|
||||
assert payload["can_cancel"] is True
|
||||
|
||||
def test_registration_hub_hides_another_users_registration(self):
|
||||
other = User.objects.create_user(
|
||||
username=f"other-{uuid.uuid4().hex[:8]}",
|
||||
email=f"other-{uuid.uuid4().hex[:8]}@example.com",
|
||||
mobile=f"0913{uuid.uuid4().int % 10**7:07d}",
|
||||
password="CheckoutPass!123",
|
||||
)
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=other,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=self.event.price,
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
{"registration_id": str(registration.ticket_id)},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_registration_hub_accepts_legacy_payment_without_linked_registration(self):
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PENDING,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
)
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
{"payment_id": payment.id},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["state"] == "verification_pending"
|
||||
assert payload["registration"] is None
|
||||
assert payload["payment"]["id"] == payment.id
|
||||
assert payload["can_register"] is False
|
||||
|
||||
def test_registration_hub_uses_event_id_for_legacy_events_without_a_slug(self):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=self.event.price,
|
||||
confirmation_notification_sent_at=timezone.now(),
|
||||
)
|
||||
payment = Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=registration,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
)
|
||||
Event.objects.filter(pk=self.event.pk).update(slug="")
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.pk}/registration/status",
|
||||
{"payment_id": payment.id},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["state"] == "paid_success"
|
||||
assert payload["event"]["slug"] == ""
|
||||
assert payload["payment"]["id"] == payment.id
|
||||
|
||||
def test_registration_hub_prefers_active_attempt_over_newer_settled_attempt(self):
|
||||
confirmed = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=self.event.price,
|
||||
)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=confirmed,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
)
|
||||
active, active_payment = self.create_pending_checkout()
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["state"] == "active_checkout"
|
||||
assert payload["registration"]["ticket_id"] == str(active.ticket_id)
|
||||
assert payload["payment"]["id"] == active_payment.id
|
||||
|
||||
def test_registration_hub_returns_manual_review_and_refund_state_from_saved_records(self):
|
||||
registration, payment = self.create_pending_checkout()
|
||||
payment.verification_manual_review_at = timezone.now()
|
||||
payment.refund_status = Payment.RefundStatusChoices.MANUAL_REVIEW
|
||||
payment.save(update_fields=["verification_manual_review_at", "refund_status"])
|
||||
|
||||
response = self.client.get(
|
||||
f"/api/events/{self.event.slug}/registration/status",
|
||||
{"payment_id": payment.id},
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
payload = response.json()
|
||||
assert payload["state"] == "refund_manual_review"
|
||||
assert payload["registration"]["ticket_id"] == str(registration.ticket_id)
|
||||
assert payload["payment"]["verification_state"] == "manual_review"
|
||||
assert payload["payment"]["refund_status"] == "manual_review"
|
||||
|
||||
def test_expired_unresolved_checkout_releases_registration_and_blocks_duplicate_payment(self):
|
||||
expired_at = timezone.now() - timedelta(seconds=1)
|
||||
registration, payment = self.create_pending_checkout(expires_at=expired_at)
|
||||
|
||||
response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps({
|
||||
"event_id": self.event.id,
|
||||
"description": "Retry after abandoned checkout",
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
}),
|
||||
content_type="application/json",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
registration.refresh_from_db()
|
||||
payment.refresh_from_db()
|
||||
assert response.status_code == 409
|
||||
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||
assert Payment.objects.filter(event=self.event, user=self.user).count() == 1
|
||||
|
||||
def test_expiry_sweep_cancels_only_new_expired_checkouts_and_releases_the_seat(self):
|
||||
expired_at = timezone.now() - timedelta(seconds=1)
|
||||
registration, payment = self.create_pending_checkout(expires_at=expired_at)
|
||||
legacy = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=User.objects.create_user(
|
||||
username=f"legacy-{uuid.uuid4().hex[:8]}",
|
||||
email=f"legacy-{uuid.uuid4().hex[:8]}@example.com",
|
||||
mobile=f"0914{uuid.uuid4().int % 10**7:07d}",
|
||||
password="CheckoutPass!123",
|
||||
),
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=self.event.price,
|
||||
)
|
||||
|
||||
count = expire_stale_registration_checkouts()
|
||||
|
||||
registration.refresh_from_db()
|
||||
payment.refresh_from_db()
|
||||
legacy.refresh_from_db()
|
||||
assert count == 1
|
||||
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||
assert registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
|
||||
assert legacy.status == Registration.StatusChoices.PENDING
|
||||
|
||||
def test_user_can_cancel_active_checkout_without_deleting_registration(self, mocker):
|
||||
registration, payment = self.create_pending_checkout()
|
||||
mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/events/{self.event.id}/checkout/cancel",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
registration.refresh_from_db()
|
||||
payment.refresh_from_db()
|
||||
assert response.status_code == 200
|
||||
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||
assert registration.cancellation_reason == Registration.CancellationReasonChoices.USER_CANCELLED
|
||||
assert Registration.objects.filter(pk=registration.pk).exists()
|
||||
assert payment.status == Payment.OrderStatusChoices.PENDING
|
||||
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
|
||||
|
||||
def test_new_checkout_is_blocked_while_late_payment_refund_is_unresolved(self):
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CANCELLED,
|
||||
cancellation_reason=Registration.CancellationReasonChoices.CHECKOUT_EXPIRED,
|
||||
checkout_started_at=timezone.now() - timedelta(minutes=20),
|
||||
checkout_expires_at=timezone.now() - timedelta(minutes=5),
|
||||
final_price=self.event.price,
|
||||
)
|
||||
Payment.objects.create(
|
||||
user=self.user,
|
||||
event=self.event,
|
||||
registration=registration,
|
||||
base_amount=self.event.price,
|
||||
amount=self.event.price,
|
||||
status=Payment.OrderStatusChoices.PAID,
|
||||
refund_status=Payment.RefundStatusChoices.PENDING,
|
||||
authority=f"AUTH-{uuid.uuid4().hex}",
|
||||
)
|
||||
|
||||
register_response = self.client.post(
|
||||
f"/api/events/{self.event.id}/register",
|
||||
data=json.dumps({}),
|
||||
content_type="application/json",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
payment_response = self.client.post(
|
||||
"/api/payments/create",
|
||||
data=json.dumps({
|
||||
"event_id": self.event.id,
|
||||
"description": "Do not charge twice while refund is unresolved",
|
||||
"idempotency_key": str(uuid.uuid4()),
|
||||
}),
|
||||
content_type="application/json",
|
||||
**self.auth_headers(),
|
||||
)
|
||||
|
||||
assert register_response.status_code == 409
|
||||
assert payment_response.status_code == 409
|
||||
assert Registration.objects.filter(event=self.event, user=self.user).count() == 1
|
||||
|
||||
def test_event_cancellation_ends_checkouts_and_queues_payment_reconciliation(self, mocker):
|
||||
registration, payment = self.create_pending_checkout()
|
||||
enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async")
|
||||
mocker.patch("apps.events.checkout.transaction.on_commit", side_effect=lambda callback: callback())
|
||||
self.event.status = Event.StatusChoices.CANCELLED
|
||||
self.event.save(update_fields=["status"])
|
||||
|
||||
cancelled = cancel_event_checkouts(self.event.id)
|
||||
|
||||
registration.refresh_from_db()
|
||||
assert cancelled == 1
|
||||
assert registration.status == Registration.StatusChoices.CANCELLED
|
||||
assert registration.cancellation_reason == Registration.CancellationReasonChoices.EVENT_CANCELLED
|
||||
assert Event.objects.get(pk=self.event.pk).current_attendees_count == 0
|
||||
enqueue.assert_called_once_with(args=(payment.id,), countdown=0)
|
||||
@@ -22,13 +22,21 @@ def test_event_admin_formats_empty_price_and_capacity():
|
||||
def test_event_admin_status_actions_update_selection(mocker):
|
||||
model_admin = EventAdmin(Event, admin.site)
|
||||
mocker.patch.object(model_admin, "message_user")
|
||||
cancel_checkouts = mocker.patch("apps.events.admin.cancel_event_checkouts")
|
||||
queryset = mocker.Mock()
|
||||
queryset.count.return_value = 2
|
||||
for action, status in (
|
||||
(model_admin.make_published, Event.StatusChoices.PUBLISHED),
|
||||
(model_admin.make_draft, Event.StatusChoices.DRAFT),
|
||||
(model_admin.make_cancelled, Event.StatusChoices.CANCELLED),
|
||||
(model_admin.make_completed, Event.StatusChoices.COMPLETED),
|
||||
):
|
||||
action(SimpleNamespace(), queryset)
|
||||
queryset.update.assert_called_with(status=status)
|
||||
|
||||
published = SimpleNamespace(status=Event.StatusChoices.PUBLISHED, pk=10, save=mocker.Mock())
|
||||
already_cancelled = SimpleNamespace(status=Event.StatusChoices.CANCELLED, pk=11, save=mocker.Mock())
|
||||
model_admin.make_cancelled(SimpleNamespace(), [published, already_cancelled])
|
||||
|
||||
published.save.assert_called_once_with(update_fields=["status"])
|
||||
already_cancelled.save.assert_not_called()
|
||||
cancel_checkouts.assert_called_once_with(published.pk)
|
||||
|
||||
Reference in New Issue
Block a user