GateSlot seat-occupancy clock: cost_current / renewal-grace / grace_expired derived from filled_at + renewal_period — TDD

Phase 2 of the room GATE VIEW + seat-renewal sprint. Pure model
properties (no migration, no new fields) layering a uniform seat clock
on top of the existing per-token debit rules (which stay untouched):

  [A, A+S)    cost_current      play normally           (A = filled_at)
  [A+S, A+2S) in_renewal_grace  cost lapsed, seat held   (S = renewal_period)
  [A+2S, ∞)   grace_expired     eligible for auto-BYE

Uniform across ALL token types per user-spec (PASS/BAND/CARTE included)
— keyed on filled_at only. A NULL filled_at (RESERVED slots, ORM-built
fixtures) reads cost_current=True / grace_expired=False so nothing
without a fill timestamp is ever evicted (protects existing FILLED-slot
tests that set status via the ORM). renewal_span falls back to 7d when
room.renewal_period is None.

Tests: GateSlotCostCurrentTest — 11 UTs covering within/after span, null
filled_at, until==filled+period, grace boundaries [S,2S), expiry at 2S,
and the 7d span fallback. 491 epic tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-31 22:47:51 -04:00
parent 1e70ffabd6
commit 6fd515bc6d
2 changed files with 122 additions and 0 deletions

View File

@@ -85,6 +85,52 @@ class GateSlot(models.Model):
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
# ── Seat-occupancy / renewal clock (sprint 2026-05-31) ────────────────
# A filled seat's token cost is "current" for one renewal span after
# `filled_at`, then sits in a renewal-grace span of equal length before
# auto-BYE. Uniform across token types (no exceptions) — keyed on
# `filled_at` only; the per-token `debit_token` rules are untouched. A
# NULL `filled_at` (ORM fixtures / RESERVED slots) reads current /
# never-expired so nothing built without a fill timestamp gets evicted.
@property
def renewal_span(self):
return self.room.renewal_period or timedelta(days=7)
@property
def cost_current_until(self):
"""End of the cost-current window [A, A+S). None if not filled."""
if self.filled_at is None:
return None
return self.filled_at + self.renewal_span
@property
def grace_expires_at(self):
"""End of the renewal-grace window [A+S, A+2S) — the auto-BYE
threshold. None if not filled."""
if self.filled_at is None:
return None
return self.filled_at + 2 * self.renewal_span
@property
def cost_current(self):
"""True in [A, A+S). NULL filled_at → True (never-filled / fixtures)."""
until = self.cost_current_until
return until is None or timezone.now() < until
@property
def in_renewal_grace(self):
"""True in [A+S, A+2S) — cost lapsed but the seat is still held for
renewal. False before the span and after grace expires."""
if self.filled_at is None:
return False
return self.cost_current_until <= timezone.now() < self.grace_expires_at
@property
def grace_expired(self):
"""True at/after A+2S — past renewal grace, eligible for auto-BYE."""
exp = self.grace_expires_at
return exp is not None and timezone.now() >= exp
class RoomInvite(models.Model):
PENDING = "PENDING"