Release expired room holds
Python · Django · intermediate · modification
Abandoned bookings no longer block rooms: `expire_holds` flips every `HELD` hold past its `expires_at` to `EXPIRED` in one `UPDATE`, so it is cheap to run every minute, and the payment flow's `status=HELD` check keeps it safe against a guest confirming at the same moment. Added it to the crontab next to the other jobs. Tested locally: a hold created at 10:00:00 was still held at the 10:14 run and released at the first run after 10:15.
Django 5.1 booking site for a US hotel group on PostgreSQL. `settings.py` has `USE_TZ = True` and `TIME_ZONE = "America/New_York"`, the hotels' time zone, used for display. Production runs in containers whose system time zone is UTC (the crontab header says so); developers run the app on laptops set to New York time. `Hold` (existing, `bookings/models.py`) has `status` (`Hold.Status`: `HELD`, `CONFIRMED`, `EXPIRED`) and `expires_at = models.DateTimeField()`. `bookings/management/commands/` already exists (it holds `import_rates.py`).
Requirements
- When a guest starts booking, a `Hold` keeps the room for them for 15 minutes while they pay (existing: `expires_at` is set to `timezone.now() + 15 minutes`). Today holds never expire, so abandoned bookings block rooms until support clears them.
- Add an `expire_holds` management command that marks every hold still `HELD` whose `expires_at` has passed as `EXPIRED`, and run it every minute from the deploy crontab. A hold must not expire before its `expires_at`, and the command reports how many holds it released.
- Expiring a hold only changes its status. Room availability already counts only `HELD` and `CONFIRMED` holds, and the payment flow already confirms with a conditional update (`Hold.objects.filter(pk=..., status=HELD).update(status=CONFIRMED)`), so it cannot confirm a hold that is no longer `HELD`.
Files touched
- bookings/management/commands/expire_holds.py
- deploy/crontab
--- bookings/management/commands/expire_holds.py
+from datetime import datetime
+
+from django.core.management.base import BaseCommand
+
+from bookings.models import Hold
+
+
+class Command(BaseCommand):
+ help = "Release room holds whose payment window has passed."
+
+ def handle(self, *args, **options):
+ now = datetime.now()
+ count = Hold.objects.filter(
+ status=Hold.Status.HELD,
+ expires_at__lte=now,
+ ).update(status=Hold.Status.EXPIRED)
+ self.stdout.write(f"Released {count} expired hold(s)")
+
--- deploy/crontab
15 3 * * * cd /app && python manage.py import_rates
*/10 * * * * cd /app && python manage.py clearsessions
+* * * * * cd /app && python manage.py expire_holds