Files
guilan-ace-backend/apps/events/checkout.py

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