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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user