Compare commits
179 Commits
69fea65bf9
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
30ea0fad9d | ||
|
|
62d5c738f9 | ||
|
|
f0f419ff7e | ||
|
|
0494710ce0 | ||
|
|
713e24863d | ||
|
|
b3bc422f46 | ||
|
|
c0016418cc | ||
|
|
4d52c4f54d | ||
|
|
db1608fa38 | ||
|
|
4728cde771 | ||
|
|
2f6fc1ff20 | ||
|
|
9698d70164 | ||
|
|
7370fd611f | ||
|
|
f5a5ed9d8d | ||
|
|
a5d71925fc | ||
|
|
b03ba09b65 | ||
|
|
befa61e1e9 | ||
|
|
15ac3216ff | ||
|
|
2896efa8e0 | ||
|
|
588358a20f | ||
|
|
11c85d56d1 | ||
|
|
8bab26e003 | ||
|
|
bc78d2c470 | ||
|
|
2447315fd3 | ||
|
|
cde231d43c | ||
|
|
a0f8aeb791 | ||
|
|
2ca4e9d39f | ||
|
|
c71f4eb68c | ||
|
|
189d329e76 | ||
|
|
18898c7a0f | ||
|
|
f347af7eff | ||
|
|
e59d5fd4c0 | ||
|
|
62f6c27806 | ||
|
|
cc02419e8d | ||
|
|
c331e72de6 | ||
|
|
a1f8d294a3 | ||
|
|
5607f70852 | ||
|
|
eecb6c2be6 | ||
|
|
2fd3ec9ab2 | ||
|
|
cad3744a57 | ||
|
|
ffb374c81c | ||
|
|
3b905e0436 | ||
|
|
f1b5ba2a71 | ||
|
|
184854a2de | ||
|
|
f5c2cf4636 | ||
|
|
91e0eaad8e | ||
|
|
5a811d0079 | ||
|
|
8c2a5d24ec | ||
|
|
4f076165ef | ||
|
|
3a87a17017 | ||
|
|
4e63323019 | ||
|
|
8b2c4e1bdc | ||
|
|
10d717a3ba | ||
|
|
e9f50810da | ||
|
|
67697fa90e | ||
|
|
97b406c7e0 | ||
|
|
568497d09d | ||
|
|
1558bb02b4 | ||
|
|
01de6e7548 | ||
|
|
c9defa5a81 | ||
|
|
462155f07b | ||
|
|
fa46fc18d7 | ||
|
|
4239245902 | ||
|
|
b49218b45b | ||
|
|
ace9a4888e | ||
|
|
435bec7988 | ||
|
|
12146037f0 | ||
|
|
ff7b71792f | ||
|
|
2e24175ec8 | ||
|
|
18ba242647 | ||
|
|
6d1b358b7c | ||
|
|
2140bd8206 | ||
|
|
52e171cb20 | ||
|
|
74d1a43559 | ||
|
|
2d453dbc78 | ||
|
|
4baaa63430 | ||
|
|
26b6d4e7db | ||
|
|
f4dfce826b | ||
|
|
53d9f79476 | ||
|
|
ed48d18c1d | ||
|
|
f76c6d0fe5 | ||
|
|
d9feb80b2a | ||
|
|
d780115515 | ||
|
|
af3523c9bb | ||
|
|
dddffd22d5 | ||
|
|
e0d1f51bf1 | ||
|
|
6a42b91420 | ||
|
|
5773462b4c | ||
|
|
681a1a4cd0 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,9 +10,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
*.sqlite3
|
||||||
db.sqlite3-journal
|
*.sqlite3-journal
|
||||||
container.db.sqlite3
|
|
||||||
media
|
media
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
|
|||||||
@@ -22,6 +22,33 @@ steps:
|
|||||||
- python manage.py test apps
|
- python manage.py test apps
|
||||||
when:
|
when:
|
||||||
- event: push
|
- 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=channels
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: test-FTs
|
- name: test-FTs
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
@@ -37,9 +64,13 @@ steps:
|
|||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
- cd ./src
|
- cd ./src
|
||||||
- python manage.py collectstatic --noinput
|
- python manage.py collectstatic --noinput
|
||||||
- python manage.py test functional_tests
|
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
|
||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: screendumps
|
- name: screendumps
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
@@ -48,6 +79,10 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- event: push
|
- event: push
|
||||||
status: failure
|
status: failure
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: build-and-push
|
- name: build-and-push
|
||||||
image: docker:cli
|
image: docker:cli
|
||||||
@@ -61,8 +96,13 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "Dockerfile"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
- name: deploy
|
- name: deploy-staging
|
||||||
image: alpine
|
image: alpine
|
||||||
environment:
|
environment:
|
||||||
SSH_KEY:
|
SSH_KEY:
|
||||||
@@ -76,4 +116,22 @@ steps:
|
|||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
event: push
|
event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "Dockerfile"
|
||||||
|
- ".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"
|
||||||
161
CLAUDE.md
Normal file
161
CLAUDE.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# EarthmanRPG — Project Context
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn)
|
||||||
|
- **Celery + Redis** (async email, channel layer)
|
||||||
|
- **django-compressor + SCSS** (`src/static_src/scss/core.scss`)
|
||||||
|
- **Selenium** (functional tests) + Django test framework (integration/unit tests)
|
||||||
|
- **Stripe** (payment, sandbox only so far)
|
||||||
|
- **Hosting:** DigitalOcean staging (`staging.earthmanrpg.me`) | CI: Gitea + Woodpecker
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
The app pairs follow a tripartite structure:
|
||||||
|
- **1st-person** (personal UX): `lyric` (backend — auth, user, tokens) · `dashboard` (frontend — notes, applets, wallet UI)
|
||||||
|
- **3rd-person** (game table UX): `epic` (backend — rooms, gates, role select, game logic) · `gameboard` (frontend — room listing, gameboard UI)
|
||||||
|
- **2nd-person** (inter-player events): `drama` (backend — activity streams, provenance) · `billboard` (frontend — provenance feed, embeddable in dashboard/gameboard)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
apps/
|
||||||
|
lyric/ # auth (magic-link email), user model, token economy
|
||||||
|
dashboard/ # Notes (formerly Lists), applets, wallet UI [1st-person frontend]
|
||||||
|
epic/ # rooms, gates, role select, game logic [3rd-person backend]
|
||||||
|
gameboard/ # room listing, gameboard UI [3rd-person frontend]
|
||||||
|
drama/ # activity streams, provenance system [2nd-person backend]
|
||||||
|
billboard/ # provenance feed, embeds in dashboard/gameboard [2nd-person frontend]
|
||||||
|
api/ # REST API
|
||||||
|
applets/ # Applet model + context helpers
|
||||||
|
core/ # settings, urls, asgi, runner
|
||||||
|
static_src/ # SCSS source
|
||||||
|
templates/
|
||||||
|
functional_tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template directory convention
|
||||||
|
Templates live under `templates/apps/<frontend-app>/`, not under the backend app that owns the view logic. Specifically:
|
||||||
|
- `lyric/` views → `templates/apps/dashboard/`
|
||||||
|
- `epic/` views → `templates/apps/gameboard/`
|
||||||
|
- `drama/` views → `templates/apps/billboard/`
|
||||||
|
|
||||||
|
Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory.
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
```bash
|
||||||
|
# Dev server (ASGI — required for WebSockets; no npm/webpack build step)
|
||||||
|
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
|
||||||
|
|
||||||
|
# Functional tests only
|
||||||
|
python src/manage.py test src/functional_tests
|
||||||
|
|
||||||
|
# All tests (integration + unit + FT)
|
||||||
|
python src/manage.py test src
|
||||||
|
```
|
||||||
|
|
||||||
|
### Multi-user manual testing — `setup_sig_session`
|
||||||
|
`src/functional_tests/management/commands/setup_sig_session.py`
|
||||||
|
|
||||||
|
Creates (or reuses) a room with all 6 gate slots filled, roles assigned, and `table_status=SIG_SELECT`. Prints one pre-auth URL per gamer for pasting into 6 Firefox Multi-Account Container tabs.
|
||||||
|
|
||||||
|
```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> # reuse existing room
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
|
||||||
|
|
||||||
|
**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`
|
||||||
|
|
||||||
|
FTs are isolated by **directory path** (`src/functional_tests/`), not by tag.
|
||||||
|
|
||||||
|
Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## SCSS Import Order
|
||||||
|
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → billboard → 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`.
|
||||||
|
|
||||||
|
### Applet menus + container-type
|
||||||
|
`container-type: inline-size` creates a containing block for all positioned descendants — applet menus must live OUTSIDE `#id_applets_container` to be viewport-fixed.
|
||||||
|
|
||||||
|
### ABU session auth
|
||||||
|
`create_pre_authenticated_session` must set `HASH_SESSION_KEY = user.get_session_auth_hash()` and hardcode `BACKEND_SESSION_KEY` to the passwordless backend string.
|
||||||
|
|
||||||
|
### Magic login email mock paths
|
||||||
|
- View tests: `apps.lyric.views.send_login_email_task.delay`
|
||||||
|
- 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.
|
||||||
@@ -20,4 +20,4 @@ RUN DJANGO_SECRET_KEY=build-dummy DJANGO_ALLOWED_HOST=localhost python manage.py
|
|||||||
RUN adduser --uid 1234 nonroot
|
RUN adduser --uid 1234 nonroot
|
||||||
|
|
||||||
USER nonroot
|
USER nonroot
|
||||||
CMD ["gunicorn", "--bind", ":8888", "core.wsgi:application"]
|
CMD ["gunicorn", "--bind", ":8888", "-k", "uvicorn.workers.UvicornWorker", "core.asgi:application"]
|
||||||
@@ -129,6 +129,7 @@
|
|||||||
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
state: started
|
state: started
|
||||||
recreate: true
|
recreate: true
|
||||||
|
restart_policy: unless-stopped
|
||||||
env:
|
env:
|
||||||
DJANGO_DEBUG_FALSE: "1"
|
DJANGO_DEBUG_FALSE: "1"
|
||||||
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||||
@@ -150,6 +151,7 @@
|
|||||||
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
state: started
|
state: started
|
||||||
recreate: true
|
recreate: true
|
||||||
|
restart_policy: unless-stopped
|
||||||
env:
|
env:
|
||||||
DJANGO_DEBUG_FALSE: "1"
|
DJANGO_DEBUG_FALSE: "1"
|
||||||
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ docker rm gamearray 2>/dev/null || true
|
|||||||
|
|
||||||
echo "==> Starting new container..."
|
echo "==> Starting new container..."
|
||||||
docker run -d --name gamearray \
|
docker run -d --name gamearray \
|
||||||
|
--restart unless-stopped \
|
||||||
--env-file /opt/gamearray/gamearray.env \
|
--env-file /opt/gamearray/gamearray.env \
|
||||||
--network gamearray_net \
|
--network gamearray_net \
|
||||||
-p 127.0.0.1:8888:8888 \
|
-p 127.0.0.1:8888:8888 \
|
||||||
@@ -23,6 +24,7 @@ docker rm gamearray_celery 2>/dev/null || true
|
|||||||
|
|
||||||
echo "==> Starting new celery worker..."
|
echo "==> Starting new celery worker..."
|
||||||
docker run -d --name gamearray_celery \
|
docker run -d --name gamearray_celery \
|
||||||
|
--restart unless-stopped \
|
||||||
--env-file /opt/gamearray/gamearray.env \
|
--env-file /opt/gamearray/gamearray.env \
|
||||||
--network gamearray_net \
|
--network gamearray_net \
|
||||||
"$IMAGE" python -m celery -A core worker -l info
|
"$IMAGE" python -m celery -A core worker -l info
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8888;
|
proxy_pass http://127.0.0.1:8888;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
0
pyswiss/apps/__init__.py
Normal file
0
pyswiss/apps/__init__.py
Normal file
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'
|
||||||
130
pyswiss/apps/charts/calc.py
Normal file
130
pyswiss/apps/charts/calc.py
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
"""
|
||||||
|
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),
|
||||||
|
('Sextile', 60, 6.0),
|
||||||
|
('Square', 90, 8.0),
|
||||||
|
('Trine', 120, 8.0),
|
||||||
|
('Opposition', 180, 10.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,
|
||||||
|
'retrograde': pos[3] < 0,
|
||||||
|
}
|
||||||
|
return planets
|
||||||
|
|
||||||
|
|
||||||
|
def get_element_counts(planets):
|
||||||
|
sign_counts = {s: 0 for s in SIGNS}
|
||||||
|
counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0}
|
||||||
|
|
||||||
|
for data in planets.values():
|
||||||
|
sign = data['sign']
|
||||||
|
counts[SIGN_ELEMENT[sign]] += 1
|
||||||
|
sign_counts[sign] += 1
|
||||||
|
|
||||||
|
# Time: highest planet concentration in a single sign, minus 1
|
||||||
|
counts['Time'] = max(sign_counts.values()) - 1
|
||||||
|
|
||||||
|
# Space: longest consecutive run of occupied signs (circular), minus 1
|
||||||
|
indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0]
|
||||||
|
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 indices:
|
||||||
|
seq_len += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
max_seq = max(max_seq, seq_len)
|
||||||
|
counts['Space'] = max_seq - 1
|
||||||
|
|
||||||
|
return counts
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
aspects.append({
|
||||||
|
'planet1': name1,
|
||||||
|
'planet2': name2,
|
||||||
|
'type': aspect_name,
|
||||||
|
'angle': round(angle, 2),
|
||||||
|
'orb': round(orb, 2),
|
||||||
|
})
|
||||||
|
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'],
|
||||||
|
'water': elements['Water'],
|
||||||
|
'earth': elements['Earth'],
|
||||||
|
'air': elements['Air'],
|
||||||
|
'time_el': elements['Time'],
|
||||||
|
'space_el': elements['Space'],
|
||||||
|
'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)
|
||||||
215
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
215
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""
|
||||||
|
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] for e in ('Fire', 'Water', 'Earth', 'Air')
|
||||||
|
)
|
||||||
|
self.assertEqual(classical, 10)
|
||||||
|
|
||||||
|
# ── 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
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Synthetic planet data — degrees chosen for predictable aspects
|
||||||
|
# Matches FAKE_PLANETS in test_populate_ephemeris.py
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FAKE_PLANETS = {
|
||||||
|
'Sun': {'degree': 10.0}, # Aries
|
||||||
|
'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine
|
||||||
|
'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine
|
||||||
|
'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square
|
||||||
|
'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile
|
||||||
|
'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine
|
||||||
|
'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine
|
||||||
|
'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile
|
||||||
|
'Neptune': {'degree': 100.0}, # Cancer
|
||||||
|
'Pluto': {'degree': 340.0}, # Pisces
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
|
||||||
|
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_type_is_a_known_name(self):
|
||||||
|
known = {'Conjunction', 'Sextile', 'Square', 'Trine', '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,
|
||||||
|
'Sextile': 6.0,
|
||||||
|
'Square': 8.0,
|
||||||
|
'Trine': 8.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
|
||||||
@@ -2,9 +2,13 @@ asgiref==3.11.0
|
|||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
certifi==2025.11.12
|
certifi==2025.11.12
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
|
channels
|
||||||
|
channels-redis
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
coverage
|
coverage
|
||||||
|
cryptography
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
dj-database-url
|
dj-database-url
|
||||||
Django==6.0
|
Django==6.0
|
||||||
django-compressor
|
django-compressor
|
||||||
@@ -23,6 +27,7 @@ pycparser==2.23
|
|||||||
PySocks==1.7.1
|
PySocks==1.7.1
|
||||||
python-dotenv
|
python-dotenv
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
|
scipy
|
||||||
selenium==4.39.0
|
selenium==4.39.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
sortedcontainers==2.4.0
|
sortedcontainers==2.4.0
|
||||||
@@ -34,6 +39,7 @@ types-PyYAML==6.0.12.20250915
|
|||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
tzdata==2025.3
|
tzdata==2025.3
|
||||||
urllib3==2.6.2
|
urllib3==2.6.2
|
||||||
|
uvicorn[standard]
|
||||||
websocket-client==1.9.0
|
websocket-client==1.9.0
|
||||||
whitenoise==6.11.0
|
whitenoise==6.11.0
|
||||||
wsproto==1.3.2
|
wsproto==1.3.2
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
celery
|
celery
|
||||||
|
cryptography
|
||||||
|
channels
|
||||||
|
channels-redis
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
Django==6.0
|
Django==6.0
|
||||||
dj-database-url
|
dj-database-url
|
||||||
django-compressor
|
django-compressor
|
||||||
@@ -13,5 +17,7 @@ lxml==6.0.2
|
|||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
redis
|
redis
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
scipy
|
||||||
stripe
|
stripe
|
||||||
whitenoise==6.11.0
|
whitenoise==6.11.0
|
||||||
|
uvicorn[standard]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ source = apps
|
|||||||
omit =
|
omit =
|
||||||
*/migrations/*
|
*/migrations/*
|
||||||
*/tests/*
|
*/tests/*
|
||||||
|
*/routing.py
|
||||||
|
|
||||||
[report]
|
[report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
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)
|
||||||
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal file
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def increase_gameboard_applet_heights(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=3)
|
||||||
|
|
||||||
|
|
||||||
|
def revert_gameboard_applet_heights(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=2)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0004_rename_list_applet_slugs')
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
increase_gameboard_applet_heights,
|
||||||
|
revert_gameboard_applet_heights,
|
||||||
|
)
|
||||||
|
]
|
||||||
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||||
|
("billboard-my-contacts", "Contacts", 4, 3),
|
||||||
|
("billboard-most-recent", "Most Recent", 8, 6),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.filter(slug__in=[
|
||||||
|
"billboard-my-scrolls",
|
||||||
|
"billboard-my-contacts",
|
||||||
|
"billboard-most-recent",
|
||||||
|
]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0005_gameboard_applet_heights"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="applet",
|
||||||
|
name="context",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("dashboard", "Dashboard"),
|
||||||
|
("gameboard", "Gameboard"),
|
||||||
|
("wallet", "Wallet"),
|
||||||
|
("billboard", "Billboard"),
|
||||||
|
],
|
||||||
|
default="dashboard",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
|
||||||
|
]
|
||||||
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fix_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
# billboard-scroll belongs only to the billscroll page template, not the grid
|
||||||
|
Applet.objects.filter(slug="billboard-scroll").delete()
|
||||||
|
# Rename "My Contacts" → "Contacts"
|
||||||
|
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_fix_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="billboard-scroll",
|
||||||
|
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
|
||||||
|
)
|
||||||
|
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0006_billboard_applets"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
|
||||||
|
]
|
||||||
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)
|
||||||
|
]
|
||||||
@@ -4,10 +4,12 @@ class Applet(models.Model):
|
|||||||
DASHBOARD = "dashboard"
|
DASHBOARD = "dashboard"
|
||||||
GAMEBOARD = "gameboard"
|
GAMEBOARD = "gameboard"
|
||||||
WALLET = "wallet"
|
WALLET = "wallet"
|
||||||
|
BILLBOARD = "billboard"
|
||||||
CONTEXT_CHOICES = [
|
CONTEXT_CHOICES = [
|
||||||
(DASHBOARD, "Dashboard"),
|
(DASHBOARD, "Dashboard"),
|
||||||
(GAMEBOARD, "Gameboard"),
|
(GAMEBOARD, "Gameboard"),
|
||||||
(WALLET, "Wallet"),
|
(WALLET, "Wallet"),
|
||||||
|
(BILLBOARD, "Billboard"),
|
||||||
]
|
]
|
||||||
|
|
||||||
slug = models.SlugField(unique=True)
|
slug = models.SlugField(unique=True)
|
||||||
|
|||||||
42
src/apps/applets/static/apps/applets/applets.js
Normal file
42
src/apps/applets/static/apps/applets/applets.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const initGearMenus = () => {
|
||||||
|
document.querySelectorAll('.gear-btn').forEach(gear => {
|
||||||
|
const menuId = gear.dataset.menuTarget;
|
||||||
|
|
||||||
|
gear.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
if (!menu) return;
|
||||||
|
const opening = menu.style.display === 'none' || menu.style.display === '';
|
||||||
|
menu.style.display = opening ? 'block' : 'none';
|
||||||
|
gear.classList.toggle('active', opening);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
if (!menu || menu.style.display === 'none') return;
|
||||||
|
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
gear.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initGearMenus);
|
||||||
|
|
||||||
|
const appletContainerIds = new Set([
|
||||||
|
'id_applets_container',
|
||||||
|
'id_game_applets_container',
|
||||||
|
'id_gk_sections_container',
|
||||||
|
'id_wallet_applets_container',
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
if (!e.detail.target || !appletContainerIds.has(e.detail.target.id)) return;
|
||||||
|
document.querySelectorAll('.gear-btn').forEach(gear => {
|
||||||
|
const menu = document.getElementById(gear.dataset.menuTarget);
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
gear.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
const initGearMenus = () => {
|
|
||||||
document.querySelectorAll('.gear-btn').forEach(gear => {
|
|
||||||
const menuId = gear.dataset.menuTarget;
|
|
||||||
|
|
||||||
gear.addEventListener('click', (e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const menu = document.getElementById(menuId);
|
|
||||||
if (!menu) return;
|
|
||||||
menu.style.display = menu.style.display === 'none' ? 'block' : 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
const menu = document.getElementById(menuId);
|
|
||||||
if (!menu || menu.style.display === 'none') return;
|
|
||||||
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
|
|
||||||
menu.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
})
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initGearMenus);
|
|
||||||
0
src/apps/billboard/__init__.py
Normal file
0
src/apps/billboard/__init__.py
Normal file
6
src/apps/billboard/apps.py
Normal file
6
src/apps/billboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillboardConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.billboard'
|
||||||
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
189
src/apps/billboard/tests/integrated/test_views.py
Normal file
189
src/apps/billboard/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_billboard_applets():
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||||
|
("billboard-my-contacts", "Contacts", 4, 3),
|
||||||
|
("billboard-most-recent", "Most Recent", 8, 6),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillboardViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@billboard.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
_seed_billboard_applets()
|
||||||
|
|
||||||
|
def test_uses_billboard_template(self):
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
|
||||||
|
|
||||||
|
def test_passes_applets_context(self):
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertIn("applets", response.context)
|
||||||
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||||
|
self.assertIn("billboard-my-scrolls", slugs)
|
||||||
|
self.assertIn("billboard-my-contacts", slugs)
|
||||||
|
self.assertIn("billboard-most-recent", slugs)
|
||||||
|
|
||||||
|
def test_passes_my_rooms_context(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertIn(room, response.context["my_rooms"])
|
||||||
|
|
||||||
|
def test_passes_recent_room_and_events(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertEqual(response.context["recent_room"], room)
|
||||||
|
self.assertEqual(len(response.context["recent_events"]), 1)
|
||||||
|
|
||||||
|
def test_recent_events_capped_at_36(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
for i in range(40):
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertEqual(len(response.context["recent_events"]), 36)
|
||||||
|
|
||||||
|
def test_recent_events_in_chronological_order(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
for _ in range(3):
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
events = response.context["recent_events"]
|
||||||
|
timestamps = [e.timestamp for e in events]
|
||||||
|
self.assertEqual(timestamps, sorted(timestamps))
|
||||||
|
|
||||||
|
def test_recent_room_is_none_when_no_events(self):
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertIsNone(response.context["recent_room"])
|
||||||
|
self.assertEqual(list(response.context["recent_events"]), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleBillboardAppletsTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@toggle.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
_seed_billboard_applets()
|
||||||
|
|
||||||
|
def test_toggle_hides_unchecked_applets(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": ["billboard-my-scrolls"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
from apps.applets.models import UserApplet
|
||||||
|
contacts = Applet.objects.get(slug="billboard-my-contacts")
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_toggle_returns_partial_on_htmx(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": ["billboard-my-scrolls"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
||||||
|
|
||||||
|
|
||||||
|
class BillscrollViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@billscroll.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
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-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_uses_room_scroll_template(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/room_scroll.html")
|
||||||
|
|
||||||
|
def test_passes_events_context(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertIn("events", response.context)
|
||||||
|
self.assertEqual(response.context["events"].count(), 1)
|
||||||
|
|
||||||
|
def test_passes_page_class_billscroll(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["page_class"], "page-billscroll")
|
||||||
|
|
||||||
|
def test_passes_scroll_position_zero_when_none_saved(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["scroll_position"], 0)
|
||||||
|
|
||||||
|
def test_passes_saved_scroll_position_in_context(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
|
||||||
|
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 SaveScrollPositionTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@savescroll.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def test_post_saves_scroll_position(self):
|
||||||
|
self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 300},
|
||||||
|
)
|
||||||
|
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
|
||||||
|
self.assertEqual(sp.position, 300)
|
||||||
|
|
||||||
|
def test_post_updates_existing_position(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||||
|
self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 450},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_returns_204(self):
|
||||||
|
response = self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 100},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
def test_post_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 100},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
12
src/apps/billboard/urls.py
Normal file
12
src/apps/billboard/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.billboard import views
|
||||||
|
|
||||||
|
app_name = "billboard"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.billboard, name="billboard"),
|
||||||
|
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||||
|
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"),
|
||||||
|
]
|
||||||
92
src/apps/billboard/views.py
Normal file
92
src/apps/billboard/views.py
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Max, Q
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
recent_room = (
|
||||||
|
Room.objects.filter(
|
||||||
|
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
|
||||||
|
)
|
||||||
|
.annotate(last_event=Max("events__timestamp"))
|
||||||
|
.filter(last_event__isnull=False)
|
||||||
|
.order_by("-last_event")
|
||||||
|
.distinct()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
recent_events = (
|
||||||
|
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 []
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(request, "apps/billboard/billboard.html", {
|
||||||
|
"my_rooms": my_rooms,
|
||||||
|
"recent_room": recent_room,
|
||||||
|
"recent_events": recent_events,
|
||||||
|
"viewer": request.user,
|
||||||
|
"applets": applet_context(request.user, "billboard"),
|
||||||
|
"page_class": "page-billboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@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},
|
||||||
|
)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||||
|
"applets": applet_context(request.user, "billboard"),
|
||||||
|
})
|
||||||
|
return redirect("billboard:billboard")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def room_scroll(request, room_id):
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
events = room.events.select_related("actor").all()
|
||||||
|
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
|
||||||
|
return render(request, "apps/billboard/room_scroll.html", {
|
||||||
|
"room": room,
|
||||||
|
"events": events,
|
||||||
|
"viewer": request.user,
|
||||||
|
"scroll_position": sp.position if sp else 0,
|
||||||
|
"page_class": "page-billscroll",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def save_scroll_position(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
from django.http import HttpResponseNotAllowed
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
position = int(request.POST.get("position", 0))
|
||||||
|
ScrollPosition.objects.update_or_create(
|
||||||
|
user=request.user, room=room,
|
||||||
|
defaults={"position": position},
|
||||||
|
)
|
||||||
|
from django.http import HttpResponse
|
||||||
|
return HttpResponse(status=204)
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-12 19:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0002_rename_list_to_note'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shared_notes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
43
src/apps/dashboard/static/apps/dashboard/dashboard.js
Normal file
43
src/apps/dashboard/static/apps/dashboard/dashboard.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// 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");
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindPaletteWheel = () => {
|
||||||
|
document.querySelectorAll('.palette-scroll').forEach(el => {
|
||||||
|
el.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindPaletteForms = () => {
|
||||||
|
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Accept": "application/json" },
|
||||||
|
body: new FormData(form, e.submitter),
|
||||||
|
});
|
||||||
|
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));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
95
src/apps/dashboard/static/apps/dashboard/game-kit.js
Normal file
95
src/apps/dashboard/static/apps/dashboard/game-kit.js
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('id_kit_btn');
|
||||||
|
var dialog = document.getElementById('id_kit_bag_dialog');
|
||||||
|
if (!btn || !dialog) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (dialog.hasAttribute('open')) {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(btn.dataset.kitUrl, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (html) {
|
||||||
|
dialog.innerHTML = html;
|
||||||
|
attachCardListeners();
|
||||||
|
btn.classList.add('active');
|
||||||
|
dialog.setAttribute('open', '');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && dialog.hasAttribute('open')) {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside (but not on the rails button — let that flow through)
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!dialog.hasAttribute('open')) return;
|
||||||
|
if (dialog.contains(e.target)) return;
|
||||||
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
if (e.target.closest('button.token-rails')) return;
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject token_id before token-rails form submits
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var rails = e.target.closest('button.token-rails');
|
||||||
|
if (!rails || !window._kitTokenId) return;
|
||||||
|
var form = rails.closest('form');
|
||||||
|
if (!form) return;
|
||||||
|
var existing = form.querySelector('input[name="token_id"]');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
var hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.name = 'token_id';
|
||||||
|
hidden.value = window._kitTokenId;
|
||||||
|
form.appendChild(hidden);
|
||||||
|
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachTooltip(el) {
|
||||||
|
el.addEventListener('mouseenter', function () {
|
||||||
|
var tooltip = el.querySelector('.token-tooltip');
|
||||||
|
if (!tooltip) return;
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
tooltip.style.position = 'fixed';
|
||||||
|
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||||
|
tooltip.style.left = rect.left + 'px';
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
});
|
||||||
|
el.addEventListener('mouseleave', function () {
|
||||||
|
var tooltip = el.querySelector('.token-tooltip');
|
||||||
|
if (tooltip) tooltip.style.display = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachCardListeners() {
|
||||||
|
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
dialog.querySelectorAll('.token[data-token-id].selected').forEach(function (c) {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
});
|
||||||
|
card.classList.add('selected');
|
||||||
|
window._kitTokenId = card.dataset.tokenId;
|
||||||
|
var slot = document.querySelector('.token-slot');
|
||||||
|
if (slot) slot.classList.add('ready');
|
||||||
|
});
|
||||||
|
attachTooltip(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}());
|
||||||
@@ -21,27 +21,7 @@ const initWallet = () => {
|
|||||||
saveBtn.hidden = false;
|
saveBtn.hidden = false;
|
||||||
cancelBtn.hidden = false;
|
cancelBtn.hidden = false;
|
||||||
const section = addBtn.closest('section');
|
const section = addBtn.closest('section');
|
||||||
const rowPx = 3 * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
section.style.setProperty('--applet-rows', '15');
|
||||||
const updateRows = () => {
|
|
||||||
const sectionTop = section.getBoundingClientRect().top;
|
|
||||||
let maxBottom = sectionTop;
|
|
||||||
for (const child of section.children) {
|
|
||||||
if (child.hidden) continue;
|
|
||||||
maxBottom = Math.max(maxBottom, child.getBoundingClientRect().bottom);
|
|
||||||
}
|
|
||||||
const padBot = parseFloat(getComputedStyle(section).paddingBottom);
|
|
||||||
const rows = Math.ceil((maxBottom - sectionTop + padBot) / rowPx) + 1;
|
|
||||||
section.style.setProperty('--applet-rows', String(rows));
|
|
||||||
};
|
|
||||||
paymentEl.on('ready', () => {
|
|
||||||
updateRows();
|
|
||||||
const stripeContainer = document.getElementById('id_stripe_payment_element');
|
|
||||||
if (stripeContainer) {
|
|
||||||
const obs = new ResizeObserver(updateRows);
|
|
||||||
obs.observe(stripeContainer);
|
|
||||||
section._stripeObs = obs;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
saveBtn.addEventListener('click', async () => {
|
saveBtn.addEventListener('click', async () => {
|
||||||
@@ -68,8 +48,7 @@ const initWallet = () => {
|
|||||||
saveBtn.hidden = true;
|
saveBtn.hidden = true;
|
||||||
cancelBtn.hidden = true;
|
cancelBtn.hidden = true;
|
||||||
const section = cancelBtn.closest('section');
|
const section = cancelBtn.closest('section');
|
||||||
section.style.setProperty('--applet-rows', '2');
|
section.style.setProperty('--applet-rows', '3');
|
||||||
if (section._stripeObs) { section._stripeObs.disconnect(); section._stripeObs = null; }
|
|
||||||
});
|
});
|
||||||
|
|
||||||
cancelBtn.addEventListener('click', () => {
|
cancelBtn.addEventListener('click', () => {
|
||||||
@@ -81,8 +60,7 @@ const initWallet = () => {
|
|||||||
saveBtn.hidden = true;
|
saveBtn.hidden = true;
|
||||||
cancelBtn.hidden = true;
|
cancelBtn.hidden = true;
|
||||||
const section = cancelBtn.closest('section');
|
const section = cancelBtn.closest('section');
|
||||||
section.style.setProperty('--applet-rows', '2');
|
section.style.setProperty('--applet-rows', '3');
|
||||||
if (section._stripeObs) { section._stripeObs.disconnect(); section._stripeObs = null; }
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
// 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");
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -299,11 +299,30 @@ class SetPaletteTest(TestCase):
|
|||||||
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
|
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
|
||||||
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_set_palette_returns_json_when_requested(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/set_palette",
|
||||||
|
data={"palette": "palette-sepia"},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"palette": "palette-sepia"})
|
||||||
|
|
||||||
|
def test_locked_palette_returns_unchanged_json(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/set_palette",
|
||||||
|
data={"palette": "palette-nirvana"},
|
||||||
|
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_dashboard_contains_set_palette_form(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
|
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
|
||||||
self.assertEqual(len(forms), 1)
|
unlocked = [p for p in response.context["palettes"] if not p["locked"]]
|
||||||
|
self.assertEqual(len(forms), len(unlocked))
|
||||||
|
|
||||||
def test_active_palette_swatch_has_active_class(self):
|
def test_active_palette_swatch_has_active_class(self):
|
||||||
response = self.client.get(self.url)
|
response = self.client.get(self.url)
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class WalletViewTest(TestCase):
|
|||||||
[_] = self.parsed.cssselect("#id_coin_on_a_string")
|
[_] = self.parsed.cssselect("#id_coin_on_a_string")
|
||||||
|
|
||||||
def test_wallet_page_shows_free_token(self):
|
def test_wallet_page_shows_free_token(self):
|
||||||
[_] = self.parsed.cssselect("#id_free_token_0")
|
[_] = self.parsed.cssselect("#id_free_token")
|
||||||
|
|
||||||
def test_wallet_page_shows_payment_methods_section(self):
|
def test_wallet_page_shows_payment_methods_section(self):
|
||||||
[_] = self.parsed.cssselect("#id_add_payment_method")
|
[_] = self.parsed.cssselect("#id_add_payment_method")
|
||||||
@@ -61,7 +61,7 @@ class WalletViewAppletContextTest(TestCase):
|
|||||||
)
|
)
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
slug="wallet-payment",
|
slug="wallet-payment",
|
||||||
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 2, "context": "wallet"},
|
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
|
||||||
)
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
@@ -96,7 +96,7 @@ class ToggleWalletAppletsTest(TestCase):
|
|||||||
)[0]
|
)[0]
|
||||||
Applet.objects.get_or_create(
|
Applet.objects.get_or_create(
|
||||||
slug="wallet-payment",
|
slug="wallet-payment",
|
||||||
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 2, "context": "wallet"},
|
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
|
||||||
)
|
)
|
||||||
self.client.force_login(self.user)
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
|||||||
@@ -13,4 +13,5 @@ urlpatterns = [
|
|||||||
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
||||||
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
|
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
|
||||||
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
|
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
|
||||||
|
path('kit-bag/', views.kit_bag, name='kit_bag'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.contrib.auth.decorators import login_required
|
|||||||
from django.db.models import Max, Q
|
from django.db.models import Max, Q
|
||||||
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
|
|
||||||
from apps.applets.models import Applet, UserApplet
|
from apps.applets.models import Applet, UserApplet
|
||||||
@@ -16,9 +17,17 @@ from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
|||||||
|
|
||||||
|
|
||||||
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
||||||
UNLOCKED_PALETTES = frozenset(["palette-default"])
|
UNLOCKED_PALETTES = frozenset([
|
||||||
|
"palette-default",
|
||||||
|
"palette-sepia",
|
||||||
|
"palette-oblivion-light",
|
||||||
|
"palette-monochrome-dark",
|
||||||
|
])
|
||||||
PALETTES = [
|
PALETTES = [
|
||||||
{"name": "palette-default", "label": "Earthman", "locked": False},
|
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||||
|
{"name": "palette-sepia", "label": "Sepia", "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-nirvana", "label": "Nirvana", "locked": True},
|
||||||
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||||
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||||
@@ -113,6 +122,8 @@ def set_palette(request):
|
|||||||
if palette in UNLOCKED_PALETTES:
|
if palette in UNLOCKED_PALETTES:
|
||||||
request.user.palette = palette
|
request.user.palette = palette
|
||||||
request.user.save(update_fields=["palette"])
|
request.user.save(update_fields=["palette"])
|
||||||
|
if "application/json" in request.headers.get("Accept", ""):
|
||||||
|
return JsonResponse({"palette": request.user.palette})
|
||||||
return redirect("home")
|
return redirect("home")
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
@@ -146,13 +157,38 @@ def toggle_applets(request):
|
|||||||
def wallet(request):
|
def wallet(request):
|
||||||
return render(request, "apps/dashboard/wallet.html", {
|
return render(request, "apps/dashboard/wallet.html", {
|
||||||
"wallet": request.user.wallet,
|
"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(),
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
"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)),
|
"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(),
|
||||||
"applets": applet_context(request.user, "wallet"),
|
"applets": applet_context(request.user, "wallet"),
|
||||||
"page_class": "page-wallet",
|
"page_class": "page-wallet",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def kit_bag(request):
|
||||||
|
tokens = list(request.user.tokens.all())
|
||||||
|
free_tokens = sorted(
|
||||||
|
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
|
||||||
|
key=lambda t: t.expires_at,
|
||||||
|
)
|
||||||
|
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||||
|
return render(request, "core/_partials/_kit_bag_panel.html", {
|
||||||
|
"equipped_deck": request.user.equipped_deck,
|
||||||
|
"equipped_trinket": request.user.equipped_trinket,
|
||||||
|
"free_token": free_tokens[0] if free_tokens else None,
|
||||||
|
"free_count": len(free_tokens),
|
||||||
|
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
||||||
|
"tithe_count": len(tithe_tokens),
|
||||||
|
})
|
||||||
|
|
||||||
@login_required(login_url="/")
|
@login_required(login_url="/")
|
||||||
def toggle_wallet_applets(request):
|
def toggle_wallet_applets(request):
|
||||||
checked = request.POST.getlist("applets")
|
checked = request.POST.getlist("applets")
|
||||||
@@ -166,6 +202,7 @@ def toggle_wallet_applets(request):
|
|||||||
return render(request, "apps/wallet/_partials/_applets.html", {
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||||
"applets": applet_context(request.user, "wallet"),
|
"applets": applet_context(request.user, "wallet"),
|
||||||
"wallet": request.user.wallet,
|
"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(),
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||||
|
|||||||
0
src/apps/drama/__init__.py
Normal file
0
src/apps/drama/__init__.py
Normal file
19
src/apps/drama/admin.py
Normal file
19
src/apps/drama/admin.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(GameEvent)
|
||||||
|
class GameEventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("timestamp", "room", "actor", "verb")
|
||||||
|
list_filter = ("verb",)
|
||||||
|
readonly_fields = ("room", "actor", "verb", "data", "timestamp")
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
src/apps/drama/apps.py
Normal file
6
src/apps/drama/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DramaConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.drama'
|
||||||
32
src/apps/drama/migrations/0001_initial.py
Normal file
32
src/apps/drama/migrations/0001_initial.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-19 18:34
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0006_table_status_and_table_seat'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GameEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('verb', 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')], max_length=30)),
|
||||||
|
('data', models.JSONField(default=dict)),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='game_events', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='epic.room')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-24 21:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('drama', '0001_initial'),
|
||||||
|
('epic', '0006_table_status_and_table_seat'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScrollPosition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to='epic.room')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'room')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/apps/drama/migrations/__init__.py
Normal file
0
src/apps/drama/migrations/__init__.py
Normal file
170
src/apps/drama/models.py
Normal file
170
src/apps/drama/models.py
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
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
|
||||||
|
ROOM_CREATED = "room_created"
|
||||||
|
SLOT_RESERVED = "slot_reserved"
|
||||||
|
SLOT_FILLED = "slot_filled"
|
||||||
|
SLOT_RETURNED = "slot_returned"
|
||||||
|
SLOT_RELEASED = "slot_released"
|
||||||
|
INVITE_SENT = "invite_sent"
|
||||||
|
# Role Select phase
|
||||||
|
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"),
|
||||||
|
(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"),
|
||||||
|
]
|
||||||
|
|
||||||
|
room = models.ForeignKey(
|
||||||
|
"epic.Room", on_delete=models.CASCADE, related_name="events",
|
||||||
|
)
|
||||||
|
actor = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="game_events",
|
||||||
|
)
|
||||||
|
verb = models.CharField(max_length=30, choices=VERB_CHOICES)
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["timestamp"]
|
||||||
|
|
||||||
|
def to_prose(self):
|
||||||
|
"""Return a human-readable action description (actor rendered separately in template)."""
|
||||||
|
d = self.data
|
||||||
|
if self.verb == self.SLOT_FILLED:
|
||||||
|
_token_names = {
|
||||||
|
"coin": "Coin-on-a-String", "Free": "Free Token",
|
||||||
|
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
|
||||||
|
}
|
||||||
|
code = d.get("token_type", "token")
|
||||||
|
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} (expires in {days} days)."
|
||||||
|
if self.verb == self.SLOT_RESERVED:
|
||||||
|
return "reserves a seat"
|
||||||
|
if self.verb == self.SLOT_RETURNED:
|
||||||
|
return "withdraws from the gate"
|
||||||
|
if self.verb == self.SLOT_RELEASED:
|
||||||
|
return f"releases slot {d.get('slot_number', '?')}"
|
||||||
|
if self.verb == self.ROOM_CREATED:
|
||||||
|
return "opens this room"
|
||||||
|
if self.verb == self.INVITE_SENT:
|
||||||
|
return "sends an invitation"
|
||||||
|
if self.verb == self.ROLE_SELECT_STARTED:
|
||||||
|
return "Role selection begins"
|
||||||
|
if self.verb == self.ROLE_SELECTED:
|
||||||
|
_role_names = {
|
||||||
|
"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)
|
||||||
|
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}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollPosition(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||||
|
related_name="scroll_positions",
|
||||||
|
)
|
||||||
|
room = models.ForeignKey(
|
||||||
|
"epic.Room", on_delete=models.CASCADE,
|
||||||
|
related_name="scroll_positions",
|
||||||
|
)
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("user", "room")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email} @ {self.room.name}: {self.position}px"
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
116
src/apps/drama/tests/integrated/test_models.py
Normal file
116
src/apps/drama/tests/integrated/test_models.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
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))
|
||||||
|
|
||||||
|
# ── 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))
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
0
src/apps/epic/__init__.py
Normal file
0
src/apps/epic/__init__.py
Normal file
18
src/apps/epic/admin.py
Normal file
18
src/apps/epic/admin.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from .models import DeckVariant, TarotCard
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(DeckVariant)
|
||||||
|
class DeckVariantAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "slug", "card_count", "is_default"]
|
||||||
|
prepopulated_fields = {"slug": ["name"]}
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(TarotCard)
|
||||||
|
class TarotCardAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ["name", "deck_variant", "arcana", "suit", "number", "group", "slug"]
|
||||||
|
list_filter = ["deck_variant", "arcana", "suit"]
|
||||||
|
search_fields = ["name", "slug", "correspondence", "group"]
|
||||||
|
readonly_fields = ["slug", "correspondence", "group"]
|
||||||
|
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||||
5
src/apps/epic/apps.py
Normal file
5
src/apps/epic/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EpicConfig(AppConfig):
|
||||||
|
name = 'apps.epic'
|
||||||
94
src/apps/epic/consumers.py
Normal file
94
src/apps/epic/consumers.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
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):
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def role_select_start(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def turn_changed(self, event):
|
||||||
|
await self.send_json(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)
|
||||||
0
src/apps/epic/forms.py
Normal file
0
src/apps/epic/forms.py
Normal file
45
src/apps/epic/migrations/0001_initial.py
Normal file
45
src/apps/epic/migrations/0001_initial.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-12 19:46
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Room',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('visibility', models.CharField(choices=[('PRIVATE', 'Private'), ('PUBLIC', 'Public'), ('INVITE ONLY', 'Invite Only')], default='PRIVATE', max_length=20)),
|
||||||
|
('gate_status', models.CharField(choices=[('GATHERING', 'Gathering'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20)),
|
||||||
|
('renewal_period', models.DurationField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('board_state', models.JSONField(default=dict)),
|
||||||
|
('seed_count', models.IntegerField(default=12)),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_rooms', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GateSlot',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slot_number', models.IntegerField()),
|
||||||
|
('status', models.CharField(choices=[('EMPTY', 'Empty'), ('RESERVED', 'Reserved'), ('FILLED', 'Filled')], default='EMPTY', max_length=10)),
|
||||||
|
('reserved_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('filled_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('funded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funded_slots', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gate_slots', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gate_slots', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/apps/epic/migrations/0002_alter_room_renewal_period.py
Normal file
19
src/apps/epic/migrations/0002_alter_room_renewal_period.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-13 20:32
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='renewal_period',
|
||||||
|
field=models.DurationField(blank=True, default=datetime.timedelta(days=7), null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
src/apps/epic/migrations/0003_roominvite.py
Normal file
27
src/apps/epic/migrations/0003_roominvite.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-13 22:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0002_alter_room_renewal_period'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RoomInvite',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('invitee_email', models.EmailField(max_length=254)),
|
||||||
|
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined')], default='PENDING', max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_invites', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/epic/migrations/0004_alter_room_gate_status.py
Normal file
18
src/apps/epic/migrations/0004_alter_room_gate_status.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-15 00:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0003_roominvite'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='gate_status',
|
||||||
|
field=models.CharField(choices=[('GATHERING', 'GATHERING GAMERS'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0004_alter_room_gate_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='gateslot',
|
||||||
|
name='debited_token_type',
|
||||||
|
field=models.CharField(max_length=8, null=True, blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='gateslot',
|
||||||
|
name='debited_token_expires_at',
|
||||||
|
field=models.DateTimeField(null=True, blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
src/apps/epic/migrations/0006_table_status_and_table_seat.py
Normal file
33
src/apps/epic/migrations/0006_table_status_and_table_seat.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-17 00:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0005_gateslot_debited_token_fields'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='table_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TableSeat',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slot_number', models.IntegerField()),
|
||||||
|
('role', models.CharField(blank=True, choices=[('PC', 'Player'), ('BC', 'Builder'), ('SC', 'Shepherd'), ('AC', 'Alchemist'), ('NC', 'Narrator'), ('EC', 'Economist')], max_length=2, null=True)),
|
||||||
|
('role_revealed', models.BooleanField(default=False)),
|
||||||
|
('seat_position', models.IntegerField(blank=True, null=True)),
|
||||||
|
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='table_seats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_seats', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal file
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-24 23:33
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0006_table_status_and_table_seat'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TarotCard',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('arcana', models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana')], max_length=5)),
|
||||||
|
('suit', models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True)),
|
||||||
|
('number', models.IntegerField()),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('keywords_upright', models.JSONField(default=list)),
|
||||||
|
('keywords_reversed', models.JSONField(default=list)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['arcana', 'suit', 'number'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TarotDeck',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('drawn_card_ids', models.JSONField(default=list)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('room', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tarot_deck', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal file
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
MAJOR_ARCANA = [
|
||||||
|
(0, "The Fool", "the-fool", ["beginnings", "spontaneity", "freedom"], ["recklessness", "naivety", "risk"]),
|
||||||
|
(1, "The Magician", "the-magician", ["willpower", "skill", "resourcefulness"], ["manipulation", "untapped potential", "deceit"]),
|
||||||
|
(2, "The High Priestess", "the-high-priestess", ["intuition", "mystery", "inner knowledge"], ["secrets", "disconnection", "withdrawal"]),
|
||||||
|
(3, "The Empress", "the-empress", ["fertility", "abundance", "nurturing"], ["dependence", "smothering", "creative block"]),
|
||||||
|
(4, "The Emperor", "the-emperor", ["authority", "structure", "stability"], ["rigidity", "domination", "inflexibility"]),
|
||||||
|
(5, "The Hierophant", "the-hierophant", ["tradition", "conformity", "institutions"], ["rebellion", "unconventionality", "challenge"]),
|
||||||
|
(6, "The Lovers", "the-lovers", ["love", "harmony", "choice"], ["disharmony", "imbalance", "misalignment"]),
|
||||||
|
(7, "The Chariot", "the-chariot", ["control", "willpower", "victory"], ["aggression", "lack of direction", "defeat"]),
|
||||||
|
(8, "Strength", "strength", ["courage", "patience", "compassion"], ["self-doubt", "weakness", "insecurity"]),
|
||||||
|
(9, "The Hermit", "the-hermit", ["introspection", "guidance", "solitude"], ["isolation", "loneliness", "withdrawal"]),
|
||||||
|
(10, "Wheel of Fortune", "wheel-of-fortune", ["change", "cycles", "fate"], ["bad luck", "resistance", "clinging to control"]),
|
||||||
|
(11, "Justice", "justice", ["fairness", "truth", "cause and effect"], ["injustice", "dishonesty", "avoidance"]),
|
||||||
|
(12, "The Hanged Man", "the-hanged-man", ["pause", "surrender", "new perspective"], ["stalling", "resistance", "indecision"]),
|
||||||
|
(13, "Death", "death", ["endings", "transition", "transformation"], ["fear of change", "stagnation", "resistance"]),
|
||||||
|
(14, "Temperance", "temperance", ["balance", "patience", "moderation"], ["imbalance", "excess", "lack of harmony"]),
|
||||||
|
(15, "The Devil", "the-devil", ["bondage", "materialism", "shadow self"], ["detachment", "freedom", "releasing control"]),
|
||||||
|
(16, "The Tower", "the-tower", ["sudden change", "upheaval", "revelation"], ["avoidance", "fear of change", "delaying disaster"]),
|
||||||
|
(17, "The Star", "the-star", ["hope", "renewal", "inspiration"], ["despair", "insecurity", "hopelessness"]),
|
||||||
|
(18, "The Moon", "the-moon", ["illusion", "fear", "the unconscious"], ["confusion", "misinterpretation", "clarity"]),
|
||||||
|
(19, "The Sun", "the-sun", ["positivity", "success", "vitality"], ["negativity", "depression", "sadness"]),
|
||||||
|
(20, "Judgement", "judgement", ["reflection", "reckoning", "absolution"], ["self-doubt", "lack of self-awareness", "loathing"]),
|
||||||
|
(21, "The World", "the-world", ["completion", "integration", "accomplishment"], ["incompletion", "no closure", "shortcuts"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
MINOR_SUITS = [
|
||||||
|
("WANDS", "wands"),
|
||||||
|
("CUPS", "cups"),
|
||||||
|
("SWORDS", "swords"),
|
||||||
|
("PENTACLES", "pentacles"),
|
||||||
|
]
|
||||||
|
|
||||||
|
MINOR_NAMES = [
|
||||||
|
(1, "Ace", "ace"),
|
||||||
|
(2, "Two", "two"),
|
||||||
|
(3, "Three", "three"),
|
||||||
|
(4, "Four", "four"),
|
||||||
|
(5, "Five", "five"),
|
||||||
|
(6, "Six", "six"),
|
||||||
|
(7, "Seven", "seven"),
|
||||||
|
(8, "Eight", "eight"),
|
||||||
|
(9, "Nine", "nine"),
|
||||||
|
(10, "Ten", "ten"),
|
||||||
|
(11, "Page", "page"),
|
||||||
|
(12, "Knight", "knight"),
|
||||||
|
(13, "Queen", "queen"),
|
||||||
|
(14, "King", "king"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Keywords: [suit][number-1] → (upright_list, reversed_list)
|
||||||
|
MINOR_KEYWORDS = {
|
||||||
|
"WANDS": [
|
||||||
|
(["inspiration", "new venture", "spark"], ["delays", "lack of motivation", "false start"]),
|
||||||
|
(["planning", "progress", "decisions"], ["impatience", "lack of planning", "hesitation"]),
|
||||||
|
(["expansion", "foresight", "enterprise"], ["obstacles", "lack of foresight", "delays"]),
|
||||||
|
(["celebration", "harmony", "homecoming"], ["lack of support", "transience", "home conflicts"]),
|
||||||
|
(["conflict", "competition", "tension"], ["avoiding conflict", "compromise", "truce"]),
|
||||||
|
(["victory", "recognition", "progress"], ["excess pride", "lack of recognition", "fall"]),
|
||||||
|
(["challenge", "courage", "competition"], ["anxiety", "giving up", "overwhelmed"]),
|
||||||
|
(["rapid action", "adventure", "change"], ["haste", "scattered energy", "delays"]),
|
||||||
|
(["resilience", "persistence", "last stand"], ["exhaustion", "giving up", "surrender"]),
|
||||||
|
(["completion", "celebration", "travel"], ["burdens", "oppression", "carrying too much"]),
|
||||||
|
(["exploration", "enthusiasm", "adventure"], ["hasty decisions", "scattered energy", "immaturity"]),
|
||||||
|
(["energy", "passion", "adventure"], ["scattered energy", "frustration", "aggression"]),
|
||||||
|
(["confidence", "independence", "courage"], ["selfishness", "jealousy", "insecurity"]),
|
||||||
|
(["big picture", "leadership", "vision"], ["impulsiveness", "haste", "overconfidence"]),
|
||||||
|
],
|
||||||
|
"CUPS": [
|
||||||
|
(["new feelings", "intuition", "opportunity"], ["blocked creativity", "emptiness", "hesitation"]),
|
||||||
|
(["partnership", "unity", "celebration"], ["imbalance", "broken bonds", "misalignment"]),
|
||||||
|
(["creativity", "community", "abundance"], ["independence", "isolation", "looking inward"]),
|
||||||
|
(["contemplation", "apathy", "reevaluation"], ["withdrawal", "boredom", "seeking motivation"]),
|
||||||
|
(["loss", "grief", "disappointment"], ["acceptance", "moving on", "forgiveness"]),
|
||||||
|
(["nostalgia", "reunion", "joy"], ["living in the past", "naivety", "unrealistic"]),
|
||||||
|
(["illusion", "fantasy", "wishful thinking"], ["alignment", "clarity", "sobriety"]),
|
||||||
|
(["disappointment", "abandonment", "walking away"], ["hopelessness", "aimlessness", "stagnation"]),
|
||||||
|
(["contentment", "fulfilment", "satisfaction"], ["inner happiness", "materialism", "indulgence"]),
|
||||||
|
(["divine love", "bliss", "fulfilment"], ["inner happiness", "alignment", "personal values"]),
|
||||||
|
(["sensitivity", "creativity", "intuition"], ["insecurity", "emotional immaturity", "creative blocks"]),
|
||||||
|
(["compassion", "romanticism", "diplomacy"], ["moodiness", "emotional manipulation", "deception"]),
|
||||||
|
(["compassion", "empathy", "nurturing"], ["emotional insecurity", "over-giving", "neglect"]),
|
||||||
|
(["emotional maturity", "diplomacy", "wisdom"], ["manipulation", "moodiness", "coldness"]),
|
||||||
|
],
|
||||||
|
"SWORDS": [
|
||||||
|
(["raw power", "breakthrough", "clarity"], ["confusion", "brutality", "mental chaos"]),
|
||||||
|
(["difficult choices", "stalemate", "truce"], ["indecision", "lies", "confusion"]),
|
||||||
|
(["heartbreak", "sorrow", "grief"], ["recovery", "forgiveness", "moving on"]),
|
||||||
|
(["rest", "restoration", "retreat"], ["restlessness", "burnout", "illness"]),
|
||||||
|
(["defeat", "change", "transition"], ["resistance to change", "inability to move"]),
|
||||||
|
(["victory", "success", "ambition"], ["an eye for an eye", "dishonour", "manipulation"]),
|
||||||
|
(["deception", "trickery", "tactics"], ["imposter syndrome", "coming clean", "rethinking"]),
|
||||||
|
(["restriction", "isolation", "imprisonment"], ["self-limiting beliefs", "inner critic", "opening up"]),
|
||||||
|
(["anxiety", "worry", "fear"], ["recovery from anxiety", "inner turmoil", "secrets"]),
|
||||||
|
(["ruin", "painful endings", "loss"], ["recovery", "regeneration", "resisting an end"]),
|
||||||
|
(["new ideas", "mental agility", "curiosity"], ["manipulation", "all talk no action", "ruthlessness"]),
|
||||||
|
(["action", "impulsiveness", "ambition"], ["no direction", "disregard for consequences"]),
|
||||||
|
(["clarity", "directness", "structure"], ["coldness", "cruelty", "manipulation"]),
|
||||||
|
(["mental clarity", "truth", "authority"], ["abuse of power", "manipulation", "coldness"]),
|
||||||
|
],
|
||||||
|
"PENTACLES": [
|
||||||
|
(["opportunity", "new venture", "manifestation"], ["lost opportunity", "lack of planning", "scarcity"]),
|
||||||
|
(["juggling resources", "flexibility", "fun"], ["imbalance", "disorganisation", "overwhelm"]),
|
||||||
|
(["teamwork", "building", "apprenticeship"], ["lack of teamwork", "disharmony", "misalignment"]),
|
||||||
|
(["stability", "security", "conservation"], ["greed", "stinginess", "possessiveness"]),
|
||||||
|
(["isolation", "insecurity", "worry"], ["recovery from loss", "overcoming hardship"]),
|
||||||
|
(["generosity", "charity", "community"], ["strings attached", "power dynamics", "inequality"]),
|
||||||
|
(["hard work", "perseverance", "diligence"], ["lack of reward", "laziness", "low quality"]),
|
||||||
|
(["apprenticeship", "education", "skill"], ["perfectionism", "misdirected activity", "misuse"]),
|
||||||
|
(["abundance", "luxury", "self-sufficiency"], ["overindulgence", "superficiality", "materialism"]),
|
||||||
|
(["wealth", "financial security", "achievement"], ["financial failure", "greed", "lost success"]),
|
||||||
|
(["ambition", "diligence", "management"], ["underhandedness", "greediness", "unethical"]),
|
||||||
|
(["hard work", "productivity", "routine"], ["laziness", "obsession with work", "burnout"]),
|
||||||
|
(["nurturing", "practical", "abundance"], ["financial dependence", "smothering", "insecurity"]),
|
||||||
|
(["abundance", "prosperity", "security"], ["greed", "indulgence", "sensual obsession"]),
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_tarot_cards(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
|
||||||
|
# Major Arcana
|
||||||
|
for number, name, slug, upright, reversed_ in MAJOR_ARCANA:
|
||||||
|
TarotCard.objects.create(
|
||||||
|
name=name,
|
||||||
|
arcana="MAJOR",
|
||||||
|
suit=None,
|
||||||
|
number=number,
|
||||||
|
slug=slug,
|
||||||
|
keywords_upright=upright,
|
||||||
|
keywords_reversed=reversed_,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Minor Arcana
|
||||||
|
for suit_code, suit_slug in MINOR_SUITS:
|
||||||
|
for number, rank_name, rank_slug in MINOR_NAMES:
|
||||||
|
upright, reversed_ = MINOR_KEYWORDS[suit_code][number - 1]
|
||||||
|
TarotCard.objects.create(
|
||||||
|
name=f"{rank_name} of {suit_code.capitalize()}",
|
||||||
|
arcana="MINOR",
|
||||||
|
suit=suit_code,
|
||||||
|
number=number,
|
||||||
|
slug=f"{rank_slug}-of-{suit_slug}",
|
||||||
|
keywords_upright=upright,
|
||||||
|
keywords_reversed=reversed_,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unseed_tarot_cards(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
TarotCard.objects.all().delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0007_tarotcard_tarotdeck"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_tarot_cards, reverse_code=unseed_tarot_cards),
|
||||||
|
]
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-25 00:48
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0008_seed_tarot_cards'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DeckVariant',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(max_length=100, unique=True)),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('card_count', models.IntegerField()),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('is_default', models.BooleanField(default=False)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='tarotcard',
|
||||||
|
options={'ordering': ['deck_variant', 'arcana', 'suit', 'number']},
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='correspondence',
|
||||||
|
field=models.CharField(blank=True, max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='group',
|
||||||
|
field=models.CharField(blank=True, max_length=100),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='name',
|
||||||
|
field=models.CharField(max_length=200),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='slug',
|
||||||
|
field=models.SlugField(max_length=120),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='suit',
|
||||||
|
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('COINS', 'Coins')], max_length=10, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tarotcard',
|
||||||
|
name='deck_variant',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='epic.deckvariant'),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='tarotdeck',
|
||||||
|
name='deck_variant',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_decks', to='epic.deckvariant'),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='tarotcard',
|
||||||
|
unique_together={('deck_variant', 'slug')},
|
||||||
|
),
|
||||||
|
]
|
||||||
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal file
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
"""
|
||||||
|
Data migration:
|
||||||
|
1. Create DeckVariant records (Fiorentine Minchiate + Earthman).
|
||||||
|
2. Backfill the 78 existing TarotCards → Fiorentine Minchiate.
|
||||||
|
3. Seed all 108 Earthman cards (52 major + 56 minor).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
# ── Earthman Major Arcana (52 cards, numbers 0–51) ──────────────────────────
|
||||||
|
# (name, slug, group, correspondence)
|
||||||
|
EARTHMAN_MAJOR = [
|
||||||
|
# ── The Schiz ──────────────────────────────────────────────────────────
|
||||||
|
(0, "The Schiz", "the-schiz", "", "The Fool / Il Matto"),
|
||||||
|
|
||||||
|
# ── The Popes ──────────────────────────────────────────────────────────
|
||||||
|
(1, "Pope I: President", "pope-i-president", "The Popes", "The Magician / Il Bagatto"),
|
||||||
|
(2, "Pope II: Tsar", "pope-ii-tsar", "The Popes", "The Popess / La Papessa"),
|
||||||
|
(3, "Pope III: Chairman", "pope-iii-chairman", "The Popes", "The Empress / L'Imperatrice"),
|
||||||
|
(4, "Pope IV: Emperor", "pope-iv-emperor", "The Popes", "The Emperor / L'Imperatore"),
|
||||||
|
(5, "Pope V: Chancellor", "pope-v-chancellor", "The Popes", "The Pope / Il Papa"),
|
||||||
|
|
||||||
|
# ── The Virtues, Implicit (cardinal / acquired) ────────────────────────
|
||||||
|
(6, "Virtue VI: Controlled Folly", "virtue-vi-controlled-folly", "The Virtues, Implicit", "Fortitude / La Fortezza"),
|
||||||
|
(7, "Virtue VII: Not-Doing", "virtue-vii-not-doing", "The Virtues, Implicit", "Justice / La Giustizia"),
|
||||||
|
(8, "Virtue VIII: Losing Self-Importance","virtue-viii-losing-self-importance","The Virtues, Implicit", "Temperance / La Temperanza"),
|
||||||
|
(9, "Virtue IX: Erasing Personal History","virtue-ix-erasing-personal-history","The Virtues, Implicit", "Prudence / La Prudenza"),
|
||||||
|
|
||||||
|
# ── Wheel ──────────────────────────────────────────────────────────────
|
||||||
|
(10, "Wheel of Fortune", "wheel-of-fortune-em", "", "La Ruota della Fortuna"),
|
||||||
|
|
||||||
|
# ── Solo cards ─────────────────────────────────────────────────────────
|
||||||
|
(11, "The Junkboat", "the-junkboat", "", "The Chariot / Il Carro"),
|
||||||
|
(12, "The Junkman", "the-junkman", "", "The Hanged Man / L'Appeso"),
|
||||||
|
(13, "Death", "death-em", "", "La Morte"),
|
||||||
|
(14, "The Traitor", "the-traitor", "", "The Devil / Il Diavolo"),
|
||||||
|
(15, "Disco Inferno", "disco-inferno", "", "The Tower / La Torre"),
|
||||||
|
(16, "Torre Terrestre", "torre-terrestre", "", "Purgatorio"),
|
||||||
|
(17, "Fantasia Celestia", "fantasia-celestia", "", "Paradiso"),
|
||||||
|
|
||||||
|
# ── The Virtues, Explicit (theological / infused) ─────────────────────
|
||||||
|
(18, "Virtue XVIII: Stalking", "virtue-xviii-stalking", "The Virtues, Explicit", "Love / Charity / La Carità"),
|
||||||
|
(19, "Virtue XIX: Intent", "virtue-xix-intent", "The Virtues, Explicit", "Hope / La Speranza"),
|
||||||
|
(20, "Virtue XX: Dreaming", "virtue-xx-dreaming", "The Virtues, Explicit", "Faith / La Fede"),
|
||||||
|
|
||||||
|
# ── The Elements, Classical ────────────────────────────────────────────
|
||||||
|
(21, "Element XXI: Fire", "element-xxi-fire", "The Elements, Classical", "Ardor [Ar]"),
|
||||||
|
(22, "Element XXII: Earth", "element-xxii-earth", "The Elements, Classical", "Ossum [Om]"),
|
||||||
|
(23, "Element XXIII: Air", "element-xxiii-air", "The Elements, Classical", "Pneuma [Pn]"),
|
||||||
|
(24, "Element XXIV: Water", "element-xxiv-water", "The Elements, Classical", "Humor [Hm]"),
|
||||||
|
|
||||||
|
# ── The Zodiac ─────────────────────────────────────────────────────────
|
||||||
|
(25, "Zodiac XXV: Aries", "zodiac-xxv-aries", "The Zodiac", "The Ram"),
|
||||||
|
(26, "Zodiac XXVI: Taurus", "zodiac-xxvi-taurus", "The Zodiac", "The Bull"),
|
||||||
|
(27, "Zodiac XXVII: Gemini", "zodiac-xxvii-gemini", "The Zodiac", "The Twins"),
|
||||||
|
(28, "Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer", "The Zodiac", "The Crab"),
|
||||||
|
(29, "Zodiac XXIX: Leo", "zodiac-xxix-leo", "The Zodiac", "The Lion"),
|
||||||
|
(30, "Zodiac XXX: Virgo", "zodiac-xxx-virgo", "The Zodiac", "The Maiden"),
|
||||||
|
(31, "Zodiac XXXI: Libra", "zodiac-xxxi-libra", "The Zodiac", "The Scales"),
|
||||||
|
(32, "Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio", "The Zodiac", "The Scorpion"),
|
||||||
|
(33, "Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius", "The Zodiac", "The Archer"),
|
||||||
|
(34, "Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn", "The Zodiac", "The Sea-Goat"),
|
||||||
|
(35, "Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius", "The Zodiac", "The Water-Bearer"),
|
||||||
|
(36, "Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces", "The Zodiac", "The Fish"),
|
||||||
|
|
||||||
|
# ── The Elements, Absolute ─────────────────────────────────────────────
|
||||||
|
(37, "Element XXXVII: Time", "element-xxxvii-time", "The Elements, Absolute", "Tempo [Tp]"),
|
||||||
|
(38, "Element XXXVIII: Space", "element-xxxviii-space", "The Elements, Absolute", "Nexus [Nx]"),
|
||||||
|
|
||||||
|
# ── The Wanderers ──────────────────────────────────────────────────────
|
||||||
|
(39, "Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar", "The Wanderers", "The Star / Le Stelle"),
|
||||||
|
(40, "Wanderer XL: The Antichthon", "wanderer-xl-antichthon", "The Wanderers", "The Moon / La Luna"),
|
||||||
|
(41, "Wanderer XLI: The Corestar", "wanderer-xli-corestar", "The Wanderers", "The Sun / Il Sole"),
|
||||||
|
(42, "Wanderer XLII: Mercury", "wanderer-xlii-mercury", "The Wanderers", "Mercurio"),
|
||||||
|
(43, "Wanderer XLIII: Venus", "wanderer-xliii-venus", "The Wanderers", "Venere"),
|
||||||
|
(44, "Wanderer XLIV: Mars", "wanderer-xliv-mars", "The Wanderers", "Marte"),
|
||||||
|
(45, "Wanderer XLV: Jupiter", "wanderer-xlv-jupiter", "The Wanderers", "Giove"),
|
||||||
|
(46, "Wanderer XLVI: Saturn", "wanderer-xlvi-saturn", "The Wanderers", "Saturno"),
|
||||||
|
(47, "Wanderer XLVII: Uranus", "wanderer-xlvii-uranus", "The Wanderers", "Urano"),
|
||||||
|
(48, "Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune", "The Wanderers", "Nettuno"),
|
||||||
|
(49, "Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades", "The Wanderers", "The Binary / Plutone-Proserpina"),
|
||||||
|
|
||||||
|
# ── Finale ─────────────────────────────────────────────────────────────
|
||||||
|
(50, "The Eagle", "the-eagle", "", "Judgement / L'Angelo"),
|
||||||
|
(51, "Divine Calculus", "divine-calculus", "", "The World / Il Mondo"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Earthman Minor Arcana ────────────────────────────────────────────────────
|
||||||
|
# 4 suits × 14 cards. Suits: WANDS / CUPS / SWORDS / COINS
|
||||||
|
# Court cards: Jack (11) / Cavalier (12) / Queen (13) / King (14)
|
||||||
|
EARTHMAN_SUITS = [
|
||||||
|
("WANDS", "wands", "Ardor [Ar] — Fire"),
|
||||||
|
("CUPS", "cups", "Humor [Hm] — Water"),
|
||||||
|
("SWORDS","swords","Pneuma [Pn] — Air"),
|
||||||
|
("COINS", "coins", "Ossum [Om] — Stone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
EARTHMAN_RANKS = [
|
||||||
|
(1, "Ace", "ace"),
|
||||||
|
(2, "2", "two"),
|
||||||
|
(3, "3", "three"),
|
||||||
|
(4, "4", "four"),
|
||||||
|
(5, "5", "five"),
|
||||||
|
(6, "6", "six"),
|
||||||
|
(7, "7", "seven"),
|
||||||
|
(8, "8", "eight"),
|
||||||
|
(9, "9", "nine"),
|
||||||
|
(10, "10", "ten"),
|
||||||
|
(11, "Jack", "jack"),
|
||||||
|
(12, "Cavalier", "cavalier"),
|
||||||
|
(13, "Queen", "queen"),
|
||||||
|
(14, "King", "king"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
# ── 1. Create DeckVariant records ────────────────────────────────────
|
||||||
|
fiorentine = DeckVariant.objects.create(
|
||||||
|
name="Fiorentine Minchiate",
|
||||||
|
slug="fiorentine-minchiate",
|
||||||
|
card_count=78,
|
||||||
|
description="Standard 78-card Minchiate deck. Alt / lite play mode.",
|
||||||
|
is_default=False,
|
||||||
|
)
|
||||||
|
earthman = DeckVariant.objects.create(
|
||||||
|
name="Earthman Deck",
|
||||||
|
slug="earthman",
|
||||||
|
card_count=108,
|
||||||
|
description=(
|
||||||
|
"Primary 108-card Earthman deck. "
|
||||||
|
"52 Major Arcana (The Schiz through Divine Calculus) "
|
||||||
|
"+ 56 Minor Arcana across Wands, Cups, Swords, Coins."
|
||||||
|
),
|
||||||
|
is_default=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 2. Backfill existing 78 Fiorentine cards ─────────────────────────
|
||||||
|
TarotCard.objects.filter(deck_variant__isnull=True).update(
|
||||||
|
deck_variant=fiorentine
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 3. Seed Earthman Major Arcana ────────────────────────────────────
|
||||||
|
for number, name, slug, group, correspondence in EARTHMAN_MAJOR:
|
||||||
|
TarotCard.objects.create(
|
||||||
|
deck_variant=earthman,
|
||||||
|
name=name,
|
||||||
|
arcana="MAJOR",
|
||||||
|
suit=None,
|
||||||
|
number=number,
|
||||||
|
slug=slug,
|
||||||
|
group=group,
|
||||||
|
correspondence=correspondence,
|
||||||
|
keywords_upright=[],
|
||||||
|
keywords_reversed=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── 4. Seed Earthman Minor Arcana ────────────────────────────────────
|
||||||
|
for suit_code, suit_slug, _element in EARTHMAN_SUITS:
|
||||||
|
for number, rank_name, rank_slug in EARTHMAN_RANKS:
|
||||||
|
name = f"{rank_name} of {suit_code.capitalize()}"
|
||||||
|
slug = f"{rank_slug}-of-{suit_slug}-em"
|
||||||
|
TarotCard.objects.create(
|
||||||
|
deck_variant=earthman,
|
||||||
|
name=name,
|
||||||
|
arcana="MINOR",
|
||||||
|
suit=suit_code,
|
||||||
|
number=number,
|
||||||
|
slug=slug,
|
||||||
|
group="",
|
||||||
|
correspondence="",
|
||||||
|
keywords_upright=[],
|
||||||
|
keywords_reversed=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(apps, schema_editor):
|
||||||
|
TarotCard = apps.get_model("epic", "TarotCard")
|
||||||
|
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||||
|
|
||||||
|
# Remove Earthman cards and clear FK from Fiorentine cards
|
||||||
|
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||||
|
if earthman:
|
||||||
|
TarotCard.objects.filter(deck_variant=earthman).delete()
|
||||||
|
|
||||||
|
fiorentine = DeckVariant.objects.filter(slug="fiorentine-minchiate").first()
|
||||||
|
if fiorentine:
|
||||||
|
TarotCard.objects.filter(deck_variant=fiorentine).update(deck_variant=None)
|
||||||
|
|
||||||
|
DeckVariant.objects.filter(slug__in=["earthman", "fiorentine-minchiate"]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0009_deckvariant_alter_tarotcard_options_and_more"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, reverse_code=reverse),
|
||||||
|
]
|
||||||
82
src/apps/epic/migrations/0011_rename_earthman_court_cards.py
Normal file
82
src/apps/epic/migrations/0011_rename_earthman_court_cards.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename Earthman court cards at positions 11 and 12.
|
||||||
|
|
||||||
|
Old naming (from 0010): Jack (11) / Cavalier (12)
|
||||||
|
New naming: Maid (11) / Jack (12)
|
||||||
|
|
||||||
|
Must rename 11 → Maid first so the "jack-of-*-em" slugs are free
|
||||||
|
before the 12s claim them.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
SUITS = ["Wands", "Cups", "Swords", "Coins"]
|
||||||
|
|
||||||
|
|
||||||
|
def rename_court_cards(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
|
||||||
|
|
||||||
|
# Step 1: Jack (11) → Maid — frees up jack-of-*-em slugs
|
||||||
|
for suit in SUITS:
|
||||||
|
suit_slug = suit.lower()
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, number=11, slug=f"jack-of-{suit_slug}-em"
|
||||||
|
).update(
|
||||||
|
name=f"Maid of {suit}",
|
||||||
|
slug=f"maid-of-{suit_slug}-em",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Cavalier (12) → Jack — takes the now-free jack-of-*-em slugs
|
||||||
|
for suit in SUITS:
|
||||||
|
suit_slug = suit.lower()
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, number=12, slug=f"cavalier-of-{suit_slug}-em"
|
||||||
|
).update(
|
||||||
|
name=f"Jack of {suit}",
|
||||||
|
slug=f"jack-of-{suit_slug}-em",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_court_cards(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
|
||||||
|
|
||||||
|
# Step 1: Jack (12) → Cavalier — frees up jack-of-*-em slugs
|
||||||
|
for suit in SUITS:
|
||||||
|
suit_slug = suit.lower()
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, number=12, slug=f"jack-of-{suit_slug}-em"
|
||||||
|
).update(
|
||||||
|
name=f"Cavalier of {suit}",
|
||||||
|
slug=f"cavalier-of-{suit_slug}-em",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 2: Maid (11) → Jack
|
||||||
|
for suit in SUITS:
|
||||||
|
suit_slug = suit.lower()
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, number=11, slug=f"maid-of-{suit_slug}-em"
|
||||||
|
).update(
|
||||||
|
name=f"Jack of {suit}",
|
||||||
|
slug=f"jack-of-{suit_slug}-em",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0010_seed_deck_variants_and_earthman"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_court_cards, reverse_code=reverse_court_cards),
|
||||||
|
]
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"""
|
||||||
|
Data migration:
|
||||||
|
1. Rename grouped Earthman major arcana to use group-relative ordinals
|
||||||
|
(e.g. "Virtue VI: Controlled Folly" → "Implicit Virtue 1: Controlled Folly").
|
||||||
|
2. Spell out Earthman minor arcana pip names 2–10
|
||||||
|
(e.g. "2 of Wands" → "Two of Wands").
|
||||||
|
|
||||||
|
Corner ranks (Roman numerals of absolute card number) are a property on the model
|
||||||
|
and are unchanged — this only affects the stored name / slug fields.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
# ── Major arcana: (new_name, new_slug) keyed by card number ─────────────────
|
||||||
|
|
||||||
|
MAJOR_RENAMES = {
|
||||||
|
# Implicit Virtues (cards 6–9)
|
||||||
|
6: ("Implicit Virtue 1: Controlled Folly", "implicit-virtue-1-controlled-folly"),
|
||||||
|
7: ("Implicit Virtue 2: Not-Doing", "implicit-virtue-2-not-doing"),
|
||||||
|
8: ("Implicit Virtue 3: Losing Self-Importance", "implicit-virtue-3-losing-self-importance"),
|
||||||
|
9: ("Implicit Virtue 4: Erasing Personal History", "implicit-virtue-4-erasing-personal-history"),
|
||||||
|
# Explicit Virtues (cards 18–20)
|
||||||
|
18: ("Explicit Virtue 1: Stalking", "explicit-virtue-1-stalking"),
|
||||||
|
19: ("Explicit Virtue 2: Intent", "explicit-virtue-2-intent"),
|
||||||
|
20: ("Explicit Virtue 3: Dreaming", "explicit-virtue-3-dreaming"),
|
||||||
|
# Classical Elements (cards 21–24)
|
||||||
|
21: ("Classical Element 1: Fire", "classical-element-1-fire"),
|
||||||
|
22: ("Classical Element 2: Earth", "classical-element-2-earth"),
|
||||||
|
23: ("Classical Element 3: Air", "classical-element-3-air"),
|
||||||
|
24: ("Classical Element 4: Water", "classical-element-4-water"),
|
||||||
|
# Zodiac (cards 25–36)
|
||||||
|
25: ("Zodiac 1: Aries", "zodiac-1-aries"),
|
||||||
|
26: ("Zodiac 2: Taurus", "zodiac-2-taurus"),
|
||||||
|
27: ("Zodiac 3: Gemini", "zodiac-3-gemini"),
|
||||||
|
28: ("Zodiac 4: Cancer", "zodiac-4-cancer"),
|
||||||
|
29: ("Zodiac 5: Leo", "zodiac-5-leo"),
|
||||||
|
30: ("Zodiac 6: Virgo", "zodiac-6-virgo"),
|
||||||
|
31: ("Zodiac 7: Libra", "zodiac-7-libra"),
|
||||||
|
32: ("Zodiac 8: Scorpio", "zodiac-8-scorpio"),
|
||||||
|
33: ("Zodiac 9: Sagittarius", "zodiac-9-sagittarius"),
|
||||||
|
34: ("Zodiac 10: Capricorn", "zodiac-10-capricorn"),
|
||||||
|
35: ("Zodiac 11: Aquarius", "zodiac-11-aquarius"),
|
||||||
|
36: ("Zodiac 12: Pisces", "zodiac-12-pisces"),
|
||||||
|
# Absolute Elements (cards 37–38)
|
||||||
|
37: ("Absolute Element 1: Time", "absolute-element-1-time"),
|
||||||
|
38: ("Absolute Element 2: Space", "absolute-element-2-space"),
|
||||||
|
# Wanderers (cards 39–49)
|
||||||
|
39: ("Wanderer 1: The Polestar", "wanderer-1-polestar"),
|
||||||
|
40: ("Wanderer 2: The Antichthon", "wanderer-2-antichthon"),
|
||||||
|
41: ("Wanderer 3: The Corestar", "wanderer-3-corestar"),
|
||||||
|
42: ("Wanderer 4: Mercury", "wanderer-4-mercury"),
|
||||||
|
43: ("Wanderer 5: Venus", "wanderer-5-venus"),
|
||||||
|
44: ("Wanderer 6: Mars", "wanderer-6-mars"),
|
||||||
|
45: ("Wanderer 7: Jupiter", "wanderer-7-jupiter"),
|
||||||
|
46: ("Wanderer 8: Saturn", "wanderer-8-saturn"),
|
||||||
|
47: ("Wanderer 9: Uranus", "wanderer-9-uranus"),
|
||||||
|
48: ("Wanderer 10: Neptune", "wanderer-10-neptune"),
|
||||||
|
49: ("Wanderer 11: The King & Queen of Hades", "wanderer-11-king-queen-hades"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Original (name, slug) pairs for reversal
|
||||||
|
MAJOR_ORIGINALS = {
|
||||||
|
6: ("Virtue VI: Controlled Folly", "virtue-vi-controlled-folly"),
|
||||||
|
7: ("Virtue VII: Not-Doing", "virtue-vii-not-doing"),
|
||||||
|
8: ("Virtue VIII: Losing Self-Importance", "virtue-viii-losing-self-importance"),
|
||||||
|
9: ("Virtue IX: Erasing Personal History", "virtue-ix-erasing-personal-history"),
|
||||||
|
18: ("Virtue XVIII: Stalking", "virtue-xviii-stalking"),
|
||||||
|
19: ("Virtue XIX: Intent", "virtue-xix-intent"),
|
||||||
|
20: ("Virtue XX: Dreaming", "virtue-xx-dreaming"),
|
||||||
|
21: ("Element XXI: Fire", "element-xxi-fire"),
|
||||||
|
22: ("Element XXII: Earth", "element-xxii-earth"),
|
||||||
|
23: ("Element XXIII: Air", "element-xxiii-air"),
|
||||||
|
24: ("Element XXIV: Water", "element-xxiv-water"),
|
||||||
|
25: ("Zodiac XXV: Aries", "zodiac-xxv-aries"),
|
||||||
|
26: ("Zodiac XXVI: Taurus", "zodiac-xxvi-taurus"),
|
||||||
|
27: ("Zodiac XXVII: Gemini", "zodiac-xxvii-gemini"),
|
||||||
|
28: ("Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer"),
|
||||||
|
29: ("Zodiac XXIX: Leo", "zodiac-xxix-leo"),
|
||||||
|
30: ("Zodiac XXX: Virgo", "zodiac-xxx-virgo"),
|
||||||
|
31: ("Zodiac XXXI: Libra", "zodiac-xxxi-libra"),
|
||||||
|
32: ("Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio"),
|
||||||
|
33: ("Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius"),
|
||||||
|
34: ("Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn"),
|
||||||
|
35: ("Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius"),
|
||||||
|
36: ("Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces"),
|
||||||
|
37: ("Element XXXVII: Time", "element-xxxvii-time"),
|
||||||
|
38: ("Element XXXVIII: Space", "element-xxxviii-space"),
|
||||||
|
39: ("Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar"),
|
||||||
|
40: ("Wanderer XL: The Antichthon", "wanderer-xl-antichthon"),
|
||||||
|
41: ("Wanderer XLI: The Corestar", "wanderer-xli-corestar"),
|
||||||
|
42: ("Wanderer XLII: Mercury", "wanderer-xlii-mercury"),
|
||||||
|
43: ("Wanderer XLIII: Venus", "wanderer-xliii-venus"),
|
||||||
|
44: ("Wanderer XLIV: Mars", "wanderer-xliv-mars"),
|
||||||
|
45: ("Wanderer XLV: Jupiter", "wanderer-xlv-jupiter"),
|
||||||
|
46: ("Wanderer XLVI: Saturn", "wanderer-xlvi-saturn"),
|
||||||
|
47: ("Wanderer XLVII: Uranus", "wanderer-xlvii-uranus"),
|
||||||
|
48: ("Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune"),
|
||||||
|
49: ("Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades"),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Pip number → spelled-out word (slugs already use the word form, only name changes)
|
||||||
|
PIP_SPELLINGS = {
|
||||||
|
2: "Two", 3: "Three", 4: "Four", 5: "Five",
|
||||||
|
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten",
|
||||||
|
}
|
||||||
|
|
||||||
|
SUITS = ["WANDS", "CUPS", "SWORDS", "COINS"]
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 1. Rename grouped major arcana to group-relative ordinals
|
||||||
|
for number, (new_name, new_slug) in MAJOR_RENAMES.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=new_name, slug=new_slug)
|
||||||
|
|
||||||
|
# 2. Spell out pip names 2–10
|
||||||
|
for number, word in PIP_SPELLINGS.items():
|
||||||
|
for suit in SUITS:
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
|
||||||
|
).update(name=f"{word} of {suit.capitalize()}")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
# 1. Restore original major arcana names
|
||||||
|
for number, (old_name, old_slug) in MAJOR_ORIGINALS.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=old_name, slug=old_slug)
|
||||||
|
|
||||||
|
# 2. Restore numeric pip names (slugs unchanged)
|
||||||
|
for number, _word in PIP_SPELLINGS.items():
|
||||||
|
for suit in SUITS:
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
|
||||||
|
).update(name=f"{number} of {suit.capitalize()}")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0011_rename_earthman_court_cards"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
55
src/apps/epic/migrations/0013_earthman_coins_to_pentacles.py
Normal file
55
src/apps/epic/migrations/0013_earthman_coins_to_pentacles.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename Earthman 4th-suit cards from COINS → PENTACLES.
|
||||||
|
|
||||||
|
Updates:
|
||||||
|
- suit field: "COINS" → "PENTACLES"
|
||||||
|
- name: "X of Coins" → "X of Pentacles"
|
||||||
|
- slug: "x-of-coins-em" → "x-of-pentacles-em"
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def coins_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
|
||||||
|
|
||||||
|
cards = TarotCard.objects.filter(deck_variant=earthman, suit="COINS")
|
||||||
|
for card in cards:
|
||||||
|
card.suit = "PENTACLES"
|
||||||
|
card.name = card.name.replace(" of Coins", " of Pentacles")
|
||||||
|
card.slug = card.slug.replace("-of-coins-em", "-of-pentacles-em")
|
||||||
|
card.save(update_fields=["suit", "name", "slug"])
|
||||||
|
|
||||||
|
|
||||||
|
def pentacles_to_coins(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
|
||||||
|
|
||||||
|
# Only reverse cards that came from Earthman (identified by -em slug suffix)
|
||||||
|
cards = TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, suit="PENTACLES", slug__endswith="-em"
|
||||||
|
)
|
||||||
|
for card in cards:
|
||||||
|
card.suit = "COINS"
|
||||||
|
card.name = card.name.replace(" of Pentacles", " of Coins")
|
||||||
|
card.slug = card.slug.replace("-of-pentacles-em", "-of-coins-em")
|
||||||
|
card.save(update_fields=["suit", "name", "slug"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0012_rename_earthman_major_groups_and_pip_spellings"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(coins_to_pentacles, reverse_code=pentacles_to_coins),
|
||||||
|
]
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename the five Pope cards to use Arabic group-relative ordinals,
|
||||||
|
matching the convention set for other grouped major arcana.
|
||||||
|
|
||||||
|
"Pope I: President" → "Pope 1: President"
|
||||||
|
"Pope II: Tsar" → "Pope 2: Tsar"
|
||||||
|
etc.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
POPE_RENAMES = {
|
||||||
|
1: ("Pope 1: President", "pope-1-president"),
|
||||||
|
2: ("Pope 2: Tsar", "pope-2-tsar"),
|
||||||
|
3: ("Pope 3: Chairman", "pope-3-chairman"),
|
||||||
|
4: ("Pope 4: Emperor", "pope-4-emperor"),
|
||||||
|
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
POPE_ORIGINALS = {
|
||||||
|
1: ("Pope I: President", "pope-i-president"),
|
||||||
|
2: ("Pope II: Tsar", "pope-ii-tsar"),
|
||||||
|
3: ("Pope III: Chairman", "pope-iii-chairman"),
|
||||||
|
4: ("Pope IV: Emperor", "pope-iv-emperor"),
|
||||||
|
5: ("Pope V: Chancellor", "pope-v-chancellor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 POPE_RENAMES.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 POPE_ORIGINALS.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=old_name, slug=old_slug)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0013_earthman_coins_to_pentacles"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""
|
||||||
|
Data migration: rename Earthman card 22 from "Classical Element 2: Earth"
|
||||||
|
to "Classical Element 2: Stone" (Stone = Ossum, the Earthman name for Earth).
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=22
|
||||||
|
).update(name="Classical Element 2: Stone", slug="classical-element-2-stone")
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=22
|
||||||
|
).update(name="Classical Element 2: Earth", slug="classical-element-2-earth")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0014_rename_earthman_popes_arabic_ordinals"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal file
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
"""
|
||||||
|
Data migration: reorder the five Pope cards.
|
||||||
|
|
||||||
|
New assignment (card number → title):
|
||||||
|
1 → Chancellor 2 → President 3 → Tsar 4 → Chairman 5 → Emperor
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
POPE_RENAMES = {
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
||||||
|
POPE_ORIGINALS = {
|
||||||
|
1: ("Pope 1: President", "pope-1-president"),
|
||||||
|
2: ("Pope 2: Tsar", "pope-2-tsar"),
|
||||||
|
3: ("Pope 3: Chairman", "pope-3-chairman"),
|
||||||
|
4: ("Pope 4: Emperor", "pope-4-emperor"),
|
||||||
|
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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 POPE_RENAMES.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 POPE_ORIGINALS.items():
|
||||||
|
TarotCard.objects.filter(
|
||||||
|
deck_variant=earthman, arcana="MAJOR", number=number
|
||||||
|
).update(name=old_name, slug=old_slug)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("epic", "0015_rename_classical_element_earth_to_stone"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user