2026-04-16 03:03:19 -04:00
|
|
|
import json
|
2026-03-09 01:07:16 -04:00
|
|
|
import stripe
|
2026-04-16 03:03:19 -04:00
|
|
|
import zoneinfo
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
|
|
|
|
import requests as http_requests
|
2026-03-09 01:07:16 -04:00
|
|
|
|
|
|
|
|
from django.conf import settings
|
2026-03-01 21:19:12 -05:00
|
|
|
from django.contrib import messages
|
2026-03-04 00:07:10 -05:00
|
|
|
from django.contrib.auth.decorators import login_required
|
2026-03-07 00:05:32 -05:00
|
|
|
from django.db.models import Max, Q
|
sky.html: DEL btn at wheel center; async SAVE SKY transitions into saved state without reload; pre-save hides wheel-col so form+SAVE SKY stay centered — TDD
DEL btn (.btn-danger, "Forget sky?" data-confirm wired to the global #id_guard_portal) sits absolutely centered inside .sky-wheel-col; OK submits a POST to the new sky_delete view, which clears every sky_* field on the User model & redirects back to /dashboard/sky/.
The sky.html aperture is now uniform across saved/unsaved: form-col is always flex-column align-center justify-center so the fields + SAVE SKY pair sits visually centered. body.sky-saved adds *only* the snap-binary scroll layer (scroll-snap-type:y, modal-body display:contents, cols min-height:100% scroll-snap-align:start, wheel-col aspect-ratio cap released, form-col flex:0 0 auto so the snap basis wins) — the column-stacking is no longer gated.
Async save: SAVE SKY's success branch now calls _activateSavedState(), which adds body.sky-saved, draws the wheel from _lastChartData, pins overlay.scrollTop to the form section's offsetTop, then runs the existing _scrollApertureToTop ease-out so the wheel reveals from above instead of replacing the form with a hard cut. The wheel preview that previously redrew during typing is now gated on _savedSky — pre-first-save typing fetches the chart data (so SAVE SKY enables) but does not render the wheel, mirroring the My Sky applet's "no wheel until saved" UX. The in-room PICK SKY overlay (_sky_overlay.html) still previews live, deliberately untouched.
Pre-save the wheel-col is hidden via `body:not(.sky-saved) .sky-page .sky-wheel-col { display: none }`, so the empty SVG can't shunt the form below the fold (& the DEL btn rides the same selector since it lives inside .sky-wheel-col).
Tests: SkyDeleteTest IT class (5: clears fields, redirects, 405 on GET, login required, preserves unrelated user fields). MySkyDeleteFlowTest FT class (3: DEL btn visibility gated on sky data, NVM dismisses w. data intact, OK clears + reverts body class). MySkyAsyncSaveTest FT (1: fresh user → SAVE SKY → body picks up sky-saved, wheel SVG populates, DEL btn becomes visible — all without a page reload). All 13 sky FTs + sky ITs green; existing MySkyApertureSnapScrollTest & MySkyTimezoneRefreshTest still pass.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:07:56 -04:00
|
|
|
from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect, JsonResponse
|
2026-01-13 20:58:05 -05:00
|
|
|
from django.shortcuts import redirect, render
|
sky.html: DEL btn at wheel center; async SAVE SKY transitions into saved state without reload; pre-save hides wheel-col so form+SAVE SKY stay centered — TDD
DEL btn (.btn-danger, "Forget sky?" data-confirm wired to the global #id_guard_portal) sits absolutely centered inside .sky-wheel-col; OK submits a POST to the new sky_delete view, which clears every sky_* field on the User model & redirects back to /dashboard/sky/.
The sky.html aperture is now uniform across saved/unsaved: form-col is always flex-column align-center justify-center so the fields + SAVE SKY pair sits visually centered. body.sky-saved adds *only* the snap-binary scroll layer (scroll-snap-type:y, modal-body display:contents, cols min-height:100% scroll-snap-align:start, wheel-col aspect-ratio cap released, form-col flex:0 0 auto so the snap basis wins) — the column-stacking is no longer gated.
Async save: SAVE SKY's success branch now calls _activateSavedState(), which adds body.sky-saved, draws the wheel from _lastChartData, pins overlay.scrollTop to the form section's offsetTop, then runs the existing _scrollApertureToTop ease-out so the wheel reveals from above instead of replacing the form with a hard cut. The wheel preview that previously redrew during typing is now gated on _savedSky — pre-first-save typing fetches the chart data (so SAVE SKY enables) but does not render the wheel, mirroring the My Sky applet's "no wheel until saved" UX. The in-room PICK SKY overlay (_sky_overlay.html) still previews live, deliberately untouched.
Pre-save the wheel-col is hidden via `body:not(.sky-saved) .sky-page .sky-wheel-col { display: none }`, so the empty SVG can't shunt the form below the fold (& the DEL btn rides the same selector since it lives inside .sky-wheel-col).
Tests: SkyDeleteTest IT class (5: clears fields, redirects, 405 on GET, login required, preserves unrelated user fields). MySkyDeleteFlowTest FT class (3: DEL btn visibility gated on sky data, NVM dismisses w. data intact, OK clears + reverts body class). MySkyAsyncSaveTest FT (1: fresh user → SAVE SKY → body picks up sky-saved, wheel SVG populates, DEL btn becomes visible — all without a page reload). All 13 sky FTs + sky ITs green; existing MySkyApertureSnapScrollTest & MySkyTimezoneRefreshTest still pass.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:07:56 -04:00
|
|
|
from django.urls import reverse
|
2026-03-15 16:08:34 -04:00
|
|
|
from django.utils import timezone
|
feat: shop PaymentIntent flow — `shop_buy` + `shop_confirm` + `stripe_webhook` — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync `/shop/confirm` is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call `Purchase.fulfill()` which is idempotent — whichever lands first wins, the other becomes a no-op via the `status==SUCCEEDED` guard. **`POST /dashboard/wallet/shop/buy`** (form-encoded `shop_item_slug`): looks up active ShopItem (404 if missing/inactive); enforces `max_owned` via `is_available_for(user)` (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via `order_by('-pk').first()` per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates `Purchase` w. pi.id; backfills pi.metadata.purchase_id via `PaymentIntent.modify` so the webhook handler can resolve back to the row; returns `{client_secret, purchase_id}` JSON for Stripe.js `confirmCardPayment`. **`POST /dashboard/wallet/shop/confirm`** (form-encoded `purchase_id`): retrieves PI from Stripe, if `status=='succeeded'` calls `purchase.fulfill()`; returns `{status}` JSON. 404 if the purchase doesn't belong to `request.user`. Idempotent — re-firing after fulfill is a safe no-op. **`POST /stripe/webhook`** (csrf_exempt, mounted at root `/stripe/webhook` so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via `stripe.Webhook.construct_event` against `STRIPE_WEBHOOK_SECRET` env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on `payment_intent.succeeded` looks up Purchase by `metadata.purchase_id` w. fall-back to `stripe_payment_intent_id` (both unique). Unknown event types are no-op 200 (Stripe sends `charge.dispute.created` etc. + would retry indefinitely on 5xx). New `STRIPE_WEBHOOK_SECRET = os.environ.get(...)` setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in `test_shop_views.py` across 3 classes: `ShopBuyViewTest` (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); `ShopConfirmViewTest` (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); `StripeWebhookViewTest` (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via `mock.patch('apps.dashboard.views.stripe')`. 1208 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:42:09 -04:00
|
|
|
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
|
2026-02-21 23:13:23 -05:00
|
|
|
|
2026-04-21 15:46:30 -04:00
|
|
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
from apps.drama.models import Note
|
2026-05-30 14:51:21 -04:00
|
|
|
from apps.epic.models import DeckVariant
|
2026-04-21 15:46:30 -04:00
|
|
|
from apps.epic.utils import _compute_distinctions
|
feat: shop PaymentIntent flow — `shop_buy` + `shop_confirm` + `stripe_webhook` — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync `/shop/confirm` is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call `Purchase.fulfill()` which is idempotent — whichever lands first wins, the other becomes a no-op via the `status==SUCCEEDED` guard. **`POST /dashboard/wallet/shop/buy`** (form-encoded `shop_item_slug`): looks up active ShopItem (404 if missing/inactive); enforces `max_owned` via `is_available_for(user)` (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via `order_by('-pk').first()` per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates `Purchase` w. pi.id; backfills pi.metadata.purchase_id via `PaymentIntent.modify` so the webhook handler can resolve back to the row; returns `{client_secret, purchase_id}` JSON for Stripe.js `confirmCardPayment`. **`POST /dashboard/wallet/shop/confirm`** (form-encoded `purchase_id`): retrieves PI from Stripe, if `status=='succeeded'` calls `purchase.fulfill()`; returns `{status}` JSON. 404 if the purchase doesn't belong to `request.user`. Idempotent — re-firing after fulfill is a safe no-op. **`POST /stripe/webhook`** (csrf_exempt, mounted at root `/stripe/webhook` so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via `stripe.Webhook.construct_event` against `STRIPE_WEBHOOK_SECRET` env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on `payment_intent.succeeded` looks up Purchase by `metadata.purchase_id` w. fall-back to `stripe_payment_intent_id` (both unique). Unknown event types are no-op 200 (Stripe sends `charge.dispute.created` etc. + would retry indefinitely on 5xx). New `STRIPE_WEBHOOK_SECRET = os.environ.get(...)` setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in `test_shop_views.py` across 3 classes: `ShopBuyViewTest` (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); `ShopConfirmViewTest` (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); `StripeWebhookViewTest` (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via `mock.patch('apps.dashboard.views.stripe')`. 1208 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:42:09 -04:00
|
|
|
from apps.lyric.models import PaymentMethod, Purchase, ShopItem, Token, User, Wallet, is_reserved_username
|
2026-01-13 20:58:05 -05:00
|
|
|
|
2026-02-21 23:13:23 -05:00
|
|
|
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
APPLET_ORDER = ["wallet", "username", "palette"]
|
2026-04-22 04:02:14 -04:00
|
|
|
_BASE_UNLOCKED = frozenset([
|
2026-03-15 18:52:09 -04:00
|
|
|
"palette-default",
|
2026-04-18 00:11:31 -04:00
|
|
|
"palette-cedar",
|
2026-03-21 23:57:05 -04:00
|
|
|
"palette-oblivion-light",
|
2026-03-15 18:52:09 -04:00
|
|
|
"palette-monochrome-dark",
|
|
|
|
|
])
|
2026-04-22 04:02:14 -04:00
|
|
|
_PALETTE_DEFS = [
|
|
|
|
|
{"name": "palette-default", "label": "Earthman", "locked": False},
|
|
|
|
|
{"name": "palette-cedar", "label": "Cedar", "locked": False},
|
|
|
|
|
{"name": "palette-oblivion-light", "label": "Oblivion (Light)","locked": False},
|
|
|
|
|
{"name": "palette-monochrome-dark","label": "Monochrome (Dark)","locked": False},
|
|
|
|
|
{"name": "palette-bardo", "label": "Bardo", "locked": True},
|
|
|
|
|
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
|
|
|
|
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
|
|
|
|
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
|
|
|
|
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
Baltimorean Note unlock loop — full UX from bawlmorese pronoun pick → Brief banner → DON → palette modal → dashboard swatch ; rootvars.scss adds the Baltimorean (Blt) hue family (red 200,16,46 / yellow 255,212,0 / white 255,255,255 / black 0,0,0 / purple 26,25,95 / orange 221,73,38 — Maryland-flag-derived plus a `--sixBlt: 162,170,173` neutral) + two `.palette-baltimore` / `.palette-maryland` palette classes wiring those hues into the standard `--priUser`…`--decUser` slots; companion section-header rename "/* X Palette */" → "/* X Hues */" across rootvars to disambiguate raw hue families (Precious Metal / Cosmic Metal / Chroma / Earthman / Technoman / Inferno) from actual palette classes — section-comment-only, no rule-level change ; baltimorean entry added in 3 registries that drive the loop: `_NOTE_DISPLAY` (drama/models.py) — `{"greeting": "Ayo,", "title": "Ard!"}` so DON flips navbar `Welcome, Earthman` → `Ayo, Ard!`; `_NOTE_TITLES` (dashboard/views.py, user-pre-staged) — drives the "recognized via Baltimorean" copy on dashboard palette swatches; `_NOTE_META` (billboard/views.py) — Baltimorean title + the literal description `"Aaron earned an iron urn."` + palette_options [palette-baltimore, palette-maryland] feeding the my-notes swatch modal ; `set_pronouns` view rewired (dashboard/views.py) — first-time `pronouns = bawlmorese` selection calls `Note.grant_if_new(user, "baltimorean")` + returns `{"brief": brief.to_banner_dict()}` JSON @ 200; idempotent on repeat (the grant_if_new returns brief=None on second call so the 204 path resumes naturally); non-bawlmorese choices stay on the original 204 contract ; client wiring: game-kit.js pronouns `commit()` handles the 200 JSON path — `resp.json().then(data => Brief.showBanner(data.brief))` instead of reload (reload would lose the just-fired banner); 204 still reloads to update active pronoun card; `game_kit.html` pulls in `apps/dashboard/note.js` so `Brief` is in scope on the Game Kit page (it wasn't before) ; Brief banner placement fix — `note.js showBanner()` now measures the `.row .col-lg-6 h2` at render-time + sets inline `top` so the banner portals SQUARELY OVER the page h2 letter-spread wordmark instead of parking at the SCSS-default `top: 0.5rem` (which had it lurking above the wordmark area on every page); portrait-only (gated `if window.innerWidth > window.innerHeight return`) — landscape h2 lives in a `writing-mode: vertical-rl` fixed sidebar column + would need a full banner reorientation (writing-mode + flex-direction restyle of banner contents) to "overlay" sensibly, deferred to a follow-up sprint ; tests: drama/tests/unit/test_models.py (new file) — 5 UTs for `_NOTE_DISPLAY[baltimorean]` greeting/title/name + stargazer smoke tests; dashboard/tests/integrated/test_views.py — `SetPronounsBawlmoreseUnlockTest` (9 ITs covering first-bawlmorese-returns-200-w-brief / Note granted / title `Ard!` / square_url to /billboard/my-notes/ / idempotent on repeat / non-bawlmorese unaffected / bawlmorese-after-other still grants); existing `SetPronounsViewTest.test_post_each_valid_choice` docstring updated to flag the bawlmorese 200 branch ; functional_tests/test_bill_baltimorean.py (new file) — 6 FTs walking the full UX: T1 Game-Kit pronouns click → Brief banner w. `Ard!` title + Look! prose + ?-square + FYI nav; T2 idempotent repeat-click (no re-fire); T3 my-notes Baltimorean item carries the Aaron quote verbatim; T4 DON flips navbar greeting `Welcome, Earthman` → `Ayo, Ard!`; T5 palette modal offers Baltimore + Maryland swatches (and not Bardo/Sheol); T6 Baltimore swatch click previews → OK commits → dashboard Palette applet shows the swatch unlocked w. `data-description` carrying `Baltimorean` + non-empty `data-unlocked-date` + Note.palette = palette-baltimore in DB — all 6 green in 51s; full IT/UT sweep 997 → green in 45s — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 02:17:07 -04:00
|
|
|
{"name": "palette-baltimore", "label": "Baltimore", "locked": True},
|
|
|
|
|
{"name": "palette-maryland", "label": "Maryland", "locked": True},
|
2026-03-02 15:45:12 -05:00
|
|
|
]
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
_NOTE_TITLES = {
|
2026-04-22 04:02:14 -04:00
|
|
|
"stargazer": "Stargazer",
|
|
|
|
|
"schizo": "Schizo",
|
|
|
|
|
"nomad": "Nomad",
|
Baltimorean Note unlock loop — full UX from bawlmorese pronoun pick → Brief banner → DON → palette modal → dashboard swatch ; rootvars.scss adds the Baltimorean (Blt) hue family (red 200,16,46 / yellow 255,212,0 / white 255,255,255 / black 0,0,0 / purple 26,25,95 / orange 221,73,38 — Maryland-flag-derived plus a `--sixBlt: 162,170,173` neutral) + two `.palette-baltimore` / `.palette-maryland` palette classes wiring those hues into the standard `--priUser`…`--decUser` slots; companion section-header rename "/* X Palette */" → "/* X Hues */" across rootvars to disambiguate raw hue families (Precious Metal / Cosmic Metal / Chroma / Earthman / Technoman / Inferno) from actual palette classes — section-comment-only, no rule-level change ; baltimorean entry added in 3 registries that drive the loop: `_NOTE_DISPLAY` (drama/models.py) — `{"greeting": "Ayo,", "title": "Ard!"}` so DON flips navbar `Welcome, Earthman` → `Ayo, Ard!`; `_NOTE_TITLES` (dashboard/views.py, user-pre-staged) — drives the "recognized via Baltimorean" copy on dashboard palette swatches; `_NOTE_META` (billboard/views.py) — Baltimorean title + the literal description `"Aaron earned an iron urn."` + palette_options [palette-baltimore, palette-maryland] feeding the my-notes swatch modal ; `set_pronouns` view rewired (dashboard/views.py) — first-time `pronouns = bawlmorese` selection calls `Note.grant_if_new(user, "baltimorean")` + returns `{"brief": brief.to_banner_dict()}` JSON @ 200; idempotent on repeat (the grant_if_new returns brief=None on second call so the 204 path resumes naturally); non-bawlmorese choices stay on the original 204 contract ; client wiring: game-kit.js pronouns `commit()` handles the 200 JSON path — `resp.json().then(data => Brief.showBanner(data.brief))` instead of reload (reload would lose the just-fired banner); 204 still reloads to update active pronoun card; `game_kit.html` pulls in `apps/dashboard/note.js` so `Brief` is in scope on the Game Kit page (it wasn't before) ; Brief banner placement fix — `note.js showBanner()` now measures the `.row .col-lg-6 h2` at render-time + sets inline `top` so the banner portals SQUARELY OVER the page h2 letter-spread wordmark instead of parking at the SCSS-default `top: 0.5rem` (which had it lurking above the wordmark area on every page); portrait-only (gated `if window.innerWidth > window.innerHeight return`) — landscape h2 lives in a `writing-mode: vertical-rl` fixed sidebar column + would need a full banner reorientation (writing-mode + flex-direction restyle of banner contents) to "overlay" sensibly, deferred to a follow-up sprint ; tests: drama/tests/unit/test_models.py (new file) — 5 UTs for `_NOTE_DISPLAY[baltimorean]` greeting/title/name + stargazer smoke tests; dashboard/tests/integrated/test_views.py — `SetPronounsBawlmoreseUnlockTest` (9 ITs covering first-bawlmorese-returns-200-w-brief / Note granted / title `Ard!` / square_url to /billboard/my-notes/ / idempotent on repeat / non-bawlmorese unaffected / bawlmorese-after-other still grants); existing `SetPronounsViewTest.test_post_each_valid_choice` docstring updated to flag the bawlmorese 200 branch ; functional_tests/test_bill_baltimorean.py (new file) — 6 FTs walking the full UX: T1 Game-Kit pronouns click → Brief banner w. `Ard!` title + Look! prose + ?-square + FYI nav; T2 idempotent repeat-click (no re-fire); T3 my-notes Baltimorean item carries the Aaron quote verbatim; T4 DON flips navbar greeting `Welcome, Earthman` → `Ayo, Ard!`; T5 palette modal offers Baltimore + Maryland swatches (and not Bardo/Sheol); T6 Baltimore swatch click previews → OK commits → dashboard Palette applet shows the swatch unlocked w. `data-description` carrying `Baltimorean` + non-empty `data-unlocked-date` + Note.palette = palette-baltimore in DB — all 6 green in 51s; full IT/UT sweep 997 → green in 45s — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 02:17:07 -04:00
|
|
|
"baltimorean": "Baltimorean",
|
2026-04-22 04:02:14 -04:00
|
|
|
}
|
|
|
|
|
# Keep PALETTES as an alias used by views that don't have a request user.
|
|
|
|
|
PALETTES = _PALETTE_DEFS
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _palettes_for_user(user):
|
|
|
|
|
if not (user and user.is_authenticated):
|
2026-04-26 18:59:10 -04:00
|
|
|
return [
|
|
|
|
|
dict(p, description="available by default" if not p["locked"] else "explore to unlock")
|
|
|
|
|
for p in _PALETTE_DEFS
|
|
|
|
|
]
|
2026-04-22 04:02:14 -04:00
|
|
|
granted = {
|
|
|
|
|
r.palette: r
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
|
2026-04-22 04:02:14 -04:00
|
|
|
}
|
|
|
|
|
result = []
|
|
|
|
|
for p in _PALETTE_DEFS:
|
|
|
|
|
entry = dict(p)
|
|
|
|
|
r = granted.get(p["name"])
|
|
|
|
|
if r and p["locked"]:
|
|
|
|
|
entry["locked"] = False
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
|
2026-04-26 18:59:10 -04:00
|
|
|
entry["description"] = f"recognized via {title}"
|
|
|
|
|
entry["unlocked_date"] = r.earned_at.isoformat()
|
|
|
|
|
elif not p["locked"]:
|
|
|
|
|
entry["description"] = "available by default"
|
2026-04-22 04:02:14 -04:00
|
|
|
else:
|
2026-04-26 18:59:10 -04:00
|
|
|
entry["description"] = "explore to unlock"
|
2026-04-22 04:02:14 -04:00
|
|
|
result.append(entry)
|
|
|
|
|
return result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _unlocked_palettes_for_user(user):
|
|
|
|
|
base = set(_BASE_UNLOCKED)
|
|
|
|
|
if user and user.is_authenticated:
|
rename: Note→Post/Line (dashboard); Recognition→Note (drama); new-post/my-posts to billboard
- dashboard: Note→Post, Item→Line across models, forms, views, API, urls & tests
- new-post (9×3) & my-posts (3×3) applets migrate from dashboard→billboard context; billboard view passes form & recent_posts
- drama: Recognition→Note, related_name notes; billboard URL /recognition/→/my-notes/, set-palette at /note/<slug>/set-palette
- recognition.js→note.js (module Note, data.note key); recognition-page.js→note-page.js; .recog-*→.note-*
- _recognition.scss→_note.scss; BillNotes page header; applet slug billboard-recognition→billboard-notes (My Notes)
- NoteSpec.js replaces RecognitionSpec.js; test_recognition.py→test_applet_my_notes.py
- 4 migrations applied: dashboard 0004, applets 0011+0012, drama 0005; 683 ITs green
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:32:34 -04:00
|
|
|
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
|
2026-04-22 04:02:14 -04:00
|
|
|
base.add(r.palette)
|
|
|
|
|
return base
|
2026-03-02 13:57:03 -05:00
|
|
|
|
|
|
|
|
|
2026-01-13 20:58:05 -05:00
|
|
|
def home_page(request):
|
2026-03-07 00:05:32 -05:00
|
|
|
context = {
|
2026-04-22 04:02:14 -04:00
|
|
|
"palettes": _palettes_for_user(request.user),
|
2026-03-07 00:05:32 -05:00
|
|
|
"page_class": "page-dashboard",
|
|
|
|
|
}
|
2026-03-05 16:08:40 -05:00
|
|
|
if request.user.is_authenticated:
|
2026-03-09 21:13:35 -04:00
|
|
|
context["applets"] = applet_context(request.user, "dashboard")
|
polish + bugfix session — wallet/Game Kit applet realign; my_sea label/shadow polish; DEL/FLIP state machine; sig-change cooldown loophole closure; sky-wheel planet shadow; Fiorentine additive numerals; kit-bag DOFF async refresh — TDD
End-of-session bundle 2026-05-26 covering ~10 distinct threads atop the A.7.5-polish-8 sky-wheel mini-portal commit (9cdd2cd). A.8 room.html sprint deferred per user — waiting on image scraping for RWS + future decks so the room can apply the image-mode pattern uniformly w.o. straddling text-mode fallback for unequippable Earthman Shabby Cardstock.
**(1) Game Kit + My Wallet applet realignment** — user spec "this isn't a place for tokens" / "only equippables should be there". Game Kit applet (/gameboard/, _applet-game-kit.html) drops the Free Token block — only PASS/BAND/CARTE/COIN trinkets + decks + dice remain. Free + Tithe tokens MOVED to the My Wallet applet on /dashboard/ (_applet-wallet.html rewrite). All trinkets COPIED into Wallet w. same .tt tooltip + DON/DOFF wiring so the user can equip from either surface. Stacked free/tithe icons (single icon per type) carry a .shop-badge ×N count (fa-coins for free, fa-piggy-bank for tithe — the latter standardized from outlier fa-hand-holding-dollar, now matching wallet / kit_bag / shop seed / FTs). Writs placeholder gets the same .token + .tt chrome ("Base currency unit ; Earned at the gate, spent in the shop"). 99+ cap on all badges. home_page view in apps/dashboard/views.py now passes pass/band/carte/coin + free/tithe tokens + counts + equipped_trinket_id. gameboard.js loaded on dashboard for the hover-portal tooltip system; #id_game_kit wrapper added (uses display: contents to stay transparent to the section-grid layout). Standalone game_kit.html page (_game_kit_sections.html) also reorganized — trinkets/tokens/decks each use bare .token icons w. centered flex row + 2rem gap, 1.5rem font-size to match gameboard sizing. id_game_kit outer wrapper data attrs (equipped-id, equipped-deck-id, in-use-deck-ids) feed buildMiniContent() for Equipped/Not Equipped/In-Use status.
**(2) My Sea label + shadow polish (my_sea.html Cross + applet)** — user spec "labels appear below and beneath the card, w. the card's shadow obscuring the very top of the label" per the GRAVITY/LEVITY .sea-stack-name pattern. .sea-pos-label repositioning: CROWN + COVER ABOVE slot (bottom: 100%; translate(-50%, -0.4rem)), LAY + CROSS BELOW slot (top: 100%; translate(-50%, 0.3rem)), LEAVE + LOOM increased breathing room (translate -0.4rem LEFT / 0.4rem RIGHT — was 0.1rem overlap). CROWN cell translateY(-0.5rem) UP + LAY cell translateY(0.5rem) DOWN for COVER/CROSS label breathing room. Filled-card downward shadow chain (1px 2px 0 black, 0 4px 0 black-faint, 2px 5px 5px black-blur) scoped to .my-sea-cross .sea-card-slot--filled only — empty dashed placeholders stay shadowless per user spec ("only the cards that replace [slots] should [have shadows]"). Four rotation-correction overrides for box-shadow rotating w. element transform: base (0deg), reversed (180deg sign-flip), cross (90deg matrix rotation → 2px -1px), cross+reversed (270deg → -2px 1px). Saved here for future reference since the matrix derivation is non-obvious: CSS rotate(θ) CW maps offset (a, b) → screen (a·cos θ − b·sin θ, a·sin θ + b·cos θ); solving for unrotated offsets that produce screen-down-right post-rotation gives the 4 chains. My Sea applet .my-sea-slot-label (z-index 0, margin-top 0.15rem) + .my-sea-slot--filled shadow + reversed-variant shadow inversion all mirror the page treatment.
**(3) DEL btn + FLIP btn state machine** — user spec: DEL un-disables as soon as ANY card drawn (was gated on hand_complete) ; FLIP btn .btn-disabled + text swap to × once hand complete. _setComplete(on) toggles FLIP btn class + label (parity w. DEL convention: × disabled / word active) ; new _setHasDrawn(on) helper extracted (was bundled in _setComplete). Wired into 4 transitions: (a) manual deposit _filled === 1, (b) initial page-load seed when _filled > 0, (c) AUTO DRAW path post-POST (CRITICAL FIX — was missing, only manual deposit synced DEL even though server already committed all cards on AUTO DRAW), (d) _resetHand spread-switch reset. Template DEL btn gates on saved_by_position (any draw); FLIP btn gates on hand_complete. Test test_partial_hand_del_btn_carries_btn_disabled inverted to test_partial_hand_del_btn_is_enabled per the new spec.
**(4) Sig-change MySeaDraw RESET (cooldown loophole closure)** — user-reported revenue-stream loophole 2026-05-26: switching sig used to re-open the FREE DRAW gate + forfeit any paid-draw credit, because apps/gameboard/views.py:266's `in_cooldown = active_draw is not None` keyed entirely off the MySeaDraw row's existence (NOT off User.last_free_draw_at, which is the cooldown TIMER but doesn't drive the in_cooldown decision). Initial draft DELETED the row on sig change — turned out too aggressive: lost both the cooldown anchor (created_at via the active_draw check) AND the paid-state fields (deposit_token_id, paid_through_at). FIX: save_sign on actual sig change `.update(hand=[], significator_id=new, significator_reversed=new)` — preserves cooldown + paid revenue, just resets the hand + sig snapshot. clear_sign left untouched (sig-cleared user can't draw anyway per my_sea_lock's no_significator guard; row sits dormant until re-pick routes through save_sign's reset). Guarded w. sig_changed so re-saving the same sig is a no-op. User.last_free_draw_at was always safe — User-level field, only ever set in my_sea_lock, never cleared (user confirmed the Brief shows 11:59pm consistently). Subtle architectural note for future: the in_cooldown decision being row-existence-based rather than timestamp-based is the load-bearing implicit dependency this loophole exposed; any refactor that delete()s the row needs to either flip in_cooldown to consult last_free_draw_at OR preserve the row as we did here.
**(5) Kit-bag DOFF async refresh** — user-reported 2026-05-26: deck disappears entirely from kit-bag on first DOFF; only manual page refresh restores the placeholder. Root cause: _syncKitBagDialog() in gameboard.js did card.querySelector('i') for the placeholder icon — worked for trinket/token cards (single FA <i>) but BROKE for image-equipped decks whose card-stack icon is <svg class="deck-stack-icon"> (no <i> to copy → empty placeholder div). DROP the client-side optimization, route both DOFF paths thru _refreshKitDialog() (symmetric w. DON). Single source of truth = server-rendered _kit_bag_panel.html's placeholder branch (re-renders _deck_stack_icon.html w.o. the deck arg for the empty-fill SVG).
**(6) Sky-wheel planet circle shadow** — user spec "tight 1px 1px black shadow at opacity 0.7 on planet circle groups in all sky locations". Base `filter: drop-shadow(1px 1px 0 rgba(0,0,0,0.7))` on .nw-planet-group so planet badges lift off the wheel rings on /dashboard/sky/ + My Sky applet + any future surface. Hover/active state chains shadow + glow ("drop-shadow ... ; drop-shadow(0 0 5px primary-lm)") since CSS filter REPLACES rather than APPENDS — shadow has to be re-stated on the hover rule to persist during interaction. Elements/signs/houses groups keep their glow-only hover (the request was planet-specific).
**(7) TarotCard suit_icon + Fiorentine additive numerals** — (a) suit_icon property pre-checks for major arcana trump 0 → fa-hat-cowboy-side (Fool/Nomad/Matto archetype) and trump 1 → fa-hat-wizard (Magician/Schizo/Bagatto archetype), pinned BEFORE the self.icon branch so even a deck seed supplying a different icon for these ranks normalizes to the convention. Earthman's seed already aligns; Minchiate (empty icon field) used to fall thru to fa-hand-dots. (b) _to_roman() adds _FIORENTINE_ADDITIVE_NUMERALS = {4:'IIII', 19:'XVIIII', 24:'XXIIII', 29:'XXVIIII', 34:'XXXIIII', 39:'XXXVIIII'} pre-check — locked-in 6-exception list per user-corrected spec (initial draft used universal additive form, user clarified "no, only these specific ones, e.g. trump 9 still prints IX + trump 14 still prints XIV per the actual Minchiate deck art"). +2 regression tests: additive overrides + non-overridden subtractive (9=IX, 14=XIV, 44=XLIV, 49=XLIX).
**(8) Gear menu NVM font fix** — _my_sea_gear.html's NVM btn changed from <a class="btn"> to <button onclick="location.href=..."> per [[feedback-btn-vs-anchor-font-family]] (anchor inherits body serif font; button stays sans-serif by browser default). Brief's NVM uses <button> + reads correctly — this matches it.
**(9) Image-mode slot transparency overrides** — 3 surfaces got `overflow: visible` (base overflow: hidden was clipping the contour-stroke filter chain) + transparent bg/border re-states for image-equipped Minchiate cards on (a) .my-sea-cross .sea-card-slot--filled + image variant, (b) .sig-stage-card.sea-sig-card.sig-stage-card--image base + levity-polarity nested override, (c) .sea-deck-stack--single .sea-stack-face:has(.sea-stack-face-img) (using :has() to key off the conditional back-img child). Followup to A.7.5-polish-* sprint — those surfaces' image-mode bg overrides didn't include overflow.
Tests: 1336/1336 IT+UT total green (was 1322 before the session). No FT runs per [[feedback-ft-run-discipline]]; visual verify ongoing by user across the session via Firefox reload.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-26 01:18:51 -04:00
|
|
|
# My Wallet applet — show all trinkets (PASS/BAND/CARTE/COIN) + stacked
|
|
|
|
|
# Free + Tithe tokens (badge count) + Writs placeholder. Trinkets COPY
|
|
|
|
|
# the Game Kit applet's tooltip + DON/DOFF wiring so the user can equip
|
|
|
|
|
# from either surface; Free + Tithe tokens MOVED off the Game Kit applet
|
|
|
|
|
# since they aren't equippable (user spec 2026-05-25 PM).
|
|
|
|
|
user = request.user
|
|
|
|
|
free_tokens = list(user.tokens.filter(
|
|
|
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
|
|
|
).order_by("expires_at"))
|
|
|
|
|
tithe_tokens = list(user.tokens.filter(token_type=Token.TITHE))
|
|
|
|
|
context.update({
|
|
|
|
|
"coin": user.tokens.filter(token_type=Token.COIN).first(),
|
|
|
|
|
"pass_token": user.tokens.filter(token_type=Token.PASS).first() if user.is_staff else None,
|
|
|
|
|
"band": user.tokens.filter(token_type=Token.BAND).first(),
|
|
|
|
|
"carte": user.tokens.filter(token_type=Token.CARTE).first(),
|
|
|
|
|
"free_tokens": free_tokens,
|
|
|
|
|
"tithe_tokens": tithe_tokens,
|
|
|
|
|
"free_count": len(free_tokens),
|
|
|
|
|
"tithe_count": len(tithe_tokens),
|
|
|
|
|
"equipped_trinket_id": user.equipped_trinket_id,
|
|
|
|
|
})
|
2026-03-05 16:08:40 -05:00
|
|
|
return render(request, "apps/dashboard/home.html", context)
|
2026-01-13 20:58:05 -05:00
|
|
|
|
2026-01-19 19:09:11 -05:00
|
|
|
|
brief sprint C1: relocate Post + Line from dashboard → billboard (no behavior change) — TDD
The Post/Line models always read more like billboard tenants than dashboard ones (1st-person personal vs. 2nd-person provenance feed); the upcoming Brief model needs them in the billboard namespace as the canonical surface they FK into. C1 is a pure relocation w. zero new behavior: 789 ITs + 20 sky/Post FTs green against the moved code.
- billboard.models adds Post (owner + shared_with) + Line (text + post FK), schema mirroring the legacy dashboard models 1:1; Post.get_absolute_url now reverses to `billboard:view_post`.
- billboard.forms adds LineForm + ExistingPostLineForm (moved from dashboard.forms; dashboard/forms.py removed).
- billboard.views absorbs new_post / view_post / share_post / my_posts (templates rendered from apps/billboard/post.html + my_posts.html).
- billboard.urls adds the namespaced routes: /billboard/new-post, /billboard/post/<uuid>/, /billboard/post/<uuid>/share-post, /billboard/users/<uuid>/. dashboard.urls drops the corresponding entries.
- _applet-my-posts + _applet-new-post URL refs now use the billboard: namespace; templates/apps/dashboard/{post,my_posts}.html removed.
- api/serializers + api/views + api/tests/integrated/test_views imports flip dashboard.models → billboard.models (PostSerializer / PostDetailAPI / PostLinesAPI / PostsAPI all retain identifiers — the model rename to Brief lands in C2).
- dashboard/tests/integrated/test_{models,views,forms} + dashboard/tests/unit/test_{models,forms} swap imports; test_views URL strings flip /dashboard/post/ → /billboard/post/, /dashboard/new_post → /billboard/new-post, /dashboard/users/ → /billboard/users/, share_post → share-post (path) / billboard:share_post (reverser). Tests stay in dashboard.tests/ for now — relocation TBD.
- functional_tests/my_posts_page.py URL string flips to /billboard/users/.
- Auto-generated migrations: billboard/0001_initial (CreateModel Post + Line), dashboard/0003_remove_post_* (drops legacy Post + Line), drama/0004_alter_gameevent_verb (incidental — choices field caught up).
This commit drops the dashboard Post/Line tables w/o data preservation; user has confirmed staging-side wipe is acceptable. C2 introduces the Brief model + read-tracking + slide-down banner unification. C3 hooks Note-unlock + share-post-invite + magic-link / invalid-link `messages` calls into the new Brief / banner pipeline.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 17:20:06 -04:00
|
|
|
# Post / Line CRUD lives in apps.billboard.views since the Post + Line models
|
|
|
|
|
# moved to apps.billboard.models.
|
|
|
|
|
|
2026-03-02 13:57:03 -05:00
|
|
|
|
2026-03-04 00:07:10 -05:00
|
|
|
@login_required(login_url="/")
|
2026-03-05 14:45:55 -05:00
|
|
|
def set_palette(request):
|
2026-03-02 13:57:03 -05:00
|
|
|
if request.method == "POST":
|
2026-03-05 14:45:55 -05:00
|
|
|
palette = request.POST.get("palette", "")
|
2026-04-22 04:02:14 -04:00
|
|
|
if palette in _unlocked_palettes_for_user(request.user):
|
2026-03-05 14:45:55 -05:00
|
|
|
request.user.palette = palette
|
|
|
|
|
request.user.save(update_fields=["palette"])
|
2026-03-24 00:26:22 -04:00
|
|
|
if "application/json" in request.headers.get("Accept", ""):
|
|
|
|
|
return JsonResponse({"palette": request.user.palette})
|
2026-03-02 13:57:03 -05:00
|
|
|
return redirect("home")
|
2026-03-04 00:07:10 -05:00
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def set_profile(request):
|
|
|
|
|
if request.method == "POST":
|
|
|
|
|
username = request.POST.get("username", "")
|
post aperture refactor (May-8b): Post.title field; Line.author PROTECT FK + created_at; Note.grant_if_new admin-vs-Look! format dispatch w. note-ref anchor; bottom-anchored aperture w. shared-between header + per-Line user/timestamp; dotted-? Brief square; reserved adman seed — TDD
- schema: billboard/0004 adds Post.title (CharField 35) + Line.author (PROTECT FK, related_name=authored_lines) + Line.created_at (auto_now_add); RunPython backfill stamps existing rows (note_unlock → "Notes & recognitions" + author=adman; user_post → first-line glean + author=Post.owner).
- lyric/0003 seeds adman User (system author for note unlock + share invite Lines); apps.lyric.models gains RESERVED_USERNAMES = {"adman"}, is_reserved_username() guard in dashboard.set_profile, get_or_create_adman() lazy fetch (TransactionTestCase flushes the seed).
- drama: Note.grant_if_new dispatches via _ADMIN_NOTE_SLUGS = {"super-schizo","super-nomad"} — admin slugs use "The administration recognizes…" prose; everyone else uses "Look!—new Note unlocked." Both wrap Note name in `<a class="note-ref">`. Header Line dropped (test_two_different_grants_share_one_post asserts 2 lines, not 3). Note.display_name property added (slug.title() default — "super-schizo" → "Super-Schizo"). User.active_title_display returns donned recognition title or "Earthman" default.
- billboard models: Post.name property removed → my_posts.html, _applet-my-posts.html, PostSerializer switched to Post.title. LineForm.save(for_post, author) + ExistingPostLineForm.save(author) signature + all callers (api.views, billboard.views.new_post + view_post + share_post). billboard.views.share_post authors via get_or_create_adman; new_post truncates first line for Post.title via _truncate_post_title.
- post.html: <h3> post title heading; .post-shared-recipients (commas only) + .post-shared-self lines ("just me, X the Earthman" / "& me, X the Y" 0/≥1 split); #id_post_table is now a <ul> w. justify-content: flex-end + per-Line 3-col grid (author/text/time); adman Lines render |safe + .post-line--system italic; #id_text → #id_post_line_text rename (post.html only — /billboard/ new-post applet keeps #id_text); page_class page-billpost (joins billboard+billscroll body-class trio).
- SCSS _billboard.scss: .post-page extends %billboard-page-base, adds bottom-anchored flex-column scroll + 3-col .post-line grid + .post-line-form pinned at bottom. _note.scss: a.note-banner__image picks up .note-item__image-box dashed-? styling for the Brief square.
- _buddy_panel.html JS rewired for new layout: _appendLine builds <li class="post-line post-line--system"> w. adman+timestamp; _appendRecipientChip handles 0→1+ transition (rewrites "just me," → "& me,", inserts .post-shared-recipients line above self).
- FT post_page.py: get_table_rows queries .post-line; wait_for_row_in_post_table matches by text containment (line_number arg ignored — kept for backwards compat); get_line_input_box probes #id_post_line_text first, falls back to #id_text; get_post_owner reads textContent (hidden span). test_applet_new_post_line_validation switched to input[name="text"]:invalid/:valid for cross-page selectors.
- rootvars.scss: minor plutonium + fuschia tweaks (pre-existing).
- 818 ITs + 35 FTs (buddy/new-post/sharing/validation/layout/jasmine/my-notes/my-posts) green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 21:29:21 -04:00
|
|
|
if is_reserved_username(username, current_user=request.user):
|
|
|
|
|
messages.error(request, "That handle is reserved.")
|
|
|
|
|
return redirect("/")
|
2026-03-04 00:07:10 -05:00
|
|
|
request.user.username = username
|
|
|
|
|
request.user.save(update_fields=["username"])
|
|
|
|
|
return redirect("/")
|
2026-03-05 14:45:55 -05:00
|
|
|
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def set_pronouns(request):
|
|
|
|
|
from django.http import HttpResponseNotAllowed
|
|
|
|
|
from apps.lyric.models import PRONOUN_TABLE
|
|
|
|
|
if request.method != "POST":
|
|
|
|
|
return HttpResponseNotAllowed(["POST"])
|
|
|
|
|
choice = request.POST.get("pronouns", "")
|
|
|
|
|
if choice not in PRONOUN_TABLE:
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
request.user.pronouns = choice
|
|
|
|
|
request.user.save(update_fields=["pronouns"])
|
Baltimorean Note unlock loop — full UX from bawlmorese pronoun pick → Brief banner → DON → palette modal → dashboard swatch ; rootvars.scss adds the Baltimorean (Blt) hue family (red 200,16,46 / yellow 255,212,0 / white 255,255,255 / black 0,0,0 / purple 26,25,95 / orange 221,73,38 — Maryland-flag-derived plus a `--sixBlt: 162,170,173` neutral) + two `.palette-baltimore` / `.palette-maryland` palette classes wiring those hues into the standard `--priUser`…`--decUser` slots; companion section-header rename "/* X Palette */" → "/* X Hues */" across rootvars to disambiguate raw hue families (Precious Metal / Cosmic Metal / Chroma / Earthman / Technoman / Inferno) from actual palette classes — section-comment-only, no rule-level change ; baltimorean entry added in 3 registries that drive the loop: `_NOTE_DISPLAY` (drama/models.py) — `{"greeting": "Ayo,", "title": "Ard!"}` so DON flips navbar `Welcome, Earthman` → `Ayo, Ard!`; `_NOTE_TITLES` (dashboard/views.py, user-pre-staged) — drives the "recognized via Baltimorean" copy on dashboard palette swatches; `_NOTE_META` (billboard/views.py) — Baltimorean title + the literal description `"Aaron earned an iron urn."` + palette_options [palette-baltimore, palette-maryland] feeding the my-notes swatch modal ; `set_pronouns` view rewired (dashboard/views.py) — first-time `pronouns = bawlmorese` selection calls `Note.grant_if_new(user, "baltimorean")` + returns `{"brief": brief.to_banner_dict()}` JSON @ 200; idempotent on repeat (the grant_if_new returns brief=None on second call so the 204 path resumes naturally); non-bawlmorese choices stay on the original 204 contract ; client wiring: game-kit.js pronouns `commit()` handles the 200 JSON path — `resp.json().then(data => Brief.showBanner(data.brief))` instead of reload (reload would lose the just-fired banner); 204 still reloads to update active pronoun card; `game_kit.html` pulls in `apps/dashboard/note.js` so `Brief` is in scope on the Game Kit page (it wasn't before) ; Brief banner placement fix — `note.js showBanner()` now measures the `.row .col-lg-6 h2` at render-time + sets inline `top` so the banner portals SQUARELY OVER the page h2 letter-spread wordmark instead of parking at the SCSS-default `top: 0.5rem` (which had it lurking above the wordmark area on every page); portrait-only (gated `if window.innerWidth > window.innerHeight return`) — landscape h2 lives in a `writing-mode: vertical-rl` fixed sidebar column + would need a full banner reorientation (writing-mode + flex-direction restyle of banner contents) to "overlay" sensibly, deferred to a follow-up sprint ; tests: drama/tests/unit/test_models.py (new file) — 5 UTs for `_NOTE_DISPLAY[baltimorean]` greeting/title/name + stargazer smoke tests; dashboard/tests/integrated/test_views.py — `SetPronounsBawlmoreseUnlockTest` (9 ITs covering first-bawlmorese-returns-200-w-brief / Note granted / title `Ard!` / square_url to /billboard/my-notes/ / idempotent on repeat / non-bawlmorese unaffected / bawlmorese-after-other still grants); existing `SetPronounsViewTest.test_post_each_valid_choice` docstring updated to flag the bawlmorese 200 branch ; functional_tests/test_bill_baltimorean.py (new file) — 6 FTs walking the full UX: T1 Game-Kit pronouns click → Brief banner w. `Ard!` title + Look! prose + ?-square + FYI nav; T2 idempotent repeat-click (no re-fire); T3 my-notes Baltimorean item carries the Aaron quote verbatim; T4 DON flips navbar greeting `Welcome, Earthman` → `Ayo, Ard!`; T5 palette modal offers Baltimore + Maryland swatches (and not Bardo/Sheol); T6 Baltimore swatch click previews → OK commits → dashboard Palette applet shows the swatch unlocked w. `data-description` carrying `Baltimorean` + non-empty `data-unlocked-date` + Note.palette = palette-baltimore in DB — all 6 green in 51s; full IT/UT sweep 997 → green in 45s — TDD
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-18 02:17:07 -04:00
|
|
|
# bawlmorese is the pronoun-side trigger for the Baltimorean Note unlock —
|
|
|
|
|
# mirrors sky_save's stargazer grant. Grant is idempotent (grant_if_new
|
|
|
|
|
# returns no Brief on the second + later calls) so the 204 path resumes
|
|
|
|
|
# naturally after the first unlock.
|
|
|
|
|
if choice == "bawlmorese":
|
|
|
|
|
_note, _created, brief = Note.grant_if_new(request.user, "baltimorean")
|
|
|
|
|
if brief is not None:
|
|
|
|
|
return JsonResponse({"brief": brief.to_banner_dict()})
|
pronouns: per-user pronouns ideology + Pronouns applet on Game Kit; provenance prose uses actor.pronouns at render time — TDD
- User.pronouns CharField w. choices=[pluralism (default), bawlmorese, misogyny, misandry, misanthropy] + pronoun_subj/obj/poss properties; PRONOUN_TABLE single source of truth in apps.lyric.models; mig 0002_user_pronouns
- drama.GameEvent.to_prose() drops module-level PRONOUN_* constants; SIG_READY/SIG_UNREADY/ROLE_SELECTED now resolve poss/subj from self.actor.pronouns at render time, so flipping a user's preference rewrites all their existing scroll prose; default actor → "their"
- SIG_READY prose strips a leading "The " from card_name so "the The Wanderer" reads "the Wanderer" and "the Engraven The Nomad" reads "the Engraven Nomad"; minor arcana ("Maid of Brands") untouched
- new applets/0005 seeds 'pronouns' applet (3x3, game-kit, default visible); _game_kit_sections.html grows a #id_gk_pronouns block w. 5 .gk-pronoun-card items labeled by ideology slug (italic) and tagged data-pronoun + data-trio
- card click → window.showGuard(card, "Set pronoun preference?<span class='guard-pronoun-trio'>{trio}</span>", commitCb); on OK fetches POST /dashboard/set-pronouns w. CSRF cookie + reloads so .active class moves and provenance prose re-renders; NVM dismisses
- dashboard.set_pronouns view (POST-only, login_required, 204/400/405) at /dashboard/set-pronouns; rejects choices not in PRONOUN_TABLE
- _game-kit.scss extends shared card rule to .gk-pronoun-card w. .active fill state + italic ideology label; #id_guard_portal .guard-pronoun-trio styled small/dim/centered under the question
- billscroll aperture: padding-right 0.75rem on #id_drama_scroll inside .applet-scroll so the timestamp column no longer sits beneath the scrollbar
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-04 01:11:40 -04:00
|
|
|
return HttpResponse(status=204)
|
|
|
|
|
|
2026-03-05 14:45:55 -05:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def toggle_applets(request):
|
|
|
|
|
checked = request.POST.getlist("applets")
|
2026-04-21 15:46:30 -04:00
|
|
|
apply_applet_toggle(request.user, "dashboard", checked)
|
2026-03-05 14:45:55 -05:00
|
|
|
if request.headers.get("HX-Request"):
|
2026-03-05 16:08:40 -05:00
|
|
|
return render(request, "apps/dashboard/_partials/_applets.html", {
|
2026-03-09 21:13:35 -04:00
|
|
|
"applets": applet_context(request.user, "dashboard"),
|
2026-04-22 04:02:14 -04:00
|
|
|
"palettes": _palettes_for_user(request.user),
|
2026-03-05 16:08:40 -05:00
|
|
|
})
|
2026-03-05 14:45:55 -05:00
|
|
|
return redirect("home")
|
2026-03-08 15:14:41 -04:00
|
|
|
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
def _shop_items_for(user):
|
|
|
|
|
"""Decorate the active ShopItem catalog w. per-user availability so the
|
|
|
|
|
template can render `.btn-disabled` + 'Already owned' microtooltip
|
|
|
|
|
for `max_owned`-capped items the user already holds. Items are returned
|
|
|
|
|
in `display_order` ASC (matches the seeded `tithe-1` < `tithe-5` < `band-1`)."""
|
|
|
|
|
items = []
|
|
|
|
|
for item in ShopItem.objects.filter(active=True).order_by("display_order", "slug"):
|
|
|
|
|
item.available = item.is_available_for(user)
|
|
|
|
|
items.append(item)
|
|
|
|
|
return items
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 14:51:21 -04:00
|
|
|
def _free_decks_for(user):
|
|
|
|
|
"""Decorate the free-in-shop DeckVariant catalog (RWS + Fiorentine) w. a
|
|
|
|
|
per-user `.owned` flag so the Shop applet renders a FREE ITEM btn for
|
|
|
|
|
decks the user hasn't claimed + an 'Already owned' pill for ones already
|
|
|
|
|
in `unlocked_decks`. Ordered by name for a stable render."""
|
|
|
|
|
unlocked_ids = set(user.unlocked_decks.values_list("pk", flat=True))
|
|
|
|
|
decks = list(DeckVariant.objects.filter(free_in_shop=True).order_by("name"))
|
|
|
|
|
for deck in decks:
|
|
|
|
|
deck.owned = deck.pk in unlocked_ids
|
|
|
|
|
return decks
|
|
|
|
|
|
|
|
|
|
|
2026-03-08 15:14:41 -04:00
|
|
|
@login_required(login_url="/")
|
2026-03-09 01:07:16 -04:00
|
|
|
@ensure_csrf_cookie
|
2026-03-08 15:14:41 -04:00
|
|
|
def wallet(request):
|
2026-04-21 15:46:30 -04:00
|
|
|
free_tokens = list(request.user.tokens.filter(
|
|
|
|
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
|
|
|
|
).order_by("expires_at"))
|
|
|
|
|
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
shop_items = _shop_items_for(request.user)
|
|
|
|
|
default_pm = request.user.payment_methods.order_by("-pk").first()
|
2026-03-08 15:14:41 -04:00
|
|
|
return render(request, "apps/dashboard/wallet.html", {
|
2026-03-11 00:58:24 -04:00
|
|
|
"wallet": request.user.wallet,
|
2026-05-21 00:35:55 -04:00
|
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
|
2026-03-11 00:58:24 -04:00
|
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
2026-05-21 23:07:42 -04:00
|
|
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
"shop_items": shop_items,
|
2026-05-30 14:51:21 -04:00
|
|
|
"free_decks": _free_decks_for(request.user),
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
|
|
|
|
|
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
2026-04-21 15:46:30 -04:00
|
|
|
"free_tokens": free_tokens,
|
|
|
|
|
"tithe_tokens": tithe_tokens,
|
|
|
|
|
"free_count": len(free_tokens),
|
|
|
|
|
"tithe_count": len(tithe_tokens),
|
2026-03-11 00:58:24 -04:00
|
|
|
"applets": applet_context(request.user, "wallet"),
|
|
|
|
|
"page_class": "page-wallet",
|
2026-03-08 15:14:41 -04:00
|
|
|
})
|
2026-03-09 01:07:16 -04:00
|
|
|
|
2026-03-15 01:17:09 -04:00
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def kit_bag(request):
|
|
|
|
|
tokens = list(request.user.tokens.all())
|
2026-03-15 16:08:34 -04:00
|
|
|
free_tokens = sorted(
|
|
|
|
|
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
|
|
|
|
|
key=lambda t: t.expires_at,
|
|
|
|
|
)
|
|
|
|
|
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
|
|
|
|
return render(request, "core/_partials/_kit_bag_panel.html", {
|
2026-03-24 23:18:04 -04:00
|
|
|
"equipped_deck": request.user.equipped_deck,
|
2026-03-16 00:07:52 -04:00
|
|
|
"equipped_trinket": request.user.equipped_trinket,
|
2026-03-15 16:08:34 -04:00
|
|
|
"free_token": free_tokens[0] if free_tokens else None,
|
|
|
|
|
"free_count": len(free_tokens),
|
|
|
|
|
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
|
|
|
|
"tithe_count": len(tithe_tokens),
|
|
|
|
|
})
|
2026-03-15 01:17:09 -04:00
|
|
|
|
2026-03-11 00:58:24 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def toggle_wallet_applets(request):
|
|
|
|
|
checked = request.POST.getlist("applets")
|
2026-04-21 15:46:30 -04:00
|
|
|
apply_applet_toggle(request.user, "wallet", checked)
|
2026-03-11 00:58:24 -04:00
|
|
|
if request.headers.get("HX-Request"):
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
default_pm = request.user.payment_methods.order_by("-pk").first()
|
2026-03-11 00:58:24 -04:00
|
|
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
|
|
|
|
"applets": applet_context(request.user, "wallet"),
|
|
|
|
|
"wallet": request.user.wallet,
|
2026-05-21 00:35:55 -04:00
|
|
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first() if request.user.is_staff else None,
|
feat: `Token.BAND` (Wristband) — non-admin variant of PASS, admin-awarded via Django admin to any user (NOT auto-granted on signal, NO `is_staff` coupling, NO model-layer guard). Mirrors PASS at runtime — fills 1 gate slot, never consumed, stays equipped, no `current_room` tie, no expiry, no In-Use microtooltip — but separates the policy concerns so PASS stays a deliberate staff-only trinket while BAND becomes the regular-user version (promotional / play-reward / staging give-away). Tooltip prose: name "Wristband", desc "Admit All Entry" (shared w. PASS — phrasing reflects the never-depleted lifetime, not multi-slot semantics), shoptalk "Unlimited free entry (BYOB)", expiry "no expiry". `fa-ring` icon across all 4 surfaces (Game Kit applet `#id_kit_wristband` between PASS + CARTE, gk-trinkets section, kit-bag dialog Trinket slot, wallet PASS→BAND→COIN elif chain). Priority chain — PASS → BAND → COIN → FREE → TITHE — wired identically into both `apps.epic.models.select_token` (room gatekeeper) + `apps.gameboard.models._select_my_sea_token` (my-sea gatekeeper); BAND wins over consumables for any holder while PASS still wins for staff who happen to hold both. `debit_token` + `debit_my_sea_token` treat BAND same as PASS: slot marked FILLED w. `debited_token_type=BAND`, token row preserved, `current_room` untouched, `equipped_trinket` unchanged. View contexts (`gameboard`, `toggle_game_applets`, `_game_kit_context`, `wallet`, `toggle_wallet_applets`) pass a `band` key — universal lookup, NO `is_staff` filter. Migration `lyric/0007_alter_token_token_type` — choices-only AlterField. TDD — 5 FTs in `test_trinket_wristband.py` (`test_band_not_auto_equipped_after_award`, `test_band_tooltip_renders_full_prose`, `test_band_uses_fa_ring_icon`, `test_equipped_band_shows_equipped_mini_tooltip`, `test_equipped_band_shows_doff_active_don_disabled`); 4 tooltip UTs (`BandTokenTooltipTest`); 5 model ITs (`BandTokenAdminAwardTest` — no-auto-grant for non-staff + staff, admin-can-award to either branch, not-auto-equipped); 2 priority-chain ITs (`test_returns_band_when_held_and_no_pass`, `test_pass_still_wins_over_band_for_staff`); 1 debit IT (`test_debit_band_does_not_consume_or_unequip`). 1145 IT/UT + 5 FT green. A boost-pass / promo-band w. richer semantics (multi-slot admit, time-window, etc.) lands as YET-ANOTHER token_type later — keep BAND the minimal "PASS minus admin gate" trinket so the policy axis stays clean. Captured in [[sprint-band-trinket-may21]] alongside the standing auto-commit rule [[feedback-auto-commit-after-build]]
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 12:33:09 -04:00
|
|
|
"band": request.user.tokens.filter(token_type=Token.BAND).first(),
|
2026-03-11 00:58:24 -04:00
|
|
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
2026-05-21 23:07:42 -04:00
|
|
|
"carte": request.user.tokens.filter(token_type=Token.CARTE).first(),
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
"shop_items": _shop_items_for(request.user),
|
2026-05-30 14:51:21 -04:00
|
|
|
"free_decks": _free_decks_for(request.user),
|
feat: wallet Shop applet — tile grid + BUY-ITEM microbutton + Stripe.js wiring — Chunk 4 of [[project-wallet-shop-expansion]]. The shop applet (slug `wallet-shop`, seeded in Chunk 2) now renders the catalog as a horizontal grid of `.shop-tile` icons: `tithe-1` ($1, fa-piggy-bank), `tithe-5` ($4, fa-piggy-bank w. `×5` badge), `band-1` ($20, fa-ring). Each tile hosts a hover-portaled tooltip carrying name + description + price + a `.tt-microbutton-portal` w. a `.btn-primary` BUY ITEM button — clicking opens `#id_guard_portal` w. "Buy {name} for ${price}?" prompt; confirming triggers Stripe.js confirmCardPayment then POSTs to /shop/confirm + reloads. Items where the user's owned-count has hit `max_owned` (eg. BAND, owned=1, cap=1) render w. `.btn-disabled` + × glyph + "Already owned" microtooltip text — visible-but-unbuyable per the locked decision. View context — `wallet` view + `toggle_wallet_applets` view both pass `shop_items` (decorated w. per-user `.available` via the new `_shop_items_for(user)` helper) + `default_payment_method_id` + `stripe_publishable_key`. SCSS — `.wallet-shop` (flex column wrapping `.shop-grid` flex row), `.shop-tile` (inline-flex tooltip target), `.shop-badge` (2rem circle, --quaUser glyph on --quiUser bg, top-right corner per spec), `.tt-microbutton-portal` (column-flex, BUY btn + 'Already owned' caption styling). JS in `wallet-shop.js` exposes a singleton `WalletShop` module (matching the project's `Brief` / `SeaDeal` / `StageCard` module pattern) w. a tested `initWalletShop()` method — uses event delegation on the shop root (so portal-relocated buy btns still hit the handler) + a DOM-keyed `data-shop-wired` flag (not a module-level boolean) so per-test fixture rebuilds re-wire cleanly. Wired into `wallet.html` after `wallet.js`. **TDD** — 5 Jasmine specs in `WalletShopSpec.js`: T1 click-on-enabled-BUY opens guard w. correct prompt; T2 click-on-disabled-BUY no-op; T3 onConfirm POSTs `shop_item_slug` to `/shop/buy`; T4 init idempotent (calling twice doesn't double-wire); T5 missing-root no-throw. **2 Jasmine traps caught**: (a) `spyOn(window, 'fetch')` collides if another spec already spied on fetch — switched to save+restore via per-test `_origFetch` capture; (b) T3 async pollution — sync assertion passed, `afterEach` restored `window.Stripe=undefined`, then `_doBuy`'s async continuation hit `Stripe(pubKey)` and threw "Unhandled promise rejection". Fixed by T3-local fetch mock returning a never-resolving promise so the chain pauses at the first await. **3 new FTs** in `test_dash_wallet.py`: tiles + icons + ×5 badge + tooltip prose; BUY click opens guard portal + NVM dismisses; BAND-already-owned shows disabled BUY w. 'Already owned' microtext (reads via `textContent` since `.tt` is `display: none`). FT trap caught: `TransactionTestCase` wipes both migration-seeded Applets + ShopItems → setUp must re-seed both manually (mirrors `test_shop_views.py`'s `_seed_starting_items` pattern). 1208 IT/UT + 9 wallet FTs + 5 Jasmine specs green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 01:15:05 -04:00
|
|
|
"default_payment_method_id": default_pm.stripe_pm_id if default_pm else "",
|
|
|
|
|
"stripe_publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
2026-03-11 00:58:24 -04:00
|
|
|
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
|
|
|
|
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
|
|
|
|
})
|
|
|
|
|
return redirect("wallet")
|
|
|
|
|
|
2026-03-09 01:07:16 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def setup_intent(request):
|
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
|
user = request.user
|
|
|
|
|
if not user.stripe_customer_id:
|
|
|
|
|
customer = stripe.Customer.create(email=user.email)
|
|
|
|
|
user.stripe_customer_id = customer.id
|
|
|
|
|
user.save(update_fields=["stripe_customer_id"])
|
|
|
|
|
intent = stripe.SetupIntent.create(customer=user.stripe_customer_id)
|
|
|
|
|
return JsonResponse({
|
|
|
|
|
"client_secret": intent.client_secret,
|
|
|
|
|
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def save_payment_method(request):
|
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
|
pm_id = request.POST.get("payment_method_id")
|
|
|
|
|
pm = stripe.PaymentMethod.retrieve(pm_id)
|
|
|
|
|
stripe.PaymentMethod.attach(pm_id, customer=request.user.stripe_customer_id)
|
|
|
|
|
PaymentMethod.objects.create(
|
|
|
|
|
user=request.user,
|
|
|
|
|
stripe_pm_id=pm_id,
|
|
|
|
|
last4=pm.card.last4,
|
|
|
|
|
brand=pm.card.brand,
|
|
|
|
|
)
|
|
|
|
|
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
|
2026-04-16 03:03:19 -04:00
|
|
|
|
|
|
|
|
|
feat: shop PaymentIntent flow — `shop_buy` + `shop_confirm` + `stripe_webhook` — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync `/shop/confirm` is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call `Purchase.fulfill()` which is idempotent — whichever lands first wins, the other becomes a no-op via the `status==SUCCEEDED` guard. **`POST /dashboard/wallet/shop/buy`** (form-encoded `shop_item_slug`): looks up active ShopItem (404 if missing/inactive); enforces `max_owned` via `is_available_for(user)` (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via `order_by('-pk').first()` per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates `Purchase` w. pi.id; backfills pi.metadata.purchase_id via `PaymentIntent.modify` so the webhook handler can resolve back to the row; returns `{client_secret, purchase_id}` JSON for Stripe.js `confirmCardPayment`. **`POST /dashboard/wallet/shop/confirm`** (form-encoded `purchase_id`): retrieves PI from Stripe, if `status=='succeeded'` calls `purchase.fulfill()`; returns `{status}` JSON. 404 if the purchase doesn't belong to `request.user`. Idempotent — re-firing after fulfill is a safe no-op. **`POST /stripe/webhook`** (csrf_exempt, mounted at root `/stripe/webhook` so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via `stripe.Webhook.construct_event` against `STRIPE_WEBHOOK_SECRET` env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on `payment_intent.succeeded` looks up Purchase by `metadata.purchase_id` w. fall-back to `stripe_payment_intent_id` (both unique). Unknown event types are no-op 200 (Stripe sends `charge.dispute.created` etc. + would retry indefinitely on 5xx). New `STRIPE_WEBHOOK_SECRET = os.environ.get(...)` setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in `test_shop_views.py` across 3 classes: `ShopBuyViewTest` (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); `ShopConfirmViewTest` (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); `StripeWebhookViewTest` (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via `mock.patch('apps.dashboard.views.stripe')`. 1208 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:42:09 -04:00
|
|
|
# ── Shop: PaymentIntent flow ─────────────────────────────────────────────────
|
|
|
|
|
# Three endpoints split fulfillment responsibility:
|
|
|
|
|
# /shop/buy creates a Purchase (PENDING) + a Stripe PaymentIntent.
|
|
|
|
|
# Returns client_secret so Stripe.js can confirmCardPayment
|
|
|
|
|
# (handles 3DS natively).
|
|
|
|
|
# /shop/confirm sync follow-up after Stripe.js confirms client-side. Pulls
|
|
|
|
|
# PI status from Stripe; if SUCCEEDED, calls Purchase.fulfill()
|
|
|
|
|
# immediately (faster UX than waiting for the webhook round-trip).
|
|
|
|
|
# /stripe/webhook async fulfillment from Stripe's webhook delivery. Same
|
|
|
|
|
# Purchase.fulfill() call — whichever (confirm or webhook)
|
|
|
|
|
# lands first wins; the other becomes a no-op via fulfill()'s
|
|
|
|
|
# idempotent guard.
|
|
|
|
|
#
|
|
|
|
|
# Decisions locked 2026-05-21 in [[project-wallet-shop-expansion]]:
|
|
|
|
|
# * Webhook is THE authoritative source for fulfillment (resilient to 3DS,
|
|
|
|
|
# network drops, browser closes during checkout).
|
|
|
|
|
# * Confirm endpoint is a UX-speedup belt-and-suspenders; never required.
|
|
|
|
|
# * Webhook idempotency via Purchase.fulfill()'s status==SUCCEEDED guard.
|
|
|
|
|
# * No STRIPE_LIVE_MODE setting — env-var swap is all that's needed.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def shop_buy(request):
|
|
|
|
|
"""Create a Stripe PaymentIntent + a PENDING Purchase row.
|
|
|
|
|
|
|
|
|
|
Body: `shop_item_slug` (form-encoded).
|
|
|
|
|
Returns: 200 `{client_secret, purchase_id}` on success;
|
|
|
|
|
402 if the user has no saved PaymentMethod;
|
|
|
|
|
404 if the slug doesn't match an active ShopItem;
|
|
|
|
|
409 if the item's max_owned cap is reached for this user.
|
|
|
|
|
"""
|
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
|
slug = request.POST.get("shop_item_slug", "")
|
|
|
|
|
item = ShopItem.objects.filter(slug=slug, active=True).first()
|
|
|
|
|
if item is None:
|
|
|
|
|
return HttpResponse(status=404)
|
|
|
|
|
if not item.is_available_for(request.user):
|
|
|
|
|
return HttpResponse(status=409)
|
|
|
|
|
pm = request.user.payment_methods.order_by("-pk").first()
|
|
|
|
|
if pm is None:
|
|
|
|
|
return HttpResponse(status=402)
|
|
|
|
|
intent = stripe.PaymentIntent.create(
|
|
|
|
|
amount=item.price_cents,
|
|
|
|
|
currency="usd",
|
|
|
|
|
customer=request.user.stripe_customer_id,
|
|
|
|
|
payment_method=pm.stripe_pm_id,
|
|
|
|
|
# `automatic_payment_methods` so Stripe.js picks the right confirm
|
|
|
|
|
# method (cards, wallets, etc.) without us hard-coding payment-method-
|
|
|
|
|
# type plumbing. `allow_redirects=never` keeps the 3DS challenge in
|
|
|
|
|
# the same window — Stripe.js handles the modal natively.
|
|
|
|
|
automatic_payment_methods={"enabled": True, "allow_redirects": "never"},
|
|
|
|
|
metadata={
|
|
|
|
|
# Webhook handler looks up the Purchase by this on
|
|
|
|
|
# `payment_intent.succeeded`. Belt-and-suspenders w. looking up
|
|
|
|
|
# by `stripe_payment_intent_id` (also unique).
|
|
|
|
|
"purchase_id": "_pending_", # overwritten after Purchase.save() below
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
purchase = Purchase.objects.create(
|
|
|
|
|
user=request.user,
|
|
|
|
|
shop_item=item,
|
|
|
|
|
stripe_payment_intent_id=intent.id,
|
|
|
|
|
amount_cents=item.price_cents,
|
|
|
|
|
granted_writs=item.granted_writs,
|
|
|
|
|
)
|
|
|
|
|
# Now we have purchase.pk — backfill the metadata on the PI so the
|
|
|
|
|
# webhook handler can resolve back to it.
|
|
|
|
|
stripe.PaymentIntent.modify(
|
|
|
|
|
intent.id, metadata={"purchase_id": str(purchase.pk)},
|
|
|
|
|
)
|
|
|
|
|
return JsonResponse({
|
|
|
|
|
"client_secret": intent.client_secret,
|
|
|
|
|
"purchase_id": purchase.pk,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def shop_confirm(request):
|
|
|
|
|
"""Sync follow-up after Stripe.js confirms client-side. Polls the PI
|
|
|
|
|
once + fulfills if SUCCEEDED. Idempotent w. the webhook handler via
|
|
|
|
|
`Purchase.fulfill()`'s status guard.
|
|
|
|
|
|
|
|
|
|
Body: `purchase_id` (form-encoded).
|
|
|
|
|
Returns: 200 always (sync fulfillment is best-effort; webhook is
|
|
|
|
|
authoritative). 404 if the purchase doesn't belong to this user.
|
|
|
|
|
"""
|
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
|
purchase_id = request.POST.get("purchase_id")
|
|
|
|
|
purchase = Purchase.objects.filter(
|
|
|
|
|
pk=purchase_id, user=request.user,
|
|
|
|
|
).first()
|
|
|
|
|
if purchase is None:
|
|
|
|
|
return HttpResponse(status=404)
|
|
|
|
|
if purchase.status != Purchase.SUCCEEDED:
|
|
|
|
|
intent = stripe.PaymentIntent.retrieve(purchase.stripe_payment_intent_id)
|
|
|
|
|
if intent.status == "succeeded":
|
|
|
|
|
purchase.fulfill()
|
|
|
|
|
return JsonResponse({"status": purchase.status})
|
|
|
|
|
|
|
|
|
|
|
2026-05-30 14:51:21 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def shop_claim_free(request):
|
|
|
|
|
"""Claim a free ($0) deck from the Shop applet — adds the DeckVariant to
|
|
|
|
|
the user's `unlocked_decks` so it renders in the Game Kit applet. No
|
|
|
|
|
Stripe, no Purchase row (free decks aren't paid goods).
|
|
|
|
|
|
|
|
|
|
Body: `deck_slug` (form-encoded).
|
|
|
|
|
Returns: 200 `{owned: true, deck_name}` on claim or re-claim (M2M add is
|
|
|
|
|
idempotent); 404 if the slug isn't a `free_in_shop` deck — the
|
|
|
|
|
`free_in_shop` filter is the guard that stops this $0 endpoint
|
|
|
|
|
from unlocking paid/auto-granted decks (eg Earthman).
|
|
|
|
|
"""
|
|
|
|
|
slug = request.POST.get("deck_slug", "")
|
|
|
|
|
deck = DeckVariant.objects.filter(slug=slug, free_in_shop=True).first()
|
|
|
|
|
if deck is None:
|
|
|
|
|
return HttpResponse(status=404)
|
|
|
|
|
request.user.unlocked_decks.add(deck)
|
|
|
|
|
return JsonResponse({"owned": True, "deck_name": deck.name})
|
|
|
|
|
|
|
|
|
|
|
feat: shop PaymentIntent flow — `shop_buy` + `shop_confirm` + `stripe_webhook` — Chunk 3 of [[project-wallet-shop-expansion]]. Three-endpoint split per the locked Stripe design: webhook is authoritative for fulfillment (resilient to 3DS, browser closes, network drops); sync `/shop/confirm` is a best-effort UX speedup (fulfills immediately when Stripe.js confirms client-side, no waiting for webhook delivery); both call `Purchase.fulfill()` which is idempotent — whichever lands first wins, the other becomes a no-op via the `status==SUCCEEDED` guard. **`POST /dashboard/wallet/shop/buy`** (form-encoded `shop_item_slug`): looks up active ShopItem (404 if missing/inactive); enforces `max_owned` via `is_available_for(user)` (409 if cap hit, eg already-owned BAND); requires a saved PaymentMethod (402 otherwise — picks most-recent via `order_by('-pk').first()` per the open-Q note in the scope doc); creates Stripe PaymentIntent (amount=item.price_cents, currency=usd, customer=user.stripe_customer_id, payment_method=pm.stripe_pm_id, automatic_payment_methods={enabled, allow_redirects=never} for in-window 3DS); creates `Purchase` w. pi.id; backfills pi.metadata.purchase_id via `PaymentIntent.modify` so the webhook handler can resolve back to the row; returns `{client_secret, purchase_id}` JSON for Stripe.js `confirmCardPayment`. **`POST /dashboard/wallet/shop/confirm`** (form-encoded `purchase_id`): retrieves PI from Stripe, if `status=='succeeded'` calls `purchase.fulfill()`; returns `{status}` JSON. 404 if the purchase doesn't belong to `request.user`. Idempotent — re-firing after fulfill is a safe no-op. **`POST /stripe/webhook`** (csrf_exempt, mounted at root `/stripe/webhook` so the URL stays stable across app-routing refactors w. Stripe's dashboard config): verifies signature via `stripe.Webhook.construct_event` against `STRIPE_WEBHOOK_SECRET` env var (400 on mismatch — Stripe won't retry on 4xx, only 5xx); on `payment_intent.succeeded` looks up Purchase by `metadata.purchase_id` w. fall-back to `stripe_payment_intent_id` (both unique). Unknown event types are no-op 200 (Stripe sends `charge.dispute.created` etc. + would retry indefinitely on 5xx). New `STRIPE_WEBHOOK_SECRET = os.environ.get(...)` setting; user swaps it on staging+prod per the live-mode env-var-only decision. TDD — 17 ITs in `test_shop_views.py` across 3 classes: `ShopBuyViewTest` (7 cases — login required, success path creates PI + Purchase w. correct shape, PI.create called w. correct args, unknown slug 404, inactive item 404, max_owned 409, no PM 402); `ShopConfirmViewTest` (5 cases — login required, succeeded PI triggers fulfill, processing PI leaves PENDING, idempotent on already-SUCCEEDED, other user's purchase 404); `StripeWebhookViewTest` (5 cases — sig mismatch 400, succeeded event triggers fulfill, unknown event type 2xx no-op, duplicate delivery idempotent, unknown purchase_id 2xx no-op). All Stripe API calls mocked via `mock.patch('apps.dashboard.views.stripe')`. 1208 IT/UT green
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 00:42:09 -04:00
|
|
|
@csrf_exempt
|
|
|
|
|
def stripe_webhook(request):
|
|
|
|
|
"""Stripe webhook listener. Verifies signature against
|
|
|
|
|
`STRIPE_WEBHOOK_SECRET`; on `payment_intent.succeeded` calls
|
|
|
|
|
`Purchase.fulfill()` (idempotent w. `/shop/confirm`).
|
|
|
|
|
|
|
|
|
|
Always returns 2xx (even on unknown event types or already-fulfilled
|
|
|
|
|
purchases) — Stripe retries on 5xx, which would just deliver the same
|
|
|
|
|
event repeatedly. 4xx is reserved for signature mismatch (a genuine
|
|
|
|
|
auth failure that Stripe should NOT retry).
|
|
|
|
|
"""
|
|
|
|
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
|
|
|
|
payload = request.body
|
|
|
|
|
sig_header = request.headers.get("Stripe-Signature", "")
|
|
|
|
|
try:
|
|
|
|
|
event = stripe.Webhook.construct_event(
|
|
|
|
|
payload, sig_header, settings.STRIPE_WEBHOOK_SECRET,
|
|
|
|
|
)
|
|
|
|
|
except (ValueError, Exception) as e:
|
|
|
|
|
# ValueError = invalid payload; SignatureVerificationError = bad sig.
|
|
|
|
|
# Either way, refuse — Stripe will alert if it can't deliver.
|
|
|
|
|
if isinstance(e, ValueError) or "Signature" in type(e).__name__:
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
raise
|
|
|
|
|
if event["type"] == "payment_intent.succeeded":
|
|
|
|
|
intent = event["data"]["object"]
|
|
|
|
|
purchase_id = intent.get("metadata", {}).get("purchase_id")
|
|
|
|
|
purchase = None
|
|
|
|
|
if purchase_id and purchase_id.isdigit():
|
|
|
|
|
purchase = Purchase.objects.filter(pk=int(purchase_id)).first()
|
|
|
|
|
# Fall-back lookup by PI ID in case metadata's missing for any reason.
|
|
|
|
|
if purchase is None:
|
|
|
|
|
purchase = Purchase.objects.filter(
|
|
|
|
|
stripe_payment_intent_id=intent.get("id", ""),
|
|
|
|
|
).first()
|
|
|
|
|
if purchase is not None:
|
|
|
|
|
purchase.fulfill()
|
|
|
|
|
return HttpResponse(status=200)
|
|
|
|
|
|
|
|
|
|
|
2026-04-16 03:03:19 -04:00
|
|
|
# ── My Sky (personal natal chart) ────────────────────────────────────────────
|
|
|
|
|
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
def _sky_preview_data(request):
|
2026-04-16 03:03:19 -04:00
|
|
|
"""Shared preview logic — proxies to PySwiss, no DB writes."""
|
|
|
|
|
date_str = request.GET.get('date')
|
|
|
|
|
time_str = request.GET.get('time', '12:00')
|
|
|
|
|
tz_str = request.GET.get('tz', '').strip()
|
|
|
|
|
lat_str = request.GET.get('lat')
|
|
|
|
|
lon_str = request.GET.get('lon')
|
|
|
|
|
|
|
|
|
|
if not date_str or lat_str is None or lon_str is None:
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
lat = float(lat_str)
|
|
|
|
|
lon = float(lon_str)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
|
|
|
|
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
|
|
|
|
|
if not tz_str:
|
|
|
|
|
try:
|
|
|
|
|
tz_resp = http_requests.get(
|
|
|
|
|
settings.PYSWISS_URL + '/api/tz/',
|
|
|
|
|
params={'lat': lat_str, 'lon': lon_str},
|
|
|
|
|
timeout=5,
|
|
|
|
|
)
|
|
|
|
|
tz_resp.raise_for_status()
|
|
|
|
|
tz_str = tz_resp.json().get('timezone') or 'UTC'
|
|
|
|
|
except Exception:
|
|
|
|
|
tz_str = 'UTC'
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
tz = zoneinfo.ZoneInfo(tz_str)
|
|
|
|
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
|
|
|
|
|
local_dt = local_dt.replace(tzinfo=tz)
|
|
|
|
|
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
|
|
|
|
|
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
|
|
|
|
except ValueError:
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
resp = http_requests.get(
|
|
|
|
|
settings.PYSWISS_URL + '/api/chart/',
|
|
|
|
|
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
|
|
|
|
|
timeout=5,
|
|
|
|
|
)
|
|
|
|
|
resp.raise_for_status()
|
|
|
|
|
except Exception:
|
|
|
|
|
return HttpResponse(status=502)
|
|
|
|
|
|
|
|
|
|
data = resp.json()
|
|
|
|
|
if 'elements' in data and 'Earth' in data['elements']:
|
|
|
|
|
data['elements']['Stone'] = data['elements'].pop('Earth')
|
|
|
|
|
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
|
|
|
|
|
data['timezone'] = tz_str
|
|
|
|
|
return JsonResponse(data)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def sky_view(request):
|
2026-04-22 00:58:19 -04:00
|
|
|
chart_data = request.user.sky_chart_data
|
|
|
|
|
birth_dt = request.user.sky_birth_dt
|
|
|
|
|
saved_birth_date = ''
|
|
|
|
|
saved_birth_time = ''
|
|
|
|
|
if birth_dt:
|
|
|
|
|
if request.user.sky_birth_tz:
|
|
|
|
|
try:
|
|
|
|
|
birth_dt = birth_dt.astimezone(zoneinfo.ZoneInfo(request.user.sky_birth_tz))
|
|
|
|
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
|
|
|
|
pass
|
|
|
|
|
saved_birth_date = birth_dt.strftime('%Y-%m-%d')
|
|
|
|
|
saved_birth_time = birth_dt.strftime('%H:%M')
|
2026-04-16 03:03:19 -04:00
|
|
|
return render(request, "apps/dashboard/sky.html", {
|
2026-04-21 20:07:40 -04:00
|
|
|
"preview_url": request.build_absolute_uri("/dashboard/sky/preview"),
|
|
|
|
|
"save_url": request.build_absolute_uri("/dashboard/sky/save"),
|
2026-04-22 00:58:19 -04:00
|
|
|
"saved_sky_json": json.dumps(chart_data) if chart_data else 'null',
|
|
|
|
|
"saved_birth_date": saved_birth_date,
|
|
|
|
|
"saved_birth_time": saved_birth_time,
|
2026-04-16 03:03:19 -04:00
|
|
|
"saved_birth_place": request.user.sky_birth_place,
|
2026-04-21 21:54:34 -04:00
|
|
|
"saved_birth_lat": request.user.sky_birth_lat,
|
|
|
|
|
"saved_birth_lon": request.user.sky_birth_lon,
|
|
|
|
|
"saved_birth_tz": request.user.sky_birth_tz,
|
2026-05-08 12:24:11 -04:00
|
|
|
"page_class": "page-sky" + (" sky-saved" if chart_data else ""),
|
2026-04-16 03:03:19 -04:00
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def sky_preview(request):
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
return _sky_preview_data(request)
|
2026-04-16 03:03:19 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def sky_save(request):
|
|
|
|
|
if request.method != 'POST':
|
|
|
|
|
return HttpResponse(status=405)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
body = json.loads(request.body)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
return HttpResponse(status=400)
|
|
|
|
|
|
|
|
|
|
user = request.user
|
2026-04-22 00:58:19 -04:00
|
|
|
birth_tz_str = body.get('birth_tz', '').strip()
|
2026-04-16 03:03:19 -04:00
|
|
|
birth_dt_str = body.get('birth_dt', '')
|
|
|
|
|
if birth_dt_str:
|
|
|
|
|
try:
|
2026-04-22 00:58:19 -04:00
|
|
|
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '').replace('+00:00', ''))
|
|
|
|
|
if naive.tzinfo is None and birth_tz_str:
|
|
|
|
|
try:
|
|
|
|
|
local_tz = zoneinfo.ZoneInfo(birth_tz_str)
|
|
|
|
|
user.sky_birth_dt = naive.replace(tzinfo=local_tz).astimezone(
|
|
|
|
|
zoneinfo.ZoneInfo('UTC')
|
|
|
|
|
)
|
|
|
|
|
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
|
|
|
|
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
|
|
|
|
|
elif naive.tzinfo is None:
|
|
|
|
|
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
|
|
|
|
|
else:
|
|
|
|
|
user.sky_birth_dt = naive.astimezone(zoneinfo.ZoneInfo('UTC'))
|
2026-04-16 03:03:19 -04:00
|
|
|
except ValueError:
|
|
|
|
|
user.sky_birth_dt = None
|
|
|
|
|
user.sky_birth_lat = body.get('birth_lat')
|
|
|
|
|
user.sky_birth_lon = body.get('birth_lon')
|
|
|
|
|
user.sky_birth_place = body.get('birth_place', '')
|
2026-04-21 21:54:34 -04:00
|
|
|
user.sky_birth_tz = body.get('birth_tz', '')
|
2026-04-16 03:03:19 -04:00
|
|
|
user.sky_house_system = body.get('house_system', 'O')
|
2026-04-21 20:58:16 -04:00
|
|
|
user.sky_chart_data = body.get('chart_data')
|
2026-04-21 20:07:40 -04:00
|
|
|
|
2026-04-16 03:03:19 -04:00
|
|
|
user.save(update_fields=[
|
|
|
|
|
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
|
2026-04-21 21:54:34 -04:00
|
|
|
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
2026-04-16 03:03:19 -04:00
|
|
|
])
|
2026-04-22 02:13:29 -04:00
|
|
|
|
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD
Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line.
Server:
- billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK).
- drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title).
- dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes').
Banner JS (apps/dashboard/note.js):
- Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working.
- sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse.
Tests:
- new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present.
- existing drama test_models.GrantIfNew tests updated to unpack the third tuple element.
- dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/).
- NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/).
- functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/.
billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
|
|
|
brief_payload = None
|
2026-04-22 02:13:29 -04:00
|
|
|
if user.sky_chart_data:
|
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD
Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line.
Server:
- billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK).
- drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title).
- dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes').
Banner JS (apps/dashboard/note.js):
- Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working.
- sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse.
Tests:
- new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present.
- existing drama test_models.GrantIfNew tests updated to unpack the third tuple element.
- dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/).
- NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/).
- functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/.
billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
|
|
|
note, created, brief = Note.grant_if_new(user, "stargazer")
|
|
|
|
|
if created and brief is not None:
|
brief sprint C3.b+c+d+e: share-post Line+Brief async, magic-link / invalid-link banners use Brief styling, .alert-* retired — TDD
Closes the C3 brief sprint. Three event sources (note unlock, share invite, login messages) now route through the Brief slide-down, & the legacy .alert-success/.alert-warning rendering in base.html is retired.
C3.b — share-post async Line + Brief:
- billboard.share_post detects Accept: application/json. JSON path appends a Line (text="Shared with X at <isoformat>", isoformat carries microseconds so two rapid shares of the same email don't collide on Line.unique_together(post,text)), spawns a Brief(kind=SHARE_INVITE) for the sharer, and returns {brief: brief.to_banner_dict() | None, line_text, recipient_display}. Sharer-shares-with-themselves stays a silent no-op (response carries brief: null). Legacy form-submit path preserved for non-AJAX (still redirects + flashes the privacy-safe message — kept for older FTs / no-JS fallback).
- billboard.Brief.to_banner_dict() (moved from dashboard.views helper to a model method) shapes the JSON the banner JS consumes.
- post.html: share form intercepted by JS — fetches POST w. Accept:application/json, then appends `data.line_text` as the next row in #id_post_table, calls Brief.showBanner(data.brief), and (when registered) appends a fresh `<span class="post-recipient">` to the new #id_post_recipients box. No page reload — the alert-success flash is gone.
- 10 new ITs (SharePostAsyncTest + SharePostLegacyRedirectTest) cover the JSON path, line append, brief creation w. SHARE_INVITE kind, registered/unregistered recipient behaviour, sharer-self skip, line dedupe via timestamp, and that the legacy form-submit redirect path still works.
- functional_tests.test_sharing line numbering updated: the share now records its own Line so the alice-reply lands at row 3 instead of 2.
C3.c+d — magic-link confirmation + invalid-link error use Brief banner styling:
- base.html's {% if messages %} block stops rendering .alert-success/.alert-warning divs. Instead each message renders as a transient Brief-styled banner: <div class="note-banner note-banner--message note-banner--{{level_tag}}"> with .note-banner__body / __description carrying the message text and a .btn-cancel NVM that removes the banner via inline onclick. No DB Brief row; no FYI; no square. Same Gaussian-glass look as note-unlock + share-invite Briefs.
- _note.scss adds the note-banner--message variant (full-opacity description) + note-banner--error/--warning border-color override (priRd 0.6) so the invalid-link banner reads as red/abandon.
C3.e — .alert-success/.alert-warning retired in markup; the SCSS class blocks aren't referenced anywhere else in templates so they sit dormant (left in place — base form styling keeps .form-control etc. working; no need to ripple into _base.scss).
Banner JS (note.js / Brief module) was untouched in C3.b+c+d — the Brief.showBanner contract from C3.a already handles all three kinds (NOTE_UNLOCK / USER_POST / SHARE_INVITE) by reading kind off the brief; the message-banner path doesn't go through showBanner because there's no Brief row.
Tests: 218 dashboard+billboard+api ITs + 322 lyric+dashboard+billboard ITs + 2 sharing FTs + 9 my_notes FTs + 1 Jasmine FT all green. Existing lyric.test_views login message-text assertions unchanged (they pull from messages framework — not the rendered HTML — so the markup swap doesn't affect them).
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:15:43 -04:00
|
|
|
brief_payload = brief.to_banner_dict()
|
brief sprint C3.a: Note unlock spawns Line + Brief on the user's per-category Post; banner JS consumes the new brief payload (FYI → post detail, square → my-notes) — TDD
Wires the C2 Brief model into the existing Note-unlock pipeline. Per the per-category Post model: each user has a single Post(owner=user, kind=NOTE_UNLOCK) titled by the header Line "Look! — new Note unlocked"; each unlock appends a per-event Line ("Stargazer, 5:21:00 PM") + spawns a Brief FK'd to that Line.
Server:
- billboard.Post gains a `kind` enum (NOTE_UNLOCK / USER_POST / SHARE_INVITE, default USER_POST) so the per-category Post is deterministically discoverable via (owner, kind=NOTE_UNLOCK).
- drama.Note.grant_if_new now returns (note, created, brief) — backwards-incompat tuple shape; the new third slot is None on idempotent re-grants. On a fresh grant it: get-or-creates the user's Note Unlocks Post; ensures the header Line; appends a per-event Line w. timestamp; creates a Brief(kind=NOTE_UNLOCK, owner, post, line, title=note.display_title).
- dashboard.views.sky_save's response shape flips {note: {...}|null} → {brief: {...}|null}. The new helper _brief_to_banner_dict exposes {id, kind, title, line_text, post_url, square_url, created_at}; for NOTE_UNLOCK kind, square_url = reverse('billboard:my_notes').
Banner JS (apps/dashboard/note.js):
- Module renamed Note → Brief (Note kept as an alias during the C3 sprint; will retire in C3.e). showBanner now consumes the Brief shape: title from brief.title, body from brief.line_text, time from brief.created_at, FYI href = brief.post_url, NVM dismisses, .note-banner__image is rendered as a clickable <a href=brief.square_url> when square_url is set. The CSS class names (.note-banner, .note-banner__title, etc.) stay so all existing SCSS + FT selectors keep working.
- sky.html + _applet-my-sky.html flip Note.handleSaveResponse → Brief.handleSaveResponse.
Tests:
- new IT class GrantIfNewSpawnsBriefTest (5 tests): first grant creates Post+Line+Brief, idempotent on second grant returns no brief, two different slugs share one Post, line text carries note title, post.kind == NOTE_UNLOCK. PostKindFieldTest (2 tests): default user_post + all three choices present.
- existing drama test_models.GrantIfNew tests updated to unpack the third tuple element.
- dashboard.tests.integrated.test_sky_views: 5 tests flipped from data["note"] → data["brief"] w/ the new payload shape (kind=note_unlock, title=Stargazer, line_text contains Stargazer, post_url under /billboard/post/, square_url=/billboard/my-notes/).
- NoteSpec.js (Jasmine): 12 specs rewritten for the Brief shape — SAMPLE_BRIEF carries the seven fields, T6 asserts the square is a clickable <a>, T8 asserts FYI points at brief.post_url (was hardcoded /billboard/my-notes/).
- functional_tests.test_applet_my_notes: T2 split — FYI now navigates to /billboard/post/<uuid>/ (the post detail / mark-read contract), the .note-banner__image square preserves the legacy jump direct to /billboard/my-notes/.
billboard/0003_post_kind migration auto-generated. 808 ITs + 9 my_notes FTs + 1 Jasmine FT all green.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 18:00:01 -04:00
|
|
|
|
|
|
|
|
return JsonResponse({"saved": True, "brief": brief_payload})
|
|
|
|
|
|
|
|
|
|
|
sky.html: DEL btn at wheel center; async SAVE SKY transitions into saved state without reload; pre-save hides wheel-col so form+SAVE SKY stay centered — TDD
DEL btn (.btn-danger, "Forget sky?" data-confirm wired to the global #id_guard_portal) sits absolutely centered inside .sky-wheel-col; OK submits a POST to the new sky_delete view, which clears every sky_* field on the User model & redirects back to /dashboard/sky/.
The sky.html aperture is now uniform across saved/unsaved: form-col is always flex-column align-center justify-center so the fields + SAVE SKY pair sits visually centered. body.sky-saved adds *only* the snap-binary scroll layer (scroll-snap-type:y, modal-body display:contents, cols min-height:100% scroll-snap-align:start, wheel-col aspect-ratio cap released, form-col flex:0 0 auto so the snap basis wins) — the column-stacking is no longer gated.
Async save: SAVE SKY's success branch now calls _activateSavedState(), which adds body.sky-saved, draws the wheel from _lastChartData, pins overlay.scrollTop to the form section's offsetTop, then runs the existing _scrollApertureToTop ease-out so the wheel reveals from above instead of replacing the form with a hard cut. The wheel preview that previously redrew during typing is now gated on _savedSky — pre-first-save typing fetches the chart data (so SAVE SKY enables) but does not render the wheel, mirroring the My Sky applet's "no wheel until saved" UX. The in-room PICK SKY overlay (_sky_overlay.html) still previews live, deliberately untouched.
Pre-save the wheel-col is hidden via `body:not(.sky-saved) .sky-page .sky-wheel-col { display: none }`, so the empty SVG can't shunt the form below the fold (& the DEL btn rides the same selector since it lives inside .sky-wheel-col).
Tests: SkyDeleteTest IT class (5: clears fields, redirects, 405 on GET, login required, preserves unrelated user fields). MySkyDeleteFlowTest FT class (3: DEL btn visibility gated on sky data, NVM dismisses w. data intact, OK clears + reverts body class). MySkyAsyncSaveTest FT (1: fresh user → SAVE SKY → body picks up sky-saved, wheel SVG populates, DEL btn becomes visible — all without a page reload). All 13 sky FTs + sky ITs green; existing MySkyApertureSnapScrollTest & MySkyTimezoneRefreshTest still pass.
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 13:07:56 -04:00
|
|
|
@login_required(login_url="/")
|
|
|
|
|
def sky_delete(request):
|
|
|
|
|
if request.method != 'POST':
|
|
|
|
|
return HttpResponse(status=405)
|
|
|
|
|
user = request.user
|
|
|
|
|
user.sky_birth_dt = None
|
|
|
|
|
user.sky_birth_lat = None
|
|
|
|
|
user.sky_birth_lon = None
|
|
|
|
|
user.sky_birth_place = ''
|
|
|
|
|
user.sky_birth_tz = ''
|
|
|
|
|
user.sky_house_system = User._meta.get_field('sky_house_system').default
|
|
|
|
|
user.sky_chart_data = None
|
|
|
|
|
user.save(update_fields=[
|
|
|
|
|
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
|
|
|
|
|
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
|
|
|
|
])
|
|
|
|
|
return HttpResponseRedirect(reverse('sky'))
|
|
|
|
|
|
|
|
|
|
|
2026-04-21 20:07:40 -04:00
|
|
|
@login_required(login_url="/")
|
rename natus → sky across the codebase — natal chart abstraction is now sky throughout, since chart inputs aren't birthday-gated
Mechanical rename: 5 files (sky-wheel.js, _sky.scss, _sky_overlay.html, SkyWheelSpec.js x2), 24 in-place edits across templates/views/urls/SCSS/JS/tests/CLAUDE.md. URL names epic:natus_save → epic:sky_save (epic namespaced, no clash w. dashboard:sky_save), JS module NatusWheel → SkyWheel, DOM ids id_natus_* → id_sky_*, BEM classes natus-* → sky-*, dashboard sky_natus_data/sky_natus_preview collapsed to sky_data/sky_preview_data. No DB migration needed (User.sky_chart_data + GameEvent.SKY_SAVED already used sky-prefix). 778 ITs + Jasmine green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 20:36:15 -04:00
|
|
|
def sky_data(request):
|
2026-04-21 20:07:40 -04:00
|
|
|
user = request.user
|
2026-04-21 20:58:16 -04:00
|
|
|
if not user.sky_chart_data:
|
2026-04-21 20:07:40 -04:00
|
|
|
return HttpResponse(status=404)
|
|
|
|
|
|
2026-04-21 20:58:16 -04:00
|
|
|
data = dict(user.sky_chart_data)
|
|
|
|
|
data['distinctions'] = _compute_distinctions(
|
|
|
|
|
data.get('planets', {}), data.get('houses', {})
|
|
|
|
|
)
|
2026-04-21 20:07:40 -04:00
|
|
|
return JsonResponse(data)
|