My Sea iter 4c: drop LOCK HAND → AUTO DRAW + GATE VIEW; quota committed at first card draw (irrevocable); DEL clears hand but preserves row as quota tracker; per-placement /lock POST upsert; lazy stale-row cleanup; sig polarity + .btn-disabled → ×; landing aperture bg revert to --priUser — Sprint 5 iter 4c of My Sea roadmap — TDD

Major refactor of the iter-4b skeleton ahead of Sprint 6's token costs. Iter 4b's LOCK HAND model let users freely DEL + LOCK in a loop, bypassing the 1/day quota; iter 4c closes that loophole by committing quota at first-card-draw (manual via FLIP OR auto via AUTO DRAW) + preserving the MySeaDraw row through DEL so the 24h clock keeps running.

## Server

`MySeaDraw` now plays double-duty: hand storage AND 24h quota tracker.
- `HAND_SIZE_BY_SPREAD` module dict maps each spread slug to its expected hand size (mirrors DRAW_ORDER in JS).
- `is_hand_complete` / `is_hand_empty` props drive view branching + template button states.
- `delete_stale()` classmethod hard-deletes rows older than FREE_DRAW_COOLDOWN_HOURS. Called lazily from `active_draw_for` on every view access (rides user traffic; no scheduler needed) + via the new `delete_stale_my_sea_draws` management command (cron backstop).
- `active_draw_for` prunes user's stale rows before lookup — auto-cleanup at the 24h mark per user spec ("sink 'em all at the 24hr mark and reinstate the FREE DRAW btn").

`my_sea_lock` is now a true upsert:
- First POST creates the row (quota commit).
- Subsequent POSTs UPDATE the existing row's hand (per-placement cadence — server stays current so navigate-away mid-draw still persists).
- Spread-mismatch (attempted spread switch within quota window) → 409.
- Empty/malformed hand → 400.
- Response carries `{ok, next_free_draw_at, hand_complete}` for JS state transitions.

`my_sea_delete` no longer deletes the row — clears the `hand` JSON only. `created_at` preserved so landing renders GATE VIEW (not FREE DRAW) until the row expires. Idempotent.

`my_sea_gate` new stub view — returns 404 for now; lets the template wire up GATE VIEW button URLs in advance. Sprint 6 will replace this w. the gatekeeper token-deposit UX.

`my_sea` view branches:
1. No sig → sign-gate
2. Active draw + non-empty hand (mid or complete) → picker phase w. saved hand
3. Active draw + empty hand (post-DEL) → landing phase w. GATE VIEW btn
4. No active draw → landing phase w. FREE DRAW btn

## Template + UX

- Picker form col: removed LOCK HAND. Replaced w. `#id_sea_action_btn` — same DOM node, label + behavior keyed on `data-state`:
  - `auto-draw` → label "AUTO DRAW"; click opens shared guard portal ("Auto deal cards?"); OK → fill remaining slots client-side + single-POST commit to server (per user spec: "commit all six draws in the same POST" so navigate-away mid-animation still persists).
  - `gate-view` → label "GATE VIEW"; click navigates to /gameboard/my-sea/gate/ (Sprint 6).
  - JS transitions auto-draw → gate-view automatically when the hand fills (via FLIP or AUTO DRAW completion).
