Compare commits

..

141 Commits

Author SHA1 Message Date
Disco DeDisco
5655342d9f CI: add sequential tag for NoteEquipTitleTest; woodpecker runs it w.o --parallel
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Parallel geckodriver startup race causes a spurious permissions error when
NoteEquipTitleTest is the first FT dispatched. @tag("sequential") moves it
into test-two-browser-FTs (sequential stage) as a reusable escape hatch.

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 03:12:24 -04:00
Disco DeDisco
2088fedeee load note.js in dashboard _scripts.html so My Sky applet banner works; fix stale new-note applet seeds in FTs
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- _scripts.html: add note.js alongside dashboard.js — Note global now available on dashboard
  page, enabling Note.handleSaveResponse from _applet-my-sky.html save handler
- test_dashboard.py: remove stale new-note applet seed (renamed to new-post/billboard)
- test_room_tray.py: remove stale new-note applet seed

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:34:40 -04:00
Disco DeDisco
6ebb2fbd51 natus wheel: fix planet aspect lines to ASC/MC; classic element tooltips show Planets +# count
Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 02:11:06 -04:00
Disco DeDisco
b86a4ddd73 fix FT note→post renames in test_sharing & test_layout_and_styling; note page layout polish
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- test_sharing.py: NotePage→PostPage, MyNotesPage→MyPostsPage, add_note_item→add_post_line,
  share_note_with→share_post_with, get_note_owner→get_post_owner; navigate to /billboard/
- test_layout_and_styling.py: NotePage→PostPage, get_item_input_box→get_line_input_box,
  add_note_item→add_post_line; navigate to /billboard/
- my_notes.html: remove "My Notes" h2 heading
- _note.scss: .note-page padding 0.75rem 1.5rem; .note-don-doff top:-1rem (DON centers on
  corner), gap:0.4rem (tight like game kit); .note-item padding-left:1.25rem (left buffer)

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:55:12 -04:00
Disco DeDisco
214120ef2d note title equip: DON/DOFF buttons outside top-left corner; User.active_title FK; don/doff views; greeting updated via JS — TDD
- lyric/models.py: active_title = ForeignKey('drama.Note', null=True, SET_NULL)
- lyric migration 0020_add_active_title
- billboard/views.py: don_title + doff_title views; is_equipped per note_item context
- billboard/urls.py: note/<slug>/don + note/<slug>/doff routes
- _navbar.html: id_greeting_name span; shows active_title.slug|capfirst when set
- my_notes.html: .note-don-doff buttons (DON/DOFF, × when disabled); data-don-url/doff-url/title attrs
- note-page.js: _bindDonDoff() — DON POST sets greeting + swaps btn state; DOFF restores Earthman
- _note.scss: .note-don-doff position:absolute left:-1rem top:0; flex-direction:column gap:1.25rem
- ITs: NoteEquipTitleViewTest (5 tests); UserModelTest.test_active_title_* (3 tests)
- FT: NoteEquipTitleTest.test_don_equips_title_greeting_and_doff_restores

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:44:58 -04:00
Disco DeDisco
7d4389a74a note palette modal: NVM closes confirm only; confirm visibility via style.display; swatch labels from _PALETTE_DEFS; recognitions block — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- note-page.js: NVM hides confirm (style.display='none') — swatch modal stays open;
  _showConfirm/_hideConfirm use style.display to bypass CSS specificity issues;
  _doSetPalette reads data-palette-label before modal closes; appends
  .note-recognitions__palette-line w. dim+bold markup after OK
- billboard/views.py: import _PALETTE_DEFS; _PALETTE_LABELS dict; _palette_opts()
  enriches palette_options w. {name, label}; my_notes adds palette_label to note_items
- _note.scss: confirmed palette swatch uses gradient (palette vars cascade from
  palette-* class); hardcoded bardo/sheol bg overrides removed; .note-recognitions block
  w. .note-recognitions__header (tt-sign-section-header style) & __dim (tt-dim style);
  .note-swatch-label in terUser bold; .note-item__palette gradient; confirm display:none default
- my_notes.html: p.name/p.label replaces slice hack; data-palette-label on swatch rows;
  Recognitions block w. dim spans & strong values; removes hidden attr from confirm
- IT: test_palette_modal_renders_swatch_labels; test_also_saves_user_palette
- FT: NVM test corrected — modal stays open, confirm is_displayed() False; T2a URL fix

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 01:31:19 -04:00
Disco DeDisco
cd5252c185 note palette: swatch previews body palette, NVM reverts, OK saves sitewide; note_set_palette also saves user.palette — TDD
- note-page.js: body class swap on swatch click; 10s auto-revert timer; NVM reverts;
  .note-item--active persists border/glow while modal open; .previewing on swatch
- billboard/views.py: note_set_palette also saves user.palette via _unlocked_palettes_for_user
- _note.scss: .note-swatch-body gradient (palette vars cascade from parent palette-* class);
  .previewing state; .note-item--active; note-palette-modal tooltip glass;
  note-palette-confirm floats below modal (position:absolute, out of flow)
- my_notes.html: note-item__body wrapper; image-box right; swatch row OK buttons removed
- FTs: T2a URL fix (/recognition → /my-notes); T2b split into preview+persist & NVM tests;
  NoteSetPaletteViewTest.test_also_saves_user_palette IT

Code architected by Disco DeDisco <discodedisco@outlook.com>
Git commit message Co-Authored-By:
Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:54:05 -04:00
Disco DeDisco
e8687dc050 note banner NVM: btn-danger → btn-cancel (orange)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:43:50 -04:00
Disco DeDisco
48aad6ce35 note banner: tooltip style (Gaussian bg, border, blur); fix Recognition→Note in sky.html & _applet-my-sky.html
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 22:41:40 -04:00
Disco DeDisco
473e6bc45a 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
Disco DeDisco
6d9d3d4f54 recognition: page, palette modal, & dashboard palette unlock — TDD
- billboard/recognition/ view + template; recognition/<slug>/set-palette endpoint (no trailing slash)
- recognition.html: <template>-based modal (clone on open, remove on close — Selenium find_elements compatible)
- recognition-page.js: image-box → modal → swatch preview → body-click restore → OK → confirm → POST set-palette
- _palettes_for_user() replaces static PALETTES; Recognition.palette unlocks swatch + populates data-shoptalk
- _unlocked_palettes_for_user() wires dynamic unlock check into set_palette view
- _applet-palette.html: data-shoptalk from context instead of hard-coded "Placeholder"
- _recognition.scss: banner, recog-list/item, image-box, modal, palette-confirm; :not([hidden]) pattern avoids display override
- FT T2 split into T2a (banner → FYI → recog page), T2b (palette modal flow), T2c (dashboard palette applet)
- 684 ITs green; 7 FTs green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 04:02:14 -04:00
Disco DeDisco
565f727aa6 recognition: recognition.js showBanner/handleSaveResponse; wired into sky SAVE handler on applet & sky page — TDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 03:05:35 -04:00
Disco DeDisco
3cc9f5a527 recognition: seed billboard-recognition applet 4×4; vertical title link; placement in billboard grid
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 02:21:35 -04:00
Disco DeDisco
be061f6bc2 recognition: Recognition model w. grant_if_new; sky_save returns stargazer on first chart save — TDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 02:13:29 -04:00
Disco DeDisco
83ce238a2f recognition: Stargazer FT — invalid save stays locked, valid save fires banner, full flow to palette unlock — TDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 02:09:48 -04:00
Disco DeDisco
6069d86ec5 skipped a localstorage natus FT clogging the pipeline
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-04-22 01:55:39 -04:00
Disco DeDisco
a44727c559 rootvars: add --qua/--qui/--six depth shades to all element color groups
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Rd, Or, Yl, Lm, Gn, Tk, Cy, Bl, Fs, Me now have full six-shade series
matching the existing Id and Vt groups; whitespace alignment pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:59:11 -04:00
Disco DeDisco
0b2320e39b natus wheel: ASC/MC angles — tooltips, aspect lines, section headers, tooltip polish
- ASC/MC clickable w. DON/DOFF aspect lines (fixed: open w.o. lines; DON/DOFF
  both work; angles ring handled in _toggleAspects; lines origin at R.planetR)
- btn-disabled click-through fix: pointer-events:auto on DON/DOFF; bounding-rect
  workaround removed
- planet tooltip: applying ⇥ left, separating ↦ right; sign shown for angle partners
- sign tooltip: Planets + Cusps section headers; ordinal house + domain; em-dash fallback
- house tooltip: Planets header; Angular/Succedent/Cadent + phase labels; em-dash fallback
- element tooltips: Planets header for Fire/Stone/Air/Water; Stellium/Parade as
  section-header labels; compact single-stellium Tempo; Parade sign : planets format
- tt-ord: no negative margin in .tt-angle-house context

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 00:58:19 -04:00
Disco DeDisco
5c05bd6552 sky: store birth_tz, prefill form from User model, drop localStorage
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:54:34 -04:00
Disco DeDisco
b5a92ddf77 natus wheel: house tooltip polish — br, pointer-events, @deg° muting
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:54:06 -04:00
Disco DeDisco
bb1cda9c9c natus wheel: planet/sign/house tooltip layout overhaul — TDD
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 21:20:15 -04:00
Disco DeDisco
3974fdac82 sky_natus_data: return stored sky_chart_data (preserves correct asc)
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
sky_save was re-fetching from PySwiss using sky_birth_dt, which was
stored as local time treated as UTC — giving a different (wrong) asc
than the chart computed by sky_preview. sky_natus_data then served this
wrong chart, rotating the applet wheel by the timezone offset.

Fix: sky_save stores body.get('chart_data') directly (client _lastChartData
is already enriched from sky_preview with correct UTC). sky_natus_data
returns the stored chart with fresh distinctions — no PySwiss call needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:58:16 -04:00
Disco DeDisco
b8ac004fb6 sky wheel: element contributor display; sign + house tooltips — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
sky_save now re-fetches from PySwiss server-side on save so stored
chart_data always carries enriched element format (contributors/stellia/
parades). New sky/data endpoint serves fresh PySwiss data to the My Sky
applet on load, replacing the stale inline json_script approach.

natus-wheel.js: sign ring slices (data-sign-name) and house ring slices
(data-house) now have click handlers with _activateSign/_activateHouse;
em-dash fallback added for classic elements with empty contributor lists.
Action URLs sky/preview, sky/save, sky/data lose trailing slashes.

Jasmine: T12 sign tooltip, T13 house tooltip, T14 enriched element
contributor display (symbols, Stellium/Parade formations, em-dash fallback).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 20:07:40 -04:00
Disco DeDisco
02975d79d3 natus wheel: fix DON/DOFF reset on PRV/NXT return to DONned planet — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
_aspectsVisible was set to false when stepping away to a different planet,
but the guard `if (item0.name !== _aspectPlanet)` only skipped resetting it
on return — it never restored it to true. Replace the conditional with a
direct assignment: _aspectsVisible = (item0.name === _aspectPlanet).

T11g: navigating away via NXT then back via PRV restores DOFF-active state.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 16:04:32 -04:00
Disco DeDisco
04f0e87eba fix shadowed test classes restoring 41 dead tests; add missing coverage
SigReserveViewTest, SigReadyViewTest, SigConfirmViewTest each defined twice
in test_views.py — second (shorter) definition silently shadowed the first
comprehensive set, so ~180 lines of tests never ran.

- remove three duplicate class definitions (lines 1648–1749)
- absorb unique tests from shadowed copies: non-POST 405 guards for all
  three views; idempotent-ready path; invalid seconds_remaining fallback
- add test_release_while_ready_records_sig_unready (lines 667–679)
- add TarotDealViewTest for tarot_deal non-POST redirect (line 904)
- 609 → 650 tests, all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:56:36 -04:00
Disco DeDisco
ebc460fe67 gitignore *.ps1 — local Windows dev utility, not app code
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:47:41 -04:00
Disco DeDisco
7c249500bd refactor: extract apply_applet_toggle, rooms_for_user & natus helpers to utils; DRY toggle views
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- epic/utils.py (new): _planet_house, _compute_distinctions, rooms_for_user
- applets/utils.py: apply_applet_toggle replaces 5 copy-pasted toggle loops
- dashboard/views.py: use apply_applet_toggle; fix double free_tokens/tithe_tokens query in wallet(); promote _compute_distinctions import to module level
- gameboard/views.py: use apply_applet_toggle & rooms_for_user; fix double free_tokens query in toggle_game_applets
- billboard/views.py: use apply_applet_toggle & rooms_for_user
- SCSS: %tt-token-fields placeholder in _tooltips.scss; _gameboard & _game-kit @extend it
- epic/tests/unit/test_utils.py (new): coverage for _planet_house fallback path

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 15:46:30 -04:00
Disco DeDisco
ea2bfa6ce1 natus wheel: aspect line system, element tooltip overhaul, ring/spoke changes — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- DON/DOFF toggle: aspect lines persist across planet switches & outside clicks; cleared only by DON (new planet) or DOFF; planet-keyed --asp-* colors (--sixU/--terU on light palettes)
- planet tooltip: aspect rows w. 2× thick line legend, planet symbol + .tt-asp-in 'in' + sign icon + orb; applying/separating direction symbols
- element tooltip: 80px square badge (float right); DON/DOFF hidden; symbol-based contributor rows (☉ @ 15.3°  +1); Stellium/Parade +N underlined headers; parade sign-grouped w. parenthetical planet symbols, counterclockwise order
- ring swap: planets outer (0.50–0.68r), houses inner (0.32–0.48r) to reduce stellia crowding
- house spokes: angle cusps only (ASC/IC/DSC/MC); non-angle spokes removed
- outside-click guard: bounding-rect check for DON/DOFF/PRV/NXT so pointer-events:none buttons don't trigger close
- add element-square & energy-vector icon dirs (Ardor/Ossum/Tempo/Nexus/Pneuma/Humor SVGs)
- T11a–f Jasmine specs for DON/DOFF line persistence

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-21 03:17:20 -04:00
Disco DeDisco
9c7d58f0b3 updates to pyswiss aspect & aspect application data served over API, incl. seminal invocation of Space parades & Time stellia
All checks were successful
ci/woodpecker/push/main Pipeline was successful
ci/woodpecker/push/pyswiss Pipeline was successful
2026-04-21 00:37:33 -04:00
Disco DeDisco
4761d3f939 natus FT: dispatchEvent for SVG element click; .click() fails on SVGElement
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 21:25:23 -04:00
Disco DeDisco
2be330e698 NATUS WHEEL: half-wheel tooltip positioning + click-outside fix — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Tooltip positioning:
- Scrapped SVG-edge priority; now places in opposite vertical half anchored
  1rem from the centreline (lower edge above CL if item in bottom half,
  upper edge below CL if item in top half)
- Horizontal: left edge aligns with item when item is left of centre;
  right edge aligns with item when right of centre
- Clamped to svgRect bounds (not window.inner*)

Click-outside fix:
- Added event.stopPropagation() to D3 v7 planet and element click handlers
- Removed svgNode.contains() guard from _attachOutsideClick so clicks on
  empty wheel areas (zodiac ring, background) now correctly dismiss the tooltip

FT fix: use execute_script click for element-ring slice (inside overflow-masked applet)
Jasmine: positioning describe block xdescribe'd (JSDOM has no layout engine)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 17:27:52 -04:00
Disco DeDisco
fbf260b148 NATUS WHEEL: tick lines + dual conjunction tooltip — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- _computeConjunctions(planets, threshold=8) detects conjunct pairs
- Tick lines (nw-planet-tick) radiate from each planet circle outward
  past the zodiac ring; animated via attrTween; styled with --pri* colours
- planetEl.raise() on mouseover puts hovered planet on top in SVG z-order
- Dual tooltip: hovering a conjunct planet shows #id_natus_tooltip_2 beside
  the primary, populated with the hidden partner's sign/degree/retrograde data
- #id_natus_tooltip_2 added to home.html, sky.html, room.html
- _natus.scss: tick line rules + both tooltip IDs share all selectors;
  #id_natus_confirm gets position:relative/z-index:1 to fix click intercept
- NatusWheelSpec.js: T7 (tick extends past zodiac), T8 (raise to front),
  T9j (conjunction dual tooltip) in new conjunction describe block
- FT T3 trimmed to element-ring hover only; planet/conjunction hover
  delegated to Jasmine (ActionChains planet-circle hover unreliable in Firefox)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 00:16:05 -04:00
Disco DeDisco
09ed64080b natus tooltip: fix portal placement + viewport clamping + SVG sign icon
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- Move #id_natus_tooltip out of #id_applets_container (container-type:
  inline-size breaks position:fixed) → add to home.html alongside
  #id_tooltip_portal
- Move #id_natus_tooltip out of .natus-modal-wrap (transform breaks
  position:fixed) → place as sibling of .natus-overlay in room.html
- Add _positionTooltip() helper in natus-wheel.js: flips tooltip to
  left of cursor when it would overflow right edge; clamps both axes
- Replace hardcoded 280px in dashboard.js palette tooltip with measured
  offsetWidth; add left-edge floor (Math.max margin)
- Planet tooltip format: @14.0° Capricorn (<svg-icon>) using preloaded
  _signPaths; falls back to unicode symbol if not yet loaded
- Add .tt-sign-icon SCSS: fill:currentColor, vertical-align:middle

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 14:02:49 -04:00
Disco DeDisco
f15b17f7bd fixed failing test to assert the right thing to match new Palette applet code
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-04-18 02:34:39 -04:00
Disco DeDisco
122de3bc80 PALETTE: swatch preview + tooltip + OK commit — TDD
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
Clicking a swatch instantly swaps the body palette class for a live
preview; OK commits silently (POST, no reload); click-elsewhere or
10 s auto-dismiss reverts. Tooltip portal shows label, shoptalk,
lock state. Locked swatches show × (disabled). 20 FTs green.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 02:05:27 -04:00
Disco DeDisco
6e995647e4 palette changes and renames: nirvana->bardo, sepia->cedar 2026-04-18 00:11:31 -04:00
Disco DeDisco
d7d20f25e3 removed #id_natus_confirm .btn.btn-primary styling that forced it to become elliptical and spread across applet; now negative top margin and self-centering
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-04-17 23:27:28 -04:00
Disco DeDisco
758c9c5377 COVERAGE: patch 91% → 96%+ — 603 tests, tasks.py at 100%
New/extended tests across billboard, dashboard, drama, epic, gameboard,
and lyric to cover previously untested branches: dev_login view, scroll
position endpoints, sky preview error paths, drama to_prose/to_activity
branches, consumer broadcast handlers, tarot deck draw/shuffle, astrology
model __str__, character model, sig reserve/ready/confirm views, natus
preview/save views, and the full tasks.py countdown scheduler.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 23:23:28 -04:00
Disco DeDisco
7c03bded8d some FT renames for readability; added natus form to My Sky applet
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-04-17 22:30:11 -04:00
Disco DeDisco
8a24021739 MY SKY: full-page layout polish — aperture pinning, wheel-above-form, centred wheel
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- sky_view passes page_class="page-sky" so the footer pins correctly
- _natus.scss: page-sky aperture block (mirrors page-wallet pattern);
  sky-page stacks wheel above form via flex order + page-level scroll;
  wheel col uses aspect-ratio:1/1 so it takes natural square size without
  compressing to fit the form
- natus-wheel.js: _layout() sets viewBox + preserveAspectRatio="xMidYMid meet"
  so the wheel is always centred inside the SVG element regardless of its
  aspect ratio (fixes left-alignment in the dashboard applet)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 14:40:52 -04:00
Disco DeDisco
bd9a2fdae3 pyswiss_url env var added to more places throughout ansible vault architecture; staging now has working astro wheel
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-04-16 12:04:46 -04:00
Disco DeDisco
4f8e52890b forgot PYSWISS_URL in live server env, preventing Sky selection from generating an astro wheel
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
2026-04-16 11:22:52 -04:00
Disco DeDisco
abf8be8861 MY SKY: dashboard applet + full-page natal chart — TDD
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- User model: sky_birth_dt/lat/lon/place/house_system/chart_data fields (lyric migration 0018)
- Applet seed: my-sky (6×6, dashboard context) via applets migration 0009
- dashboard/sky/ — full monoapplet page: natus form + D3 wheel, LS key scoped to dashboard:sky; saves to User model
- dashboard/sky/preview/ — PySwiss proxy (same logic as epic:natus_preview, no seat check)
- dashboard/sky/save/ — persists 6 sky fields to User via update_fields
- _applet-my-sky.html: tile with h2 link to /dashboard/sky/
- 2 FTs (applet→link→form, localStorage persistence) + 12 ITs — all green

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 03:03:19 -04:00
Disco DeDisco
127f4a092d TOOLTIPS: extract .tt base to _tooltips.scss + natus element/planet title colours
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- New _tooltips.scss: .token-tooltip/.tt base block extracted from _wallet-tokens.scss
- core.scss: import order …billboard → tooltips → game-kit → wallet-tokens
- _natus.scss: .tt-title--au/ag/…/pu (--six* dark, --pri* light) + .tt-title--el-* for element ring (--pri* dark, --ter* light) via body[class*="-light"] selector
- natus-wheel.js: element tooltip title switched from inline style to .tt-title--el-{key} CSS class; PLANET_ELEMENTS map drives .tt-title--{el} class on planet titles
- _game-kit.scss: kit bag .tt child font-size rules added (1rem title, 0.75rem desc/shoptalk/expiry)
- CLAUDE.md: SCSS import order updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 02:36:24 -04:00
Disco DeDisco
2910012b67 PICK SKY: natal wheel planet tooltips + FT modernisation
- natus-wheel.js: per-planet <g> group with data-planet/sign/degree/retrograde
  attrs; mouseover/mouseout on group (pointer-events:none on child text/℞ so
  the whole apparatus triggers hover); tooltip uses .tt-title/.tt-description;
  in-sign degree via _inSignDeg() (ecliptic % 30); D3 switched from CDN to
  local d3.min.js
- _natus.scss: .nw-planet--hover glow; #id_natus_tooltip position:fixed z-200
- _natus_overlay.html: tooltip div uses .tt; local d3.min.js script tag
- T3/T4/T5 converted from Selenium execute_script to Jasmine unit tests
  (NatusWheelSpec.js) — NatusWheel was never defined in headless GeckoDriver;
  SpecRunner.html updated to load D3 + natus-wheel.js
- test_pick_sky.py: NatusWheelTooltipTest removed (replaced by Jasmine)
- test_component_cards_tarot / test_trinket_carte_blanche: equip assertions
  updated from legacy .equip-deck-btn/.equip-trinket-btn mini-tooltip pattern
  to current DON|DOFF (.btn-equip in main portal); mini-portal text assertions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 01:57:02 -04:00
Disco DeDisco
db9ac9cb24 GAME KIT: DON|DOFF equip system — portal tooltips, kit bag sync, btn-disabled fix
Some checks failed
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline failed
- DON/DOFF buttons on left edge of game kit applet portal tooltip (mirroring FLIP/FYI)
- equip-trinket/unequip-trinket/equip-deck/unequip-deck views + URLs
- Portal stays open after DON/DOFF; buttons swap state in-place (_setEquipState)
- _syncTokenButtons: updates all .tt DON/DOFF buttons after equip state change
- _syncKitBagDialog (DOFF): replaces card with grayed placeholder icon in-place
- _refreshKitDialog (DON): re-fetches kit content so newly-equipped card appears immediately
- kit-content-refreshed event: game-kit.js re-attaches card listeners after re-fetch
- Bounding box expanded 24px left so buttons at portal edge don't trigger close
- mini-portal pinned with right (not left) so text width changes grow/shrink leftward
- btn-disabled moved dead last in .btn block — wins by source order, no !important needed
- Kit bag panel: trinket + token sections always render (placeholder when empty)
- Backstage Pass in GameKitEquipTest setUp (is_staff, natural unequipped state)
- Portal padding 0.75rem / 1.5rem; tt-description/shoptalk smaller; tt-expiry --priRd
- Wallet tokens CSS hover rule for .tt removed (portal-only now)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 00:14:47 -04:00
Disco DeDisco
d3e4638233 TOOLTIPS: wallet tokens refactor — .token-tooltip → .tt + child classes
- _applet-wallet-tokens.html: all 6 token blocks (pass, coin, free×2, tithe×2)
  updated to .tt wrapper + .tt-title / .tt-description / .tt-shoptalk / .tt-expiry
  child classes; small→p.tt-shoptalk, p.expiry→p.tt-expiry
- wallet.js: querySelector('.token-tooltip') → querySelector('.tt') in initWalletTooltips

13 FTs green (trinket_carte_blanche + game_kit + gameboard)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:42:37 -04:00
Disco DeDisco
10a6809dcf TOOLTIPS: game kit applet refactor — .token-tooltip → .tt + double-tooltip fix
- _applet-game-kit.html: all 5 token blocks use .tt + child classes (.tt-title,
  .tt-description, .tt-shoptalk, .tt-expiry); removed .token-tooltip-body wrapper
- gameboard.js: 4× querySelector('.token-tooltip') → querySelector('.tt')
- _gameboard.scss: extend hover suppressor to .tt so CSS hover doesn't show inline
  .tt when JS portal is active (fixed double tooltip visual bug)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:39:01 -04:00
Disco DeDisco
de4ac60aec tooltips app TDD spike + kit bag refactor to .tt
- New apps.tooltips: TooltipContent model, {% tooltip data %} inclusion
  tag, _tooltip.html partial with .tt/.tt-title/.tt-description etc.
  class contract; 34 tests green
- Kit bag panel (_kit_bag_panel.html): .token-tooltip → .tt + child
  class renames (tt-title, tt-description, tt-shoptalk, tt-expiry)
