137 lines
4.4 KiB
Python
137 lines
4.4 KiB
Python
"""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
|