diff --git a/src/apps/epic/management/commands/expire_lapsed_room_seats.py b/src/apps/epic/management/commands/expire_lapsed_room_seats.py new file mode 100644 index 0000000..ad47e7f --- /dev/null +++ b/src/apps/epic/management/commands/expire_lapsed_room_seats.py @@ -0,0 +1,35 @@ +"""Auto-BYE gamers whose room seat token cost lapsed past the renewal grace. + +The lazy `_expire_lapsed_seats` inside the room view / gatekeeper / +gate-view already frees lapsed seats on every access; this command is the +cron backstop for rooms nobody reopens — a mid-game table left idle past +the grace window (filled_at + 2*renewal_period) would otherwise keep its +stuck seats forever. Mirrors `delete_stale_my_sea_draws`. No flags; +idempotent. + +Usage: + python manage.py expire_lapsed_room_seats +""" +from django.core.management.base import BaseCommand + +from apps.epic.models import GateSlot, Room +from apps.epic.views import _expire_lapsed_seats + + +class Command(BaseCommand): + help = "Free room seats whose token cost lapsed past the renewal grace." + + def handle(self, *args, **options): + rooms = Room.objects.filter( + gate_slots__status=GateSlot.FILLED, + gate_slots__filled_at__isnull=False, + ).distinct() + freed = 0 + for room in rooms: + before = room.gate_slots.filter(status=GateSlot.FILLED).count() + _expire_lapsed_seats(room) + if room.gate_slots.filter(status=GateSlot.FILLED).count() < before: + freed += 1 + self.stdout.write(self.style.SUCCESS( + f"Freed lapsed seats in {freed} room{'s' if freed != 1 else ''}." + )) diff --git a/src/apps/epic/tests/integrated/test_views.py b/src/apps/epic/tests/integrated/test_views.py index ae2e718..12e8386 100644 --- a/src/apps/epic/tests/integrated/test_views.py +++ b/src/apps/epic/tests/integrated/test_views.py @@ -2995,3 +2995,41 @@ class ExpireLapsedSeatsTest(TestCase): self.room.save() response = self.client.get(reverse("epic:room", args=[self.room.id])) self.assertContains(response, "id_gamer_needed") + + +class ExpireLapsedRoomSeatsCommandTest(TestCase): + """Cron backstop for rooms nobody reopens — `expire_lapsed_room_seats` + runs the same `_expire_lapsed_seats` sweep the lazy view path uses, for + mid-game tables left idle past the grace window.""" + + def _room_with_filled_slot(self, email, filled_at): + owner = User.objects.create(email=email) + room = Room.objects.create( + name="Cron Room", owner=owner, renewal_period=timedelta(days=7), + gate_status=Room.OPEN, table_status=Room.ROLE_SELECT, + ) + slot = room.gate_slots.get(slot_number=1) + slot.gamer = owner + slot.status = GateSlot.FILLED + slot.filled_at = filled_at + slot.debited_token_type = Token.FREE + slot.save() + return room, slot + + def test_command_frees_lapsed_seat_and_sets_renewal_due(self): + from django.core.management import call_command + room, slot = self._room_with_filled_slot( + "lapsed@test.io", timezone.now() - timedelta(days=15)) + call_command("expire_lapsed_room_seats") + slot.refresh_from_db() + room.refresh_from_db() + self.assertEqual(slot.status, GateSlot.EMPTY) + self.assertEqual(room.gate_status, Room.RENEWAL_DUE) + + def test_command_noop_within_grace(self): + from django.core.management import call_command + room, slot = self._room_with_filled_slot( + "ingrace@test.io", timezone.now() - timedelta(days=8)) + call_command("expire_lapsed_room_seats") + slot.refresh_from_db() + self.assertEqual(slot.status, GateSlot.FILLED)