Compare commits
141 Commits
30ea0fad9d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5655342d9f | ||
|
|
2088fedeee | ||
|
|
6ebb2fbd51 | ||
|
|
b86a4ddd73 | ||
|
|
214120ef2d | ||
|
|
7d4389a74a | ||
|
|
cd5252c185 | ||
|
|
e8687dc050 | ||
|
|
48aad6ce35 | ||
|
|
473e6bc45a | ||
|
|
6d9d3d4f54 | ||
|
|
565f727aa6 | ||
|
|
3cc9f5a527 | ||
|
|
be061f6bc2 | ||
|
|
83ce238a2f | ||
|
|
6069d86ec5 | ||
|
|
a44727c559 | ||
|
|
0b2320e39b | ||
|
|
5c05bd6552 | ||
|
|
b5a92ddf77 | ||
|
|
bb1cda9c9c | ||
|
|
3974fdac82 | ||
|
|
b8ac004fb6 | ||
|
|
02975d79d3 | ||
|
|
04f0e87eba | ||
|
|
ebc460fe67 | ||
|
|
7c249500bd | ||
|
|
ea2bfa6ce1 | ||
|
|
9c7d58f0b3 | ||
|
|
4761d3f939 | ||
|
|
2be330e698 | ||
|
|
fbf260b148 | ||
|
|
09ed64080b | ||
|
|
f15b17f7bd | ||
|
|
122de3bc80 | ||
|
|
6e995647e4 | ||
|
|
d7d20f25e3 | ||
|
|
758c9c5377 | ||
|
|
7c03bded8d | ||
|
|
8a24021739 | ||
|
|
bd9a2fdae3 | ||
|
|
4f8e52890b | ||
|
|
abf8be8861 | ||
|
|
127f4a092d | ||
|
|
2910012b67 | ||
|
|
db9ac9cb24 | ||
|
|
d3e4638233 | ||
|
|
10a6809dcf | ||
|
|
de4ac60aec | ||
|
|
71ef3dcb7f | ||
|
|
9beb21bffe | ||
|
|
6248d95bf3 | ||
|
|
44cf399352 | ||
|
|
df2b353ebd | ||
|
|
3fd1f5e990 | ||
|
|
02a7a0ef2e | ||
|
|
cc2ab869f1 | ||
|
|
8c711ac674 | ||
|
|
b8af0041cc | ||
|
|
97ec2f6ee6 | ||
|
|
0a135c2149 | ||
|
|
f1e9a9657b | ||
|
|
32d8d97360 | ||
|
|
df421fb6c0 | ||
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab | ||
|
|
d4518a0671 | ||
|
|
74f63a7721 | ||
|
|
bd3d7fc7bd | ||
|
|
c00288e256 | ||
|
|
b5de96660a | ||
|
|
96bb05a4ba | ||
|
|
4e07fcf38b | ||
|
|
b74f8e1bb1 | ||
|
|
188365f412 | ||
|
|
824f35590b | ||
|
|
43cb84e8f4 | ||
|
|
afe8e2b32c | ||
|
|
ca38875660 | ||
|
|
8538f76b13 | ||
|
|
2a7d4c7410 | ||
|
|
ed10e58383 | ||
|
|
b65cba5ed2 | ||
|
|
afe79f1a48 | ||
|
|
0e5e39b0dc | ||
|
|
4860b6ee2a | ||
|
|
c025a38709 | ||
|
|
581ea7e349 | ||
|
|
596175cd1c | ||
|
|
1aaf353066 | ||
|
|
441def9a34 | ||
|
|
736b59b5c0 | ||
|
|
a8592aeaec | ||
|
|
8b006be138 | ||
|
|
299a806862 | ||
|
|
fb782cf5ef | ||
|
|
224f5e2ad0 | ||
|
|
96379934d7 | ||
|
|
29a5658b01 | ||
|
|
73135df7a6 | ||
|
|
57f47cc77e | ||
|
|
5d21e79be5 | ||
|
|
ff0883002b | ||
|
|
7f927741d4 | ||
|
|
3bf48546e3 | ||
|
|
6817323f8e | ||
|
|
11283118d6 | ||
|
|
6c91ec0385 | ||
|
|
39db59c71a | ||
|
|
5f643350c5 | ||
|
|
ab41797e57 | ||
|
|
e35855f472 | ||
|
|
0e5805efd2 | ||
|
|
de99b538d2 | ||
|
|
c08b5b764e | ||
|
|
d63a4bec4a | ||
|
|
b35c9b483e |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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
33
.woodpecker/pyswiss.yaml
Normal 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
129
CLAUDE.md
@@ -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, 1–6). 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).
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
0
pyswiss/apps/charts/__init__.py
Normal file
0
pyswiss/apps/charts/__init__.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal 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
178
pyswiss/apps/charts/calc.py
Normal 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
|
||||
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal 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).')
|
||||
)
|
||||
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
36
pyswiss/apps/charts/models.py
Normal file
36
pyswiss/apps/charts/models.py
Normal 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,
|
||||
}
|
||||
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal 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)
|
||||
247
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
247
pyswiss/apps/charts/tests/integrated/test_views.py
Normal 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')
|
||||
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
329
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
329
pyswiss/apps/charts/tests/unit/test_calc.py
Normal 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)
|
||||
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal 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)
|
||||
8
pyswiss/apps/charts/urls.py
Normal file
8
pyswiss/apps/charts/urls.py
Normal 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'),
|
||||
]
|
||||
143
pyswiss/apps/charts/views.py
Normal file
143
pyswiss/apps/charts/views.py
Normal 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
0
pyswiss/core/__init__.py
Normal file
49
pyswiss/core/settings.py
Normal file
49
pyswiss/core/settings.py
Normal 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
5
pyswiss/core/urls.py
Normal 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
6
pyswiss/core/wsgi.py
Normal 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
20
pyswiss/manage.py
Normal 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
5
pyswiss/requirements.txt
Normal 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
|
||||
@@ -6,6 +6,7 @@ channels
|
||||
channels-redis
|
||||
charset-normalizer==3.4.4
|
||||
coverage
|
||||
cryptography
|
||||
cssselect==1.3.0
|
||||
daphne
|
||||
dj-database-url
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
celery
|
||||
cryptography
|
||||
channels
|
||||
channels-redis
|
||||
cssselect==1.3.0
|
||||
|
||||
0
src/apps/ap/__init__.py
Normal file
0
src/apps/ap/__init__.py
Normal file
7
src/apps/ap/apps.py
Normal file
7
src/apps/ap/apps.py
Normal 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"
|
||||
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal 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)
|
||||
0
src/apps/ap/tests/unit/__init__.py
Normal file
0
src/apps/ap/tests/unit/__init__.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal 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
10
src/apps/ap/urls.py
Normal 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
83
src/apps/ap/views.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"},
|
||||
)
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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):
|
||||
|
||||
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal 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)
|
||||
]
|
||||
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal file
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal 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)
|
||||
]
|
||||
25
src/apps/applets/migrations/0010_recognition_applet.py
Normal file
25
src/apps/applets/migrations/0010_recognition_applet.py
Normal 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),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
245
src/apps/billboard/static/apps/billboard/note-page.js
Normal file
245
src/apps/billboard/static/apps/billboard/note-page.js
Normal 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();
|
||||
}
|
||||
}());
|
||||
@@ -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):
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal file
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal 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')},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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 () {
|
||||
|
||||
49
src/apps/dashboard/static/apps/dashboard/note.js
Normal file
49
src/apps/dashboard/static/apps/dashboard/note.js
Normal 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 };
|
||||
})();
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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")
|
||||
|
||||
290
src/apps/dashboard/tests/integrated/test_sky_views.py
Normal file
290
src/apps/dashboard/tests/integrated/test_sky_views.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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'),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal 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),
|
||||
),
|
||||
]
|
||||
30
src/apps/drama/migrations/0004_recognition.py
Normal file
30
src/apps/drama/migrations/0004_recognition.py
Normal 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')},
|
||||
},
|
||||
),
|
||||
]
|
||||
24
src/apps/drama/migrations/0005_rename_recognition_to_note.py
Normal file
24
src/apps/drama/migrations/0005_rename_recognition_to_note.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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()},
|
||||
)
|
||||
|
||||
242
src/apps/drama/tests/integrated/test_models.py
Normal file
242
src/apps/drama/tests/integrated/test_models.py
Normal 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")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 1–5)
|
||||
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),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Data migration: rename Pope cards 2–5 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),
|
||||
]
|
||||
@@ -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)),
|
||||
]
|
||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Data migration: Earthman deck — court cards and major arcana icons.
|
||||
|
||||
1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE"
|
||||
2. Major arcana icons (stored in TarotCard.icon):
|
||||
0 (Nomad) → fa-hat-cowboy-side
|
||||
1 (Schizo) → fa-hat-wizard
|
||||
2–51 (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),
|
||||
]
|
||||
@@ -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
|
||||
# 21–38: 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
|
||||
# 42–49: 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),
|
||||
]
|
||||
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal 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),
|
||||
]
|
||||
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal 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),
|
||||
),
|
||||
]
|
||||
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal 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),
|
||||
]
|
||||
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
||||
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal 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),
|
||||
),
|
||||
]
|
||||
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal file
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal 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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal file
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal 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),
|
||||
]
|
||||
35
src/apps/epic/migrations/0034_character_model.py
Normal file
35
src/apps/epic/migrations/0034_character_model.py
Normal 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
Reference in New Issue
Block a user