142 lines
4.8 KiB
Python
142 lines
4.8 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.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
|
|
|
|
|
|
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.status == Event.StatusChoices.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
|