- game-kit.js attachTooltip: .token-tooltip → .tt selector
- SCSS: .tt added alongside .token-tooltip for display:none default +
  hover rules in _wallet-tokens.scss and _game-kit.scss

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 22:16:50 -04:00
Disco DeDisco
71ef3dcb7f PICK SKY: natal wheel icon + palette sprint — inline SVG zodiac icons, per-planet alchemical colors
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- Replace Unicode glyph text elements with inline SVG <path> icons loaded from icons/zodiac-signs/*.svg files
- NatusWheel.preload() fetches + caches all 12 zodiac SVG paths before first draw; auto-detects static base URL from <script src>
- Per-element (fire/stone/air/water) colored circles behind zodiac icons; icon fill colors use element palette vars
- PLANET_ELEMENTS map (au/ag/hg/cu/fe/sn/pb/u/np/pu) drives per-planet circle + label CSS modifier classes
- Planet circles: ternary fill + senary stroke per alchemical metal palette; labels: senary fill + stroke halo
- Zodiac icon scale = 85% of circle diameter (15% inset so icons breathe inside circles)
- Zodiac + house segment bg opacity 0.5; all ring/segment borders --terUser

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 18:20:39 -04:00
Disco DeDisco
9beb21bffe PICK SKY: natal wheel polish — house/sign fill fixes, button layout, localStorage FT
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
- Fix D3 arc coordinate offset (add π/2 to all arc angles — D3 subtracts it
  internally, causing fills to render 90° CW from label midpoints)
- Fix house-12 wrap-around: normalise nextCusp += 360 when it crosses 0°,
  eliminating the 330° ghost arc that buried house fill/number layers
- Draw all house fills before cusp lines + numbers (z-order fix)
- SCSS: sign/element fills corrected to rgba(var(--priXx, R, G, B), α) —
  CSS vars are raw RGB tuples so bare var() in fill was invalid
- brighten Stone/Air/Water fallback colours; raise house fill opacities
- Button layout: SAVE SKY moves into form column (full-width, pinned bottom);
  NVM becomes a btn-sm circle anchored on the modal's top-right corner via
  .natus-modal-wrap (position:relative, outside overflow:hidden modal);
  entrance animation moved to wrapper so NVM rides the fade+slide
- Form fields wrapped in .natus-form-main (scrollable); portrait layout
  switches form-col to flex-row so form spans most width, SAVE SKY on right
- Modal max-height 92→96vh, max-width 840→920px, SVG cap 400→480px
- FT: PickSkyLocalStorageTest (2 tests) — form fields restored after NVM
  and after page refresh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-15 00:49:14 -04:00
Disco DeDisco
6248d95bf3 PICK SKY overlay: D3 natal wheel, Character model, PySwiss aspects+tz
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
PySwiss:
- calculate_aspects() in calc.py (conjunction/sextile/square/trine/opposition with orbs)
- /api/tz/ endpoint (timezonefinder lat/lon → IANA timezone)
- aspects included in /api/chart/ response
- timezonefinder==8.2.2 added to requirements
- 14 new unit tests (test_calc.py) + 12 new integration tests (TimezoneApiTest, aspect fields)

Main app:
- Sign, Planet, AspectType, HouseLabel reference models + seeded migrations (0032–0033)
- Character model with birth_dt/lat/lon/place, house_system, chart_data, celtic_cross,
  confirmed_at/retired_at lifecycle (migration 0034)
- natus_preview proxy view: calls PySwiss /api/chart/ + optional /api/tz/ auto-resolution,
  computes planet-in-house distinctions, returns enriched JSON
- natus_save view: find-or-create draft Character, confirmed_at on action='confirm'
- natus-wheel.js: D3 v7 SVG natal wheel (elements pie, signs, houses, planets, aspects,
  ASC/MC axes); NatusWheel.draw() / redraw() / clear()
- _natus_overlay.html: Nominatim place autocomplete (debounced 400ms), geolocation button
  with reverse-geocode city name, live chart preview (debounced 300ms), tz auto-fill,
  NVM / SAVE SKY footer; html.natus-open class toggle pattern
- _natus.scss: Gaussian backdrop+modal, two-column form|wheel layout, suggestion dropdown,
  portrait collapse at 600px, landscape sidebar z-index sink
- room.html: include overlay when table_status == SKY_SELECT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 02:09:26 -04:00
Disco DeDisco
44cf399352 added path filters to main + pyswiss pipelines; fixed deploy-pyswiss absolute path for deploy.sh
All checks were successful
ci/woodpecker/push/pyswiss Pipeline was successful
ci/woodpecker/push/main Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-14 00:26:54 -04:00
Disco DeDisco
df2b353ebd split Woodpecker into separate main + pyswiss pipelines; added deploy-prod tag-triggered step for earthmanrpg.com cutover
Some checks failed
ci/woodpecker/push/pyswiss Pipeline failed
ci/woodpecker/push/main Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:55:49 -04:00
Disco DeDisco
3fd1f5e990 added django-cors-headers to pyswiss: allows browser fetch from *.earthmanrpg.me and localhost
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 23:08:32 -04:00
Disco DeDisco
02a7a0ef2e added deploy-pyswiss Woodpecker step to auto-deploy pyswiss droplet on push to main
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 22:59:08 -04:00
Disco DeDisco
cc2ab869f1 wired PySwiss microservice deployment: env-driven settings, gunicorn pinned, PYSWISS_URL in main settings, test-pyswiss CI stage
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 21:55:58 -04:00
Disco DeDisco
8c711ac674 added missing & crucial pyswiss.core.wsgi file pointing to DJANGO_SETTINGS_MODULE
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-13 20:47:18 -04:00
Disco DeDisco
b8af0041cc scaffolded PySwiss microservice: Django 6.0 project at pyswiss/ (core/ settings, apps/charts/), GET /api/chart/ + GET /api/charts/ views, EphemerisSnapshot model + migration, populate_ephemeris management command, 41 ITs (integrated + unit, all green); separate .venv with pyswisseph 2.10.3.2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 19:03:45 -04:00
Disco DeDisco
97ec2f6ee6 cropped & centered Sig icon placeholder in #id_tray_* apparatus
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-13 14:28:53 -04:00
Disco DeDisco
0a135c2149 implemented sig_confirm view: guards (403 unseated, 400 wrong phase, 400 not-all-ready), significator assignment from reservations, polarity_room_done + pick_sky_available broadcasts, SKY_SELECT advance when both polarities done
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 13:00:16 -04:00
Disco DeDisco
f1e9a9657b fixed SRG5-8 channels FTs: multi-browser sig-card OK flow now uses execute_async_script to iterate cards until a non-conflicting reserve succeeds (bypasses ElementNotInteractableException + 409 same-card conflicts); added wait_for_slow for 12s countdown in SRG8; added browser=None param to ChannelsFunctionalTest.wait_for/wait_for_slow
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 02:31:00 -04:00
Disco DeDisco
32d8d97360 wired PICK SKY server-side polarity countdown via threading.Timer (tasks.py); fixed polarity_done overlay gating on refresh; cleared sig-select floats on overlay dismiss; filtered Redact events from Most Recent applet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-13 00:34:05 -04:00
Disco DeDisco
df421fb6c0 added PICK SKY ready gate: SigReservation.ready + countdown_remaining fields, Room.SKY_SELECT status + sig_select_started_at, sig_ready + sig_confirm views, WS notifiers for countdown_start/cancel/polarity_room_done/pick_sky_available, migration 0031, PICK SKY btn in hex center at SKY_SELECT, tray cell 2 sig card placeholder; FTs SRG1-8 written (pending JS/consumer)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-09 01:17:24 -04:00
Disco DeDisco
3800c5bdad fixed attribution of .fa-hand-pointer cursor color scheme to ordering according to token-drop sequence instead of seat sequence; updates to accomodate this throughout apps.epic.models & .views, plus new apps.epic migration; assigned #id_sig_cursor_portal a z-index value corresponding to a high position but still beneath the #id_tray apparatus; minor semantic reordering of INSTALLED_APPS in core.settings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-08 22:53:44 -04:00
Disco DeDisco
12d575a84b fixed seeding problem w. setUp helper causing same FTs to persistently fail
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-08 13:34:22 -04:00
Disco DeDisco
c14b6d7062 fixed some old data in two pipeline errors pointing to new Middle Arcana labels still as Minor Arcana
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-08 12:51:46 -04:00
Disco DeDisco
a7c5468cbc fixed failing channels FT related to Sig select; FT fix only, code written as intended
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-08 12:18:20 -04:00
Disco DeDisco
4da8750c60 fixed tooltip illegibility due to similar color to bg on .sig-overlay when data-polarity='gravity'
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-08 11:57:44 -04:00
Disco DeDisco
cf40f626e6 Sig select: _card-deck.scss extract, WS cursor fixes, own-role indicators, role icon refresh
- New _card-deck.scss: sig select styles moved out of _room.scss + _game-kit.scss
- sig-select.js: 3 WS bug fixes — thumbs-up deferred to window.load (layout settled
  before getBoundingClientRect), hover cursor cleared for all cards on reservation
  (not just the reserved card), applyHover guards against already-reserved roles
- Own-role indicators: gamer now sees their own role-coloured card outline + thumbs-up
- Reservation glow: replaced blurry role+ninUser double-shadow with crisp 2px outline
- Gravity qualifier: Graven text set to --terUser (matches Leavened/--quiUser pattern)
- Role card SVGs refreshed; starter-role-Blank removed
- FTs + Jasmine specs extended for sig select WS behaviour
- setup_sig_session management command for multi-browser manual testing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-08 11:52:49 -04:00
Disco DeDisco
99a826f6c9 FT: pin AppletMenuDismissTest to portrait viewport (800×1200)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Landscape layout activates sidebar CSS which causes #id_dash_content to
overlap the base-template h2 in CI headless Firefox, triggering
ElementClickInterceptedException. Portrait viewport sidesteps all
landscape breakpoints so the h2 sits safely above #id_dash_content.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 01:26:35 -04:00
Disco DeDisco
51fe2614fa overruling other scss specificity in .btn-disabled 2026-04-07 00:43:26 -04:00
Disco DeDisco
56dc094b45 Jasmine: fix 2 failing specs, drop 5 always-pending touch specs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- FYI btn is now btn-disabled when caution open; rename test to assert
  disabled click does NOT close caution (old toggle expectation was stale)
- Hover-resets-is-reversed: cloneNode post-init has no mouseenter listener
  (direct binding, not delegation); use mouseleave + re-enter on same card
- Remove 3 touch describe blocks (5 specs total); TouchEvent unavailable
  in desktop Firefox means they never ran; touch behaviour covered by FTs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:36:28 -04:00
Disco DeDisco
520fdf7862 Sig select: caution tooltip, FLIP/FYI stat block, keyword display
- TarotCard.cautions JSONField + cautions_json property; migrations
  0027–0029 seed The Schizo (number=1) with 4 rival-interaction cautions
  (Roman-numeral card refs: I. The Pervert / II. The Occultist, etc.)
- Sig-select overlay: FLIP (stat-block toggle) + FYI (caution tooltip)
  buttons; nav PRV/NXT portaled outside tooltip at bottom corners (z-70);
  caution tooltip covers stat block (inset:0, z-60, Gaussian blur);
  tooltip click dismisses; FLIP/FYI fully dead while btn-disabled;
  nav wraps circularly (4/4 → 1/4, 1/4 → 4/4)
- SCSS: btn-disabled specificity fix (!important); btn-nav-left/right
  classes; sig-caution-* layout; stat-face keyword lists
- Jasmine suite expanded: stat block + FLIP (5 specs), caution tooltip
  (16 specs) including wrap-around and disabled-button behaviour
- IT tests: TarotCardCautionsTest (5), SigSelectRenderingTest (8)
- Role-card SVG icons added to static/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 00:22:04 -04:00
Disco DeDisco
e2cc38686f XL landscape: revert tray to landscape style; fix sig-stage stretch
- Remove _tray.scss XL (≥1800px) portrait override block entirely
- _isLandscape() no longer returns false at ≥1800px — tray uses
  landscape slide-from-top at all wide landscape widths
- sig-stage: align-self: stretch (was center) so JS sizeSigCard()
  measures correct stage width; card size no longer collapses
- Position strip: horizontal row at top (was vertical column-reverse)
- sig-overlay/sig-stage/sig-deck-grid layout polish at 1100px/1800px

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 20:11:24 -04:00
Disco DeDisco
0bcc7567bb XL landscape polish: btn-primary sizing, tray from right, footer bg, layout fixes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- .btn-xl removed; .btn-primary absorbs 4rem sizing (same as PICK SIGS/PICK ROLES)
- Landscape navbar .btn-primary: 3rem → 4rem to match base; XL stays 4rem (consistent)
- _button-pad.scss XL: base .btn ×1.2 (2.4rem); .btn-xl block deleted
- _tray.scss XL (≥1800px): portrait-style tray (slides from right, z-95)
- tray.js: _isLandscape() returns false at ≥1800px; portrait code paths run throughout
- Footer sidebar: background-color added so opaque footer masks tray sliding behind it
- Copyright .footer-container: bottom → top in landscape sidebar
- #id_room_menu: right: 2.5rem override in _room.scss XL block (cascade fix)
- navbar-text XL: 0.65rem × 1.2 = 0.78rem
- All landscape media queries: max-width: 1440px cutoff removed (already done prior)
- btn-xl class stripped from all 5 templates; test_navbar.py assertion updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 03:02:37 -04:00
Disco DeDisco
6654785f25 XL landscape breakpoint (≥1800px): double sidebar widths + scale content
- _base.scss: new @media (orientation:landscape) and (min-width:1800px) block —
  sidebars 4rem→8rem; navbar btn 3rem→5rem; brand h1 1.2rem→2.4rem; navbar-text
  0.65rem→1.3rem; footer icons 1.75rem→3rem; nav gap 3rem→4rem; footer-container
  0.55rem→0.85rem; container margins 4rem→8rem; h2 portrait-style (2rem, centred)
- _applets.scss: gear btn right 0.5rem→2.5rem; menus right 0.5rem→2rem at ≥1800px
- _game-kit.scss: kit btn right 0.5rem→2.5rem at ≥1800px
- _room.scss: sig-overlay padding-left 4rem→8rem at ≥1800px
- _tray.scss: tray wrap left/right 4rem→8rem at ≥1800px
- room.js: sizeSigModal right inset 64px→128px at ≥1800px viewport width

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 01:41:18 -04:00
Disco DeDisco
99a69202b9 landscape layout: remove max-width cutoff; sig-select stage/grid polish
- All landscape @media queries: drop and (max-width: 1440px) — sidebar layout
  now activates for all landscape orientations regardless of viewport width
- _base.scss landscape container: add max-width:none to override the
  @media(min-width:1200px) rule and fill the full space between sidebars
- sig-select sig-deck-grid: landscape now 9×2 @ 3rem cards; 18×1 at ≥1100px
  (bumped from 992px to avoid last-card clip); card text scales with --sig-card-w
- sig-stat-block: flex:1→flex:0 0 auto with width:--sig-card-w so it matches
  preview card dimensions instead of stretching across the full stage
- room.js sizeSigModal: landscape card width clamped to [90px, 160px]

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 01:30:31 -04:00
Disco DeDisco
55bb450d27 z-index audit + aperture fill + resize:end debounce + landscape sig-grid cap
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- #id_aperture_fill: position:fixed→absolute (clips to .room-page, avoids h2/navbar);
  z-index 105→90 (below blur backdrops at z-100); landscape override removed (inset:0 works both orientations)
- _base.scss: landscape footer z-index:100 (matches navbar); corrects unset z-index
- _room.scss: fix stale "navbar z-300" comment; landscape sig-deck-grid columns
  repeat(9,1fr)→repeat(9,minmax(0,90px)) to cap card size on wide viewports
- room.js: add resize:end listeners for scaleTable + sizeSigModal; new IIFE dispatches
  resize:end 500ms after resize stops so both functions re-measure settled layout
- tray.js: extract _reposition() from inline resize handler; wire to both resize and
  resize:end so tray repositions correctly after rapid resize or orientation change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-06 00:48:25 -04:00
Disco DeDisco
e28d55ad58 remove obsolete sig-select FTs (S1/S3/S4) based on old sequential 36-card design
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
The new sig-select has two parallel 18-card overlays per polarity group (levity:
PC/NC/SC; gravity: BC/EC/AC) — no shared 36-card deck, no active-seat turn order.
S1 (36 cards), S3 (PC picks → deck shrinks → active advances to NC), and S4
(non-active seat blocked) all tested the old design and have been failing in CI.
S2 (seat display order) passed and is kept. Header comment updated to match.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:44:54 -04:00
Disco DeDisco
b110bb6d01 remove obsolete skipped tests; fix billboard applet menu containment; align landscape menus
Deleted skips:
- test_fan_next_button_advances_card (T11) + test_fan_remembers_position_on_reopen (T13):
  fan-nav nav button obstruction — deferred indefinitely, not worth tracking
- test_selected_sig_card_removed_from_deck_for_other_gamers (S5): card count
  mismatch in channels context — grand overhaul pending, obsolete with new sig-select
- Removed stale TODO comment about #id_inv_sig_card (element no longer exists)
- Dropped unused `import unittest` from test_room_sig_select.py

billboard applet menu fix: moved #id_billboard_applet_menu out of
#id_billboard_applets_container — container-type:inline-size was making the
container a containing block for fixed-position descendants, clipping the menu.

Landscape menu alignment: all applet menus now right:0.5rem (flush with gear/kit
buttons in the 4rem right sidebar); added #id_room_menu to the landscape rule.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:33:13 -04:00
Disco DeDisco
2892b51101 fix SigSelect Jasmine: return test API from IIFE; pend touch specs on desktop
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
window.SigSelect was being clobbered by the IIFE's undefined return value
(var SigSelect = (function(){...window.SigSelect={...}}()) overwrites window.SigSelect
with undefined). Fixed by using return {} like RoleSelect does.

TouchEvent is not defined in desktop Firefox, so the 5 touch-related specs now
call pending() when the API is absent rather than throwing a ReferenceError.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:14:56 -04:00
Disco DeDisco
871e94b298 sig-select landscape: stage card now visible; gear/kit btns in right sidebar column
sizeSigModal() no longer uses tray bottomInset in landscape (was over-shrinking the
modal, pushing the stage off-screen); fixed 60px kit-bag-handle clearance instead.
Gear btn + kit btn shifted into the 4rem right sidebar strip (right: 0.5rem) and
nudged down a quarter-rem so they clear the last card in the 9×2 grid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 23:02:32 -04:00
Disco DeDisco
c3ab78cc57 many deck changes, including pentacles to crowns, middle arcana, and major arcana fa icons 2026-04-05 22:32:40 -04:00
Disco DeDisco
c7370bda03 sig-select sprint: SigReservation model + sig_reserve view (OK/NVM hold); full sig-select.js rewrite with stage preview, WS hover cursors, reservation lock (must NVM before OK-ing another card — enforced server-side 409 + JS guard); sizeSigModal() + sizeSigCard() in room.js (JS-based card sizing avoids libsass cqw/cqh limitation); stat block hidden until OK pressed; mobile touch: dismiss stage on outside-grid tap when unfocused; 17 IT + Jasmine specs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 22:01:23 -04:00
Disco DeDisco
a15d91dfe6 wrapped the _gatekeeper.html partial modal to split each function into four different panels; removed deviant landscape styling to unify it with default styling (much more robust now)
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-05 19:10:02 -04:00
Disco DeDisco
fecb1fddca restored position circles to their top attr value to avoid old clipping-under-h2 issue; pushed down gatekeeper modal in room.html 2026-04-05 18:32:45 -04:00
Disco DeDisco
2028f1a544 more refinements to Earthman deck names and allegories; tweaks to navbar alignment in landscape media queries
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-05 17:23:51 -04:00
Disco DeDisco
40c747a837 landscape navbar centering: reset portrait margin-right on .container-fluid + margin-left on .navbar-brand so sidebar contents align to horizontal centre; showGuard gains invertY option for modal-grid callers (role-select cards fly away from centre); gameboard.js showPortals gains viewport-half detection so game-kit tooltips show below when tokens are in upper half (landscape clip fix); position-strip top: 0; tighten gear-btn btn-abandon selector to #id_room_menu scope
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 16:54:03 -04:00
Disco DeDisco
40a55721ab major navbar overhaul: .btn-primary.btn-xl now reads CONT GAME and links to the user's most recently active game; log out functionality transferred to new BYE .btn-abandon abutting login spans; tooltips for each asserted via new FTs.test_navbar methods to appear w.in visible area 2026-04-05 16:00:52 -04:00
Disco DeDisco
d4518a0671 fixed jasmine & RoleSelectTest FT methods that were failing due to the Role card reordering in previous pipeline push
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-05 01:52:30 -04:00
Disco DeDisco
74f63a7721 rearranged Role select cards for final presentation ordering; unified Role select tooltip appearance; bottom row of Role select tooltips now appears below bottom row, not layered atop top row; clicking out of one Role select card tooltip and onto another Role select card specifically opens the next tooltip (former behavior made user click once to exit old tooltip, once more to open new one)
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-05 01:23:20 -04:00
Disco DeDisco
bd3d7fc7bd role-select.js ensures Role select card stack disappears via WS upon conclusion of Role selection, w. if-conditional support from apps.epic.views; ensured border present on card-stack when .active in _room.scss; changed default #id_tray to unhidden, only hidden during Role select until Role selected; polished & unified Role .card-front, .card.back & .card-stack styling 2026-04-05 01:14:31 -04:00
Disco DeDisco
c00288e256 another #id_pick_sigs_btn IT fix
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-04 15:10:48 -04:00
Disco DeDisco
b5de96660a fix to pipeline involving new #id_pick_sigs_btn css selector
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-04 15:05:55 -04:00
Disco DeDisco
96bb05a4ba fixed some failing jasmine tests stemming from previous commit
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-04 14:54:54 -04:00
Disco DeDisco
4e07fcf38b fixed several animation & transition problems plaguing the inventory tray 2026-04-04 14:51:49 -04:00
Disco DeDisco
b74f8e1bb1 pick_sigs view + cursor polarity groups; game kit gear menu; housekeeping
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
roles_revealed WS event removed; select_role last pick now fires _notify_all_roles_filled() + stays in ROLE_SELECT; new pick_sigs view (POST /room/<uuid>/pick-sigs) transitions ROLE_SELECT→SIG_SELECT + broadcasts sig_select_started; room.html shows .pick-sigs-btn when all 6 roles filled; PICK SIGS btn absent during mid-selection; 11 new/modified ITs in SelectRoleViewTest + RoomViewAllRolesFilledTest + PickSigsViewTest

consumer: LEVITY_ROLES {PC/NC/SC} + GRAVITY_ROLES {BC/EC/AC}; connects to per-polarity cursor group (cursors_{id}_levity/gravity); receive_json routes cursor_move to cursor group; new handlers all_roles_filled, sig_select_started, cursor_move; CursorMoveConsumerTest (TransactionTestCase, @tag channels): levity cursor reaches fellow levity player, does not reach gravity player

game kit gear menu: #id_game_kit_menu registered in _applets.scss %applet-menu + fixed-position + landscape offset; id_gk_sections_container added to appletContainerIds in applets.js so OK submit dismisses menu; _game_kit_sections.html sections use entry.applet.grid_cols/grid_rows (was hardcoded 6); %applets-grid applied to #id_gk_sections_container (direct parent of sections, not outer wrapper); FT setUp seeds gk-* applets via get_or_create

drama test reorg: integrated/test_views.py deleted (no drama views); two test classes moved to epic/tests/integrated/test_views.py + GameEvent import added; drama/tests/unit/test_models.py → drama/tests/integrated/test_models.py; unit/ dir removed

login form: position:fixed + vertically centred in base styles across all breakpoints; 24rem width, text-align:center; landscape block reduced to left/right sidebar offsets; alert moved below h2; left-side position indicator slots 3/4/5 column order flipped via CSS data-slot selectors

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 14:33:35 -04:00
Disco DeDisco
188365f412 game kit gear menu + login form UX polish; left-side position indicator flip
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
game kit: new Applet model rows (context=game-kit) for Trinkets, Tokens, Card Decks, Dice Sets via applets migration 0008; _game_kit_context() helper in gameboard.views; toggle_game_kit_sections view + URL; new _game_kit_sections.html (HTMX-swappable, visibility-conditional) + _game_kit_applet_menu.html partials; game_kit.html wired to gear btn + menu; Dice Sets now renders _forthcoming.html partial; 16 new green ITs in GameKitViewTest + ToggleGameKitSectionsViewTest

login form: .input-group now position:fixed + vertically centred (top:50%) across all breakpoints as default; landscape block reduced to left/right sidebar offsets only; form-control width 24rem, text-align:center; alert block moved below h2 in base.html; alert margin 0.75rem all sides; home.html header switches between Howdy Stranger (anon) and Dashboard (authed)

room.html position indicators: slots 3/4/5 (AC/SC/EC) column order flipped via SCSS data-slot selectors so .fa-chair sits table-side and label+status icon sit outward

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 13:49:48 -04:00
Disco DeDisco
824f35590b minor styling fixes
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-03 14:55:37 -04:00
Disco DeDisco
43cb84e8f4 updated assertions in FTs.test_billboard to match the refined prose rendering from last commit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 22:31:29 -04:00
Disco DeDisco
afe8e2b32c tweaked some prose templating in apps.drama.models; updated _applet-billboard-most-recent.html partial to mirror new row-by-row styling & html structure of room_scroll.html
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-02 15:38:44 -04:00
Disco DeDisco
ca38875660 fixed reverse chronological ordering in a pair of FTs clogging the pipeline; added ActivityPub to project; new apps.ap for WebFinger, Actor, Outbox views; apps.lyric.models now contains ap_public_key, ap_private_key fields + ensure_keypair(); new apps.lyric migration accordingly; new in drama.models are to_activity() w. JoinGate, SelectRole, Create compat. & None verb support; new core.urls for /.well-known/webfinger + /ap/ included; cryptography installed, added to reqs.txt; 24 new green UTs & ITs; in sum, project is now read-only ActivityPub node
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 15:22:04 -04:00
Disco DeDisco
8538f76b13 new core.middleware sets cookie for scroll timestamp view to local browser time, w. new corresponding tests in core.tests.UTs.test_middleware; apps.lyric.templatetags.lyric_extras determines timestamp format based on duration elapsed since timestamp; apps.bill.tests.ITs.test_views renamed, now also asserts scroll renders event body and time in columns
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-02 14:51:08 -04:00
Disco DeDisco
2a7d4c7410 new migrations in apps.epic for further refinements made to Pope card nomenclature
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 14:15:00 -04:00
Disco DeDisco
ed10e58383 small tweaks to h2 text-shadow attr rootvars values
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-02 00:00:14 -04:00
Disco DeDisco
b65cba5ed2 wrapped room table in .room-table-scene div, built styles and scripts to ensure table scales w. available viewport or aperture space
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-01 23:24:17 -04:00
Disco DeDisco
afe79f1a48 other minor styling fixes for gatekeeper modal, position circles 2026-04-01 23:12:49 -04:00
Disco DeDisco
0e5e39b0dc ensured .fa-ban next to empty seat changes to .fa-circle-check at the same time that .fa-chair glows & the pos circle fades out (i.e., when the gamer 'sits') not during or after the role card deposits itself in the tray; minor styling fixes for title h2, incl. text-shadow attr values when selected palette ends in *-light & opacity increases
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-01 22:11:43 -04:00
Disco DeDisco
4860b6ee2a real fix this time, rule overridden last time
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-01 15:41:19 -04:00
Disco DeDisco
c025a38709 small pipeline z-index hierarchy fix
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-01 15:30:20 -04:00
Disco DeDisco
581ea7e349 stopped card deck nav arrows from inheriting global .btn box-shadow attrs
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-01 15:14:19 -04:00
Disco DeDisco
596175cd1c refined _room.scss styles, incl. .launch-game-btn & .gate-slot
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-01 15:10:20 -04:00
Disco DeDisco
1aaf353066 renamed the Popes/0-card trumps from Earthman deck (feat. new apps.epic migrations to reseed); fixes to card deck horizontal scroll speed, game_kit.html, to make scrolling feel more natural 2026-04-01 14:45:53 -04:00
Disco DeDisco
441def9a34 skipped lowlevel grid cell assertion FT clogging pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-31 00:08:35 -04:00
Disco DeDisco
736b59b5c0 role-select UX: tray timing delays, seat/circle state polish, 394 ITs green
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- _animationPending set before fetch (not in .then()) — blocks WS turn advance during in-flight request
- _placeCardDelay (3s) + _postTrayDelay (3s) give gamer time to see each step; both zeroed by _testReset()
- .role-confirmed class: full-opacity chair after placeCard completes; server-rendered on reload
- Slot circles disappear in join order (slot 1 first) via count-based logic, not role-label matching
- data-active-slot on card-stack; handleTurnChanged writes it for selectRole() to read
- #id_tray_wrap not rendered during gate phase ({% if room.table_status %})
- Tray slide/arc-in slowed to 1s for diagnostics; wobble kept at 0.45s
- Obsolete test_roles_revealed_simultaneously FT removed; T8 tray FT uses ROLE_SELECT room
- Jasmine macrotask flush pattern: await new Promise(r => setTimeout(r, 0)) after fetch .then()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-31 00:01:04 -04:00
Disco DeDisco
a8592aeaec hex position indicators: chair icons at hex edge midpoints replace gate-slot circles
- Split .gate-overlay into .gate-backdrop (z-100, blur) + .gate-overlay modal (z-120) so .table-position elements (z-110) render above backdrop but below modal
- New _table_positions.html partial: 6 .table-position divs with .fa-chair, role label, and .fa-ban/.fa-circle-check status icons; included unconditionally in room.html
- New epic:room view at /gameboard/room/<uuid>/; gatekeeper redirects there when table_status set; pick_roles redirects there
- role-select.js: adds .active glow to position on selectRole(); swaps .fa-ban→.fa-circle-check in placeCard onComplete; handleTurnChanged clears stale .active from all positions
- FTs: PositionIndicatorsTest (5 tests) + RoleSelectTest 8a/8b (glow + check state)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 18:31:05 -04:00
Disco DeDisco
8b006be138 demo'd old inventory area in room.html to make way for new content (hex table now centered in view); old test suite now targets Role card in #id_tray cells where appropriate, or skips Sig card select until aforementioned new feature deployed; new scripts & jasmine tests too; removed one irrelevant test case from apps.epic.tests.ITs.test_views.SelectRoleViewTest 2026-03-30 16:42:23 -04:00
Disco DeDisco
299a806862 fixed open #id_tray obscuring role select FTs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 23:46:23 -04:00
Disco DeDisco
fb782cf5ef maybe don't delete collectstatic static/tests/ dir 2026-03-29 23:39:03 -04:00
Disco DeDisco
224f5e2ad0 fixed Inferno palette --priUser rootvar hue
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 22:57:29 -04:00
Disco DeDisco
96379934d7 trying to reset to get this pipe clear
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 22:33:42 -04:00
Disco DeDisco
29a5658b01 'channels' tag now also moved to sequential FT group in pipeline; role-select.js ensures Tray.close() before turn advances so as not to obstruct next gamer selection; RoleSelectSpec.js asserrts this functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 22:08:59 -04:00
Disco DeDisco
73135df7a6 skipped thorny failing FTs; separated out 'two-browser' tag to run before FTs–proper in pipeline for faster fail states
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 21:39:20 -04:00
Disco DeDisco
57f47cc77e another attempt to unclog pipeline; this time a slight sleep timeout used to accomodate headless browser resize flush
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 21:11:24 -04:00
Disco DeDisco
5d21e79be5 more headless patches to address pipeline clog; 'two-browsers' may not have been doing anything before
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 20:41:26 -04:00
Disco DeDisco
ff0883002b added one more FT to the 'two-browser' tag'; for real this might actually unclog the pipeline this time
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 20:04:57 -04:00
Disco DeDisco
7f927741d4 oops, forgot the normal .grid-cell styles, had only updated the landscape media query & not the base condition
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 19:46:59 -04:00
Disco DeDisco
3bf48546e3 tagged some further tests as 'two-browser' in persisting attempt to unclog pipeline fails; _tray.scss .grid-cell border-color changed
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 19:43:48 -04:00
Disco DeDisco
6817323f8e further tweaked sepia palette; shored up TestTray for headless browser pipeline testing
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 19:10:42 -04:00
Disco DeDisco
11283118d6 small rootvars hue changes to sepia palette (should rename to 'cedar'); new FTs skipped via unittest to try to unclog pipeline fails
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 18:35:20 -04:00
Disco DeDisco
6c91ec0385 expanded margin of position spots on gatekeeper; cleaned up #id_tray scripts & styles
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-29 15:22:00 -04:00
Disco DeDisco
39db59c71a styles related to #id_tray & apparatus separated out into _tray.scss; new tray.js computes the cell size of the tray grid for item organization; room.html now sports the grid as a separate div so as not to interfere w. tray styling or size; new tests in FTs.test_room_tray 2026-03-29 13:36:44 -04:00
Disco DeDisco
5f643350c5 unskipped certain passing FTs
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 01:21:33 -04:00
Disco DeDisco
ab41797e57 refined styling for #id_tray & .table-hex, which now mirror ea. other visually as parts of a befelted table
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-29 00:48:19 -04:00
Disco DeDisco
e35855f472 fixed wobble timing condition to be slow enough for headless firefox to catch it
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-03-28 23:50:08 -04:00
Disco DeDisco
0e5805efd2 'two-browser' tag separates out tests that run multiple browsers in pipeline so that --parallel tests don't interfere w. loading of one or more of such windows; both FTs.test_sharing & woodpecker.yaml updated accordingly
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-28 23:14:31 -04:00
Disco DeDisco
de99b538d2 FTs.test_room_tray.TrayTest now contains setUp() helper to set default window size for methods which don't otherwise define a specific media query; several new Jasmine methods test drawer snap-to-close & wobble functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-03-28 22:50:43 -04:00
Disco DeDisco
c08b5b764e new landscape styling & scripting for gameroom #id_tray apparatus, & some overall scripting & styling like wobble on click-to-close; new --undUser & --duoUser rootvars universally the table felt values; many new Jasmine tests to handle tray functionality 2026-03-28 21:23:50 -04:00
Disco DeDisco
d63a4bec4a new .active styling to #id_tray_btn, _handle & _grip whenever drawer is open 2026-03-28 19:06:09 -04:00
Disco DeDisco
b35c9b483e seat tray: tray.js, SCSS, FTs, Jasmine specs
- new apps.epic.static tray.js: IIFE with drag-open/click-close/wobble
  behaviour; document-level pointermove+mouseup listeners; reset() for
  Jasmine afterEach; try/catch around setPointerCapture for synthetic events
- _room.scss: #id_tray_wrap fixed-right flex container; #id_tray_handle +
  #id_tray_grip (box-shadow frame, transparent inner window, border-radius
  clip); #id_tray_btn grab cursor; #id_tray bevel box-shadows, margin-left
  gap, height removed (align-items:stretch handles it); tray-wobble keyframes
- _applets.scss + _game-kit.scss: z-index raised (312-318) for primacy over
  tray (310)
- room.html: #id_tray_wrap + children markup; tray.js script tag
- FTs test_room_tray: 5 tests (T1-T5); _simulate_drag via execute_script
  pointer events (replaces unreliable ActionChains drag); wobble asserts on
  #id_tray_wrap not btn
- static_src/tests/TraySpec.js + SpecRunner.html: Jasmine unit tests for
  all tray.js branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 18:52:46 -04:00
281 changed files with 42829 additions and 2786 deletions

3
.gitignore vendored
View File

@@ -183,3 +183,6 @@ cython_debug/
#.idea/
# End of https://www.toptal.com/developers/gitignore/api/django
# Local dev utilities (Windows-only, not part of the app)
*.ps1

View File

@@ -22,6 +22,34 @@ steps:
- python manage.py test apps
when:
- event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: test-two-browser-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
environment:
HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
STRIPE_SECRET_KEY:
from_secret: stripe_secret_key
STRIPE_PUBLISHABLE_KEY:
from_secret: stripe_publishable_key
commands:
- pip install -r requirements.txt
- cd ./src
- python manage.py collectstatic --noinput
- python manage.py test functional_tests --tag=two-browser
- python manage.py test functional_tests --tag=sequential
- python manage.py test functional_tests --tag=channels
when:
- event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: test-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
@@ -37,10 +65,13 @@ steps:
- pip install -r requirements.txt
- cd ./src
- python manage.py collectstatic --noinput
- python manage.py test functional_tests --parallel --exclude-tag=channels
- python manage.py test functional_tests --tag=channels
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
when:
- event: push
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
@@ -49,6 +80,10 @@ steps:
when:
- event: push
status: failure
path:
- "src/**"
- "requirements.txt"
- ".woodpecker/main.yaml"
- name: build-and-push
image: docker:cli
@@ -62,8 +97,13 @@ steps:
when:
- branch: main
event: push
path:
- "src/**"
- "requirements.txt"
- "Dockerfile"
- ".woodpecker/main.yaml"
- name: deploy
- name: deploy-staging
image: alpine
environment:
SSH_KEY:
@@ -77,4 +117,23 @@ steps:
when:
- branch: main
event: push
path:
- "src/**"
- "requirements.txt"
- "Dockerfile"
- "infra/**"
- ".woodpecker/main.yaml"
- name: deploy-prod
image: alpine
environment:
SSH_KEY:
from_secret: deploy_ssh_key
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
when:
- event: tag

33
.woodpecker/pyswiss.yaml Normal file
View File

@@ -0,0 +1,33 @@
steps:
- name: test-pyswiss
image: python:3.13-slim
environment:
SWISSEPH_PATH: /tmp/ephe
commands:
- apt-get update -qq && apt-get install -y -q gcc g++
- pip install -r pyswiss/requirements.txt
- cd ./pyswiss
- python manage.py test apps.charts
when:
- event: push
path:
- "pyswiss/**"
- ".woodpecker/pyswiss.yaml"
- name: deploy-pyswiss
image: alpine
environment:
SSH_KEY:
from_secret: pyswiss_deploy
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh -o StrictHostKeyChecking=no discoman@167.172.154.66 /home/discoman/deploy.sh
when:
- branch: main
event: push
path:
- "pyswiss/**"
- ".woodpecker/pyswiss.yaml"

129
CLAUDE.md
View File

@@ -3,43 +3,10 @@
Originally built following Harry Percival's *Test-Driven Development with Python* (3rd ed., complete through ch. 25). Now an ongoing game app — EarthmanRPG — extended well beyond the book.
## Browser Integration
**Claudezilla** is installed — a Firefox extension + native host that lets Claude observe and interact with the browser directly.
**Claudezilla** is installed — a Firefox extension + native host for browser automation.
See `.claude/skills/claudezilla-browser/SKILL.md` for tool list, startup protocol, and setup reference.
### Tool names
Tools are available as `mcp__claudezilla__firefox_*`, e.g.:
- `mcp__claudezilla__firefox_screenshot` — capture current tab
- `mcp__claudezilla__firefox_navigate` — navigate to URL
- `mcp__claudezilla__firefox_get_page_state` — structured JSON (faster than screenshot)
- `mcp__claudezilla__firefox_create_window` — open new tab (returns `tabId`)
- `mcp__claudezilla__firefox_diagnose` — check connection status
- `mcp__claudezilla__firefox_set_private_mode` — disable private mode to use session cookies
All tools require a `tabId` except `firefox_create_window` and `firefox_diagnose`.
### If tools aren't available in a session
MCP servers load at session startup only. **Start a new Claude Code conversation** (hit "+" in the sidebar) — no need to reboot VSCode, just open a fresh chat. Always call `firefox_diagnose` first to confirm the connection is live.
### Correct startup sequence
1. Firefox open with Claudezilla extension active (native host must be running)
2. Open a new Claude Code conversation → tools appear as `mcp__claudezilla__firefox_*`
3. Call `firefox_diagnose` to confirm before depending on any tool
### Setup (already done — for reference)
The native messaging host requires a `.bat` wrapper on Windows (Firefox can't execute `.js` directly):
- Wrapper: `E:\ClaudeLibrary\claudezilla\host\claudezilla.bat` — contains `@echo off` / `node "%~dp0index.js" %*`
- Manifest: `C:\Users\adamc\AppData\Roaming\claudezilla\claudezilla.json` — points to the `.bat` file
- Registry: `HKCU\SOFTWARE\Mozilla\NativeMessagingHosts\claudezilla` → manifest path
- MCP server: registered in `~/.claude.json` (NOT `~/.claude/settings.json` or `~/.claude/mcp.json`) — use the CLI to register:
```
claude mcp add --scope user claudezilla "D:/Program Files/nodejs/node.exe" "E:/ClaudeLibrary/claudezilla/mcp/server.js"
```
- Permission: `mcp__claudezilla__*` in `~/.claude/settings.json` `permissions.allow`
**Config file gotcha:** The Claude Code CLI and VSCode extension read user-level MCP servers from `~/.claude.json` (home dir, single file) — NOT from `~/.claude/settings.json` or `~/.claude/mcp.json`. Always use `claude mcp add --scope user` to register; never hand-edit. Verify registration with `claude mcp list`.
**BOM gotcha:** PowerShell writes JSON files with a UTF-8 BOM, which causes `JSON.parse` to throw. Never use PowerShell `Set-Content` to write any Claude config JSON — use the Write tool or the CLI instead.
Native host: `E:\ClaudeLibrary\claudezilla\host\`. Extension: `claudezilla@boot.industries`.
**STARTUP RULE:** Call `mcp__claudezilla__firefox_diagnose` at the start of every conversation before any browser tool. If tools aren't listed in a session, open a new Claude Code conversation (MCP servers load at startup only).
## Stack
- **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn)
@@ -87,49 +54,66 @@ Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory.
cd src
uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --reload --app-dir src
# Integration + unit tests only (from project root — targets src/apps, skips src/functional_tests)
python src/manage.py test src/apps
# Integration + unit tests (exclude channels)
python src/manage.py test src/apps --exclude-tag=channels
# Functional tests only
# Functional tests
python src/manage.py test src/functional_tests
# All tests (integration + unit + FT)
python src/manage.py test src
```
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
See `.claude/skills/TDD/SKILL.md` for the full TDD cycle, test file conventions, base classes, and per-layer run commands. See `.claude/skills/dev-server/SKILL.md` for server startup options.
FTs are isolated by **directory path** (`src/functional_tests/`), not by tag.
### Multi-user manual testing — `setup_sig_session`
Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic.
Creates (or reuses) a room at `table_status=SIG_SELECT` with all 6 slots filled. Prints one pre-auth URL per gamer.
## CI/CD
```bash
python src/manage.py setup_sig_session
python src/manage.py setup_sig_session --base-url http://localhost:8000
python src/manage.py setup_sig_session --room <uuid>
```
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all superusers with Earthman deck. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
## CI/CD + Hosting
- Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`)
- Gitea: `https://gitea.earthmanrpg.me` | Woodpecker CI: `https://ci.earthmanrpg.me`
- Push to `main` triggers Woodpecker → deploys to staging
- Push to `main` triggers Woodpecker → deploys to staging (`staging.earthmanrpg.me`)
- Prod deploy: `git tag v1.0.0 && git push --tags` → triggers `deploy-prod` step (tag-based gate)
- Two CI pipelines run in parallel: `.woodpecker/main.yaml` (main app) + `.woodpecker/pyswiss.yaml` (PySwiss at charts.earthmanrpg.me)
- Multi-browser FTs tagged `@tag("two-browser")` run in a dedicated CI stage (`test-two-browser-FTs`) alongside `--tag=channels`; `test-FTs` stage is parallel-only
- Hosting: DigitalOcean — main app on staging droplet; PySwiss on separate droplet (167.172.154.66)
- Email: Mailgun (`adman@howdy.earthmanrpg.me`) | DNS: NameCheap
## UI / Layout Conventions
### Sidebar layout (`$sidebar-w: 4rem`)
Navbar is a fixed left sidebar; footer is a fixed right sidebar. Both are `4rem` wide. Main container uses `margin-left: $sidebar-w; margin-right: $sidebar-w`. Landscape layout resets `min-width` to `0` on `.gameboard-page` and `#id_dash_content` (override of the `@media (min-width: 738px)` block that sets `min-width: 666px`).
### Applet headings + page titles
- Section headings: plain `<h2>` — browser default + body color inherited; no extra SCSS needed
- Clickable headings: `<h2><a href="...">Text</a></h2>` — global `body a` rule supplies gold + hover glow
- Page titles: `<span>Dash</span>suffix` pattern (Dashwallet, Dashnote, Dashnotes)
### Position vs Seat terminology
Circles around the table hex are **positions** (gate slot order, 16). After role assignment they become **seats** (PC→NC→EC→SC→AC→BC). CSS carries both: `.table-seat.table-position`. `SLOT_ROLE_LABELS = {1:"PC", 2:"NC", 3:"EC", 4:"SC", 5:"AC", 6:"BC"}` in `epic/views.py`.
## Game Architecture
### Token priority chain
`select_token(user)` in `apps.epic.models`: **PASS → COIN → FREE → TITHE → None**. `debit_token` handles each type's consumption rules (Coin cooldown, Free/Tithe expiry).
### Two-step gate token flow
Drop → RESERVED → confirm/reject. `_gate_context()` builds slot state; `_expire_reserved_slots()` clears stale reservations after 60s. Views: `confirm_token`, `reject_token` (renamed `return_token`).
### Room URL routing
`epic:room` view at `/gameboard/room/<uuid>/`. `gatekeeper` redirects there when `table_status` is set. Error redirects in `select_role`/`select_sig` use `epic:room` if `table_status` is set, else `epic:gatekeeper`.
## SCSS Import Order
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → billboard → game-kit → wallet-tokens`
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens`
## Critical Gotchas
### TransactionTestCase flushes migration data
FTs use `TransactionTestCase` which flushes migration-seeded rows. Any IT/FT `setUp` that needs `Applet` rows must call `Applet.objects.get_or_create(slug=..., defaults={...})` — never rely on migration data surviving.
### Static files in tests
`StaticLiveServerTestCase` serves via `STATICFILES_FINDERS` only — NOT from `STATIC_ROOT`. JS/CSS that lives only in `src/static/` (STATIC_ROOT, gitignored) is a silent 404 in CI. All app JS must live in `src/apps/<app>/static/` or `src/static_src/`.
### msgpack integer key bug (Django Channels)
`channels_redis` uses msgpack with `strict_map_key=True` — integer dict keys in `group_send` payloads raise `ValueError` and crash consumers. Always use `str(slot_number)` as dict keys.
### Multi-browser FTs in CI
Any FT opening a second browser must pass `FirefoxOptions` with `--headless` when `HEADLESS` env var is set. Bare `webdriver.Firefox()` crashes in headless CI with `Process unexpectedly closed with status 1`.
### Selenium + CSS text-transform
Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` causes `assertIn("Test Room", body.text)` to fail. Assert against the uppercased string or use `body.text.upper()`.
### Tooltip portal pattern
`mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`.
@@ -144,5 +128,16 @@ Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase`
- Task unit tests: `apps.lyric.tasks.requests.post`
- FTs: mock both with `side_effect=send_login_email_task`
## Teaching Style
User prefers guided learning: describe what to type and why, let them write the code, then review together. But for now, given user's accelerated timetable, please respond with complete code snippets for user to transcribe directly.
### game-kit.js selection persistence
`window._kitTokenId` must NOT be cleared on kit-bag close — users close the dialog before clicking the rails button. Selection persists until page navigation. No `clearSelection()` in `game-kit.js`.
### Billboard timezone cookie
`document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone`**no `encodeURIComponent`**. Slashes in TZ names (`America/New_York`) are cookie-safe; encoding breaks the `ZoneInfo` lookup in `TimezoneMiddleware`.
### CSS `:has()` for child-dependent styling
Use `.parent:has(.child-class)` to style a parent based on its contents without template changes. Example: `.gate-slot:has(.drop-token-btn)` makes CARTE OK-button circles match `.reserved` circles.
### Plausible FT noise
Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag.
See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys).

View File

@@ -140,6 +140,7 @@
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
PYSWISS_URL: "{{ pyswiss_url }}"
networks:
- name: gamearray_net
ports:
@@ -160,6 +161,7 @@
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
PYSWISS_URL: "{{ pyswiss_url }}"
networks:
- name: gamearray_net
command: "python -m celery -A core worker -l info"

View File

@@ -9,4 +9,5 @@ STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }}
STRIPE_SECRET_KEY={{ stripe_secret_key }}
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
REDIS_URL=redis://gamearray_redis:6379/1
PYSWISS_URL=https://charts.earthmanrpg.me

View File

@@ -1,42 +1,44 @@
$ANSIBLE_VAULT;1.1;AES256
38383061343764656262613934313230656462366163363263653462333338333863326338343838
3664646437643462346636623231633639396239333532340a363338313839353734326238643735
39343237396433336436366430626332343666666461613636656433363838613432393539386266
3237336434346333350a663530623334633438616135376437666631313064333735653633396461
31306163343838336465626663373661343839653037333235313361633335646337353339616333
35343233346562346236636364316265313936646235373866636333353866623161663935626637
31633864366339653930626365373237326531366632626337636163333266656434323063333365
38373437383261613439306666373764633737623466626235356465636365646337306534326535
36633866663161613632613434666134343465383663633165663330376535653537333763376232
61653265303134656338393033303834663630653064666134633638393235346631346461633030
35343332393961363361613661633633613262663231366236396663636239326534373134623762
30653139333134616236666238616466633733656633326331386138363839653566333434346534
63326539333461383265316332336333656365386531393630663537363365643061363263313738
37633564363533633762393736636333306433306534393539636231656162343562383232663932
62646339363266303564383438636636373661656465666663613863396639633732636635326166
39323738303338373466366236623665633538363134616565326665386564613735393638656630
31326431316163376132623064376634643737313864336464623431333834663361336133353838
32303635663261333732306137383133623134373363613837306637663566303634653863343766
33613936626362653466333537666462373633313038376565623363666631353162643634653730
30323532623261643136666237316561353038323265303930336364633731333533386563623133
31343965643336613933663431626435333235366639363334653065303434386165333739336632
61363030376664643638653365626365623936623864666663326534343863613962616431376666
39363837386639393235316339323932326466616330303165613032663637616232656162653335
61613266376262626234383135306238313366346330656333383465383861663962653638303362
34353833646461383839386238626661346263363131643438343461393739336132386466373665
32646238633161363064666335626639653335306236613866333934646366323564306133396131
36343032623964316138386538333863363530396330646431373466646538663063326330663639
32323762356632336364333162336133336335623865323861663131626232633066643238333237
32343938353166353037316162653832663433343534626331633936633866356666653932656665
38396533356131326262633431653435306362633966383531356236396639376437396333616130
35666435393461316232323234653865346338326330623065373461323961393663306262313066
30313430353065616230356135333565333338373663643434353561363438656233383739663233
35653832353062396634613832353837333835636461616234343462626239636634613430373931
31656534343764643065643733326637343631356633653531313062633362663461313732633331
35626364393563373339636466346339383032383635303865306636623737343237333863353238
63306132396262656365323833323635633563653735366630313363386236613231346339643430
63396230353566633830383932666335373665356434656438336338633035653465613665613862
31663565653338376662323866613538363566306635333735646363363730646331306234353839
30346363393231623563646439623261643634663831313338393761343865303930373133633733
31656466303365316164396463373335396464643130643337656361333339653238333633373662
6539
33643937613637343765356165333337356138326236356334363238366632633935363563383232
6263396663316461353035393836313535353133336132650a643062656239633635373930366131
63363566666263336337356161663231343266383333613261666534653438666661303761653063
6163333239313430620a613665393231356535666530613731303536613537333464613533616663
30373935366138643939316563346364376333646333396264653537643666393835353964303031
30366366666163383263663961383037386264393939306235646532636439383838343237303339
62333965323763323233303239343132383830303130306265333330333434663337363930653161
30646133333530333330653365306437313839636535333163346263343064376436633432623061
39343332643836333932316439636166333831393864363434663837646339666638353835393964
61363430303637633239373031396535383730623862386464316633393361306561613933353830
66313835306563643733366135353062623635663165303833373563663063323731313162323133
61373837353732656266336461663165626435383234336461343365396561623037353566356339
32366336396638626166616362613230323933666565613561393431393035376465343739333739
36313934313636386465306435353132373364653562666162613033373130623430656632396635
39373437353838313734636166323336376534373765623332356638666234376464383033326433
33636336376231313062643237636534363838326264333930383635373761346532393664363038
34633334653464313430363735666435373535363465343134333636303536303265333931343138
35633864623930386661316264383865373930316233653238323437363836643236333236336537
37353565313434383733333861626566623363316335666230373435633163356566616366663339
64323533366265396164303937323036323037383637643332326361363864333334653232376134
33346366343865336437383138396639393238353633343562356435306537633830303361333730
30386133396565613539653931663961303534613566626265376135386461383162396334393733
39343466336136643565656332336562643933383330343830633264396436383065373032646664
34643939613962653137303238663535633565363961336263316631313737663036336331663133
61323538376434396432633565613135376163636233373832366461353665633266373435396436
30376539366264306661353863313165323839646536393466623838393862396530326466363936
36373865316165393665353737643561663863353630373333313936653163386136623831396637
36306236626337303561376366376639613337396136313336383131303634623364316234376432
65383362346363336639366665333436346234383566643937643130363261656662653763313639
66396162356234343163633633376639623736643066643030626232633634616261303530623032
38393032643963386133393534616133396135303531333839643063613331643334323762653933
39646234366564333935366335363964666337383264333263326561636231303164356532323163
63323430363337353339353739363638366136326231666335343830363838663366613432303735
34323431343336643566346365333062363862646138396535633036653737643462323235326265
39306336396238653063353939613966323466306335346635353964613535313961353263303235
35646330366534386330333135316437313435376331343630643330323030626432343034323861
39363437333137386137323036333336613238613530316338343930616137666261383733653432
63316266323664396335363334663465636262663366346139383535626236653765323038343366
64386639373536306638323036386364373465313037393431663965646633613838303566663139
31663162313166636262313663363061666531636432366536343063336439636465663032356563
30656562336565303237663332303230306637353465616136346233636464616666383734303938
32666466366363346232653461333263366164313130336331326339366361326139636635646630
376264626331393262653961663566383866

View File

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class ChartsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.charts'

178
pyswiss/apps/charts/calc.py Normal file
View File

@@ -0,0 +1,178 @@
"""
Core ephemeris calculation logic — shared by views and management commands.
"""
from django.conf import settings as django_settings
import swisseph as swe
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
SIGNS = [
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
]
SIGN_ELEMENT = {
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
}
ASPECTS = [
('Conjunction', 0, 8.0),
('Semisextile', 30, 4.0),
('Sextile', 60, 6.0),
('Square', 90, 8.0),
('Trine', 120, 8.0),
('Quincunx', 150, 5.0),
('Opposition', 180, 10.0),
# ('Semisquare', 45, 4.0),
# ('Sesquiquadrate', 135, 4.0),
]
PLANET_CODES = {
'Sun': swe.SUN,
'Moon': swe.MOON,
'Mercury': swe.MERCURY,
'Venus': swe.VENUS,
'Mars': swe.MARS,
'Jupiter': swe.JUPITER,
'Saturn': swe.SATURN,
'Uranus': swe.URANUS,
'Neptune': swe.NEPTUNE,
'Pluto': swe.PLUTO,
}
def set_ephe_path():
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
if ephe_path:
swe.set_ephe_path(ephe_path)
def get_sign(lon):
return SIGNS[int(lon // 30) % 12]
def get_julian_day(dt):
return swe.julday(
dt.year, dt.month, dt.day,
dt.hour + dt.minute / 60 + dt.second / 3600,
)
def get_planet_positions(jd):
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
planets = {}
for name, code in PLANET_CODES.items():
pos, _ = swe.calc_ut(jd, code, flag)
degree = pos[0]
planets[name] = {
'sign': get_sign(degree),
'degree': degree,
'speed': pos[3],
'retrograde': pos[3] < 0,
}
return planets
def get_element_counts(planets):
sign_counts = {s: 0 for s in SIGNS}
sign_planets = {s: [] for s in SIGNS}
classic = {'Fire': [], 'Water': [], 'Earth': [], 'Air': []}
for name, data in planets.items():
sign = data['sign']
el = SIGN_ELEMENT[sign]
classic[el].append({'planet': name, 'sign': sign})
sign_counts[sign] += 1
sign_planets[sign].append({'planet': name, 'sign': sign})
result = {
el: {'count': len(contribs), 'contributors': contribs}
for el, contribs in classic.items()
}
# Time: stellium — highest concentration in one sign, bonus = size - 1.
# Collect all signs tied at the maximum.
max_in_sign = max(sign_counts.values())
stellia = [
{'sign': s, 'planets': sign_planets[s]}
for s in SIGNS
if sign_counts[s] == max_in_sign and max_in_sign > 1
]
result['Time'] = {
'count': max_in_sign - 1,
'stellia': stellia,
}
# Space: parade — longest consecutive run of occupied signs (circular),
# bonus = run length - 1. Collect all runs tied at the maximum.
index_set = {i for i, s in enumerate(SIGNS) if sign_counts[s] > 0}
indices = sorted(index_set)
max_seq = 0
for start in range(len(indices)):
seq_len = 1
for offset in range(1, len(indices)):
if (indices[start] + offset) % len(SIGNS) in index_set:
seq_len += 1
else:
break
max_seq = max(max_seq, seq_len)
parades = []
for start in range(len(indices)):
run = []
for offset in range(max_seq):
idx = (indices[start] + offset) % len(SIGNS)
if idx not in index_set:
break
run.append(idx)
else:
sign_run = [SIGNS[i] for i in run]
parade_planets = [
p for s in sign_run for p in sign_planets[s]
]
parades.append({'signs': sign_run, 'planets': parade_planets})
result['Space'] = {
'count': max_seq - 1,
'parades': parades,
}
return result
def calculate_aspects(planets):
"""Return a list of aspects between all planet pairs.
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
Only the first matching aspect type is reported per pair (aspects are
well-separated enough that at most one can apply with standard orbs).
"""
names = list(planets.keys())
aspects = []
for i, name1 in enumerate(names):
for name2 in names[i + 1:]:
deg1 = planets[name1]['degree']
deg2 = planets[name2]['degree']
angle = abs(deg1 - deg2)
if angle > 180:
angle = 360 - angle
for aspect_name, target, max_orb in ASPECTS:
orb = abs(angle - target)
if orb <= max_orb:
s1 = abs(planets[name1].get('speed', 0))
s2 = abs(planets[name2].get('speed', 0))
applying = name1 if s1 >= s2 else name2
aspects.append({
'planet1': name1,
'planet2': name2,
'type': aspect_name,
'angle': round(angle, 2),
'orb': round(orb, 2),
'applying_planet': applying,
})
break
return aspects

View File

@@ -0,0 +1,49 @@
from datetime import date, datetime, timedelta, timezone
from django.core.management.base import BaseCommand
from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path
from apps.charts.models import EphemerisSnapshot
class Command(BaseCommand):
help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).'
def add_arguments(self, parser):
parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)')
parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)')
def handle(self, *args, **options):
set_ephe_path()
date_from = date.fromisoformat(options['date_from'])
date_to = date.fromisoformat(options['date_to'])
current = date_from
count = 0
while current <= date_to:
dt = datetime(current.year, current.month, current.day,
12, 0, 0, tzinfo=timezone.utc)
jd = get_julian_day(dt)
planets = get_planet_positions(jd)
elements = get_element_counts(planets)
EphemerisSnapshot.objects.update_or_create(
dt=dt,
defaults={
'fire': elements['Fire']['count'],
'water': elements['Water']['count'],
'earth': elements['Earth']['count'],
'air': elements['Air']['count'],
'time_el': elements['Time']['count'],
'space_el': elements['Space']['count'],
'chart_data': {'planets': planets},
},
)
current += timedelta(days=1)
count += 1
if options['verbosity'] > 0:
self.stdout.write(
self.style.SUCCESS(f'Created/updated {count} snapshot(s).')
)

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0.4 on 2026-04-13 20:40
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='EphemerisSnapshot',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('dt', models.DateTimeField(db_index=True, unique=True)),
('fire', models.PositiveSmallIntegerField()),
('water', models.PositiveSmallIntegerField()),
('earth', models.PositiveSmallIntegerField()),
('air', models.PositiveSmallIntegerField()),
('time_el', models.PositiveSmallIntegerField()),
('space_el', models.PositiveSmallIntegerField()),
('chart_data', models.JSONField()),
],
options={
'ordering': ['dt'],
},
),
]

View File

@@ -0,0 +1,36 @@
from django.db import models
class EphemerisSnapshot(models.Model):
"""Pre-computed chart data for a single point in time.
Element counts are stored as denormalised columns for fast DB-level range
filtering. Full planet/house data lives in chart_data (JSONField) for
response serialisation.
"""
dt = models.DateTimeField(unique=True, db_index=True)
# Denormalised element counts — indexed for range queries
fire = models.PositiveSmallIntegerField()
water = models.PositiveSmallIntegerField()
earth = models.PositiveSmallIntegerField()
air = models.PositiveSmallIntegerField()
time_el = models.PositiveSmallIntegerField()
space_el = models.PositiveSmallIntegerField()
# Full chart payload
chart_data = models.JSONField()
class Meta:
ordering = ['dt']
def elements_dict(self):
return {
'Fire': self.fire,
'Water': self.water,
'Earth': self.earth,
'Air': self.air,
'Time': self.time_el,
'Space': self.space_el,
}

View File

View File

@@ -0,0 +1,159 @@
"""
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
These tests drive the EphemerisSnapshot model and list view.
Snapshots are created directly in setUp — no live ephemeris calc needed.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from django.test import TestCase
from apps.charts.models import EphemerisSnapshot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
CHART_DATA_STUB = {
'planets': {
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
},
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
}
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
chart_data=None):
return EphemerisSnapshot.objects.create(
dt=dt_str,
fire=fire, water=water, earth=earth, air=air,
time_el=time_el, space_el=space_el,
chart_data=chart_data or CHART_DATA_STUB,
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class ChartsListApiTest(TestCase):
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
def setUp(self):
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
# Outside the usual date range — should not appear in filtered results
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
def _get(self, params=None):
return self.client.get('/api/charts/', params or {})
# ── guards ────────────────────────────────────────────────────────────
def test_charts_returns_400_if_date_from_missing(self):
response = self._get({'date_to': '2000-01-31'})
self.assertEqual(response.status_code, 400)
def test_charts_returns_400_if_date_to_missing(self):
response = self._get({'date_from': '2000-01-01'})
self.assertEqual(response.status_code, 400)
def test_charts_returns_400_for_invalid_date_from(self):
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
self.assertEqual(response.status_code, 400)
def test_charts_returns_400_if_date_to_before_date_from(self):
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
self.assertEqual(response.status_code, 400)
# ── response shape ────────────────────────────────────────────────────
def test_charts_returns_200_for_valid_params(self):
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
self.assertEqual(response.status_code, 200)
def test_charts_response_is_json(self):
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
self.assertIn('application/json', response['Content-Type'])
def test_charts_response_has_results_and_count(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
self.assertIn('results', data)
self.assertIn('count', data)
def test_each_result_has_dt_and_elements(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
for result in data['results']:
with self.subTest(dt=result.get('dt')):
self.assertIn('dt', result)
self.assertIn('elements', result)
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
self.assertIn(key, result['elements'])
def test_each_result_has_planets(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
for result in data['results']:
with self.subTest(dt=result.get('dt')):
self.assertIn('planets', result)
# ── date range filtering ──────────────────────────────────────────────
def test_charts_returns_only_snapshots_in_date_range(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
self.assertEqual(data['count'], 3)
def test_charts_count_matches_results_length(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
self.assertEqual(data['count'], len(data['results']))
def test_charts_date_range_is_inclusive(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
self.assertEqual(data['count'], 1)
def test_charts_results_ordered_by_dt(self):
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
dts = [r['dt'] for r in data['results']]
self.assertEqual(dts, sorted(dts))
# ── element range filtering ───────────────────────────────────────────
def test_charts_filters_by_fire_min(self):
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
}).json()
self.assertEqual(data['count'], 1)
def test_charts_filters_by_water_min(self):
# Only the Jan 2 snapshot has water=4
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
}).json()
self.assertEqual(data['count'], 1)
def test_charts_filters_by_earth_min(self):
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
}).json()
self.assertEqual(data['count'], 1)
def test_charts_multiple_element_filters_are_conjunctive(self):
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
data = self._get({
'date_from': '2000-01-01', 'date_to': '2000-01-31',
'fire_min': 2, 'water_min': 2,
}).json()
self.assertEqual(data['count'], 2)

View File

@@ -0,0 +1,247 @@
"""
Integration tests for the PySwiss chart calculation API.
These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/.
They verify the HTTP contract using Django's test client.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from django.test import TestCase
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
J2000 = '2000-01-01T12:00:00Z'
LONDON = {'lat': 51.5074, 'lon': -0.1278}
# Well-known coordinates with unambiguous timezone results
NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York
TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo
REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik
class ChartApiTest(TestCase):
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
def _get(self, params):
return self.client.get('/api/chart/', params)
# ── guards ────────────────────────────────────────────────────────────
def test_chart_returns_400_if_dt_missing(self):
response = self._get({'lat': 51.5074, 'lon': -0.1278})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_if_lat_missing(self):
response = self._get({'dt': J2000, 'lon': -0.1278})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_if_lon_missing(self):
response = self._get({'dt': J2000, 'lat': 51.5074})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_for_invalid_dt_format(self):
response = self._get({'dt': 'not-a-date', **LONDON})
self.assertEqual(response.status_code, 400)
def test_chart_returns_400_for_out_of_range_lat(self):
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
self.assertEqual(response.status_code, 400)
# ── response shape ────────────────────────────────────────────────────
def test_chart_returns_200_for_valid_params(self):
response = self._get({'dt': J2000, **LONDON})
self.assertEqual(response.status_code, 200)
def test_chart_response_is_json(self):
response = self._get({'dt': J2000, **LONDON})
self.assertIn('application/json', response['Content-Type'])
def test_chart_returns_all_ten_planets(self):
data = self._get({'dt': J2000, **LONDON}).json()
expected = {
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
}
self.assertEqual(set(data['planets'].keys()), expected)
def test_each_planet_has_sign_degree_and_retrograde(self):
data = self._get({'dt': J2000, **LONDON}).json()
for name, planet in data['planets'].items():
with self.subTest(planet=name):
self.assertIn('sign', planet)
self.assertIn('degree', planet)
self.assertIn('retrograde', planet)
def test_chart_returns_houses(self):
data = self._get({'dt': J2000, **LONDON}).json()
houses = data['houses']
self.assertEqual(len(houses['cusps']), 12)
self.assertIn('asc', houses)
self.assertIn('mc', houses)
def test_chart_returns_six_element_counts(self):
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
data = self._get({'dt': J2000, **LONDON}).json()
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
with self.subTest(element=key):
self.assertIn(key, data['elements'])
def test_chart_reports_active_house_system(self):
data = self._get({'dt': J2000, **LONDON}).json()
self.assertIn('house_system', data)
# ── calculation correctness ───────────────────────────────────────────
def test_sun_is_in_capricorn_at_j2000(self):
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
data = self._get({'dt': J2000, **LONDON}).json()
sun = data['planets']['Sun']
self.assertEqual(sun['sign'], 'Capricorn')
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
def test_sun_is_not_retrograde(self):
"""The Sun never goes retrograde."""
data = self._get({'dt': J2000, **LONDON}).json()
self.assertFalse(data['planets']['Sun']['retrograde'])
def test_element_counts_sum_to_ten(self):
"""All 10 planets are assigned to exactly one classical element."""
data = self._get({'dt': J2000, **LONDON}).json()
classical = sum(
data['elements'][e]['count'] for e in ('Fire', 'Water', 'Earth', 'Air')
)
self.assertEqual(classical, 10)
def test_each_element_has_count_key(self):
data = self._get({'dt': J2000, **LONDON}).json()
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
with self.subTest(element=key):
self.assertIn('count', data['elements'][key])
def test_classic_elements_have_contributors(self):
data = self._get({'dt': J2000, **LONDON}).json()
for key in ('Fire', 'Water', 'Earth', 'Air'):
with self.subTest(element=key):
self.assertIn('contributors', data['elements'][key])
def test_time_has_stellia(self):
data = self._get({'dt': J2000, **LONDON}).json()
self.assertIn('stellia', data['elements']['Time'])
def test_space_has_parades(self):
data = self._get({'dt': J2000, **LONDON}).json()
self.assertIn('parades', data['elements']['Space'])
def test_each_planet_has_speed(self):
data = self._get({'dt': J2000, **LONDON}).json()
for name, planet in data['planets'].items():
with self.subTest(planet=name):
self.assertIn('speed', planet)
def test_each_aspect_has_applying_planet(self):
data = self._get({'dt': J2000, **LONDON}).json()
for aspect in data['aspects']:
with self.subTest(aspect=aspect):
self.assertIn('applying_planet', aspect)
# ── house system ──────────────────────────────────────────────────────
def test_default_house_system_is_porphyry(self):
"""Porphyry ('O') is the project default — no param needed."""
data = self._get({'dt': J2000, **LONDON}).json()
self.assertEqual(data['house_system'], 'O')
def test_non_superuser_cannot_override_house_system(self):
"""House system override is superuser-only; plain requests get 403."""
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
self.assertEqual(response.status_code, 403)
# ── aspects ───────────────────────────────────────────────────────────
def test_chart_returns_aspects_list(self):
data = self._get({'dt': J2000, **LONDON}).json()
self.assertIn('aspects', data)
self.assertIsInstance(data['aspects'], list)
def test_each_aspect_has_required_fields(self):
data = self._get({'dt': J2000, **LONDON}).json()
for aspect in data['aspects']:
with self.subTest(aspect=aspect):
self.assertIn('planet1', aspect)
self.assertIn('planet2', aspect)
self.assertIn('type', aspect)
self.assertIn('angle', aspect)
self.assertIn('orb', aspect)
def test_sun_saturn_trine_present_at_j2000(self):
"""Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine."""
data = self._get({'dt': J2000, **LONDON}).json()
pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']}
self.assertIn(('Sun', 'Saturn', 'Trine'), pairs)
class TimezoneApiTest(TestCase):
"""GET /api/tz/ — resolve IANA timezone from lat/lon coordinates."""
def _get(self, params):
return self.client.get('/api/tz/', params)
# ── guards ────────────────────────────────────────────────────────────
def test_returns_400_if_lat_missing(self):
response = self._get({'lon': -74.0060})
self.assertEqual(response.status_code, 400)
def test_returns_400_if_lon_missing(self):
response = self._get({'lat': 40.7128})
self.assertEqual(response.status_code, 400)
def test_returns_400_for_invalid_lat(self):
response = self._get({'lat': 'abc', 'lon': -74.0060})
self.assertEqual(response.status_code, 400)
def test_returns_400_for_out_of_range_lat(self):
response = self._get({'lat': 999, 'lon': -74.0060})
self.assertEqual(response.status_code, 400)
def test_returns_400_for_out_of_range_lon(self):
response = self._get({'lat': 40.7128, 'lon': 999})
self.assertEqual(response.status_code, 400)
# ── response shape ────────────────────────────────────────────────────
def test_returns_200_for_valid_coords(self):
response = self._get(NEW_YORK)
self.assertEqual(response.status_code, 200)
def test_response_is_json(self):
response = self._get(NEW_YORK)
self.assertIn('application/json', response['Content-Type'])
def test_response_contains_timezone_key(self):
data = self._get(NEW_YORK).json()
self.assertIn('timezone', data)
def test_timezone_is_a_string(self):
data = self._get(NEW_YORK).json()
self.assertIsInstance(data['timezone'], str)
# ── correctness ───────────────────────────────────────────────────────
def test_new_york_timezone(self):
data = self._get(NEW_YORK).json()
self.assertEqual(data['timezone'], 'America/New_York')
def test_tokyo_timezone(self):
data = self._get(TOKYO).json()
self.assertEqual(data['timezone'], 'Asia/Tokyo')
def test_reykjavik_timezone(self):
data = self._get(REYKJAVIK).json()
self.assertEqual(data['timezone'], 'Atlantic/Reykjavik')

View File

@@ -0,0 +1,329 @@
"""
Unit tests for calc.py helper functions.
These tests verify pure calculation logic without hitting the database
or the Swiss Ephemeris — all inputs are fixed synthetic data.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from django.test import SimpleTestCase
from apps.charts.calc import calculate_aspects, get_element_counts
# ---------------------------------------------------------------------------
# FAKE_PLANETS_ASPECTS — degrees only; used by calculate_aspects tests.
# Each planet also carries a speed (deg/day) for applying_planet tests.
# ---------------------------------------------------------------------------
FAKE_PLANETS = {
'Sun': {'degree': 10.0, 'speed': 1.00}, # Aries
'Moon': {'degree': 130.0, 'speed': 13.00}, # Leo — 120° from Sun → Trine
'Mercury': {'degree': 250.0, 'speed': 1.50}, # Sagittarius — 120° from Sun → Trine
'Venus': {'degree': 40.0, 'speed': 1.10}, # Taurus — 90° from Moon → Square
'Mars': {'degree': 160.0, 'speed': 0.50}, # Virgo — 60° from Neptune → Sextile
'Jupiter': {'degree': 280.0, 'speed': 0.08}, # Capricorn — 120° from Mars → Trine
'Saturn': {'degree': 70.0, 'speed': 0.03}, # Gemini — 120° from Uranus → Trine
'Uranus': {'degree': 310.0, 'speed': 0.01}, # Aquarius — 60° from Sun (wrap) → Sextile
'Neptune': {'degree': 100.0, 'speed': 0.006}, # Cancer
'Pluto': {'degree': 340.0, 'speed': 0.003}, # Pisces
}
# ---------------------------------------------------------------------------
# FAKE_PLANETS_ELEMENTS — sign + degree + speed; used by get_element_counts.
# Designed to produce a known stellium and parade.
#
# Occupied signs: Aries(0), Taurus(1), Gemini(2), Leo(4), Virgo(5),
# Scorpio(7), Capricorn(9), Aquarius(10)
# Gaps at Cancer(3), Libra(6), Sagittarius(8), Pisces(11) prevent wrap-around.
#
# Consecutive runs: Aries→Taurus→Gemini = 3 ← parade (Space = 2)
# Leo→Virgo = 2
# Capricorn→Aquarius = 2
#
# Time = 2 (Aries has Sun+Mercury+Venus → stellium of 3, bonus = 2)
# Space = 2 (Aries→Taurus→Gemini = 3-sign parade, bonus = 2)
# Classic: Fire=4, Earth=3, Air=2, Water=1
# ---------------------------------------------------------------------------
FAKE_PLANETS_ELEMENTS = {
'Sun': {'sign': 'Aries', 'degree': 10.0, 'speed': 1.00}, # Fire, stellium
'Moon': {'sign': 'Taurus', 'degree': 40.0, 'speed': 13.00}, # Earth, parade
'Mercury': {'sign': 'Aries', 'degree': 20.0, 'speed': 1.50}, # Fire, stellium
'Venus': {'sign': 'Aries', 'degree': 25.0, 'speed': 1.10}, # Fire, stellium
'Mars': {'sign': 'Leo', 'degree': 130.0, 'speed': 0.50}, # Fire
'Jupiter': {'sign': 'Scorpio', 'degree': 220.0, 'speed': 0.08}, # Water
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'speed': 0.03}, # Air, parade
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'speed': 0.01}, # Air
'Neptune': {'sign': 'Capricorn', 'degree': 270.0, 'speed': 0.006}, # Earth
'Pluto': {'sign': 'Virgo', 'degree': 160.0, 'speed': 0.003}, # Earth
}
def _aspect_pairs(aspects):
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
# ===========================================================================
# get_element_counts — enriched shape
# ===========================================================================
class GetElementCountsTest(SimpleTestCase):
def setUp(self):
self.counts = get_element_counts(FAKE_PLANETS_ELEMENTS)
# ── top-level keys ───────────────────────────────────────────────────────
def test_returns_all_six_elements(self):
for key in ('Fire', 'Earth', 'Air', 'Water', 'Time', 'Space'):
with self.subTest(key=key):
self.assertIn(key, self.counts)
# ── classic four — count + contributors ──────────────────────────────────
def test_classic_element_has_count_key(self):
self.assertIn('count', self.counts['Fire'])
def test_classic_element_has_contributors_key(self):
self.assertIn('contributors', self.counts['Fire'])
def test_fire_count_is_correct(self):
# Sun + Mercury + Venus (Aries) + Mars (Leo) = 4
self.assertEqual(self.counts['Fire']['count'], 4)
def test_earth_count_is_correct(self):
# Moon (Taurus) + Neptune (Capricorn) + Pluto (Virgo) = 3
self.assertEqual(self.counts['Earth']['count'], 3)
def test_air_count_is_correct(self):
# Saturn (Gemini) + Uranus (Aquarius) = 2
self.assertEqual(self.counts['Air']['count'], 2)
def test_water_count_is_correct(self):
# Jupiter (Scorpio) = 1
self.assertEqual(self.counts['Water']['count'], 1)
def test_fire_contributors_contains_expected_planets(self):
planets = {c['planet'] for c in self.counts['Fire']['contributors']}
self.assertEqual(planets, {'Sun', 'Mercury', 'Venus', 'Mars'})
def test_contributor_has_planet_and_sign_keys(self):
contrib = self.counts['Fire']['contributors'][0]
self.assertIn('planet', contrib)
self.assertIn('sign', contrib)
def test_fire_contributor_signs_are_correct(self):
sign_map = {c['planet']: c['sign'] for c in self.counts['Fire']['contributors']}
self.assertEqual(sign_map['Sun'], 'Aries')
self.assertEqual(sign_map['Mercury'], 'Aries')
self.assertEqual(sign_map['Venus'], 'Aries')
self.assertEqual(sign_map['Mars'], 'Leo')
# ── Time — count + stellia ───────────────────────────────────────────────
def test_time_has_count_key(self):
self.assertIn('count', self.counts['Time'])
def test_time_has_stellia_key(self):
self.assertIn('stellia', self.counts['Time'])
def test_time_count_is_correct(self):
# Aries has 3 planets → bonus = 2
self.assertEqual(self.counts['Time']['count'], 2)
def test_time_stellia_is_a_list(self):
self.assertIsInstance(self.counts['Time']['stellia'], list)
def test_time_stellia_contains_one_entry(self):
self.assertEqual(len(self.counts['Time']['stellia']), 1)
def test_time_stellium_sign_is_aries(self):
self.assertEqual(self.counts['Time']['stellia'][0]['sign'], 'Aries')
def test_time_stellium_planets_are_correct(self):
planet_names = {p['planet'] for p in self.counts['Time']['stellia'][0]['planets']}
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus'})
def test_time_stellium_planet_entries_have_sign(self):
for entry in self.counts['Time']['stellia'][0]['planets']:
with self.subTest(planet=entry['planet']):
self.assertEqual(entry['sign'], 'Aries')
# ── Space — count + parades ──────────────────────────────────────────────
def test_space_has_count_key(self):
self.assertIn('count', self.counts['Space'])
def test_space_has_parades_key(self):
self.assertIn('parades', self.counts['Space'])
def test_space_count_is_correct(self):
# Aries→Taurus→Gemini = 3 consecutive → bonus = 2
self.assertEqual(self.counts['Space']['count'], 2)
def test_space_parades_is_a_list(self):
self.assertIsInstance(self.counts['Space']['parades'], list)
def test_space_parades_contains_one_entry(self):
self.assertEqual(len(self.counts['Space']['parades']), 1)
def test_space_parade_signs_are_correct(self):
self.assertEqual(
self.counts['Space']['parades'][0]['signs'],
['Aries', 'Taurus', 'Gemini'],
)
def test_space_parade_planets_are_correct(self):
planet_names = {p['planet'] for p in self.counts['Space']['parades'][0]['planets']}
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus', 'Moon', 'Saturn'})
def test_space_parade_planet_entries_have_planet_and_sign(self):
for entry in self.counts['Space']['parades'][0]['planets']:
with self.subTest(planet=entry['planet']):
self.assertIn('planet', entry)
self.assertIn('sign', entry)
# ===========================================================================
# calculate_aspects
# ===========================================================================
class CalculateAspectsTest(SimpleTestCase):
def setUp(self):
self.aspects = calculate_aspects(FAKE_PLANETS)
# ── return shape ──────────────────────────────────────────────────────
def test_returns_a_list(self):
self.assertIsInstance(self.aspects, list)
def test_each_aspect_has_required_keys(self):
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIn('planet1', aspect)
self.assertIn('planet2', aspect)
self.assertIn('type', aspect)
self.assertIn('angle', aspect)
self.assertIn('orb', aspect)
def test_each_aspect_has_applying_planet_key(self):
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIn('applying_planet', aspect)
def test_applying_planet_is_one_of_the_pair(self):
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIn(
aspect['applying_planet'],
(aspect['planet1'], aspect['planet2']),
)
def test_applying_planet_is_the_faster_body(self):
"""Moon (13.0°/day) applies to Sun (1.0°/day) in their Trine."""
sun_moon = next(
a for a in self.aspects
if {a['planet1'], a['planet2']} == {'Sun', 'Moon'}
)
self.assertEqual(sun_moon['applying_planet'], 'Moon')
def test_each_aspect_type_is_a_known_name(self):
known = {
'Conjunction', 'Semisextile', 'Sextile', 'Square',
'Trine', 'Quincunx', 'Opposition',
}
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIn(aspect['type'], known)
def test_angle_and_orb_are_floats(self):
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertIsInstance(aspect['angle'], float)
self.assertIsInstance(aspect['orb'], float)
def test_no_self_aspects(self):
for aspect in self.aspects:
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
def test_no_duplicate_pairs(self):
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
self.assertEqual(len(pairs), len(set(pairs)))
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
def test_sun_moon_trine(self):
"""Moon at 130° is exactly 120° from Sun at 10°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
def test_sun_mercury_trine(self):
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
def test_moon_mercury_trine(self):
"""Moon 130° → Mercury 250° = 120°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
def test_moon_venus_square(self):
"""Moon 130° → Venus 40° = 90°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
def test_venus_neptune_sextile(self):
"""Venus 40° → Neptune 100° = 60°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
def test_mars_neptune_sextile(self):
"""Mars 160° → Neptune 100° = 60°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
def test_sun_uranus_sextile(self):
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
def test_mars_jupiter_trine(self):
"""Mars 160° → Jupiter 280° = 120°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
def test_saturn_uranus_trine(self):
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
pairs = _aspect_pairs(self.aspects)
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
# ── orb bounds ────────────────────────────────────────────────────────
def test_orb_is_within_allowed_maximum(self):
max_orbs = {
'Conjunction': 8.0,
'Semisextile': 4.0,
'Sextile': 6.0,
'Square': 8.0,
'Trine': 8.0,
'Quincunx': 5.0,
'Opposition': 10.0,
}
for aspect in self.aspects:
with self.subTest(aspect=aspect):
self.assertLessEqual(
aspect['orb'], max_orbs[aspect['type']],
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
)
def test_exact_trine_has_zero_orb(self):
"""Sun-Moon at exactly 120° should report orb of 0.0."""
sun_moon = next(
a for a in self.aspects
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
)
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)

View File

@@ -0,0 +1,99 @@
"""
Unit tests for the populate_ephemeris management command.
pyswisseph calls are mocked — these tests verify date iteration,
snapshot persistence, and idempotency without touching the ephemeris.
Run:
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
"""
from datetime import datetime, timezone
from unittest.mock import patch
from django.core.management import call_command
from django.test import TestCase
from apps.charts.models import EphemerisSnapshot
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
# 10 planets covering Fire×3, Earth×3, Air×2, Water×2 (one per sign)
# Expected: fire=3, water=2, earth=3, air=2, time=0, space=9
FAKE_PLANETS = {
'Sun': {'sign': 'Aries', 'degree': 10.0, 'retrograde': False},
'Moon': {'sign': 'Leo', 'degree': 130.0, 'retrograde': False},
'Mercury': {'sign': 'Sagittarius', 'degree': 250.0, 'retrograde': False},
'Venus': {'sign': 'Taurus', 'degree': 40.0, 'retrograde': False},
'Mars': {'sign': 'Virgo', 'degree': 160.0, 'retrograde': False},
'Jupiter': {'sign': 'Capricorn', 'degree': 280.0, 'retrograde': False},
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'retrograde': False},
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'retrograde': False},
'Neptune': {'sign': 'Cancer', 'degree': 100.0, 'retrograde': False},
'Pluto': {'sign': 'Pisces', 'degree': 340.0, 'retrograde': False},
}
PATCH_TARGET = (
'apps.charts.management.commands.populate_ephemeris.get_planet_positions'
)
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class PopulateEphemerisCommandTest(TestCase):
def _run(self, date_from, date_to):
with patch(PATCH_TARGET, return_value=FAKE_PLANETS):
call_command('populate_ephemeris',
date_from=date_from, date_to=date_to,
verbosity=0)
# ── date iteration ────────────────────────────────────────────────────
def test_creates_one_snapshot_per_day(self):
self._run('2000-01-01', '2000-01-03')
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
def test_single_day_range_creates_one_snapshot(self):
self._run('2000-01-01', '2000-01-01')
self.assertEqual(EphemerisSnapshot.objects.count(), 1)
def test_snapshots_are_at_noon_utc(self):
self._run('2000-01-01', '2000-01-01')
snap = EphemerisSnapshot.objects.get()
self.assertEqual(snap.dt, datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc))
# ── idempotency ───────────────────────────────────────────────────────
def test_rerunning_does_not_create_duplicates(self):
self._run('2000-01-01', '2000-01-03')
self._run('2000-01-01', '2000-01-03')
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
def test_overlapping_ranges_do_not_duplicate(self):
self._run('2000-01-01', '2000-01-03')
self._run('2000-01-02', '2000-01-05')
self.assertEqual(EphemerisSnapshot.objects.count(), 5)
# ── element counts ────────────────────────────────────────────────────
def test_element_counts_are_persisted(self):
self._run('2000-01-01', '2000-01-01')
snap = EphemerisSnapshot.objects.get()
self.assertEqual(snap.fire, 3)
self.assertEqual(snap.water, 2)
self.assertEqual(snap.earth, 3)
self.assertEqual(snap.air, 2)
self.assertEqual(snap.time_el, 0)
self.assertEqual(snap.space_el, 9)
# ── chart_data payload ────────────────────────────────────────────────
def test_chart_data_contains_planets(self):
self._run('2000-01-01', '2000-01-01')
snap = EphemerisSnapshot.objects.get()
self.assertEqual(snap.chart_data['planets'], FAKE_PLANETS)

View File

@@ -0,0 +1,8 @@
from django.urls import path
from . import views
urlpatterns = [
path('chart/', views.chart, name='chart'),
path('charts/', views.charts_list, name='charts_list'),
path('tz/', views.timezone_lookup, name='timezone_lookup'),
]

View File

@@ -0,0 +1,143 @@
from datetime import datetime, timezone
from django.http import HttpResponse, JsonResponse
from timezonefinder import TimezoneFinder
import swisseph as swe
from .calc import (
DEFAULT_HOUSE_SYSTEM,
calculate_aspects,
get_element_counts,
get_julian_day,
get_planet_positions,
set_ephe_path,
)
from .models import EphemerisSnapshot
def chart(request):
dt_str = request.GET.get('dt')
lat_str = request.GET.get('lat')
lon_str = request.GET.get('lon')
if not dt_str or lat_str is None or lon_str is None:
return HttpResponse(status=400)
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
except ValueError:
return HttpResponse(status=400)
try:
lat = float(lat_str)
lon = float(lon_str)
except ValueError:
return HttpResponse(status=400)
if not (-90 <= lat <= 90):
return HttpResponse(status=400)
house_system_param = request.GET.get('house_system')
if house_system_param is not None:
if not (hasattr(request, 'user') and request.user.is_authenticated
and request.user.is_superuser):
return HttpResponse(status=403)
house_system = house_system_param
else:
house_system = DEFAULT_HOUSE_SYSTEM
set_ephe_path()
jd = get_julian_day(dt)
planets = get_planet_positions(jd)
cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode())
houses = {
'cusps': list(cusps),
'asc': ascmc[0],
'mc': ascmc[1],
}
return JsonResponse({
'planets': planets,
'houses': houses,
'elements': get_element_counts(planets),
'aspects': calculate_aspects(planets),
'house_system': house_system,
})
_tf = TimezoneFinder()
def timezone_lookup(request):
"""GET /api/tz/ — resolve IANA timezone string from lat/lon.
Query params: lat (float), lon (float)
Returns: { "timezone": "America/New_York" }
Returns 404 JSON { "timezone": null } if coordinates fall in international
waters (no timezone found) — not an error, just no result.
"""
lat_str = request.GET.get('lat')
lon_str = request.GET.get('lon')
if 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)
tz = _tf.timezone_at(lat=lat, lng=lon)
return JsonResponse({'timezone': tz})
def charts_list(request):
date_from_str = request.GET.get('date_from')
date_to_str = request.GET.get('date_to')
if not date_from_str or not date_to_str:
return HttpResponse(status=400)
try:
date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace(
tzinfo=timezone.utc)
date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace(
hour=23, minute=59, second=59, tzinfo=timezone.utc)
except ValueError:
return HttpResponse(status=400)
if date_to < date_from:
return HttpResponse(status=400)
qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to)
element_fields = {
'fire_min': 'fire', 'water_min': 'water',
'earth_min': 'earth', 'air_min': 'air',
'time_min': 'time_el', 'space_min': 'space_el',
}
for param, field in element_fields.items():
value = request.GET.get(param)
if value is not None:
try:
qs = qs.filter(**{f'{field}__gte': int(value)})
except ValueError:
return HttpResponse(status=400)
results = [
{
'dt': snap.dt.isoformat(),
'elements': snap.elements_dict(),
'planets': snap.chart_data.get('planets', {}),
}
for snap in qs
]
return JsonResponse({'results': results, 'count': len(results)})

0
pyswiss/core/__init__.py Normal file
View File

49
pyswiss/core/settings.py Normal file
View File

@@ -0,0 +1,49 @@
import os
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
SECRET_KEY = os.environ.get('SECRET_KEY', 'pyswiss-dev-only-key-replace-in-production')
DEBUG = os.environ.get('DEBUG', 'true').lower() != 'false'
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
INSTALLED_APPS = [
'corsheaders',
'django.contrib.contenttypes',
'django.contrib.auth',
'apps.charts',
]
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
CORS_ALLOWED_ORIGIN_REGEXES = [
r'^https://.*\.earthmanrpg\.me$',
r'^http://localhost(:\d+)?$',
]
ROOT_URLCONF = 'core.urls'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
STATIC_URL = '/static/'
MEDIA_URL = '/media/'
USE_TZ = True
TIME_ZONE = 'UTC'
# Swiss Ephemeris data files.
# Override via SWISSEPH_PATH env var on staging/production.
SWISSEPH_PATH = os.environ.get(
'SWISSEPH_PATH',
r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe',
)

5
pyswiss/core/urls.py Normal file
View File

@@ -0,0 +1,5 @@
from django.urls import path, include
urlpatterns = [
path('api/', include('apps.charts.urls')),
]

6
pyswiss/core/wsgi.py Normal file
View File

@@ -0,0 +1,6 @@
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application()

20
pyswiss/manage.py Normal file
View File

@@ -0,0 +1,20 @@
#!/usr/bin/env python
import os
import sys
def main():
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and available "
"on your PYTHONPATH environment variable? Did you forget to activate "
"a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

5
pyswiss/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
django==6.0.4
django-cors-headers==4.3.1
gunicorn==23.0.0
pyswisseph==2.10.3.2
timezonefinder==8.2.2

View File

@@ -6,6 +6,7 @@ channels
channels-redis
charset-normalizer==3.4.4
coverage
cryptography
cssselect==1.3.0
daphne
dj-database-url

View File

@@ -1,4 +1,5 @@
celery
cryptography
channels
channels-redis
cssselect==1.3.0

0
src/apps/ap/__init__.py Normal file
View File

7
src/apps/ap/apps.py Normal file
View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class ApConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.ap"
label = "ap"

View File

View File

View File

@@ -0,0 +1,119 @@
import json
from django.test import TestCase
from apps.drama.models import GameEvent, record
from apps.epic.models import Room
from apps.lyric.models import User
class WebFingerTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="actor")
def test_returns_jrd_for_known_user(self):
response = self.client.get(
"/.well-known/webfinger",
{"resource": "acct:actor@earthmanrpg.me"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "application/jrd+json")
def test_jrd_links_to_actor_url(self):
response = self.client.get(
"/.well-known/webfinger",
{"resource": "acct:actor@earthmanrpg.me"},
)
data = json.loads(response.content)
hrefs = [link["href"] for link in data["links"]]
self.assertTrue(any("/ap/users/actor/" in href for href in hrefs))
def test_returns_404_for_unknown_user(self):
response = self.client.get(
"/.well-known/webfinger",
{"resource": "acct:nobody@earthmanrpg.me"},
)
self.assertEqual(response.status_code, 404)
def test_returns_400_for_missing_resource(self):
response = self.client.get("/.well-known/webfinger")
self.assertEqual(response.status_code, 400)
class ActorViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="actor")
def test_returns_200_for_known_user(self):
response = self.client.get("/ap/users/actor/")
self.assertEqual(response.status_code, 200)
def test_returns_activity_json_content_type(self):
response = self.client.get("/ap/users/actor/")
self.assertEqual(response["Content-Type"], "application/activity+json")
def test_actor_has_required_fields(self):
response = self.client.get("/ap/users/actor/")
data = json.loads(response.content)
self.assertEqual(data["type"], "Person")
self.assertIn("id", data)
self.assertIn("outbox", data)
self.assertIn("publicKey", data)
def test_requires_no_authentication(self):
# AP Actor endpoints must be publicly accessible
self.client.logout()
response = self.client.get("/ap/users/actor/")
self.assertEqual(response.status_code, 200)
def test_returns_404_for_unknown_user(self):
response = self.client.get("/ap/users/nobody/")
self.assertEqual(response.status_code, 404)
class OutboxViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="actor")
self.room = Room.objects.create(name="Test Room", owner=self.user)
record(
self.room, GameEvent.SLOT_FILLED, actor=self.user,
slot_number=1, token_type="coin",
token_display="Coin", renewal_days=7,
)
record(
self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="PC", slot_number=1, role_display="Player",
)
# INVITE_SENT is unsupported — should be excluded from outbox
record(self.room, GameEvent.INVITE_SENT, actor=self.user)
def test_returns_200(self):
response = self.client.get("/ap/users/actor/outbox/")
self.assertEqual(response.status_code, 200)
def test_returns_activity_json_content_type(self):
response = self.client.get("/ap/users/actor/outbox/")
self.assertEqual(response["Content-Type"], "application/activity+json")
def test_outbox_is_ordered_collection(self):
response = self.client.get("/ap/users/actor/outbox/")
data = json.loads(response.content)
self.assertEqual(data["type"], "OrderedCollection")
def test_total_items_excludes_unsupported_verbs(self):
response = self.client.get("/ap/users/actor/outbox/")
data = json.loads(response.content)
# 2 supported events (SLOT_FILLED + ROLE_SELECTED); INVITE_SENT excluded
self.assertEqual(data["totalItems"], 2)
def test_requires_no_authentication(self):
self.client.logout()
response = self.client.get("/ap/users/actor/outbox/")
self.assertEqual(response.status_code, 200)
def test_returns_404_for_unknown_user(self):
response = self.client.get("/ap/users/nobody/outbox/")
self.assertEqual(response.status_code, 404)

View File

View File

@@ -0,0 +1,88 @@
from django.test import TestCase
from apps.drama.models import GameEvent, record
from apps.epic.models import Room
from apps.lyric.models import User
BASE = "https://earthmanrpg.me"
class ToActivityTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io", username="testactor")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def _record(self, verb, **data):
return record(self.room, verb, actor=self.user, **data)
def test_slot_filled_returns_join_gate_activity(self):
event = self._record(
GameEvent.SLOT_FILLED,
slot_number=1, token_type="coin",
token_display="Coin", renewal_days=7,
)
activity = event.to_activity(BASE)
self.assertIsNotNone(activity)
self.assertEqual(activity["type"], "earthman:JoinGate")
def test_role_selected_returns_select_role_activity(self):
event = self._record(
GameEvent.ROLE_SELECTED,
role="PC", slot_number=1, role_display="Player",
)
activity = event.to_activity(BASE)
self.assertIsNotNone(activity)
self.assertEqual(activity["type"], "earthman:SelectRole")
def test_room_created_returns_create_activity(self):
event = self._record(GameEvent.ROOM_CREATED)
activity = event.to_activity(BASE)
self.assertIsNotNone(activity)
self.assertEqual(activity["type"], "Create")
def test_unsupported_verb_returns_none(self):
event = self._record(GameEvent.INVITE_SENT)
self.assertIsNone(event.to_activity(BASE))
def test_activity_contains_actor_url(self):
event = self._record(
GameEvent.ROLE_SELECTED,
role="PC", slot_number=1, role_display="Player",
)
activity = event.to_activity(BASE)
self.assertIn(BASE, activity["actor"])
def test_activity_contains_object_url(self):
event = self._record(
GameEvent.SLOT_FILLED,
slot_number=1, token_type="coin",
token_display="Coin", renewal_days=7,
)
activity = event.to_activity(BASE)
self.assertIn(str(self.room.id), activity["object"])
class EnsureKeypairTest(TestCase):
def test_ensure_keypair_populates_both_fields(self):
user = User.objects.create(email="keys@test.io")
self.assertEqual(user.ap_public_key, "")
self.assertEqual(user.ap_private_key, "")
user.ensure_keypair()
self.assertTrue(user.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
self.assertTrue(user.ap_private_key.startswith("-----BEGIN PRIVATE KEY-----"))
def test_ensure_keypair_persists_to_db(self):
user = User.objects.create(email="persist@test.io")
user.ensure_keypair()
refreshed = User.objects.get(pk=user.pk)
self.assertTrue(refreshed.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
def test_ensure_keypair_is_idempotent(self):
user = User.objects.create(email="idem@test.io")
user.ensure_keypair()
original_pub = user.ap_public_key
user.ensure_keypair()
self.assertEqual(user.ap_public_key, original_pub)

10
src/apps/ap/urls.py Normal file
View File

@@ -0,0 +1,10 @@
from django.urls import path
from . import views
app_name = "ap"
urlpatterns = [
path("users/<str:username>/", views.actor, name="actor"),
path("users/<str:username>/outbox/", views.outbox, name="outbox"),
]

83
src/apps/ap/views.py Normal file
View File

@@ -0,0 +1,83 @@
import json
from django.http import HttpResponse, JsonResponse
from django.shortcuts import get_object_or_404
from apps.lyric.models import User
AP_CONTEXT = [
"https://www.w3.org/ns/activitystreams",
{"earthman": "https://earthmanrpg.me/ns#"},
]
def _base_url(request):
return f"{request.scheme}://{request.get_host()}"
def _ap_response(data):
return HttpResponse(
json.dumps(data),
content_type="application/activity+json",
)
def webfinger(request):
resource = request.GET.get("resource", "")
if not resource:
return HttpResponse(status=400)
# Expect acct:username@host
if not resource.startswith("acct:"):
return HttpResponse(status=400)
username = resource[len("acct:"):].split("@")[0]
user = get_object_or_404(User, username=username)
base = _base_url(request)
data = {
"subject": resource,
"links": [
{
"rel": "self",
"type": "application/activity+json",
"href": f"{base}/ap/users/{user.username}/",
}
],
}
return HttpResponse(json.dumps(data), content_type="application/jrd+json")
def actor(request, username):
user = get_object_or_404(User, username=username)
user.ensure_keypair()
base = _base_url(request)
actor_url = f"{base}/ap/users/{username}/"
data = {
"@context": AP_CONTEXT,
"id": actor_url,
"type": "Person",
"preferredUsername": username,
"inbox": f"{actor_url}inbox/",
"outbox": f"{actor_url}outbox/",
"publicKey": {
"id": f"{actor_url}#main-key",
"owner": actor_url,
"publicKeyPem": user.ap_public_key,
},
}
return _ap_response(data)
def outbox(request, username):
user = get_object_or_404(User, username=username)
base = _base_url(request)
events = user.game_events.select_related("room").order_by("timestamp")
activities = [a for e in events if (a := e.to_activity(base)) is not None]
actor_url = f"{base}/ap/users/{username}/"
data = {
"@context": AP_CONTEXT,
"id": f"{actor_url}outbox/",
"type": "OrderedCollection",
"totalItems": len(activities),
"orderedItems": activities,
}
return _ap_response(data)

View File

@@ -1,30 +1,30 @@
from rest_framework import serializers
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class ItemSerializer(serializers.ModelSerializer):
class LineSerializer(serializers.ModelSerializer):
text = serializers.CharField()
def validate_text(self, value):
note = self.context["note"]
if note.item_set.filter(text=value).exists():
post = self.context["post"]
if post.lines.filter(text=value).exists():
raise serializers.ValidationError("duplicate")
return value
class Meta:
model = Item
model = Line
fields = ["id", "text"]
class NoteSerializer(serializers.ModelSerializer):
class PostSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField()
url = serializers.CharField(source="get_absolute_url", read_only=True)
items = ItemSerializer(many=True, read_only=True, source="item_set")
lines = LineSerializer(many=True, read_only=True)
class Meta:
model = Note
fields = ["id", "name", "url", "items"]
model = Post
fields = ["id", "name", "url", "lines"]
class UserSerializer(serializers.ModelSerializer):
class Meta:

View File

@@ -1,7 +1,7 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class BaseAPITest(TestCase):
@@ -11,76 +11,76 @@ class BaseAPITest(TestCase):
self.user = User.objects.create_user("test@example.com")
self.client.force_authenticate(user=self.user)
class NoteDetailAPITest(BaseAPITest):
def test_returns_note_with_items(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note)
Item.objects.create(text="item 2", note=note)
class PostDetailAPITest(BaseAPITest):
def test_returns_post_with_lines(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post)
Line.objects.create(text="line 2", post=post)
response = self.client.get(f"/api/notes/{note.id}/")
response = self.client.get(f"/api/posts/{post.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(note.id))
self.assertEqual(len(response.data["items"]), 2)
self.assertEqual(response.data["id"], str(post.id))
self.assertEqual(len(response.data["lines"]), 2)
class NoteItemsAPITest(BaseAPITest):
def test_can_add_item_to_note(self):
note = Note.objects.create(owner=self.user)
class PostLinesAPITest(BaseAPITest):
def test_can_add_line_to_post(self):
post = Post.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "a new item"},
f"/api/posts/{post.id}/lines/",
{"text": "a new line"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Item.objects.first().text, "a new item")
self.assertEqual(Line.objects.count(), 1)
self.assertEqual(Line.objects.first().text, "a new line")
def test_cannot_add_empty_item_to_note(self):
note = Note.objects.create(owner=self.user)
def test_cannot_add_empty_line_to_post(self):
post = Post.objects.create(owner=self.user)
response = self.client.post(
f"/api/notes/{note.id}/items/",
f"/api/posts/{post.id}/lines/",
{"text": ""},
)
self.assertEqual(response.status_code, 400)
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_cannot_add_duplicate_item_to_note(self):
note = Note.objects.create(owner=self.user)
Item.objects.create(text="note item", note=note)
def test_cannot_add_duplicate_line_to_post(self):
post = Post.objects.create(owner=self.user)
Line.objects.create(text="post line", post=post)
duplicate_response = self.client.post(
f"/api/notes/{note.id}/items/",
{"text": "note item"},
f"/api/posts/{post.id}/lines/",
{"text": "post line"},
)
self.assertEqual(duplicate_response.status_code, 400)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Line.objects.count(), 1)
class NotesAPITest(BaseAPITest):
def test_get_returns_only_users_notes(self):
note1 = Note.objects.create(owner=self.user)
Item.objects.create(text="item 1", note=note1)
class PostsAPITest(BaseAPITest):
def test_get_returns_only_users_posts(self):
post1 = Post.objects.create(owner=self.user)
Line.objects.create(text="line 1", post=post1)
other_user = User.objects.create_user("other@example.com")
Note.objects.create(owner=other_user)
Post.objects.create(owner=other_user)
response = self.client.get("/api/notes/")
response = self.client.get("/api/posts/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(note1.id))
self.assertEqual(response.data[0]["id"], str(post1.id))
def test_post_creates_note_with_item(self):
def test_post_creates_post_with_line(self):
response = self.client.post(
"/api/notes/",
{"text": "first item"},
"/api/posts/",
{"text": "first line"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(Note.objects.first().owner, self.user)
self.assertEqual(Item.objects.first().text, "first item")
self.assertEqual(Post.objects.count(), 1)
self.assertEqual(Post.objects.first().owner, self.user)
self.assertEqual(Line.objects.first().text, "first line")
class UserSearchAPITest(BaseAPITest):
def test_returns_users_matching_username(self):

View File

@@ -1,19 +1,19 @@
from django.test import SimpleTestCase
from apps.api.serializers import ItemSerializer, NoteSerializer
from apps.api.serializers import LineSerializer, PostSerializer
class ItemSerializerTest(SimpleTestCase):
class LineSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ItemSerializer()
serializer = LineSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "text"},
)
class NoteSerializerTest(SimpleTestCase):
class PostSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = NoteSerializer()
serializer = PostSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "name", "url", "items"},
{"id", "name", "url", "lines"},
)

View File

@@ -4,8 +4,8 @@ from . import views
urlpatterns = [
path('notes/', views.NotesAPI.as_view(), name='api_notes'),
path('notes/<uuid:note_id>/', views.NoteDetailAPI.as_view(), name='api_note_detail'),
path('notes/<uuid:note_id>/items/', views.NoteItemsAPI.as_view(), name='api_note_items'),
path('posts/', views.PostsAPI.as_view(), name='api_posts'),
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
]

View File

@@ -2,36 +2,36 @@ from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from apps.api.serializers import ItemSerializer, NoteSerializer, UserSerializer
from apps.dashboard.models import Item, Note
from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class NoteDetailAPI(APIView):
def get(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = NoteSerializer(note)
class PostDetailAPI(APIView):
def get(self, request, post_id):
post = get_object_or_404(Post, id=post_id)
serializer = PostSerializer(post)
return Response(serializer.data)
class NoteItemsAPI(APIView):
def post(self, request, note_id):
note = get_object_or_404(Note, id=note_id)
serializer = ItemSerializer(data=request.data, context={"note": note})
class PostLinesAPI(APIView):
def post(self, request, post_id):
post = get_object_or_404(Post, id=post_id)
serializer = LineSerializer(data=request.data, context={"post": post})
if serializer.is_valid():
serializer.save(note=note)
serializer.save(post=post)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class NotesAPI(APIView):
class PostsAPI(APIView):
def get(self, request):
notes = Note.objects.filter(owner=request.user)
serializer = NoteSerializer(notes, many=True)
posts = Post.objects.filter(owner=request.user)
serializer = PostSerializer(posts, many=True)
return Response(serializer.data)
def post(self, request):
note = Note.objects.create(owner=request.user)
item = Item.objects.create(text=request.data.get("text", ""), note=note)
serializer = NoteSerializer(note)
post = Post.objects.create(owner=request.user)
line = Line.objects.create(text=request.data.get("text", ""), post=post)
serializer = PostSerializer(post)
return Response(serializer.data, status=201)
class UserSearchAPI(APIView):

View File

@@ -0,0 +1,25 @@
from django.db import migrations
def seed_game_kit_applets(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
for slug, name in [
('gk-trinkets', 'Trinkets'),
('gk-tokens', 'Tokens'),
('gk-decks', 'Card Decks'),
('gk-dice', 'Dice Sets'),
]:
Applet.objects.get_or_create(
slug=slug,
defaults={'name': name, 'grid_cols': 3, 'grid_rows': 3, 'context': 'game-kit'},
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0007_fix_billboard_applets'),
]
operations = [
migrations.RunPython(seed_game_kit_applets, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def seed_my_sky_applet(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.get_or_create(
slug='my-sky',
defaults={
'name': 'My Sky',
'grid_cols': 6,
'grid_rows': 6,
'context': 'dashboard',
},
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0008_game_kit_applets'),
]
operations = [
migrations.RunPython(seed_my_sky_applet, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,25 @@
from django.db import migrations
def seed_recognition_applet(apps, schema_editor):
Applet = apps.get_model("applets", "Applet")
Applet.objects.get_or_create(
slug="billboard-recognition",
defaults={
"name": "Recognition",
"grid_cols": 4,
"grid_rows": 4,
"context": "billboard",
},
)
class Migration(migrations.Migration):
dependencies = [
("applets", "0009_my_sky_applet"),
]
operations = [
migrations.RunPython(seed_recognition_applet, migrations.RunPython.noop),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def rename_note_applets_to_post(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-note').update(slug='new-post', name='New Post', context='billboard')
Applet.objects.filter(slug='my-notes').update(slug='my-posts', name='My Posts', context='billboard')
def reverse_rename_note_applets_to_post(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='new-post').update(slug='new-note', name='New Note', context='dashboard')
Applet.objects.filter(slug='my-posts').update(slug='my-notes', name='My Notes', context='dashboard')
class Migration(migrations.Migration):
dependencies = [
('applets', '0010_recognition_applet'),
]
operations = [
migrations.RunPython(rename_note_applets_to_post, reverse_rename_note_applets_to_post),
]

View File

@@ -0,0 +1,31 @@
from django.db import migrations
def rename_recognition_applet_to_notes(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='billboard-recognition').update(
slug='billboard-notes',
name='My Notes',
)
def reverse_rename_recognition_applet_to_notes(apps, schema_editor):
Applet = apps.get_model('applets', 'Applet')
Applet.objects.filter(slug='billboard-notes').update(
slug='billboard-recognition',
name='Recognition',
)
class Migration(migrations.Migration):
dependencies = [
('applets', '0011_rename_note_applets_to_post'),
]
operations = [
migrations.RunPython(
rename_recognition_applet_to_notes,
reverse_rename_recognition_applet_to_notes,
),
]

View File

@@ -28,6 +28,7 @@ document.addEventListener('DOMContentLoaded', initGearMenus);
const appletContainerIds = new Set([
'id_applets_container',
'id_game_applets_container',
'id_gk_sections_container',
'id_wallet_applets_container',
]);

View File

@@ -1,6 +1,16 @@
from apps.applets.models import Applet, UserApplet
def apply_applet_toggle(user, context, checked_slugs):
"""Persist applet visibility choices for a given context."""
for applet in Applet.objects.filter(context=context):
UserApplet.objects.update_or_create(
user=user,
applet=applet,
defaults={"visible": applet.slug in checked_slugs},
)
def applet_context(user, context):
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
applets = {a.slug: a for a in Applet.objects.filter(context=context)}

View File

@@ -0,0 +1,245 @@
(function () {
'use strict';
var _selectedPalette = null;
var _activeItem = null;
var _originalPalette = null;
var _dismissTimer = null;
// ── helpers ──────────────────────────────────────────────────────────────
function _activeModal() {
return _activeItem && _activeItem.querySelector('.note-palette-modal');
}
function _paletteClass(el) {
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
}
function _currentBodyPalette() {
return Array.from(document.body.classList).find(function (c) { return c.startsWith('palette-'); }) || null;
}
function _swapBodyPalette(paletteName) {
var old = _currentBodyPalette();
if (old) document.body.classList.remove(old);
document.body.classList.add(paletteName);
}
function _revertBodyPalette() {
var current = _currentBodyPalette();
if (current) document.body.classList.remove(current);
if (_originalPalette) document.body.classList.add(_originalPalette);
}
function _getCsrf() {
var m = document.cookie.match(/csrftoken=([^;]+)/);
return m ? m[1] : '';
}
function _showConfirm(modal) {
var el = modal && modal.querySelector('.note-palette-confirm');
if (el) el.style.display = 'flex';
}
function _hideConfirm(modal) {
var el = modal && modal.querySelector('.note-palette-confirm');
if (el) el.style.display = 'none';
}
// ── modal lifecycle ───────────────────────────────────────────────────────
function _openModal() {
var existing = _activeModal();
if (!existing) {
var tpl = _activeItem.querySelector('.note-palette-modal-tpl');
if (!tpl) return;
var clone = tpl.content.firstElementChild.cloneNode(true);
_activeItem.appendChild(clone);
_wireModal();
}
_activeItem.classList.add('note-item--active');
_hideConfirm(_activeModal());
}
function _closeModal() {
clearTimeout(_dismissTimer);
_dismissTimer = null;
var modal = _activeModal();
if (modal) modal.remove();
if (_activeItem) _activeItem.classList.remove('note-item--active');
_activeItem = null;
_selectedPalette = null;
_originalPalette = null;
}
function _revertPreview() {
clearTimeout(_dismissTimer);
_dismissTimer = null;
_revertBodyPalette();
var modal = _activeModal();
if (modal) {
modal.querySelectorAll('.note-swatch-body.previewing').forEach(function (s) {
s.classList.remove('previewing');
});
_hideConfirm(modal);
}
_selectedPalette = null;
_originalPalette = null;
}
// Wire event listeners onto the freshly-cloned modal DOM.
function _wireModal() {
var modal = _activeModal();
if (!modal) return;
// Swatch body click → preview palette sitewide + show confirm
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
body.addEventListener('click', function (e) {
e.stopPropagation();
if (_selectedPalette) _revertPreview();
_selectedPalette = _paletteClass(body.parentElement);
_originalPalette = _currentBodyPalette();
body.classList.add('previewing');
_swapBodyPalette(_selectedPalette);
_showConfirm(modal);
_dismissTimer = setTimeout(function () {
_revertPreview();
}, 10000);
});
});
// Confirm OK → commit palette sitewide
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
_doSetPalette();
});
});
// Confirm NVM → revert preview only; main swatch modal stays open
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
btn.addEventListener('click', function (e) {
e.stopPropagation();
_revertPreview();
});
});
// Stop modal clicks from reaching the body dismiss handler.
modal.addEventListener('click', function (e) { e.stopPropagation(); });
}
// ── set-palette POST ──────────────────────────────────────────────────────
function _doSetPalette() {
var url = _activeItem.dataset.setPaletteUrl;
var palette = _selectedPalette;
var item = _activeItem;
// Read label from swatch row while modal is still in DOM
var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]');
var paletteLabel = swatchRow
? swatchRow.dataset.paletteLabel
: palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); });
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': _getCsrf(),
},
body: JSON.stringify({ palette: palette }),
})
.then(function (r) { return r.json(); })
.then(function () {
_closeModal();
var imageBox = item.querySelector('.note-item__image-box');
if (imageBox) {
var swatch = document.createElement('div');
swatch.className = 'note-item__palette ' + palette;
imageBox.parentNode.replaceChild(swatch, imageBox);
}
var list = item.querySelector('.note-recognitions__list');
if (list && !item.querySelector('.note-recognitions__palette-line')) {
var li = document.createElement('li');
li.className = 'note-recognitions__palette-line';
li.innerHTML = '<span class="note-recognitions__dim">Palette:</span> <strong>' + paletteLabel + '</strong>';
list.appendChild(li);
}
});
}
// ── init ──────────────────────────────────────────────────────────────────
function _setGreeting(name) {
var el = document.getElementById('id_greeting_name');
if (el) el.textContent = name;
}
function _bindDonDoff(item) {
var donBtn = item.querySelector('.note-don-btn');
var doffBtn = item.querySelector('.note-doff-btn');
if (!donBtn || !doffBtn) return;
donBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (donBtn.classList.contains('btn-disabled')) return;
fetch(item.dataset.donUrl, {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() },
})
.then(function (r) { return r.json(); })
.then(function (data) {
_setGreeting(data.title);
donBtn.classList.add('btn-disabled');
donBtn.textContent = '×';
doffBtn.classList.remove('btn-disabled');
doffBtn.textContent = 'DOFF';
});
});
doffBtn.addEventListener('click', function (e) {
e.stopPropagation();
if (doffBtn.classList.contains('btn-disabled')) return;
fetch(item.dataset.doffUrl, {
method: 'POST', credentials: 'same-origin',
headers: { 'X-CSRFToken': _getCsrf() },
})
.then(function () {
_setGreeting('Earthman');
doffBtn.classList.add('btn-disabled');
doffBtn.textContent = '×';
donBtn.classList.remove('btn-disabled');
donBtn.textContent = 'DON';
});
});
}
function _init() {
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
box.addEventListener('click', function (e) {
e.stopPropagation();
_activeItem = box.closest('.note-item');
_openModal();
});
});
document.querySelectorAll('.note-item').forEach(function (item) {
_bindDonDoff(item);
});
// Body click → dismiss modal and revert any preview
document.body.addEventListener('click', function () {
if (_selectedPalette) _revertPreview();
_closeModal();
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', _init);
} else {
_init();
}
}());

View File

@@ -1,8 +1,11 @@
import json as _json
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.applets.models import Applet
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
@@ -83,6 +86,18 @@ class BillboardViewTest(TestCase):
self.assertEqual(list(response.context["recent_events"]), [])
class SaveScrollPositionViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
self.url = f"/billboard/room/{self.room.id}/scroll-position/"
def test_get_returns_405(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
class ToggleBillboardAppletsTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="test@toggle.io")
@@ -143,6 +158,174 @@ class BillscrollViewTest(TestCase):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertEqual(response.context["scroll_position"], 250)
def test_scroll_renders_event_body_and_time_columns(self):
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
self.assertContains(response, 'class="drama-event-body"')
self.assertContains(response, 'class="drama-event-time"')
class NotePageViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog@test.io")
self.client.force_login(self.user)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 302)
def test_returns_200(self):
response = self.client.get("/billboard/my-notes/")
self.assertEqual(response.status_code, 200)
def test_uses_note_page_template(self):
response = self.client.get("/billboard/my-notes/")
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
def test_passes_notes_in_context(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertIn(recog, response.context["notes"])
def test_excludes_other_users_notes(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(
user=other, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertEqual(list(response.context["notes"]), [])
def test_renders_recog_list_and_items(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-list"')
self.assertContains(response, 'class="note-item"')
def test_renders_recog_item_title_description_image_box(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-item__title"')
self.assertContains(response, 'class="note-item__description"')
self.assertContains(response, 'class="note-item__image-box"')
def test_palette_modal_renders_swatch_labels(self):
"""Each palette option in the swatch modal should display its human-readable
label next to the swatch body so the user knows what they are choosing."""
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now()
)
response = self.client.get("/billboard/my-notes/")
self.assertContains(response, 'class="note-swatch-label"')
self.assertContains(response, "Bardo")
self.assertContains(response, "Sheol")
class NoteSetPaletteViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="setpal@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.url = "/billboard/note/stargazer/set-palette"
def test_requires_login(self):
self.client.logout()
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 302)
def test_sets_palette_on_note(self):
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.note.refresh_from_db()
self.assertEqual(self.note.palette, "palette-bardo")
def test_returns_200_with_ok(self):
response = self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_returns_404_for_slug_user_does_not_own(self):
response = self.client.post(
"/billboard/note/schizo/set-palette",
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.assertEqual(response.status_code, 404)
def test_also_saves_user_palette(self):
"""note_set_palette must persist the choice to user.palette so the
palette survives page navigation (sitewide commitment)."""
self.client.post(
self.url,
data=_json.dumps({"palette": "palette-bardo"}),
content_type="application/json",
)
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
class NoteEquipTitleViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="don@test.io")
self.client.force_login(self.user)
self.note = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
def test_don_sets_active_title(self):
self.client.post("/billboard/note/stargazer/don")
self.user.refresh_from_db()
self.assertEqual(self.user.active_title, self.note)
def test_doff_clears_active_title(self):
self.user.active_title = self.note
self.user.save(update_fields=["active_title"])
self.client.post("/billboard/note/stargazer/doff")
self.user.refresh_from_db()
self.assertIsNone(self.user.active_title)
def test_don_returns_200_with_title(self):
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["title"], "Stargazer")
def test_doff_returns_200(self):
response = self.client.post("/billboard/note/stargazer/doff")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"ok": True})
def test_don_requires_login(self):
self.client.logout()
response = self.client.post("/billboard/note/stargazer/don")
self.assertEqual(response.status_code, 302)
def test_don_returns_404_for_unowned_note(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.client.logout()
self.client.force_login(other)
response = self.client.post("/billboard/note/stargazer/don")
# other user's own note — should work
self.assertEqual(response.status_code, 200)
class SaveScrollPositionTest(TestCase):
def setUp(self):

View File

@@ -7,6 +7,10 @@ app_name = "billboard"
urlpatterns = [
path("", views.billboard, name="billboard"),
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
path("my-notes/", views.my_notes, name="my_notes"),
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
path("note/<slug:slug>/don", views.don_title, name="don_title"),
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
]

View File

@@ -1,20 +1,35 @@
import json
from django.contrib.auth.decorators import login_required
from django.db.models import Max, Q
from django.http import JsonResponse
from django.shortcuts import redirect, render
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.drama.models import GameEvent, ScrollPosition
from apps.epic.models import GateSlot, Room, RoomInvite
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.dashboard.forms import LineForm
from apps.dashboard.models import Post
from apps.dashboard.views import _PALETTE_DEFS
from apps.drama.models import GameEvent, Note, ScrollPosition
from apps.epic.models import Room
from apps.epic.utils import rooms_for_user
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
def _recent_posts(user, limit=3):
return (
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
@login_required(login_url="/")
def billboard(request):
my_rooms = Room.objects.filter(
Q(owner=request.user) |
Q(gate_slots__gamer=request.user) |
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
).distinct().order_by("-created_at")
my_rooms = rooms_for_user(request.user).order_by("-created_at")
recent_room = (
Room.objects.filter(
@@ -27,7 +42,13 @@ def billboard(request):
.first()
)
recent_events = (
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
list(
recent_room.events
.select_related("actor")
.exclude(verb=GameEvent.SIG_UNREADY)
.exclude(verb=GameEvent.SIG_READY, data__retracted=True)
.order_by("-timestamp")[:36]
)[::-1]
if recent_room else []
)
@@ -37,6 +58,8 @@ def billboard(request):
"recent_events": recent_events,
"viewer": request.user,
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
"page_class": "page-billboard",
})
@@ -44,15 +67,12 @@ def billboard(request):
@login_required(login_url="/")
def toggle_billboard_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="billboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
apply_applet_toggle(request.user, "billboard", checked)
if request.headers.get("HX-Request"):
return render(request, "apps/billboard/_partials/_applets.html", {
"applets": applet_context(request.user, "billboard"),
"form": LineForm(),
"recent_posts": _recent_posts(request.user),
})
return redirect("billboard:billboard")
@@ -71,6 +91,93 @@ def room_scroll(request, room_id):
})
def _palette_opts(names):
return [{"name": n, "label": _PALETTE_LABELS.get(n, n)} for n in names]
_NOTE_META = {
"stargazer": {
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
},
"schizo": {
"title": "Schizo",
"description": "The socius recognizes the line of flight.",
"palette_options": [],
},
"nomad": {
"title": "Nomad",
"description": "The socius recognizes the smooth space.",
"palette_options": [],
},
}
@login_required(login_url="/")
def note_set_palette(request, slug):
from django.http import Http404
from apps.dashboard.views import _unlocked_palettes_for_user
try:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
body = json.loads(request.body)
palette = body.get("palette", "")
note.palette = palette
note.save(update_fields=["palette"])
# Commit as the user's active sitewide palette now that the Note unlocks it.
if palette in _unlocked_palettes_for_user(request.user):
request.user.palette = palette
request.user.save(update_fields=["palette"])
return JsonResponse({"ok": True})
@login_required(login_url="/")
def my_notes(request):
qs = Note.objects.filter(user=request.user)
active_title = request.user.active_title
note_items = [
{
"obj": n,
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
"is_equipped": active_title is not None and active_title.pk == n.pk,
}
for n in qs
]
return render(request, "apps/billboard/my_notes.html", {
"notes": qs,
"note_items": note_items,
"page_class": "page-notes",
})
@login_required(login_url="/")
def don_title(request, slug):
from django.http import Http404
try:
note = Note.objects.get(user=request.user, slug=slug)
except Note.DoesNotExist:
raise Http404
if request.method == "POST":
request.user.active_title = note
request.user.save(update_fields=["active_title"])
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize())
return JsonResponse({"title": title})
@login_required(login_url="/")
def doff_title(request, slug):
if request.method == "POST":
request.user.active_title = None
request.user.save(update_fields=["active_title"])
return JsonResponse({"ok": True})
@login_required(login_url="/")
def save_scroll_position(request, room_id):
if request.method != "POST":

View File

@@ -1,32 +1,32 @@
from django import forms
from django.core.exceptions import ValidationError
from .models import Item
from .models import Line
DUPLICATE_ITEM_ERROR = "You've already logged this to your note"
EMPTY_ITEM_ERROR = "You can't have an empty note item"
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
EMPTY_LINE_ERROR = "You can't have an empty post line"
class ItemForm(forms.Form):
class LineForm(forms.Form):
text = forms.CharField(
error_messages = {"required": EMPTY_ITEM_ERROR},
error_messages = {"required": EMPTY_LINE_ERROR},
required=True,
)
def save(self, for_note):
return Item.objects.create(
note=for_note,
def save(self, for_post):
return Line.objects.create(
post=for_post,
text=self.cleaned_data["text"],
)
class ExistingNoteItemForm(ItemForm):
def __init__(self, for_note, *args, **kwargs):
class ExistingPostLineForm(LineForm):
def __init__(self, for_post, *args, **kwargs):
super().__init__(*args, **kwargs)
self._for_note = for_note
self._for_post = for_post
def clean_text(self):
text = self.cleaned_data["text"]
if self._for_note.item_set.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
if self._for_post.lines.filter(text=text).exists():
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
return text
def save(self):
return super().save(for_note=self._for_note)
return super().save(for_post=self._for_post)

View File

@@ -0,0 +1,18 @@
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0003_alter_note_owner_alter_note_shared_with'),
]
operations = [
migrations.RenameModel('Note', 'Post'),
migrations.RenameModel('Item', 'Line'),
migrations.RenameField('Line', 'note', 'post'),
migrations.AlterUniqueTogether(
name='line',
unique_together={('post', 'text')},
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2026-04-23 05:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0004_rename_note_to_post'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AlterField(
model_name='line',
name='post',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='dashboard.post'),
),
migrations.AlterField(
model_name='post',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='post',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -4,11 +4,11 @@ from django.db import models
from django.urls import reverse
class Note(models.Model):
class Post(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey(
"lyric.User",
related_name="notes",
related_name="posts",
blank=True,
null=True,
on_delete=models.CASCADE,
@@ -16,24 +16,24 @@ class Note(models.Model):
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_notes",
related_name="shared_posts",
blank=True,
)
@property
def name(self):
return self.item_set.first().text
return self.lines.first().text
def get_absolute_url(self):
return reverse("view_note", args=[self.id])
return reverse("view_post", args=[self.id])
class Item(models.Model):
class Line(models.Model):
text = models.TextField(default="")
note = models.ForeignKey(Note, default=None, on_delete=models.CASCADE)
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
class Meta:
ordering = ("id",)
unique_together = ("note", "text")
unique_together = ("post", "text")
def __str__(self):
return self.text

View File

@@ -1,12 +1,7 @@
// console.log("apps/scripts/dashboard.js loading");
const initialize = (inputSelector) => {
// console.log("initialize called!");
const textInput = document.querySelector(inputSelector);
if (!textInput) return;
textInput.oninput = () => {
// console.log("oninput triggered");
textInput.classList.remove("is-invalid");
};
textInput.oninput = () => textInput.classList.remove("is-invalid");
};
const bindPaletteWheel = () => {
@@ -18,6 +13,127 @@ const bindPaletteWheel = () => {
});
};
// ── Palette swatch preview + commit ──────────────────────────────────────────
const bindPaletteSwatches = () => {
const portal = document.getElementById('id_tooltip_portal');
let activePreview = null;
let originalPalette = null;
let dismissTimer = null;
function currentBodyPalette() {
return [...document.body.classList].find(c => c.startsWith('palette-'));
}
function swapPalette(paletteName) {
const old = currentBodyPalette();
if (old) document.body.classList.remove(old);
document.body.classList.add(paletteName);
}
function showTooltip(swatch) {
if (!portal) return;
const label = swatch.dataset.label || '';
const locked = swatch.dataset.locked === 'true';
const date = swatch.dataset.unlockedDate || '';
const shoptalk = swatch.dataset.shoptalk || '';
const lockIcon = locked ? 'fa-lock' : 'fa-lock-open';
const lockText = locked ? 'Locked' : `Unlocked — ${date}`.trim();
portal.innerHTML = `
<h4 class="tt-title">${label}</h4>
${shoptalk ? `<p class="tt-shoptalk"><em>${shoptalk}</em></p>` : ''}
<p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>`;
const rect = swatch.getBoundingClientRect();
portal.style.display = 'block';
portal.style.position = 'fixed';
portal.style.top = `${rect.bottom + 8}px`;
portal.style.zIndex = '9999';
const margin = 8;
const ttW = portal.offsetWidth;
portal.style.left = `${Math.max(margin, Math.min(rect.left, window.innerWidth - ttW - margin))}px`;
}
function hideTooltip() {
if (!portal) return;
portal.style.display = 'none';
portal.innerHTML = '';
}
function dismiss() {
if (!activePreview) return;
clearTimeout(dismissTimer);
const paletteName = activePreview.dataset.palette;
activePreview.classList.remove('previewing');
activePreview.querySelector('.palette-ok').style.display = '';
document.body.classList.remove(paletteName);
if (originalPalette) document.body.classList.add(originalPalette);
activePreview = null;
originalPalette = null;
hideTooltip();
}
async function commitPalette(swatch, paletteName) {
// Silent commit — no animation, wipe already happened on preview
const old = originalPalette;
swatch.classList.remove('previewing');
swatch.querySelector('.palette-ok').style.display = '';
hideTooltip();
activePreview = null;
originalPalette = null;
clearTimeout(dismissTimer);
// Remove old palette, keep new one (already on body from preview)
if (old && old !== paletteName) {
document.body.classList.remove(old);
}
// Update active indicator
document.querySelectorAll('.swatch').forEach(sw => {
sw.classList.toggle('active', sw.classList.contains(paletteName));
});
// POST to server
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
await fetch('/dashboard/set_palette', {
method: 'POST',
headers: { 'Accept': 'application/json', 'X-CSRFToken': csrf },
body: new URLSearchParams({ palette: paletteName }),
});
}
document.querySelectorAll('.palette-item .swatch').forEach(swatch => {
swatch.addEventListener('click', async (e) => {
e.stopPropagation();
if (swatch.classList.contains('previewing')) return;
dismiss(); // clear any existing preview
originalPalette = currentBodyPalette();
activePreview = swatch;
swatch.classList.add('previewing');
showTooltip(swatch);
swapPalette(swatch.dataset.palette);
swatch.querySelector('.palette-ok').style.display = 'flex';
// Auto-dismiss after 10s
dismissTimer = setTimeout(dismiss, 10000);
});
const okBtn = swatch.querySelector('.btn-confirm.palette-ok');
if (okBtn) {
okBtn.addEventListener('click', async (e) => {
e.stopPropagation();
await commitPalette(swatch, swatch.dataset.palette);
});
}
});
document.addEventListener('click', () => dismiss());
};
const bindPaletteForms = () => {
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
form.addEventListener("submit", async (e) => {
@@ -29,12 +145,10 @@ const bindPaletteForms = () => {
});
if (!resp.ok) return;
const { palette } = await resp.json();
// Swap body palette class
[...document.body.classList]
.filter(c => c.startsWith("palette-"))
.forEach(c => document.body.classList.remove(c));
document.body.classList.add(palette);
// Update active swatch indicator
document.querySelectorAll(".swatch").forEach(sw => {
sw.classList.toggle("active", sw.classList.contains(palette));
});

View File

@@ -60,7 +60,7 @@
function attachTooltip(el) {
el.addEventListener('mouseenter', function () {
var tooltip = el.querySelector('.token-tooltip');
var tooltip = el.querySelector('.tt');
if (!tooltip) return;
var rect = el.getBoundingClientRect();
tooltip.style.position = 'fixed';
@@ -69,11 +69,16 @@
tooltip.style.display = 'block';
});
el.addEventListener('mouseleave', function () {
var tooltip = el.querySelector('.token-tooltip');
var tooltip = el.querySelector('.tt');
if (tooltip) tooltip.style.display = '';
});
}
// gameboard.js re-fetches dialog content after DON and fires this event.
dialog.addEventListener('kit-content-refreshed', function () {
attachCardListeners();
});
function attachCardListeners() {
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
card.addEventListener('click', function () {

View File

@@ -0,0 +1,49 @@
const Note = (() => {
'use strict';
function showBanner(note) {
if (!note) return;
const earned = new Date(note.earned_at);
const dateStr = earned.toLocaleDateString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
});
const banner = document.createElement('div');
banner.className = 'note-banner';
banner.innerHTML =
'<div class="note-banner__body">' +
'<p class="note-banner__title">' + _esc(note.title) + '</p>' +
'<p class="note-banner__description">' + _esc(note.description) + '</p>' +
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' +
dateStr +
'</time>' +
'</div>' +
'<div class="note-banner__image"></div>' +
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
'<a href="/billboard/my-notes/" class="btn btn-caution note-banner__fyi">FYI</a>';
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
banner.remove();
});
var h2 = document.querySelector('h2');
if (h2 && h2.parentNode) {
h2.parentNode.insertBefore(banner, h2.nextSibling);
} else {
document.body.insertBefore(banner, document.body.firstChild);
}
}
function handleSaveResponse(data) {
showBanner(data && data.note);
}
function _esc(str) {
var d = document.createElement('div');
d.textContent = str || '';
return d.innerHTML;
}
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
})();

View File

@@ -69,7 +69,7 @@ function initWalletTooltips() {
if (!portal) return;
document.querySelectorAll('.wallet-tokens .token').forEach(token => {
const tooltip = token.querySelector('.token-tooltip');
const tooltip = token.querySelector('.tt');
if (!tooltip) return;
token.addEventListener('mouseenter', () => {

View File

@@ -1,41 +1,41 @@
from django.test import TestCase
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
ExistingNoteItemForm,
ItemForm,
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
ExistingPostLineForm,
LineForm,
)
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
class ItemFormTest(TestCase):
def test_form_save_handles_saving_to_a_note(self):
mynote = Note.objects.create()
form = ItemForm(data={"text": "do re mi"})
class LineFormTest(TestCase):
def test_form_save_handles_saving_to_a_post(self):
mypost = Post.objects.create()
form = LineForm(data={"text": "do re mi"})
self.assertTrue(form.is_valid())
new_item = form.save(for_note=mynote)
self.assertEqual(new_item, Item.objects.get())
self.assertEqual(new_item.text, "do re mi")
self.assertEqual(new_item.note, mynote)
new_line = form.save(for_post=mypost)
self.assertEqual(new_line, Line.objects.get())
self.assertEqual(new_line.text, "do re mi")
self.assertEqual(new_line.post, mypost)
class ExistingNoteItemFormTest(TestCase):
def test_form_validation_for_blank_items(self):
note = Note.objects.create()
form = ExistingNoteItemForm(for_note=note, data={"text": ""})
class ExistingPostLineFormTest(TestCase):
def test_form_validation_for_blank_lines(self):
post = Post.objects.create()
form = ExistingPostLineForm(for_post=post, data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])
def test_form_validation_for_duplicate_items(self):
note = Note.objects.create()
Item.objects.create(note=note, text="twins, basil")
form = ExistingNoteItemForm(for_note=note, data={"text": "twins, basil"})
def test_form_validation_for_duplicate_lines(self):
post = Post.objects.create()
Line.objects.create(post=post, text="twins, basil")
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
def test_form_save(self):
mynote = Note.objects.create()
form = ExistingNoteItemForm(for_note=mynote, data={"text": "howdy"})
mypost = Post.objects.create()
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
self.assertTrue(form.is_valid())
new_item = form.save()
self.assertEqual(new_item, Item.objects.get())
new_line = form.save()
self.assertEqual(new_line, Line.objects.get())

View File

@@ -2,69 +2,69 @@ from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.lyric.models import User
class ItemModelTest(TestCase):
def test_item_is_related_to_note(self):
mynote = Note.objects.create()
item = Item()
item.note = mynote
item.save()
self.assertIn(item, mynote.item_set.all())
class LineModelTest(TestCase):
def test_line_is_related_to_post(self):
mypost = Post.objects.create()
line = Line()
line.post = mypost
line.save()
self.assertIn(line, mypost.lines.all())
def test_cannot_save_null_note_items(self):
mynote = Note.objects.create()
item = Item(note=mynote, text=None)
def test_cannot_save_null_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text=None)
with self.assertRaises(IntegrityError):
item.save()
line.save()
def test_cannot_save_empty_note_items(self):
mynote = Note.objects.create()
item = Item(note=mynote, text="")
def test_cannot_save_empty_post_lines(self):
mypost = Post.objects.create()
line = Line(post=mypost, text="")
with self.assertRaises(ValidationError):
item.full_clean()
line.full_clean()
def test_duplicate_items_are_invalid(self):
mynote = Note.objects.create()
Item.objects.create(note=mynote, text="jklol")
def test_duplicate_lines_are_invalid(self):
mypost = Post.objects.create()
Line.objects.create(post=mypost, text="jklol")
with self.assertRaises(ValidationError):
item = Item(note=mynote, text="jklol")
item.full_clean()
line = Line(post=mypost, text="jklol")
line.full_clean()
def test_still_can_save_same_item_to_different_notes(self):
note1 = Note.objects.create()
note2 = Note.objects.create()
Item.objects.create(note=note1, text="nojk")
item = Item(note=note2, text="nojk")
item.full_clean() # should not raise
def test_still_can_save_same_line_to_different_posts(self):
post1 = Post.objects.create()
post2 = Post.objects.create()
Line.objects.create(post=post1, text="nojk")
line = Line(post=post2, text="nojk")
line.full_clean() # should not raise
class NoteModelTest(TestCase):
class PostModelTest(TestCase):
def test_get_absolute_url(self):
mynote = Note.objects.create()
self.assertEqual(mynote.get_absolute_url(), f"/dashboard/note/{mynote.id}/")
mypost = Post.objects.create()
self.assertEqual(mypost.get_absolute_url(), f"/dashboard/post/{mypost.id}/")
def test_note_items_order(self):
note1 = Note.objects.create()
item1 = Item.objects.create(note=note1, text="i1")
item2 = Item.objects.create(note=note1, text="item 2")
item3 = Item.objects.create(note=note1, text="3")
def test_post_lines_order(self):
post1 = Post.objects.create()
line1 = Line.objects.create(post=post1, text="i1")
line2 = Line.objects.create(post=post1, text="line 2")
line3 = Line.objects.create(post=post1, text="3")
self.assertEqual(
list(note1.item_set.all()),
[item1, item2, item3],
list(post1.lines.all()),
[line1, line2, line3],
)
def test_notes_can_have_owners(self):
def test_posts_can_have_owners(self):
user = User.objects.create(email="a@b.cde")
mynote = Note.objects.create(owner=user)
self.assertIn(mynote, user.notes.all())
mypost = Post.objects.create(owner=user)
self.assertIn(mypost, user.posts.all())
def test_note_owner_is_optional(self):
Note.objects.create()
def test_post_owner_is_optional(self):
Post.objects.create()
def test_note_name_is_first_item_text(self):
note = Note.objects.create()
Item.objects.create(note=note, text="first item")
Item.objects.create(note=note, text="second item")
self.assertEqual(note.name, "first item")
def test_post_name_is_first_line_text(self):
post = Post.objects.create()
Line.objects.create(post=post, text="first line")
Line.objects.create(post=post, text="second line")
self.assertEqual(post.name, "first line")

View File

@@ -0,0 +1,290 @@
"""Integration tests for the My Sky dashboard views.
sky_view — GET /dashboard/sky/ → renders sky template
sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write)
sky_save — POST /dashboard/sky/save → saves natal data to User model;
grants Stargazer Note on first save with real chart_data
"""
import json
from unittest.mock import patch, MagicMock
from django.test import TestCase
from django.urls import reverse
from apps.drama.models import Note
from apps.lyric.models import User
class SkyViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
def test_sky_view_renders_template(self):
response = self.client.get(reverse("sky"))
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/sky.html")
def test_sky_view_requires_login(self):
self.client.logout()
response = self.client.get(reverse("sky"))
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_sky_view_passes_preview_and_save_urls(self):
response = self.client.get(reverse("sky"))
self.assertContains(response, reverse("sky_preview"))
self.assertContains(response, reverse("sky_save"))
class SkyPreviewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
self.url = reverse("sky_preview")
def test_requires_login(self):
self.client.logout()
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_missing_params_returns_400(self):
response = self.client.get(self.url, {"date": "1990-06-15"})
self.assertEqual(response.status_code, 400)
def test_invalid_lat_returns_400(self):
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
self.assertEqual(response.status_code, 400)
@patch("apps.dashboard.views.http_requests")
def test_proxies_to_pyswiss_and_returns_chart(self, mock_requests):
chart_payload = {
"planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}},
"houses": {"cusps": [0]*12},
"elements": {"Fire": 1, "Earth": 0, "Air": 0, "Water": 0},
"house_system": "O",
}
tz_response = MagicMock()
tz_response.json.return_value = {"timezone": "Europe/London"}
tz_response.raise_for_status = MagicMock()
chart_response = MagicMock()
chart_response.json.return_value = chart_payload
chart_response.raise_for_status = MagicMock()
mock_requests.get.side_effect = [tz_response, chart_response]
response = self.client.get(self.url, {
"date": "1990-06-15", "time": "09:30",
"lat": "51.5074", "lon": "-0.1278",
})
self.assertEqual(response.status_code, 200)
data = response.json()
self.assertIn("planets", data)
# Earth→Stone rename applied
self.assertIn("Stone", data["elements"])
self.assertNotIn("Earth", data["elements"])
self.assertIn("timezone", data)
self.assertIn("distinctions", data)
class SkySaveTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
self.url = reverse("sky_save")
def _post(self, payload):
return self.client.post(
self.url,
data=json.dumps(payload),
content_type="application/json",
)
def test_requires_login(self):
self.client.logout()
response = self._post({})
self.assertEqual(response.status_code, 302)
self.assertIn("/?next=", response["Location"])
def test_get_not_allowed(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 405)
def test_saves_sky_fields_to_user(self):
payload = {
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5074,
"birth_lon": -0.1278,
"birth_place": "London, UK",
"house_system": "O",
"chart_data": {},
}
response = self._post(payload)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertEqual(str(self.user.sky_birth_dt), "1990-06-15 08:30:00+00:00")
self.assertAlmostEqual(float(self.user.sky_birth_lat), 51.5074, places=3)
self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3)
self.assertEqual(self.user.sky_birth_place, "London, UK")
self.assertEqual(self.user.sky_house_system, "O")
def test_invalid_json_returns_400(self):
response = self.client.post(
self.url, data="not json", content_type="application/json"
)
self.assertEqual(response.status_code, 400)
def test_response_contains_saved_flag(self):
payload = {
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5,
"birth_lon": -0.1,
"birth_place": "",
"house_system": "O",
"chart_data": {},
}
data = self._post(payload).json()
self.assertTrue(data["saved"])
def test_invalid_birth_dt_string_sets_sky_birth_dt_to_none(self):
payload = {
"birth_dt": "not-a-date",
"birth_lat": 51.5,
"birth_lon": -0.1,
"birth_place": "",
"house_system": "O",
"chart_data": {},
}
response = self._post(payload)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertIsNone(self.user.sky_birth_dt)
class SkyPreviewErrorPathTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="star2@test.io")
self.client.force_login(self.user)
self.url = reverse("sky_preview")
def test_non_numeric_lat_returns_400(self):
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"})
self.assertEqual(response.status_code, 400)
def test_invalid_tz_string_returns_400(self):
response = self.client.get(
self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/ATimezone"}
)
self.assertEqual(response.status_code, 400)
def test_bad_date_format_returns_400(self):
response = self.client.get(
self.url,
{"date": "not-a-date", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"},
)
self.assertEqual(response.status_code, 400)
@patch("apps.dashboard.views.http_requests")
def test_pyswiss_tz_failure_falls_back_to_utc_and_continues(self, mock_requests):
chart_payload = {
"planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}},
"houses": {"cusps": [0] * 12},
"elements": {},
"house_system": "O",
}
tz_response = MagicMock()
tz_response.raise_for_status.side_effect = Exception("tz timeout")
chart_response = MagicMock()
chart_response.json.return_value = chart_payload
chart_response.raise_for_status = MagicMock()
mock_requests.get.side_effect = [tz_response, chart_response]
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["timezone"], "UTC")
@patch("apps.dashboard.views.http_requests")
def test_pyswiss_chart_failure_returns_502(self, mock_requests):
tz_response = MagicMock()
tz_response.json.return_value = {"timezone": "UTC"}
tz_response.raise_for_status = MagicMock()
chart_response = MagicMock()
chart_response.raise_for_status.side_effect = Exception("chart timeout")
mock_requests.get.side_effect = [tz_response, chart_response]
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
self.assertEqual(response.status_code, 502)
_REAL_CHART = {
"planets": {"Sun": {"degree": 66.7, "sign": "Gemini", "retrograde": False}},
"houses": {"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150]},
"elements": {"Fire": 1, "Stone": 2, "Air": 4, "Water": 0, "Time": 1, "Space": 1},
"aspects": [],
}
class SkySaveNoteTest(TestCase):
"""sky_save grants the Stargazer Note on the first save with real chart_data."""
def setUp(self):
self.user = User.objects.create(email="star@test.io")
self.client.force_login(self.user)
self.url = reverse("sky_save")
def _post(self, chart_data=_REAL_CHART):
return self.client.post(
self.url,
data=json.dumps({
"birth_dt": "1990-06-15T08:30:00",
"birth_lat": 51.5074,
"birth_lon": -0.1278,
"birth_place": "London, UK",
"birth_tz": "Europe/London",
"house_system": "O",
"chart_data": chart_data,
}),
content_type="application/json",
)
def test_first_save_with_chart_data_returns_stargazer_note(self):
data = self._post().json()
self.assertIn("note", data)
recog = data["note"]
self.assertEqual(recog["slug"], "stargazer")
self.assertIn("title", recog)
self.assertIn("description", recog)
self.assertIn("earned_at", recog)
def test_first_save_creates_note_in_db(self):
self._post()
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_second_save_returns_null_note(self):
self._post()
data = self._post().json()
self.assertIsNone(data["note"])
def test_second_save_does_not_create_duplicate_note(self):
self._post()
self._post()
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
def test_save_with_empty_chart_data_does_not_grant_note(self):
data = self._post(chart_data={}).json()
self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
def test_save_with_null_chart_data_does_not_grant_note(self):
data = self._post(chart_data=None).json()
self.assertIsNone(data["note"])
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)

View File

@@ -1,16 +1,19 @@
import json
import lxml.html
from unittest.mock import patch, MagicMock
from django.contrib.messages import get_messages
from django.test import override_settings, TestCase
from django.urls import reverse
from django.utils import html
from django.utils import html, timezone
from apps.applets.models import Applet, UserApplet
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
DUPLICATE_LINE_ERROR,
EMPTY_LINE_ERROR,
)
from apps.dashboard.models import Item, Note
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.lyric.models import User
@@ -18,64 +21,59 @@ class HomePageTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
def test_uses_home_template(self):
response = self.client.get('/')
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
def test_renders_input_form(self):
response = self.client.get('/')
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[method=POST]')
self.assertIn("/dashboard/new_note", [form.get("action") for form in forms])
[form] = [form for form in forms if form.get("action") == "/dashboard/new_note"]
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
class NewNoteTest(TestCase):
@override_settings(COMPRESS_ENABLED=False)
class NewPostTest(TestCase):
def setUp(self):
user = User.objects.create(email="disco@test.io")
self.client.force_login(user)
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(
slug="new-post",
defaults={"name": "New Post", "context": "billboard", "grid_cols": 9, "grid_rows": 3},
)
def test_can_save_a_POST_request(self):
self.client.post("/dashboard/new_note", data={"text": "A new note item"})
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new note item")
self.client.post("/dashboard/new_post", data={"text": "A new post line"})
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new post line")
def test_redirects_after_POST(self):
response = self.client.post("/dashboard/new_note", data={"text": "A new note item"})
new_note = Note.objects.get()
self.assertRedirects(response, f"/dashboard/note/{new_note.id}/")
response = self.client.post("/dashboard/new_post", data={"text": "A new post line"})
new_post = Post.objects.get()
self.assertRedirects(response, f"/dashboard/post/{new_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
return self.client.post("/dashboard/new_note", data={"text": ""})
return self.client.post("/dashboard/new_post", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_home_template(self):
def test_for_invalid_input_renders_billboard_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/home.html")
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
@override_settings(COMPRESS_ENABLED=False)
class NoteViewTest(TestCase):
def test_uses_note_template(self):
mynote = Note.objects.create()
response = self.client.get(f"/dashboard/note/{mynote.id}/")
self.assertTemplateUsed(response, "apps/dashboard/note.html")
class PostViewTest(TestCase):
def test_uses_post_template(self):
mypost = Post.objects.create()
response = self.client.get(f"/dashboard/post/{mypost.id}/")
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_renders_input_form(self):
mynote = Note.objects.create()
url = f"/dashboard/note/{mynote.id}/"
mypost = Post.objects.create()
url = f"/dashboard/post/{mypost.id}/"
response = self.client.get(url)
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]")
@@ -84,62 +82,62 @@ class NoteViewTest(TestCase):
inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs])
def test_displays_only_items_for_that_note(self):
def test_displays_only_lines_for_that_post(self):
# Given/Arrange
correct_note = Note.objects.create()
Item.objects.create(text="itemey 1", note=correct_note)
Item.objects.create(text="itemey 2", note=correct_note)
other_note = Note.objects.create()
Item.objects.create(text="other note item", note=other_note)
correct_post = Post.objects.create()
Line.objects.create(text="itemey 1", post=correct_post)
Line.objects.create(text="itemey 2", post=correct_post)
other_post = Post.objects.create()
Line.objects.create(text="other post line", post=other_post)
# When/Act
response = self.client.get(f"/dashboard/note/{correct_note.id}/")
response = self.client.get(f"/dashboard/post/{correct_post.id}/")
# Then/Assert
self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2")
self.assertNotContains(response, "other note item")
self.assertNotContains(response, "other post line")
def test_can_save_a_POST_request_to_an_existing_note(self):
other_note = Note.objects.create()
correct_note = Note.objects.create()
def test_can_save_a_POST_request_to_an_existing_post(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
self.client.post(
f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing note"},
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new item for an existing note")
self.assertEqual(new_item.note, correct_note)
self.assertEqual(Line.objects.count(), 1)
new_line = Line.objects.get()
self.assertEqual(new_line.text, "A new line for an existing post")
self.assertEqual(new_line.post, correct_post)
def test_POST_redirects_to_note_view(self):
other_note = Note.objects.create()
correct_note = Note.objects.create()
def test_POST_redirects_to_post_view(self):
other_post = Post.objects.create()
correct_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{correct_note.id}/",
data={"text": "A new item for an existing note"},
f"/dashboard/post/{correct_post.id}/",
data={"text": "A new line for an existing post"},
)
self.assertRedirects(response, f"/dashboard/note/{correct_note.id}/")
self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/")
# Post invalid input helper
def post_invalid_input(self):
mynote = Note.objects.create()
return self.client.post(f"/dashboard/note/{mynote.id}/", data={"text": ""})
mypost = Post.objects.create()
return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input()
self.assertEqual(Item.objects.count(), 0)
self.assertEqual(Line.objects.count(), 0)
def test_for_invalid_input_renders_note_template(self):
def test_for_invalid_input_renders_post_template(self):
response = self.post_invalid_input()
self.assertEqual(response.status_code, 200)
self.assertTemplateUsed(response, "apps/dashboard/note.html")
self.assertTemplateUsed(response, "apps/dashboard/post.html")
def test_for_invalid_input_shows_error_on_page(self):
response = self.post_invalid_input()
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
def test_for_invalid_input_sets_is_invalid_class(self):
response = self.post_invalid_input()
@@ -147,26 +145,26 @@ class NoteViewTest(TestCase):
[input] = parsed.cssselect("input[name=text]")
self.assertIn("is-invalid", set(input.classes))
def test_duplicate_item_validation_errors_end_up_on_note_page(self):
note1 = Note.objects.create()
Item.objects.create(note=note1, text="lorem ipsum")
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
post1 = Post.objects.create()
Line.objects.create(post=post1, text="lorem ipsum")
response = self.client.post(
f"/dashboard/note/{note1.id}/",
f"/dashboard/post/{post1.id}/",
data={"text": "lorem ipsum"},
)
expected_error = html.escape(DUPLICATE_ITEM_ERROR)
expected_error = html.escape(DUPLICATE_LINE_ERROR)
self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/note.html")
self.assertEqual(Item.objects.all().count(), 1)
self.assertTemplateUsed(response, "apps/dashboard/post.html")
self.assertEqual(Line.objects.all().count(), 1)
class MyNotesTest(TestCase):
def test_my_notes_url_renders_my_notes_template(self):
class MyPostsTest(TestCase):
def test_my_posts_url_renders_my_posts_template(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_notes.html")
self.assertTemplateUsed(response, "apps/dashboard/my_posts.html")
def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com")
@@ -175,71 +173,69 @@ class MyNotesTest(TestCase):
response = self.client.get(f"/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user)
def test_note_owner_is_saved_if_user_is_authenticated(self):
def test_post_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
self.client.post("/dashboard/new_note", data={"text": "new item"})
new_note = Note.objects.get()
self.assertEqual(new_note.owner, user)
self.client.post("/dashboard/new_post", data={"text": "new line"})
new_post = Post.objects.get()
self.assertEqual(new_post.owner, user)
def test_my_notes_redirects_if_not_logged_in(self):
def test_my_posts_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertRedirects(response, "/")
def test_my_notes_returns_403_for_wrong_user(self):
# create two users, login as user_a, request user_b's my_notes url
def test_my_posts_returns_403_for_wrong_user(self):
user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2)
response = self.client.get(f"/dashboard/users/{user1.id}/")
# assert 403
self.assertEqual(response.status_code, 403)
class ShareNoteTest(TestCase):
def test_post_to_share_note_url_redirects_to_note(self):
our_note = Note.objects.create()
class SharePostTest(TestCase):
def test_post_to_share_post_url_redirects_to_post(self):
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/dashboard/note/{our_note.id}/")
self.assertRedirects(response, f"/dashboard/post/{our_post.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_note = Note.objects.create()
our_post = Post.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_note.shared_with.all())
self.assertIn(alice, our_post.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_note(self):
our_note = Note.objects.create()
def test_post_with_nonexistent_email_redirects_to_post(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(
response,
f"/dashboard/note/{our_note.id}/",
f"/dashboard/post/{our_post.id}/",
fetch_redirect_response=False,
)
def test_share_note_does_not_add_owner_as_recipient(self):
def test_share_post_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_note = Note.objects.create(owner=owner)
our_post = Post.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_note", args=[our_note.id]),
self.client.post(reverse("share_post", args=[our_post.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_note.shared_with.all())
self.assertNotIn(owner, our_post.shared_with.all())
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
def test_share_note_shows_privacy_safe_message(self):
our_note = Note.objects.create()
def test_share_post_shows_privacy_safe_message(self):
our_post = Post.objects.create()
response = self.client.post(
f"/dashboard/note/{our_note.id}/share_note",
f"/dashboard/post/{our_post.id}/share_post",
data={"recipient": "nobody@example.com"},
follow=True,
)
@@ -249,26 +245,26 @@ class ShareNoteTest(TestCase):
"An invite has been sent if that address is registered.",
)
class ViewAuthNoteTest(TestCase):
class ViewAuthPostTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_note = Note.objects.create(owner=self.owner)
self.our_post = Post.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertRedirects(response, "/", fetch_redirect_response=False)
def test_non_owner_non_shared_user_gets_403(self):
stranger = User.objects.create(email="stranger@example.com")
self.client.force_login(stranger)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_note(self):
def test_shared_with_user_can_access_post(self):
guest = User.objects.create(email="guest@example.com")
self.our_note.shared_with.add(guest)
self.our_post.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
self.assertEqual(response.status_code, 200)
@override_settings(COMPRESS_ENABLED=False)
@@ -290,7 +286,7 @@ class SetPaletteTest(TestCase):
self.assertEqual(self.user.palette, "palette-default")
def test_locked_palette_is_rejected(self):
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-nirvana"})
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-default")
self.assertRedirects(response, "/", fetch_redirect_response=False)
@@ -302,27 +298,27 @@ class SetPaletteTest(TestCase):
def test_set_palette_returns_json_when_requested(self):
response = self.client.post(
"/dashboard/set_palette",
data={"palette": "palette-sepia"},
data={"palette": "palette-cedar"},
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"palette": "palette-sepia"})
self.assertEqual(response.json(), {"palette": "palette-cedar"})
def test_locked_palette_returns_unchanged_json(self):
response = self.client.post(
"/dashboard/set_palette",
data={"palette": "palette-nirvana"},
data={"palette": "palette-bardo"},
headers={"Accept": "application/json"},
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json(), {"palette": "palette-default"})
def test_dashboard_contains_set_palette_form(self):
def test_unlocked_swatches_count_matches_context(self):
response = self.client.get(self.url)
parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
swatches = parsed.cssselect(".swatch:not(.locked)")
unlocked = [p for p in response.context["palettes"] if not p["locked"]]
self.assertEqual(len(forms), len(unlocked))
self.assertEqual(len(swatches), len(unlocked))
def test_active_palette_swatch_has_active_class(self):
response = self.client.get(self.url)
@@ -346,6 +342,52 @@ class SetPaletteTest(TestCase):
swatches = parsed.cssselect(".swatch")
self.assertEqual(len(swatches), len(response.context["palettes"]))
class NotePaletteContextTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="recog_palette@test.io")
self.client.force_login(self.user)
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
def test_note_palette_unlocks_swatch_in_context(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertFalse(bardo["locked"])
def test_note_palette_shoptalk_contains_note_title(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertIn("Stargazer", bardo["shoptalk"])
def test_note_without_palette_field_keeps_swatch_locked(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette=None,
)
response = self.client.get("/")
palettes = response.context["palettes"]
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
self.assertTrue(bardo["locked"])
def test_note_palette_allows_set_palette_via_view(self):
Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
self.user.refresh_from_db()
self.assertEqual(self.user.palette, "palette-bardo")
@override_settings(COMPRESS_ENABLED=False)
class ProfileViewTest(TestCase):
def setUp(self):
@@ -482,3 +524,96 @@ class WalletAppletTest(TestCase):
def test_wallet_applet_has_manage_link(self):
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
self.assertEqual(link.get("href"), "/dashboard/wallet/")
ENRICHED_CHART = {
"planets": {"Sun": {"lon": 10.0, "sign": "Aries", "house": 1, "degree": 10.0}},
"houses": {"cusps": [float(i * 30) for i in range(12)]},
"elements": {
"Fire": {"count": 3, "contributors": ["Sun", "Mars", "Jupiter"]},
"Stone": {"count": 1, "contributors": ["Venus"]},
"Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
"Water": {"count": 0, "contributors": []},
"Time": {"count": 1, "stellia": ["Saturn"]},
"Space": {"count": 1, "parades": ["Neptune"]},
},
"distinctions": [],
"timezone": "UTC",
}
BIRTH_PAYLOAD = {
"birth_dt": "1990-06-15T12:00:00Z",
"birth_lat": 51.5,
"birth_lon": -0.1,
"birth_place": "London",
"house_system": "O",
"chart_data": {"stale": True},
}
@override_settings(PYSWISS_URL="http://pyswiss-test")
class SkySaveViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(
email="disco@test.io",
sky_birth_lat=51.5,
sky_birth_lon=-0.1,
sky_birth_place="London",
)
self.client.force_login(self.user)
def _post(self, payload=None):
return self.client.post(
"/dashboard/sky/save",
data=json.dumps(payload or BIRTH_PAYLOAD),
content_type="application/json",
)
def test_save_stores_client_chart_data(self):
"""sky_save stores the chart_data from the client (already enriched by sky_preview)."""
client_chart = {
"planets": {},
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 123.4, "mc": 45.6},
"elements": {"Fire": {"count": 1, "contributors": []}},
}
payload = dict(BIRTH_PAYLOAD, chart_data=client_chart)
response = self._post(payload)
self.assertEqual(response.status_code, 200)
self.user.refresh_from_db()
self.assertAlmostEqual(self.user.sky_chart_data["houses"]["asc"], 123.4)
class SkyNatusDataViewTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="disco@test.io")
self.client.force_login(self.user)
def test_returns_stored_chart_with_asc_preserved(self):
"""sky_natus_data returns sky_chart_data — asc must match what was saved."""
stored = {
"planets": {},
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 236.1, "mc": 159.1},
"elements": {"Fire": {"count": 1, "contributors": []}},
"aspects": [],
"house_system": "O",
}
self.user.sky_chart_data = stored
self.user.save(update_fields=["sky_chart_data"])
response = self.client.get("/dashboard/sky/data")
self.assertEqual(response.status_code, 200)
data = json.loads(response.content)
self.assertAlmostEqual(data["houses"]["asc"], 236.1)
self.assertIn("distinctions", data)
def test_returns_404_if_no_chart_data_saved(self):
response = self.client.get("/dashboard/sky/data")
self.assertEqual(response.status_code, 404)
def test_requires_login(self):
self.client.logout()
response = self.client.get("/dashboard/sky/data")
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)

View File

@@ -1,13 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.forms import (
EMPTY_ITEM_ERROR,
ItemForm,
EMPTY_LINE_ERROR,
LineForm,
)
class SimpleItemFormTest(SimpleTestCase):
def test_form_validation_for_blank_items(self):
form = ItemForm(data={"text": ""})
class SimpleLineFormTest(SimpleTestCase):
def test_form_validation_for_blank_lines(self):
form = LineForm(data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])

View File

@@ -1,13 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.models import Item
from apps.dashboard.models import Line
class SimpleItemModelTest(SimpleTestCase):
class SimpleLineModelTest(SimpleTestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
line = Line()
self.assertEqual(line.text, "")
def test_string_representation(self):
item = Item(text="sample text")
self.assertEqual(str(item), "sample text")
line = Line(text="sample text")
self.assertEqual(str(line), "sample text")

View File

@@ -2,16 +2,20 @@ from django.urls import path
from . import views
urlpatterns = [
path('new_note', views.new_note, name='new_note'),
path('note/<uuid:note_id>/', views.view_note, name='view_note'),
path('note/<uuid:note_id>/share_note', views.share_note, name="share_note"),
path('new_post', views.new_post, name='new_post'),
path('post/<uuid:post_id>/', views.view_post, name='view_post'),
path('post/<uuid:post_id>/share_post', views.share_post, name="share_post"),
path('set_palette', views.set_palette, name='set_palette'),
path('set_profile', views.set_profile, name='set_profile'),
path('users/<uuid:user_id>/', views.my_notes, name='my_notes'),
path('users/<uuid:user_id>/', views.my_posts, name='my_posts'),
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
path('wallet/', views.wallet, name='wallet'),
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
path('kit-bag/', views.kit_bag, name='kit_bag'),
path('sky/', views.sky_view, name='sky'),
path('sky/preview', views.sky_preview, name='sky_preview'),
path('sky/save', views.sky_save, name='sky_save'),
path('sky/data', views.sky_natus_data, name='sky_natus_data'),
]

View File

@@ -1,4 +1,9 @@
import json
import stripe
import zoneinfo
from datetime import datetime
import requests as http_requests
from django.conf import settings
from django.contrib import messages
@@ -9,117 +14,151 @@ from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.csrf import ensure_csrf_cookie
from apps.applets.models import Applet, UserApplet
from apps.applets.utils import applet_context
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
from apps.dashboard.models import Item, Note
from apps.applets.utils import applet_context, apply_applet_toggle
from apps.dashboard.forms import ExistingPostLineForm, LineForm
from apps.dashboard.models import Line, Post
from apps.drama.models import Note
from apps.epic.utils import _compute_distinctions
from apps.lyric.models import PaymentMethod, Token, User, Wallet
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
UNLOCKED_PALETTES = frozenset([
APPLET_ORDER = ["wallet", "username", "palette"]
_BASE_UNLOCKED = frozenset([
"palette-default",
"palette-sepia",
"palette-cedar",
"palette-oblivion-light",
"palette-monochrome-dark",
])
PALETTES = [
_PALETTE_DEFS = [
{"name": "palette-default", "label": "Earthman", "locked": False},
{"name": "palette-sepia", "label": "Sepia", "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-nirvana", "label": "Nirvana", "locked": True},
{"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},
]
_NOTE_TITLES = {
"stargazer": "Stargazer",
"schizo": "Schizo",
"nomad": "Nomad",
}
# Keep PALETTES as an alias used by views that don't have a request user.
PALETTES = _PALETTE_DEFS
def _recent_notes(user, limit=3):
def _palettes_for_user(user):
if not (user and user.is_authenticated):
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
granted = {
r.palette: r
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
}
result = []
for p in _PALETTE_DEFS:
entry = dict(p)
r = granted.get(p["name"])
if r and p["locked"]:
entry["locked"] = False
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
else:
entry["shoptalk"] = "Placeholder"
result.append(entry)
return result
def _unlocked_palettes_for_user(user):
base = set(_BASE_UNLOCKED)
if user and user.is_authenticated:
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
base.add(r.palette)
return base
def _recent_posts(user, limit=3):
return (
Note
Post
.objects
.filter(Q(owner=user) | Q(shared_with=user))
.annotate(last_item=Max('item__id'))
.order_by('-last_item')
.annotate(last_line=Max('lines__id'))
.order_by('-last_line')
.distinct()[:limit]
)
def home_page(request):
context = {
"form": ItemForm(),
"palettes": PALETTES,
"palettes": _palettes_for_user(request.user),
"page_class": "page-dashboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
def new_note(request):
form = ItemForm(data=request.POST)
def new_post(request):
form = LineForm(data=request.POST)
if form.is_valid():
nunote = Note.objects.create()
nupost = Post.objects.create()
if request.user.is_authenticated:
nunote.owner = request.user
nunote.save()
form.save(for_note=nunote)
return redirect(nunote)
nupost.owner = request.user
nupost.save()
form.save(for_post=nupost)
return redirect(nupost)
else:
context = {
"form": form,
"palettes": PALETTES,
"page_class": "page-dashboard",
"page_class": "page-billboard",
}
if request.user.is_authenticated:
context["applets"] = applet_context(request.user, "dashboard")
context["recent_notes"] = _recent_notes(request.user)
return render(request, "apps/dashboard/home.html", context)
context["applets"] = applet_context(request.user, "billboard")
context["recent_posts"] = _recent_posts(request.user)
return render(request, "apps/billboard/billboard.html", context)
def view_note(request, note_id):
our_note = Note.objects.get(id=note_id)
def view_post(request, post_id):
our_post = Post.objects.get(id=post_id)
if our_note.owner:
if our_post.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_note.owner and request.user not in our_note.shared_with.all():
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
return HttpResponseForbidden()
form = ExistingNoteItemForm(for_note=our_note)
form = ExistingPostLineForm(for_post=our_post)
if request.method == "POST":
form = ExistingNoteItemForm(for_note=our_note, data=request.POST)
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
if form.is_valid():
form.save()
return redirect(our_note)
return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form})
return redirect(our_post)
return render(request, "apps/dashboard/post.html", {"post": our_post, "form": form})
def my_notes(request, user_id):
def my_posts(request, user_id):
owner = User.objects.get(id=user_id)
if not request.user.is_authenticated:
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/dashboard/my_notes.html", {"owner": owner})
return render(request, "apps/dashboard/my_posts.html", {"owner": owner})
def share_note(request, note_id):
our_note = Note.objects.get(id=note_id)
def share_post(request, post_id):
our_post = Post.objects.get(id=post_id)
try:
recipient = User.objects.get(email=request.POST["recipient"])
if recipient == request.user:
return redirect(our_note)
our_note.shared_with.add(recipient)
return redirect(our_post)
our_post.shared_with.add(recipient)
except User.DoesNotExist:
pass
messages.success(request, "An invite has been sent if that address is registered.")
return redirect(our_note)
return redirect(our_post)
@login_required(login_url="/")
def set_palette(request):
if request.method == "POST":
palette = request.POST.get("palette", "")
if palette in UNLOCKED_PALETTES:
if palette in _unlocked_palettes_for_user(request.user):
request.user.palette = palette
request.user.save(update_fields=["palette"])
if "application/json" in request.headers.get("Accept", ""):
@@ -137,36 +176,29 @@ def set_profile(request):
@login_required(login_url="/")
def toggle_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="dashboard"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
apply_applet_toggle(request.user, "dashboard", checked)
if request.headers.get("HX-Request"):
return render(request, "apps/dashboard/_partials/_applets.html", {
"applets": applet_context(request.user, "dashboard"),
"palettes": PALETTES,
"form": ItemForm(),
"recent_notes": _recent_notes(request.user),
"palettes": _palettes_for_user(request.user),
})
return redirect("home")
@login_required(login_url="/")
@ensure_csrf_cookie
def wallet(request):
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))
return render(request, "apps/dashboard/wallet.html", {
"wallet": request.user.wallet,
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
"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)),
"free_count": request.user.tokens.filter(
token_type=Token.FREE, expires_at__gt=timezone.now()
).count(),
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
"free_tokens": free_tokens,
"tithe_tokens": tithe_tokens,
"free_count": len(free_tokens),
"tithe_count": len(tithe_tokens),
"applets": applet_context(request.user, "wallet"),
"page_class": "page-wallet",
})
@@ -192,12 +224,7 @@ def kit_bag(request):
@login_required(login_url="/")
def toggle_wallet_applets(request):
checked = request.POST.getlist("applets")
for applet in Applet.objects.filter(context="wallet"):
UserApplet.objects.update_or_create(
user=request.user,
applet=applet,
defaults={"visible": applet.slug in checked},
)
apply_applet_toggle(request.user, "wallet", checked)
if request.headers.get("HX-Request"):
return render(request, "apps/wallet/_partials/_applets.html", {
"applets": applet_context(request.user, "wallet"),
@@ -236,3 +263,170 @@ def save_payment_method(request):
brand=pm.card.brand,
)
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
# ── My Sky (personal natal chart) ────────────────────────────────────────────
def _sky_natus_preview(request):
"""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):
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')
return render(request, "apps/dashboard/sky.html", {
"preview_url": request.build_absolute_uri("/dashboard/sky/preview"),
"save_url": request.build_absolute_uri("/dashboard/sky/save"),
"saved_sky_json": json.dumps(chart_data) if chart_data else 'null',
"saved_birth_date": saved_birth_date,
"saved_birth_time": saved_birth_time,
"saved_birth_place": request.user.sky_birth_place,
"saved_birth_lat": request.user.sky_birth_lat,
"saved_birth_lon": request.user.sky_birth_lon,
"saved_birth_tz": request.user.sky_birth_tz,
"page_class": "page-sky",
})
@login_required(login_url="/")
def sky_preview(request):
return _sky_natus_preview(request)
@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
birth_tz_str = body.get('birth_tz', '').strip()
birth_dt_str = body.get('birth_dt', '')
if birth_dt_str:
try:
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'))
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', '')
user.sky_birth_tz = body.get('birth_tz', '')
user.sky_house_system = body.get('house_system', 'O')
user.sky_chart_data = body.get('chart_data')
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',
])
note_payload = None
if user.sky_chart_data:
note, created = Note.grant_if_new(user, "stargazer")
if created:
note_payload = {
"slug": note.slug,
"title": "Stargazer",
"description": "You saved your first personal sky chart.",
"earned_at": note.earned_at.isoformat(),
}
return JsonResponse({"saved": True, "note": note_payload})
@login_required(login_url="/")
def sky_natus_data(request):
user = request.user
if not user.sky_chart_data:
return HttpResponse(status=404)
data = dict(user.sky_chart_data)
data['distinctions'] = _compute_distinctions(
data.get('planets', {}), data.get('houses', {})
)
return JsonResponse(data)

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-12 23:38
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0002_scrollposition'),
]
operations = [
migrations.AlterField(
model_name='gameevent',
name='verb',
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30),
),
]

View File

@@ -0,0 +1,30 @@
# Generated by Django 6.0 on 2026-04-22 06:11
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0003_alter_gameevent_verb'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='Recognition',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('slug', models.SlugField(max_length=60)),
('earned_at', models.DateTimeField()),
('palette', models.CharField(blank=True, max_length=60, null=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recognitions', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['earned_at'],
'unique_together': {('user', 'slug')},
},
),
]

View File

@@ -0,0 +1,24 @@
import django.conf
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('drama', '0004_recognition'),
migrations.swappable_dependency(django.conf.settings.AUTH_USER_MODEL),
]
operations = [
migrations.RenameModel('Recognition', 'Note'),
migrations.AlterField(
model_name='note',
name='user',
field=models.ForeignKey(
django.conf.settings.AUTH_USER_MODEL,
on_delete=django.db.models.deletion.CASCADE,
related_name='notes',
),
),
]

View File

@@ -1,6 +1,12 @@
from django.conf import settings
from django.db import models
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
# Later: replace with per-actor lookup when User model gains a pronouns field.
PRONOUN_SUBJ = "yo"
PRONOUN_OBJ = "yo"
PRONOUN_POSS = "yos"
class GameEvent(models.Model):
# Gate phase
@@ -14,6 +20,9 @@ class GameEvent(models.Model):
ROLE_SELECT_STARTED = "role_select_started"
ROLE_SELECTED = "role_selected"
ROLES_REVEALED = "roles_revealed"
# Sig Select phase
SIG_READY = "sig_ready"
SIG_UNREADY = "sig_unready"
VERB_CHOICES = [
(ROOM_CREATED, "Room created"),
@@ -25,6 +34,8 @@ class GameEvent(models.Model):
(ROLE_SELECT_STARTED, "Role select started"),
(ROLE_SELECTED, "Role selected"),
(ROLES_REVEALED, "Roles revealed"),
(SIG_READY, "Sig claim staked"),
(SIG_UNREADY, "Sig claim withdrawn"),
]
room = models.ForeignKey(
@@ -53,7 +64,7 @@ class GameEvent(models.Model):
token = d.get("token_display") or _token_names.get(code, code)
days = d.get("renewal_days", 7)
slot = d.get("slot_number", "?")
return f"deposits a {token} for slot {slot} ({days} days)"
return f"deposits a {token} for slot {slot} (expires in {days} days)."
if self.verb == self.SLOT_RESERVED:
return "reserves a seat"
if self.verb == self.SLOT_RETURNED:
@@ -71,13 +82,65 @@ class GameEvent(models.Model):
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
}
_chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"]
_ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"]
code = d.get("role", "?")
role = d.get("role_display") or _role_names.get(code, code)
return f"elects to start as {role}"
try:
ordinal = _ordinals[_chair_order.index(code)]
except ValueError:
ordinal = "?"
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
if self.verb == self.ROLES_REVEALED:
return "All roles assigned"
if self.verb == self.SIG_READY:
card_name = d.get("card_name", "a card")
corner_rank = d.get("corner_rank", "")
suit_icon = d.get("suit_icon", "")
if corner_rank:
icon_html = f' <i class="fa-solid {suit_icon}"></i>' if suit_icon else ""
abbrev = f" ({corner_rank}{icon_html})"
else:
abbrev = ""
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
if self.verb == self.SIG_UNREADY:
return f"disembodies {PRONOUN_POSS} Significator."
return self.verb
@property
def struck(self):
"""True when this SIG_READY event was subsequently retracted (WAIT NVM)."""
return self.data.get("retracted", False)
def to_activity(self, base_url):
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
if not self.actor or not self.actor.username:
return None
actor_url = f"{base_url}/ap/users/{self.actor.username}/"
room_url = f"{base_url}/gameboard/room/{self.room_id}/"
if self.verb == self.SLOT_FILLED:
return {
"type": "earthman:JoinGate",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
if self.verb == self.ROLE_SELECTED:
return {
"type": "earthman:SelectRole",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
if self.verb == self.ROOM_CREATED:
return {
"type": "Create",
"actor": actor_url,
"object": room_url,
"summary": self.to_prose(),
}
return None
def __str__(self):
actor = self.actor.email if self.actor else "system"
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor}{self.verb}"
@@ -105,3 +168,28 @@ class ScrollPosition(models.Model):
def record(room, verb, actor=None, **data):
"""Record a game event in the drama log."""
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
class Note(models.Model):
user = models.ForeignKey(
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
related_name="notes",
)
slug = models.SlugField(max_length=60)
earned_at = models.DateTimeField()
palette = models.CharField(max_length=60, null=True, blank=True)
class Meta:
unique_together = [("user", "slug")]
ordering = ["earned_at"]
def __str__(self):
return f"{self.user.email}{self.slug}"
@classmethod
def grant_if_new(cls, user, slug):
from django.utils import timezone
return cls.objects.get_or_create(
user=user, slug=slug,
defaults={"earned_at": timezone.now()},
)

View File

@@ -0,0 +1,242 @@
from django.test import TestCase
from django.db import IntegrityError
from django.utils import timezone
from apps.drama.models import GameEvent, Note, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
class GameEventModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_record_creates_game_event(self):
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
self.assertEqual(GameEvent.objects.count(), 1)
self.assertEqual(event.room, self.room)
self.assertEqual(event.actor, self.user)
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
def test_record_without_actor(self):
event = record(self.room, GameEvent.ROOM_CREATED)
self.assertIsNone(event.actor)
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
def test_events_ordered_by_timestamp(self):
record(self.room, GameEvent.ROOM_CREATED)
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
verbs = list(GameEvent.objects.values_list("verb", flat=True))
self.assertEqual(verbs, [
GameEvent.ROOM_CREATED,
GameEvent.SLOT_RESERVED,
GameEvent.SLOT_FILLED,
])
def test_str_includes_actor_and_verb(self):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
self.assertIn("actor@test.io", str(event))
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
# ── to_prose — ROLE_SELECTED ──────────────────────────────────────────
def test_role_selected_prose_uses_ordinal_chair(self):
for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"),
("SC", "4th"), ("AC", "5th"), ("BC", "6th")]:
with self.subTest(role=role):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role=role, role_display="")
self.assertIn(f"assumes {ordinal} Chair", event.to_prose())
def test_role_selected_prose_includes_role_name(self):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="PC", role_display="Player")
prose = event.to_prose()
self.assertIn("Player", prose)
self.assertIn("yo will start the game", prose)
# ── to_prose — SIG_READY ─────────────────────────────────────────────
def test_sig_ready_prose_embodies_card_with_rank_and_icon(self):
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands", corner_rank="M",
suit_icon="fa-wand-sparkles")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
self.assertIn("(M", prose)
self.assertIn("fa-wand-sparkles", prose)
def test_sig_ready_prose_omits_icon_when_none(self):
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="The Wanderer", corner_rank="0", suit_icon="")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
self.assertNotIn("fa-", prose)
def test_sig_ready_prose_degrades_without_corner_rank(self):
# Old events recorded before this change have no corner_rank key
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
card_name="Maid of Brands")
prose = event.to_prose()
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
self.assertNotIn("(", prose)
def test_str_without_actor_shows_system(self):
event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertIn("system", str(event))
# ── to_prose — remaining verb branches ───────────────────────────────
def test_slot_reserved_prose(self):
event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
self.assertEqual(event.to_prose(), "reserves a seat")
def test_slot_returned_prose(self):
event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user)
self.assertEqual(event.to_prose(), "withdraws from the gate")
def test_slot_released_prose_includes_slot_number(self):
event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3)
self.assertIn("slot 3", event.to_prose())
def test_invite_sent_prose(self):
event = record(self.room, GameEvent.INVITE_SENT, actor=self.user)
self.assertEqual(event.to_prose(), "sends an invitation")
def test_role_select_started_prose(self):
event = record(self.room, GameEvent.ROLE_SELECT_STARTED)
self.assertEqual(event.to_prose(), "Role selection begins")
def test_roles_revealed_prose(self):
event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertEqual(event.to_prose(), "All roles assigned")
def test_role_selected_prose_unknown_role_code_uses_question_mark_ordinal(self):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
role="XX", role_display="Unknown")
self.assertIn("?", event.to_prose())
def test_sig_unready_prose(self):
event = record(self.room, GameEvent.SIG_UNREADY, actor=self.user)
self.assertIn("disembodies", event.to_prose())
self.assertIn("Significator", event.to_prose())
def test_unknown_verb_falls_back_to_verb_string(self):
event = record(self.room, "custom_event", actor=self.user)
self.assertEqual(event.to_prose(), "custom_event")
def test_to_activity_returns_none_when_actor_has_no_username(self):
actor = User.objects.create(email="noname@test.io")
event = record(self.room, GameEvent.SLOT_FILLED, actor=actor, slot_number=1)
self.assertIsNone(event.to_activity("https://example.com"))
class ScrollPositionStrTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_str_includes_email_room_and_position(self):
from apps.drama.models import ScrollPosition
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=42)
s = str(sp)
self.assertIn("reader@test.io", s)
self.assertIn("Test Room", s)
self.assertIn("42", s)
class ScrollPositionModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_can_save_scroll_position(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
self.assertEqual(ScrollPosition.objects.count(), 1)
self.assertEqual(sp.position, 150)
def test_default_position_is_zero(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
self.assertEqual(sp.position, 0)
def test_unique_per_user_and_room(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
with self.assertRaises(IntegrityError):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
def test_upsert_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
ScrollPosition.objects.update_or_create(
user=self.user, room=self.room,
defaults={"position": 200},
)
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
class NoteModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="earner@test.io")
def test_can_create_recognition(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertEqual(Note.objects.count(), 1)
self.assertEqual(recog.slug, "stargazer")
self.assertEqual(recog.user, self.user)
def test_palette_is_null_by_default(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
self.assertIsNone(recog.palette)
def test_palette_can_be_set(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
palette="palette-bardo",
)
self.assertEqual(recog.palette, "palette-bardo")
def test_unique_per_user_and_slug(self):
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
with self.assertRaises(IntegrityError):
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
def test_different_users_can_share_slug(self):
other = User.objects.create(email="other@test.io")
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
self.assertEqual(Note.objects.count(), 2)
def test_str_includes_slug_and_email(self):
recog = Note.objects.create(
user=self.user, slug="stargazer", earned_at=timezone.now(),
)
s = str(recog)
self.assertIn("stargazer", s)
self.assertIn("earner@test.io", s)
def test_grant_if_new_creates_on_first_call(self):
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertTrue(created)
self.assertEqual(recog.slug, "stargazer")
self.assertIsNotNone(recog.earned_at)
def test_grant_if_new_is_idempotent(self):
Note.grant_if_new(self.user, "stargazer")
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(Note.objects.count(), 1)
def test_grant_if_new_does_not_overwrite_palette(self):
Note.objects.create(
user=self.user, slug="stargazer",
earned_at=timezone.now(), palette="palette-bardo",
)
recog, created = Note.grant_if_new(self.user, "stargazer")
self.assertFalse(created)
self.assertEqual(recog.palette, "palette-bardo")

View File

@@ -1,77 +0,0 @@
from django.test import TestCase
from django.urls import reverse
from django.utils import timezone
from apps.drama.models import GameEvent
from apps.epic.models import GateSlot, Room, TableSeat
from apps.lyric.models import Token, User
class ConfirmTokenRecordsSlotFilledTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="gamer@test.io")
self.client.force_login(self.user)
self.room = Room.objects.create(name="Test Room", owner=self.user)
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
self.slot = self.room.gate_slots.get(slot_number=1)
self.slot.gamer = self.user
self.slot.status = GateSlot.RESERVED
self.slot.reserved_at = timezone.now()
self.slot.save()
def test_confirm_token_records_slot_filled_event(self):
session = self.client.session
session["kit_token_id"] = str(self.token.id)
session.save()
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
self.assertEqual(event.actor, self.user)
self.assertEqual(event.data["slot_number"], 1)
self.assertEqual(event.data["token_type"], Token.TITHE)
def test_no_event_recorded_if_no_reserved_slot(self):
self.slot.gamer = None
self.slot.status = GateSlot.EMPTY
self.slot.save()
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
class SelectRoleRecordsRoleSelectedTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="player@test.io")
self.client.force_login(self.user)
self.room = Room.objects.create(
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
)
self.seat = TableSeat.objects.create(
room=self.room, gamer=self.user, slot_number=1
)
def test_select_role_records_role_selected_event(self):
self.client.post(
reverse("epic:select_role", args=[self.room.id]),
data={"role": "PC"},
)
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
self.assertEqual(event.actor, self.user)
self.assertEqual(event.data["role"], "PC")
self.assertEqual(event.data["slot_number"], 1)
def test_roles_revealed_event_recorded_when_all_seats_assigned(self):
# Only one seat — assigning it triggers roles_revealed
self.client.post(
reverse("epic:select_role", args=[self.room.id]),
data={"role": "PC"},
)
self.assertTrue(
GameEvent.objects.filter(room=self.room, verb=GameEvent.ROLES_REVEALED).exists()
)
def test_no_event_if_role_already_taken(self):
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
self.client.post(
reverse("epic:select_role", args=[self.room.id]),
data={"role": "PC"},
)
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)

View File

@@ -1,73 +0,0 @@
from django.test import TestCase
from django.db import IntegrityError
from apps.drama.models import GameEvent, ScrollPosition, record
from apps.epic.models import Room
from apps.lyric.models import User
class GameEventModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="actor@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_record_creates_game_event(self):
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
self.assertEqual(GameEvent.objects.count(), 1)
self.assertEqual(event.room, self.room)
self.assertEqual(event.actor, self.user)
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
def test_record_without_actor(self):
event = record(self.room, GameEvent.ROOM_CREATED)
self.assertIsNone(event.actor)
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
def test_events_ordered_by_timestamp(self):
record(self.room, GameEvent.ROOM_CREATED)
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
verbs = list(GameEvent.objects.values_list("verb", flat=True))
self.assertEqual(verbs, [
GameEvent.ROOM_CREATED,
GameEvent.SLOT_RESERVED,
GameEvent.SLOT_FILLED,
])
def test_str_includes_actor_and_verb(self):
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
self.assertIn("actor@test.io", str(event))
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
def test_str_without_actor_shows_system(self):
event = record(self.room, GameEvent.ROLES_REVEALED)
self.assertIn("system", str(event))
class ScrollPositionModelTest(TestCase):
def setUp(self):
self.user = User.objects.create(email="reader@test.io")
self.room = Room.objects.create(name="Test Room", owner=self.user)
def test_can_save_scroll_position(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
self.assertEqual(ScrollPosition.objects.count(), 1)
self.assertEqual(sp.position, 150)
def test_default_position_is_zero(self):
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
self.assertEqual(sp.position, 0)
def test_unique_per_user_and_room(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
with self.assertRaises(IntegrityError):
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
def test_upsert_updates_existing_position(self):
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
ScrollPosition.objects.update_or_create(
user=self.user, room=self.room,
defaults={"position": 200},
)
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)

View File

@@ -1,18 +1,58 @@
from channels.db import database_sync_to_async
from channels.generic.websocket import AsyncJsonWebsocketConsumer
LEVITY_ROLES = {"PC", "NC", "SC"}
GRAVITY_ROLES = {"BC", "EC", "AC"}
class RoomConsumer(AsyncJsonWebsocketConsumer):
async def connect(self):
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
self.group_name = f"room_{self.room_id}"
await self.channel_layer.group_add(self.group_name, self.channel_name)
self.cursor_group = None
user = self.scope.get("user")
if user and user.is_authenticated:
seat = await self._get_seat(user)
if seat:
if seat.role in LEVITY_ROLES:
self.cursor_group = f"cursors_{self.room_id}_levity"
elif seat.role in GRAVITY_ROLES:
self.cursor_group = f"cursors_{self.room_id}_gravity"
if self.cursor_group:
await self.channel_layer.group_add(self.cursor_group, self.channel_name)
await self.accept()
async def disconnect(self, close_code):
await self.channel_layer.group_discard(self.group_name, self.channel_name)
if self.cursor_group:
await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
async def receive_json(self, content):
pass # handlers added as events introduced
msg_type = content.get("type")
if msg_type == "cursor_move" and self.cursor_group:
await self.channel_layer.group_send(
self.cursor_group,
{"type": "cursor_move", "x": content.get("x"), "y": content.get("y")},
)
elif msg_type == "sig_hover" and self.cursor_group:
await self.channel_layer.group_send(
self.cursor_group,
{
"type": "sig_hover",
"card_id": content.get("card_id"),
"role": content.get("role"),
"active": content.get("active"),
},
)
@database_sync_to_async
def _get_seat(self, user):
from apps.epic.models import TableSeat
return TableSeat.objects.filter(room_id=self.room_id, gamer=user).first()
async def gate_update(self, event):
await self.send_json(event)
@@ -23,8 +63,32 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
async def turn_changed(self, event):
await self.send_json(event)
async def roles_revealed(self, event):
async def all_roles_filled(self, event):
await self.send_json(event)
async def sig_select_started(self, event):
await self.send_json(event)
async def sig_selected(self, event):
await self.send_json(event)
async def sig_hover(self, event):
await self.send_json(event)
async def sig_reserved(self, event):
await self.send_json(event)
async def countdown_start(self, event):
await self.send_json(event)
async def countdown_cancel(self, event):
await self.send_json(event)
async def polarity_room_done(self, event):
await self.send_json(event)
async def pick_sky_available(self, event):
await self.send_json(event)
async def cursor_move(self, event):
await self.send_json(event)

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-01 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0017_tableseat_significator_fk'),
]
operations = [
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,70 @@
"""
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 15)
in the Earthman deck.
0: "The Schiz""The Nomad"
1: "Pope 1: Chancellor""Pope 1: The Schizo"
2: "Pope 2: President""Pope 2: The Despot"
3: "Pope 3: Tsar""Pope 3: The Capitalist"
4: "Pope 4: Chairman""Pope 4: The Fascist"
5: "Pope 5: Emperor""Pope 5: The War Machine"
"""
from django.db import migrations
NEW_NAMES = {
0: ("The Nomad", "the-nomad"),
1: ("Pope 1: The Schizo", "pope-1-the-schizo"),
2: ("Pope 2: The Despot", "pope-2-the-despot"),
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
5: ("Pope 5: The War Machine","pope-5-the-war-machine"),
}
OLD_NAMES = {
0: ("The Schiz", "the-schiz"),
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
2: ("Pope 2: President", "pope-2-president"),
3: ("Pope 3: Tsar", "pope-3-tsar"),
4: ("Pope 4: Chairman", "pope-4-chairman"),
5: ("Pope 5: Emperor", "pope-5-emperor"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in NEW_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in OLD_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0018_alter_tarotcard_suit"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,63 @@
"""
Data migration: rename Pope cards 25 in the Earthman deck.
2: "Pope 2: The Despot""Pope 2: The Occultist"
3: "Pope 3: The Capitalist""Pope 3: The Despot"
4: "Pope 4: The Fascist""Pope 4: The Capitalist"
5: "Pope 5: The War Machine""Pope 5: The Fascist"
"""
from django.db import migrations
NEW_NAMES = {
2: ("Pope 2: The Occultist", "pope-2-the-occultist"),
3: ("Pope 3: The Despot", "pope-3-the-despot"),
4: ("Pope 4: The Capitalist","pope-4-the-capitalist"),
5: ("Pope 5: The Fascist", "pope-5-the-fascist"),
}
OLD_NAMES = {
2: ("Pope 2: The Despot", "pope-2-the-despot"),
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
5: ("Pope 5: The War Machine", "pope-5-the-war-machine"),
}
def rename_forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (new_name, new_slug) in NEW_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=new_name, slug=new_slug)
def rename_reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for number, (old_name, old_slug) in OLD_NAMES.items():
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(name=old_name, slug=old_slug)
class Migration(migrations.Migration):
dependencies = [
("epic", "0019_rename_earthman_schiz_and_popes"),
]
operations = [
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
]

View File

@@ -0,0 +1,56 @@
"""
Data migration: rename/update six Earthman Major Arcana cards.
13 name: "Death""King Death & the Cosmic Tree"
14 name: "The Traitor""The Great Hunt"
15 correspondence: "The Tower / La Torre""The House of the Devil / Inferno"
16 correspondence: "Purgatorio""The Tower / La Torre / Purgatorio"
50 name/slug: "The Eagle""The Mould of Man"
51 name/slug: "Divine Calculus""The Eagle"
"""
from django.db import migrations
FORWARD = {
13: dict(name="King Death & the Cosmic Tree", slug="king-death-and-the-cosmic-tree"),
14: dict(name="The Great Hunt", slug="the-great-hunt"),
15: dict(correspondence="The House of the Devil / Inferno"),
16: dict(correspondence="The Tower / La Torre / Purgatorio"),
50: dict(name="The Mould of Man", slug="the-mould-of-man"),
51: dict(name="The Eagle", slug="the-eagle"),
}
REVERSE = {
13: dict(name="Death", slug="death-em"),
14: dict(name="The Traitor", slug="the-traitor"),
15: dict(correspondence="The Tower / La Torre"),
16: dict(correspondence="Purgatorio"),
50: dict(name="The Eagle", slug="the-eagle"),
51: dict(name="Divine Calculus",slug="divine-calculus"),
}
def apply(changes):
def fn(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Process in sorted order so card 50 vacates "the-eagle" slug before card 51 claims it.
for number in sorted(changes):
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=number
).update(**changes[number])
return fn
class Migration(migrations.Migration):
dependencies = [
("epic", "0020_rename_earthman_pope_cards_2_5"),
]
operations = [
migrations.RunPython(apply(FORWARD), reverse_code=apply(REVERSE)),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 6.0 on 2026-04-06 00:02
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0021_rename_earthman_major_arcana_batch_2'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='SigReservation',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('role', models.CharField(max_length=2)),
('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)),
('reserved_at', models.DateTimeField(auto_now_add=True)),
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')),
('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)),
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')),
],
options={
'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')],
},
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 6.0 on 2026-04-06 02:22
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0022_sig_reservation'),
]
operations = [
migrations.AddField(
model_name='tarotcard',
name='icon',
field=models.CharField(blank=True, default='', max_length=50),
),
migrations.AlterField(
model_name='tarotcard',
name='arcana',
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
),
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,46 @@
"""
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
Updates for every Earthman card where suit="PENTACLES":
- suit: "PENTACLES""CROWNS"
- name: " of Pentacles"" of Crowns"
- slug: "pentacles""crowns"
"""
from django.db import migrations
def pentacles_to_crowns(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
card.suit = "CROWNS"
card.name = card.name.replace(" of Pentacles", " of Crowns")
card.slug = card.slug.replace("pentacles", "crowns")
card.save(update_fields=["suit", "name", "slug"])
def crowns_to_pentacles(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
card.suit = "PENTACLES"
card.name = card.name.replace(" of Crowns", " of Pentacles")
card.slug = card.slug.replace("crowns", "pentacles")
card.save(update_fields=["suit", "name", "slug"])
class Migration(migrations.Migration):
dependencies = [
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
]
operations = [
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
]

View File

@@ -0,0 +1,62 @@
"""
Data migration: Earthman deck — court cards and major arcana icons.
1. Court cards (numbers 1114, all suits): arcana "MINOR""MIDDLE"
2. Major arcana icons (stored in TarotCard.icon):
0 (Nomad) → fa-hat-cowboy-side
1 (Schizo) → fa-hat-wizard
251 (rest) → fa-hand-dots
"""
from django.db import migrations
MAJOR_ICONS = {
0: "fa-hat-cowboy-side",
1: "fa-hat-wizard",
}
DEFAULT_MAJOR_ICON = "fa-hand-dots"
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
# Court cards → MIDDLE
TarotCard.objects.filter(
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
).update(arcana="MIDDLE")
# Major arcana icons
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
card.save(update_fields=["icon"])
def backward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
earthman = DeckVariant.objects.filter(slug="earthman").first()
if not earthman:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
).update(arcana="MINOR")
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR"
).update(icon="")
class Migration(migrations.Migration):
dependencies = [
("epic", "0024_earthman_pentacles_to_crowns"),
]
operations = [
migrations.RunPython(forward, reverse_code=backward),
]

View File

@@ -0,0 +1,154 @@
"""
Data migration — Earthman deck:
1. Rename three suit codes (and card names) for Earthman cards:
WANDS → BRANDS (Wands → Brands)
CUPS → GRAILS (Cups → Grails)
SWORDS → BLADES (Swords → Blades)
CROWNS stays CROWNS.
2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate
deck to corresponding Earthman cards:
• Major: explicit number-to-number map based on card correspondences.
• Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS,
BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart
stay with empty keyword lists.
"""
from django.db import migrations
# ── 1. Suit rename map ────────────────────────────────────────────────────────
SUIT_RENAMES = {
"WANDS": "BRANDS",
"CUPS": "GRAILS",
"SWORDS": "BLADES",
}
# ── 2. Major arcana: Earthman number → Fiorentine number ─────────────────────
# Cards without a Fiorentine counterpart are omitted (keywords stay empty).
MAJOR_KEYWORD_MAP = {
0: 0, # The Schiz → The Fool
1: 1, # Pope I (President) → The Magician
2: 2, # Pope II (Tsar) → The High Priestess
3: 3, # Pope III (Chairman) → The Empress
4: 4, # Pope IV (Emperor) → The Emperor
5: 5, # Pope V (Chancellor) → The Hierophant
6: 8, # Virtue VI (Controlled Folly) → Strength
7: 11, # Virtue VII (Not-Doing) → Justice
8: 14, # Virtue VIII (Losing Self-Importance) → Temperance
# 9: Prudence — no Fiorentine equivalent
10: 10, # Wheel of Fortune → Wheel of Fortune
11: 7, # The Junkboat → The Chariot
12: 12, # The Junkman → The Hanged Man
13: 13, # Death → Death
14: 15, # The Traitor → The Devil
15: 16, # Disco Inferno → The Tower
# 16: Torre Terrestre (Purgatory) — no equivalent
# 17: Fantasia Celestia (Paradise) — no equivalent
18: 6, # Virtue XVIII (Stalking) → The Lovers
# 19: Virtue XIX (Intent / Hope) — no equivalent
# 20: Virtue XX (Dreaming / Faith)— no equivalent
# 2138: Classical Elements + Zodiac — no equivalents
39: 17, # Wanderer XXXIX (Polestar) → The Star
40: 18, # Wanderer XL (Antichthon) → The Moon
41: 19, # Wanderer XLI (Corestar) → The Sun
# 4249: Planets + The Binary — no equivalents
50: 20, # The Eagle → Judgement
51: 21, # Divine Calculus → The World
}
# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ───────────────────
MINOR_SUIT_MAP = {
"BRANDS": "WANDS",
"GRAILS": "CUPS",
"BLADES": "SWORDS",
"CROWNS": "PENTACLES",
}
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
except DeckVariant.DoesNotExist:
return # decks not seeded — nothing to do
# ── Step 1: rename Earthman suit codes + card names ───────────────────────
for old_suit, new_suit in SUIT_RENAMES.items():
old_display = old_suit.capitalize() # e.g. "Wands"
new_display = new_suit.capitalize() # e.g. "Brands"
cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit)
for card in cards:
card.name = card.name.replace(f" of {old_display}", f" of {new_display}")
card.suit = new_suit
card.save()
# ── Step 2: copy major arcana keywords ───────────────────────────────────
fio_major = {
card.number: card
for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR")
}
for em_num, fio_num in MAJOR_KEYWORD_MAP.items():
fio_card = fio_major.get(fio_num)
if not fio_card:
continue
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=em_num
).update(
keywords_upright=fio_card.keywords_upright,
keywords_reversed=fio_card.keywords_reversed,
)
# ── Step 3: copy minor/middle arcana keywords ─────────────────────────────
for em_suit, fio_suit in MINOR_SUIT_MAP.items():
fio_by_number = {
card.number: card
for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit)
}
for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit):
fio_card = fio_by_number.get(em_card.number)
if fio_card:
em_card.keywords_upright = fio_card.keywords_upright
em_card.keywords_reversed = fio_card.keywords_reversed
em_card.save()
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
# Reverse suit renames
reverse_renames = {new: old for old, new in SUIT_RENAMES.items()}
for new_suit, old_suit in reverse_renames.items():
new_display = new_suit.capitalize()
old_display = old_suit.capitalize()
cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit)
for card in cards:
card.name = card.name.replace(f" of {new_display}", f" of {old_display}")
card.suit = old_suit
card.save()
# Clear all Earthman keywords
TarotCard.objects.filter(deck_variant=earthman).update(
keywords_upright=[],
keywords_reversed=[],
)
class Migration(migrations.Migration):
dependencies = [
("epic", "0025_earthman_middle_arcana_and_major_icons"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,65 @@
"""
Schema + data migration:
1. Add `cautions` JSONField (list, default=[]) to TarotCard.
2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions.
All other cards default to [] — the UI shows a placeholder when empty.
"""
from django.db import migrations, models
SCHIZO_CAUTIONS = [
'This card will reverse into <span class="card-ref">The Pervert</span> when it'
' comes under dominion of <span class="card-ref">The Occultist</span>, which in turn'
' reverses into <span class="card-ref">Pestilence</span>.',
'This card will reverse into <span class="card-ref">The Paranoiac</span> when it'
' comes under dominion of <span class="card-ref">The Despot</span>, which in turn'
' reverses into <span class="card-ref">War</span>.',
'This card will reverse into <span class="card-ref">The Neurotic</span> when it'
' comes under dominion of <span class="card-ref">The Capitalist</span>, which in turn'
' reverses into <span class="card-ref">Famine</span>.',
'This card will reverse into <span class="card-ref">The Suicidal</span> when it'
' comes under dominion of <span class="card-ref">The Fascist</span>, which in turn'
' reverses into <span class="card-ref">Death</span>.',
]
def seed_schizo_cautions(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1
).update(cautions=SCHIZO_CAUTIONS)
def clear_schizo_cautions(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1
).update(cautions=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0026_earthman_suit_renames_and_keywords"),
]
operations = [
migrations.AddField(
model_name="tarotcard",
name="cautions",
field=models.JSONField(default=list),
),
migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 6.0 on 2026-04-07 03:09
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0027_tarotcard_cautions'),
]
operations = [
migrations.AlterField(
model_name='tarotcard',
name='suit',
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
),
]

View File

@@ -0,0 +1,61 @@
"""
Data fix: clear Schizo cautions from The Nomad (number=0) if present,
and ensure they land on The Schizo (number=1).
"""
from django.db import migrations
SCHIZO_CAUTIONS = [
'This card will reverse into <span class="card-ref">I. The Pervert</span> when it'
' comes under dominion of <span class="card-ref">II. The Occultist</span>, which in turn'
' reverses into <span class="card-ref">II. Pestilence</span>.',
'This card will reverse into <span class="card-ref">I. The Paranoiac</span> when it'
' comes under dominion of <span class="card-ref">III. The Despot</span>, which in turn'
' reverses into <span class="card-ref">III. War</span>.',
'This card will reverse into <span class="card-ref">I. The Neurotic</span> when it'
' comes under dominion of <span class="card-ref">IV. The Capitalist</span>, which in turn'
' reverses into <span class="card-ref">IV. Famine</span>.',
'This card will reverse into <span class="card-ref">I. The Suicidal</span> when it'
' comes under dominion of <span class="card-ref">V. The Fascist</span>, which in turn'
' reverses into <span class="card-ref">V. Death</span>.',
]
def forward(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=0
).update(cautions=[])
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1
).update(cautions=SCHIZO_CAUTIONS)
def reverse(apps, schema_editor):
TarotCard = apps.get_model("epic", "TarotCard")
DeckVariant = apps.get_model("epic", "DeckVariant")
try:
earthman = DeckVariant.objects.get(slug="earthman")
except DeckVariant.DoesNotExist:
return
TarotCard.objects.filter(
deck_variant=earthman, arcana="MAJOR", number=1
).update(cautions=[])
class Migration(migrations.Migration):
dependencies = [
("epic", "0028_alter_tarotcard_suit"),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,23 @@
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0029_fix_schizo_cautions'),
]
operations = [
migrations.AddField(
model_name='sigreservation',
name='seat',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name='sig_reservation',
to='epic.tableseat',
),
),
]

View File

@@ -0,0 +1,33 @@
# Generated by Django 6.0 on 2026-04-09 04:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0030_sigreservation_seat_fk'),
]
operations = [
migrations.AddField(
model_name='room',
name='sig_select_started_at',
field=models.DateTimeField(blank=True, null=True),
),
migrations.AddField(
model_name='sigreservation',
name='countdown_remaining',
field=models.IntegerField(blank=True, null=True),
),
migrations.AddField(
model_name='sigreservation',
name='ready',
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name='room',
name='table_status',
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
),
]

View File

@@ -0,0 +1,65 @@
# Generated by Django 6.0 on 2026-04-14 05:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0031_sig_ready_sky_select'),
]
operations = [
migrations.CreateModel(
name='AspectType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('symbol', models.CharField(max_length=5)),
('angle', models.PositiveSmallIntegerField()),
('orb', models.FloatField()),
],
options={
'ordering': ['angle'],
},
),
migrations.CreateModel(
name='HouseLabel',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('number', models.PositiveSmallIntegerField(unique=True)),
('name', models.CharField(max_length=30)),
('keywords', models.CharField(blank=True, max_length=100)),
],
options={
'ordering': ['number'],
},
),
migrations.CreateModel(
name='Planet',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('symbol', models.CharField(max_length=5)),
('order', models.PositiveSmallIntegerField(unique=True)),
],
options={
'ordering': ['order'],
},
),
migrations.CreateModel(
name='Sign',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=20, unique=True)),
('symbol', models.CharField(max_length=5)),
('element', models.CharField(choices=[('Fire', 'Fire'), ('Earth', 'Earth'), ('Air', 'Air'), ('Water', 'Water')], max_length=5)),
('modality', models.CharField(choices=[('Cardinal', 'Cardinal'), ('Fixed', 'Fixed'), ('Mutable', 'Mutable')], max_length=8)),
('order', models.PositiveSmallIntegerField(unique=True)),
('start_degree', models.FloatField()),
],
options={
'ordering': ['order'],
},
),
]

View File

@@ -0,0 +1,106 @@
"""
Data migration: seed Sign, Planet, AspectType, and HouseLabel tables.
These are stable astrological reference rows — never user-edited.
The data matches the constants in pyswiss/apps/charts/calc.py so that
the proxy view and D3 wheel share a single source of truth.
"""
from django.db import migrations
# ── Signs ────────────────────────────────────────────────────────────────────
# (order, name, symbol, element, modality, start_degree)
SIGNS = [
(0, 'Aries', '', 'Fire', 'Cardinal', 0.0),
(1, 'Taurus', '', 'Earth', 'Fixed', 30.0),
(2, 'Gemini', '', 'Air', 'Mutable', 60.0),
(3, 'Cancer', '', 'Water', 'Cardinal', 90.0),
(4, 'Leo', '', 'Fire', 'Fixed', 120.0),
(5, 'Virgo', '', 'Earth', 'Mutable', 150.0),
(6, 'Libra', '', 'Air', 'Cardinal', 180.0),
(7, 'Scorpio', '', 'Water', 'Fixed', 210.0),
(8, 'Sagittarius', '', 'Fire', 'Mutable', 240.0),
(9, 'Capricorn', '', 'Earth', 'Cardinal', 270.0),
(10, 'Aquarius', '', 'Air', 'Fixed', 300.0),
(11, 'Pisces', '', 'Water', 'Mutable', 330.0),
]
# ── Planets ───────────────────────────────────────────────────────────────────
# (order, name, symbol)
PLANETS = [
(0, 'Sun', ''),
(1, 'Moon', ''),
(2, 'Mercury', ''),
(3, 'Venus', ''),
(4, 'Mars', ''),
(5, 'Jupiter', ''),
(6, 'Saturn', ''),
(7, 'Uranus', ''),
(8, 'Neptune', ''),
(9, 'Pluto', ''),
]
# ── Aspect types ──────────────────────────────────────────────────────────────
# (name, symbol, angle, orb) — mirrors ASPECTS constant in pyswiss calc.py
ASPECT_TYPES = [
('Conjunction', '', 0, 8.0),
('Sextile', '', 60, 6.0),
('Square', '', 90, 8.0),
('Trine', '', 120, 8.0),
('Opposition', '', 180, 10.0),
]
# ── House labels (distinctions) ───────────────────────────────────────────────
# (number, name, keywords)
HOUSE_LABELS = [
(1, 'Self', 'identity, appearance, first impressions'),
(2, 'Worth', 'possessions, values, finances'),
(3, 'Education', 'communication, siblings, short journeys'),
(4, 'Family', 'home, roots, ancestry'),
(5, 'Creation', 'creativity, romance, children, pleasure'),
(6, 'Ritual', 'service, health, daily routines'),
(7, 'Cooperation', 'partnerships, marriage, open enemies'),
(8, 'Regeneration', 'transformation, shared resources, death'),
(9, 'Enterprise', 'philosophy, travel, higher learning'),
(10, 'Career', 'public life, reputation, authority'),
(11, 'Reward', 'friends, groups, aspirations'),
(12, 'Reprisal', 'hidden matters, karma, self-undoing'),
]
def forward(apps, schema_editor):
Sign = apps.get_model('epic', 'Sign')
Planet = apps.get_model('epic', 'Planet')
AspectType = apps.get_model('epic', 'AspectType')
HouseLabel = apps.get_model('epic', 'HouseLabel')
for order, name, symbol, element, modality, start_degree in SIGNS:
Sign.objects.create(
order=order, name=name, symbol=symbol,
element=element, modality=modality, start_degree=start_degree,
)
for order, name, symbol in PLANETS:
Planet.objects.create(order=order, name=name, symbol=symbol)
for name, symbol, angle, orb in ASPECT_TYPES:
AspectType.objects.create(name=name, symbol=symbol, angle=angle, orb=orb)
for number, name, keywords in HOUSE_LABELS:
HouseLabel.objects.create(number=number, name=name, keywords=keywords)
def reverse(apps, schema_editor):
for model_name in ('Sign', 'Planet', 'AspectType', 'HouseLabel'):
apps.get_model('epic', model_name).objects.all().delete()
class Migration(migrations.Migration):
dependencies = [
('epic', '0032_astro_reference_tables'),
]
operations = [
migrations.RunPython(forward, reverse_code=reverse),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 6.0 on 2026-04-14 05:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('epic', '0033_seed_astro_reference_tables'),
]
operations = [
migrations.CreateModel(
name='Character',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('birth_dt', models.DateTimeField(blank=True, null=True)),
('birth_lat', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('birth_lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
('birth_place', models.CharField(blank=True, max_length=200)),
('house_system', models.CharField(choices=[('O', 'Porphyry'), ('P', 'Placidus'), ('K', 'Koch'), ('W', 'Whole Sign')], default='O', max_length=1)),
('chart_data', models.JSONField(blank=True, null=True)),
('celtic_cross', models.JSONField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('confirmed_at', models.DateTimeField(blank=True, null=True)),
('retired_at', models.DateTimeField(blank=True, null=True)),
('seat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='characters', to='epic.tableseat')),
('significator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='character_significators', to='epic.tarotcard')),
],
options={
'ordering': ['-created_at'],
},
),
]

Some files were not shown because too many files have changed in this diff Show More