Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
142 lines
4.7 KiB
Python
142 lines
4.7 KiB
Python
"""Registration checkout lifecycle rules shared by API views and workers."""
|
|
|
|
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)
|
|
|
|
|
|
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):
|
|
"""Mark no-authority attempts for review; the recovery sweep dispatches the rest."""
|
|
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 not payment.authority:
|
|
# 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())
|
|
|
|
|
|
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)
|
|
|
|
with transaction.atomic():
|
|
batch = list(
|
|
candidates.select_for_update(skip_locked=True)
|
|
.order_by("checkout_expires_at", "pk")[:50]
|
|
)
|
|
expired_count = 0
|
|
for registration in batch:
|
|
if registration.status != Registration.StatusChoices.PENDING or registration.checkout_expires_at > now:
|
|
continue
|
|
reason = (
|
|
Registration.CancellationReasonChoices.EVENT_CANCELLED
|
|
if registration.event.is_cancelled
|
|
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
|
)
|
|
if mark_checkout_cancelled(registration, reason):
|
|
expired_count += 1
|
|
queue_checkout_reconciliation(registration)
|
|
return expired_count
|
|
|
|
|
|
def expire_user_registration_checkouts(*, event, user, now):
|
|
"""Expire only this user's rows while the caller holds the event lock."""
|
|
registrations = (
|
|
Registration.objects.select_for_update()
|
|
.filter(
|
|
event=event,
|
|
user=user,
|
|
is_deleted=False,
|
|
status=Registration.StatusChoices.PENDING,
|
|
checkout_expires_at__lte=now,
|
|
)
|
|
.order_by("pk")
|
|
)
|
|
for registration in registrations:
|
|
reason = (
|
|
Registration.CancellationReasonChoices.EVENT_CANCELLED
|
|
if event.is_cancelled
|
|
else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED
|
|
)
|
|
if mark_checkout_cancelled(registration, reason):
|
|
queue_checkout_reconciliation(registration)
|
|
|
|
|
|
@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
|