- DEL btn: server-renders `.btn-disabled` pre-completion (per spec, the 1/day quota commits at first-card-draw — can't be refunded by an early DEL). JS removes `.btn-disabled` on hand completion. Post-completion click opens the shared guard portal; CONFIRM POSTs the delete endpoint (which clears hand server-side) + reloads to GATE VIEW landing.
- Deck stacks remain click-responsive post-completion so the user sees the disabled-FLIP feedback (signalling "no more draws"); the FLIP click is gated on `_locked` flag.
- Landing: primary nav btn is FREE DRAW (no active draw) or GATE VIEW (active draw exists w. empty hand). Both render as `<button>` (not `<a>`) so the typography matches across states — `<a>`'s UA-default serif typeface was bleeding into GATE VIEW under iter 4b polish.

## Other polish bundled

- **Sig polarity rendered in picker** — added `.my-sea-page[data-polarity]` to the existing `.sig-overlay[data-polarity]` + `.my-sign-page[data-polarity]` selector list in `_card-deck.scss`. Template wires `data-polarity` on the page wrapper based on `significator_reversed`. Previously the picker's center sig card was always gravity-themed regardless of the user's actual sig polarity.
- **`.btn-disabled` → × overlay** — universal CSS rule: any `.btn-disabled` button reads as × regardless of its native inner text/icons (DEL → ×, FLIP → ×, etc.). Hides inner content via `visibility: hidden` on children + paints × via `::before` pseudo-element. Templates that already render `&times;` explicitly (don/doff toggle pairs) get the pseudo overlay on top of their hidden inner ×; no double-× regression.
- **Landing aperture bg → `--priUser`** — explicit override on `.my-sea-page[data-phase="landing"]` so any bf-cache / stale-CSS state can't leak the picker-phase `--duoUser` green bg onto a landing render. Per user spec (2026-05-20): "Keep --duoUser on the hex, not on the aperture bg."
- **Dynamic combobox state** — `aria-selected` + `.sea-select-current` visible label both branch on `default_spread` (previously hardcoded SAO). Matters when the saved spread is non-SAO (e.g., Celtic Cross resumed mid-draw).

## Test coverage

- ITs (1100 IT/UT green in 57s):
  - `MySeaDrawModelTest` — `is_hand_complete`, `is_hand_empty`, `delete_stale`, lazy cleanup in `active_draw_for`.
  - `MySeaLockHandViewTest` — upsert same-row (rewrote 409 test), spread-mismatch 409, hand_complete flag in response.
  - `MySeaDeleteDrawViewTest` — clears hand but preserves row (rewrote "deletes row" test).
  - `MySeaViewWithSavedDrawTest` — picker w. complete hand renders GATE VIEW state.
  - `MySeaViewWithEmptyHandTest` (new) — empty-hand post-DEL renders landing w. GATE VIEW btn, no FREE DRAW.
  - `MySeaViewWithPartialHandTest` (new) — partial-hand renders picker w. AUTO DRAW + DEL btn-disabled.
  - `MySeaGateStubViewTest` (new) — 404 stub + login required.
- FTs (35 my_sea FTs green in 5m):
  - Iter-4b `test_del_confirm_clears_saved_draw_and_returns_to_landing` rewrote → `test_del_confirm_clears_hand_and_returns_to_gate_view_landing` (row preserved, landing renders GATE VIEW).
  - Iter-4a `test_lock_hand_enables_when_sao_hand_is_complete` → `test_action_btn_transitions_to_gate_view_on_hand_complete`.
  - Iter-4a `test_del_click_resets_hand_and_disables_lock_hand` → `test_del_btn_is_disabled_until_hand_complete`.
  - Iter-4a `test_lock_hand_click_disables_further_interaction` → `test_hand_completion_locks_picker_state` (no LOCK HAND click; transition is automatic).
  - Iter-4a `test_first_draw_locks_spread_combobox` trimmed — DEL no longer unlocks (DEL is `.btn-disabled` pre-completion).
  - Iter-4a `test_form_col_renders_decks_lock_hand_del_and_reversal_pct` → action btn + DEL btn-disabled assertions.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Disco DeDisco
2026-05-20 01:34:03 -04:00
parent 6f901fd9ce
commit 7b7e80520a
12 changed files with 735 additions and 230 deletions

View File

@@ -0,0 +1,24 @@
"""Hard-delete MySeaDraw rows older than FREE_DRAW_COOLDOWN_HOURS.
The lazy cleanup inside `apps.gameboard.models.active_draw_for` already
prunes a user's stale rows on every view hit; this command is the cron
backstop for inactive accounts whose rows would otherwise sit forever.
Run from cron (or invoke manually). No flags; idempotent.
Usage:
python manage.py delete_stale_my_sea_draws
"""
from django.core.management.base import BaseCommand
from apps.gameboard.models import MySeaDraw
class Command(BaseCommand):
help = "Delete MySeaDraw rows older than the free-draw cooldown window (24h)."
def handle(self, *args, **options):
n = MySeaDraw.delete_stale()
self.stdout.write(self.style.SUCCESS(
f"Deleted {n} stale MySeaDraw row{'s' if n != 1 else ''}."
))

View File

@@ -5,28 +5,45 @@ from django.utils import timezone
FREE_DRAW_COOLDOWN_HOURS = 24
# Total hand size per spread — matches DRAW_ORDER in my_sea.html JS.
# Used by `is_hand_complete` to decide whether the picker UX is in
# "still drawing" (AUTO DRAW visible, DEL disabled, FLIPs enabled)
# vs "completed" (GATE VIEW visible, DEL enabled, FLIPs disabled) state.
HAND_SIZE_BY_SPREAD = {
"past-present-future": 3,
"situation-action-outcome": 3,
"mind-body-spirit": 3,
"desire-obstacle-solution": 3,
"waite-smith": 6,
"escape-velocity": 6,
}
class MySeaDraw(models.Model):
"""Persisted Celtic-Cross-style tarot draw for the solo-user My Sea
feature. Each row is one locked hand by one user.
feature. Each row is the user's active draw — both the hand state
AND the 24h quota tracker.
Sprint 5 iter 4b of [[project-my-sea-roadmap]] — server-side
persistence of the iter-4a client-side draw mechanic.
Quota: one row per user per `FREE_DRAW_COOLDOWN_HOURS` window
(24h, irrespective of spread type). Subsequent draws within the
window are intended to be gated behind a token deposit at the My
Sea gatekeeper, which Sprint 6 will build.
Sprint 5 iter 4c of [[project-my-sea-roadmap]] — refactor of the
iter-4b LOCK-HAND model. The row is created on the FIRST card draw
(manual or AUTO DRAW), not on a separate LOCK action. Quota is
committed at first-card moment + survives a DEL (DEL clears the
`hand` JSON but preserves the row — so `created_at` keeps running
the 24h clock + landing renders GATE VIEW instead of FREE DRAW
until the row expires).
`hand` is an ordered list of position-dicts in draw order — Sprint 7's
applet renders them left-to-right in that order. Each entry shape:
{"position": "lay", "card_id": 42, "reversed": false, "polarity": "gravity"}
For mid-draw rows, `hand` is partial (1+ entries < HAND_SIZE_BY_SPREAD).
For DEL'd rows, `hand` is `[]` but `created_at` still anchors the
quota window.
`significator_id` + `significator_reversed` snapshot the user's sig
at lock time so a subsequent `User.significator = None` (via my-sign
DEL) doesn't invalidate the saved draw — per user spec, preserve the
old sig; any future draw uses whatever sig is current at that time.
at first-card-draw time so a subsequent `User.significator = None`
(via my-sign DEL) doesn't invalidate the saved draw.
"""
user = models.ForeignKey(
settings.AUTH_USER_MODEL,
@@ -60,16 +77,45 @@ class MySeaDraw(models.Model):
timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
)
@property
def is_hand_complete(self):
"""True iff the hand has filled every position for its spread.
Drives the picker-form-col button state (AUTO DRAW → GATE VIEW
+ DEL enable + FLIPs disable on the deck stacks)."""
return len(self.hand or []) >= HAND_SIZE_BY_SPREAD.get(self.spread, 0)
@property
def is_hand_empty(self):
"""True iff the hand carries no entries — the post-DEL state,
where the row stays as a quota tracker but the cards are gone."""
return not self.hand
@classmethod
def delete_stale(cls):
"""Hard-delete every draw older than FREE_DRAW_COOLDOWN_HOURS.
Returns the deletion count.
Called lazily from `active_draw_for` on every view access (so
the cleanup naturally rides user traffic — no scheduler needed
for the common case) AND from the `delete_stale_my_sea_draws`
management command (cron backstop for inactive periods)."""
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
deleted, _ = cls.objects.filter(created_at__lt=cutoff).delete()
return deleted
def active_draw_for(user):
"""Return the user's most-recent draw within the quota window, or
None. Used both for rendering the picker w. saved hand on page load
and for gating LOCK HAND POSTs.
None. Single source of truth for "does the user have an active draw"
(drives view branching: no-active → landing w. FREE DRAW; active w.
empty hand → landing w. GATE VIEW; active w. non-empty hand → picker).
Importing this helper rather than re-deriving the cutoff in every
caller keeps the 24h window a single-source-of-truth tied to
FREE_DRAW_COOLDOWN_HOURS."""
Lazy stale-row cleanup: every call prunes the user's >24h rows, so
the DB doesn't accumulate one row per user per day. The user-spec
'auto-delete all draws after 24hrs, whether or not the user has
deleted them' (2026-05-20) lands here w. no scheduler required."""
cutoff = timezone.now() - timezone.timedelta(hours=FREE_DRAW_COOLDOWN_HOURS)
MySeaDraw.objects.filter(user=user, created_at__lt=cutoff).delete()
return MySeaDraw.objects.filter(
user=user, created_at__gte=cutoff,
).order_by("-created_at").first()

View File

@@ -717,12 +717,18 @@ class MySeaSpreadFormTemplateTest(TestCase):
self.assertIn('data-position="loom"></span>', html)
self.assertIn('data-position="cross"></span>', html)
def test_form_col_renders_decks_lock_hand_del_and_reversal_hint(self):
def test_form_col_renders_decks_action_btn_del_and_reversal_hint(self):
# Iter 4c — LOCK HAND replaced by AUTO DRAW (mid-draw) which JS
# transitions to GATE VIEW on completion. ID `id_sea_action_btn`
# is the single slot housing both states (label + `data-state`
# toggled by JS). User w. no active draw → AUTO DRAW label.
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
self.assertIn("sea-deck-stack--gravity", html)
self.assertIn("sea-deck-stack--levity", html)
self.assertIn('id="id_sea_lock_hand"', html)
self.assertIn('id="id_sea_action_btn"', html)
self.assertIn('data-state="auto-draw"', html)
self.assertIn("AUTO", html)
self.assertIn('id="id_sea_del"', html)
self.assertIn("sea-reversal-hint", html)
self.assertIn("25% reversals", html)
@@ -956,21 +962,77 @@ class MySeaLockHandViewTest(TestCase):
parsed = datetime.fromisoformat(body["next_free_draw_at"])
self.assertIsNotNone(parsed)
def test_lock_post_within_quota_window_returns_409(self):
# Second lock within 24h: the existing draw already occupies the
# quota; the server rejects rather than overwriting.
def test_lock_post_within_quota_upserts_same_row(self):
# Iter 4c — `/lock` is now an upsert (per-placement POST cadence).
# Second POST w. same spread updates the existing row's hand
# rather than 409'ing. Only one row exists per user per 24h.
import json
from apps.gameboard.models import MySeaDraw
from apps.epic.models import TarotCard
# First POST: 1-card partial hand
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
partial = {
"spread": "situation-action-outcome",
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
}
r1 = self.client.post(
self.url, data=json.dumps(partial),
content_type="application/json",
)
self.assertEqual(r1.status_code, 200)
# Second POST: full 3-card hand (the SAME draw progressing).
full = self._build_payload()
r2 = self.client.post(
self.url, data=json.dumps(full),
content_type="application/json",
)
self.assertEqual(r2.status_code, 200)
# Exactly one MySeaDraw row exists; hand is the latest full one.
rows = MySeaDraw.objects.filter(user=self.user)
self.assertEqual(rows.count(), 1)
self.assertEqual(len(rows.first().hand), 3)
def test_lock_post_spread_mismatch_within_quota_returns_409(self):
# Spread is committed at first-card moment; switching to a
# different spread mid-quota-window is rejected.
import json
from apps.epic.models import TarotCard
cards = list(TarotCard.objects.exclude(id=self.target.id)[:3])
self.client.post(
self.url, data=json.dumps(self._build_payload()),
self.url, data=json.dumps({
"spread": "situation-action-outcome",
"hand": [{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"}],
}),
content_type="application/json",
)
response = self.client.post(
self.url, data=json.dumps(self._build_payload()),
self.url, data=json.dumps({
"spread": "waite-smith",
"hand": [{"position": "crown", "card_id": cards[1].id, "reversed": False, "polarity": "levity"}],
}),
content_type="application/json",
)
self.assertEqual(response.status_code, 409)
self.assertEqual(MySeaDraw.objects.filter(user=self.user).count(), 1)
def test_lock_post_returns_hand_complete_flag(self):
# Body includes `hand_complete` so the JS can decide whether to
# transition the picker into post-completion state (DEL enable,
# FLIPs disable, AUTO DRAW → GATE VIEW).
import json
partial = {
"spread": "situation-action-outcome",
"hand": self._build_payload()["hand"][:1],
}
r1 = self.client.post(
self.url, data=json.dumps(partial),
content_type="application/json",
)
self.assertFalse(r1.json()["hand_complete"])
r2 = self.client.post(
self.url, data=json.dumps(self._build_payload()),
content_type="application/json",
)
self.assertTrue(r2.json()["hand_complete"])
def test_lock_post_empty_hand_returns_400(self):
import json
@@ -1006,7 +1068,8 @@ class MySeaLockHandViewTest(TestCase):
class MySeaDeleteDrawViewTest(TestCase):
"""Sprint 5 iter 4b — POST `/gameboard/my-sea/delete` clears the draw."""
"""Sprint 5 iter 4c — POST `/gameboard/my-sea/delete` clears the HAND
but preserves the row so the 24h quota window keeps running."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
@@ -1037,11 +1100,18 @@ class MySeaDeleteDrawViewTest(TestCase):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_delete_post_clears_active_draw(self):
def test_delete_post_clears_hand_but_preserves_row(self):
# Iter 4c — DEL no longer deletes; the row stays as a 24h quota
# tracker. Hand JSON gets wiped + `created_at` preserved (so the
# landing renders GATE VIEW, not FREE DRAW, until the row expires).
from apps.gameboard.models import MySeaDraw
original_created_at = self.draw.created_at
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
self.assertFalse(MySeaDraw.objects.filter(user=self.user).exists())
self.draw.refresh_from_db()
self.assertEqual(self.draw.hand, [])
self.assertEqual(self.draw.created_at, original_created_at)
self.assertTrue(MySeaDraw.objects.filter(user=self.user).exists())
def test_delete_post_scoped_to_user_does_not_touch_others(self):
from apps.gameboard.models import MySeaDraw
@@ -1051,10 +1121,13 @@ class MySeaDeleteDrawViewTest(TestCase):
hand=self.draw.hand, significator_id=self.target.id,
)
self.client.post(self.url)
self.assertTrue(MySeaDraw.objects.filter(pk=other_draw.pk).exists())
other_draw.refresh_from_db()
self.assertEqual(len(other_draw.hand), 3) # untouched
def test_delete_post_idempotent_when_no_active_draw(self):
# User deletes twice in a row — second call is a no-op, not a 500.
# First DEL clears hand. Second DEL finds the row w. empty hand
# already; just no-ops.
self.client.post(self.url)
response = self.client.post(self.url)
self.assertIn(response.status_code, (200, 204, 302))
@@ -1170,3 +1243,146 @@ class MySeaViewWithSavedDrawTest(TestCase):
response = self.client.get(reverse("my_sea"))
self.assertNotContains(response, 'id="id_draw_sea_btn"')
self.assertNotContains(response, 'data-phase="landing"')
def test_complete_hand_renders_action_btn_as_gate_view(self):
# Iter 4c — server pre-renders the action btn label + data-state
# based on `hand_complete`. With the setUp's 3-card SAO hand,
# hand is complete → btn label is GATE VIEW.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-state="gate-view"')
self.assertContains(response, "GATE")
self.assertContains(response, "VIEW")
def test_complete_hand_picker_carries_locked_class(self):
# `.my-sea-picker--locked` is server-rendered for completed hands
# so the JS init seeds `_locked=true` w.o. waiting for the post-
# placement state transition (matters for hot reloads, bfcache).
response = self.client.get(reverse("my_sea"))
self.assertContains(response, "my-sea-picker--locked")
def test_complete_hand_del_btn_is_not_disabled(self):
# DEL is `.btn-disabled` only when hand is INCOMPLETE. Complete
# hand → DEL renders w.o. the disabled class (clicking opens the
# guard portal).
import re
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
m = re.search(
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
)
self.assertIsNotNone(m)
self.assertNotIn("btn-disabled", m.group(1))
class MySeaViewWithEmptyHandTest(TestCase):
"""Sprint 5 iter 4c — view branch for an active draw w. empty hand
(the post-DEL state, where the quota row stays as a 24h tracker but
the user's hand has been wiped). Landing renders w. GATE VIEW (NOT
FREE DRAW) as the primary nav."""
def setUp(self):
from apps.epic.models import personal_sig_cards
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="empty@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
# Active draw row but hand is empty — simulates the post-DEL state.
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id, hand=[],
)
def test_empty_hand_renders_landing_phase_not_picker(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-phase="landing"')
def test_empty_hand_landing_renders_gate_view_btn_not_free_draw(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'id="id_my_sea_gate_view_btn"')
self.assertNotContains(response, 'id="id_draw_sea_btn"')
def test_empty_hand_gate_view_btn_links_to_gate_url(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, reverse("my_sea_gate"))
def test_empty_hand_brief_banner_still_triggered(self):
# Quota's still committed (row exists, 24h clock still running) →
# the Brief banner is part of the saved-draw context, regardless
# of hand state. Informs the user when the next free draw is.
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'window._showFreeDrawLockedBrief("')
class MySeaViewWithPartialHandTest(TestCase):
"""Sprint 5 iter 4c — view branch for an active draw w. mid-progress
hand (some slots filled, not yet complete). Picker renders w. the
partial slots + AUTO DRAW btn (not GATE VIEW); DEL stays disabled."""
def setUp(self):
from apps.epic.models import personal_sig_cards, TarotCard
from apps.gameboard.models import MySeaDraw
self.user = User.objects.create(email="partial@test.io")
self.client.force_login(self.user)
self.target = personal_sig_cards(self.user)[0]
self.user.significator = self.target
self.user.save(update_fields=["significator"])
cards = list(TarotCard.objects.exclude(id=self.target.id)[:2])
# SAO is a 3-position spread; partial = 2 cards drawn.
self.draw = MySeaDraw.objects.create(
user=self.user, spread="situation-action-outcome",
significator_id=self.target.id,
hand=[
{"position": "lay", "card_id": cards[0].id, "reversed": False, "polarity": "gravity"},
{"position": "cover", "card_id": cards[1].id, "reversed": True, "polarity": "levity"},
],
)
def test_partial_hand_renders_picker_phase(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-phase="picker"')
def test_partial_hand_action_btn_is_auto_draw_not_gate_view(self):
response = self.client.get(reverse("my_sea"))
self.assertContains(response, 'data-state="auto-draw"')
self.assertContains(response, "AUTO")
def test_partial_hand_del_btn_carries_btn_disabled(self):
import re
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
m = re.search(
r'<button[^>]*id="id_sea_del"[^>]*class="([^"]*)"', html,
)
self.assertIsNotNone(m)
self.assertIn("btn-disabled", m.group(1))
def test_partial_hand_picker_does_NOT_carry_locked_class(self):
# Hand is mid-progress; locked class only applies on completion.
import re
response = self.client.get(reverse("my_sea"))
html = response.content.decode()
m = re.search(r'<div class="my-sea-picker([^"]*)"', html)
self.assertIsNotNone(m)
self.assertNotIn("my-sea-picker--locked", m.group(1))
class MySeaGateStubViewTest(TestCase):
"""Sprint 5 iter 4c — placeholder for the Sprint 6 gatekeeper. Returns
a 404 so the template-side GATE VIEW button URL resolves but the
actual gatekeeper UX rides Sprint 6."""
def setUp(self):
self.user = User.objects.create(email="gate@test.io")
self.client.force_login(self.user)
def test_gate_view_returns_404(self):
response = self.client.get(reverse("my_sea_gate"))
self.assertEqual(response.status_code, 404)
def test_gate_view_requires_login(self):
self.client.logout()
response = self.client.get(reverse("my_sea_gate"))
# @login_required redirects before the 404 path runs.
self.assertEqual(response.status_code, 302)

View File

@@ -16,5 +16,6 @@ urlpatterns = [
path('my-sea/', views.my_sea, name='my_sea'),
path('my-sea/lock', views.my_sea_lock, name='my_sea_lock'),
path('my-sea/delete', views.my_sea_delete, name='my_sea_delete'),
path('my-sea/gate/', views.my_sea_gate, name='my_sea_gate'),
]

View File

@@ -7,7 +7,7 @@ from django.utils import timezone
from django.views.decorators.http import require_POST
from apps.applets.utils import applet_context, apply_applet_toggle
from .models import MySeaDraw, active_draw_for
from .models import HAND_SIZE_BY_SPREAD, MySeaDraw, active_draw_for
def _annotate_deck_in_use(decks, user):
@@ -174,16 +174,24 @@ def toggle_game_kit_sections(request):
def my_sea(request):
"""Shell view for the My Sea standalone page.
Branches:
Sprint 5 iter 4c branching — `MySeaDraw` now plays double-duty as
hand storage AND 24h quota tracker. The row is created on first
card draw + survives DEL (which only wipes the hand). View states:
1. Active saved draw (within FREE_DRAW_COOLDOWN_HOURS) → picker phase
w. saved hand + Brief banner + DEL guard portal. The draw's sig
snapshot is rendered (NOT user.significator) so a cleared sig
elsewhere doesn't invalidate the saved draw.
2. No active draw + no sig → Look!-formatted sign-gate (Sprint 4b).
3. No active draw + sig set → FREE DRAW landing (Sprint 5 iter 1) →
click swaps data-phase to picker for a fresh draw.
3a. + no equipped deck → also show backup-deck Brief banner.
1. No sig → Look!-formatted sign-gate (Sprint 4b).
2. Active draw, hand non-empty (mid-draw or complete) → picker phase
w. saved hand state. The DEL btn is server-rendered `.btn-
disabled` until hand is complete; AUTO DRAW becomes GATE VIEW on
completion. The draw's sig snapshot is rendered (NOT user.
significator) so a cleared sig elsewhere doesn't invalidate the
saved draw.
3. Active draw, hand empty (post-DEL) → landing w. GATE VIEW (the
free-draw quota is spent; user must use tokens via the upcoming
Sprint 6 gatekeeper). Brief banner still surfaces the next-
free-draw timestamp.
4. No active draw + sig set → landing w. FREE DRAW (the daily quota
is available).
4a. + no equipped deck → also show backup-deck Brief banner.
"""
active_draw = active_draw_for(request.user)
sig_card, sig_reversed = _resolve_sig(request.user, active_draw)
@@ -194,10 +202,22 @@ def my_sea(request):
default_spread = active_draw.spread
saved_hand = active_draw.hand
next_free_draw_at = active_draw.next_free_draw_at
hand_complete = active_draw.is_hand_complete
hand_empty = active_draw.is_hand_empty
else:
default_spread = "situation-action-outcome"
saved_hand = []
next_free_draw_at = None
hand_complete = False
hand_empty = True
# Picker is the active phase iff the user has a non-empty hand in
# progress (or completed). Empty-hand active draws (post-DEL) fall
# back to the landing — but render GATE VIEW instead of FREE DRAW
# (the daily quota's spent already; landing's primary nav routes to
# the upcoming gatekeeper). New users + post-24h users land on the
# standard FREE DRAW landing.
show_picker = active_draw is not None and not hand_empty
quota_spent = active_draw is not None # any active row = quota committed
# Per-position lookup for the template — keyed by the position slug
# ("lay", "cover", ...) so each `.sea-pos-<name>` block can render
@@ -234,11 +254,14 @@ def my_sea(request):
_my_sea_deck_data(request.user, exclude_id=sig_card.id if sig_card else None)
if user_has_sig else {"levity": [], "gravity": []}
),
# Iter 4b
# Iter 4b / 4c
"active_draw": active_draw,
"saved_hand": saved_hand,
"saved_by_position": saved_by_position,
"next_free_draw_at": next_free_draw_at,
"hand_complete": hand_complete,
"show_picker": show_picker,
"quota_spent": quota_spent,
"page_class": "page-gameboard page-my-sea",
})
@@ -257,12 +280,21 @@ def _resolve_sig(user, active_draw):
@login_required(login_url="/")
@require_POST
def my_sea_lock(request):
"""Persist the user's just-drawn hand as a `MySeaDraw` row.
"""Upsert the user's draw hand state. Sprint 5 iter 4c refactor —
fires on every card placement (manual FLIP or AUTO DRAW completion)
rather than only on a discrete LOCK HAND action.
Body: JSON `{"spread": "<slug>", "hand": [{position, card_id, reversed,
polarity}, ...]}`. Returns 200 w. `{ok, next_free_draw_at}` on success,
400 for malformed payload, 409 if the user is still within the free-
draw cooldown window (existing active draw)."""
polarity}, ...]}` — `hand` is the current FULL state (partial OK
for mid-draw; sized to HAND_SIZE_BY_SPREAD for complete).
Returns:
200 `{ok, next_free_draw_at, hand_complete}` on success
400 malformed payload or no sig
409 spread differs from the user's already-active draw's spread
(the spread is locked at first-card moment; can't switch mid-
draw via a sneaky POST)
"""
try:
payload = json.loads(request.body.decode("utf-8") or "{}")
except json.JSONDecodeError:
@@ -272,10 +304,26 @@ def my_sea_lock(request):
hand = payload.get("hand")
if not spread or not isinstance(hand, list) or not hand:
return JsonResponse({"error": "spread_and_hand_required"}, status=400)
if spread not in HAND_SIZE_BY_SPREAD:
return JsonResponse({"error": "unknown_spread"}, status=400)
if active_draw_for(request.user) is not None:
return JsonResponse({"error": "quota_active"}, status=409)
existing = active_draw_for(request.user)
if existing is not None:
# Mid-draw upsert OR post-DEL re-draw (which Sprint 6 will route
# through the gatekeeper but the endpoint stays permissive here).
# Spread-switch attempts get 409 — the spread is committed at
# first-card moment.
if existing.spread != spread:
return JsonResponse({"error": "spread_mismatch"}, status=409)
existing.hand = hand
existing.save(update_fields=["hand"])
return JsonResponse({
"ok": True,
"next_free_draw_at": existing.next_free_draw_at.isoformat(),
"hand_complete": existing.is_hand_complete,
})
# First card draw → quota commit. Create the row.
sig_id = request.user.significator_id
if sig_id is None:
return JsonResponse({"error": "no_significator"}, status=400)
@@ -290,18 +338,35 @@ def my_sea_lock(request):
return JsonResponse({
"ok": True,
"next_free_draw_at": draw.next_free_draw_at.isoformat(),
"hand_complete": draw.is_hand_complete,
})
@login_required(login_url="/")
@require_POST
def my_sea_delete(request):
"""Delete the user's active draw — invoked by the DEL guard portal's
CONFIRM. Idempotent: a second call w. no active draw is a 204."""
MySeaDraw.objects.filter(user=request.user).delete()
"""Clear the user's active draw hand — preserves the `MySeaDraw` row
so the 24h quota window keeps running. Per user spec (2026-05-20):
DEL doesn't refund the daily free-draw; the row stays as a quota
tracker until 24h elapse, after which `active_draw_for`'s lazy
cleanup reaps it (or the `delete_stale_my_sea_draws` mgmt cmd does).
Idempotent: re-firing on a row w. already-empty hand is a no-op."""
draw = active_draw_for(request.user)
if draw is not None:
draw.hand = []
draw.save(update_fields=["hand"])
return HttpResponse(status=204)
@login_required(login_url="/")
def my_sea_gate(request):
"""Stub for the Sprint 6 gatekeeper. Renders a 404 for now — the
button-target placeholder lets the template's GATE VIEW UX wire up
in advance; Sprint 6 will replace this w. the token-deposit flow."""
return HttpResponse(status=404)
def _my_sea_deck_data(user, exclude_id=None):
"""Build the shuffled deck (levity + gravity halves) for the my-sea
picker's card-draw mechanic. Card payload shape is whatever