Compare commits
91 Commits
74f63a7721
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5655342d9f | ||
|
|
2088fedeee | ||
|
|
6ebb2fbd51 | ||
|
|
b86a4ddd73 | ||
|
|
214120ef2d | ||
|
|
7d4389a74a | ||
|
|
cd5252c185 | ||
|
|
e8687dc050 | ||
|
|
48aad6ce35 | ||
|
|
473e6bc45a | ||
|
|
6d9d3d4f54 | ||
|
|
565f727aa6 | ||
|
|
3cc9f5a527 | ||
|
|
be061f6bc2 | ||
|
|
83ce238a2f | ||
|
|
6069d86ec5 | ||
|
|
a44727c559 | ||
|
|
0b2320e39b | ||
|
|
5c05bd6552 | ||
|
|
b5a92ddf77 | ||
|
|
bb1cda9c9c | ||
|
|
3974fdac82 | ||
|
|
b8ac004fb6 | ||
|
|
02975d79d3 | ||
|
|
04f0e87eba | ||
|
|
ebc460fe67 | ||
|
|
7c249500bd | ||
|
|
ea2bfa6ce1 | ||
|
|
9c7d58f0b3 | ||
|
|
4761d3f939 | ||
|
|
2be330e698 | ||
|
|
fbf260b148 | ||
|
|
09ed64080b | ||
|
|
f15b17f7bd | ||
|
|
122de3bc80 | ||
|
|
6e995647e4 | ||
|
|
d7d20f25e3 | ||
|
|
758c9c5377 | ||
|
|
7c03bded8d | ||
|
|
8a24021739 | ||
|
|
bd9a2fdae3 | ||
|
|
4f8e52890b | ||
|
|
abf8be8861 | ||
|
|
127f4a092d | ||
|
|
2910012b67 | ||
|
|
db9ac9cb24 | ||
|
|
d3e4638233 | ||
|
|
10a6809dcf | ||
|
|
de4ac60aec | ||
|
|
71ef3dcb7f | ||
|
|
9beb21bffe | ||
|
|
6248d95bf3 | ||
|
|
44cf399352 | ||
|
|
df2b353ebd | ||
|
|
3fd1f5e990 | ||
|
|
02a7a0ef2e | ||
|
|
cc2ab869f1 | ||
|
|
8c711ac674 | ||
|
|
b8af0041cc | ||
|
|
97ec2f6ee6 | ||
|
|
0a135c2149 | ||
|
|
f1e9a9657b | ||
|
|
32d8d97360 | ||
|
|
df421fb6c0 | ||
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab | ||
|
|
d4518a0671 |
3
.gitignore
vendored
@@ -183,3 +183,6 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/django
|
||||
|
||||
# Local dev utilities (Windows-only, not part of the app)
|
||||
*.ps1
|
||||
|
||||
@@ -22,6 +22,10 @@ steps:
|
||||
- python manage.py test apps
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: test-two-browser-FTs
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
@@ -38,9 +42,14 @@ steps:
|
||||
- cd ./src
|
||||
- python manage.py collectstatic --noinput
|
||||
- python manage.py test functional_tests --tag=two-browser
|
||||
- python manage.py test functional_tests --tag=sequential
|
||||
- python manage.py test functional_tests --tag=channels
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: test-FTs
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
@@ -59,6 +68,10 @@ steps:
|
||||
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: screendumps
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
@@ -67,6 +80,10 @@ steps:
|
||||
when:
|
||||
- event: push
|
||||
status: failure
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: build-and-push
|
||||
image: docker:cli
|
||||
@@ -80,8 +97,13 @@ steps:
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- "Dockerfile"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: deploy
|
||||
- name: deploy-staging
|
||||
image: alpine
|
||||
environment:
|
||||
SSH_KEY:
|
||||
@@ -95,4 +117,23 @@ steps:
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- "Dockerfile"
|
||||
- "infra/**"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: deploy-prod
|
||||
image: alpine
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: deploy_ssh_key
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
|
||||
when:
|
||||
- event: tag
|
||||
33
.woodpecker/pyswiss.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
steps:
|
||||
- name: test-pyswiss
|
||||
image: python:3.13-slim
|
||||
environment:
|
||||
SWISSEPH_PATH: /tmp/ephe
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y -q gcc g++
|
||||
- pip install -r pyswiss/requirements.txt
|
||||
- cd ./pyswiss
|
||||
- python manage.py test apps.charts
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "pyswiss/**"
|
||||
- ".woodpecker/pyswiss.yaml"
|
||||
|
||||
- name: deploy-pyswiss
|
||||
image: alpine
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: pyswiss_deploy
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh -o StrictHostKeyChecking=no discoman@167.172.154.66 /home/discoman/deploy.sh
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
path:
|
||||
- "pyswiss/**"
|
||||
- ".woodpecker/pyswiss.yaml"
|
||||
129
CLAUDE.md
@@ -3,43 +3,10 @@
|
||||
Originally built following Harry Percival's *Test-Driven Development with Python* (3rd ed., complete through ch. 25). Now an ongoing game app — EarthmanRPG — extended well beyond the book.
|
||||
|
||||
## Browser Integration
|
||||
**Claudezilla** is installed — a Firefox extension + native host that lets Claude observe and interact with the browser directly.
|
||||
**Claudezilla** is installed — a Firefox extension + native host for browser automation.
|
||||
See `.claude/skills/claudezilla-browser/SKILL.md` for tool list, startup protocol, and setup reference.
|
||||
|
||||
### Tool names
|
||||
Tools are available as `mcp__claudezilla__firefox_*`, e.g.:
|
||||
- `mcp__claudezilla__firefox_screenshot` — capture current tab
|
||||
- `mcp__claudezilla__firefox_navigate` — navigate to URL
|
||||
- `mcp__claudezilla__firefox_get_page_state` — structured JSON (faster than screenshot)
|
||||
- `mcp__claudezilla__firefox_create_window` — open new tab (returns `tabId`)
|
||||
- `mcp__claudezilla__firefox_diagnose` — check connection status
|
||||
- `mcp__claudezilla__firefox_set_private_mode` — disable private mode to use session cookies
|
||||
|
||||
All tools require a `tabId` except `firefox_create_window` and `firefox_diagnose`.
|
||||
|
||||
### If tools aren't available in a session
|
||||
MCP servers load at session startup only. **Start a new Claude Code conversation** (hit "+" in the sidebar) — no need to reboot VSCode, just open a fresh chat. Always call `firefox_diagnose` first to confirm the connection is live.
|
||||
|
||||
### Correct startup sequence
|
||||
1. Firefox open with Claudezilla extension active (native host must be running)
|
||||
2. Open a new Claude Code conversation → tools appear as `mcp__claudezilla__firefox_*`
|
||||
3. Call `firefox_diagnose` to confirm before depending on any tool
|
||||
|
||||
### Setup (already done — for reference)
|
||||
The native messaging host requires a `.bat` wrapper on Windows (Firefox can't execute `.js` directly):
|
||||
- Wrapper: `E:\ClaudeLibrary\claudezilla\host\claudezilla.bat` — contains `@echo off` / `node "%~dp0index.js" %*`
|
||||
- Manifest: `C:\Users\adamc\AppData\Roaming\claudezilla\claudezilla.json` — points to the `.bat` file
|
||||
- Registry: `HKCU\SOFTWARE\Mozilla\NativeMessagingHosts\claudezilla` → manifest path
|
||||
- MCP server: registered in `~/.claude.json` (NOT `~/.claude/settings.json` or `~/.claude/mcp.json`) — use the CLI to register:
|
||||
```
|
||||
claude mcp add --scope user claudezilla "D:/Program Files/nodejs/node.exe" "E:/ClaudeLibrary/claudezilla/mcp/server.js"
|
||||
```
|
||||
- Permission: `mcp__claudezilla__*` in `~/.claude/settings.json` `permissions.allow`
|
||||
|
||||
**Config file gotcha:** The Claude Code CLI and VSCode extension read user-level MCP servers from `~/.claude.json` (home dir, single file) — NOT from `~/.claude/settings.json` or `~/.claude/mcp.json`. Always use `claude mcp add --scope user` to register; never hand-edit. Verify registration with `claude mcp list`.
|
||||
|
||||
**BOM gotcha:** PowerShell writes JSON files with a UTF-8 BOM, which causes `JSON.parse` to throw. Never use PowerShell `Set-Content` to write any Claude config JSON — use the Write tool or the CLI instead.
|
||||
|
||||
Native host: `E:\ClaudeLibrary\claudezilla\host\`. Extension: `claudezilla@boot.industries`.
|
||||
**STARTUP RULE:** Call `mcp__claudezilla__firefox_diagnose` at the start of every conversation before any browser tool. If tools aren't listed in a session, open a new Claude Code conversation (MCP servers load at startup only).
|
||||
|
||||
## Stack
|
||||
- **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn)
|
||||
@@ -87,49 +54,66 @@ Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory.
|
||||
cd src
|
||||
uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --reload --app-dir src
|
||||
|
||||
# Integration + unit tests only (from project root — targets src/apps, skips src/functional_tests)
|
||||
python src/manage.py test src/apps
|
||||
# Integration + unit tests (exclude channels)
|
||||
python src/manage.py test src/apps --exclude-tag=channels
|
||||
|
||||
# Functional tests only
|
||||
# Functional tests
|
||||
python src/manage.py test src/functional_tests
|
||||
|
||||
# All tests (integration + unit + FT)
|
||||
python src/manage.py test src
|
||||
```
|
||||
|
||||
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
||||
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
||||
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
||||
See `.claude/skills/TDD/SKILL.md` for the full TDD cycle, test file conventions, base classes, and per-layer run commands. See `.claude/skills/dev-server/SKILL.md` for server startup options.
|
||||
|
||||
FTs are isolated by **directory path** (`src/functional_tests/`), not by tag.
|
||||
### Multi-user manual testing — `setup_sig_session`
|
||||
|
||||
Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic.
|
||||
Creates (or reuses) a room at `table_status=SIG_SELECT` with all 6 slots filled. Prints one pre-auth URL per gamer.
|
||||
|
||||
## CI/CD
|
||||
```bash
|
||||
python src/manage.py setup_sig_session
|
||||
python src/manage.py setup_sig_session --base-url http://localhost:8000
|
||||
python src/manage.py setup_sig_session --room <uuid>
|
||||
```
|
||||
|
||||
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all superusers with Earthman deck. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
|
||||
|
||||
## CI/CD + Hosting
|
||||
- Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`)
|
||||
- Gitea: `https://gitea.earthmanrpg.me` | Woodpecker CI: `https://ci.earthmanrpg.me`
|
||||
- Push to `main` triggers Woodpecker → deploys to staging
|
||||
- Push to `main` triggers Woodpecker → deploys to staging (`staging.earthmanrpg.me`)
|
||||
- Prod deploy: `git tag v1.0.0 && git push --tags` → triggers `deploy-prod` step (tag-based gate)
|
||||
- Two CI pipelines run in parallel: `.woodpecker/main.yaml` (main app) + `.woodpecker/pyswiss.yaml` (PySwiss at charts.earthmanrpg.me)
|
||||
- Multi-browser FTs tagged `@tag("two-browser")` run in a dedicated CI stage (`test-two-browser-FTs`) alongside `--tag=channels`; `test-FTs` stage is parallel-only
|
||||
- Hosting: DigitalOcean — main app on staging droplet; PySwiss on separate droplet (167.172.154.66)
|
||||
- Email: Mailgun (`adman@howdy.earthmanrpg.me`) | DNS: NameCheap
|
||||
|
||||
## UI / Layout Conventions
|
||||
|
||||
### Sidebar layout (`$sidebar-w: 4rem`)
|
||||
Navbar is a fixed left sidebar; footer is a fixed right sidebar. Both are `4rem` wide. Main container uses `margin-left: $sidebar-w; margin-right: $sidebar-w`. Landscape layout resets `min-width` to `0` on `.gameboard-page` and `#id_dash_content` (override of the `@media (min-width: 738px)` block that sets `min-width: 666px`).
|
||||
|
||||
### Applet headings + page titles
|
||||
- Section headings: plain `<h2>` — browser default + body color inherited; no extra SCSS needed
|
||||
- Clickable headings: `<h2><a href="...">Text</a></h2>` — global `body a` rule supplies gold + hover glow
|
||||
- Page titles: `<span>Dash</span>suffix` pattern (Dashwallet, Dashnote, Dashnotes)
|
||||
|
||||
### Position vs Seat terminology
|
||||
Circles around the table hex are **positions** (gate slot order, 1–6). After role assignment they become **seats** (PC→NC→EC→SC→AC→BC). CSS carries both: `.table-seat.table-position`. `SLOT_ROLE_LABELS = {1:"PC", 2:"NC", 3:"EC", 4:"SC", 5:"AC", 6:"BC"}` in `epic/views.py`.
|
||||
|
||||
## Game Architecture
|
||||
|
||||
### Token priority chain
|
||||
`select_token(user)` in `apps.epic.models`: **PASS → COIN → FREE → TITHE → None**. `debit_token` handles each type's consumption rules (Coin cooldown, Free/Tithe expiry).
|
||||
|
||||
### Two-step gate token flow
|
||||
Drop → RESERVED → confirm/reject. `_gate_context()` builds slot state; `_expire_reserved_slots()` clears stale reservations after 60s. Views: `confirm_token`, `reject_token` (renamed `return_token`).
|
||||
|
||||
### Room URL routing
|
||||
`epic:room` view at `/gameboard/room/<uuid>/`. `gatekeeper` redirects there when `table_status` is set. Error redirects in `select_role`/`select_sig` use `epic:room` if `table_status` is set, else `epic:gatekeeper`.
|
||||
|
||||
## SCSS Import Order
|
||||
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → billboard → game-kit → wallet-tokens`
|
||||
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens`
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
### TransactionTestCase flushes migration data
|
||||
FTs use `TransactionTestCase` which flushes migration-seeded rows. Any IT/FT `setUp` that needs `Applet` rows must call `Applet.objects.get_or_create(slug=..., defaults={...})` — never rely on migration data surviving.
|
||||
|
||||
### Static files in tests
|
||||
`StaticLiveServerTestCase` serves via `STATICFILES_FINDERS` only — NOT from `STATIC_ROOT`. JS/CSS that lives only in `src/static/` (STATIC_ROOT, gitignored) is a silent 404 in CI. All app JS must live in `src/apps/<app>/static/` or `src/static_src/`.
|
||||
|
||||
### msgpack integer key bug (Django Channels)
|
||||
`channels_redis` uses msgpack with `strict_map_key=True` — integer dict keys in `group_send` payloads raise `ValueError` and crash consumers. Always use `str(slot_number)` as dict keys.
|
||||
|
||||
### Multi-browser FTs in CI
|
||||
Any FT opening a second browser must pass `FirefoxOptions` with `--headless` when `HEADLESS` env var is set. Bare `webdriver.Firefox()` crashes in headless CI with `Process unexpectedly closed with status 1`.
|
||||
|
||||
### Selenium + CSS text-transform
|
||||
Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` causes `assertIn("Test Room", body.text)` to fail. Assert against the uppercased string or use `body.text.upper()`.
|
||||
|
||||
### Tooltip portal pattern
|
||||
`mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`.
|
||||
|
||||
@@ -144,5 +128,16 @@ Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase`
|
||||
- Task unit tests: `apps.lyric.tasks.requests.post`
|
||||
- FTs: mock both with `side_effect=send_login_email_task`
|
||||
|
||||
## Teaching Style
|
||||
User prefers guided learning: describe what to type and why, let them write the code, then review together. But for now, given user's accelerated timetable, please respond with complete code snippets for user to transcribe directly.
|
||||
### game-kit.js selection persistence
|
||||
`window._kitTokenId` must NOT be cleared on kit-bag close — users close the dialog before clicking the rails button. Selection persists until page navigation. No `clearSelection()` in `game-kit.js`.
|
||||
|
||||
### Billboard timezone cookie
|
||||
`document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone` — **no `encodeURIComponent`**. Slashes in TZ names (`America/New_York`) are cookie-safe; encoding breaks the `ZoneInfo` lookup in `TimezoneMiddleware`.
|
||||
|
||||
### CSS `:has()` for child-dependent styling
|
||||
Use `.parent:has(.child-class)` to style a parent based on its contents without template changes. Example: `.gate-slot:has(.drop-token-btn)` makes CARTE OK-button circles match `.reserved` circles.
|
||||
|
||||
### Plausible FT noise
|
||||
Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag.
|
||||
|
||||
See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys).
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
|
||||
REDIS_URL: "redis://gamearray_redis:6379/1"
|
||||
PYSWISS_URL: "{{ pyswiss_url }}"
|
||||
networks:
|
||||
- name: gamearray_net
|
||||
ports:
|
||||
@@ -160,6 +161,7 @@
|
||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
|
||||
REDIS_URL: "redis://gamearray_redis:6379/1"
|
||||
PYSWISS_URL: "{{ pyswiss_url }}"
|
||||
networks:
|
||||
- name: gamearray_net
|
||||
command: "python -m celery -A core worker -l info"
|
||||
|
||||
@@ -9,4 +9,5 @@ STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }}
|
||||
STRIPE_SECRET_KEY={{ stripe_secret_key }}
|
||||
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
|
||||
REDIS_URL=redis://gamearray_redis:6379/1
|
||||
PYSWISS_URL=https://charts.earthmanrpg.me
|
||||
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
38383061343764656262613934313230656462366163363263653462333338333863326338343838
|
||||
3664646437643462346636623231633639396239333532340a363338313839353734326238643735
|
||||
39343237396433336436366430626332343666666461613636656433363838613432393539386266
|
||||
3237336434346333350a663530623334633438616135376437666631313064333735653633396461
|
||||
31306163343838336465626663373661343839653037333235313361633335646337353339616333
|
||||
35343233346562346236636364316265313936646235373866636333353866623161663935626637
|
||||
31633864366339653930626365373237326531366632626337636163333266656434323063333365
|
||||
38373437383261613439306666373764633737623466626235356465636365646337306534326535
|
||||
36633866663161613632613434666134343465383663633165663330376535653537333763376232
|
||||
61653265303134656338393033303834663630653064666134633638393235346631346461633030
|
||||
35343332393961363361613661633633613262663231366236396663636239326534373134623762
|
||||
30653139333134616236666238616466633733656633326331386138363839653566333434346534
|
||||
63326539333461383265316332336333656365386531393630663537363365643061363263313738
|
||||
37633564363533633762393736636333306433306534393539636231656162343562383232663932
|
||||
62646339363266303564383438636636373661656465666663613863396639633732636635326166
|
||||
39323738303338373466366236623665633538363134616565326665386564613735393638656630
|
||||
31326431316163376132623064376634643737313864336464623431333834663361336133353838
|
||||
32303635663261333732306137383133623134373363613837306637663566303634653863343766
|
||||
33613936626362653466333537666462373633313038376565623363666631353162643634653730
|
||||
30323532623261643136666237316561353038323265303930336364633731333533386563623133
|
||||
31343965643336613933663431626435333235366639363334653065303434386165333739336632
|
||||
61363030376664643638653365626365623936623864666663326534343863613962616431376666
|
||||
39363837386639393235316339323932326466616330303165613032663637616232656162653335
|
||||
61613266376262626234383135306238313366346330656333383465383861663962653638303362
|
||||
34353833646461383839386238626661346263363131643438343461393739336132386466373665
|
||||
32646238633161363064666335626639653335306236613866333934646366323564306133396131
|
||||
36343032623964316138386538333863363530396330646431373466646538663063326330663639
|
||||
32323762356632336364333162336133336335623865323861663131626232633066643238333237
|
||||
32343938353166353037316162653832663433343534626331633936633866356666653932656665
|
||||
38396533356131326262633431653435306362633966383531356236396639376437396333616130
|
||||
35666435393461316232323234653865346338326330623065373461323961393663306262313066
|
||||
30313430353065616230356135333565333338373663643434353561363438656233383739663233
|
||||
35653832353062396634613832353837333835636461616234343462626239636634613430373931
|
||||
31656534343764643065643733326637343631356633653531313062633362663461313732633331
|
||||
35626364393563373339636466346339383032383635303865306636623737343237333863353238
|
||||
63306132396262656365323833323635633563653735366630313363386236613231346339643430
|
||||
63396230353566633830383932666335373665356434656438336338633035653465613665613862
|
||||
31663565653338376662323866613538363566306635333735646363363730646331306234353839
|
||||
30346363393231623563646439623261643634663831313338393761343865303930373133633733
|
||||
31656466303365316164396463373335396464643130643337656361333339653238333633373662
|
||||
6539
|
||||
33643937613637343765356165333337356138326236356334363238366632633935363563383232
|
||||
6263396663316461353035393836313535353133336132650a643062656239633635373930366131
|
||||
63363566666263336337356161663231343266383333613261666534653438666661303761653063
|
||||
6163333239313430620a613665393231356535666530613731303536613537333464613533616663
|
||||
30373935366138643939316563346364376333646333396264653537643666393835353964303031
|
||||
30366366666163383263663961383037386264393939306235646532636439383838343237303339
|
||||
62333965323763323233303239343132383830303130306265333330333434663337363930653161
|
||||
30646133333530333330653365306437313839636535333163346263343064376436633432623061
|
||||
39343332643836333932316439636166333831393864363434663837646339666638353835393964
|
||||
61363430303637633239373031396535383730623862386464316633393361306561613933353830
|
||||
66313835306563643733366135353062623635663165303833373563663063323731313162323133
|
||||
61373837353732656266336461663165626435383234336461343365396561623037353566356339
|
||||
32366336396638626166616362613230323933666565613561393431393035376465343739333739
|
||||
36313934313636386465306435353132373364653562666162613033373130623430656632396635
|
||||
39373437353838313734636166323336376534373765623332356638666234376464383033326433
|
||||
33636336376231313062643237636534363838326264333930383635373761346532393664363038
|
||||
34633334653464313430363735666435373535363465343134333636303536303265333931343138
|
||||
35633864623930386661316264383865373930316233653238323437363836643236333236336537
|
||||
37353565313434383733333861626566623363316335666230373435633163356566616366663339
|
||||
64323533366265396164303937323036323037383637643332326361363864333334653232376134
|
||||
33346366343865336437383138396639393238353633343562356435306537633830303361333730
|
||||
30386133396565613539653931663961303534613566626265376135386461383162396334393733
|
||||
39343466336136643565656332336562643933383330343830633264396436383065373032646664
|
||||
34643939613962653137303238663535633565363961336263316631313737663036336331663133
|
||||
61323538376434396432633565613135376163636233373832366461353665633266373435396436
|
||||
30376539366264306661353863313165323839646536393466623838393862396530326466363936
|
||||
36373865316165393665353737643561663863353630373333313936653163386136623831396637
|
||||
36306236626337303561376366376639613337396136313336383131303634623364316234376432
|
||||
65383362346363336639366665333436346234383566643937643130363261656662653763313639
|
||||
66396162356234343163633633376639623736643066643030626232633634616261303530623032
|
||||
38393032643963386133393534616133396135303531333839643063613331643334323762653933
|
||||
39646234366564333935366335363964666337383264333263326561636231303164356532323163
|
||||
63323430363337353339353739363638366136326231666335343830363838663366613432303735
|
||||
34323431343336643566346365333062363862646138396535633036653737643462323235326265
|
||||
39306336396238653063353939613966323466306335346635353964613535313961353263303235
|
||||
35646330366534386330333135316437313435376331343630643330323030626432343034323861
|
||||
39363437333137386137323036333336613238613530316338343930616137666261383733653432
|
||||
63316266323664396335363334663465636262663366346139383535626236653765323038343366
|
||||
64386639373536306638323036386364373465313037393431663965646633613838303566663139
|
||||
31663162313166636262313663363061666531636432366536343063336439636465663032356563
|
||||
30656562336565303237663332303230306637353465616136346233636464616666383734303938
|
||||
32666466366363346232653461333263366164313130336331326339366361326139636635646630
|
||||
376264626331393262653961663566383866
|
||||
|
||||
0
pyswiss/apps/__init__.py
Normal file
0
pyswiss/apps/charts/__init__.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChartsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.charts'
|
||||
178
pyswiss/apps/charts/calc.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""
|
||||
Core ephemeris calculation logic — shared by views and management commands.
|
||||
"""
|
||||
from django.conf import settings as django_settings
|
||||
import swisseph as swe
|
||||
|
||||
|
||||
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
|
||||
|
||||
SIGNS = [
|
||||
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
|
||||
]
|
||||
|
||||
SIGN_ELEMENT = {
|
||||
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
|
||||
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
|
||||
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
|
||||
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
|
||||
}
|
||||
|
||||
ASPECTS = [
|
||||
('Conjunction', 0, 8.0),
|
||||
('Semisextile', 30, 4.0),
|
||||
('Sextile', 60, 6.0),
|
||||
('Square', 90, 8.0),
|
||||
('Trine', 120, 8.0),
|
||||
('Quincunx', 150, 5.0),
|
||||
('Opposition', 180, 10.0),
|
||||
# ('Semisquare', 45, 4.0),
|
||||
# ('Sesquiquadrate', 135, 4.0),
|
||||
]
|
||||
|
||||
PLANET_CODES = {
|
||||
'Sun': swe.SUN,
|
||||
'Moon': swe.MOON,
|
||||
'Mercury': swe.MERCURY,
|
||||
'Venus': swe.VENUS,
|
||||
'Mars': swe.MARS,
|
||||
'Jupiter': swe.JUPITER,
|
||||
'Saturn': swe.SATURN,
|
||||
'Uranus': swe.URANUS,
|
||||
'Neptune': swe.NEPTUNE,
|
||||
'Pluto': swe.PLUTO,
|
||||
}
|
||||
|
||||
|
||||
def set_ephe_path():
|
||||
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
|
||||
if ephe_path:
|
||||
swe.set_ephe_path(ephe_path)
|
||||
|
||||
|
||||
def get_sign(lon):
|
||||
return SIGNS[int(lon // 30) % 12]
|
||||
|
||||
|
||||
def get_julian_day(dt):
|
||||
return swe.julday(
|
||||
dt.year, dt.month, dt.day,
|
||||
dt.hour + dt.minute / 60 + dt.second / 3600,
|
||||
)
|
||||
|
||||
|
||||
def get_planet_positions(jd):
|
||||
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
|
||||
planets = {}
|
||||
for name, code in PLANET_CODES.items():
|
||||
pos, _ = swe.calc_ut(jd, code, flag)
|
||||
degree = pos[0]
|
||||
planets[name] = {
|
||||
'sign': get_sign(degree),
|
||||
'degree': degree,
|
||||
'speed': pos[3],
|
||||
'retrograde': pos[3] < 0,
|
||||
}
|
||||
return planets
|
||||
|
||||
|
||||
def get_element_counts(planets):
|
||||
sign_counts = {s: 0 for s in SIGNS}
|
||||
sign_planets = {s: [] for s in SIGNS}
|
||||
classic = {'Fire': [], 'Water': [], 'Earth': [], 'Air': []}
|
||||
|
||||
for name, data in planets.items():
|
||||
sign = data['sign']
|
||||
el = SIGN_ELEMENT[sign]
|
||||
classic[el].append({'planet': name, 'sign': sign})
|
||||
sign_counts[sign] += 1
|
||||
sign_planets[sign].append({'planet': name, 'sign': sign})
|
||||
|
||||
result = {
|
||||
el: {'count': len(contribs), 'contributors': contribs}
|
||||
for el, contribs in classic.items()
|
||||
}
|
||||
|
||||
# Time: stellium — highest concentration in one sign, bonus = size - 1.
|
||||
# Collect all signs tied at the maximum.
|
||||
max_in_sign = max(sign_counts.values())
|
||||
stellia = [
|
||||
{'sign': s, 'planets': sign_planets[s]}
|
||||
for s in SIGNS
|
||||
if sign_counts[s] == max_in_sign and max_in_sign > 1
|
||||
]
|
||||
result['Time'] = {
|
||||
'count': max_in_sign - 1,
|
||||
'stellia': stellia,
|
||||
}
|
||||
|
||||
# Space: parade — longest consecutive run of occupied signs (circular),
|
||||
# bonus = run length - 1. Collect all runs tied at the maximum.
|
||||
index_set = {i for i, s in enumerate(SIGNS) if sign_counts[s] > 0}
|
||||
indices = sorted(index_set)
|
||||
max_seq = 0
|
||||
for start in range(len(indices)):
|
||||
seq_len = 1
|
||||
for offset in range(1, len(indices)):
|
||||
if (indices[start] + offset) % len(SIGNS) in index_set:
|
||||
seq_len += 1
|
||||
else:
|
||||
break
|
||||
max_seq = max(max_seq, seq_len)
|
||||
|
||||
parades = []
|
||||
for start in range(len(indices)):
|
||||
run = []
|
||||
for offset in range(max_seq):
|
||||
idx = (indices[start] + offset) % len(SIGNS)
|
||||
if idx not in index_set:
|
||||
break
|
||||
run.append(idx)
|
||||
else:
|
||||
sign_run = [SIGNS[i] for i in run]
|
||||
parade_planets = [
|
||||
p for s in sign_run for p in sign_planets[s]
|
||||
]
|
||||
parades.append({'signs': sign_run, 'planets': parade_planets})
|
||||
|
||||
result['Space'] = {
|
||||
'count': max_seq - 1,
|
||||
'parades': parades,
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def calculate_aspects(planets):
|
||||
"""Return a list of aspects between all planet pairs.
|
||||
|
||||
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
|
||||
Only the first matching aspect type is reported per pair (aspects are
|
||||
well-separated enough that at most one can apply with standard orbs).
|
||||
"""
|
||||
names = list(planets.keys())
|
||||
aspects = []
|
||||
for i, name1 in enumerate(names):
|
||||
for name2 in names[i + 1:]:
|
||||
deg1 = planets[name1]['degree']
|
||||
deg2 = planets[name2]['degree']
|
||||
angle = abs(deg1 - deg2)
|
||||
if angle > 180:
|
||||
angle = 360 - angle
|
||||
for aspect_name, target, max_orb in ASPECTS:
|
||||
orb = abs(angle - target)
|
||||
if orb <= max_orb:
|
||||
s1 = abs(planets[name1].get('speed', 0))
|
||||
s2 = abs(planets[name2].get('speed', 0))
|
||||
applying = name1 if s1 >= s2 else name2
|
||||
aspects.append({
|
||||
'planet1': name1,
|
||||
'planet2': name2,
|
||||
'type': aspect_name,
|
||||
'angle': round(angle, 2),
|
||||
'orb': round(orb, 2),
|
||||
'applying_planet': applying,
|
||||
})
|
||||
break
|
||||
return aspects
|
||||
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path
|
||||
from apps.charts.models import EphemerisSnapshot
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)')
|
||||
parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
set_ephe_path()
|
||||
|
||||
date_from = date.fromisoformat(options['date_from'])
|
||||
date_to = date.fromisoformat(options['date_to'])
|
||||
|
||||
current = date_from
|
||||
count = 0
|
||||
while current <= date_to:
|
||||
dt = datetime(current.year, current.month, current.day,
|
||||
12, 0, 0, tzinfo=timezone.utc)
|
||||
jd = get_julian_day(dt)
|
||||
planets = get_planet_positions(jd)
|
||||
elements = get_element_counts(planets)
|
||||
|
||||
EphemerisSnapshot.objects.update_or_create(
|
||||
dt=dt,
|
||||
defaults={
|
||||
'fire': elements['Fire']['count'],
|
||||
'water': elements['Water']['count'],
|
||||
'earth': elements['Earth']['count'],
|
||||
'air': elements['Air']['count'],
|
||||
'time_el': elements['Time']['count'],
|
||||
'space_el': elements['Space']['count'],
|
||||
'chart_data': {'planets': planets},
|
||||
},
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
count += 1
|
||||
|
||||
if options['verbosity'] > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Created/updated {count} snapshot(s).')
|
||||
)
|
||||
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
@@ -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
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/integrated/__init__.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
|
||||
|
||||
These tests drive the EphemerisSnapshot model and list view.
|
||||
Snapshots are created directly in setUp — no live ephemeris calc needed.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.charts.models import EphemerisSnapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHART_DATA_STUB = {
|
||||
'planets': {
|
||||
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
|
||||
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
|
||||
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
|
||||
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
|
||||
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
|
||||
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
|
||||
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
|
||||
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
|
||||
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
|
||||
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
|
||||
},
|
||||
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
|
||||
}
|
||||
|
||||
|
||||
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
|
||||
chart_data=None):
|
||||
return EphemerisSnapshot.objects.create(
|
||||
dt=dt_str,
|
||||
fire=fire, water=water, earth=earth, air=air,
|
||||
time_el=time_el, space_el=space_el,
|
||||
chart_data=chart_data or CHART_DATA_STUB,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChartsListApiTest(TestCase):
|
||||
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
|
||||
|
||||
def setUp(self):
|
||||
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
|
||||
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
|
||||
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
|
||||
# Outside the usual date range — should not appear in filtered results
|
||||
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
|
||||
|
||||
def _get(self, params=None):
|
||||
return self.client.get('/api/charts/', params or {})
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_400_if_date_from_missing(self):
|
||||
response = self._get({'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_if_date_to_missing(self):
|
||||
response = self._get({'date_from': '2000-01-01'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_for_invalid_date_from(self):
|
||||
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_if_date_to_before_date_from(self):
|
||||
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_200_for_valid_params(self):
|
||||
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_charts_response_is_json(self):
|
||||
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_charts_response_has_results_and_count(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
self.assertIn('results', data)
|
||||
self.assertIn('count', data)
|
||||
|
||||
def test_each_result_has_dt_and_elements(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
for result in data['results']:
|
||||
with self.subTest(dt=result.get('dt')):
|
||||
self.assertIn('dt', result)
|
||||
self.assertIn('elements', result)
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
self.assertIn(key, result['elements'])
|
||||
|
||||
def test_each_result_has_planets(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
for result in data['results']:
|
||||
with self.subTest(dt=result.get('dt')):
|
||||
self.assertIn('planets', result)
|
||||
|
||||
# ── date range filtering ──────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_only_snapshots_in_date_range(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
self.assertEqual(data['count'], 3)
|
||||
|
||||
def test_charts_count_matches_results_length(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
|
||||
self.assertEqual(data['count'], len(data['results']))
|
||||
|
||||
def test_charts_date_range_is_inclusive(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_results_ordered_by_dt(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
dts = [r['dt'] for r in data['results']]
|
||||
self.assertEqual(dts, sorted(dts))
|
||||
|
||||
# ── element range filtering ───────────────────────────────────────────
|
||||
|
||||
def test_charts_filters_by_fire_min(self):
|
||||
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_filters_by_water_min(self):
|
||||
# Only the Jan 2 snapshot has water=4
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_filters_by_earth_min(self):
|
||||
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_multiple_element_filters_are_conjunctive(self):
|
||||
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31',
|
||||
'fire_min': 2, 'water_min': 2,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 2)
|
||||
247
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Integration tests for the PySwiss chart calculation API.
|
||||
|
||||
These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/.
|
||||
They verify the HTTP contract using Django's test client.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
|
||||
J2000 = '2000-01-01T12:00:00Z'
|
||||
LONDON = {'lat': 51.5074, 'lon': -0.1278}
|
||||
|
||||
# Well-known coordinates with unambiguous timezone results
|
||||
NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York
|
||||
TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo
|
||||
REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik
|
||||
|
||||
|
||||
class ChartApiTest(TestCase):
|
||||
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
|
||||
|
||||
def _get(self, params):
|
||||
return self.client.get('/api/chart/', params)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_400_if_dt_missing(self):
|
||||
response = self._get({'lat': 51.5074, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_if_lat_missing(self):
|
||||
response = self._get({'dt': J2000, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_if_lon_missing(self):
|
||||
response = self._get({'dt': J2000, 'lat': 51.5074})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_for_invalid_dt_format(self):
|
||||
response = self._get({'dt': 'not-a-date', **LONDON})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_for_out_of_range_lat(self):
|
||||
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_200_for_valid_params(self):
|
||||
response = self._get({'dt': J2000, **LONDON})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_chart_response_is_json(self):
|
||||
response = self._get({'dt': J2000, **LONDON})
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_chart_returns_all_ten_planets(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
expected = {
|
||||
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
|
||||
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
|
||||
}
|
||||
self.assertEqual(set(data['planets'].keys()), expected)
|
||||
|
||||
def test_each_planet_has_sign_degree_and_retrograde(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for name, planet in data['planets'].items():
|
||||
with self.subTest(planet=name):
|
||||
self.assertIn('sign', planet)
|
||||
self.assertIn('degree', planet)
|
||||
self.assertIn('retrograde', planet)
|
||||
|
||||
def test_chart_returns_houses(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
houses = data['houses']
|
||||
self.assertEqual(len(houses['cusps']), 12)
|
||||
self.assertIn('asc', houses)
|
||||
self.assertIn('mc', houses)
|
||||
|
||||
def test_chart_returns_six_element_counts(self):
|
||||
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
with self.subTest(element=key):
|
||||
self.assertIn(key, data['elements'])
|
||||
|
||||
def test_chart_reports_active_house_system(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('house_system', data)
|
||||
|
||||
# ── calculation correctness ───────────────────────────────────────────
|
||||
|
||||
def test_sun_is_in_capricorn_at_j2000(self):
|
||||
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
sun = data['planets']['Sun']
|
||||
self.assertEqual(sun['sign'], 'Capricorn')
|
||||
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
|
||||
|
||||
def test_sun_is_not_retrograde(self):
|
||||
"""The Sun never goes retrograde."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertFalse(data['planets']['Sun']['retrograde'])
|
||||
|
||||
def test_element_counts_sum_to_ten(self):
|
||||
"""All 10 planets are assigned to exactly one classical element."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
classical = sum(
|
||||
data['elements'][e]['count'] for e in ('Fire', 'Water', 'Earth', 'Air')
|
||||
)
|
||||
self.assertEqual(classical, 10)
|
||||
|
||||
def test_each_element_has_count_key(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
with self.subTest(element=key):
|
||||
self.assertIn('count', data['elements'][key])
|
||||
|
||||
def test_classic_elements_have_contributors(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air'):
|
||||
with self.subTest(element=key):
|
||||
self.assertIn('contributors', data['elements'][key])
|
||||
|
||||
def test_time_has_stellia(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('stellia', data['elements']['Time'])
|
||||
|
||||
def test_space_has_parades(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('parades', data['elements']['Space'])
|
||||
|
||||
def test_each_planet_has_speed(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for name, planet in data['planets'].items():
|
||||
with self.subTest(planet=name):
|
||||
self.assertIn('speed', planet)
|
||||
|
||||
def test_each_aspect_has_applying_planet(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for aspect in data['aspects']:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn('applying_planet', aspect)
|
||||
|
||||
# ── house system ──────────────────────────────────────────────────────
|
||||
|
||||
def test_default_house_system_is_porphyry(self):
|
||||
"""Porphyry ('O') is the project default — no param needed."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertEqual(data['house_system'], 'O')
|
||||
|
||||
def test_non_superuser_cannot_override_house_system(self):
|
||||
"""House system override is superuser-only; plain requests get 403."""
|
||||
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
# ── aspects ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_aspects_list(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('aspects', data)
|
||||
self.assertIsInstance(data['aspects'], list)
|
||||
|
||||
def test_each_aspect_has_required_fields(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for aspect in data['aspects']:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn('planet1', aspect)
|
||||
self.assertIn('planet2', aspect)
|
||||
self.assertIn('type', aspect)
|
||||
self.assertIn('angle', aspect)
|
||||
self.assertIn('orb', aspect)
|
||||
|
||||
def test_sun_saturn_trine_present_at_j2000(self):
|
||||
"""Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']}
|
||||
self.assertIn(('Sun', 'Saturn', 'Trine'), pairs)
|
||||
|
||||
|
||||
class TimezoneApiTest(TestCase):
|
||||
"""GET /api/tz/ — resolve IANA timezone from lat/lon coordinates."""
|
||||
|
||||
def _get(self, params):
|
||||
return self.client.get('/api/tz/', params)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_400_if_lat_missing(self):
|
||||
response = self._get({'lon': -74.0060})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_if_lon_missing(self):
|
||||
response = self._get({'lat': 40.7128})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_for_invalid_lat(self):
|
||||
response = self._get({'lat': 'abc', 'lon': -74.0060})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_for_out_of_range_lat(self):
|
||||
response = self._get({'lat': 999, 'lon': -74.0060})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_for_out_of_range_lon(self):
|
||||
response = self._get({'lat': 40.7128, 'lon': 999})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_200_for_valid_coords(self):
|
||||
response = self._get(NEW_YORK)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_response_is_json(self):
|
||||
response = self._get(NEW_YORK)
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_response_contains_timezone_key(self):
|
||||
data = self._get(NEW_YORK).json()
|
||||
self.assertIn('timezone', data)
|
||||
|
||||
def test_timezone_is_a_string(self):
|
||||
data = self._get(NEW_YORK).json()
|
||||
self.assertIsInstance(data['timezone'], str)
|
||||
|
||||
# ── correctness ───────────────────────────────────────────────────────
|
||||
|
||||
def test_new_york_timezone(self):
|
||||
data = self._get(NEW_YORK).json()
|
||||
self.assertEqual(data['timezone'], 'America/New_York')
|
||||
|
||||
def test_tokyo_timezone(self):
|
||||
data = self._get(TOKYO).json()
|
||||
self.assertEqual(data['timezone'], 'Asia/Tokyo')
|
||||
|
||||
def test_reykjavik_timezone(self):
|
||||
data = self._get(REYKJAVIK).json()
|
||||
self.assertEqual(data['timezone'], 'Atlantic/Reykjavik')
|
||||
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
329
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
@@ -0,0 +1,329 @@
|
||||
"""
|
||||
Unit tests for calc.py helper functions.
|
||||
|
||||
These tests verify pure calculation logic without hitting the database
|
||||
or the Swiss Ephemeris — all inputs are fixed synthetic data.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.charts.calc import calculate_aspects, get_element_counts
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FAKE_PLANETS_ASPECTS — degrees only; used by calculate_aspects tests.
|
||||
# Each planet also carries a speed (deg/day) for applying_planet tests.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAKE_PLANETS = {
|
||||
'Sun': {'degree': 10.0, 'speed': 1.00}, # Aries
|
||||
'Moon': {'degree': 130.0, 'speed': 13.00}, # Leo — 120° from Sun → Trine
|
||||
'Mercury': {'degree': 250.0, 'speed': 1.50}, # Sagittarius — 120° from Sun → Trine
|
||||
'Venus': {'degree': 40.0, 'speed': 1.10}, # Taurus — 90° from Moon → Square
|
||||
'Mars': {'degree': 160.0, 'speed': 0.50}, # Virgo — 60° from Neptune → Sextile
|
||||
'Jupiter': {'degree': 280.0, 'speed': 0.08}, # Capricorn — 120° from Mars → Trine
|
||||
'Saturn': {'degree': 70.0, 'speed': 0.03}, # Gemini — 120° from Uranus → Trine
|
||||
'Uranus': {'degree': 310.0, 'speed': 0.01}, # Aquarius — 60° from Sun (wrap) → Sextile
|
||||
'Neptune': {'degree': 100.0, 'speed': 0.006}, # Cancer
|
||||
'Pluto': {'degree': 340.0, 'speed': 0.003}, # Pisces
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FAKE_PLANETS_ELEMENTS — sign + degree + speed; used by get_element_counts.
|
||||
# Designed to produce a known stellium and parade.
|
||||
#
|
||||
# Occupied signs: Aries(0), Taurus(1), Gemini(2), Leo(4), Virgo(5),
|
||||
# Scorpio(7), Capricorn(9), Aquarius(10)
|
||||
# Gaps at Cancer(3), Libra(6), Sagittarius(8), Pisces(11) prevent wrap-around.
|
||||
#
|
||||
# Consecutive runs: Aries→Taurus→Gemini = 3 ← parade (Space = 2)
|
||||
# Leo→Virgo = 2
|
||||
# Capricorn→Aquarius = 2
|
||||
#
|
||||
# Time = 2 (Aries has Sun+Mercury+Venus → stellium of 3, bonus = 2)
|
||||
# Space = 2 (Aries→Taurus→Gemini = 3-sign parade, bonus = 2)
|
||||
# Classic: Fire=4, Earth=3, Air=2, Water=1
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAKE_PLANETS_ELEMENTS = {
|
||||
'Sun': {'sign': 'Aries', 'degree': 10.0, 'speed': 1.00}, # Fire, stellium
|
||||
'Moon': {'sign': 'Taurus', 'degree': 40.0, 'speed': 13.00}, # Earth, parade
|
||||
'Mercury': {'sign': 'Aries', 'degree': 20.0, 'speed': 1.50}, # Fire, stellium
|
||||
'Venus': {'sign': 'Aries', 'degree': 25.0, 'speed': 1.10}, # Fire, stellium
|
||||
'Mars': {'sign': 'Leo', 'degree': 130.0, 'speed': 0.50}, # Fire
|
||||
'Jupiter': {'sign': 'Scorpio', 'degree': 220.0, 'speed': 0.08}, # Water
|
||||
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'speed': 0.03}, # Air, parade
|
||||
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'speed': 0.01}, # Air
|
||||
'Neptune': {'sign': 'Capricorn', 'degree': 270.0, 'speed': 0.006}, # Earth
|
||||
'Pluto': {'sign': 'Virgo', 'degree': 160.0, 'speed': 0.003}, # Earth
|
||||
}
|
||||
|
||||
|
||||
def _aspect_pairs(aspects):
|
||||
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
|
||||
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# get_element_counts — enriched shape
|
||||
# ===========================================================================
|
||||
|
||||
class GetElementCountsTest(SimpleTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.counts = get_element_counts(FAKE_PLANETS_ELEMENTS)
|
||||
|
||||
# ── top-level keys ───────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_all_six_elements(self):
|
||||
for key in ('Fire', 'Earth', 'Air', 'Water', 'Time', 'Space'):
|
||||
with self.subTest(key=key):
|
||||
self.assertIn(key, self.counts)
|
||||
|
||||
# ── classic four — count + contributors ──────────────────────────────────
|
||||
|
||||
def test_classic_element_has_count_key(self):
|
||||
self.assertIn('count', self.counts['Fire'])
|
||||
|
||||
def test_classic_element_has_contributors_key(self):
|
||||
self.assertIn('contributors', self.counts['Fire'])
|
||||
|
||||
def test_fire_count_is_correct(self):
|
||||
# Sun + Mercury + Venus (Aries) + Mars (Leo) = 4
|
||||
self.assertEqual(self.counts['Fire']['count'], 4)
|
||||
|
||||
def test_earth_count_is_correct(self):
|
||||
# Moon (Taurus) + Neptune (Capricorn) + Pluto (Virgo) = 3
|
||||
self.assertEqual(self.counts['Earth']['count'], 3)
|
||||
|
||||
def test_air_count_is_correct(self):
|
||||
# Saturn (Gemini) + Uranus (Aquarius) = 2
|
||||
self.assertEqual(self.counts['Air']['count'], 2)
|
||||
|
||||
def test_water_count_is_correct(self):
|
||||
# Jupiter (Scorpio) = 1
|
||||
self.assertEqual(self.counts['Water']['count'], 1)
|
||||
|
||||
def test_fire_contributors_contains_expected_planets(self):
|
||||
planets = {c['planet'] for c in self.counts['Fire']['contributors']}
|
||||
self.assertEqual(planets, {'Sun', 'Mercury', 'Venus', 'Mars'})
|
||||
|
||||
def test_contributor_has_planet_and_sign_keys(self):
|
||||
contrib = self.counts['Fire']['contributors'][0]
|
||||
self.assertIn('planet', contrib)
|
||||
self.assertIn('sign', contrib)
|
||||
|
||||
def test_fire_contributor_signs_are_correct(self):
|
||||
sign_map = {c['planet']: c['sign'] for c in self.counts['Fire']['contributors']}
|
||||
self.assertEqual(sign_map['Sun'], 'Aries')
|
||||
self.assertEqual(sign_map['Mercury'], 'Aries')
|
||||
self.assertEqual(sign_map['Venus'], 'Aries')
|
||||
self.assertEqual(sign_map['Mars'], 'Leo')
|
||||
|
||||
# ── Time — count + stellia ───────────────────────────────────────────────
|
||||
|
||||
def test_time_has_count_key(self):
|
||||
self.assertIn('count', self.counts['Time'])
|
||||
|
||||
def test_time_has_stellia_key(self):
|
||||
self.assertIn('stellia', self.counts['Time'])
|
||||
|
||||
def test_time_count_is_correct(self):
|
||||
# Aries has 3 planets → bonus = 2
|
||||
self.assertEqual(self.counts['Time']['count'], 2)
|
||||
|
||||
def test_time_stellia_is_a_list(self):
|
||||
self.assertIsInstance(self.counts['Time']['stellia'], list)
|
||||
|
||||
def test_time_stellia_contains_one_entry(self):
|
||||
self.assertEqual(len(self.counts['Time']['stellia']), 1)
|
||||
|
||||
def test_time_stellium_sign_is_aries(self):
|
||||
self.assertEqual(self.counts['Time']['stellia'][0]['sign'], 'Aries')
|
||||
|
||||
def test_time_stellium_planets_are_correct(self):
|
||||
planet_names = {p['planet'] for p in self.counts['Time']['stellia'][0]['planets']}
|
||||
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus'})
|
||||
|
||||
def test_time_stellium_planet_entries_have_sign(self):
|
||||
for entry in self.counts['Time']['stellia'][0]['planets']:
|
||||
with self.subTest(planet=entry['planet']):
|
||||
self.assertEqual(entry['sign'], 'Aries')
|
||||
|
||||
# ── Space — count + parades ──────────────────────────────────────────────
|
||||
|
||||
def test_space_has_count_key(self):
|
||||
self.assertIn('count', self.counts['Space'])
|
||||
|
||||
def test_space_has_parades_key(self):
|
||||
self.assertIn('parades', self.counts['Space'])
|
||||
|
||||
def test_space_count_is_correct(self):
|
||||
# Aries→Taurus→Gemini = 3 consecutive → bonus = 2
|
||||
self.assertEqual(self.counts['Space']['count'], 2)
|
||||
|
||||
def test_space_parades_is_a_list(self):
|
||||
self.assertIsInstance(self.counts['Space']['parades'], list)
|
||||
|
||||
def test_space_parades_contains_one_entry(self):
|
||||
self.assertEqual(len(self.counts['Space']['parades']), 1)
|
||||
|
||||
def test_space_parade_signs_are_correct(self):
|
||||
self.assertEqual(
|
||||
self.counts['Space']['parades'][0]['signs'],
|
||||
['Aries', 'Taurus', 'Gemini'],
|
||||
)
|
||||
|
||||
def test_space_parade_planets_are_correct(self):
|
||||
planet_names = {p['planet'] for p in self.counts['Space']['parades'][0]['planets']}
|
||||
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus', 'Moon', 'Saturn'})
|
||||
|
||||
def test_space_parade_planet_entries_have_planet_and_sign(self):
|
||||
for entry in self.counts['Space']['parades'][0]['planets']:
|
||||
with self.subTest(planet=entry['planet']):
|
||||
self.assertIn('planet', entry)
|
||||
self.assertIn('sign', entry)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# calculate_aspects
|
||||
# ===========================================================================
|
||||
|
||||
class CalculateAspectsTest(SimpleTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.aspects = calculate_aspects(FAKE_PLANETS)
|
||||
|
||||
# ── return shape ──────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_a_list(self):
|
||||
self.assertIsInstance(self.aspects, list)
|
||||
|
||||
def test_each_aspect_has_required_keys(self):
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn('planet1', aspect)
|
||||
self.assertIn('planet2', aspect)
|
||||
self.assertIn('type', aspect)
|
||||
self.assertIn('angle', aspect)
|
||||
self.assertIn('orb', aspect)
|
||||
|
||||
def test_each_aspect_has_applying_planet_key(self):
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn('applying_planet', aspect)
|
||||
|
||||
def test_applying_planet_is_one_of_the_pair(self):
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn(
|
||||
aspect['applying_planet'],
|
||||
(aspect['planet1'], aspect['planet2']),
|
||||
)
|
||||
|
||||
def test_applying_planet_is_the_faster_body(self):
|
||||
"""Moon (13.0°/day) applies to Sun (1.0°/day) in their Trine."""
|
||||
sun_moon = next(
|
||||
a for a in self.aspects
|
||||
if {a['planet1'], a['planet2']} == {'Sun', 'Moon'}
|
||||
)
|
||||
self.assertEqual(sun_moon['applying_planet'], 'Moon')
|
||||
|
||||
def test_each_aspect_type_is_a_known_name(self):
|
||||
known = {
|
||||
'Conjunction', 'Semisextile', 'Sextile', 'Square',
|
||||
'Trine', 'Quincunx', 'Opposition',
|
||||
}
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn(aspect['type'], known)
|
||||
|
||||
def test_angle_and_orb_are_floats(self):
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIsInstance(aspect['angle'], float)
|
||||
self.assertIsInstance(aspect['orb'], float)
|
||||
|
||||
def test_no_self_aspects(self):
|
||||
for aspect in self.aspects:
|
||||
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
|
||||
|
||||
def test_no_duplicate_pairs(self):
|
||||
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
|
||||
self.assertEqual(len(pairs), len(set(pairs)))
|
||||
|
||||
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
|
||||
|
||||
def test_sun_moon_trine(self):
|
||||
"""Moon at 130° is exactly 120° from Sun at 10°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
|
||||
|
||||
def test_sun_mercury_trine(self):
|
||||
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
|
||||
|
||||
def test_moon_mercury_trine(self):
|
||||
"""Moon 130° → Mercury 250° = 120°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
|
||||
|
||||
def test_moon_venus_square(self):
|
||||
"""Moon 130° → Venus 40° = 90°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
|
||||
|
||||
def test_venus_neptune_sextile(self):
|
||||
"""Venus 40° → Neptune 100° = 60°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
|
||||
|
||||
def test_mars_neptune_sextile(self):
|
||||
"""Mars 160° → Neptune 100° = 60°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
|
||||
|
||||
def test_sun_uranus_sextile(self):
|
||||
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
|
||||
|
||||
def test_mars_jupiter_trine(self):
|
||||
"""Mars 160° → Jupiter 280° = 120°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
|
||||
|
||||
def test_saturn_uranus_trine(self):
|
||||
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
|
||||
|
||||
# ── orb bounds ────────────────────────────────────────────────────────
|
||||
|
||||
def test_orb_is_within_allowed_maximum(self):
|
||||
max_orbs = {
|
||||
'Conjunction': 8.0,
|
||||
'Semisextile': 4.0,
|
||||
'Sextile': 6.0,
|
||||
'Square': 8.0,
|
||||
'Trine': 8.0,
|
||||
'Quincunx': 5.0,
|
||||
'Opposition': 10.0,
|
||||
}
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertLessEqual(
|
||||
aspect['orb'], max_orbs[aspect['type']],
|
||||
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
|
||||
)
|
||||
|
||||
def test_exact_trine_has_zero_orb(self):
|
||||
"""Sun-Moon at exactly 120° should report orb of 0.0."""
|
||||
sun_moon = next(
|
||||
a for a in self.aspects
|
||||
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
|
||||
)
|
||||
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)
|
||||
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
@@ -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
@@ -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
@@ -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
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
@@ -0,0 +1,5 @@
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include('apps.charts.urls')),
|
||||
]
|
||||
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
@@ -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
@@ -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
|
||||
@@ -1,30 +1,30 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.dashboard.models import Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class ItemSerializer(serializers.ModelSerializer):
|
||||
class LineSerializer(serializers.ModelSerializer):
|
||||
text = serializers.CharField()
|
||||
|
||||
def validate_text(self, value):
|
||||
note = self.context["note"]
|
||||
if note.item_set.filter(text=value).exists():
|
||||
post = self.context["post"]
|
||||
if post.lines.filter(text=value).exists():
|
||||
raise serializers.ValidationError("duplicate")
|
||||
return value
|
||||
|
||||
class Meta:
|
||||
model = Item
|
||||
model = Line
|
||||
fields = ["id", "text"]
|
||||
|
||||
class NoteSerializer(serializers.ModelSerializer):
|
||||
class PostSerializer(serializers.ModelSerializer):
|
||||
name = serializers.ReadOnlyField()
|
||||
url = serializers.CharField(source="get_absolute_url", read_only=True)
|
||||
items = ItemSerializer(many=True, read_only=True, source="item_set")
|
||||
lines = LineSerializer(many=True, read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Note
|
||||
fields = ["id", "name", "url", "items"]
|
||||
model = Post
|
||||
fields = ["id", "name", "url", "lines"]
|
||||
|
||||
class UserSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.test import TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.dashboard.models import Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
class BaseAPITest(TestCase):
|
||||
@@ -11,76 +11,76 @@ class BaseAPITest(TestCase):
|
||||
self.user = User.objects.create_user("test@example.com")
|
||||
self.client.force_authenticate(user=self.user)
|
||||
|
||||
class NoteDetailAPITest(BaseAPITest):
|
||||
def test_returns_note_with_items(self):
|
||||
note = Note.objects.create(owner=self.user)
|
||||
Item.objects.create(text="item 1", note=note)
|
||||
Item.objects.create(text="item 2", note=note)
|
||||
class PostDetailAPITest(BaseAPITest):
|
||||
def test_returns_post_with_lines(self):
|
||||
post = Post.objects.create(owner=self.user)
|
||||
Line.objects.create(text="line 1", post=post)
|
||||
Line.objects.create(text="line 2", post=post)
|
||||
|
||||
response = self.client.get(f"/api/notes/{note.id}/")
|
||||
response = self.client.get(f"/api/posts/{post.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["id"], str(note.id))
|
||||
self.assertEqual(len(response.data["items"]), 2)
|
||||
self.assertEqual(response.data["id"], str(post.id))
|
||||
self.assertEqual(len(response.data["lines"]), 2)
|
||||
|
||||
class NoteItemsAPITest(BaseAPITest):
|
||||
def test_can_add_item_to_note(self):
|
||||
note = Note.objects.create(owner=self.user)
|
||||
class PostLinesAPITest(BaseAPITest):
|
||||
def test_can_add_line_to_post(self):
|
||||
post = Post.objects.create(owner=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/notes/{note.id}/items/",
|
||||
{"text": "a new item"},
|
||||
f"/api/posts/{post.id}/lines/",
|
||||
{"text": "a new line"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Item.objects.count(), 1)
|
||||
self.assertEqual(Item.objects.first().text, "a new item")
|
||||
self.assertEqual(Line.objects.count(), 1)
|
||||
self.assertEqual(Line.objects.first().text, "a new line")
|
||||
|
||||
def test_cannot_add_empty_item_to_note(self):
|
||||
note = Note.objects.create(owner=self.user)
|
||||
def test_cannot_add_empty_line_to_post(self):
|
||||
post = Post.objects.create(owner=self.user)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/notes/{note.id}/items/",
|
||||
f"/api/posts/{post.id}/lines/",
|
||||
{"text": ""},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertEqual(Item.objects.count(), 0)
|
||||
self.assertEqual(Line.objects.count(), 0)
|
||||
|
||||
def test_cannot_add_duplicate_item_to_note(self):
|
||||
note = Note.objects.create(owner=self.user)
|
||||
Item.objects.create(text="note item", note=note)
|
||||
def test_cannot_add_duplicate_line_to_post(self):
|
||||
post = Post.objects.create(owner=self.user)
|
||||
Line.objects.create(text="post line", post=post)
|
||||
duplicate_response = self.client.post(
|
||||
f"/api/notes/{note.id}/items/",
|
||||
{"text": "note item"},
|
||||
f"/api/posts/{post.id}/lines/",
|
||||
{"text": "post line"},
|
||||
)
|
||||
|
||||
self.assertEqual(duplicate_response.status_code, 400)
|
||||
self.assertEqual(Item.objects.count(), 1)
|
||||
self.assertEqual(Line.objects.count(), 1)
|
||||
|
||||
class NotesAPITest(BaseAPITest):
|
||||
def test_get_returns_only_users_notes(self):
|
||||
note1 = Note.objects.create(owner=self.user)
|
||||
Item.objects.create(text="item 1", note=note1)
|
||||
class PostsAPITest(BaseAPITest):
|
||||
def test_get_returns_only_users_posts(self):
|
||||
post1 = Post.objects.create(owner=self.user)
|
||||
Line.objects.create(text="line 1", post=post1)
|
||||
other_user = User.objects.create_user("other@example.com")
|
||||
Note.objects.create(owner=other_user)
|
||||
Post.objects.create(owner=other_user)
|
||||
|
||||
response = self.client.get("/api/notes/")
|
||||
response = self.client.get("/api/posts/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(len(response.data), 1)
|
||||
self.assertEqual(response.data[0]["id"], str(note1.id))
|
||||
self.assertEqual(response.data[0]["id"], str(post1.id))
|
||||
|
||||
def test_post_creates_note_with_item(self):
|
||||
def test_post_creates_post_with_line(self):
|
||||
response = self.client.post(
|
||||
"/api/notes/",
|
||||
{"text": "first item"},
|
||||
"/api/posts/",
|
||||
{"text": "first line"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 201)
|
||||
self.assertEqual(Note.objects.count(), 1)
|
||||
self.assertEqual(Note.objects.first().owner, self.user)
|
||||
self.assertEqual(Item.objects.first().text, "first item")
|
||||
self.assertEqual(Post.objects.count(), 1)
|
||||
self.assertEqual(Post.objects.first().owner, self.user)
|
||||
self.assertEqual(Line.objects.first().text, "first line")
|
||||
|
||||
class UserSearchAPITest(BaseAPITest):
|
||||
def test_returns_users_matching_username(self):
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
from django.test import SimpleTestCase
|
||||
from apps.api.serializers import ItemSerializer, NoteSerializer
|
||||
from apps.api.serializers import LineSerializer, PostSerializer
|
||||
|
||||
|
||||
class ItemSerializerTest(SimpleTestCase):
|
||||
class LineSerializerTest(SimpleTestCase):
|
||||
def test_fields(self):
|
||||
serializer = ItemSerializer()
|
||||
serializer = LineSerializer()
|
||||
self.assertEqual(
|
||||
set(serializer.fields.keys()),
|
||||
{"id", "text"},
|
||||
)
|
||||
|
||||
class NoteSerializerTest(SimpleTestCase):
|
||||
class PostSerializerTest(SimpleTestCase):
|
||||
def test_fields(self):
|
||||
serializer = NoteSerializer()
|
||||
serializer = PostSerializer()
|
||||
self.assertEqual(
|
||||
set(serializer.fields.keys()),
|
||||
{"id", "name", "url", "items"},
|
||||
{"id", "name", "url", "lines"},
|
||||
)
|
||||
|
||||
@@ -4,8 +4,8 @@ from . import views
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path('notes/', views.NotesAPI.as_view(), name='api_notes'),
|
||||
path('notes/<uuid:note_id>/', views.NoteDetailAPI.as_view(), name='api_note_detail'),
|
||||
path('notes/<uuid:note_id>/items/', views.NoteItemsAPI.as_view(), name='api_note_items'),
|
||||
path('posts/', views.PostsAPI.as_view(), name='api_posts'),
|
||||
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
|
||||
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
|
||||
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
|
||||
]
|
||||
|
||||
@@ -2,36 +2,36 @@ from django.shortcuts import get_object_or_404
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from apps.api.serializers import ItemSerializer, NoteSerializer, UserSerializer
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer
|
||||
from apps.dashboard.models import Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class NoteDetailAPI(APIView):
|
||||
def get(self, request, note_id):
|
||||
note = get_object_or_404(Note, id=note_id)
|
||||
serializer = NoteSerializer(note)
|
||||
class PostDetailAPI(APIView):
|
||||
def get(self, request, post_id):
|
||||
post = get_object_or_404(Post, id=post_id)
|
||||
serializer = PostSerializer(post)
|
||||
return Response(serializer.data)
|
||||
|
||||
class NoteItemsAPI(APIView):
|
||||
def post(self, request, note_id):
|
||||
note = get_object_or_404(Note, id=note_id)
|
||||
serializer = ItemSerializer(data=request.data, context={"note": note})
|
||||
class PostLinesAPI(APIView):
|
||||
def post(self, request, post_id):
|
||||
post = get_object_or_404(Post, id=post_id)
|
||||
serializer = LineSerializer(data=request.data, context={"post": post})
|
||||
if serializer.is_valid():
|
||||
serializer.save(note=note)
|
||||
serializer.save(post=post)
|
||||
return Response(serializer.data, status=201)
|
||||
return Response(serializer.errors, status=400)
|
||||
|
||||
class NotesAPI(APIView):
|
||||
class PostsAPI(APIView):
|
||||
def get(self, request):
|
||||
notes = Note.objects.filter(owner=request.user)
|
||||
serializer = NoteSerializer(notes, many=True)
|
||||
posts = Post.objects.filter(owner=request.user)
|
||||
serializer = PostSerializer(posts, many=True)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request):
|
||||
note = Note.objects.create(owner=request.user)
|
||||
item = Item.objects.create(text=request.data.get("text", ""), note=note)
|
||||
serializer = NoteSerializer(note)
|
||||
post = Post.objects.create(owner=request.user)
|
||||
line = Line.objects.create(text=request.data.get("text", ""), post=post)
|
||||
serializer = PostSerializer(post)
|
||||
return Response(serializer.data, status=201)
|
||||
|
||||
class UserSearchAPI(APIView):
|
||||
|
||||
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed_my_sky_applet(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
Applet.objects.get_or_create(
|
||||
slug='my-sky',
|
||||
defaults={
|
||||
'name': 'My Sky',
|
||||
'grid_cols': 6,
|
||||
'grid_rows': 6,
|
||||
'context': 'dashboard',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('applets', '0008_game_kit_applets'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_my_sky_applet, migrations.RunPython.noop)
|
||||
]
|
||||
25
src/apps/applets/migrations/0010_recognition_applet.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed_recognition_applet(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.get_or_create(
|
||||
slug="billboard-recognition",
|
||||
defaults={
|
||||
"name": "Recognition",
|
||||
"grid_cols": 4,
|
||||
"grid_rows": 4,
|
||||
"context": "billboard",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0009_my_sky_applet"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_recognition_applet, migrations.RunPython.noop),
|
||||
]
|
||||
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def rename_note_applets_to_post(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
Applet.objects.filter(slug='new-note').update(slug='new-post', name='New Post', context='billboard')
|
||||
Applet.objects.filter(slug='my-notes').update(slug='my-posts', name='My Posts', context='billboard')
|
||||
|
||||
|
||||
def reverse_rename_note_applets_to_post(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
Applet.objects.filter(slug='new-post').update(slug='new-note', name='New Note', context='dashboard')
|
||||
Applet.objects.filter(slug='my-posts').update(slug='my-notes', name='My Notes', context='dashboard')
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applets', '0010_recognition_applet'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_note_applets_to_post, reverse_rename_note_applets_to_post),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def rename_recognition_applet_to_notes(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
Applet.objects.filter(slug='billboard-recognition').update(
|
||||
slug='billboard-notes',
|
||||
name='My Notes',
|
||||
)
|
||||
|
||||
|
||||
def reverse_rename_recognition_applet_to_notes(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
Applet.objects.filter(slug='billboard-notes').update(
|
||||
slug='billboard-recognition',
|
||||
name='Recognition',
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applets', '0011_rename_note_applets_to_post'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
rename_recognition_applet_to_notes,
|
||||
reverse_rename_recognition_applet_to_notes,
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,16 @@
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
|
||||
|
||||
def apply_applet_toggle(user, context, checked_slugs):
|
||||
"""Persist applet visibility choices for a given context."""
|
||||
for applet in Applet.objects.filter(context=context):
|
||||
UserApplet.objects.update_or_create(
|
||||
user=user,
|
||||
applet=applet,
|
||||
defaults={"visible": applet.slug in checked_slugs},
|
||||
)
|
||||
|
||||
|
||||
def applet_context(user, context):
|
||||
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
||||
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
|
||||
|
||||
245
src/apps/billboard/static/apps/billboard/note-page.js
Normal file
@@ -0,0 +1,245 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _selectedPalette = null;
|
||||
var _activeItem = null;
|
||||
var _originalPalette = null;
|
||||
var _dismissTimer = null;
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function _activeModal() {
|
||||
return _activeItem && _activeItem.querySelector('.note-palette-modal');
|
||||
}
|
||||
|
||||
function _paletteClass(el) {
|
||||
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
|
||||
}
|
||||
|
||||
function _currentBodyPalette() {
|
||||
return Array.from(document.body.classList).find(function (c) { return c.startsWith('palette-'); }) || null;
|
||||
}
|
||||
|
||||
function _swapBodyPalette(paletteName) {
|
||||
var old = _currentBodyPalette();
|
||||
if (old) document.body.classList.remove(old);
|
||||
document.body.classList.add(paletteName);
|
||||
}
|
||||
|
||||
function _revertBodyPalette() {
|
||||
var current = _currentBodyPalette();
|
||||
if (current) document.body.classList.remove(current);
|
||||
if (_originalPalette) document.body.classList.add(_originalPalette);
|
||||
}
|
||||
|
||||
function _getCsrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function _showConfirm(modal) {
|
||||
var el = modal && modal.querySelector('.note-palette-confirm');
|
||||
if (el) el.style.display = 'flex';
|
||||
}
|
||||
|
||||
function _hideConfirm(modal) {
|
||||
var el = modal && modal.querySelector('.note-palette-confirm');
|
||||
if (el) el.style.display = 'none';
|
||||
}
|
||||
|
||||
// ── modal lifecycle ───────────────────────────────────────────────────────
|
||||
|
||||
function _openModal() {
|
||||
var existing = _activeModal();
|
||||
if (!existing) {
|
||||
var tpl = _activeItem.querySelector('.note-palette-modal-tpl');
|
||||
if (!tpl) return;
|
||||
var clone = tpl.content.firstElementChild.cloneNode(true);
|
||||
_activeItem.appendChild(clone);
|
||||
_wireModal();
|
||||
}
|
||||
_activeItem.classList.add('note-item--active');
|
||||
_hideConfirm(_activeModal());
|
||||
}
|
||||
|
||||
function _closeModal() {
|
||||
clearTimeout(_dismissTimer);
|
||||
_dismissTimer = null;
|
||||
var modal = _activeModal();
|
||||
if (modal) modal.remove();
|
||||
if (_activeItem) _activeItem.classList.remove('note-item--active');
|
||||
_activeItem = null;
|
||||
_selectedPalette = null;
|
||||
_originalPalette = null;
|
||||
}
|
||||
|
||||
function _revertPreview() {
|
||||
clearTimeout(_dismissTimer);
|
||||
_dismissTimer = null;
|
||||
_revertBodyPalette();
|
||||
var modal = _activeModal();
|
||||
if (modal) {
|
||||
modal.querySelectorAll('.note-swatch-body.previewing').forEach(function (s) {
|
||||
s.classList.remove('previewing');
|
||||
});
|
||||
_hideConfirm(modal);
|
||||
}
|
||||
_selectedPalette = null;
|
||||
_originalPalette = null;
|
||||
}
|
||||
|
||||
// Wire event listeners onto the freshly-cloned modal DOM.
|
||||
function _wireModal() {
|
||||
var modal = _activeModal();
|
||||
if (!modal) return;
|
||||
|
||||
// Swatch body click → preview palette sitewide + show confirm
|
||||
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
|
||||
body.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (_selectedPalette) _revertPreview();
|
||||
|
||||
_selectedPalette = _paletteClass(body.parentElement);
|
||||
_originalPalette = _currentBodyPalette();
|
||||
|
||||
body.classList.add('previewing');
|
||||
_swapBodyPalette(_selectedPalette);
|
||||
_showConfirm(modal);
|
||||
|
||||
_dismissTimer = setTimeout(function () {
|
||||
_revertPreview();
|
||||
}, 10000);
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm OK → commit palette sitewide
|
||||
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_doSetPalette();
|
||||
});
|
||||
});
|
||||
|
||||
// Confirm NVM → revert preview only; main swatch modal stays open
|
||||
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||
btn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_revertPreview();
|
||||
});
|
||||
});
|
||||
|
||||
// Stop modal clicks from reaching the body dismiss handler.
|
||||
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||
}
|
||||
|
||||
// ── set-palette POST ──────────────────────────────────────────────────────
|
||||
|
||||
function _doSetPalette() {
|
||||
var url = _activeItem.dataset.setPaletteUrl;
|
||||
var palette = _selectedPalette;
|
||||
var item = _activeItem;
|
||||
// Read label from swatch row while modal is still in DOM
|
||||
var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]');
|
||||
var paletteLabel = swatchRow
|
||||
? swatchRow.dataset.paletteLabel
|
||||
: palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); });
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': _getCsrf(),
|
||||
},
|
||||
body: JSON.stringify({ palette: palette }),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function () {
|
||||
_closeModal();
|
||||
var imageBox = item.querySelector('.note-item__image-box');
|
||||
if (imageBox) {
|
||||
var swatch = document.createElement('div');
|
||||
swatch.className = 'note-item__palette ' + palette;
|
||||
imageBox.parentNode.replaceChild(swatch, imageBox);
|
||||
}
|
||||
var list = item.querySelector('.note-recognitions__list');
|
||||
if (list && !item.querySelector('.note-recognitions__palette-line')) {
|
||||
var li = document.createElement('li');
|
||||
li.className = 'note-recognitions__palette-line';
|
||||
li.innerHTML = '<span class="note-recognitions__dim">Palette:</span> <strong>' + paletteLabel + '</strong>';
|
||||
list.appendChild(li);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function _setGreeting(name) {
|
||||
var el = document.getElementById('id_greeting_name');
|
||||
if (el) el.textContent = name;
|
||||
}
|
||||
|
||||
function _bindDonDoff(item) {
|
||||
var donBtn = item.querySelector('.note-don-btn');
|
||||
var doffBtn = item.querySelector('.note-doff-btn');
|
||||
if (!donBtn || !doffBtn) return;
|
||||
|
||||
donBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (donBtn.classList.contains('btn-disabled')) return;
|
||||
fetch(item.dataset.donUrl, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': _getCsrf() },
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
_setGreeting(data.title);
|
||||
donBtn.classList.add('btn-disabled');
|
||||
donBtn.textContent = '×';
|
||||
doffBtn.classList.remove('btn-disabled');
|
||||
doffBtn.textContent = 'DOFF';
|
||||
});
|
||||
});
|
||||
|
||||
doffBtn.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
if (doffBtn.classList.contains('btn-disabled')) return;
|
||||
fetch(item.dataset.doffUrl, {
|
||||
method: 'POST', credentials: 'same-origin',
|
||||
headers: { 'X-CSRFToken': _getCsrf() },
|
||||
})
|
||||
.then(function () {
|
||||
_setGreeting('Earthman');
|
||||
doffBtn.classList.add('btn-disabled');
|
||||
doffBtn.textContent = '×';
|
||||
donBtn.classList.remove('btn-disabled');
|
||||
donBtn.textContent = 'DON';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _init() {
|
||||
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
|
||||
box.addEventListener('click', function (e) {
|
||||
e.stopPropagation();
|
||||
_activeItem = box.closest('.note-item');
|
||||
_openModal();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('.note-item').forEach(function (item) {
|
||||
_bindDonDoff(item);
|
||||
});
|
||||
|
||||
// Body click → dismiss modal and revert any preview
|
||||
document.body.addEventListener('click', function () {
|
||||
if (_selectedPalette) _revertPreview();
|
||||
_closeModal();
|
||||
});
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
} else {
|
||||
_init();
|
||||
}
|
||||
}());
|
||||
@@ -1,8 +1,11 @@
|
||||
import json as _json
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
@@ -83,6 +86,18 @@ class BillboardViewTest(TestCase):
|
||||
self.assertEqual(list(response.context["recent_events"]), [])
|
||||
|
||||
|
||||
class SaveScrollPositionViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="reader@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
self.url = f"/billboard/room/{self.room.id}/scroll-position/"
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
|
||||
class ToggleBillboardAppletsTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@toggle.io")
|
||||
@@ -149,6 +164,169 @@ class BillscrollViewTest(TestCase):
|
||||
self.assertContains(response, 'class="drama-event-time"')
|
||||
|
||||
|
||||
class NotePageViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_uses_note_page_template(self):
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
|
||||
|
||||
def test_passes_notes_in_context(self):
|
||||
recog = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertIn(recog, response.context["notes"])
|
||||
|
||||
def test_excludes_other_users_notes(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
Note.objects.create(
|
||||
user=other, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertEqual(list(response.context["notes"]), [])
|
||||
|
||||
def test_renders_recog_list_and_items(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertContains(response, 'class="note-list"')
|
||||
self.assertContains(response, 'class="note-item"')
|
||||
|
||||
def test_renders_recog_item_title_description_image_box(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertContains(response, 'class="note-item__title"')
|
||||
self.assertContains(response, 'class="note-item__description"')
|
||||
self.assertContains(response, 'class="note-item__image-box"')
|
||||
|
||||
def test_palette_modal_renders_swatch_labels(self):
|
||||
"""Each palette option in the swatch modal should display its human-readable
|
||||
label next to the swatch body so the user knows what they are choosing."""
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||
)
|
||||
response = self.client.get("/billboard/my-notes/")
|
||||
self.assertContains(response, 'class="note-swatch-label"')
|
||||
self.assertContains(response, "Bardo")
|
||||
self.assertContains(response, "Sheol")
|
||||
|
||||
|
||||
class NoteSetPaletteViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="setpal@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.note = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.url = "/billboard/note/stargazer/set-palette"
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_sets_palette_on_note(self):
|
||||
self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.note.refresh_from_db()
|
||||
self.assertEqual(self.note.palette, "palette-bardo")
|
||||
|
||||
def test_returns_200_with_ok(self):
|
||||
response = self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"ok": True})
|
||||
|
||||
def test_returns_404_for_slug_user_does_not_own(self):
|
||||
response = self.client.post(
|
||||
"/billboard/note/schizo/set-palette",
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_also_saves_user_palette(self):
|
||||
"""note_set_palette must persist the choice to user.palette so the
|
||||
palette survives page navigation (sitewide commitment)."""
|
||||
self.client.post(
|
||||
self.url,
|
||||
data=_json.dumps({"palette": "palette-bardo"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.palette, "palette-bardo")
|
||||
|
||||
|
||||
class NoteEquipTitleViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="don@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.note = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
|
||||
def test_don_sets_active_title(self):
|
||||
self.client.post("/billboard/note/stargazer/don")
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.active_title, self.note)
|
||||
|
||||
def test_doff_clears_active_title(self):
|
||||
self.user.active_title = self.note
|
||||
self.user.save(update_fields=["active_title"])
|
||||
self.client.post("/billboard/note/stargazer/doff")
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.active_title)
|
||||
|
||||
def test_don_returns_200_with_title(self):
|
||||
response = self.client.post("/billboard/note/stargazer/don")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["title"], "Stargazer")
|
||||
|
||||
def test_doff_returns_200(self):
|
||||
response = self.client.post("/billboard/note/stargazer/doff")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"ok": True})
|
||||
|
||||
def test_don_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post("/billboard/note/stargazer/don")
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_don_returns_404_for_unowned_note(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
|
||||
self.client.logout()
|
||||
self.client.force_login(other)
|
||||
response = self.client.post("/billboard/note/stargazer/don")
|
||||
# other user's own note — should work
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class SaveScrollPositionTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@savescroll.io")
|
||||
|
||||
@@ -7,6 +7,10 @@ app_name = "billboard"
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||
path("my-notes/", views.my_notes, name="my_notes"),
|
||||
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
|
||||
path("note/<slug:slug>/don", views.don_title, name="don_title"),
|
||||
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
|
||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||
]
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import json
|
||||
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Max, Q
|
||||
from django.http import JsonResponse
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.applets.utils import applet_context
|
||||
from apps.drama.models import GameEvent, ScrollPosition
|
||||
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.dashboard.forms import LineForm
|
||||
from apps.dashboard.models import Post
|
||||
from apps.dashboard.views import _PALETTE_DEFS
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||
from apps.epic.models import Room
|
||||
from apps.epic.utils import rooms_for_user
|
||||
|
||||
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
|
||||
|
||||
|
||||
def _recent_posts(user, limit=3):
|
||||
return (
|
||||
Post
|
||||
.objects
|
||||
.filter(Q(owner=user) | Q(shared_with=user))
|
||||
.annotate(last_line=Max('lines__id'))
|
||||
.order_by('-last_line')
|
||||
.distinct()[:limit]
|
||||
)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def billboard(request):
|
||||
my_rooms = Room.objects.filter(
|
||||
Q(owner=request.user) |
|
||||
Q(gate_slots__gamer=request.user) |
|
||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
||||
).distinct().order_by("-created_at")
|
||||
my_rooms = rooms_for_user(request.user).order_by("-created_at")
|
||||
|
||||
recent_room = (
|
||||
Room.objects.filter(
|
||||
@@ -27,7 +42,13 @@ def billboard(request):
|
||||
.first()
|
||||
)
|
||||
recent_events = (
|
||||
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
|
||||
list(
|
||||
recent_room.events
|
||||
.select_related("actor")
|
||||
.exclude(verb=GameEvent.SIG_UNREADY)
|
||||
.exclude(verb=GameEvent.SIG_READY, data__retracted=True)
|
||||
.order_by("-timestamp")[:36]
|
||||
)[::-1]
|
||||
if recent_room else []
|
||||
)
|
||||
|
||||
@@ -37,6 +58,8 @@ def billboard(request):
|
||||
"recent_events": recent_events,
|
||||
"viewer": request.user,
|
||||
"applets": applet_context(request.user, "billboard"),
|
||||
"form": LineForm(),
|
||||
"recent_posts": _recent_posts(request.user),
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
|
||||
@@ -44,15 +67,12 @@ def billboard(request):
|
||||
@login_required(login_url="/")
|
||||
def toggle_billboard_applets(request):
|
||||
checked = request.POST.getlist("applets")
|
||||
for applet in Applet.objects.filter(context="billboard"):
|
||||
UserApplet.objects.update_or_create(
|
||||
user=request.user,
|
||||
applet=applet,
|
||||
defaults={"visible": applet.slug in checked},
|
||||
)
|
||||
apply_applet_toggle(request.user, "billboard", checked)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "billboard"),
|
||||
"form": LineForm(),
|
||||
"recent_posts": _recent_posts(request.user),
|
||||
})
|
||||
return redirect("billboard:billboard")
|
||||
|
||||
@@ -71,6 +91,93 @@ def room_scroll(request, room_id):
|
||||
})
|
||||
|
||||
|
||||
def _palette_opts(names):
|
||||
return [{"name": n, "label": _PALETTE_LABELS.get(n, n)} for n in names]
|
||||
|
||||
|
||||
_NOTE_META = {
|
||||
"stargazer": {
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
|
||||
},
|
||||
"schizo": {
|
||||
"title": "Schizo",
|
||||
"description": "The socius recognizes the line of flight.",
|
||||
"palette_options": [],
|
||||
},
|
||||
"nomad": {
|
||||
"title": "Nomad",
|
||||
"description": "The socius recognizes the smooth space.",
|
||||
"palette_options": [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def note_set_palette(request, slug):
|
||||
from django.http import Http404
|
||||
from apps.dashboard.views import _unlocked_palettes_for_user
|
||||
try:
|
||||
note = Note.objects.get(user=request.user, slug=slug)
|
||||
except Note.DoesNotExist:
|
||||
raise Http404
|
||||
if request.method == "POST":
|
||||
body = json.loads(request.body)
|
||||
palette = body.get("palette", "")
|
||||
note.palette = palette
|
||||
note.save(update_fields=["palette"])
|
||||
# Commit as the user's active sitewide palette now that the Note unlocks it.
|
||||
if palette in _unlocked_palettes_for_user(request.user):
|
||||
request.user.palette = palette
|
||||
request.user.save(update_fields=["palette"])
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def my_notes(request):
|
||||
qs = Note.objects.filter(user=request.user)
|
||||
active_title = request.user.active_title
|
||||
note_items = [
|
||||
{
|
||||
"obj": n,
|
||||
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
|
||||
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
|
||||
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
|
||||
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
|
||||
"is_equipped": active_title is not None and active_title.pk == n.pk,
|
||||
}
|
||||
for n in qs
|
||||
]
|
||||
return render(request, "apps/billboard/my_notes.html", {
|
||||
"notes": qs,
|
||||
"note_items": note_items,
|
||||
"page_class": "page-notes",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def don_title(request, slug):
|
||||
from django.http import Http404
|
||||
try:
|
||||
note = Note.objects.get(user=request.user, slug=slug)
|
||||
except Note.DoesNotExist:
|
||||
raise Http404
|
||||
if request.method == "POST":
|
||||
request.user.active_title = note
|
||||
request.user.save(update_fields=["active_title"])
|
||||
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize())
|
||||
return JsonResponse({"title": title})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def doff_title(request, slug):
|
||||
if request.method == "POST":
|
||||
request.user.active_title = None
|
||||
request.user.save(update_fields=["active_title"])
|
||||
return JsonResponse({"ok": True})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
|
||||
@@ -1,32 +1,32 @@
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from .models import Item
|
||||
from .models import Line
|
||||
|
||||
DUPLICATE_ITEM_ERROR = "You've already logged this to your note"
|
||||
EMPTY_ITEM_ERROR = "You can't have an empty note item"
|
||||
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
|
||||
EMPTY_LINE_ERROR = "You can't have an empty post line"
|
||||
|
||||
class ItemForm(forms.Form):
|
||||
class LineForm(forms.Form):
|
||||
text = forms.CharField(
|
||||
error_messages = {"required": EMPTY_ITEM_ERROR},
|
||||
error_messages = {"required": EMPTY_LINE_ERROR},
|
||||
required=True,
|
||||
)
|
||||
|
||||
def save(self, for_note):
|
||||
return Item.objects.create(
|
||||
note=for_note,
|
||||
def save(self, for_post):
|
||||
return Line.objects.create(
|
||||
post=for_post,
|
||||
text=self.cleaned_data["text"],
|
||||
)
|
||||
|
||||
class ExistingNoteItemForm(ItemForm):
|
||||
def __init__(self, for_note, *args, **kwargs):
|
||||
class ExistingPostLineForm(LineForm):
|
||||
def __init__(self, for_post, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._for_note = for_note
|
||||
self._for_post = for_post
|
||||
|
||||
def clean_text(self):
|
||||
text = self.cleaned_data["text"]
|
||||
if self._for_note.item_set.filter(text=text).exists():
|
||||
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
|
||||
if self._for_post.lines.filter(text=text).exists():
|
||||
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
|
||||
return text
|
||||
|
||||
def save(self):
|
||||
return super().save(for_note=self._for_note)
|
||||
return super().save(for_post=self._for_post)
|
||||
|
||||
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dashboard', '0003_alter_note_owner_alter_note_shared_with'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel('Note', 'Post'),
|
||||
migrations.RenameModel('Item', 'Line'),
|
||||
migrations.RenameField('Line', 'note', 'post'),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='line',
|
||||
unique_together={('post', 'text')},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0 on 2026-04-23 05:42
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dashboard', '0004_rename_note_to_post'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='line',
|
||||
name='post',
|
||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='dashboard.post'),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='owner',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='post',
|
||||
name='shared_with',
|
||||
field=models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -4,11 +4,11 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
class Post(models.Model):
|
||||
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||
owner = models.ForeignKey(
|
||||
"lyric.User",
|
||||
related_name="notes",
|
||||
related_name="posts",
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=models.CASCADE,
|
||||
@@ -16,24 +16,24 @@ class Note(models.Model):
|
||||
|
||||
shared_with = models.ManyToManyField(
|
||||
"lyric.User",
|
||||
related_name="shared_notes",
|
||||
related_name="shared_posts",
|
||||
blank=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.item_set.first().text
|
||||
return self.lines.first().text
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse("view_note", args=[self.id])
|
||||
return reverse("view_post", args=[self.id])
|
||||
|
||||
class Item(models.Model):
|
||||
class Line(models.Model):
|
||||
text = models.TextField(default="")
|
||||
note = models.ForeignKey(Note, default=None, on_delete=models.CASCADE)
|
||||
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
|
||||
|
||||
class Meta:
|
||||
ordering = ("id",)
|
||||
unique_together = ("note", "text")
|
||||
unique_together = ("post", "text")
|
||||
|
||||
def __str__(self):
|
||||
return self.text
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
// console.log("apps/scripts/dashboard.js loading");
|
||||
const initialize = (inputSelector) => {
|
||||
// console.log("initialize called!");
|
||||
const textInput = document.querySelector(inputSelector);
|
||||
if (!textInput) return;
|
||||
textInput.oninput = () => {
|
||||
// console.log("oninput triggered");
|
||||
textInput.classList.remove("is-invalid");
|
||||
};
|
||||
textInput.oninput = () => textInput.classList.remove("is-invalid");
|
||||
};
|
||||
|
||||
const bindPaletteWheel = () => {
|
||||
@@ -18,6 +13,127 @@ const bindPaletteWheel = () => {
|
||||
});
|
||||
};
|
||||
|
||||
// ── Palette swatch preview + commit ──────────────────────────────────────────
|
||||
|
||||
const bindPaletteSwatches = () => {
|
||||
const portal = document.getElementById('id_tooltip_portal');
|
||||
let activePreview = null;
|
||||
let originalPalette = null;
|
||||
let dismissTimer = null;
|
||||
|
||||
function currentBodyPalette() {
|
||||
return [...document.body.classList].find(c => c.startsWith('palette-'));
|
||||
}
|
||||
|
||||
function swapPalette(paletteName) {
|
||||
const old = currentBodyPalette();
|
||||
if (old) document.body.classList.remove(old);
|
||||
document.body.classList.add(paletteName);
|
||||
}
|
||||
|
||||
function showTooltip(swatch) {
|
||||
if (!portal) return;
|
||||
const label = swatch.dataset.label || '';
|
||||
const locked = swatch.dataset.locked === 'true';
|
||||
const date = swatch.dataset.unlockedDate || '';
|
||||
const shoptalk = swatch.dataset.shoptalk || '';
|
||||
const lockIcon = locked ? 'fa-lock' : 'fa-lock-open';
|
||||
const lockText = locked ? 'Locked' : `Unlocked — ${date}`.trim();
|
||||
|
||||
portal.innerHTML = `
|
||||
<h4 class="tt-title">${label}</h4>
|
||||
${shoptalk ? `<p class="tt-shoptalk"><em>${shoptalk}</em></p>` : ''}
|
||||
<p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>`;
|
||||
|
||||
const rect = swatch.getBoundingClientRect();
|
||||
portal.style.display = 'block';
|
||||
portal.style.position = 'fixed';
|
||||
portal.style.top = `${rect.bottom + 8}px`;
|
||||
portal.style.zIndex = '9999';
|
||||
const margin = 8;
|
||||
const ttW = portal.offsetWidth;
|
||||
portal.style.left = `${Math.max(margin, Math.min(rect.left, window.innerWidth - ttW - margin))}px`;
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
if (!portal) return;
|
||||
portal.style.display = 'none';
|
||||
portal.innerHTML = '';
|
||||
}
|
||||
|
||||
function dismiss() {
|
||||
if (!activePreview) return;
|
||||
clearTimeout(dismissTimer);
|
||||
const paletteName = activePreview.dataset.palette;
|
||||
activePreview.classList.remove('previewing');
|
||||
activePreview.querySelector('.palette-ok').style.display = '';
|
||||
document.body.classList.remove(paletteName);
|
||||
if (originalPalette) document.body.classList.add(originalPalette);
|
||||
activePreview = null;
|
||||
originalPalette = null;
|
||||
hideTooltip();
|
||||
}
|
||||
|
||||
async function commitPalette(swatch, paletteName) {
|
||||
// Silent commit — no animation, wipe already happened on preview
|
||||
const old = originalPalette;
|
||||
swatch.classList.remove('previewing');
|
||||
swatch.querySelector('.palette-ok').style.display = '';
|
||||
hideTooltip();
|
||||
activePreview = null;
|
||||
originalPalette = null;
|
||||
clearTimeout(dismissTimer);
|
||||
|
||||
// Remove old palette, keep new one (already on body from preview)
|
||||
if (old && old !== paletteName) {
|
||||
document.body.classList.remove(old);
|
||||
}
|
||||
|
||||
// Update active indicator
|
||||
document.querySelectorAll('.swatch').forEach(sw => {
|
||||
sw.classList.toggle('active', sw.classList.contains(paletteName));
|
||||
});
|
||||
|
||||
// POST to server
|
||||
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
||||
await fetch('/dashboard/set_palette', {
|
||||
method: 'POST',
|
||||
headers: { 'Accept': 'application/json', 'X-CSRFToken': csrf },
|
||||
body: new URLSearchParams({ palette: paletteName }),
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.palette-item .swatch').forEach(swatch => {
|
||||
swatch.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
if (swatch.classList.contains('previewing')) return;
|
||||
|
||||
dismiss(); // clear any existing preview
|
||||
|
||||
originalPalette = currentBodyPalette();
|
||||
activePreview = swatch;
|
||||
|
||||
swatch.classList.add('previewing');
|
||||
showTooltip(swatch);
|
||||
swapPalette(swatch.dataset.palette);
|
||||
swatch.querySelector('.palette-ok').style.display = 'flex';
|
||||
|
||||
// Auto-dismiss after 10s
|
||||
dismissTimer = setTimeout(dismiss, 10000);
|
||||
});
|
||||
|
||||
const okBtn = swatch.querySelector('.btn-confirm.palette-ok');
|
||||
if (okBtn) {
|
||||
okBtn.addEventListener('click', async (e) => {
|
||||
e.stopPropagation();
|
||||
await commitPalette(swatch, swatch.dataset.palette);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('click', () => dismiss());
|
||||
};
|
||||
|
||||
const bindPaletteForms = () => {
|
||||
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
@@ -29,12 +145,10 @@ const bindPaletteForms = () => {
|
||||
});
|
||||
if (!resp.ok) return;
|
||||
const { palette } = await resp.json();
|
||||
// Swap body palette class
|
||||
[...document.body.classList]
|
||||
.filter(c => c.startsWith("palette-"))
|
||||
.forEach(c => document.body.classList.remove(c));
|
||||
document.body.classList.add(palette);
|
||||
// Update active swatch indicator
|
||||
document.querySelectorAll(".swatch").forEach(sw => {
|
||||
sw.classList.toggle("active", sw.classList.contains(palette));
|
||||
});
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
function attachTooltip(el) {
|
||||
el.addEventListener('mouseenter', function () {
|
||||
var tooltip = el.querySelector('.token-tooltip');
|
||||
var tooltip = el.querySelector('.tt');
|
||||
if (!tooltip) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
@@ -69,11 +69,16 @@
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
el.addEventListener('mouseleave', function () {
|
||||
var tooltip = el.querySelector('.token-tooltip');
|
||||
var tooltip = el.querySelector('.tt');
|
||||
if (tooltip) tooltip.style.display = '';
|
||||
});
|
||||
}
|
||||
|
||||
// gameboard.js re-fetches dialog content after DON and fires this event.
|
||||
dialog.addEventListener('kit-content-refreshed', function () {
|
||||
attachCardListeners();
|
||||
});
|
||||
|
||||
function attachCardListeners() {
|
||||
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
|
||||
card.addEventListener('click', function () {
|
||||
|
||||
49
src/apps/dashboard/static/apps/dashboard/note.js
Normal file
@@ -0,0 +1,49 @@
|
||||
const Note = (() => {
|
||||
'use strict';
|
||||
|
||||
function showBanner(note) {
|
||||
if (!note) return;
|
||||
|
||||
const earned = new Date(note.earned_at);
|
||||
const dateStr = earned.toLocaleDateString(undefined, {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
});
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'note-banner';
|
||||
banner.innerHTML =
|
||||
'<div class="note-banner__body">' +
|
||||
'<p class="note-banner__title">' + _esc(note.title) + '</p>' +
|
||||
'<p class="note-banner__description">' + _esc(note.description) + '</p>' +
|
||||
'<time class="note-banner__timestamp" datetime="' + _esc(note.earned_at) + '">' +
|
||||
dateStr +
|
||||
'</time>' +
|
||||
'</div>' +
|
||||
'<div class="note-banner__image"></div>' +
|
||||
'<button type="button" class="btn btn-cancel note-banner__nvm">NVM</button>' +
|
||||
'<a href="/billboard/my-notes/" class="btn btn-caution note-banner__fyi">FYI</a>';
|
||||
|
||||
banner.querySelector('.note-banner__nvm').addEventListener('click', function () {
|
||||
banner.remove();
|
||||
});
|
||||
|
||||
var h2 = document.querySelector('h2');
|
||||
if (h2 && h2.parentNode) {
|
||||
h2.parentNode.insertBefore(banner, h2.nextSibling);
|
||||
} else {
|
||||
document.body.insertBefore(banner, document.body.firstChild);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSaveResponse(data) {
|
||||
showBanner(data && data.note);
|
||||
}
|
||||
|
||||
function _esc(str) {
|
||||
var d = document.createElement('div');
|
||||
d.textContent = str || '';
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
return { showBanner: showBanner, handleSaveResponse: handleSaveResponse };
|
||||
})();
|
||||
@@ -69,7 +69,7 @@ function initWalletTooltips() {
|
||||
if (!portal) return;
|
||||
|
||||
document.querySelectorAll('.wallet-tokens .token').forEach(token => {
|
||||
const tooltip = token.querySelector('.token-tooltip');
|
||||
const tooltip = token.querySelector('.tt');
|
||||
if (!tooltip) return;
|
||||
|
||||
token.addEventListener('mouseenter', () => {
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.dashboard.forms import (
|
||||
DUPLICATE_ITEM_ERROR,
|
||||
EMPTY_ITEM_ERROR,
|
||||
ExistingNoteItemForm,
|
||||
ItemForm,
|
||||
DUPLICATE_LINE_ERROR,
|
||||
EMPTY_LINE_ERROR,
|
||||
ExistingPostLineForm,
|
||||
LineForm,
|
||||
)
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.dashboard.models import Line, Post
|
||||
|
||||
|
||||
class ItemFormTest(TestCase):
|
||||
def test_form_save_handles_saving_to_a_note(self):
|
||||
mynote = Note.objects.create()
|
||||
form = ItemForm(data={"text": "do re mi"})
|
||||
class LineFormTest(TestCase):
|
||||
def test_form_save_handles_saving_to_a_post(self):
|
||||
mypost = Post.objects.create()
|
||||
form = LineForm(data={"text": "do re mi"})
|
||||
self.assertTrue(form.is_valid())
|
||||
new_item = form.save(for_note=mynote)
|
||||
self.assertEqual(new_item, Item.objects.get())
|
||||
self.assertEqual(new_item.text, "do re mi")
|
||||
self.assertEqual(new_item.note, mynote)
|
||||
new_line = form.save(for_post=mypost)
|
||||
self.assertEqual(new_line, Line.objects.get())
|
||||
self.assertEqual(new_line.text, "do re mi")
|
||||
self.assertEqual(new_line.post, mypost)
|
||||
|
||||
class ExistingNoteItemFormTest(TestCase):
|
||||
def test_form_validation_for_blank_items(self):
|
||||
note = Note.objects.create()
|
||||
form = ExistingNoteItemForm(for_note=note, data={"text": ""})
|
||||
class ExistingPostLineFormTest(TestCase):
|
||||
def test_form_validation_for_blank_lines(self):
|
||||
post = Post.objects.create()
|
||||
form = ExistingPostLineForm(for_post=post, data={"text": ""})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
|
||||
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])
|
||||
|
||||
def test_form_validation_for_duplicate_items(self):
|
||||
note = Note.objects.create()
|
||||
Item.objects.create(note=note, text="twins, basil")
|
||||
form = ExistingNoteItemForm(for_note=note, data={"text": "twins, basil"})
|
||||
def test_form_validation_for_duplicate_lines(self):
|
||||
post = Post.objects.create()
|
||||
Line.objects.create(post=post, text="twins, basil")
|
||||
form = ExistingPostLineForm(for_post=post, data={"text": "twins, basil"})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
|
||||
self.assertEqual(form.errors["text"], [DUPLICATE_LINE_ERROR])
|
||||
|
||||
def test_form_save(self):
|
||||
mynote = Note.objects.create()
|
||||
form = ExistingNoteItemForm(for_note=mynote, data={"text": "howdy"})
|
||||
mypost = Post.objects.create()
|
||||
form = ExistingPostLineForm(for_post=mypost, data={"text": "howdy"})
|
||||
self.assertTrue(form.is_valid())
|
||||
new_item = form.save()
|
||||
self.assertEqual(new_item, Item.objects.get())
|
||||
new_line = form.save()
|
||||
self.assertEqual(new_line, Line.objects.get())
|
||||
|
||||
@@ -2,69 +2,69 @@ from django.core.exceptions import ValidationError
|
||||
from django.db.utils import IntegrityError
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.dashboard.models import Line, Post
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class ItemModelTest(TestCase):
|
||||
def test_item_is_related_to_note(self):
|
||||
mynote = Note.objects.create()
|
||||
item = Item()
|
||||
item.note = mynote
|
||||
item.save()
|
||||
self.assertIn(item, mynote.item_set.all())
|
||||
class LineModelTest(TestCase):
|
||||
def test_line_is_related_to_post(self):
|
||||
mypost = Post.objects.create()
|
||||
line = Line()
|
||||
line.post = mypost
|
||||
line.save()
|
||||
self.assertIn(line, mypost.lines.all())
|
||||
|
||||
def test_cannot_save_null_note_items(self):
|
||||
mynote = Note.objects.create()
|
||||
item = Item(note=mynote, text=None)
|
||||
def test_cannot_save_null_post_lines(self):
|
||||
mypost = Post.objects.create()
|
||||
line = Line(post=mypost, text=None)
|
||||
with self.assertRaises(IntegrityError):
|
||||
item.save()
|
||||
line.save()
|
||||
|
||||
def test_cannot_save_empty_note_items(self):
|
||||
mynote = Note.objects.create()
|
||||
item = Item(note=mynote, text="")
|
||||
def test_cannot_save_empty_post_lines(self):
|
||||
mypost = Post.objects.create()
|
||||
line = Line(post=mypost, text="")
|
||||
with self.assertRaises(ValidationError):
|
||||
item.full_clean()
|
||||
line.full_clean()
|
||||
|
||||
def test_duplicate_items_are_invalid(self):
|
||||
mynote = Note.objects.create()
|
||||
Item.objects.create(note=mynote, text="jklol")
|
||||
def test_duplicate_lines_are_invalid(self):
|
||||
mypost = Post.objects.create()
|
||||
Line.objects.create(post=mypost, text="jklol")
|
||||
with self.assertRaises(ValidationError):
|
||||
item = Item(note=mynote, text="jklol")
|
||||
item.full_clean()
|
||||
line = Line(post=mypost, text="jklol")
|
||||
line.full_clean()
|
||||
|
||||
def test_still_can_save_same_item_to_different_notes(self):
|
||||
note1 = Note.objects.create()
|
||||
note2 = Note.objects.create()
|
||||
Item.objects.create(note=note1, text="nojk")
|
||||
item = Item(note=note2, text="nojk")
|
||||
item.full_clean() # should not raise
|
||||
def test_still_can_save_same_line_to_different_posts(self):
|
||||
post1 = Post.objects.create()
|
||||
post2 = Post.objects.create()
|
||||
Line.objects.create(post=post1, text="nojk")
|
||||
line = Line(post=post2, text="nojk")
|
||||
line.full_clean() # should not raise
|
||||
|
||||
class NoteModelTest(TestCase):
|
||||
class PostModelTest(TestCase):
|
||||
def test_get_absolute_url(self):
|
||||
mynote = Note.objects.create()
|
||||
self.assertEqual(mynote.get_absolute_url(), f"/dashboard/note/{mynote.id}/")
|
||||
mypost = Post.objects.create()
|
||||
self.assertEqual(mypost.get_absolute_url(), f"/dashboard/post/{mypost.id}/")
|
||||
|
||||
def test_note_items_order(self):
|
||||
note1 = Note.objects.create()
|
||||
item1 = Item.objects.create(note=note1, text="i1")
|
||||
item2 = Item.objects.create(note=note1, text="item 2")
|
||||
item3 = Item.objects.create(note=note1, text="3")
|
||||
def test_post_lines_order(self):
|
||||
post1 = Post.objects.create()
|
||||
line1 = Line.objects.create(post=post1, text="i1")
|
||||
line2 = Line.objects.create(post=post1, text="line 2")
|
||||
line3 = Line.objects.create(post=post1, text="3")
|
||||
self.assertEqual(
|
||||
list(note1.item_set.all()),
|
||||
[item1, item2, item3],
|
||||
list(post1.lines.all()),
|
||||
[line1, line2, line3],
|
||||
)
|
||||
|
||||
def test_notes_can_have_owners(self):
|
||||
def test_posts_can_have_owners(self):
|
||||
user = User.objects.create(email="a@b.cde")
|
||||
mynote = Note.objects.create(owner=user)
|
||||
self.assertIn(mynote, user.notes.all())
|
||||
mypost = Post.objects.create(owner=user)
|
||||
self.assertIn(mypost, user.posts.all())
|
||||
|
||||
def test_note_owner_is_optional(self):
|
||||
Note.objects.create()
|
||||
def test_post_owner_is_optional(self):
|
||||
Post.objects.create()
|
||||
|
||||
def test_note_name_is_first_item_text(self):
|
||||
note = Note.objects.create()
|
||||
Item.objects.create(note=note, text="first item")
|
||||
Item.objects.create(note=note, text="second item")
|
||||
self.assertEqual(note.name, "first item")
|
||||
def test_post_name_is_first_line_text(self):
|
||||
post = Post.objects.create()
|
||||
Line.objects.create(post=post, text="first line")
|
||||
Line.objects.create(post=post, text="second line")
|
||||
self.assertEqual(post.name, "first line")
|
||||
|
||||
290
src/apps/dashboard/tests/integrated/test_sky_views.py
Normal file
@@ -0,0 +1,290 @@
|
||||
"""Integration tests for the My Sky dashboard views.
|
||||
|
||||
sky_view — GET /dashboard/sky/ → renders sky template
|
||||
sky_preview — GET /dashboard/sky/preview → proxies to PySwiss (no DB write)
|
||||
sky_save — POST /dashboard/sky/save → saves natal data to User model;
|
||||
grants Stargazer Note on first save with real chart_data
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.drama.models import Note
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class SkyViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="star@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_sky_view_renders_template(self):
|
||||
response = self.client.get(reverse("sky"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/dashboard/sky.html")
|
||||
|
||||
def test_sky_view_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(reverse("sky"))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/?next=", response["Location"])
|
||||
|
||||
def test_sky_view_passes_preview_and_save_urls(self):
|
||||
response = self.client.get(reverse("sky"))
|
||||
self.assertContains(response, reverse("sky_preview"))
|
||||
self.assertContains(response, reverse("sky_save"))
|
||||
|
||||
|
||||
class SkyPreviewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="star@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.url = reverse("sky_preview")
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/?next=", response["Location"])
|
||||
|
||||
def test_missing_params_returns_400(self):
|
||||
response = self.client.get(self.url, {"date": "1990-06-15"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_invalid_lat_returns_400(self):
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch("apps.dashboard.views.http_requests")
|
||||
def test_proxies_to_pyswiss_and_returns_chart(self, mock_requests):
|
||||
chart_payload = {
|
||||
"planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}},
|
||||
"houses": {"cusps": [0]*12},
|
||||
"elements": {"Fire": 1, "Earth": 0, "Air": 0, "Water": 0},
|
||||
"house_system": "O",
|
||||
}
|
||||
tz_response = MagicMock()
|
||||
tz_response.json.return_value = {"timezone": "Europe/London"}
|
||||
tz_response.raise_for_status = MagicMock()
|
||||
|
||||
chart_response = MagicMock()
|
||||
chart_response.json.return_value = chart_payload
|
||||
chart_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_requests.get.side_effect = [tz_response, chart_response]
|
||||
|
||||
response = self.client.get(self.url, {
|
||||
"date": "1990-06-15", "time": "09:30",
|
||||
"lat": "51.5074", "lon": "-0.1278",
|
||||
})
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("planets", data)
|
||||
# Earth→Stone rename applied
|
||||
self.assertIn("Stone", data["elements"])
|
||||
self.assertNotIn("Earth", data["elements"])
|
||||
self.assertIn("timezone", data)
|
||||
self.assertIn("distinctions", data)
|
||||
|
||||
|
||||
class SkySaveTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="star@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.url = reverse("sky_save")
|
||||
|
||||
def _post(self, payload):
|
||||
return self.client.post(
|
||||
self.url,
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self._post({})
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/?next=", response["Location"])
|
||||
|
||||
def test_get_not_allowed(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_saves_sky_fields_to_user(self):
|
||||
payload = {
|
||||
"birth_dt": "1990-06-15T08:30:00",
|
||||
"birth_lat": 51.5074,
|
||||
"birth_lon": -0.1278,
|
||||
"birth_place": "London, UK",
|
||||
"house_system": "O",
|
||||
"chart_data": {},
|
||||
}
|
||||
response = self._post(payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(str(self.user.sky_birth_dt), "1990-06-15 08:30:00+00:00")
|
||||
self.assertAlmostEqual(float(self.user.sky_birth_lat), 51.5074, places=3)
|
||||
self.assertAlmostEqual(float(self.user.sky_birth_lon), -0.1278, places=3)
|
||||
self.assertEqual(self.user.sky_birth_place, "London, UK")
|
||||
self.assertEqual(self.user.sky_house_system, "O")
|
||||
|
||||
def test_invalid_json_returns_400(self):
|
||||
response = self.client.post(
|
||||
self.url, data="not json", content_type="application/json"
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_response_contains_saved_flag(self):
|
||||
payload = {
|
||||
"birth_dt": "1990-06-15T08:30:00",
|
||||
"birth_lat": 51.5,
|
||||
"birth_lon": -0.1,
|
||||
"birth_place": "",
|
||||
"house_system": "O",
|
||||
"chart_data": {},
|
||||
}
|
||||
data = self._post(payload).json()
|
||||
self.assertTrue(data["saved"])
|
||||
|
||||
def test_invalid_birth_dt_string_sets_sky_birth_dt_to_none(self):
|
||||
payload = {
|
||||
"birth_dt": "not-a-date",
|
||||
"birth_lat": 51.5,
|
||||
"birth_lon": -0.1,
|
||||
"birth_place": "",
|
||||
"house_system": "O",
|
||||
"chart_data": {},
|
||||
}
|
||||
response = self._post(payload)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.user.refresh_from_db()
|
||||
self.assertIsNone(self.user.sky_birth_dt)
|
||||
|
||||
|
||||
class SkyPreviewErrorPathTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="star2@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.url = reverse("sky_preview")
|
||||
|
||||
def test_non_numeric_lat_returns_400(self):
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_invalid_tz_string_returns_400(self):
|
||||
response = self.client.get(
|
||||
self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/ATimezone"}
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_bad_date_format_returns_400(self):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"date": "not-a-date", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch("apps.dashboard.views.http_requests")
|
||||
def test_pyswiss_tz_failure_falls_back_to_utc_and_continues(self, mock_requests):
|
||||
chart_payload = {
|
||||
"planets": {"Sun": {"degree": 84.5, "sign": "Gemini", "retrograde": False}},
|
||||
"houses": {"cusps": [0] * 12},
|
||||
"elements": {},
|
||||
"house_system": "O",
|
||||
}
|
||||
tz_response = MagicMock()
|
||||
tz_response.raise_for_status.side_effect = Exception("tz timeout")
|
||||
|
||||
chart_response = MagicMock()
|
||||
chart_response.json.return_value = chart_payload
|
||||
chart_response.raise_for_status = MagicMock()
|
||||
|
||||
mock_requests.get.side_effect = [tz_response, chart_response]
|
||||
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["timezone"], "UTC")
|
||||
|
||||
@patch("apps.dashboard.views.http_requests")
|
||||
def test_pyswiss_chart_failure_returns_502(self, mock_requests):
|
||||
tz_response = MagicMock()
|
||||
tz_response.json.return_value = {"timezone": "UTC"}
|
||||
tz_response.raise_for_status = MagicMock()
|
||||
|
||||
chart_response = MagicMock()
|
||||
chart_response.raise_for_status.side_effect = Exception("chart timeout")
|
||||
|
||||
mock_requests.get.side_effect = [tz_response, chart_response]
|
||||
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||||
self.assertEqual(response.status_code, 502)
|
||||
|
||||
|
||||
_REAL_CHART = {
|
||||
"planets": {"Sun": {"degree": 66.7, "sign": "Gemini", "retrograde": False}},
|
||||
"houses": {"cusps": [180, 210, 240, 270, 300, 330, 0, 30, 60, 90, 120, 150]},
|
||||
"elements": {"Fire": 1, "Stone": 2, "Air": 4, "Water": 0, "Time": 1, "Space": 1},
|
||||
"aspects": [],
|
||||
}
|
||||
|
||||
|
||||
class SkySaveNoteTest(TestCase):
|
||||
"""sky_save grants the Stargazer Note on the first save with real chart_data."""
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="star@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.url = reverse("sky_save")
|
||||
|
||||
def _post(self, chart_data=_REAL_CHART):
|
||||
return self.client.post(
|
||||
self.url,
|
||||
data=json.dumps({
|
||||
"birth_dt": "1990-06-15T08:30:00",
|
||||
"birth_lat": 51.5074,
|
||||
"birth_lon": -0.1278,
|
||||
"birth_place": "London, UK",
|
||||
"birth_tz": "Europe/London",
|
||||
"house_system": "O",
|
||||
"chart_data": chart_data,
|
||||
}),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_first_save_with_chart_data_returns_stargazer_note(self):
|
||||
data = self._post().json()
|
||||
self.assertIn("note", data)
|
||||
recog = data["note"]
|
||||
self.assertEqual(recog["slug"], "stargazer")
|
||||
self.assertIn("title", recog)
|
||||
self.assertIn("description", recog)
|
||||
self.assertIn("earned_at", recog)
|
||||
|
||||
def test_first_save_creates_note_in_db(self):
|
||||
self._post()
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
|
||||
|
||||
def test_second_save_returns_null_note(self):
|
||||
self._post()
|
||||
data = self._post().json()
|
||||
self.assertIsNone(data["note"])
|
||||
|
||||
def test_second_save_does_not_create_duplicate_note(self):
|
||||
self._post()
|
||||
self._post()
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 1)
|
||||
|
||||
def test_save_with_empty_chart_data_does_not_grant_note(self):
|
||||
data = self._post(chart_data={}).json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
|
||||
|
||||
def test_save_with_null_chart_data_does_not_grant_note(self):
|
||||
data = self._post(chart_data=None).json()
|
||||
self.assertIsNone(data["note"])
|
||||
self.assertEqual(Note.objects.filter(user=self.user, slug="stargazer").count(), 0)
|
||||
@@ -1,16 +1,19 @@
|
||||
import json
|
||||
import lxml.html
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.contrib.messages import get_messages
|
||||
from django.test import override_settings, TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import html
|
||||
from django.utils import html, timezone
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.dashboard.forms import (
|
||||
DUPLICATE_ITEM_ERROR,
|
||||
EMPTY_ITEM_ERROR,
|
||||
DUPLICATE_LINE_ERROR,
|
||||
EMPTY_LINE_ERROR,
|
||||
)
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.dashboard.models import Line, Post
|
||||
from apps.drama.models import Note
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
@@ -18,64 +21,59 @@ class HomePageTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="disco@test.io")
|
||||
self.client.force_login(self.user)
|
||||
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
|
||||
|
||||
def test_uses_home_template(self):
|
||||
response = self.client.get('/')
|
||||
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
|
||||
|
||||
def test_renders_input_form(self):
|
||||
response = self.client.get('/')
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
forms = parsed.cssselect('form[method=POST]')
|
||||
self.assertIn("/dashboard/new_note", [form.get("action") for form in forms])
|
||||
[form] = [form for form in forms if form.get("action") == "/dashboard/new_note"]
|
||||
inputs = form.cssselect("input")
|
||||
self.assertIn("text", [input.get("name") for input in inputs])
|
||||
|
||||
class NewNoteTest(TestCase):
|
||||
@override_settings(COMPRESS_ENABLED=False)
|
||||
class NewPostTest(TestCase):
|
||||
def setUp(self):
|
||||
user = User.objects.create(email="disco@test.io")
|
||||
self.client.force_login(user)
|
||||
self.user = User.objects.create(email="disco@test.io")
|
||||
self.client.force_login(self.user)
|
||||
Applet.objects.get_or_create(
|
||||
slug="new-post",
|
||||
defaults={"name": "New Post", "context": "billboard", "grid_cols": 9, "grid_rows": 3},
|
||||
)
|
||||
|
||||
def test_can_save_a_POST_request(self):
|
||||
self.client.post("/dashboard/new_note", data={"text": "A new note item"})
|
||||
self.assertEqual(Item.objects.count(), 1)
|
||||
new_item = Item.objects.get()
|
||||
self.assertEqual(new_item.text, "A new note item")
|
||||
self.client.post("/dashboard/new_post", data={"text": "A new post line"})
|
||||
self.assertEqual(Line.objects.count(), 1)
|
||||
new_line = Line.objects.get()
|
||||
self.assertEqual(new_line.text, "A new post line")
|
||||
|
||||
def test_redirects_after_POST(self):
|
||||
response = self.client.post("/dashboard/new_note", data={"text": "A new note item"})
|
||||
new_note = Note.objects.get()
|
||||
self.assertRedirects(response, f"/dashboard/note/{new_note.id}/")
|
||||
response = self.client.post("/dashboard/new_post", data={"text": "A new post line"})
|
||||
new_post = Post.objects.get()
|
||||
self.assertRedirects(response, f"/dashboard/post/{new_post.id}/")
|
||||
|
||||
# Post invalid input helper
|
||||
def post_invalid_input(self):
|
||||
return self.client.post("/dashboard/new_note", data={"text": ""})
|
||||
return self.client.post("/dashboard/new_post", data={"text": ""})
|
||||
|
||||
def test_for_invalid_input_nothing_saved_to_db(self):
|
||||
self.post_invalid_input()
|
||||
self.assertEqual(Item.objects.count(), 0)
|
||||
self.assertEqual(Line.objects.count(), 0)
|
||||
|
||||
def test_for_invalid_input_renders_home_template(self):
|
||||
def test_for_invalid_input_renders_billboard_template(self):
|
||||
response = self.post_invalid_input()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/dashboard/home.html")
|
||||
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
|
||||
|
||||
def test_for_invalid_input_shows_error_on_page(self):
|
||||
response = self.post_invalid_input()
|
||||
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
|
||||
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
|
||||
|
||||
@override_settings(COMPRESS_ENABLED=False)
|
||||
class NoteViewTest(TestCase):
|
||||
def test_uses_note_template(self):
|
||||
mynote = Note.objects.create()
|
||||
response = self.client.get(f"/dashboard/note/{mynote.id}/")
|
||||
self.assertTemplateUsed(response, "apps/dashboard/note.html")
|
||||
class PostViewTest(TestCase):
|
||||
def test_uses_post_template(self):
|
||||
mypost = Post.objects.create()
|
||||
response = self.client.get(f"/dashboard/post/{mypost.id}/")
|
||||
self.assertTemplateUsed(response, "apps/dashboard/post.html")
|
||||
|
||||
def test_renders_input_form(self):
|
||||
mynote = Note.objects.create()
|
||||
url = f"/dashboard/note/{mynote.id}/"
|
||||
mypost = Post.objects.create()
|
||||
url = f"/dashboard/post/{mypost.id}/"
|
||||
response = self.client.get(url)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
forms = parsed.cssselect("form[method=POST]")
|
||||
@@ -84,62 +82,62 @@ class NoteViewTest(TestCase):
|
||||
inputs = form.cssselect("input")
|
||||
self.assertIn("text", [input.get("name") for input in inputs])
|
||||
|
||||
def test_displays_only_items_for_that_note(self):
|
||||
def test_displays_only_lines_for_that_post(self):
|
||||
# Given/Arrange
|
||||
correct_note = Note.objects.create()
|
||||
Item.objects.create(text="itemey 1", note=correct_note)
|
||||
Item.objects.create(text="itemey 2", note=correct_note)
|
||||
other_note = Note.objects.create()
|
||||
Item.objects.create(text="other note item", note=other_note)
|
||||
correct_post = Post.objects.create()
|
||||
Line.objects.create(text="itemey 1", post=correct_post)
|
||||
Line.objects.create(text="itemey 2", post=correct_post)
|
||||
other_post = Post.objects.create()
|
||||
Line.objects.create(text="other post line", post=other_post)
|
||||
# When/Act
|
||||
response = self.client.get(f"/dashboard/note/{correct_note.id}/")
|
||||
response = self.client.get(f"/dashboard/post/{correct_post.id}/")
|
||||
# Then/Assert
|
||||
self.assertContains(response, "itemey 1")
|
||||
self.assertContains(response, "itemey 2")
|
||||
self.assertNotContains(response, "other note item")
|
||||
self.assertNotContains(response, "other post line")
|
||||
|
||||
def test_can_save_a_POST_request_to_an_existing_note(self):
|
||||
other_note = Note.objects.create()
|
||||
correct_note = Note.objects.create()
|
||||
def test_can_save_a_POST_request_to_an_existing_post(self):
|
||||
other_post = Post.objects.create()
|
||||
correct_post = Post.objects.create()
|
||||
|
||||
self.client.post(
|
||||
f"/dashboard/note/{correct_note.id}/",
|
||||
data={"text": "A new item for an existing note"},
|
||||
f"/dashboard/post/{correct_post.id}/",
|
||||
data={"text": "A new line for an existing post"},
|
||||
)
|
||||
|
||||
self.assertEqual(Item.objects.count(), 1)
|
||||
new_item = Item.objects.get()
|
||||
self.assertEqual(new_item.text, "A new item for an existing note")
|
||||
self.assertEqual(new_item.note, correct_note)
|
||||
self.assertEqual(Line.objects.count(), 1)
|
||||
new_line = Line.objects.get()
|
||||
self.assertEqual(new_line.text, "A new line for an existing post")
|
||||
self.assertEqual(new_line.post, correct_post)
|
||||
|
||||
def test_POST_redirects_to_note_view(self):
|
||||
other_note = Note.objects.create()
|
||||
correct_note = Note.objects.create()
|
||||
def test_POST_redirects_to_post_view(self):
|
||||
other_post = Post.objects.create()
|
||||
correct_post = Post.objects.create()
|
||||
|
||||
response = self.client.post(
|
||||
f"/dashboard/note/{correct_note.id}/",
|
||||
data={"text": "A new item for an existing note"},
|
||||
f"/dashboard/post/{correct_post.id}/",
|
||||
data={"text": "A new line for an existing post"},
|
||||
)
|
||||
|
||||
self.assertRedirects(response, f"/dashboard/note/{correct_note.id}/")
|
||||
self.assertRedirects(response, f"/dashboard/post/{correct_post.id}/")
|
||||
|
||||
# Post invalid input helper
|
||||
def post_invalid_input(self):
|
||||
mynote = Note.objects.create()
|
||||
return self.client.post(f"/dashboard/note/{mynote.id}/", data={"text": ""})
|
||||
mypost = Post.objects.create()
|
||||
return self.client.post(f"/dashboard/post/{mypost.id}/", data={"text": ""})
|
||||
|
||||
def test_for_invalid_input_nothing_saved_to_db(self):
|
||||
self.post_invalid_input()
|
||||
self.assertEqual(Item.objects.count(), 0)
|
||||
self.assertEqual(Line.objects.count(), 0)
|
||||
|
||||
def test_for_invalid_input_renders_note_template(self):
|
||||
def test_for_invalid_input_renders_post_template(self):
|
||||
response = self.post_invalid_input()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/dashboard/note.html")
|
||||
self.assertTemplateUsed(response, "apps/dashboard/post.html")
|
||||
|
||||
def test_for_invalid_input_shows_error_on_page(self):
|
||||
response = self.post_invalid_input()
|
||||
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
|
||||
self.assertContains(response, html.escape(EMPTY_LINE_ERROR))
|
||||
|
||||
def test_for_invalid_input_sets_is_invalid_class(self):
|
||||
response = self.post_invalid_input()
|
||||
@@ -147,26 +145,26 @@ class NoteViewTest(TestCase):
|
||||
[input] = parsed.cssselect("input[name=text]")
|
||||
self.assertIn("is-invalid", set(input.classes))
|
||||
|
||||
def test_duplicate_item_validation_errors_end_up_on_note_page(self):
|
||||
note1 = Note.objects.create()
|
||||
Item.objects.create(note=note1, text="lorem ipsum")
|
||||
def test_duplicate_line_validation_errors_end_up_on_post_page(self):
|
||||
post1 = Post.objects.create()
|
||||
Line.objects.create(post=post1, text="lorem ipsum")
|
||||
|
||||
response = self.client.post(
|
||||
f"/dashboard/note/{note1.id}/",
|
||||
f"/dashboard/post/{post1.id}/",
|
||||
data={"text": "lorem ipsum"},
|
||||
)
|
||||
|
||||
expected_error = html.escape(DUPLICATE_ITEM_ERROR)
|
||||
expected_error = html.escape(DUPLICATE_LINE_ERROR)
|
||||
self.assertContains(response, expected_error)
|
||||
self.assertTemplateUsed(response, "apps/dashboard/note.html")
|
||||
self.assertEqual(Item.objects.all().count(), 1)
|
||||
self.assertTemplateUsed(response, "apps/dashboard/post.html")
|
||||
self.assertEqual(Line.objects.all().count(), 1)
|
||||
|
||||
class MyNotesTest(TestCase):
|
||||
def test_my_notes_url_renders_my_notes_template(self):
|
||||
class MyPostsTest(TestCase):
|
||||
def test_my_posts_url_renders_my_posts_template(self):
|
||||
user = User.objects.create(email="a@b.cde")
|
||||
self.client.force_login(user)
|
||||
response = self.client.get(f"/dashboard/users/{user.id}/")
|
||||
self.assertTemplateUsed(response, "apps/dashboard/my_notes.html")
|
||||
self.assertTemplateUsed(response, "apps/dashboard/my_posts.html")
|
||||
|
||||
def test_passes_correct_owner_to_template(self):
|
||||
User.objects.create(email="wrongowner@example.com")
|
||||
@@ -175,71 +173,69 @@ class MyNotesTest(TestCase):
|
||||
response = self.client.get(f"/dashboard/users/{correct_user.id}/")
|
||||
self.assertEqual(response.context["owner"], correct_user)
|
||||
|
||||
def test_note_owner_is_saved_if_user_is_authenticated(self):
|
||||
def test_post_owner_is_saved_if_user_is_authenticated(self):
|
||||
user = User.objects.create(email="a@b.cde")
|
||||
self.client.force_login(user)
|
||||
self.client.post("/dashboard/new_note", data={"text": "new item"})
|
||||
new_note = Note.objects.get()
|
||||
self.assertEqual(new_note.owner, user)
|
||||
self.client.post("/dashboard/new_post", data={"text": "new line"})
|
||||
new_post = Post.objects.get()
|
||||
self.assertEqual(new_post.owner, user)
|
||||
|
||||
def test_my_notes_redirects_if_not_logged_in(self):
|
||||
def test_my_posts_redirects_if_not_logged_in(self):
|
||||
user = User.objects.create(email="a@b.cde")
|
||||
response = self.client.get(f"/dashboard/users/{user.id}/")
|
||||
self.assertRedirects(response, "/")
|
||||
|
||||
def test_my_notes_returns_403_for_wrong_user(self):
|
||||
# create two users, login as user_a, request user_b's my_notes url
|
||||
def test_my_posts_returns_403_for_wrong_user(self):
|
||||
user1 = User.objects.create(email="a@b.cde")
|
||||
user2 = User.objects.create(email="wrongowner@example.com")
|
||||
self.client.force_login(user2)
|
||||
response = self.client.get(f"/dashboard/users/{user1.id}/")
|
||||
# assert 403
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
class ShareNoteTest(TestCase):
|
||||
def test_post_to_share_note_url_redirects_to_note(self):
|
||||
our_note = Note.objects.create()
|
||||
class SharePostTest(TestCase):
|
||||
def test_post_to_share_post_url_redirects_to_post(self):
|
||||
our_post = Post.objects.create()
|
||||
alice = User.objects.create(email="alice@example.com")
|
||||
response = self.client.post(
|
||||
f"/dashboard/note/{our_note.id}/share_note",
|
||||
f"/dashboard/post/{our_post.id}/share_post",
|
||||
data={"recipient": "alice@example.com"},
|
||||
)
|
||||
self.assertRedirects(response, f"/dashboard/note/{our_note.id}/")
|
||||
self.assertRedirects(response, f"/dashboard/post/{our_post.id}/")
|
||||
|
||||
def test_post_with_email_adds_user_to_shared_with(self):
|
||||
our_note = Note.objects.create()
|
||||
our_post = Post.objects.create()
|
||||
alice = User.objects.create(email="alice@example.com")
|
||||
self.client.post(
|
||||
f"/dashboard/note/{our_note.id}/share_note",
|
||||
f"/dashboard/post/{our_post.id}/share_post",
|
||||
data={"recipient": "alice@example.com"},
|
||||
)
|
||||
self.assertIn(alice, our_note.shared_with.all())
|
||||
self.assertIn(alice, our_post.shared_with.all())
|
||||
|
||||
def test_post_with_nonexistent_email_redirects_to_note(self):
|
||||
our_note = Note.objects.create()
|
||||
def test_post_with_nonexistent_email_redirects_to_post(self):
|
||||
our_post = Post.objects.create()
|
||||
response = self.client.post(
|
||||
f"/dashboard/note/{our_note.id}/share_note",
|
||||
f"/dashboard/post/{our_post.id}/share_post",
|
||||
data={"recipient": "nobody@example.com"},
|
||||
)
|
||||
self.assertRedirects(
|
||||
response,
|
||||
f"/dashboard/note/{our_note.id}/",
|
||||
f"/dashboard/post/{our_post.id}/",
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
def test_share_note_does_not_add_owner_as_recipient(self):
|
||||
def test_share_post_does_not_add_owner_as_recipient(self):
|
||||
owner = User.objects.create(email="owner@example.com")
|
||||
our_note = Note.objects.create(owner=owner)
|
||||
our_post = Post.objects.create(owner=owner)
|
||||
self.client.force_login(owner)
|
||||
self.client.post(reverse("share_note", args=[our_note.id]),
|
||||
self.client.post(reverse("share_post", args=[our_post.id]),
|
||||
data={"recipient": "owner@example.com"})
|
||||
self.assertNotIn(owner, our_note.shared_with.all())
|
||||
self.assertNotIn(owner, our_post.shared_with.all())
|
||||
|
||||
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
|
||||
def test_share_note_shows_privacy_safe_message(self):
|
||||
our_note = Note.objects.create()
|
||||
def test_share_post_shows_privacy_safe_message(self):
|
||||
our_post = Post.objects.create()
|
||||
response = self.client.post(
|
||||
f"/dashboard/note/{our_note.id}/share_note",
|
||||
f"/dashboard/post/{our_post.id}/share_post",
|
||||
data={"recipient": "nobody@example.com"},
|
||||
follow=True,
|
||||
)
|
||||
@@ -249,26 +245,26 @@ class ShareNoteTest(TestCase):
|
||||
"An invite has been sent if that address is registered.",
|
||||
)
|
||||
|
||||
class ViewAuthNoteTest(TestCase):
|
||||
class ViewAuthPostTest(TestCase):
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="disco@example.com")
|
||||
self.our_note = Note.objects.create(owner=self.owner)
|
||||
self.our_post = Post.objects.create(owner=self.owner)
|
||||
|
||||
def test_anonymous_user_is_redirected(self):
|
||||
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
|
||||
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
|
||||
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||
|
||||
def test_non_owner_non_shared_user_gets_403(self):
|
||||
stranger = User.objects.create(email="stranger@example.com")
|
||||
self.client.force_login(stranger)
|
||||
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
|
||||
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_shared_with_user_can_access_note(self):
|
||||
def test_shared_with_user_can_access_post(self):
|
||||
guest = User.objects.create(email="guest@example.com")
|
||||
self.our_note.shared_with.add(guest)
|
||||
self.our_post.shared_with.add(guest)
|
||||
self.client.force_login(guest)
|
||||
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
|
||||
response = self.client.get(reverse("view_post", args=[self.our_post.id]))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
@override_settings(COMPRESS_ENABLED=False)
|
||||
@@ -290,7 +286,7 @@ class SetPaletteTest(TestCase):
|
||||
self.assertEqual(self.user.palette, "palette-default")
|
||||
|
||||
def test_locked_palette_is_rejected(self):
|
||||
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-nirvana"})
|
||||
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.palette, "palette-default")
|
||||
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||
@@ -302,27 +298,27 @@ class SetPaletteTest(TestCase):
|
||||
def test_set_palette_returns_json_when_requested(self):
|
||||
response = self.client.post(
|
||||
"/dashboard/set_palette",
|
||||
data={"palette": "palette-sepia"},
|
||||
data={"palette": "palette-cedar"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"palette": "palette-sepia"})
|
||||
self.assertEqual(response.json(), {"palette": "palette-cedar"})
|
||||
|
||||
def test_locked_palette_returns_unchanged_json(self):
|
||||
response = self.client.post(
|
||||
"/dashboard/set_palette",
|
||||
data={"palette": "palette-nirvana"},
|
||||
data={"palette": "palette-bardo"},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json(), {"palette": "palette-default"})
|
||||
|
||||
def test_dashboard_contains_set_palette_form(self):
|
||||
def test_unlocked_swatches_count_matches_context(self):
|
||||
response = self.client.get(self.url)
|
||||
parsed = lxml.html.fromstring(response.content)
|
||||
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
|
||||
swatches = parsed.cssselect(".swatch:not(.locked)")
|
||||
unlocked = [p for p in response.context["palettes"] if not p["locked"]]
|
||||
self.assertEqual(len(forms), len(unlocked))
|
||||
self.assertEqual(len(swatches), len(unlocked))
|
||||
|
||||
def test_active_palette_swatch_has_active_class(self):
|
||||
response = self.client.get(self.url)
|
||||
@@ -346,6 +342,52 @@ class SetPaletteTest(TestCase):
|
||||
swatches = parsed.cssselect(".swatch")
|
||||
self.assertEqual(len(swatches), len(response.context["palettes"]))
|
||||
|
||||
class NotePaletteContextTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="recog_palette@test.io")
|
||||
self.client.force_login(self.user)
|
||||
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||
|
||||
def test_note_palette_unlocks_swatch_in_context(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertFalse(bardo["locked"])
|
||||
|
||||
def test_note_palette_shoptalk_contains_note_title(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertIn("Stargazer", bardo["shoptalk"])
|
||||
|
||||
def test_note_without_palette_field_keeps_swatch_locked(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette=None,
|
||||
)
|
||||
response = self.client.get("/")
|
||||
palettes = response.context["palettes"]
|
||||
bardo = next(p for p in palettes if p["name"] == "palette-bardo")
|
||||
self.assertTrue(bardo["locked"])
|
||||
|
||||
def test_note_palette_allows_set_palette_via_view(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
self.client.post("/dashboard/set_palette", data={"palette": "palette-bardo"})
|
||||
self.user.refresh_from_db()
|
||||
self.assertEqual(self.user.palette, "palette-bardo")
|
||||
|
||||
|
||||
@override_settings(COMPRESS_ENABLED=False)
|
||||
class ProfileViewTest(TestCase):
|
||||
def setUp(self):
|
||||
@@ -482,3 +524,96 @@ class WalletAppletTest(TestCase):
|
||||
def test_wallet_applet_has_manage_link(self):
|
||||
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
|
||||
self.assertEqual(link.get("href"), "/dashboard/wallet/")
|
||||
|
||||
|
||||
ENRICHED_CHART = {
|
||||
"planets": {"Sun": {"lon": 10.0, "sign": "Aries", "house": 1, "degree": 10.0}},
|
||||
"houses": {"cusps": [float(i * 30) for i in range(12)]},
|
||||
"elements": {
|
||||
"Fire": {"count": 3, "contributors": ["Sun", "Mars", "Jupiter"]},
|
||||
"Stone": {"count": 1, "contributors": ["Venus"]},
|
||||
"Air": {"count": 2, "contributors": ["Mercury", "Uranus"]},
|
||||
"Water": {"count": 0, "contributors": []},
|
||||
"Time": {"count": 1, "stellia": ["Saturn"]},
|
||||
"Space": {"count": 1, "parades": ["Neptune"]},
|
||||
},
|
||||
"distinctions": [],
|
||||
"timezone": "UTC",
|
||||
}
|
||||
|
||||
BIRTH_PAYLOAD = {
|
||||
"birth_dt": "1990-06-15T12:00:00Z",
|
||||
"birth_lat": 51.5,
|
||||
"birth_lon": -0.1,
|
||||
"birth_place": "London",
|
||||
"house_system": "O",
|
||||
"chart_data": {"stale": True},
|
||||
}
|
||||
|
||||
|
||||
@override_settings(PYSWISS_URL="http://pyswiss-test")
|
||||
class SkySaveViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(
|
||||
email="disco@test.io",
|
||||
sky_birth_lat=51.5,
|
||||
sky_birth_lon=-0.1,
|
||||
sky_birth_place="London",
|
||||
)
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def _post(self, payload=None):
|
||||
return self.client.post(
|
||||
"/dashboard/sky/save",
|
||||
data=json.dumps(payload or BIRTH_PAYLOAD),
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
def test_save_stores_client_chart_data(self):
|
||||
"""sky_save stores the chart_data from the client (already enriched by sky_preview)."""
|
||||
client_chart = {
|
||||
"planets": {},
|
||||
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 123.4, "mc": 45.6},
|
||||
"elements": {"Fire": {"count": 1, "contributors": []}},
|
||||
}
|
||||
payload = dict(BIRTH_PAYLOAD, chart_data=client_chart)
|
||||
|
||||
response = self._post(payload)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.user.refresh_from_db()
|
||||
self.assertAlmostEqual(self.user.sky_chart_data["houses"]["asc"], 123.4)
|
||||
|
||||
|
||||
class SkyNatusDataViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="disco@test.io")
|
||||
self.client.force_login(self.user)
|
||||
|
||||
def test_returns_stored_chart_with_asc_preserved(self):
|
||||
"""sky_natus_data returns sky_chart_data — asc must match what was saved."""
|
||||
stored = {
|
||||
"planets": {},
|
||||
"houses": {"cusps": [float(i * 30) for i in range(12)], "asc": 236.1, "mc": 159.1},
|
||||
"elements": {"Fire": {"count": 1, "contributors": []}},
|
||||
"aspects": [],
|
||||
"house_system": "O",
|
||||
}
|
||||
self.user.sky_chart_data = stored
|
||||
self.user.save(update_fields=["sky_chart_data"])
|
||||
|
||||
response = self.client.get("/dashboard/sky/data")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = json.loads(response.content)
|
||||
self.assertAlmostEqual(data["houses"]["asc"], 236.1)
|
||||
self.assertIn("distinctions", data)
|
||||
|
||||
def test_returns_404_if_no_chart_data_saved(self):
|
||||
response = self.client.get("/dashboard/sky/data")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/dashboard/sky/data")
|
||||
self.assertRedirects(response, "/?next=/dashboard/sky/data", fetch_redirect_response=False)
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.dashboard.forms import (
|
||||
EMPTY_ITEM_ERROR,
|
||||
ItemForm,
|
||||
EMPTY_LINE_ERROR,
|
||||
LineForm,
|
||||
)
|
||||
|
||||
|
||||
class SimpleItemFormTest(SimpleTestCase):
|
||||
def test_form_validation_for_blank_items(self):
|
||||
form = ItemForm(data={"text": ""})
|
||||
class SimpleLineFormTest(SimpleTestCase):
|
||||
def test_form_validation_for_blank_lines(self):
|
||||
form = LineForm(data={"text": ""})
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
|
||||
self.assertEqual(form.errors["text"], [EMPTY_LINE_ERROR])
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.dashboard.models import Item
|
||||
from apps.dashboard.models import Line
|
||||
|
||||
|
||||
class SimpleItemModelTest(SimpleTestCase):
|
||||
class SimpleLineModelTest(SimpleTestCase):
|
||||
def test_default_text(self):
|
||||
item = Item()
|
||||
self.assertEqual(item.text, "")
|
||||
line = Line()
|
||||
self.assertEqual(line.text, "")
|
||||
|
||||
def test_string_representation(self):
|
||||
item = Item(text="sample text")
|
||||
self.assertEqual(str(item), "sample text")
|
||||
line = Line(text="sample text")
|
||||
self.assertEqual(str(line), "sample text")
|
||||
|
||||
@@ -2,16 +2,20 @@ from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('new_note', views.new_note, name='new_note'),
|
||||
path('note/<uuid:note_id>/', views.view_note, name='view_note'),
|
||||
path('note/<uuid:note_id>/share_note', views.share_note, name="share_note"),
|
||||
path('new_post', views.new_post, name='new_post'),
|
||||
path('post/<uuid:post_id>/', views.view_post, name='view_post'),
|
||||
path('post/<uuid:post_id>/share_post', views.share_post, name="share_post"),
|
||||
path('set_palette', views.set_palette, name='set_palette'),
|
||||
path('set_profile', views.set_profile, name='set_profile'),
|
||||
path('users/<uuid:user_id>/', views.my_notes, name='my_notes'),
|
||||
path('users/<uuid:user_id>/', views.my_posts, name='my_posts'),
|
||||
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
|
||||
path('wallet/', views.wallet, name='wallet'),
|
||||
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
||||
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
|
||||
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
|
||||
path('kit-bag/', views.kit_bag, name='kit_bag'),
|
||||
path('sky/', views.sky_view, name='sky'),
|
||||
path('sky/preview', views.sky_preview, name='sky_preview'),
|
||||
path('sky/save', views.sky_save, name='sky_save'),
|
||||
path('sky/data', views.sky_natus_data, name='sky_natus_data'),
|
||||
]
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import json
|
||||
import stripe
|
||||
import zoneinfo
|
||||
from datetime import datetime
|
||||
|
||||
import requests as http_requests
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import messages
|
||||
@@ -9,117 +14,151 @@ from django.shortcuts import redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
|
||||
from apps.applets.models import Applet, UserApplet
|
||||
from apps.applets.utils import applet_context
|
||||
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
|
||||
from apps.dashboard.models import Item, Note
|
||||
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||
from apps.dashboard.forms import ExistingPostLineForm, LineForm
|
||||
from apps.dashboard.models import Line, Post
|
||||
from apps.drama.models import Note
|
||||
from apps.epic.utils import _compute_distinctions
|
||||
from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
||||
|
||||
|
||||
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
||||
UNLOCKED_PALETTES = frozenset([
|
||||
APPLET_ORDER = ["wallet", "username", "palette"]
|
||||
_BASE_UNLOCKED = frozenset([
|
||||
"palette-default",
|
||||
"palette-sepia",
|
||||
"palette-cedar",
|
||||
"palette-oblivion-light",
|
||||
"palette-monochrome-dark",
|
||||
])
|
||||
PALETTES = [
|
||||
_PALETTE_DEFS = [
|
||||
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||
{"name": "palette-sepia", "label": "Sepia", "locked": False},
|
||||
{"name": "palette-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-cedar", "label": "Cedar", "locked": False},
|
||||
{"name": "palette-oblivion-light", "label": "Oblivion (Light)","locked": False},
|
||||
{"name": "palette-monochrome-dark","label": "Monochrome (Dark)","locked": False},
|
||||
{"name": "palette-bardo", "label": "Bardo", "locked": True},
|
||||
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
||||
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
||||
]
|
||||
_NOTE_TITLES = {
|
||||
"stargazer": "Stargazer",
|
||||
"schizo": "Schizo",
|
||||
"nomad": "Nomad",
|
||||
}
|
||||
# Keep PALETTES as an alias used by views that don't have a request user.
|
||||
PALETTES = _PALETTE_DEFS
|
||||
|
||||
|
||||
def _recent_notes(user, limit=3):
|
||||
def _palettes_for_user(user):
|
||||
if not (user and user.is_authenticated):
|
||||
return [dict(p, shoptalk="Placeholder") for p in _PALETTE_DEFS]
|
||||
granted = {
|
||||
r.palette: r
|
||||
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette="")
|
||||
}
|
||||
result = []
|
||||
for p in _PALETTE_DEFS:
|
||||
entry = dict(p)
|
||||
r = granted.get(p["name"])
|
||||
if r and p["locked"]:
|
||||
entry["locked"] = False
|
||||
title = _NOTE_TITLES.get(r.slug, r.slug.capitalize())
|
||||
entry["shoptalk"] = f"{title} · {r.earned_at.strftime('%b %d, %Y').replace(' 0', ' ')}"
|
||||
else:
|
||||
entry["shoptalk"] = "Placeholder"
|
||||
result.append(entry)
|
||||
return result
|
||||
|
||||
|
||||
def _unlocked_palettes_for_user(user):
|
||||
base = set(_BASE_UNLOCKED)
|
||||
if user and user.is_authenticated:
|
||||
for r in Note.objects.filter(user=user, palette__isnull=False).exclude(palette=""):
|
||||
base.add(r.palette)
|
||||
return base
|
||||
|
||||
|
||||
def _recent_posts(user, limit=3):
|
||||
return (
|
||||
Note
|
||||
Post
|
||||
.objects
|
||||
.filter(Q(owner=user) | Q(shared_with=user))
|
||||
.annotate(last_item=Max('item__id'))
|
||||
.order_by('-last_item')
|
||||
.annotate(last_line=Max('lines__id'))
|
||||
.order_by('-last_line')
|
||||
.distinct()[:limit]
|
||||
)
|
||||
|
||||
def home_page(request):
|
||||
context = {
|
||||
"form": ItemForm(),
|
||||
"palettes": PALETTES,
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
"page_class": "page-dashboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
context["applets"] = applet_context(request.user, "dashboard")
|
||||
context["recent_notes"] = _recent_notes(request.user)
|
||||
return render(request, "apps/dashboard/home.html", context)
|
||||
|
||||
def new_note(request):
|
||||
form = ItemForm(data=request.POST)
|
||||
def new_post(request):
|
||||
form = LineForm(data=request.POST)
|
||||
if form.is_valid():
|
||||
nunote = Note.objects.create()
|
||||
nupost = Post.objects.create()
|
||||
if request.user.is_authenticated:
|
||||
nunote.owner = request.user
|
||||
nunote.save()
|
||||
form.save(for_note=nunote)
|
||||
return redirect(nunote)
|
||||
nupost.owner = request.user
|
||||
nupost.save()
|
||||
form.save(for_post=nupost)
|
||||
return redirect(nupost)
|
||||
else:
|
||||
context = {
|
||||
"form": form,
|
||||
"palettes": PALETTES,
|
||||
"page_class": "page-dashboard",
|
||||
"page_class": "page-billboard",
|
||||
}
|
||||
if request.user.is_authenticated:
|
||||
context["applets"] = applet_context(request.user, "dashboard")
|
||||
context["recent_notes"] = _recent_notes(request.user)
|
||||
return render(request, "apps/dashboard/home.html", context)
|
||||
context["applets"] = applet_context(request.user, "billboard")
|
||||
context["recent_posts"] = _recent_posts(request.user)
|
||||
return render(request, "apps/billboard/billboard.html", context)
|
||||
|
||||
def view_note(request, note_id):
|
||||
our_note = Note.objects.get(id=note_id)
|
||||
def view_post(request, post_id):
|
||||
our_post = Post.objects.get(id=post_id)
|
||||
|
||||
if our_note.owner:
|
||||
if our_post.owner:
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("/")
|
||||
if request.user != our_note.owner and request.user not in our_note.shared_with.all():
|
||||
if request.user != our_post.owner and request.user not in our_post.shared_with.all():
|
||||
return HttpResponseForbidden()
|
||||
|
||||
form = ExistingNoteItemForm(for_note=our_note)
|
||||
form = ExistingPostLineForm(for_post=our_post)
|
||||
|
||||
if request.method == "POST":
|
||||
form = ExistingNoteItemForm(for_note=our_note, data=request.POST)
|
||||
form = ExistingPostLineForm(for_post=our_post, data=request.POST)
|
||||
if form.is_valid():
|
||||
form.save()
|
||||
return redirect(our_note)
|
||||
return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form})
|
||||
return redirect(our_post)
|
||||
return render(request, "apps/dashboard/post.html", {"post": our_post, "form": form})
|
||||
|
||||
def my_notes(request, user_id):
|
||||
def my_posts(request, user_id):
|
||||
owner = User.objects.get(id=user_id)
|
||||
if not request.user.is_authenticated:
|
||||
return redirect("/")
|
||||
if request.user.id != owner.id:
|
||||
return HttpResponseForbidden()
|
||||
return render(request, "apps/dashboard/my_notes.html", {"owner": owner})
|
||||
return render(request, "apps/dashboard/my_posts.html", {"owner": owner})
|
||||
|
||||
def share_note(request, note_id):
|
||||
our_note = Note.objects.get(id=note_id)
|
||||
def share_post(request, post_id):
|
||||
our_post = Post.objects.get(id=post_id)
|
||||
try:
|
||||
recipient = User.objects.get(email=request.POST["recipient"])
|
||||
if recipient == request.user:
|
||||
return redirect(our_note)
|
||||
our_note.shared_with.add(recipient)
|
||||
return redirect(our_post)
|
||||
our_post.shared_with.add(recipient)
|
||||
except User.DoesNotExist:
|
||||
pass
|
||||
messages.success(request, "An invite has been sent if that address is registered.")
|
||||
return redirect(our_note)
|
||||
return redirect(our_post)
|
||||
|
||||
@login_required(login_url="/")
|
||||
def set_palette(request):
|
||||
if request.method == "POST":
|
||||
palette = request.POST.get("palette", "")
|
||||
if palette in UNLOCKED_PALETTES:
|
||||
if palette in _unlocked_palettes_for_user(request.user):
|
||||
request.user.palette = palette
|
||||
request.user.save(update_fields=["palette"])
|
||||
if "application/json" in request.headers.get("Accept", ""):
|
||||
@@ -137,36 +176,29 @@ def set_profile(request):
|
||||
@login_required(login_url="/")
|
||||
def toggle_applets(request):
|
||||
checked = request.POST.getlist("applets")
|
||||
for applet in Applet.objects.filter(context="dashboard"):
|
||||
UserApplet.objects.update_or_create(
|
||||
user=request.user,
|
||||
applet=applet,
|
||||
defaults={"visible": applet.slug in checked},
|
||||
)
|
||||
apply_applet_toggle(request.user, "dashboard", checked)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/dashboard/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "dashboard"),
|
||||
"palettes": PALETTES,
|
||||
"form": ItemForm(),
|
||||
"recent_notes": _recent_notes(request.user),
|
||||
"palettes": _palettes_for_user(request.user),
|
||||
})
|
||||
return redirect("home")
|
||||
|
||||
@login_required(login_url="/")
|
||||
@ensure_csrf_cookie
|
||||
def wallet(request):
|
||||
free_tokens = list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at"))
|
||||
tithe_tokens = list(request.user.tokens.filter(token_type=Token.TITHE))
|
||||
return render(request, "apps/dashboard/wallet.html", {
|
||||
"wallet": request.user.wallet,
|
||||
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
||||
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||
"free_tokens": list(request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).order_by("expires_at")),
|
||||
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||
"free_count": request.user.tokens.filter(
|
||||
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||
).count(),
|
||||
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
|
||||
"free_tokens": free_tokens,
|
||||
"tithe_tokens": tithe_tokens,
|
||||
"free_count": len(free_tokens),
|
||||
"tithe_count": len(tithe_tokens),
|
||||
"applets": applet_context(request.user, "wallet"),
|
||||
"page_class": "page-wallet",
|
||||
})
|
||||
@@ -192,12 +224,7 @@ def kit_bag(request):
|
||||
@login_required(login_url="/")
|
||||
def toggle_wallet_applets(request):
|
||||
checked = request.POST.getlist("applets")
|
||||
for applet in Applet.objects.filter(context="wallet"):
|
||||
UserApplet.objects.update_or_create(
|
||||
user=request.user,
|
||||
applet=applet,
|
||||
defaults={"visible": applet.slug in checked},
|
||||
)
|
||||
apply_applet_toggle(request.user, "wallet", checked)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "wallet"),
|
||||
@@ -236,3 +263,170 @@ def save_payment_method(request):
|
||||
brand=pm.card.brand,
|
||||
)
|
||||
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
|
||||
|
||||
|
||||
# ── My Sky (personal natal chart) ────────────────────────────────────────────
|
||||
|
||||
def _sky_natus_preview(request):
|
||||
"""Shared preview logic — proxies to PySwiss, no DB writes."""
|
||||
date_str = request.GET.get('date')
|
||||
time_str = request.GET.get('time', '12:00')
|
||||
tz_str = request.GET.get('tz', '').strip()
|
||||
lat_str = request.GET.get('lat')
|
||||
lon_str = request.GET.get('lon')
|
||||
|
||||
if not date_str or lat_str is None or lon_str is None:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
lat = float(lat_str)
|
||||
lon = float(lon_str)
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if not tz_str:
|
||||
try:
|
||||
tz_resp = http_requests.get(
|
||||
settings.PYSWISS_URL + '/api/tz/',
|
||||
params={'lat': lat_str, 'lon': lon_str},
|
||||
timeout=5,
|
||||
)
|
||||
tz_resp.raise_for_status()
|
||||
tz_str = tz_resp.json().get('timezone') or 'UTC'
|
||||
except Exception:
|
||||
tz_str = 'UTC'
|
||||
|
||||
try:
|
||||
tz = zoneinfo.ZoneInfo(tz_str)
|
||||
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
local_dt = datetime.strptime(f'{date_str} {time_str}', '%Y-%m-%d %H:%M')
|
||||
local_dt = local_dt.replace(tzinfo=tz)
|
||||
utc_dt = local_dt.astimezone(zoneinfo.ZoneInfo('UTC'))
|
||||
dt_iso = utc_dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
resp = http_requests.get(
|
||||
settings.PYSWISS_URL + '/api/chart/',
|
||||
params={'dt': dt_iso, 'lat': lat_str, 'lon': lon_str},
|
||||
timeout=5,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except Exception:
|
||||
return HttpResponse(status=502)
|
||||
|
||||
data = resp.json()
|
||||
if 'elements' in data and 'Earth' in data['elements']:
|
||||
data['elements']['Stone'] = data['elements'].pop('Earth')
|
||||
data['distinctions'] = _compute_distinctions(data['planets'], data['houses'])
|
||||
data['timezone'] = tz_str
|
||||
return JsonResponse(data)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def sky_view(request):
|
||||
chart_data = request.user.sky_chart_data
|
||||
birth_dt = request.user.sky_birth_dt
|
||||
saved_birth_date = ''
|
||||
saved_birth_time = ''
|
||||
if birth_dt:
|
||||
if request.user.sky_birth_tz:
|
||||
try:
|
||||
birth_dt = birth_dt.astimezone(zoneinfo.ZoneInfo(request.user.sky_birth_tz))
|
||||
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||
pass
|
||||
saved_birth_date = birth_dt.strftime('%Y-%m-%d')
|
||||
saved_birth_time = birth_dt.strftime('%H:%M')
|
||||
return render(request, "apps/dashboard/sky.html", {
|
||||
"preview_url": request.build_absolute_uri("/dashboard/sky/preview"),
|
||||
"save_url": request.build_absolute_uri("/dashboard/sky/save"),
|
||||
"saved_sky_json": json.dumps(chart_data) if chart_data else 'null',
|
||||
"saved_birth_date": saved_birth_date,
|
||||
"saved_birth_time": saved_birth_time,
|
||||
"saved_birth_place": request.user.sky_birth_place,
|
||||
"saved_birth_lat": request.user.sky_birth_lat,
|
||||
"saved_birth_lon": request.user.sky_birth_lon,
|
||||
"saved_birth_tz": request.user.sky_birth_tz,
|
||||
"page_class": "page-sky",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def sky_preview(request):
|
||||
return _sky_natus_preview(request)
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def sky_save(request):
|
||||
if request.method != 'POST':
|
||||
return HttpResponse(status=405)
|
||||
|
||||
try:
|
||||
body = json.loads(request.body)
|
||||
except json.JSONDecodeError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
user = request.user
|
||||
birth_tz_str = body.get('birth_tz', '').strip()
|
||||
birth_dt_str = body.get('birth_dt', '')
|
||||
if birth_dt_str:
|
||||
try:
|
||||
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '').replace('+00:00', ''))
|
||||
if naive.tzinfo is None and birth_tz_str:
|
||||
try:
|
||||
local_tz = zoneinfo.ZoneInfo(birth_tz_str)
|
||||
user.sky_birth_dt = naive.replace(tzinfo=local_tz).astimezone(
|
||||
zoneinfo.ZoneInfo('UTC')
|
||||
)
|
||||
except (zoneinfo.ZoneInfoNotFoundError, KeyError):
|
||||
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
|
||||
elif naive.tzinfo is None:
|
||||
user.sky_birth_dt = naive.replace(tzinfo=zoneinfo.ZoneInfo('UTC'))
|
||||
else:
|
||||
user.sky_birth_dt = naive.astimezone(zoneinfo.ZoneInfo('UTC'))
|
||||
except ValueError:
|
||||
user.sky_birth_dt = None
|
||||
user.sky_birth_lat = body.get('birth_lat')
|
||||
user.sky_birth_lon = body.get('birth_lon')
|
||||
user.sky_birth_place = body.get('birth_place', '')
|
||||
user.sky_birth_tz = body.get('birth_tz', '')
|
||||
user.sky_house_system = body.get('house_system', 'O')
|
||||
user.sky_chart_data = body.get('chart_data')
|
||||
|
||||
user.save(update_fields=[
|
||||
'sky_birth_dt', 'sky_birth_lat', 'sky_birth_lon',
|
||||
'sky_birth_place', 'sky_birth_tz', 'sky_house_system', 'sky_chart_data',
|
||||
])
|
||||
|
||||
note_payload = None
|
||||
if user.sky_chart_data:
|
||||
note, created = Note.grant_if_new(user, "stargazer")
|
||||
if created:
|
||||
note_payload = {
|
||||
"slug": note.slug,
|
||||
"title": "Stargazer",
|
||||
"description": "You saved your first personal sky chart.",
|
||||
"earned_at": note.earned_at.isoformat(),
|
||||
}
|
||||
|
||||
return JsonResponse({"saved": True, "note": note_payload})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def sky_natus_data(request):
|
||||
user = request.user
|
||||
if not user.sky_chart_data:
|
||||
return HttpResponse(status=404)
|
||||
|
||||
data = dict(user.sky_chart_data)
|
||||
data['distinctions'] = _compute_distinctions(
|
||||
data.get('planets', {}), data.get('houses', {})
|
||||
)
|
||||
return JsonResponse(data)
|
||||
|
||||
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-04-12 23:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('drama', '0002_scrollposition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='gameevent',
|
||||
name='verb',
|
||||
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30),
|
||||
),
|
||||
]
|
||||
30
src/apps/drama/migrations/0004_recognition.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-04-22 06:11
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('drama', '0003_alter_gameevent_verb'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Recognition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('slug', models.SlugField(max_length=60)),
|
||||
('earned_at', models.DateTimeField()),
|
||||
('palette', models.CharField(blank=True, max_length=60, null=True)),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='recognitions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['earned_at'],
|
||||
'unique_together': {('user', 'slug')},
|
||||
},
|
||||
),
|
||||
]
|
||||
24
src/apps/drama/migrations/0005_rename_recognition_to_note.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import django.conf
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('drama', '0004_recognition'),
|
||||
migrations.swappable_dependency(django.conf.settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameModel('Recognition', 'Note'),
|
||||
migrations.AlterField(
|
||||
model_name='note',
|
||||
name='user',
|
||||
field=models.ForeignKey(
|
||||
django.conf.settings.AUTH_USER_MODEL,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name='notes',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
|
||||
# Later: replace with per-actor lookup when User model gains a pronouns field.
|
||||
PRONOUN_SUBJ = "yo"
|
||||
PRONOUN_OBJ = "yo"
|
||||
PRONOUN_POSS = "yos"
|
||||
|
||||
|
||||
class GameEvent(models.Model):
|
||||
# Gate phase
|
||||
@@ -14,6 +20,9 @@ class GameEvent(models.Model):
|
||||
ROLE_SELECT_STARTED = "role_select_started"
|
||||
ROLE_SELECTED = "role_selected"
|
||||
ROLES_REVEALED = "roles_revealed"
|
||||
# Sig Select phase
|
||||
SIG_READY = "sig_ready"
|
||||
SIG_UNREADY = "sig_unready"
|
||||
|
||||
VERB_CHOICES = [
|
||||
(ROOM_CREATED, "Room created"),
|
||||
@@ -25,6 +34,8 @@ class GameEvent(models.Model):
|
||||
(ROLE_SELECT_STARTED, "Role select started"),
|
||||
(ROLE_SELECTED, "Role selected"),
|
||||
(ROLES_REVEALED, "Roles revealed"),
|
||||
(SIG_READY, "Sig claim staked"),
|
||||
(SIG_UNREADY, "Sig claim withdrawn"),
|
||||
]
|
||||
|
||||
room = models.ForeignKey(
|
||||
@@ -71,13 +82,36 @@ class GameEvent(models.Model):
|
||||
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
||||
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
||||
}
|
||||
_chair_order = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||
_ordinals = ["1st", "2nd", "3rd", "4th", "5th", "6th"]
|
||||
code = d.get("role", "?")
|
||||
role = d.get("role_display") or _role_names.get(code, code)
|
||||
return f"elects to start as the {role}, and will enjoy affinity with this Role for the remainder of the game."
|
||||
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:
|
||||
@@ -134,3 +168,28 @@ class ScrollPosition(models.Model):
|
||||
def record(room, verb, actor=None, **data):
|
||||
"""Record a game event in the drama log."""
|
||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
|
||||
|
||||
class Note(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name="notes",
|
||||
)
|
||||
slug = models.SlugField(max_length=60)
|
||||
earned_at = models.DateTimeField()
|
||||
palette = models.CharField(max_length=60, null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("user", "slug")]
|
||||
ordering = ["earned_at"]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} — {self.slug}"
|
||||
|
||||
@classmethod
|
||||
def grant_if_new(cls, user, slug):
|
||||
from django.utils import timezone
|
||||
return cls.objects.get_or_create(
|
||||
user=user, slug=slug,
|
||||
defaults={"earned_at": timezone.now()},
|
||||
)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.drama.models import GameEvent, Note, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
@@ -40,10 +41,112 @@ class GameEventModelTest(TestCase):
|
||||
self.assertIn("actor@test.io", str(event))
|
||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||
|
||||
# ── to_prose — ROLE_SELECTED ──────────────────────────────────────────
|
||||
|
||||
def test_role_selected_prose_uses_ordinal_chair(self):
|
||||
for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"),
|
||||
("SC", "4th"), ("AC", "5th"), ("BC", "6th")]:
|
||||
with self.subTest(role=role):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||
role=role, role_display="")
|
||||
self.assertIn(f"assumes {ordinal} Chair", event.to_prose())
|
||||
|
||||
def test_role_selected_prose_includes_role_name(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||
role="PC", role_display="Player")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("Player", prose)
|
||||
self.assertIn("yo will start the game", prose)
|
||||
|
||||
# ── to_prose — SIG_READY ─────────────────────────────────────────────
|
||||
|
||||
def test_sig_ready_prose_embodies_card_with_rank_and_icon(self):
|
||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||
card_name="Maid of Brands", corner_rank="M",
|
||||
suit_icon="fa-wand-sparkles")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||
self.assertIn("(M", prose)
|
||||
self.assertIn("fa-wand-sparkles", prose)
|
||||
|
||||
def test_sig_ready_prose_omits_icon_when_none(self):
|
||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
|
||||
self.assertNotIn("fa-", prose)
|
||||
|
||||
def test_sig_ready_prose_degrades_without_corner_rank(self):
|
||||
# Old events recorded before this change have no corner_rank key
|
||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||
card_name="Maid of Brands")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||
self.assertNotIn("(", prose)
|
||||
|
||||
def test_str_without_actor_shows_system(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertIn("system", str(event))
|
||||
|
||||
# ── to_prose — remaining verb branches ───────────────────────────────
|
||||
|
||||
def test_slot_reserved_prose(self):
|
||||
event = record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||
self.assertEqual(event.to_prose(), "reserves a seat")
|
||||
|
||||
def test_slot_returned_prose(self):
|
||||
event = record(self.room, GameEvent.SLOT_RETURNED, actor=self.user)
|
||||
self.assertEqual(event.to_prose(), "withdraws from the gate")
|
||||
|
||||
def test_slot_released_prose_includes_slot_number(self):
|
||||
event = record(self.room, GameEvent.SLOT_RELEASED, actor=self.user, slot_number=3)
|
||||
self.assertIn("slot 3", event.to_prose())
|
||||
|
||||
def test_invite_sent_prose(self):
|
||||
event = record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
||||
self.assertEqual(event.to_prose(), "sends an invitation")
|
||||
|
||||
def test_role_select_started_prose(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECT_STARTED)
|
||||
self.assertEqual(event.to_prose(), "Role selection begins")
|
||||
|
||||
def test_roles_revealed_prose(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertEqual(event.to_prose(), "All roles assigned")
|
||||
|
||||
def test_role_selected_prose_unknown_role_code_uses_question_mark_ordinal(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||
role="XX", role_display="Unknown")
|
||||
self.assertIn("?", event.to_prose())
|
||||
|
||||
def test_sig_unready_prose(self):
|
||||
event = record(self.room, GameEvent.SIG_UNREADY, actor=self.user)
|
||||
self.assertIn("disembodies", event.to_prose())
|
||||
self.assertIn("Significator", event.to_prose())
|
||||
|
||||
def test_unknown_verb_falls_back_to_verb_string(self):
|
||||
event = record(self.room, "custom_event", actor=self.user)
|
||||
self.assertEqual(event.to_prose(), "custom_event")
|
||||
|
||||
def test_to_activity_returns_none_when_actor_has_no_username(self):
|
||||
actor = User.objects.create(email="noname@test.io")
|
||||
event = record(self.room, GameEvent.SLOT_FILLED, actor=actor, slot_number=1)
|
||||
self.assertIsNone(event.to_activity("https://example.com"))
|
||||
|
||||
|
||||
class ScrollPositionStrTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="reader@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_str_includes_email_room_and_position(self):
|
||||
from apps.drama.models import ScrollPosition
|
||||
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=42)
|
||||
s = str(sp)
|
||||
self.assertIn("reader@test.io", s)
|
||||
self.assertIn("Test Room", s)
|
||||
self.assertIn("42", s)
|
||||
|
||||
|
||||
class ScrollPositionModelTest(TestCase):
|
||||
def setUp(self):
|
||||
@@ -71,3 +174,69 @@ class ScrollPositionModelTest(TestCase):
|
||||
defaults={"position": 200},
|
||||
)
|
||||
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
|
||||
|
||||
|
||||
class NoteModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="earner@test.io")
|
||||
|
||||
def test_can_create_recognition(self):
|
||||
recog = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.assertEqual(Note.objects.count(), 1)
|
||||
self.assertEqual(recog.slug, "stargazer")
|
||||
self.assertEqual(recog.user, self.user)
|
||||
|
||||
def test_palette_is_null_by_default(self):
|
||||
recog = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
self.assertIsNone(recog.palette)
|
||||
|
||||
def test_palette_can_be_set(self):
|
||||
recog = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
palette="palette-bardo",
|
||||
)
|
||||
self.assertEqual(recog.palette, "palette-bardo")
|
||||
|
||||
def test_unique_per_user_and_slug(self):
|
||||
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
|
||||
with self.assertRaises(IntegrityError):
|
||||
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
|
||||
|
||||
def test_different_users_can_share_slug(self):
|
||||
other = User.objects.create(email="other@test.io")
|
||||
Note.objects.create(user=self.user, slug="stargazer", earned_at=timezone.now())
|
||||
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
|
||||
self.assertEqual(Note.objects.count(), 2)
|
||||
|
||||
def test_str_includes_slug_and_email(self):
|
||||
recog = Note.objects.create(
|
||||
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||
)
|
||||
s = str(recog)
|
||||
self.assertIn("stargazer", s)
|
||||
self.assertIn("earner@test.io", s)
|
||||
|
||||
def test_grant_if_new_creates_on_first_call(self):
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertTrue(created)
|
||||
self.assertEqual(recog.slug, "stargazer")
|
||||
self.assertIsNotNone(recog.earned_at)
|
||||
|
||||
def test_grant_if_new_is_idempotent(self):
|
||||
Note.grant_if_new(self.user, "stargazer")
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(Note.objects.count(), 1)
|
||||
|
||||
def test_grant_if_new_does_not_overwrite_palette(self):
|
||||
Note.objects.create(
|
||||
user=self.user, slug="stargazer",
|
||||
earned_at=timezone.now(), palette="palette-bardo",
|
||||
)
|
||||
recog, created = Note.grant_if_new(self.user, "stargazer")
|
||||
self.assertFalse(created)
|
||||
self.assertEqual(recog.palette, "palette-bardo")
|
||||
|
||||
@@ -32,11 +32,22 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
|
||||
|
||||
async def receive_json(self, content):
|
||||
if content.get("type") == "cursor_move" and self.cursor_group:
|
||||
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):
|
||||
@@ -61,5 +72,23 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
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,0 +1,56 @@
|
||||
"""
|
||||
Data migration: rename/update six Earthman Major Arcana cards.
|
||||
|
||||
13 name: "Death" → "King Death & the Cosmic Tree"
|
||||
14 name: "The Traitor" → "The Great Hunt"
|
||||
15 correspondence: "The Tower / La Torre" → "The House of the Devil / Inferno"
|
||||
16 correspondence: "Purgatorio" → "The Tower / La Torre / Purgatorio"
|
||||
50 name/slug: "The Eagle" → "The Mould of Man"
|
||||
51 name/slug: "Divine Calculus" → "The Eagle"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
FORWARD = {
|
||||
13: dict(name="King Death & the Cosmic Tree", slug="king-death-and-the-cosmic-tree"),
|
||||
14: dict(name="The Great Hunt", slug="the-great-hunt"),
|
||||
15: dict(correspondence="The House of the Devil / Inferno"),
|
||||
16: dict(correspondence="The Tower / La Torre / Purgatorio"),
|
||||
50: dict(name="The Mould of Man", slug="the-mould-of-man"),
|
||||
51: dict(name="The Eagle", slug="the-eagle"),
|
||||
}
|
||||
|
||||
REVERSE = {
|
||||
13: dict(name="Death", slug="death-em"),
|
||||
14: dict(name="The Traitor", slug="the-traitor"),
|
||||
15: dict(correspondence="The Tower / La Torre"),
|
||||
16: dict(correspondence="Purgatorio"),
|
||||
50: dict(name="The Eagle", slug="the-eagle"),
|
||||
51: dict(name="Divine Calculus",slug="divine-calculus"),
|
||||
}
|
||||
|
||||
|
||||
def apply(changes):
|
||||
def fn(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
# Process in sorted order so card 50 vacates "the-eagle" slug before card 51 claims it.
|
||||
for number in sorted(changes):
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(**changes[number])
|
||||
return fn
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0020_rename_earthman_pope_cards_2_5"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply(FORWARD), reverse_code=apply(REVERSE)),
|
||||
]
|
||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0 on 2026-04-06 00:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0021_rename_earthman_major_arcana_batch_2'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SigReservation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(max_length=2)),
|
||||
('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)),
|
||||
('reserved_at', models.DateTimeField(auto_now_add=True)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')),
|
||||
('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0 on 2026-04-06 02:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0022_sig_reservation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='arcana',
|
||||
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
|
||||
|
||||
Updates for every Earthman card where suit="PENTACLES":
|
||||
- suit: "PENTACLES" → "CROWNS"
|
||||
- name: " of Pentacles" → " of Crowns"
|
||||
- slug: "pentacles" → "crowns"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def pentacles_to_crowns(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
|
||||
card.suit = "CROWNS"
|
||||
card.name = card.name.replace(" of Pentacles", " of Crowns")
|
||||
card.slug = card.slug.replace("pentacles", "crowns")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
def crowns_to_pentacles(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
|
||||
card.suit = "PENTACLES"
|
||||
card.name = card.name.replace(" of Crowns", " of Pentacles")
|
||||
card.slug = card.slug.replace("crowns", "pentacles")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Data migration: Earthman deck — court cards and major arcana icons.
|
||||
|
||||
1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE"
|
||||
2. Major arcana icons (stored in TarotCard.icon):
|
||||
0 (Nomad) → fa-hat-cowboy-side
|
||||
1 (Schizo) → fa-hat-wizard
|
||||
2–51 (rest) → fa-hand-dots
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
MAJOR_ICONS = {
|
||||
0: "fa-hat-cowboy-side",
|
||||
1: "fa-hat-wizard",
|
||||
}
|
||||
DEFAULT_MAJOR_ICON = "fa-hand-dots"
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# Court cards → MIDDLE
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
|
||||
).update(arcana="MIDDLE")
|
||||
|
||||
# Major arcana icons
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
|
||||
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
|
||||
card.save(update_fields=["icon"])
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
|
||||
).update(arcana="MINOR")
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR"
|
||||
).update(icon="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0024_earthman_pentacles_to_crowns"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=backward),
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Data migration — Earthman deck:
|
||||
1. Rename three suit codes (and card names) for Earthman cards:
|
||||
WANDS → BRANDS (Wands → Brands)
|
||||
CUPS → GRAILS (Cups → Grails)
|
||||
SWORDS → BLADES (Swords → Blades)
|
||||
CROWNS stays CROWNS.
|
||||
2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate
|
||||
deck to corresponding Earthman cards:
|
||||
• Major: explicit number-to-number map based on card correspondences.
|
||||
• Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS,
|
||||
BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart
|
||||
stay with empty keyword lists.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
# ── 1. Suit rename map ────────────────────────────────────────────────────────
|
||||
|
||||
SUIT_RENAMES = {
|
||||
"WANDS": "BRANDS",
|
||||
"CUPS": "GRAILS",
|
||||
"SWORDS": "BLADES",
|
||||
}
|
||||
|
||||
# ── 2. Major arcana: Earthman number → Fiorentine number ─────────────────────
|
||||
# Cards without a Fiorentine counterpart are omitted (keywords stay empty).
|
||||
|
||||
MAJOR_KEYWORD_MAP = {
|
||||
0: 0, # The Schiz → The Fool
|
||||
1: 1, # Pope I (President) → The Magician
|
||||
2: 2, # Pope II (Tsar) → The High Priestess
|
||||
3: 3, # Pope III (Chairman) → The Empress
|
||||
4: 4, # Pope IV (Emperor) → The Emperor
|
||||
5: 5, # Pope V (Chancellor) → The Hierophant
|
||||
6: 8, # Virtue VI (Controlled Folly) → Strength
|
||||
7: 11, # Virtue VII (Not-Doing) → Justice
|
||||
8: 14, # Virtue VIII (Losing Self-Importance) → Temperance
|
||||
# 9: Prudence — no Fiorentine equivalent
|
||||
10: 10, # Wheel of Fortune → Wheel of Fortune
|
||||
11: 7, # The Junkboat → The Chariot
|
||||
12: 12, # The Junkman → The Hanged Man
|
||||
13: 13, # Death → Death
|
||||
14: 15, # The Traitor → The Devil
|
||||
15: 16, # Disco Inferno → The Tower
|
||||
# 16: Torre Terrestre (Purgatory) — no equivalent
|
||||
# 17: Fantasia Celestia (Paradise) — no equivalent
|
||||
18: 6, # Virtue XVIII (Stalking) → The Lovers
|
||||
# 19: Virtue XIX (Intent / Hope) — no equivalent
|
||||
# 20: Virtue XX (Dreaming / Faith)— no equivalent
|
||||
# 21–38: Classical Elements + Zodiac — no equivalents
|
||||
39: 17, # Wanderer XXXIX (Polestar) → The Star
|
||||
40: 18, # Wanderer XL (Antichthon) → The Moon
|
||||
41: 19, # Wanderer XLI (Corestar) → The Sun
|
||||
# 42–49: Planets + The Binary — no equivalents
|
||||
50: 20, # The Eagle → Judgement
|
||||
51: 21, # Divine Calculus → The World
|
||||
}
|
||||
|
||||
# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ───────────────────
|
||||
|
||||
MINOR_SUIT_MAP = {
|
||||
"BRANDS": "WANDS",
|
||||
"GRAILS": "CUPS",
|
||||
"BLADES": "SWORDS",
|
||||
"CROWNS": "PENTACLES",
|
||||
}
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return # decks not seeded — nothing to do
|
||||
|
||||
# ── Step 1: rename Earthman suit codes + card names ───────────────────────
|
||||
for old_suit, new_suit in SUIT_RENAMES.items():
|
||||
old_display = old_suit.capitalize() # e.g. "Wands"
|
||||
new_display = new_suit.capitalize() # e.g. "Brands"
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit)
|
||||
for card in cards:
|
||||
card.name = card.name.replace(f" of {old_display}", f" of {new_display}")
|
||||
card.suit = new_suit
|
||||
card.save()
|
||||
|
||||
# ── Step 2: copy major arcana keywords ───────────────────────────────────
|
||||
fio_major = {
|
||||
card.number: card
|
||||
for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR")
|
||||
}
|
||||
for em_num, fio_num in MAJOR_KEYWORD_MAP.items():
|
||||
fio_card = fio_major.get(fio_num)
|
||||
if not fio_card:
|
||||
continue
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=em_num
|
||||
).update(
|
||||
keywords_upright=fio_card.keywords_upright,
|
||||
keywords_reversed=fio_card.keywords_reversed,
|
||||
)
|
||||
|
||||
# ── Step 3: copy minor/middle arcana keywords ─────────────────────────────
|
||||
for em_suit, fio_suit in MINOR_SUIT_MAP.items():
|
||||
fio_by_number = {
|
||||
card.number: card
|
||||
for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit)
|
||||
}
|
||||
for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit):
|
||||
fio_card = fio_by_number.get(em_card.number)
|
||||
if fio_card:
|
||||
em_card.keywords_upright = fio_card.keywords_upright
|
||||
em_card.keywords_reversed = fio_card.keywords_reversed
|
||||
em_card.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
|
||||
# Reverse suit renames
|
||||
reverse_renames = {new: old for old, new in SUIT_RENAMES.items()}
|
||||
for new_suit, old_suit in reverse_renames.items():
|
||||
new_display = new_suit.capitalize()
|
||||
old_display = old_suit.capitalize()
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit)
|
||||
for card in cards:
|
||||
card.name = card.name.replace(f" of {new_display}", f" of {old_display}")
|
||||
card.suit = old_suit
|
||||
card.save()
|
||||
|
||||
# Clear all Earthman keywords
|
||||
TarotCard.objects.filter(deck_variant=earthman).update(
|
||||
keywords_upright=[],
|
||||
keywords_reversed=[],
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0025_earthman_middle_arcana_and_major_icons"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Schema + data migration:
|
||||
1. Add `cautions` JSONField (list, default=[]) to TarotCard.
|
||||
2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions.
|
||||
All other cards default to [] — the UI shows a placeholder when empty.
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
SCHIZO_CAUTIONS = [
|
||||
'This card will reverse into <span class="card-ref">The Pervert</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Occultist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Pestilence</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Paranoiac</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Despot</span>, which in turn'
|
||||
' reverses into <span class="card-ref">War</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Neurotic</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Capitalist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Famine</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Suicidal</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Fascist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Death</span>.',
|
||||
]
|
||||
|
||||
|
||||
def seed_schizo_cautions(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=SCHIZO_CAUTIONS)
|
||||
|
||||
|
||||
def clear_schizo_cautions(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=[])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0026_earthman_suit_renames_and_keywords"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tarotcard",
|
||||
name="cautions",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions),
|
||||
]
|
||||
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-04-07 03:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0027_tarotcard_cautions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Data fix: clear Schizo cautions from The Nomad (number=0) if present,
|
||||
and ensure they land on The Schizo (number=1).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
SCHIZO_CAUTIONS = [
|
||||
'This card will reverse into <span class="card-ref">I. The Pervert</span> when it'
|
||||
' comes under dominion of <span class="card-ref">II. The Occultist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">II. Pestilence</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Paranoiac</span> when it'
|
||||
' comes under dominion of <span class="card-ref">III. The Despot</span>, which in turn'
|
||||
' reverses into <span class="card-ref">III. War</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Neurotic</span> when it'
|
||||
' comes under dominion of <span class="card-ref">IV. The Capitalist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">IV. Famine</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Suicidal</span> when it'
|
||||
' comes under dominion of <span class="card-ref">V. The Fascist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">V. Death</span>.',
|
||||
]
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=0
|
||||
).update(cautions=[])
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=SCHIZO_CAUTIONS)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=[])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0028_alter_tarotcard_suit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0029_fix_schizo_cautions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='seat',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='sig_reservation',
|
||||
to='epic.tableseat',
|
||||
),
|
||||
),
|
||||
]
|
||||
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0 on 2026-04-09 04:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0030_sigreservation_seat_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='sig_select_started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='countdown_remaining',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='ready',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='room',
|
||||
name='table_status',
|
||||
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||
),
|
||||
]
|
||||
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0 on 2026-04-14 05:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0031_sig_ready_sky_select'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AspectType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True)),
|
||||
('symbol', models.CharField(max_length=5)),
|
||||
('angle', models.PositiveSmallIntegerField()),
|
||||
('orb', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'ordering': ['angle'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HouseLabel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('number', models.PositiveSmallIntegerField(unique=True)),
|
||||
('name', models.CharField(max_length=30)),
|
||||
('keywords', models.CharField(blank=True, max_length=100)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Planet',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True)),
|
||||
('symbol', models.CharField(max_length=5)),
|
||||
('order', models.PositiveSmallIntegerField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Sign',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True)),
|
||||
('symbol', models.CharField(max_length=5)),
|
||||
('element', models.CharField(choices=[('Fire', 'Fire'), ('Earth', 'Earth'), ('Air', 'Air'), ('Water', 'Water')], max_length=5)),
|
||||
('modality', models.CharField(choices=[('Cardinal', 'Cardinal'), ('Fixed', 'Fixed'), ('Mutable', 'Mutable')], max_length=8)),
|
||||
('order', models.PositiveSmallIntegerField(unique=True)),
|
||||
('start_degree', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Data migration: seed Sign, Planet, AspectType, and HouseLabel tables.
|
||||
|
||||
These are stable astrological reference rows — never user-edited.
|
||||
The data matches the constants in pyswiss/apps/charts/calc.py so that
|
||||
the proxy view and D3 wheel share a single source of truth.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
# ── Signs ────────────────────────────────────────────────────────────────────
|
||||
# (order, name, symbol, element, modality, start_degree)
|
||||
SIGNS = [
|
||||
(0, 'Aries', '♈', 'Fire', 'Cardinal', 0.0),
|
||||
(1, 'Taurus', '♉', 'Earth', 'Fixed', 30.0),
|
||||
(2, 'Gemini', '♊', 'Air', 'Mutable', 60.0),
|
||||
(3, 'Cancer', '♋', 'Water', 'Cardinal', 90.0),
|
||||
(4, 'Leo', '♌', 'Fire', 'Fixed', 120.0),
|
||||
(5, 'Virgo', '♍', 'Earth', 'Mutable', 150.0),
|
||||
(6, 'Libra', '♎', 'Air', 'Cardinal', 180.0),
|
||||
(7, 'Scorpio', '♏', 'Water', 'Fixed', 210.0),
|
||||
(8, 'Sagittarius', '♐', 'Fire', 'Mutable', 240.0),
|
||||
(9, 'Capricorn', '♑', 'Earth', 'Cardinal', 270.0),
|
||||
(10, 'Aquarius', '♒', 'Air', 'Fixed', 300.0),
|
||||
(11, 'Pisces', '♓', 'Water', 'Mutable', 330.0),
|
||||
]
|
||||
|
||||
# ── Planets ───────────────────────────────────────────────────────────────────
|
||||
# (order, name, symbol)
|
||||
PLANETS = [
|
||||
(0, 'Sun', '☉'),
|
||||
(1, 'Moon', '☽'),
|
||||
(2, 'Mercury', '☿'),
|
||||
(3, 'Venus', '♀'),
|
||||
(4, 'Mars', '♂'),
|
||||
(5, 'Jupiter', '♃'),
|
||||
(6, 'Saturn', '♄'),
|
||||
(7, 'Uranus', '♅'),
|
||||
(8, 'Neptune', '♆'),
|
||||
(9, 'Pluto', '♇'),
|
||||
]
|
||||
|
||||
# ── Aspect types ──────────────────────────────────────────────────────────────
|
||||
# (name, symbol, angle, orb) — mirrors ASPECTS constant in pyswiss calc.py
|
||||
ASPECT_TYPES = [
|
||||
('Conjunction', '☌', 0, 8.0),
|
||||
('Sextile', '⚹', 60, 6.0),
|
||||
('Square', '□', 90, 8.0),
|
||||
('Trine', '△', 120, 8.0),
|
||||
('Opposition', '☍', 180, 10.0),
|
||||
]
|
||||
|
||||
# ── House labels (distinctions) ───────────────────────────────────────────────
|
||||
# (number, name, keywords)
|
||||
HOUSE_LABELS = [
|
||||
(1, 'Self', 'identity, appearance, first impressions'),
|
||||
(2, 'Worth', 'possessions, values, finances'),
|
||||
(3, 'Education', 'communication, siblings, short journeys'),
|
||||
(4, 'Family', 'home, roots, ancestry'),
|
||||
(5, 'Creation', 'creativity, romance, children, pleasure'),
|
||||
(6, 'Ritual', 'service, health, daily routines'),
|
||||
(7, 'Cooperation', 'partnerships, marriage, open enemies'),
|
||||
(8, 'Regeneration', 'transformation, shared resources, death'),
|
||||
(9, 'Enterprise', 'philosophy, travel, higher learning'),
|
||||
(10, 'Career', 'public life, reputation, authority'),
|
||||
(11, 'Reward', 'friends, groups, aspirations'),
|
||||
(12, 'Reprisal', 'hidden matters, karma, self-undoing'),
|
||||
]
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Sign = apps.get_model('epic', 'Sign')
|
||||
Planet = apps.get_model('epic', 'Planet')
|
||||
AspectType = apps.get_model('epic', 'AspectType')
|
||||
HouseLabel = apps.get_model('epic', 'HouseLabel')
|
||||
|
||||
for order, name, symbol, element, modality, start_degree in SIGNS:
|
||||
Sign.objects.create(
|
||||
order=order, name=name, symbol=symbol,
|
||||
element=element, modality=modality, start_degree=start_degree,
|
||||
)
|
||||
|
||||
for order, name, symbol in PLANETS:
|
||||
Planet.objects.create(order=order, name=name, symbol=symbol)
|
||||
|
||||
for name, symbol, angle, orb in ASPECT_TYPES:
|
||||
AspectType.objects.create(name=name, symbol=symbol, angle=angle, orb=orb)
|
||||
|
||||
for number, name, keywords in HOUSE_LABELS:
|
||||
HouseLabel.objects.create(number=number, name=name, keywords=keywords)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
for model_name in ('Sign', 'Planet', 'AspectType', 'HouseLabel'):
|
||||
apps.get_model('epic', model_name).objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0032_astro_reference_tables'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
35
src/apps/epic/migrations/0034_character_model.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0 on 2026-04-14 05:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0033_seed_astro_reference_tables'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Character',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('birth_dt', models.DateTimeField(blank=True, null=True)),
|
||||
('birth_lat', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('birth_lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('birth_place', models.CharField(blank=True, max_length=200)),
|
||||
('house_system', models.CharField(choices=[('O', 'Porphyry'), ('P', 'Placidus'), ('K', 'Koch'), ('W', 'Whole Sign')], default='O', max_length=1)),
|
||||
('chart_data', models.JSONField(blank=True, null=True)),
|
||||
('celtic_cross', models.JSONField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('retired_at', models.DateTimeField(blank=True, null=True)),
|
||||
('seat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='characters', to='epic.tableseat')),
|
||||
('significator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='character_significators', to='epic.tarotcard')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -3,6 +3,7 @@ import uuid
|
||||
|
||||
from datetime import timedelta
|
||||
from django.db import models
|
||||
from django.db.models import UniqueConstraint
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.conf import settings
|
||||
@@ -32,10 +33,12 @@ class Room(models.Model):
|
||||
|
||||
ROLE_SELECT = "ROLE_SELECT"
|
||||
SIG_SELECT = "SIG_SELECT"
|
||||
SKY_SELECT = "SKY_SELECT"
|
||||
IN_GAME = "IN_GAME"
|
||||
TABLE_STATUS_CHOICES = [
|
||||
(ROLE_SELECT, "Role Select"),
|
||||
(SIG_SELECT, "Significator Select"),
|
||||
(SKY_SELECT, "Sky Select"),
|
||||
(IN_GAME, "In Game"),
|
||||
]
|
||||
|
||||
@@ -49,6 +52,7 @@ class Room(models.Model):
|
||||
table_status = models.CharField(
|
||||
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
||||
)
|
||||
sig_select_started_at = models.DateTimeField(null=True, blank=True)
|
||||
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
board_state = models.JSONField(default=dict)
|
||||
@@ -204,20 +208,30 @@ class DeckVariant(models.Model):
|
||||
class TarotCard(models.Model):
|
||||
MAJOR = "MAJOR"
|
||||
MINOR = "MINOR"
|
||||
MIDDLE = "MIDDLE" # Earthman court cards (M/J/Q/K)
|
||||
ARCANA_CHOICES = [
|
||||
(MAJOR, "Major Arcana"),
|
||||
(MINOR, "Minor Arcana"),
|
||||
(MIDDLE, "Middle Arcana"),
|
||||
]
|
||||
|
||||
WANDS = "WANDS"
|
||||
CUPS = "CUPS"
|
||||
SWORDS = "SWORDS"
|
||||
PENTACLES = "PENTACLES" # Fiorentine 4th suit
|
||||
CROWNS = "CROWNS" # Earthman 4th suit
|
||||
BRANDS = "BRANDS" # Earthman Wands
|
||||
GRAILS = "GRAILS" # Earthman Cups
|
||||
BLADES = "BLADES" # Earthman Swords
|
||||
SUIT_CHOICES = [
|
||||
(WANDS, "Wands"),
|
||||
(CUPS, "Cups"),
|
||||
(SWORDS, "Swords"),
|
||||
(PENTACLES, "Pentacles"),
|
||||
(CROWNS, "Crowns"),
|
||||
(BRANDS, "Brands"),
|
||||
(GRAILS, "Grails"),
|
||||
(BLADES, "Blades"),
|
||||
]
|
||||
|
||||
deck_variant = models.ForeignKey(
|
||||
@@ -225,14 +239,16 @@ class TarotCard(models.Model):
|
||||
on_delete=models.CASCADE, related_name="cards",
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
arcana = models.CharField(max_length=5, choices=ARCANA_CHOICES)
|
||||
arcana = models.CharField(max_length=6, choices=ARCANA_CHOICES)
|
||||
suit = models.CharField(max_length=10, choices=SUIT_CHOICES, null=True, blank=True)
|
||||
icon = models.CharField(max_length=50, blank=True, default='') # FA icon override (e.g. major arcana)
|
||||
number = models.IntegerField() # 0–21 major (Fiorentine); 0–51 major (Earthman); 1–14 minor
|
||||
slug = models.SlugField(max_length=120)
|
||||
correspondence = models.CharField(max_length=200, blank=True) # standard / Italian equivalent
|
||||
group = models.CharField(max_length=100, blank=True) # Earthman major grouping
|
||||
keywords_upright = models.JSONField(default=list)
|
||||
keywords_reversed = models.JSONField(default=list)
|
||||
cautions = models.JSONField(default=list)
|
||||
|
||||
class Meta:
|
||||
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||
@@ -274,6 +290,8 @@ class TarotCard(models.Model):
|
||||
|
||||
@property
|
||||
def suit_icon(self):
|
||||
if self.icon:
|
||||
return self.icon
|
||||
if self.arcana == self.MAJOR:
|
||||
return ''
|
||||
return {
|
||||
@@ -281,8 +299,17 @@ class TarotCard(models.Model):
|
||||
self.CUPS: 'fa-trophy',
|
||||
self.SWORDS: 'fa-gun',
|
||||
self.PENTACLES: 'fa-star',
|
||||
self.CROWNS: 'fa-crown',
|
||||
self.BRANDS: 'fa-wand-sparkles',
|
||||
self.GRAILS: 'fa-trophy',
|
||||
self.BLADES: 'fa-gun',
|
||||
}.get(self.suit, '')
|
||||
|
||||
@property
|
||||
def cautions_json(self):
|
||||
import json
|
||||
return json.dumps(self.cautions)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@@ -324,29 +351,66 @@ class TarotDeck(models.Model):
|
||||
self.save(update_fields=["drawn_card_ids"])
|
||||
|
||||
|
||||
# ── SigReservation — provisional card hold during SIG_SELECT ──────────────────
|
||||
|
||||
class SigReservation(models.Model):
|
||||
LEVITY = 'levity'
|
||||
GRAVITY = 'gravity'
|
||||
POLARITY_CHOICES = [(LEVITY, 'Levity'), (GRAVITY, 'Gravity')]
|
||||
|
||||
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name='sig_reservations')
|
||||
gamer = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='sig_reservations'
|
||||
)
|
||||
seat = models.ForeignKey(
|
||||
'TableSeat', null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='sig_reservation',
|
||||
)
|
||||
card = models.ForeignKey(
|
||||
'TarotCard', on_delete=models.CASCADE, related_name='sig_reservations'
|
||||
)
|
||||
role = models.CharField(max_length=2)
|
||||
polarity = models.CharField(max_length=7, choices=POLARITY_CHOICES)
|
||||
reserved_at = models.DateTimeField(auto_now_add=True)
|
||||
ready = models.BooleanField(default=False)
|
||||
countdown_remaining = models.IntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
constraints = [
|
||||
UniqueConstraint(
|
||||
fields=['room', 'gamer'],
|
||||
name='one_sig_reservation_per_gamer_per_room',
|
||||
),
|
||||
UniqueConstraint(
|
||||
fields=['room', 'card', 'polarity'],
|
||||
name='one_reservation_per_card_per_polarity_per_room',
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
# ── Significator deck helpers ─────────────────────────────────────────────────
|
||||
|
||||
def sig_deck_cards(room):
|
||||
"""Return 36 TarotCard objects forming the Significator deck (18 unique × 2).
|
||||
|
||||
PC/BC pair → WANDS + PENTACLES court cards (numbers 11–14): 8 unique
|
||||
SC/AC pair → SWORDS + CUPS court cards (numbers 11–14): 8 unique
|
||||
PC/BC pair → BRANDS/WANDS + CROWNS Middle Arcana court cards (11–14): 8 unique
|
||||
SC/AC pair → BLADES/SWORDS + GRAILS/CUPS Middle Arcana court cards (11–14): 8 unique
|
||||
NC/EC pair → MAJOR arcana numbers 0 and 1: 2 unique
|
||||
Total: 18 unique × 2 (levity + gravity piles) = 36 cards.
|
||||
"""
|
||||
deck_variant = room.owner.equipped_deck
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_pentacles = list(TarotCard.objects.filter(
|
||||
wands_crowns = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MINOR,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.PENTACLES],
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
swords_cups = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MINOR,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.CUPS],
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
@@ -354,10 +418,45 @@ def sig_deck_cards(room):
|
||||
arcana=TarotCard.MAJOR,
|
||||
number__in=[0, 1],
|
||||
))
|
||||
unique_cards = wands_pentacles + swords_cups + major # 18 unique
|
||||
unique_cards = wands_crowns + swords_cups + major # 18 unique
|
||||
return unique_cards + unique_cards # × 2 = 36
|
||||
|
||||
|
||||
def _sig_unique_cards(room):
|
||||
"""Return the 18 unique TarotCard objects that form one sig pile."""
|
||||
deck_variant = room.owner.equipped_deck
|
||||
if deck_variant is None:
|
||||
return []
|
||||
wands_crowns = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.WANDS, TarotCard.BRANDS, TarotCard.CROWNS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
swords_cups = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MIDDLE,
|
||||
suit__in=[TarotCard.SWORDS, TarotCard.BLADES, TarotCard.CUPS, TarotCard.GRAILS],
|
||||
number__in=[11, 12, 13, 14],
|
||||
))
|
||||
major = list(TarotCard.objects.filter(
|
||||
deck_variant=deck_variant,
|
||||
arcana=TarotCard.MAJOR,
|
||||
number__in=[0, 1],
|
||||
))
|
||||
return wands_crowns + swords_cups + major
|
||||
|
||||
|
||||
def levity_sig_cards(room):
|
||||
"""The 18 cards available to the levity group (PC/NC/SC)."""
|
||||
return _sig_unique_cards(room)
|
||||
|
||||
|
||||
def gravity_sig_cards(room):
|
||||
"""The 18 cards available to the gravity group (BC/EC/AC)."""
|
||||
return _sig_unique_cards(room)
|
||||
|
||||
|
||||
def sig_seat_order(room):
|
||||
"""Return TableSeats in canonical PC→NC→EC→SC→AC→BC order."""
|
||||
_order = {r: i for i, r in enumerate(SIG_SEAT_ORDER)}
|
||||
@@ -371,3 +470,141 @@ def active_sig_seat(room):
|
||||
if seat.significator_id is None:
|
||||
return seat
|
||||
return None
|
||||
|
||||
|
||||
# ── Astrological reference tables (seeded, never user-edited) ─────────────────
|
||||
|
||||
class Sign(models.Model):
|
||||
FIRE = 'Fire'
|
||||
EARTH = 'Earth'
|
||||
AIR = 'Air'
|
||||
WATER = 'Water'
|
||||
ELEMENT_CHOICES = [(e, e) for e in (FIRE, EARTH, AIR, WATER)]
|
||||
|
||||
CARDINAL = 'Cardinal'
|
||||
FIXED = 'Fixed'
|
||||
MUTABLE = 'Mutable'
|
||||
MODALITY_CHOICES = [(m, m) for m in (CARDINAL, FIXED, MUTABLE)]
|
||||
|
||||
name = models.CharField(max_length=20, unique=True)
|
||||
symbol = models.CharField(max_length=5) # ♈ ♉ … ♓
|
||||
element = models.CharField(max_length=5, choices=ELEMENT_CHOICES)
|
||||
modality = models.CharField(max_length=8, choices=MODALITY_CHOICES)
|
||||
order = models.PositiveSmallIntegerField(unique=True) # 0–11, Aries first
|
||||
start_degree = models.FloatField() # 0, 30, 60 … 330
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Planet(models.Model):
|
||||
name = models.CharField(max_length=20, unique=True)
|
||||
symbol = models.CharField(max_length=5) # ☉ ☽ ☿ ♀ ♂ ♃ ♄ ♅ ♆ ♇
|
||||
order = models.PositiveSmallIntegerField(unique=True) # 0–9, Sun first
|
||||
|
||||
class Meta:
|
||||
ordering = ['order']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class AspectType(models.Model):
|
||||
name = models.CharField(max_length=20, unique=True)
|
||||
symbol = models.CharField(max_length=5) # ☌ ⚹ □ △ ☍
|
||||
angle = models.PositiveSmallIntegerField() # 0, 60, 90, 120, 180
|
||||
orb = models.FloatField() # max allowed orb in degrees
|
||||
|
||||
class Meta:
|
||||
ordering = ['angle']
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class HouseLabel(models.Model):
|
||||
"""Life-area label for each of the 12 astrological houses (distinctions)."""
|
||||
|
||||
number = models.PositiveSmallIntegerField(unique=True) # 1–12
|
||||
name = models.CharField(max_length=30)
|
||||
keywords = models.CharField(max_length=100, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['number']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.number}: {self.name}"
|
||||
|
||||
|
||||
# ── Character ─────────────────────────────────────────────────────────────────
|
||||
|
||||
class Character(models.Model):
|
||||
"""A gamer's player-character for one seat in one game session.
|
||||
|
||||
Lifecycle:
|
||||
- Created (draft) when gamer opens PICK SKY overlay.
|
||||
- confirmed_at set on confirm → locked.
|
||||
- retired_at set on retirement → archived (seat may hold a new Character).
|
||||
|
||||
Active character for a seat: confirmed_at__isnull=False, retired_at__isnull=True.
|
||||
"""
|
||||
|
||||
PORPHYRY = 'O'
|
||||
PLACIDUS = 'P'
|
||||
KOCH = 'K'
|
||||
WHOLE = 'W'
|
||||
HOUSE_SYSTEM_CHOICES = [
|
||||
(PORPHYRY, 'Porphyry'),
|
||||
(PLACIDUS, 'Placidus'),
|
||||
(KOCH, 'Koch'),
|
||||
(WHOLE, 'Whole Sign'),
|
||||
]
|
||||
|
||||
# ── seat relationship ─────────────────────────────────────────────────
|
||||
seat = models.ForeignKey(
|
||||
TableSeat, on_delete=models.CASCADE, related_name='characters',
|
||||
)
|
||||
|
||||
# ── significator (set at PICK SKY) ────────────────────────────────────
|
||||
significator = models.ForeignKey(
|
||||
TarotCard, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name='character_significators',
|
||||
)
|
||||
|
||||
# ── natus input (what the gamer entered) ─────────────────────────────
|
||||
birth_dt = models.DateTimeField(null=True, blank=True) # UTC
|
||||
birth_lat = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
birth_lon = models.DecimalField(max_digits=9, decimal_places=6, null=True, blank=True)
|
||||
birth_place = models.CharField(max_length=200, blank=True) # display string only
|
||||
house_system = models.CharField(
|
||||
max_length=1, choices=HOUSE_SYSTEM_CHOICES, default=PORPHYRY,
|
||||
)
|
||||
|
||||
# ── computed natus snapshot (full PySwiss response) ───────────────────
|
||||
chart_data = models.JSONField(null=True, blank=True)
|
||||
|
||||
# ── celtic cross spread (added at PICK SEA) ───────────────────────────
|
||||
celtic_cross = models.JSONField(null=True, blank=True)
|
||||
|
||||
# ── lifecycle ─────────────────────────────────────────────────────────
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True)
|
||||
retired_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
status = 'confirmed' if self.confirmed_at else 'draft'
|
||||
return f"Character(seat={self.seat_id}, {status})"
|
||||
|
||||
@property
|
||||
def is_confirmed(self):
|
||||
return self.confirmed_at is not None
|
||||
|
||||
@property
|
||||
def is_active(self):
|
||||
return self.confirmed_at is not None and self.retired_at is None
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #354a9c;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #381507;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
stroke-width: 2.75px;
|
||||
}
|
||||
|
||||
.cls-3, .cls-4 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #4f66d4;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #4258b8;
|
||||
}
|
||||
|
||||
.cls-7 {
|
||||
fill: #3d180d;
|
||||
}
|
||||
|
||||
.cls-8 {
|
||||
fill: #3a1709;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
stroke-width: 2.2px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-5" d="M185.31,203.37l.12.54,1.16,5.4-2.22.49c-.08,8.17-.62,15.76-1.54,24.08-.26,2.33.18,7.31,3.04,7.58,4.78.44,9.33-1.88,14.1-2.1,1.59-.07,3.44-.05,4.69-.32l.98,9.54c.24,2.25,2.12,2.5,3.74,3.16,1.99.81,2.31,3.55,3.46,5.16l3.75,5.23c.62.86,1.45,1.66,2.51,1.36-.12,2.28.15,4.61.11,7.14l-.44,1.49c-1.47-.84-2.37,1.63-3.55,1.77l-16.08,1.97-21.66,2.39c4.99,1.47,9.61,1.45,14.55,2.03l23.5,2.78c.22,1.66.49.84,1.47.31,1.01,6.14,2.22,12.45,2.53,19.3,1.2,4.17.5,8.59-3.88,10.35l-3.91,2.35c.65.86,1.46,1.29,1.81,2.27,2.77,1.35,3.4,4.57,3.09,7.75,2.44,5.7,2.42,11.51,1.87,17.57l-.91,9.96c-.14,1.55-1.69,1.51-2.77,1.41l-17.57-1.6c-4.26-.39-8.71.46-12.16-2.84l-5.53-7.04c-.25.2-.35.47-.36.58,0-.11.11-.38.36-.58-.25.2-.35.47-.36.58-.28,3.07-.81,5.92-2.85,8.97l-21.21,1.95c-.12-1.76-.26-3.48-1.01-5.15l-4.42-9.91-3.75-25.3c-.14-.97-.63-1.8-1.07-2.16.43-1.73-.03-4.16-.58-6.18-1.9-4.98-3.59-9.83-4.83-15.35.18-.51-1.33-1.69-.39-2.35l1.62-1.16c1-.71-.63-1.33-.17-2.07.4-.66,1.75-.46,1.97-1.2.33-1.08-.35-2.18-1.64-2.16l-4.2.07-9.67-35.31c-.2-.75-.95-.91-1.34-1.05-.54-.19-1.5,0-1.53.93-.02.66-.64,1.72-1.09,2.78-1.2-1.34-4.74-2.35-5.46-.22l-12.57,37.25-7.39,1.96-1.8.57c-.58.18-.63,2.12,0,2.14,1.81.06,3.73-1.13,5.06.44-1.62.37-4.59.86-3.54,2.77.86,1.55,3.62.48,4.95.31l-4.78,15.62c-.24.78.03,1.76.57,2.34.4.43,1.33.72,2.04-.13,1.08-1.29,1.25-3.2,1.82-4.45.97-.38,2.22.37.73.97-.13,1.21-.23,2.19.18,3.14.23.53.84.92,2.01.61,2.71-6.4,4.7-12.72,6.8-19.73l18.11-2.9,5.96,21.86c.44,1.61,1.47,1.6,2.91,1.32,1.12-.22.51-1.69.51-2.99v-1.31c0-.37.34-.39,1.2-.31-1.04,1.12-.03,2.08.5,3.48.36.96,1.5,1.25,2.74,1.23l-.5,20.05c-.09,3.58-.56,6.95-.22,10.34l-21.98.14-2.92-9.49c2.68-4.69,5.77-9.41,3.04-13.61l-6.91,1.61c-1.59.37.86,2.08.68,2.8l-1.98,7.57c-.27,1.01.11,2.26-.46,3.03-1.37,1.88-6.3,1.61-9.49.15-1.3-2.98,1.24-7.91.16-13.45-.44-2.25-3.52-1.29-5.21-1.15l-8.08.67-6.35-5.57c.37-4.82-6.34-6.88-5.95-12.46.07-.96,1.36-1.31,1.96-2.42l5.08-9.34c.18-1.24-1.67-1.79-2.25-2.13l-3.45-1.97c-1-.57-1.54-1.46-1.61-2.3-.11-1.23,3.32-3.74,6.12-4.08l23.77-2.92c-4.84-1.4-9.6-.46-14.47-.77l-20.58-1.25-1.75-17.45c-.27-2.73-1.52-5.99.31-8.64,1.48-2.15,4.9-2.45,7.04-3.85,3.01-1.97-4.02-5.01-3.67-6.6,1.58-7.04-3.55-9.14-2.92-17.11l1.35-16.91c19.69,3.11,39.13,3.08,58.78.65,2.37-.29,3.87.88,5.27,2.25l.56.54-.56-.54-2.23,1.33c3.23,4.36,4.37,8.99,5.14,13.85l2.71,17.1,3.13,17.22c.82-.18.95-1.1.85-1.6-.94-4.55-.24-9.14-.6-13.84-.32-4.1-.56-7.97.06-12.11.92-6.26,1.04-12.4-.43-18.5-.18-.76-1.4-.95-1.7-.74.48-1.08.49-3.87,2.16-4.43l15.4-1.26,24.35-2.21ZM187.63,257.75c.4.93.52,1.9,1.14,2.91,2.36,3.88,4.35-8.06-7.31-13.58-7.5-3.55-15.29-1.54-21.1,4.83-13.04,14.31-17.28,39.69-4.52,53.8,5.7,6.31,14.78,8.03,22.8,5.87,13.32-3.6,20.27-20.17,13.32-16.11-.83,1.08-1.92.91-1.28-.45.23-1.19-.5-2.25-1.19-2.44-2.65-.75-3.54,8.1-13.46,10.47-5.92,1.42-11.86-1.51-14.55-6.83-4.2-8.32-4.34-17.68-1.05-26.64,3.45-9.39,10.35-17.68,18.54-13.94,4.41,2.01,4.83,8.81,5.68,8.88s1.2-.15,1.64-.32c.27-.1,1.75-2.67.35-6.47.34.07.6.1.97.03Z"/>
|
||||
<path class="cls-6" d="M142.89,343.17l.29,2.88c.86.07,1.58.05,2.04.23.67.25,1.91,1.3,1.32,2.05-1.46,1.19-1.67,3.08-2.56,5.44l-38.54,2.82c-2.19.16-4,.22-5.07-1.87-.06.09-.12.09-.17.04l-31.82-30.7.34-.51,6.2,1.52c6.89,2.24,14.28,1.74,21.71,1.33,1.56-.09,1.37,2.99,1.43,3.85-.3,7.59-.1,14.62,2.15,21.47.05.41.41.65.83.61.39-.4.95-.17.83-.59l-.37-1.33.4-13.65c.01-.34.23-.76.19-.97-.05-.23.41-.49.79-.42,3.19,1.46,8.11,1.72,9.49-.15.56-.77.19-2.01.46-3.03l1.98-7.57c.19-.72-2.26-2.43-.68-2.8l6.91-1.61c2.74,4.2-.36,8.92-3.04,13.61l2.92,9.49,21.98-.14Z"/>
|
||||
<path class="cls-6" d="M89.74,321.43c-1.43.12-3.07.88-4.9.94l-9.56.29c-.16,1.21.7,1.9-.37,2.41l-6.2-1.52-.57-.13-.57-.14c.06-.16.14-.32.15-.48l1.45-15.57,1.33-17.08,1.52-13.95,20.58,1.25c4.86.31,9.63-.63,14.47.77l-23.77,2.92c-2.8.34-6.23,2.85-6.12,4.08.07.84.61,1.73,1.61,2.3l3.45,1.97c.58.33,2.43.89,2.25,2.13l-5.08,9.34c-.6,1.11-1.89,1.46-1.96,2.42-.4,5.59,6.32,7.64,5.95,12.46l6.35,5.57Z"/>
|
||||
<path class="cls-5" d="M185.31,203.37l.41.36,32.83,32.44c.15.15.23.32.17.52l-3.36-.08c-.18,0-.37-.17-.52.11-9.2-3.04-24.62.27-26.12-.81-2.61-1.89,1.62-13.42-2.11-26.59l-1.16-5.4-.12-.54Z"/>
|
||||
<path class="cls-6" d="M219.09,263.48c-1.07.3-1.89-.5-2.51-1.36l-3.75-5.23c-1.15-1.61-1.47-4.34-3.46-5.16-1.62-.66-3.5-.91-3.74-3.16l-.98-9.54c2.56-.56,5.24-.75,8.24-.37l1.71-.29c.49-.08,1.13-1.43.74-1.77-.18,0-.37-.17-.52.11.15-.28.34-.11.52-.11l3.36.08c.06-.2-.01-.36-.17-.52.15.15.23.32.17.52,2.85,1.17,1.38,7.41,1.07,13.48l-.68,13.31Z"/>
|
||||
<path class="cls-6" d="M137.02,209.09l5.09,4.92c1.03-.68.94-1.93,1.29-2.74.3-.21,1.51-.02,1.7.74,1.47,6.1,1.35,12.23.43,18.5-.61,4.14-.37,8.01-.06,12.11.37,4.7-.33,9.3.6,13.84.1.5-.03,1.42-.85,1.6l-3.13-17.22-2.71-17.1c-.77-4.87-1.91-9.49-5.14-13.85l2.23-1.33.56.54Z"/>
|
||||
<path class="cls-1" d="M155.13,354.34l-6.55-5.25c-.5-.4-.58-1.25-1.2-1.09-.28.07-.6.17-.85.32.59-.75-.65-1.8-1.32-2.05-.47-.17-1.18-.15-2.04-.23l-.29-2.88c-.34-3.39.13-6.76.22-10.34l.5-20.05c.35-.26.61-.87,1.26-.95.44.36.93,1.18,1.07,2.16l3.75,25.3,4.42,9.91c.75,1.67.89,3.39,1.01,5.15Z"/>
|
||||
<path class="cls-1" d="M218.76,272.12c.55,2.96-1.28,5.06-3.13,7.88-.92,1.4,1.16,2.13,1.36,3.36-.98.53-1.25,1.36-1.47-.31l-23.5-2.78c-4.93-.58-9.56-.56-14.55-2.03l21.66-2.39,16.08-1.97c1.17-.14,2.07-2.61,3.55-1.77Z"/>
|
||||
<path class="cls-8" d="M144.88,311.82c-.66.08-.92.69-1.26.95-1.24.01-2.37-.28-2.74-1.23-.53-1.4-1.54-2.36-.5-3.48-.86-.08-1.19-.06-1.2.31v1.31c0,1.3.6,2.76-.52,2.99-1.43.29-2.47.29-2.91-1.32l-5.96-21.86-18.11,2.9c-2.1,7.01-4.09,13.33-6.8,19.73-1.17.31-1.78-.08-2.01-.61-.41-.95-.31-1.94-.18-3.14,1.5-.6.24-1.35-.73-.97-.56,1.24-.74,3.16-1.82,4.45-.71.85-1.64.57-2.04.13-.54-.58-.8-1.56-.57-2.34l4.78-15.62c-1.33.17-4.1,1.25-4.95-.31-1.05-1.92,1.92-2.4,3.54-2.77-1.34-1.57-3.26-.38-5.06-.44-.62-.02-.58-1.96,0-2.14l1.8-.57,7.39-1.96,12.57-37.25c.72-2.13,4.25-1.12,5.46.22.45-1.06,1.07-2.12,1.09-2.78.03-.94.98-1.12,1.53-.93.39.13,1.13.3,1.34,1.05l9.67,35.31,4.2-.07c1.29-.02,1.98,1.07,1.64,2.16-.23.73-1.57.54-1.97,1.2-.45.74,1.17,1.36.17,2.07l-1.62,1.16c-.93.67.57,1.85.39,2.35,1.24,5.52,2.93,10.37,4.83,15.35.55,2.02,1.01,4.45.58,6.18ZM122.28,263.01l-7.68,21.2,13.37-1.5-5.69-19.69Z"/>
|
||||
<path class="cls-7" d="M187.63,257.75l-1.21-2.81c-.33-.78-.83-2.11-2.39-2.23l-.53-1.37c-.16-.41-.82-.21-1.64-.58-1.01-.56-1.29.55.06.58,2.16,2.07,3.86,4.01,4.73,6.38,1.4,3.8-.08,6.37-.35,6.47-.45.17-.81.39-1.64.32s-1.27-6.87-5.68-8.88c-8.19-3.73-15.09,4.55-18.54,13.94-3.29,8.97-3.16,18.32,1.05,26.64,2.69,5.33,8.63,8.25,14.55,6.83,9.92-2.37,10.81-11.22,13.46-10.47.69.19,1.42,1.25,1.19,2.44-.64,1.37.45,1.54,1.28.45,6.95-4.06,0,12.51-13.32,16.11-8.02,2.17-17.1.44-22.8-5.87-12.75-14.1-8.52-39.49,4.52-53.8,5.8-6.37,13.59-8.37,21.1-4.83,11.66,5.51,9.67,17.45,7.31,13.58-.61-1.01-.74-1.98-1.14-2.91ZM157.46,302.61l3.6,3.77c.64.67.95.15,1.82.3.52.09-.23-.61-.29-.93-.07-.41-1.13-.1-1.37-.35-1.17-1.26-1.91-3.37-3.77-2.78Z"/>
|
||||
<path class="cls-1" d="M186.59,209.31c3.74,13.17-.49,24.7,2.11,26.59,1.5,1.08,16.92-2.22,26.12.81.15-.28.34-.11.52-.11.4.33-.25,1.68-.74,1.77l-1.71.29c-3-.38-5.68-.19-8.24.37-1.25.27-3.1.25-4.69.32-4.76.22-9.32,2.54-14.1,2.1-2.86-.26-3.3-5.24-3.04-7.58.92-8.32,1.46-15.9,1.54-24.08l2.22-.49Z"/>
|
||||
<path class="cls-1" d="M102.87,335.37c-.38-.07-.84.19-.79.42.05.21-.18.63-.19.97l-.4,13.65.37,1.33c.12.42-.44.19-.83.59-.42.03-.78-.2-.83-.61-2.25-6.85-2.45-13.87-2.15-21.47-.06-.86.13-3.93-1.43-3.85-7.43.41-14.82.91-21.71-1.33,1.07-.51.21-1.2.37-2.41l9.56-.29c1.83-.06,3.48-.82,4.9-.94l8.08-.67c1.68-.14,4.77-1.1,5.21,1.15,1.08,5.55-1.46,10.47-.16,13.45Z"/>
|
||||
<path class="cls-1" d="M187.63,257.75c-.37.07-.64.04-.97-.03-.88-2.38-2.57-4.31-4.73-6.38-1.35-.04-1.07-1.15-.06-.58.82.37,1.48.17,1.64.58l.53,1.37c1.56.12,2.06,1.45,2.39,2.23l1.21,2.81Z"/>
|
||||
<polygon class="cls-5" points="122.28 263.01 127.97 282.71 114.6 284.21 122.28 263.01"/>
|
||||
<path class="cls-1" d="M157.46,302.61c1.86-.59,2.59,1.52,3.77,2.78.23.25,1.3-.06,1.37.35.05.32.81,1.02.29.93-.87-.15-1.18.38-1.82-.3l-3.6-3.77Z"/>
|
||||
<path class="cls-2" d="M210.46,277.3l6.02,1.81c1.38.43.78,2.5-.62,2.11,0,0-6.03-1.81-6.03-1.81-1.97-.86-4.17-1.86-6.07-2.91,1.33.16,5.03.6,6.7.81h0Z"/>
|
||||
<path class="cls-4" d="M185.31,203.37l-24.35,2.21-15.4,1.26c-1.66.57-1.68,3.35-2.16,4.43-.36.81-.27,2.06-1.29,2.74l-5.09-4.92-.56-.54c-1.41-1.37-2.9-2.54-5.27-2.25-19.66,2.42-39.1,2.45-58.78-.65l-1.35,16.91c-.64,7.97,4.5,10.07,2.92,17.11-.36,1.59,6.68,4.62,3.67,6.6-2.14,1.4-5.56,1.7-7.04,3.85-1.83,2.66-.58,5.92-.31,8.64l1.75,17.45-1.52,13.95-1.33,17.08-1.45,15.57-.15.48"/>
|
||||
<path class="cls-4" d="M185.32,203.34l.4.39,32.83,32.44c.15.15.23.32.17.52l-3.36-.08c-.18,0-.37-.17-.52.11-9.2-3.04-24.62.27-26.12-.81-2.61-1.89,1.62-13.42-2.11-26.59l-1.16-5.4-.11-.58Z"/>
|
||||
<path class="cls-4" d="M213.54,317.63c2.77,1.35,3.4,4.57,3.09,7.75,2.44,5.7,2.42,11.51,1.87,17.57l-.91,9.96c-.14,1.55-1.69,1.51-2.77,1.41l-17.57-1.6c-4.26-.39-8.71.46-12.16-2.84l-5.53-7.04c-.25.2-.35.47-.36.58-.28,3.07-.81,5.92-2.85,8.97l-21.21,1.95-6.55-5.25c-.5-.4-.58-1.25-1.2-1.09-.28.07-.6.17-.85.32-1.46,1.19-1.67,3.08-2.56,5.44l-38.54,2.82c-2.19.16-4,.22-5.07-1.87-.62-.8-.65-1.96-.17-3.01-2.25-6.85-2.45-13.87-2.15-21.47-.06-.86.13-3.93-1.43-3.85-7.43.41-14.82.91-21.71-1.33l-6.2-1.52-.57-.13-.57-.14"/>
|
||||
<polyline class="cls-3" points="100.19 354.76 68.38 324.06 67.57 323.28"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #39170a;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #6b1f65;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #852f7e;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #3d1a0d;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2.2px;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #9e3d96;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-6" d="M134.56,198.73c-.18.4-.63,1.06-.86,1.01l-1.53-.32,2.73,6.11c3.16,4.21,3.45,9.15,4.36,14.26l5.43,30.42-.31-26.42.69-6.77-.39-15.14c.78.13,1.2.48,1.86.21.09,1.59-.38,3.25-.01,4.91,3.95,1.89,3.31,4.73,5.82,4.74,1.17,0,3.35.51,3.8-.95.88-2.85,3.04-3.98,3.96-8.34,2.01-.62,4.59.12,5.2,2.41l2.94,4.65.47,2.59c.11.63,3.07.5,5.91,4.7.74-2.1,2.63-2.9,4.18-3.47,2.1-.76,3.81.92,5.23,2.46l1.28.36-1.2,10.28c-.16,1.36.06,4.75,1.34,5.26l12.32-1.33c1.3-.14,2.73-.73,4.27-.46-.74,2.06,2.02,3.53,2.3,4.95.63,3.15.89,5.85,3.93,7.95,1.18.81,2.14,2.65,4.4,1.82l2.14,4.34c-1.97,3.85-2.69,7.52-1.18,11.55l-7.95,3.92-9.59,1.55-24.02,4.56,31.72.18c3.86-1.21,7.59-.06,10.11,3.3-3.28,1.87-2.97,4.67-2,7.19,3.17,8.23-.69,15.5-7.22,20.39-2.27,2.33-3.26,5.96-4.66,8.69l-14.97-1.08-1.98.67c-1.17.4-1.4,6.07-1.2,10.28l-5.97.64c.01,1.31.89,2.27,1.35,3.52l-3.55,5.37c-5.53-.62-8.28,7-13.89,10.9-1.98,1.37-3.73-.25-5.2-.67-1.79-.51-3.25-.83-4.12-2.65-1.17.63-2.28.31-3.09,0-.93-.36-1.45-1.42-1.58-2.63l-1.24-12.07-.54-21.41-3.37,26.93-2.32,11.58c-1.35-.6-2.56-.79-3.93-.64-2.87.32-5.45-.38-8.31-.41-1.55-.02-1.88-1.53-3.11-3.07l2.02-2.29c-1.1-2.21-3.48-2.22-5.28-3.2-.75-.41-1.27-1.25-2.14-1.2-2.2.13.67,6.44-2.89,6.37l-6.18-.11c-.97-.02-1.52-.97-1.46-1.38l.36-2.39c-2.16-3.31-6.64-4.97-10.34-6.05l-4.61-1.35,9.71-11.1c1.03-1.17,2.56-2.27,2.2-3.91l-10.97,8.35c-4.57-1.25-8.28-3.55-8.21-8.02-.63-.81-1.9-1.86-1.64-2.9.76-2.95,3.93-2.39,4-6.01.03-1.5-1.05-2.01-2.21-3.07-1.5-1.37-2.59-3.79-4.86-4.28-1.89-.41-6.44-4.24-7.02-6.09-1.01-3.25,2.85-5.63.92-8.2l8.46-2.25,18.21-3.46.55,22.34-4.07-1.09c-.62-.17-1.15.71-1.26,1.11-.71,2.66,6.57,4.49,6.46,4.96-.06.26-.4.99-.55.77-.26-.37-.68-.81-1.29-.89l-2.57-.33c-.63-.08-1.82,2.04-.78,2.4,13.51,4.72,30.52,4.52,40.19-6.04,4.26-4.65,7-10.96,5.44-17.11-1.88-7.43-8.07-12.69-15.99-13.65,4.61-3.97,8.07-9.52,6.06-15.48s-7.82-9.61-13.78-10.33c-13.8-1.66-26.43,7.86-23.9,11.48l1.82.27c.83.77.9,1.54,1.58,1.28l2.21-.86-.14,17.59-10.69.46-11.02-.53c-3.1-.15-5.69-2.07-8.52-1.76l-2.44-19.92c-1.31-2.93-.76-6.46,2.46-7.96,3.42-1.59,4.59-3.37,5.99-2.92.03-2.33-2.37-3.38-4.36-4.69l-.32-6.53c-5.46-9.7-.92-17.59-1.51-28.43l16.23,2.03,23.56-.02,16.48-1.23c2.97-.22,5.24-1.39,7.61.8ZM185.54,293.84c2.42-3.82,5.39-7.88,3.02-9.23s-3.38,6.71-11.7,9.93c-4.18,1.62-8.99.92-12.68-1.81-3.38-2.5-5.13-6.55-6.15-11.09-3.58-15.97,6.89-36.59,18.87-32.85,6.82,2.13,4.36,12.37,8.6,8.12.92-1.52.02-3.6.33-5.64l2.51,3.38c1.54-.91,1.02-2.87.63-4.33-1.61-6.12-7.34-9.87-13.12-11.04-6.3-1.28-11.96,1.46-16.42,6.15-6.23,6.56-9.49,15.21-11.29,23.96-2.95,14.33,3.3,32.61,19.18,34.63,11.19,1.43,22.11-4.68,26.13-15.27.19-.49.04-1.09.04-1.41,0-.73-2.81-.21-2.68-.3-1.54,1.13-1.96,6.72-5.28,6.8Z"/>
|
||||
<path class="cls-3" d="M74.46,278.72c1.93,2.57-1.93,4.94-.92,8.2.57,1.85,5.13,5.69,7.02,6.09,2.27.49,3.37,2.91,4.86,4.28,1.15,1.06,2.24,1.57,2.21,3.07-.07,3.62-3.24,3.06-4,6.01-.26,1.03,1.01,2.08,1.64,2.9-.07,4.47,3.65,6.77,8.21,8.02l10.97-8.35c.36,1.64-1.18,2.73-2.2,3.91l-9.71,11.1,4.61,1.35c3.7,1.08,8.18,2.75,10.34,6.05l-.36,2.39c-.06.4.49,1.36,1.46,1.38l6.18.11c3.56.07.69-6.24,2.89-6.37.86-.05,1.39.79,2.14,1.2,1.8.99,4.18.99,5.28,3.2l-2.02,2.29c1.23,1.55,1.56,3.06,3.11,3.07,2.87.03,5.44.73,8.31.41,1.37-.15,2.57.04,3.93.64l2.32-11.58,3.37-26.93.54,21.41,1.24,12.07c.12,1.21.65,2.27,1.58,2.63.81.32,1.92.64,3.09,0,.86,1.82,2.33,2.14,4.12,2.65,1.47.42,3.22,2.04,5.2.67,5.6-3.9,8.36-11.52,13.89-10.9l3.55-5.37c-.47-1.25-1.34-2.21-1.35-3.52l5.97-.64.85,18.06-.17,3.08c.67-.03,1.46-.09,2.22.11l-1.57,4.51-.4,1.16-.74-.15-9.48-.44-13.17-.04c-5.51-.02-10.81.2-15.91-2.37-4.17,3.3-8.82,3.85-13.75,4.04l-11.57.44c-4.22.16-8-1.47-12.1-2.44l-9.23-2.18-2.37-2.53-1-1.14c-.19-.22-.42.16-1.49,0l-2.88,3.29c-1.64,3.38-4.27,3.48-7.57,2.93l-8.76-1.47c-1.06-.18-1.68-1.56-2.63-2.24l-.25-10.04c-.09-3.55-2.33-6.92-2.12-10.04l.33-4.87,1.13-9.81c.44-3.77.39-7.85,0-11.53l-.45-4.37c-.88-2.66-.01-5.19,1.15-7.52l3.1-6.18c.48,0,.8.42,1.38.27Z"/>
|
||||
<path class="cls-3" d="M219.05,227.87l.79.78-.21.25.6.14-.13.88-1.61,30.29-6.75,7.18,6.67,3.15,1.66,35.78c.09,1.99,0,4.05-1.49,5-.19.12-.4.28-.59.29l.1.11-.1-.11-3.17.11c-.29-.45-.37-1.65-1.08-1.62l-5.37.21c-2.72.1-5.69.2-8.32.01,1.39-2.73,2.38-6.36,4.66-8.69,6.53-4.89,10.39-12.17,7.22-20.39-.97-2.52-1.28-5.32,2-7.19-2.53-3.36-6.25-4.51-10.11-3.3l-31.72-.18,24.02-4.56,9.59-1.55,7.95-3.92c-1.51-4.02-.8-7.7,1.18-11.55l-2.14-4.34c-2.26.84-3.21-1-4.4-1.82-3.04-2.1-3.3-4.8-3.93-7.95-.28-1.42-3.04-2.89-2.3-4.95l3.85-.57,8.82.23c.74-.32,1.1.42,1.09-.02-.02-.58.56-1.21.18-1.42l2.43.46.62-.74Z"/>
|
||||
<path class="cls-3" d="M188.5,197.92c-.14-.14-.26-.35-.42-.36.16.02.28.22.42.36l-.8,2.07.09,1.09-1.96.21c.42,5.17.06,10.03-.5,14.87l-1.28-.36c-1.42-1.53-3.13-3.22-5.23-2.46-1.55.57-3.44,1.37-4.18,3.47-2.84-4.2-5.79-4.07-5.91-4.7l-.47-2.59-2.94-4.65c-.61-2.29-3.19-3.03-5.2-2.41-.92,4.36-3.08,5.48-3.96,8.34-.45,1.46-2.64.96-3.8.95-2.51,0-1.87-2.85-5.82-4.74-.37-1.66.1-3.32.01-4.91-.66.27-1.08-.08-1.86-.21l.39,15.14-.69,6.77.31,26.42-5.43-30.42c-.91-5.11-1.2-10.05-4.36-14.26l-2.73-6.11,1.53.32c.22.05.68-.6.86-1.01,2.07,1.91,4.3,4.1,6.23,6.93l3.69-7.11,37.01-2.68c2.54-1.25,5.05-.41,6.58,1.69.16.02.28.22.42.36Z"/>
|
||||
<path class="cls-6" d="M217.99,311.6l-.39.42-33.59,33.88-.77.03,1.57-4.51c2.75-7.94,3.11-16.57,1.63-25.15-.21-1.2,1.02-2.43,2.16-2.43l11.01.06c5.33.03,10.37-.9,15.23-2.19l3.17-.11Z"/>
|
||||
<path class="cls-3" d="M219.05,227.87l-.62.74-2.43-.46c-8.06-2.53-16.09-3.31-24.73-2.08-1.96.28-2.28-4.19-1.56-5.38.71-6.92-.38-13.29-1.92-19.6l-.09-1.09.8-2.07c-.14-.14-.26-.35-.42-.36.16.02.28.22.42.36l30.55,29.94Z"/>
|
||||
<path class="cls-2" d="M101.08,269.43l.04,3.58-18.21,3.46-8.46,2.25c-.58.15-.9-.27-1.38-.27l2.44-3.67c-.82-2.73-4.33-4.52-4.66-7.18,2.83-.32,5.42,1.61,8.52,1.76l11.02.53,10.69-.46Z"/>
|
||||
<path class="cls-1" d="M101.08,269.43l.14-17.59-2.21.86c-.67.26-.75-.52-1.58-1.28l-1.82-.27c-2.52-3.62,10.1-13.15,23.9-11.48,5.96.72,11.76,4.34,13.78,10.33s-1.45,11.51-6.06,15.48c7.92.96,14.11,6.22,15.99,13.65,1.56,6.15-1.18,12.46-5.44,17.11-9.67,10.56-26.68,10.76-40.19,6.04-1.04-.36.15-2.48.78-2.4l2.57.33c.61.08,1.03.52,1.29.89.15.22.49-.51.55-.77.11-.47-7.17-2.29-6.46-4.96.11-.41.64-1.28,1.26-1.11l4.07,1.09-.55-22.34-.04-3.58ZM124.8,255.68c1.58-3.1.42-5.63-2.19-7.08-3.93-2.19-8.3-2.63-13.25-1.34v16.49c0,.62.35,1.06.86,1.37.26.15.45-.22,1.12-.47,5.49-1.09,10.87-3.89,13.45-8.97ZM106.69,248.96l-1.41-.03.17,8.81c.23-.03.44-.47.56-.3l.68-8.48ZM129.95,257.42c.65-1.58.8-4.23,1.16-5.86-1.76-.16-.51-1.16-.52-1.51,0-.22-.65-.01-1.42-.09,1.09,3.53.8,7.12-1.99,10.11,1.03.81,1.77-.21,1.99-.75l.77-1.9ZM133.96,284.81c.89-6.65-3.22-11.63-9.46-12.87-5.07-1.01-9.78.28-14.97,1.72l.7,23.53c5.81,1,11.07-.12,16.57-2.3,3.82-1.51,6.61-6.02,7.16-10.08ZM105.85,278.54c-.96,2.12-1.03,4.41.28,6.27.22-1.99.88-3.84-.28-6.27ZM138.89,279.18h-1.19s.2,6.6.2,6.6c.2-.03.41-.45.51-.3.97-1.95.1-4,.47-6.3Z"/>
|
||||
<path class="cls-4" d="M185.54,293.84c3.32-.08,3.73-5.67,5.28-6.8-.13.1,2.68-.43,2.68.3,0,.32.14.92-.04,1.41-4.02,10.58-14.94,16.69-26.13,15.27-15.88-2.03-22.13-20.3-19.18-34.63,1.8-8.74,5.06-17.4,11.29-23.96,4.45-4.69,10.12-7.43,16.42-6.15,5.78,1.17,11.5,4.93,13.12,11.04.38,1.46.9,3.42-.63,4.33l-2.51-3.38c-.3,2.04.6,4.12-.33,5.64-4.24,4.25-1.78-5.98-8.6-8.12-11.98-3.75-22.45,16.87-18.87,32.85,1.02,4.54,2.77,8.58,6.15,11.09,3.68,2.73,8.49,3.43,12.68,1.81,8.32-3.22,9.32-11.28,11.7-9.93s-.59,5.41-3.02,9.23Z"/>
|
||||
<path class="cls-2" d="M187.78,201.08c1.54,6.31,2.64,12.68,1.92,19.6-.72,1.2-.4,5.66,1.56,5.38,8.64-1.23,16.67-.45,24.73,2.08.38.2-.2.83-.18,1.42.02.44-.35-.31-1.09.02l-8.82-.23-3.85.57c-1.54-.27-2.98.32-4.27.46l-12.32,1.33c-1.29-.51-1.5-3.9-1.34-5.26l1.2-10.28c.57-4.84.93-9.7.5-14.87l1.96-.21Z"/>
|
||||
<path class="cls-2" d="M200.05,310.31c2.64.19,5.6.09,8.32-.01l5.37-.21c.71-.03.79,1.17,1.08,1.62-4.86,1.29-9.9,2.22-15.23,2.19l-11.01-.06c-1.13,0-2.36,1.22-2.16,2.43,1.48,8.57,1.13,17.21-1.63,25.15-.76-.2-1.55-.15-2.22-.11l.17-3.08-.85-18.06c-.2-4.21.03-9.88,1.2-10.28l1.98-.67,14.97,1.08Z"/>
|
||||
<path class="cls-6" d="M133.96,284.81c-.55,4.07-3.34,8.57-7.16,10.08-5.5,2.17-10.76,3.29-16.57,2.3l-.7-23.53c5.2-1.44,9.9-2.72,14.97-1.72,6.24,1.24,10.35,6.22,9.46,12.87Z"/>
|
||||
<path class="cls-6" d="M124.8,255.68c-2.58,5.08-7.97,7.88-13.45,8.97-.67.25-.86.62-1.12.47-.51-.3-.87-.74-.87-1.37v-16.49c4.96-1.3,9.32-.85,13.25,1.34,2.61,1.45,3.76,3.98,2.19,7.08Z"/>
|
||||
<path class="cls-2" d="M129.95,257.42l-.77,1.9c-.22.54-.96,1.56-1.99.75,2.79-3,3.08-6.58,1.99-10.11.77.08,1.42-.13,1.42.09,0,.36-1.24,1.36.52,1.51-.36,1.63-.51,4.27-1.16,5.86Z"/>
|
||||
<path class="cls-6" d="M106.69,248.96l-.68,8.48c-.13-.17-.33.27-.56.3l-.17-8.81,1.41.03Z"/>
|
||||
<path class="cls-2" d="M138.89,279.18c-.37,2.3.5,4.35-.47,6.3-.1-.15-.32.27-.51.3l-.2-6.6h1.19Z"/>
|
||||
<path class="cls-2" d="M105.85,278.54c1.16,2.43.51,4.28.28,6.27-1.31-1.86-1.24-4.15-.28-6.27Z"/>
|
||||
<path class="cls-5" d="M220.24,229.03l-.6-.14-1.21-.28-2.43-.46c-8.06-2.53-16.09-3.31-24.73-2.08-1.96.28-2.28-4.19-1.56-5.38.71-6.92-.38-13.29-1.92-19.6l-.09-1.09"/>
|
||||
<path class="cls-5" d="M76.87,236.81c.03-2.33-2.37-3.38-4.36-4.69l-.32-6.53c-5.46-9.7-.92-17.59-1.51-28.43l16.23,2.03,23.56-.02,16.48-1.23c2.97-.22,5.24-1.39,7.61.8,2.07,1.91,4.3,4.1,6.23,6.93l3.69-7.11,37.01-2.68c2.54-1.25,5.05-.41,6.58,1.69.16.02.28.22.42.36l30.55,29.94.79.78.4.39"/>
|
||||
<path class="cls-5" d="M220.24,229.03l-.13.88-1.61,30.29-6.75,7.18,6.67,3.15,1.66,35.78c.09,1.99,0,4.05-1.49,5l-.49.39-.5.31-33.59,33.88-1.17,1.19"/>
|
||||
<path class="cls-5" d="M182.84,347.08l-.74-.15-9.48-.44-13.17-.04c-5.51-.02-10.81.2-15.91-2.37-4.17,3.3-8.82,3.85-13.75,4.04l-11.57.44c-4.22.16-8-1.47-12.1-2.44l-9.23-2.18-2.37-2.53"/>
|
||||
<path class="cls-5" d="M182.84,347.08l.4-1.16,1.57-4.51c2.75-7.94,3.11-16.57,1.63-25.15-.21-1.2,1.02-2.43,2.16-2.43l11.01.06c5.33.03,10.37-.9,15.23-2.19l3.17-.11"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,47 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #006d30;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #00873e;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2.75px;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #3a160a;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #3d180d;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #00a04b;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-6" d="M153.66,195.96l-.46-.48-2.25.71,3.07,18.95.16,18.83c.02,1.82-1.11,3.58.04,5.52.86-1.54,1.21-3.1,1.51-4.81l4.63-26.09c.51-2.89.63-6.32-1.68-7.91,3.44-.33,1.92-4.68,4.63-6.02,1.06-.53,2.58-.34,3.85-.57l.57-.11.09,1.97c-1.23.02-2.43-.35-3.03.56,1.72-.21,4.69,1.64,3.78,3.07-1.78,2.8-2.55,5.21-1.44,8.53.2.59-.73,1.95-.05,2.92.42.6,1.37.66,2.45,1.35l.89,1.5c.22.37,1.97.27,1.95-.45-.06-2.39-4.42-4.98-2.14-5.98.89-.39,1.59-.33,2.08.47l3.78,6.21,3.2-1.66,6.99.65c-.45,5.85-2.86,9.72.48,15.56l8.02-1.38c4.46-.77,3.87,5.08,6.83,6.44-.37,2.19.28,4.05,1.64,6.45-6.86,3.73-8.55.88-9.78,1.54-2.84,1.52-2.36,6.78,0,8.07,2.28-5.08,5.7-2.99,8.83-5.88l3.1,3.95c.13.5-.52.62-.77.88-.49.54-1.76-.53-2.23-.28-1.18.63-.37,1.39-.08,1.77.39.5,2.4.95,4.35,1.17.61,1.74-1.58,2.73-2.4,3.52.4,2.18,3.63,1.16,5.22,1.12,1.87-.05,3.1,2.33,5.08,1.26l-.3,8.25c-7.48,2.34-14.03,3.49-21.14,4.16l-23.27,2.2c.09.33.06.66.03.83.7.61,1.85-.06,2.86-.16l30.5,2.09c2.57.18,5.12.05,7.54,1.41-6.47,3.24-11.29,0-12.25.87-.59.54-1.48,2.27-.13,2.72.91.3,1.87.35,2.3.69,1.2.95-1.2,5.83,1.12,6.74.78.3,2.4-.64,2.93.56.31.71.23,2.04-.94,2.69l-1.39,1.83c-1.39.83-1.28,1.3-1.07,2.69.24,1.57,1.51,1.36,3.53.89v3.28s4.12.29,4.12.29l.1,2.95c-4.9.84-9.59,2.46-12.77,6.65l-8.81-1.19c-7.44-1-2.19,11.29-2.46,19.21l-4.89-.16c-2.63-.09-6.35,0-7.58-2.4,1.86-2.75-1.3-5.62-.12-9.66-1.36-.25-2.17-1.48-3.52-1.43-2.58,1.83-5.92,2.68-6.55,5.79-.14.7,1.35,1.6.78,2.39-.88,1.22-5.4.29-5.27,3.28.14,3.32,3.47,1.65,4.91,3.68-1.55,1.93-3.69,4.09-5.51,5.41-.67.04-2.1-.08-2.75-.12l-7.79-.48-1.91-10.68-.87-20.54c-.02-.38.98-.89.38-1.07-.21-.06-.37-.3-.58-.52-.15-.16-.64.62-.67.95l-1.71,16.74c-.72,7.06-2.02,13.71-4.34,20.67-4.06.6-8.21-.87-12.16-2.67-.9-.41-2.41-.75-2.22-2.2.46-.15,1.4-.34,1.49-.82.06-.36.32-1.32-.43-1.33l-9.76-.09c-.43,0-.7-1.15-.36-1.47.61-.58,1.76-.61,2.03-1.46.31-.96.25-1.75.13-3.16l-14.56.05c-3.39.01-6.59.16-10.04-.64l12.59-14.11c-.78-.51-1.89.32-2.63,1.01-4.11,3.83-8.08,7.52-13.71,8.92.38-1.11,1.48-1.61,2.04-2.49l-1.39-4.29c-2.64.06-2.8-2.85-3.9-3.74-2.66-2.14-5.75-3.87-5.86-7.47-.13-4.69,5.58-4.68,4.87-7.46-1.89-.09-2.79-.22-3.53-1.67-2.76-5.4-5.55-10.74-6.13-16.95l.26-3.84c1.78-1.26,3.89-1.22,6.04-1.61l19.34-3.5c-7.89-1.44-15.42-1.63-22.88-4.1l-3.59-.27c-1.87-2.75-1.64-5.92-2.07-9l-1.2-8.62-.43-6.7c-.53-8.33-.99-6.87,3.53-10.65l3.35-2.87.41.38-.41-.38-6.25-5.77,1.99-24.45,29.01,2.92c.3,2.58-.26,4.92.22,7,.52-.09,1.76-.17,2.02.58.15.44.4.77.33,1.31l3.48,3.71c1.73,1.85,4.57,3.61,5.86,5.97,1.68,3.06,3.62,8.54,4.74,8.09.28-.11.94-.29,1.08-1.02.16-.85-.78-2.14-.85-3.37l-.49-9.68c-1.06-2.47-3.15-4.98-2.29-7.94l7.42-2.99.74-.79c.25-.26.12-.53-.1-.59-.25-.07-.86-.46-1.49.09l-.35-1.99,28.3-2.23c1.58-.12,3.1-.91,3.9,1l.46.48ZM186.99,284.59c.05-.8-.21-2.93-.89-3.46-1.03-.8-2.09-.08-2.67.96-2.92,5.19-8.02,9.43-14.12,9.6-10.92.3-16.23-12.43-14.77-24.11,1.5-12,9.42-26.8,19.63-23.04,6.4,2.36,4.16,10.81,8.14,8.64,1.03-.56,1.08-3.75.59-6.65,1.56,1.07,1.13,2.55,1.92,3.73.35.51,1.07.12,1.41-.26,1.54-1.69-1.42-11.67-11.3-14.47-18.44-5.21-31.37,21.26-30.31,39.87.46,8.02,4.55,18.71,12.73,22.74,12.45,6.13,27.46.08,33.05-12.33.29-.63.45-1.54.17-1.99s-.96-1.1-1.65-.81c-.59.25-1.21.83-1.92,1.58ZM101.07,245.27c-.97-.28-1.36,2.51-.27,3.04.84.41,1.82.3,3.43.02l-.02,15.84.52,28.42c.02,1.26-3.05.8-3.99,2.17-.37.54-.43,1.53.14,1.9.48.31,1.1.34,2.1.38l1.74.07c.21,0-.06,1.21-.78.9-1.53-.66-2.37,1.04-2.06,2.1.54,1.85,3.63,1.46,6.98.54.54.72.85,2.18,1.64,2.22.74.04,1.45-.09,1.73-.57.43-.71.32-1.49.26-2.52l13.46-1.99,15.07-1.37c.5-.05,1.21-.93,1.18-1.32-.02-.33-.33-.94-.83-.93l-6.64.11v-.92s7.21-.58,7.21-.58c.67-.05,1.07-1,1.09-1.41s-.74-1.12-1.09-1.32l-2.18-1.26c-7.01-.15-13.57.87-20.29,1.77l-6.33.85-.28-18.72c1.4-.01,2.1-.11,2.8-.21l14.99-2.18c1.84-.27,2.77-2.22,2.67-4.08-1.51-.62-2.4-.02-3.68-.25.35-1.33,1.22-1.8.93-2.28-.46-.75-.97-1.35-1.81-1.22l-16.69,2.55v-17.6c8.76-1.96,17.15-2.99,25.82-3.25.13,0,3.38-2.78,2.38-4.14-.56-.77-2.05-.63-3.18-.4-.1-1.28.99-1.47.68-1.91-.6-.86-1.04-1.38-1.97-1.52-8.39.05-16.34,1.7-24.78,2.69-.47-.45-.8-1.64-1.29-1.61-.68.04-.97.86-1.59,1.72-.59-.64-1.13-1.26-1.75-1.19-.85.11-1.69,0-1.57,1.15.13,1.23-2,1.2-2.96,1.52l-2.84.93c-1.36.44-1.02,1.83-.51,2.86l5.73-.22-1.66.49c-.18.57-.19,1.1-1.51.72Z"/>
|
||||
<path class="cls-2" d="M189.09,192.96c.05.24.06.17.06-.01l.12.59,1.48,13.12.05,15.42c9.75-1.12,18.49.18,27.17,2.92l.66-.85,1.15,1.22.02.65-.12,6.48c-.06,3.28-.1,6.15-.34,9.4l-1.49,20.28c-.14,1.92.53,4.27-.65,6.12l-5.41,3.97,6.02,6.36,1.49,21.56c.37,5.41,2.59,8.94-3.33,12.4.34.27.29,1.3.03,1.53s-.16.34-.97.66c-.37,1.66-2.22,1.95-3.2,2.95l-20.55,20.92c-1.09,1.11-1.62,2.3-2.99,2.91-1.44.64-2.89,1.38-4.34,2.88l-11.83-.23-21.18-.57c-2.81-.08-5.69-.28-7.16-3.49l-2.05-.16c-3.58,7.25-10.93,5.75-18.03,6.21-3.56.23-7.24,1.2-10.75.28l-13.96-3.64c-3.32-.87-4.74-4.5-6.28-4.44-1.22.05-1.49,1.13-2.18,1.92l-3.06,3.53c-1.81,2.08-4.75,1.11-7.01.74l-7.91-1.31c-.88-.14-1.36-1.64-2.36-2.17.88-6.23-.67-11.82-2.56-17.57-.32-.99.84-5.27,1.01-8.28l.29-4.88.53-6.71.52-27.67,1.82-3.79c.68.02,1.42.1,2.03.75l-.26,3.84c.58,6.2,3.37,11.55,6.13,16.95.74,1.45,1.64,1.58,3.53,1.67.71,2.78-5.01,2.77-4.87,7.46.1,3.59,3.19,5.33,5.86,7.47,1.1.88,1.26,3.8,3.9,3.74l1.39,4.29c-.55.88-1.66,1.38-2.04,2.49,5.64-1.4,9.6-5.08,13.71-8.92.74-.69,1.84-1.52,2.63-1.01l-12.59,14.11c3.44.8,6.65.65,10.04.64l14.56-.05c.11,1.41.17,2.2-.13,3.16-.27.85-1.42.88-2.03,1.46-.33.32-.07,1.46.36,1.47l9.76.09c.76,0,.5.97.43,1.33-.08.47-1.03.66-1.49.82-.2,1.44,1.31,1.79,2.22,2.2,3.95,1.79,8.1,3.27,12.16,2.67,2.32-6.96,3.62-13.61,4.34-20.67l1.71-16.74c.03-.33.52-1.11.67-.95.21.23.36.46.58.52.6.17-.4.69-.38,1.07l.87,20.54,1.91,10.68,7.79.48c.64.04,2.08.16,2.75.12,1.82-1.32,3.96-3.47,5.51-5.41-1.45-2.03-4.77-.36-4.91-3.68-.13-2.98,4.38-2.06,5.27-3.28.57-.79-.92-1.69-.78-2.39.63-3.11,3.98-3.96,6.55-5.79,1.36-.05,2.16,1.19,3.52,1.43-1.18,4.04,1.98,6.9.12,9.66,1.23,2.39,4.95,2.31,7.58,2.4l4.89.16c.28-7.91-4.98-20.21,2.46-19.21l8.81,1.19c3.18-4.19,7.87-5.81,12.77-6.65l-.1-2.95-4.12-.29v-3.28c-2.02.46-3.28.68-3.52-.89-.21-1.38-.32-1.86,1.07-2.69l1.39-1.83c1.17-.65,1.25-1.97.94-2.69-.53-1.2-2.15-.26-2.93-.56-2.32-.9.09-5.78-1.12-6.74-.43-.34-1.39-.39-2.3-.69-1.35-.45-.46-2.18.13-2.72.95-.87,5.78,2.37,12.25-.87-2.42-1.36-4.97-1.23-7.54-1.41l-30.5-2.09c-1,.11-2.15.77-2.86.16.03-.17.07-.51-.03-.83l23.27-2.2c7.11-.67,13.66-1.83,21.14-4.16l.3-8.25c-1.98,1.07-3.21-1.31-5.08-1.26-1.6.04-4.83,1.06-5.22-1.12.82-.79,3.01-1.78,2.4-3.52-1.94-.22-3.96-.67-4.35-1.17-.3-.39-1.1-1.14.08-1.77.47-.25,1.75.82,2.23.28.24-.27.9-.39.77-.88l-3.1-3.95c-3.13,2.9-6.55.8-8.83,5.88-2.36-1.3-2.84-6.55,0-8.07,1.24-.66,2.93,2.18,9.78-1.54-1.36-2.4-2.01-4.25-1.64-6.45-2.96-1.36-2.37-7.21-6.83-6.44l-8.02,1.38c-3.34-5.83-.94-9.71-.48-15.56l-6.99-.65-3.2,1.66-3.78-6.21c-.48-.79-1.18-.86-2.08-.47-2.29,1,2.08,3.59,2.14,5.98.02.72-1.73.82-1.95.45l-.89-1.5c-1.07-.69-2.02-.75-2.45-1.35-.68-.97.25-2.32.05-2.92-1.11-3.33-.34-5.73,1.44-8.53.91-1.43-2.05-3.28-3.78-3.07.6-.91,1.8-.54,3.03-.56l-.09-1.97-.57.11.57-.11,13.46-1.04c2.81-.22,5.44-.4,7.96,0,0,.25,0,.18-.06.01Z"/>
|
||||
<path class="cls-6" d="M189.09,192.96c.13-.05.29.17.44.4l29.1,30.8-.66.85c-8.68-2.74-17.43-4.04-27.17-2.92l-.05-15.42-1.48-13.12-.12-.59c0,.18-.01.25-.06.01Z"/>
|
||||
<path class="cls-2" d="M121,196.71l.35,1.99c.63-.55,1.25-.16,1.49-.09.22.06.34.33.1.59l-.74.79-7.42,2.99c-.86,2.97,1.23,5.48,2.29,7.94l.49,9.68c.06,1.23,1,2.52.85,3.37-.14.73-.8.91-1.08,1.02-1.11.45-3.05-5.03-4.74-8.09-1.29-2.35-4.13-4.12-5.86-5.97l-3.48-3.71c.07-.54-.17-.88-.33-1.31-.26-.75-1.5-.67-2.02-.58-.48-2.08.07-4.42-.22-7,3.74.38,6.19,6.49,8.76,5.54,2.12-.78.47-6.55,7.47-6.94l4.09-.23Z"/>
|
||||
<path class="cls-1" d="M153.66,195.96c1.59,1.69,3.16,3.44,5.02,4.72,2.32,1.59,2.2,5.02,1.68,7.91l-4.63,26.09c-.3,1.71-.65,3.27-1.51,4.81-1.16-1.94-.03-3.7-.04-5.52l-.16-18.83-3.07-18.95,2.25-.71.46.48Z"/>
|
||||
<path class="cls-1" d="M73.83,272.96c-.61-.65-1.35-.72-2.03-.75l3.58-4.84-2.64-3.89,3.59.27c7.46,2.46,14.99,2.66,22.88,4.1l-19.34,3.5c-2.15.39-4.26.34-6.04,1.61Z"/>
|
||||
<path class="cls-4" d="M101.07,245.27c1.32.37,1.33-.16,1.51-.72l1.66-.49-5.73.22c-.51-1.03-.85-2.41.51-2.86l2.84-.93c.97-.32,3.09-.29,2.96-1.52-.12-1.15.72-1.05,1.57-1.15.61-.08,1.16.55,1.75,1.19.62-.86.91-1.67,1.59-1.72.49-.03.82,1.16,1.29,1.61,8.43-1,16.39-2.64,24.78-2.69.93.14,1.37.67,1.97,1.52.31.44-.79.63-.68,1.91,1.13-.23,2.61-.37,3.18.4,1,1.35-2.25,4.13-2.38,4.14-8.67.26-17.05,1.29-25.81,3.25v17.6s16.68-2.55,16.68-2.55c.84-.13,1.36.47,1.81,1.22.29.47-.58.95-.93,2.28,1.28.22,2.17-.38,3.68.25.1,1.86-.83,3.81-2.67,4.08l-14.99,2.18c-.7.1-1.4.2-2.8.21l.28,18.72,6.33-.85c6.72-.9,13.28-1.91,20.29-1.77l2.18,1.26c.35.2,1.11.91,1.09,1.32s-.42,1.36-1.09,1.41l-7.21.58v.92s6.65-.11,6.65-.11c.5,0,.81.59.83.93.02.4-.69,1.28-1.18,1.32l-15.07,1.37-13.46,1.99c.07,1.03.17,1.8-.26,2.52-.29.48-.99.61-1.73.57-.79-.04-1.1-1.5-1.64-2.22-3.36.92-6.44,1.31-6.98-.54-.31-1.06.53-2.77,2.06-2.1.72.31,1-.89.78-.9l-1.74-.07c-.99-.04-1.62-.08-2.1-.38-.57-.37-.51-1.36-.14-1.9.94-1.37,4.01-.91,3.99-2.17l-.52-28.42.02-15.84c-1.61.28-2.59.39-3.43-.02-1.08-.53-.7-3.32.27-3.04Z"/>
|
||||
<path class="cls-5" d="M186.99,284.59c.71-.75,1.33-1.33,1.92-1.58.69-.29,1.36.35,1.65.81s.12,1.36-.17,1.99c-5.59,12.41-20.6,18.47-33.05,12.33-8.18-4.03-12.28-14.72-12.73-22.74-1.06-18.61,11.87-45.08,30.31-39.87,9.88,2.79,12.84,12.78,11.3,14.47-.34.38-1.07.77-1.41.26-.8-1.18-.36-2.66-1.92-3.73.49,2.89.44,6.09-.59,6.65-3.98,2.17-1.74-6.28-8.14-8.64-10.22-3.76-18.14,11.04-19.63,23.04-1.45,11.68,3.85,24.41,14.77,24.11,6.1-.17,11.2-4.41,14.12-9.6.59-1.04,1.65-1.76,2.67-.96.68.53.94,2.66.89,3.46-.24,0-.82-.26-1.15.32l-2.56,4.45.51.54c1.45-1.23,3.14-4.22,3.13-4.06l.07-1.24Z"/>
|
||||
<path class="cls-1" d="M186.99,284.59l-.07,1.24c0-.16-1.68,2.83-3.13,4.06l-.51-.54,2.56-4.45c.33-.57.91-.32,1.15-.32Z"/>
|
||||
<path class="cls-3" d="M219.78,225.37l-1.81-.37c-8.68-2.74-17.43-4.04-27.17-2.92l-.05-15.42-1.48-13.12-.19-.58"/>
|
||||
<path class="cls-3" d="M219.78,225.37l.02.65-.12,6.48c-.06,3.28-.1,6.15-.34,9.4l-1.49,20.28c-.14,1.92.53,4.27-.65,6.12l-5.41,3.97,6.02,6.36,1.49,21.56c.37,5.41,2.59,8.94-3.33,12.4-7.99.64-14.33,1.69-23.27.55-5.4-.69-.74,10.29-4.65,22.7-.68,2.15-.83,4.01.26,5.73"/>
|
||||
<path class="cls-3" d="M189.15,192.95c-2.52-.41-5.15-.22-7.96,0l-13.46,1.04-.57.11c-1.27.24-2.79.05-3.85.57-2.71,1.35-1.19,5.7-4.63,6.02-1.86-1.28-3.42-3.03-5.02-4.72l-.46-.48c-.8-1.91-2.32-1.12-3.9-1l-28.3,2.23-4.09.23c-7.01.39-5.35,6.16-7.47,6.94-2.57.94-5.02-5.17-8.76-5.54l-29.01-2.92-1.99,24.45,6.25,5.77.41.38.41.37"/>
|
||||
<polyline class="cls-3" points="189.15 192.95 189.53 193.36 218.63 224.15 219.78 225.37"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #39170a;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2.75px;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #3d180b;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #a88a21;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #d3ac2c;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #ffcf34;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-6" d="M179.77,205.02l.77,4.04c.04.21.1.45.08.59.02-.14-.04-.38-.08-.59-.85,1.19-1.1-.58-2.67-.25l.79,8.42-.89,10.4c-.41,4.82-1.24,9.97,1.41,14.45,1.05,1.78,9.63-.31,17.16-.75,1.02,1.74,4.84.68,6.63,4.83.88,2.04,2.91,3.45,3.86,5.38,1.4,2.81,2.25,5.73,4.84,7.45,1.02-.82,1.88-.07,2.66-.28l.07,6.27c.03,2.58.2,4.71-.47,6.93-.75.09-2.1-.23-2.94.31-2.87,1.83-6.08,2.33-9.57,2.78l-6.5.84-16.99.61c-3.66.13-7.28-.69-11.12,1.38l16.47,2.61,22.92,3.07,7.37.46c.9,1.94.62,4.11.83,6.29l1.64,17c.53,5.45-8.08,6.66-5.27,8.93,2.01,1.62,5.55,12.81,5.16,16.41l-2.13,19.73c-8.18,1.27-16.4,2.09-24.72,1.88l-6.37-8.74c-.46-.63-1.12-1.06-1.64-1.08-.67-.03-1.56.51-2.14,1.26l-5.23,6.74-16.53,1.49c-2.64.24-5.37.61-7.42-.64l-.56-.34c.37,0,.52-.6.99-.96.23-.18-.52-.27-.45-.87l-1.23-17.67c-.63-9.07-.23-17.95-2.92-26.95l-2.35,17.78-4.21,27.18,3.26.18c-1.16,2.56-3.95,2.42-6.56,2.51l-13.67.49-16.87,1.08c-.05-.45-.49-.46-.65-.67l-.4-.38-.4-.38-.4-.38.58-.54.2.56c2.01-.21.19-3.36.22-9.43,0-2.13-.72-4.28.05-6.44.35-.98,2.07-.69,2.87-.56.43-1.64-1.95-1.67-2.13-2.33l.95-10.42c.09-.94.1-1.71-.31-2.02-1.64-1.25-3.51.15-5.41-.22.06-.7-.29-1.6-.74-2.1-.64-.71-1.77-.71-2.91-.62l-12.92,1.07.18-1.34s-.25-1-.63-1.37l-3.78-3.63c-1.39-1.34-2.03-2.83-1.58-5.16l-3.26-3.67c-1.51-1.7-2.88-4.39-.81-6.29,1.1-.32,3.6.81,4.46-.47,2.86-4.24-1.31-5.58-.49-7.74,1.33-3.48-4.58-6-4.26-8.5l13.01-2.5c3.67-1.59,6.78-3.8,10.59-3.22l.1,32.81c0,1.01,1.11,1.93,1.68,2.01,2.03.27,1.28-3.03,2.08-6.24,2.13.46-1.02,4.51,1.57,8.01.45.61,1.46,0,1.84-.26.62-.41.57-1.41.77-2.39l1.09-.2c.09-.02.41-.44.4-.93l-.43-14.35-.45-23.53c-.01-.52-.02-1.19.34-1.48.58-.46,1.53-.32.75.63l8.86,15.58,13.62,24.51c.37.67,1.86,1.46,2.5,1.41.85-.07,1.58-1.73,1.05-2.73-.13-1.47.35-1.05,1.48-.63,1.09-.52,1.88.67,2.48.34.91-.51,1.18-1.31,1.03-2.48-1.7-13.24-2.12-26.26-1.41-39.55l.91-17.03c.05-.93-.07-1.64-.43-2.01-.49-.49-1.44-.78-1.91-.55-.75.36-1.15,1.14-1.13,2.02l.19,7.68h-.86s-.48-6.72-.48-6.72c-.08-1.09-1.4-1.77-2.14-1.88-1.31-.2-2.03,1.16-2.03,2.59l-.14,40.7c-1.18.02-.63-.49-.97-1.13-7.29-13.26-14.57-26.19-20.99-39.77-.21-.45-.86-.62-1.11-.66-.4-.05-.83.64-1.21.96l-.5-2.31c-.16-.75-1.63-1.47-2.45-.7-1.35,1.27-.5,3.3-.84,4.65-1.25-.1-.52-1.14-.93-1.47-.51-.4-1.14-.55-2.26-.76l-.37,3.69c-2.89-2.03-5.73-4.3-9.04-6.32-.72-.34-2.29.61-2.32,1.39-.03,1.15-.89,2.45.54,3.02l10.66,3.95.41,19.15-14.09-.14c-5.14-.17-9.74-2.39-14.67-4.07-.85.62-1.32,1.36-2.39.69l-1.67-18.92c-.18-2.03.78-3.84,2.65-4.71l7.42-3.44-5.93-5.13.16-5.46c-1.39-3.37-3.58-6.69-3.35-10.39l1.05-16.95c.05-.8-.56-1.46-.02-2.1s1.18.05,2.02.15l15.08,1.79,14.48.61,27.87-1.53c2.78-.15,4.85.21,6.45,2.28-.81,1.03-1.69-.01-3.26,0l4.13,7.93c4.58,11.64,3.97,31.83,8.32,46.37l-.45-23.47,1.02-30.81c-.29-.23-.77.13-1.3.05-.68-.1.4-.6.29-1.64l-.63.29.63-.29,16.21-1.53,9.43-.31c4.27-.14,8.52-1.66,12.53-.39l.66.23c-.03.16-.03.22-.03-.04ZM183.3,260.1c.11.72.67,3.38,2.19,2.35,1.76-7.21-5.95-14.22-14.14-15.14-14.41-1.63-23.52,15.84-26.38,29.41-2.46,11.72.92,27.85,11.7,33.33,3.81,1.94,8.55,3.2,12.6,2.71,17.38-2.11,23.73-18.78,19.2-17.57-1.69.45-1.96,3.32-2.63,3.58-2.37.93.71-2.54.04-4.79-.24-.81-1.17-1.31-1.71-.96-2.42,1.56-3.83,8.02-11.84,10.21-7.31,2.01-14.11-2.14-16.6-9.5-6.23-18.48,5.85-40.89,17.78-36.88,5.29,1.78,5.29,9.24,6.76,9.31,2.39.13,2.79-4.52,2.18-6.12l.85.05Z"/>
|
||||
<path class="cls-5" d="M97.21,275.95c.18.35.62.79.03.98-3.81-.59-6.93,1.63-10.59,3.22l-13.01,2.5c-.32,2.5,5.59,5.02,4.26,8.5-.82,2.16,3.35,3.5.49,7.74-.86,1.28-3.37.15-4.46.47-2.07,1.9-.69,4.59.81,6.29l3.26,3.67c-.45,2.32.19,3.82,1.58,5.16l3.78,3.63c.38.37.64,1.34.63,1.37l-.18,1.34c-5.9.49-11.78-1.34-17.6-2.56,0,.16,0,.27,0,.02l-.69-.73c.06-1.46-.73-4.12-.11-5.67.88-2.2.34-3.61.48-5.56l1.71-23.37c.25-3.49-1.22-7.09-1.53-10.53,1.07.67,1.54-.07,2.39-.69,4.93,1.69,9.53,3.9,14.67,4.07l14.09.14Z"/>
|
||||
<path class="cls-6" d="M83.8,320.81l12.92-1.07c1.13-.09,2.26-.09,2.91.62.45.5.8,1.4.74,2.1-.91,10.45-.01,20.93,3.56,30.87l-.58.54-36.74-35.2-.4-.41c5.82,1.22,11.7,3.05,17.6,2.56Z"/>
|
||||
<path class="cls-6" d="M179.77,205.02c0,.18,0,.24.03.04l34.21,33.44-.49.54-1.92-.35c-.22-.04-.45-.09-.64-.11l-2.66-.27c-13.98-3.98-24.18.77-25.99-.65-.73-.57-.59-1.87-.82-2.93,1.42-8.61.58-16.85-.87-25.09.02-.14-.04-.38-.08-.59l-.77-4.04Z"/>
|
||||
<path class="cls-5" d="M149.77,353.23l-.56-.34-3.01-2.1c-1.49-1.04-3.18-.66-3.91.8l-3.26-.18,4.21-27.18,2.35-17.78c2.69,9,2.29,17.87,2.92,26.95l1.23,17.67c-.07.59.69.68.45.87-.47.37-.62.97-.99.96l.56.34Z"/>
|
||||
<path class="cls-5" d="M140.36,207.35l.63-.29c.11,1.04-.97,1.54-.29,1.64.53.08,1.01-.28,1.3-.05l-1.02,30.81.45,23.47c-4.34-14.54-3.74-34.72-8.32-46.37l-4.13-7.93c1.57-.02,2.45,1.02,3.26,0,1.15,1.48,3.05,3.31,5.03,3.99l3.08-5.27Z"/>
|
||||
<path class="cls-5" d="M213.94,271.89c-1.02,3.42-5.64,5.2-4.81,6.04l3.77,3.83c.8.81.39,1.56.68,2.19l-7.37-.46-22.92-3.07-16.47-2.61c3.84-2.08,7.46-1.25,11.12-1.38l16.99-.61,6.5-.84c3.49-.45,6.7-.95,9.57-2.78.84-.53,2.19-.22,2.94-.31Z"/>
|
||||
<path class="cls-5" d="M214.34,258.7c-.77.21-1.64-.54-2.66.28-2.59-1.73-3.44-4.65-4.84-7.45-.96-1.92-2.99-3.34-3.86-5.38-1.79-4.15-5.61-3.09-6.63-4.83l14.51-.85c-.03-.62-.15-1.25.1-1.89.19.02.43.07.64.11l1.92.35.49-.54.4.39c.14.14.38.23.4.39l.1.72c.48,3.66-.57,7.44-.57,11.31v7.39Z"/>
|
||||
<path class="cls-1" d="M96.95,254.77l.37-3.69c1.12.21,1.75.37,2.26.76.42.33-.32,1.37.93,1.47.34-1.34-.52-3.38.84-4.65.82-.77,2.29-.05,2.45.7l.5,2.31c.37-.32.81-1.01,1.21-.96.25.03.9.2,1.11.66,6.43,13.58,13.7,26.51,20.99,39.77.33.64-.22,1.15.97,1.13l.14-40.7c0-1.44.72-2.79,2.03-2.59.75.11,2.07.79,2.14,1.88l.48,6.73h.86s-.19-7.69-.19-7.69c-.02-.88.38-1.66,1.13-2.02.47-.23,1.41.06,1.91.55.36.36.48,1.08.43,2.01l-.91,17.03c-.71,13.29-.29,26.3,1.41,39.55.15,1.17-.12,1.97-1.03,2.48-.6.34-1.39-.86-2.48-.34-1.13-.42-1.61-.84-1.48.63.53,1-.2,2.66-1.05,2.73-.63.05-2.13-.74-2.5-1.41l-13.62-24.51-8.86-15.58c.78-.95-.17-1.09-.75-.63-.36.29-.35.96-.34,1.48l.45,23.53.43,14.35c.01.49-.31.91-.4.93l-1.09.2c-.2.98-.16,1.98-.77,2.39-.38.26-1.39.87-1.84.26-2.59-3.5.56-7.55-1.57-8.01-.8,3.22-.05,6.51-2.08,6.24-.58-.08-1.68-1-1.68-2.01l-.1-32.81c.59-.19.15-.63-.03-.98l-.41-19.15c-.01-.67.09-1.35.16-2.03ZM131.61,294.87c.56.17.86.1,1.15.05l-.14-5.48h-.88s-.12,5.43-.12,5.43Z"/>
|
||||
<path class="cls-3" d="M183.3,260.1l-.32-2.09c-.11-.72-1.11-1.15-.97-1.75l-3.04-3.05c-.51-.51-.92-.45-1.63-.42,2.53,1.87,4.03,4.39,5.11,7.26.61,1.6.21,6.25-2.18,6.12-1.47-.08-1.46-7.54-6.76-9.31-11.93-4-24.01,18.41-17.78,36.88,2.48,7.36,9.28,11.51,16.6,9.5,8-2.2,9.41-8.66,11.84-10.21.54-.35,1.47.15,1.71.96.66,2.24-2.41,5.72-.04,4.79.67-.26.94-3.13,2.63-3.58,4.53-1.21-1.83,15.46-19.2,17.57-4.05.49-8.79-.77-12.6-2.71-10.78-5.48-14.16-21.61-11.7-33.33,2.86-13.58,11.96-31.05,26.38-29.41,8.2.93,15.9,7.93,14.14,15.14-1.52,1.03-2.08-1.63-2.19-2.35Z"/>
|
||||
<path class="cls-4" d="M180.62,209.65c1.45,8.24,2.29,16.48.87,25.09.23,1.06.09,2.35.82,2.93,1.81,1.42,12.02-3.33,25.99.65l2.66.27c.19.02.43.07.64.11-.22-.04-.45-.09-.64-.11-.26.64-.13,1.27-.1,1.89l-14.51.85c-7.53.44-16.11,2.54-17.16.75-2.65-4.48-1.82-9.62-1.41-14.45l.89-10.4-.79-8.42c1.56-.33,1.82,1.45,2.67.25.04.21.1.45.08.59Z"/>
|
||||
<path class="cls-4" d="M100.37,322.45c1.9.37,3.77-1.03,5.41.22.41.31.4,1.07.31,2.02l-.95,10.42c.18.66,2.56.69,2.13,2.33-.8-.12-2.52-.42-2.87.56-.77,2.16-.04,4.31-.05,6.44-.03,6.07,1.79,9.23-.22,9.43l-.2-.56c-3.58-9.94-4.47-20.41-3.56-30.87Z"/>
|
||||
<path class="cls-5" d="M96.95,254.77c-.07.68-.17,1.36-.16,2.03l-10.66-3.95c-1.43-.57-.57-1.87-.54-3.02.02-.78,1.6-1.73,2.32-1.39,3.31,2.02,6.15,4.3,9.04,6.32Z"/>
|
||||
<path class="cls-4" d="M183.3,260.1l-.85-.05c-1.08-2.87-2.59-5.4-5.11-7.26.71-.03,1.12-.09,1.63.42l3.04,3.05c-.15.6.86,1.03.97,1.75l.32,2.09Z"/>
|
||||
<path class="cls-4" d="M131.61,294.87l.12-5.43h.88s.14,5.47.14,5.47c-.29.05-.59.12-1.15-.05Z"/>
|
||||
<path class="cls-2" d="M179.77,205.02l-.62-.2c-4-1.27-8.26.25-12.53.39l-9.43.31-16.21,1.53-.63.29-3.08,5.27c-1.99-.68-3.89-2.51-5.03-3.99-1.6-2.07-3.66-2.43-6.45-2.28l-27.87,1.53-14.48-.61-15.08-1.79c-.84-.1-1.46-.81-2.02-.15s.07,1.3.02,2.1l-1.05,16.95c-.23,3.7,1.96,7.02,3.35,10.39l-.16,5.46,5.93,5.13-7.42,3.44c-1.86.87-2.83,2.67-2.65,4.71l1.67,18.92c.3,3.43,1.78,7.04,1.53,10.53l-1.71,23.37c-.14,1.96.39,3.36-.48,5.56-.62,1.55.17,4.21.11,5.67l.69.73"/>
|
||||
<path class="cls-2" d="M214.8,239.27l-1.28-.24-1.92-.35c-.22-.04-.45-.09-.64-.11l-2.66-.27c-13.98-3.98-24.18.77-25.99-.65-.73-.57-.59-1.87-.82-2.93,1.42-8.61.58-16.85-.87-25.09.02-.14-.04-.38-.08-.59l-.77-4.04"/>
|
||||
<path class="cls-2" d="M104.54,355.01l-.41-1.13-.2-.56c-3.58-9.94-4.47-20.41-3.56-30.87.06-.7-.29-1.6-.74-2.1-.64-.71-1.77-.71-2.91-.62l-12.92,1.07c-5.9.49-11.78-1.34-17.6-2.56"/>
|
||||
<polyline class="cls-2" points="179.8 205.06 214.01 238.49 214.41 238.88 214.8 239.27"/>
|
||||
<path class="cls-2" d="M214.8,239.27l.1.72c.48,3.66-.57,7.44-.57,11.31v7.39s.07,6.27.07,6.27c.03,2.58.2,4.71-.47,6.93-1.02,3.42-5.64,5.2-4.81,6.04"/>
|
||||
<polyline class="cls-2" points="66.21 318.28 66.6 318.66 103.34 353.86 103.74 354.24 104.14 354.63 104.54 355.01"/>
|
||||
<path class="cls-2" d="M210.79,316.19c2.01,1.62,5.55,12.81,5.16,16.41l-2.13,19.73c-8.18,1.27-16.4,2.09-24.72,1.88l-6.37-8.74c-.46-.63-1.12-1.06-1.64-1.08-.67-.03-1.56.51-2.14,1.26l-5.23,6.74-16.53,1.49c-2.64.24-5.37.61-7.42-.64l-.56-.34-3.01-2.1c-1.49-1.04-3.18-.66-3.91.8-1.16,2.56-3.95,2.42-6.56,2.51l-13.67.49-16.87,1.08-.65-.67"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9.6 KiB |
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #9b1f0f;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #3a160a;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #e93525;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #3d180d;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #c12b1c;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2.2px;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-3" d="M141.35,207.84c-.15-.1-.38-.08-.57-.14-.5.53.56,1.73-.35,2.63,2.2,6.52,2.73,13.17,3.12,20.16l1.18,20.95c.3-.07.67-.04.84-.02.7-.89-.2-2.18-.08-3.4l3.51-37.22c.07-.78-.41-1.44-.5-1.88l19.5-2.14c1.96.84,1.89,4.16,2.43,5.54-.19-.36-1.21.01-1.82.21-.39.13.11.72-.4,1.5.58,10.03-3.74,18.54-4.28,28.36,3.88-6.54,14.81-30.94,15.78-31.51,1.1-.65,1.54.47,2.43.78,1.28.45,3.34-.37,3.73,1.34.25,1.09,1.66.7,2.44,1.56-.46,1.49-1.33,2.83-1.48,4.39,3.42.29,7.69-.88,11.02.3,2.37.84-.91,3.18-1.22,4.37.17,4.04,4.98,4.47,10.21,3.99.82.43.92,2.65,2.23,2.64l-4.61,3.64c4.87-.22,8.3,1.84,11.15,1.44l-.61,4.42c-.69.13-1.97-.34-2.8.24-6.06,4.22-12.42,7.63-18.95,11.04l-6.29,4.39c4.87.92,9.07-1.38,13.76-2.48,3.75-.88,7.71-1.15,11.61-1.17,1.47,0,3.02,1.79,4.47.65l-1.19,12.44c-.25,2.67-.6,5.14-1.34,7.45-.83-.98-1.63-.31-2.63.07-6.58,2.5-13.24,3.89-20.13,5l-17.39,2.78c1.75,1.3,3.52.34,5.24.34h32.08c-1.46,8.73-7.23,6.14-7.99,8.08-1.21,3.1,2.92,5.92,1.74,7.9s-3.15,3.27-4.07,5.7c2.08.02,5.13-3.57,7.39-2.41.77.39,1.39,1.77,1.11,3.19-3.36,4.96-9.42,5.44-8.61,13.35l-.74,3.75c-1.58.06-2.1,1.03-2.21,2.08l-12.24-1.78c-1.47-.21-4.87-.09-4.63,1.94l1.6,13.76c.17,1.43.76,2.77-.25,3.79l-5.27,2.54-2.14.63-1.93-1.16c-6.66,3.78-4.71,7.07-15.02,6.31l-11.95,2.56-1.39-7.83c-3.15-7.67-3.81-15.27-3.95-23.41l-.15-8.71c-.38.04-.44-.34-.87-.53l-1.62,20.27c-.21,2.63-.8,4.53-1.38,7.39l-20.37-.61c-2.55-.08-4.58-3.05-7.89-1.97-.6-2.94-2.88-3.36-5.33-3.82.34-4.53,4.71-6.78,3.51-8.96l-7.49,7.73-1.13-2.09c-1.15-2.13-1.4-5.14-4.15-6.09-.73-1.72-2.1-3.05-4.58-2.9-.17-1.75-2.45-1.79-2.86-2.71-.24-.54.43-.87-.61-.57-.2.06-3.02-3.72-3.62-7.19-3.05-.69-.25-4.22-2.63-4.24,3.16-3.19-3.84-1.11-4.81-3.41-.81-1.91-.04-3.39.74-4.96.26-.51-.56-4.29,2.44-9.1,6.33-2.1,12.4-3.33,18.8-4.23.25-.04.88.64,1.17.5.34-.16.76-.41.89-1.14l-22.09-1.45-1.42-.42c.68.2-2.27-4.82-.93-7.13,1.88-.95,4.57-.3,6.65-.77.49-.11,4.68-5.32,4.6-4.85.15-.81-.35-1.49-.9-1.62-1.08-.25-1.49,2.01-5.22,2.29-1.4.11-3.82-3.52-3.86-3.9-.09-.81.64-.76,1.09-1.56,3.25-5.81,2.03-5.22,1.61-9.67-.13-1.35-1.69-2.4-.19-3.82,2.65-2.49,5.15-5.35,6.94-8.32.25-.1.64-.51,1.11-.47l13.86,1.31c3.55-2.76,1.46-9.93,1.27-15.68l-.49-14.85c.39-.69.33-1.35.37-1.85.06-.7-.98-1.5-1.55-.94l.55-2.93.11-.58.22-1.16-.39.4.39-.34c4.32.66,9.46,1.37,13.92,1.69l14.97,1.06c3.88.27,7.66-.03,11.21,1.08.19.06.42.03.57.14ZM102.09,290.2l.93,21.66c.06,1.44-.52,3.82,1.01,4.64.98.52,2.05,1,2.45.38l1.25-1.93c.34-.52-.14-1.15.09-1.4.18-.19.64-.12.85-.03.32.12.23.16.26.39.08.53-.64.83-.5,1.16.41.96,1.63,1.3,2.11.96.43-.3,1.27-1.06,1.24-1.95l-.87-22.21c8.59-.6,16.52-3.59,22.83-8.98,7.59-6.48,9.09-17.7,2.59-25.38s-21.36-8.59-30.78-5.14c-.19-1.38-.35-2.08-.8-2.15l-2.07-.29-.32,3.56c-9.69,4.25-8.22,8.19-6.55,7.13.37-.23.75-.79,2.02-.94l-1.7,2.61c-.33.5-.28,1.36.15,1.65.29.2,1.12.33,1.58,0l4.17-3.06-.03,24.78c-1.77.34-3.13-1.12-4.55-.4-1.95.98.56,3.2,4.63,4.93ZM183.9,295.43c-2.54-1.64-4.08,8.31-13.8,10.5-5.05,1.14-10.5-1.01-13.47-5.57-4.78-7.32-5.07-16.43-2.58-24.6,3.13-10.28,10.83-20.05,19.27-15.53,5.81,3.12,2.84,9.38,6.76,8.1.76-.25,1.62-3.66.55-6.94,1.78.38,1.35,3.89,2.76,4.16,2.82.54.95-11.97-10.88-15.11-17.71-4.7-30.43,20.87-29.61,38.76.36,7.72,2.8,15.24,8.44,20.4,5.98,5.47,14.37,6.55,22.33,4.17,9.83-2.94,16.54-13.86,14.45-15.95-.52-.53-1.42-.6-1.75-.07l-.88,1.4c-.87.17-.68.78-.87.89-1.52.83,1.03-3.48-.73-4.61Z"/>
|
||||
<path class="cls-5" d="M100.35,205.56l-.55,2.93c-1.08,5.75-2.71,11.68-2.27,17.86.25,3.54.85,6.96.03,10.68-8.89.06-16.45-2.38-25.75.39-.64.15-.28,1.16-.01,1.44.24.26.66-.03,1.36.22l11.41,1.34c.18.5.36.67.67.55-1.8,2.97-4.29,5.82-6.94,8.32-1.5,1.41.06,2.46.19,3.82.42,4.45,1.64,3.85-1.61,9.67-.45.81-1.18.76-1.09,1.56.04.39,2.46,4.01,3.86,3.9,3.73-.29,4.14-2.55,5.22-2.29.56.13,1.05.81.9,1.62.09-.47-4.1,4.74-4.6,4.85-2.08.48-4.77-.18-6.65.77-1.35,2.31,1.61,7.33.93,7.13l1.42.42,22.09,1.45c-.13.74-.55.98-.89,1.14-.29.14-.91-.53-1.17-.5-6.4.9-12.47,2.12-18.8,4.23-3,4.81-2.18,8.59-2.44,9.1-.78,1.57-1.55,3.04-.74,4.96.97,2.29,7.97.21,4.81,3.41,2.38.02-.42,3.55,2.63,4.24.6,3.47,3.42,7.25,3.62,7.19,1.04-.3.38.02.61.57.4.92,2.69.96,2.86,2.71,2.48-.16,3.85,1.18,4.58,2.9,2.75.95,3,3.96,4.15,6.09l1.13,2.09,7.49-7.73c1.2,2.18-3.16,4.43-3.51,8.96,2.45.45,4.73.87,5.33,3.82,3.31-1.07,5.34,1.9,7.89,1.97l20.37.61c.58-2.85,1.17-4.75,1.38-7.39l1.62-20.27c.43.19.49.57.87.53l.15,8.71c.14,8.14.8,15.74,3.95,23.41l1.39,7.83,11.95-2.56c10.32.76,8.36-2.53,15.02-6.31l1.93,1.16,2.14-.63,5.27-2.54c.4-.02.63.4.83.64.16.18-.17.6-.2,1.04l-.67,10.12c.82-.14,1.5-.45,2.27,0,2.81-6.65,3.02-13.87,2.67-21.4.15-.92-.09-2.34.39-2.85.45-.48,1.84-1.22,2.79-1.11,7.02.82,13.69.47,20.29-1.35,1.03.54,2.17.4,3.18.19.08.61.02,1.31-.5,1.83l-14.08,13.99c-3.04,3.02-6.73,5.26-9.07,8.88l-3.1,3.24c-1.03-1.01-2.03-.45-2.82-.97l-2.06,4.85-35.37-1.32c-4.34-1.79-6.54-8.04-7.73-7.92s-2.8,6.38-5.48,6.54l-10.41.64c-5.38.33-10.15.72-15.49,2.19-3.14.87-7.62.86-10.83.08-2.95-1.18-3.85-5.14-6.2-6.63-2.58.57-3.1,5.36-5.17,6.12s-15.98-.69-16.94-1.64c-1.04-1.02-1.66-2.74-1.93-4.36l-1.46-8.74c-.26-1.57-1.4-2.96-.05-5l.25-3.57c1.08-2.07.21-4.64.52-7.05l1.27-9.71c.24-1.86.5-4.12-.13-5.95l-.38-7.85c-.09-1.91-.47-3.76-.13-5.55l1.4-7.4c.42-2.24,1.09-4.38,1.82-6.51-.7-1.43-2.17-2.49-2.25-3.91l-.41-7.84-.51-7.74-.6-8.71-.3-7.65c-.86-4.02-1.08-8.37,2.47-11.45.28-.11.66-.01.92.02l30.19-31.05.84.55Z"/>
|
||||
<path class="cls-5" d="M215.61,235.34c-2.85.4-6.28-1.66-11.15-1.44l4.61-3.64c-1.3,0-1.41-2.21-2.23-2.64-5.23.47-10.04.05-10.21-3.99.31-1.19,3.58-3.53,1.22-4.37-3.33-1.18-7.6,0-11.02-.3.14-1.57,1.01-2.91,1.48-4.39-.78-.86-2.19-.47-2.44-1.56-.39-1.71-2.45-.89-3.73-1.34-.89-.32-1.33-1.43-2.43-.78-.97.57-11.91,24.97-15.78,31.51.54-9.82,4.86-18.33,4.28-28.36.51-.78,0-1.37.4-1.5.61-.2,1.63-.57,1.82-.21.52,1.34,1.39,2.32,2.25,3.36l.35.42-.35-.42c2.99-4.01,4.32-9.78,6.79-11.04,1.99-1.01,6.98.63,10.05.99,2.25.26,4.67-.67,7.13.45l5.57.52c4.86.45,13.52,1.2,14.2,1.84.95.89.45,1.58.67,2.86,1.43,8.35.85,16.35-1.48,24.06Z"/>
|
||||
<path class="cls-5" d="M214.27,272.31c-.4,1.25-2.02,2.41-1.74,4.07l2.61,3.32,1.18,21.98,1.27,18.14c.18,2.6-1.32,4.68-3.5,5.32-1,.22-2.14.35-3.18-.19.51-.44.48-1.12.22-2.05-4.36.28-8.77-.13-13.07-.76.11-1.05.62-2.02,2.21-2.08l.74-3.75c-.81-7.92,5.25-8.4,8.61-13.35.28-1.42-.34-2.79-1.11-3.19-2.26-1.16-5.3,2.44-7.39,2.41.92-2.43,2.85-3.65,4.07-5.7s-2.95-4.8-1.74-7.9c.76-1.94,6.53.65,7.99-8.08h-32.08c-1.72,0-3.48.96-5.24-.34l17.39-2.78c6.89-1.1,13.55-2.49,20.13-5,1-.38,1.8-1.05,2.63-.07Z"/>
|
||||
<path class="cls-1" d="M148.5,208.91c.09.44.57,1.1.5,1.88l-3.51,37.22c-.12,1.22.79,2.51.08,3.4-.17-.02-.55-.05-.84.02l-1.18-20.95c-.39-6.98-.92-13.64-3.12-20.16.91-.89-.15-2.09.35-2.63.19.06.42.03.57.14,2.12,1.41,4.66,1.34,7.15,1.07Z"/>
|
||||
<path class="cls-5" d="M215,239.76c-.37,2.69-1.06,4.75-4.09,5.88,0,1.06-1.32,1.19-1.19,1.7.25,1,1.38,1.43,2.04,1.7,1.6.65,5.23,1.29,5.03,3.38-1.45,1.14-3-.66-4.47-.65-3.89.02-7.85.3-11.61,1.17-4.69,1.1-8.9,3.39-13.76,2.48l6.29-4.39c6.53-3.41,12.89-6.83,18.95-11.04.83-.58,2.12-.11,2.8-.24Z"/>
|
||||
<path class="cls-2" d="M102.09,290.2c-4.07-1.73-6.58-3.95-4.63-4.93,1.42-.72,2.78.75,4.55.4l.03-24.78-4.17,3.06c-.46.33-1.29.2-1.58,0-.43-.29-.48-1.15-.15-1.65l1.7-2.61c-1.27.15-1.65.71-2.02.94-1.68,1.06-3.15-2.89,6.55-7.13l.32-3.56,2.07.29c.45.06.61.77.8,2.15,9.43-3.46,24.09-2.78,30.78,5.14s5,18.9-2.59,25.38c-6.31,5.39-14.24,8.38-22.83,8.98l.87,22.21c.03.89-.81,1.65-1.24,1.95-.48.34-1.7,0-2.11-.96-.15-.34.58-.64.5-1.16-.04-.23.06-.27-.26-.39-.21-.08-.67-.16-.85.03-.23.25.25.88-.09,1.4l-1.25,1.93c-.4.62-1.47.15-2.45-.38-1.53-.82-.95-3.2-1.01-4.64l-.93-21.66ZM131.84,268.69c-.17-9.31-11.39-12.74-20.83-10.93-.29,9.08-1.11,17.3-.24,26.28,9.58-2.04,21.23-6.33,21.06-15.35Z"/>
|
||||
<path class="cls-4" d="M183.9,295.43c1.76,1.14-.79,5.45.73,4.61.19-.11,0-.72.87-.89l.88-1.4c.33-.52,1.23-.45,1.75.07,2.08,2.09-4.63,13.02-14.45,15.95-7.96,2.38-16.35,1.3-22.33-4.17-5.64-5.17-8.09-12.68-8.44-20.4-.82-17.89,11.9-43.46,29.61-38.76,11.84,3.14,13.7,15.66,10.88,15.11-1.41-.27-.98-3.78-2.76-4.16,1.07,3.28.2,6.69-.55,6.94-3.93,1.28-.95-4.98-6.76-8.1-8.45-4.53-16.14,5.24-19.27,15.53-2.49,8.17-2.2,17.28,2.58,24.6,2.98,4.56,8.43,6.7,13.47,5.57,9.71-2.19,11.26-12.13,13.8-10.5ZM177.02,256.36l3.08,4.12c1.58-.8-.62-4.44-3.08-4.12Z"/>
|
||||
<path class="cls-1" d="M198.07,322.14c4.3.63,8.71,1.03,13.07.76.25.92.29,1.6-.22,2.05-6.61,1.82-13.27,2.17-20.29,1.35-.95-.11-2.34.63-2.79,1.11-.48.51-.23,1.94-.39,2.85.34,7.53.14,14.75-2.67,21.4-.77-.46-1.44-.14-2.27,0l.67-10.12c.03-.44.36-.86.2-1.04-.2-.24-.43-.66-.83-.64,1.02-1.02.42-2.37.25-3.79l-1.6-13.76c-.24-2.03,3.16-2.15,4.63-1.94l12.24,1.78Z"/>
|
||||
<path class="cls-1" d="M85.24,240.96c-.31.13-.49-.05-.67-.55l-11.41-1.34c-.71-.24-1.12.04-1.36-.22-.26-.28-.63-1.29.01-1.44,9.3-2.77,16.87-.34,25.75-.39.82-3.72.22-7.14-.03-10.68-.44-6.17,1.19-12.1,2.27-17.86.58-.57,1.61.24,1.55.94-.04.5.02,1.16-.37,1.85l.49,14.85c.19,5.76,2.28,12.92-1.27,15.68l-13.86-1.31c-.47-.04-.87.37-1.11.47Z"/>
|
||||
<path class="cls-3" d="M131.84,268.69c.17,9.02-11.48,13.31-21.06,15.35-.87-8.98-.05-17.2.24-26.28,9.44-1.81,20.65,1.63,20.83,10.93Z"/>
|
||||
<path class="cls-1" d="M177.02,256.36c2.46-.32,4.66,3.32,3.08,4.12l-3.08-4.12Z"/>
|
||||
<path class="cls-6" d="M211.77,249.04c1.6.65,5.23,1.29,5.03,3.38l-1.19,12.44c-.25,2.67-.6,5.14-1.34,7.45-.4,1.25-2.02,2.41-1.74,4.07l2.61,3.32,1.18,21.98,1.27,18.14c.18,2.6-1.32,4.68-3.5,5.32-1,.22-2.14.35-3.18-.19-6.61,1.82-13.27,2.17-20.29,1.35-.95-.11-2.34.63-2.79,1.11-.48.51-.23,1.94-.39,2.85.34,7.53.14,14.75-2.67,21.4-.09.1-.19.28-.26.44l-2.06,4.85-35.37-1.32c-4.34-1.79-6.54-8.04-7.73-7.92s-2.8,6.38-5.48,6.54l-10.41.64c-5.38.33-10.15.72-15.49,2.19-3.14.87-7.62.86-10.83.08-2.95-1.18-3.85-5.14-6.2-6.63"/>
|
||||
<path class="cls-6" d="M100.69,203.88c4.32.66,9.46,1.37,13.92,1.69l14.97,1.06c3.88.27,7.66-.03,11.21,1.08.19.06.42.03.57.14,2.12,1.41,4.66,1.34,7.15,1.07l19.5-2.14c1.96.84,1.89,4.16,2.43,5.54s1.39,2.32,2.25,3.36l.35.42"/>
|
||||
<path class="cls-6" d="M100.68,203.82l-.39.4-.78.79-30.19,31.05c.27,1.12,1.58,1.24,2.49,1.36,9.3-2.77,16.87-.34,25.75-.39.82-3.72.22-7.14-.03-10.68-.44-6.17,1.19-12.1,2.27-17.86l.55-2.93.11-.58.22-1.16Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #0db3c8;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #007988;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #0c96a8;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #3a170d;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2.2px;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: #3c1b0d;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-1" d="M214.65,239.1l-2.58-.1c-3.79,2.1-7.73,4.08-11.61,6.32l-6.56,3.79c-2.2,1.27-5.02,2.22-6.12,5.03l12.97-3.01,11.9-1.19c.44-.63.06-1.58.21-2.2,1.11.52,3.9,1.16,3.71,3.1l-1.82,18.71c-1.5,1.84-3.42,2.56-4.11,4.45-.39.04-.84-.28-1.1-.57-.2-.23.18-1.3-.21-1.23l-13.32,2.67-24.66,3.99c2.94.91,5.47.34,8.11.31l30.61-.32c.58,0,1.24.32,1.55.45s.06,1.13-.11,1.49l-5.94,12.1-2.34,5.89c1.31.57,2.15-1.03,3.19-1.23,1.71-.34,2.18,1.87,2.29,2.91.19,1.76-.21,2.53-1.63,3.75l-3.61,3.1c-2.25,1.93-2.51,5.48-3.11,8.25l-2.36,4.83-14.21-1.65c-1.16-.13-2.3.56-2.59,1.21-1.13,2.5,3.49,19.87,1.51,29.66l2.38-.32.4,1.88c-.02,1.11-.76,2.05-1.49,2.86-.9,1-2.37.8-3.88.8l-24.17.07c-3.64.01-6.98.15-10.19-1.01l-.55-.2c.38-.35-.12-1.32,1-2.02l-3.02-10.07c-.55-1.82-.65-4.03-.68-6.05l-.38-23.49c0-.35.21-1.3-.12-1.78-.19-.26,1.36-1.02-.61-1.17l-4.19,32.02c-.53,4.08-2.01,7.72-1.64,11.98,1.56-.87,2.59-.64,3.6-.38-1.06.63-1.9,2.74-3.63,3-8.84,1.34-17.96,1.85-26.77.44l-13.88-3.27c-1.56-1.74-2.19-3.54-3.98-4.71l-6.41,7.6c-5.24-.33-10.27-1.31-15.49-2.21-2.15-4.26-.12-10.16-1.62-14.2-1.64-4.39-1.98-8.67-1.04-13.21,1.9-9.16,1.37-17.8.49-27.02-.43-4.52,3.2-9.56,5.02-12.83l.8-.58.55-.55-.55.55-.8.58c.11.87,1.36,1.26,2.07.83.33-.2.87.26,1.48-.62l30.23-5.39c1.79-.32,6.53.08,5.59-1.14-.12-.15-.41-.79-.78-.81l-16.48-.56-11.54.04c-3.89-.56-7.57.1-11.44-1.87.21,1.09-.13,1.57-.76,1.25-.22-.11-.39.11-.92.03l-2.78-19.95c-.32-2.3-1.51-5.82-.08-7.61l6.94-4.85c.64-.45.02-1.82-.8-1.97-2.71-1.37-4.11-3.91-2.9-7.27-2.45-4.5-2.75-9.25-2.39-14.17l.97-13.21c.38-5.16,5.65-2.67,12.92-.43,3.25.54,6.3-.23,9.33-1.63,8.42.55,16.41-.06,24.8-.67l16.36-1.2,8.59,1.05c.2.02.38.14.56.21-.19-.07-.37-.18-.56-.21-.54,4.1,1.56,7.36,1.72,9.19l1.11,13.13.8,17.66-.2,8.37c.25,0,.57-.14.92.02l3.92-47.24-.57.22.57-.22,19.53-1.91c1.57.6,1.43,3.49,2.12,4.44.09.12.09.4.12.6-.03-.2-.03-.48-.12-.6l-2.15.91c.17,8.51-1.44,13.81-3.15,21.63l-2.13,9.75c2.92-2.92,3.58-6.64,5.27-10.02l10.81-21.63c.85-1.69.49-4.05,3.09-4.69-.5-.7-.42-1.53-.6-2.46,1.54.33,3.13-.5,4.85.26l5.72.4,10.88,1.12,6.13.57c2.16.2,6.59-.53,8.1,2.25.4,6.45,1.14,13.48-.1,19.79l-2.24,11.36ZM168.83,303.16c-6.94.18-12.13-5.11-13.68-11.47-1.72-5.03-2.28-10.32-.88-15.55,1.12-10.54,9.73-25.45,20.19-20.69,5.19,2.36,4.68,11.87,7.33,8.34-.13.18.87-3.46,1.06-3.05-.47-1.02-.68-2.77-.56-2.97.38-.62.92-.03.9.33-.01.16.1.13.26.77.43.68.42,1.72.95,1.95,3.46,1.48,1.8-9.41-6.67-13.59-5.47-2.7-10.86-3.08-15.84-.29-7.33,4.11-11.48,11.04-14.58,18.6-3.51,8.58-5.17,17.76-2.65,26.66,1.55,5.5,3.59,11.18,8.36,15.03,6.06,4.89,14.44,6.2,22.41,3.63,13.87-4.48,18.3-21.76,11.29-15.14-.28.27.49.89.09,1.03-.25.08-.69-.15-1.14.58-.32.51.58,1.26-.3,1.42l-1.48.26,1.93-3.85c.54-1.09.15-2.4-.76-2.62-3.3-.79-4.86,10.32-16.21,10.62ZM99.04,299.48l-2.33-4.68-3.47.03c-.04,6.06,3.08,10.63,7.43,14.01,4.93,3.83,10.3,5.14,16.54,4.87,10.18-.44,19.57-6.79,21.53-16.96,1.57-8.14-2.54-15.62-9.31-19.96-7.16-4.59-16.77-5.53-20.09-10.13-1.83-2.53-1.29-5.65.09-8.51,1.08-2.23,3.67-3.76,6.68-4.4,8.68-1.84,13.61,5.15,15,4.52.46-.2,1.46-.79,1.17-1.39l-1.53-3.17c2.36.38,3.63,3.38,4.81,1.76,1.42-1.94-9.35-13.75-24.73-9.15-8.64,2.58-13.65,10.74-11.83,19.53.96,4.64,3.52,8.84,8.3,10.67l12.22,4.68c6.12,2.34,10.3,8.86,8.33,15.46-1.45,4.86-6.35,7.18-11.25,7.51-5.75.39-12.14-2.11-13.82-7.94-.66-2.3-.6-5.38-4.17-5.51-.75,4.05,1.16,6.89.42,8.75ZM103.94,315l-7.75,6.79c.97.96,1.64.88,2.27.52-.28.16,5.09-5.27,5.48-7.31Z"/>
|
||||
<path class="cls-3" d="M210.63,274c-.34,2.98,4.2,2.59,4.27,3.76l2.51,42.37c.1,1.67-2.35,3.33-3.58,3.16l.11-2.9c-5.22.96-10.62.62-15.97,0l2.36-4.83c.6-2.77.86-6.32,3.11-8.25l3.61-3.1c1.42-1.22,1.82-1.99,1.63-3.75-.11-1.05-.58-3.25-2.29-2.91-1.04.2-1.88,1.81-3.19,1.23l2.34-5.89,5.94-12.1c.18-.36.42-1.36.11-1.49s-.97-.45-1.55-.45l-30.61.32c-2.64.03-5.17.59-8.11-.31l24.66-3.99,13.32-2.67c.39-.08,0,1,.21,1.23.25.29.71.61,1.1.57Z"/>
|
||||
<path class="cls-1" d="M213.84,323.29c.69.99.45,1.15-.19,1.78l-17.39,17.12c-3.16,3.11-6.44,5.57-9.4,9.05-.38.45-.97.07-1.39-.06l-.4-1.88c1.94-6.68,3.23-13.66,2.17-20.91-.22-1.52-.28-2.52.82-3.51.9-.81,1.78-.11,3.06-.13l14.27-.23c3.03-.05,5.64-1.6,8.45-1.22Z"/>
|
||||
<path class="cls-3" d="M69.29,278.12c.52.08.69-.14.92-.03.63.32.97-.16.76-1.25,3.87,1.96,7.55,1.31,11.44,1.87l11.54-.04,16.48.56c.38.01.66.65.78.81.93,1.22-3.8.82-5.59,1.14l-30.23,5.39c-.61.87-1.15.42-1.48.62-.71.43-1.96.04-2.07-.83l.8-.58.55-.55-.55.55.55-.55c.13-.13.21-.35.38-.39.47-.12.74-1.1.12-1.45-.29-.17-.73,0-.96-1.07-.92-1.33-3.2-2.39-3.45-4.18Z"/>
|
||||
<path class="cls-3" d="M145.75,353.91l-.55-.2c-1.16-.42-2.27-1.18-3.54-1.9-1.2-.68-1.89,1.02-2.51.94-1.01-.26-2.05-.49-3.6.38-.38-4.26,1.1-7.9,1.64-11.98l4.19-32.02c1.97.15.43.91.61,1.17.33.47.11,1.42.12,1.78l.38,23.49c.03,2.02.13,4.23.68,6.05l3.02,10.07c-1.11.7-.61,1.67-1,2.02l.55.2Z"/>
|
||||
<path class="cls-2" d="M181.3,203.36c.18.94.09,1.76.6,2.46-2.6.64-2.24,3-3.09,4.69l-10.81,21.63c-1.69,3.38-2.35,7.1-5.27,10.02l2.13-9.75c1.71-7.83,3.31-13.12,3.15-21.63l2.15-.91c.09.12.09.4.12.6.19,1.19.46,2.37,1.32,3.3.37-.75,1.52-.99,1.94-1.77l3.72-6.88c.54-1,.67-1.36,1.64-1.89,1.02-.56,1.31-.09,2.4.14Z"/>
|
||||
<path class="cls-3" d="M147.94,207.55l.57-.22-3.92,47.24c-.34-.16-.67-.01-.92-.02l.2-8.37-.8-17.66-1.11-13.13c-.15-1.82-2.26-5.09-1.72-9.19.2.02.38.14.56.21,2.24.82,4.78.99,7.14,1.13Z"/>
|
||||
<path class="cls-2" d="M214.65,239.1c-.45,2.26-2.11,3.94-3.91,5.65-.84.16-1.05.39-1.13.79-.15.82.08,1.71,1.03,1.66.76-.04,1.5.2,2.23.54-.16.62.22,1.57-.21,2.2l-11.9,1.19-12.97,3.01c1.1-2.81,3.92-3.76,6.12-5.03l6.56-3.79c3.88-2.24,7.82-4.22,11.61-6.32l2.58.1Z"/>
|
||||
<path class="cls-4" d="M99.04,299.48c.73-1.86-1.18-4.7-.42-8.75,3.57.13,3.51,3.2,4.17,5.51,1.68,5.83,8.07,8.33,13.82,7.94,4.9-.33,9.79-2.65,11.25-7.51,1.97-6.6-2.21-13.11-8.33-15.46l-12.22-4.68c-4.78-1.83-7.34-6.03-8.3-10.67-1.82-8.79,3.19-16.95,11.83-19.53,15.38-4.6,26.15,7.2,24.73,9.15-1.18,1.62-2.45-1.38-4.81-1.76l1.53,3.17c.29.6-.71,1.18-1.17,1.39-1.39.62-6.32-6.36-15-4.52-3.02.64-5.61,2.16-6.68,4.4-1.38,2.86-1.93,5.98-.09,8.51,3.33,4.59,12.93,5.53,20.09,10.13,6.77,4.34,10.89,11.82,9.31,19.96-1.96,10.18-11.36,16.52-21.53,16.96-6.25.27-11.61-1.04-16.54-4.87-4.35-3.38-7.47-7.95-7.43-14.01l3.47-.03,2.33,4.68Z"/>
|
||||
<path class="cls-6" d="M168.83,303.16c11.35-.3,12.91-11.42,16.21-10.62.9.22,1.3,1.53.76,2.62l-1.93,3.85,1.48-.26c.88-.16-.02-.91.3-1.42.46-.73.9-.5,1.14-.58.4-.14-.37-.76-.09-1.03,7.01-6.62,2.58,10.66-11.29,15.14-7.97,2.58-16.35,1.27-22.41-3.63-4.76-3.85-6.8-9.53-8.36-15.03-2.51-8.9-.86-18.09,2.65-26.66,3.1-7.56,7.25-14.49,14.58-18.6,4.98-2.79,10.38-2.41,15.84.29,8.47,4.18,10.13,15.07,6.67,13.59-.52-.22-.52-1.27-.95-1.95-.16-.64-.27-.62-.26-.77.02-.36-.52-.95-.9-.33-.12.2.09,1.95.56,2.97-.19-.41-1.19,3.23-1.06,3.05-2.65,3.53-2.14-5.98-7.33-8.34-10.46-4.76-19.07,10.15-20.19,20.69-1.4,5.23-.84,10.52.88,15.55,1.55,6.36,6.74,11.66,13.68,11.47Z"/>
|
||||
<path class="cls-2" d="M197.98,320.39c5.36.62,10.75.96,15.97,0l-.11,2.9c-2.81-.38-5.42,1.17-8.45,1.22l-14.27.23c-1.27.02-2.16-.67-3.06.13-1.1.99-1.04,1.99-.82,3.51,1.06,7.25-.23,14.24-2.17,20.91l-2.38.32c1.98-9.79-2.64-27.16-1.51-29.66.29-.65,1.43-1.35,2.59-1.21l14.21,1.65Z"/>
|
||||
<path class="cls-3" d="M103.94,315c-.39,2.04-5.76,7.47-5.48,7.31-.63.36-1.3.44-2.27-.52l7.75-6.79Z"/>
|
||||
<path class="cls-5" d="M210.64,247.2c.76-.04,1.5.2,2.23.54,1.11.52,3.9,1.16,3.71,3.1l-1.82,18.71c-1.5,1.84-3.42,2.56-4.11,4.45-.34,2.98,4.2,2.59,4.27,3.76l2.51,42.37c.1,1.67-2.35,3.33-3.58,3.16-2.81-.38-5.42,1.17-8.45,1.22l-14.27.23c-1.27.02-2.16-.67-3.06.13-1.1.99-1.04,1.99-.82,3.51,1.06,7.25-.23,14.24-2.17,20.91l.4,1.88c-.02,1.11-.76,2.05-1.49,2.86-.9,1-2.37.8-3.88.8l-24.17.07c-3.64.01-6.98.15-10.19-1.01l-.55-.2c-1.16-.42-2.27-1.18-3.54-1.9-1.2-.68-1.89,1.02-2.51.94-1.06.63-1.9,2.74-3.63,3-8.84,1.34-17.96,1.85-26.77.44l-13.88-3.27c-1.56-1.74-2.19-3.54-3.98-4.71l-6.41,7.6c-5.24-.33-10.27-1.31-15.49-2.21-2.15-4.26-.12-10.16-1.62-14.2-1.64-4.39-1.98-8.67-1.04-13.21,1.9-9.16,1.37-17.8.49-27.02-.43-4.52,3.2-9.56,5.02-12.83l.8-.58.55-.55c.13-.13.21-.35.38-.39"/>
|
||||
<path class="cls-5" d="M172.5,214.73l-.9-.97c-.85-.92-1.12-2.1-1.32-3.3-.03-.2-.03-.48-.12-.6-.7-.95-.55-3.83-2.12-4.44l-19.53,1.91-.57.22c-2.36-.14-4.9-.31-7.14-1.13-.19-.07-.37-.18-.56-.21l-8.59-1.05-16.36,1.2c-8.38.62-16.38,1.23-24.8.67-3.03,1.4-6.08,2.17-9.33,1.63-7.26-2.24-12.54-4.74-12.92.43l-.97,13.21c-.36,4.92-.07,9.67,2.39,14.17-1.22,3.36.19,5.9,2.9,7.27"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.7 KiB |
62
src/apps/epic/static/apps/epic/icons/cards-sigs/Blank.svg
Normal file
@@ -0,0 +1,62 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 560">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #ead08e;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #e1bc70;
|
||||
}
|
||||
|
||||
.cls-3 {
|
||||
fill: #c8a363;
|
||||
}
|
||||
|
||||
.cls-4 {
|
||||
fill: #d2ab67;
|
||||
}
|
||||
|
||||
.cls-5 {
|
||||
fill: #e7c278;
|
||||
}
|
||||
|
||||
.cls-6 {
|
||||
fill: none;
|
||||
stroke: #381507;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 2.2px;
|
||||
}
|
||||
|
||||
.cls-7 {
|
||||
fill: #dfbc6d;
|
||||
}
|
||||
|
||||
.cls-8 {
|
||||
fill: #cfa864;
|
||||
}
|
||||
|
||||
.cls-9 {
|
||||
fill: #f4dfa9;
|
||||
}
|
||||
|
||||
.cls-10 {
|
||||
fill: #d0a965;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<path class="cls-9" d="M176.79,209.43l-2.01.07-2.45,11.42c.4,3.9-.63,6.69-1.9,10.36l-3.61,10.39c.59.78.74,1.77,1.37.7l7.89-16.39,7.62-10.14c2.27.08,3.91.46,5.82.02-.48,1.32-.34,1.86-.19,3.24,2.27-.16,4.39.54,6.42,1.88,1.42-1.07,2.79-1.65,4.6-1.66h3.49c.68.88-.13,2.84-1.05,3.79-.72.74.87,3.35,1.92,3.32l6.17-.17c1.75,1.96,1.05,4.36.98,6.82-.21,7.02-6.11,2.65-10.61,6.97-.68.65-.91,2.18-.23,2.68.84.62,4.26-1.19,5.18,1.09.05.12-2.19,4.54-4.01,7.24l3.5,4.78c.67.91,1.01,1.73,1.7,1.94,2.5.74,3.42-1.07,5.16.15l-.16-6.2c5.06-.66.3,10.94,7.05,9.19-.39,1.97-.65,4.21-1.67,6.09l-.88,1.61-23.67,3.92-26.09,5.6,28.81-1.28,21.9,1.79,2.66,21.59c.23,1.91.61,4.63,0,6.52-.9,2.75-4.75,3.45-6.85,4.52-.11-.6-.55-.77-.29-.96.27-.2.75-.49.46-.76-1.69-1.58-3.7-.88-5.42-1.5l-25.61-9.14c-2.01-.72-4.18-2.11-5.81.36,9.47,1.99,17.53,6.65,23.36,13.99,3.22,1.6,5.73,3.69,8.19,6.23,2.46,1.69,4.62,3.8,5.34,6.49,1.07-.18.92.5,1.05.83.15.38-.61.67-.62,1-.12,4.43-3.89,4.36-7.11,9.5-2.55,4.08-7.38,4.35-9.21,6.15l-.94.92c-.16.16.8.55.24.65-.67.12-1.28.23-1.51.37-.2.12.32.78-.29.87-1.03.15-2.52-.07-3.24.26-.61.28-1.54,1.08-1.53,2.3.01,1.24,3.21,1.69,4.07.81-.22,1.01.07,1.8.09,2.48l-6.88-.51c-3.33-.25-6.05-1.19-8.14-3.56l1.22-1.88c.25-.38-.05-1.81-.78-2.58l-7.92-8.31-1.79-6.55c1.07-.94.71-1.26-.44-1.1.06,2.16-1.07,5.3.01,7.63,2.52,5.41,2.99,11.11,2.18,16.92l-13.46,1.33c-2.91.29-5.41-.37-7.43-2.26l-.56-.52c.46-.08,1.32-.85.89-.92-.9-.15-.17-.72-.3-1.29-2.17-9.59-2.64-19.05-3.8-28.66l-2.19-18.19c-.03-.27-.5-.91-.37-1.27.06-.16-.25-.18-.89-.27.05,8.57-.38,17.06-1.41,25.88l-2.92,25.11c-2.05-1.12-1.73-2.64-2.66-3.77-2.42-1.3-5.35-.57-7.72.19-.82-1.15-2.95-.7-3.79-1.24l-7.41-4.74c-1.04-.66-1.4-2.15-1.42-3.1-.05-2.5,5.64-4.26,5.39-8.93l-4.61,2.69c-2.5,1.46-5.16,2.36-7.42,4.49,1.1,1.73,2.39,6.23.43,7.73-2.2,1.69-4.11-1.83-4.14-3.67-.04-2.86-4.05-4.49-6.8-4.27-2.19-1.72-4.47-2.69-7.41-2.81,2.92-5.58,6.84-9.71,11.07-13.92l9.26-9.22c-4.84,1.85-8.54,5.02-12.9,7.76l-13.73,8.66c-2.37-3.34-2.85-6.15-7.7-5.29-.62-.03-1.45-1.19-2.31-.96-.3-2.98.95-6.63,3.72-8.23,2.3-1.71,4.51-3.28,7.48-2.66l9.81-4.84-3.17-6.89c-.54-1.17-2.95-1.86-4.17-1.27-1.03.5-3.22,1.58-2.74,3.27.24.84,2.09,1.72,1.17,2.66-1.77,1.82-4.46,1.82-7,1.48-3.12-5.27-4.99-4.03-6.2-5.44-1.11-1.28-.17-2.47-1.16-4.71-.95-.19-2.17.03-3.09-.52l.47-8.25c5.41-1.54,11-1.66,16.65-2.11l13.76-1.11,23.43-2.65c.72-.08,1.55-.2,1.17-1.12l-34.67-.55-10.78-.52-6.8-1.66c.07.32.09.89.3,1.07.4.35-.32.87-.61,1.11-.78-.59-2.15-1.21-2.35-2.59l-1.83-12.66c-.25-1.72.79-3.24,1.79-4.12.41-.03,1.02.46.89.93-.09.34-.92.63-.78,1.42l24.02-6.49,13.85-1.37,22.02-.39-2.6-11.6c1.4-.26,3.69-1.85,4.49-2.9.89-1.16-.7-2.67-.56-3.76l1.42-10.88c2.44.37,3.7.45,5.42.41l.57,3.78,1.87,20.67c.26,2.86-.56,5.85,1.49,8.37.13-9.58.68-18.19,1.32-27.3l1.78-21.14c-.18-.34.15-.71.44-.92.45-.32.07-.8-.36-1.29.1.71-.38.67-.55.8.16-.14.65-.09.55-.8l26.18-2.61c2.68-.27,3.68,4.3,4.17,6.14Z"/>
|
||||
<path class="cls-2" d="M70.64,293.55c.93.55,2.14.33,3.09.52.99,2.25.05,3.43,1.16,4.71,1.21,1.41,3.09.17,6.2,5.44,2.54.34,5.22.34,7-1.48.92-.95-.93-1.82-1.17-2.66-.48-1.69,1.71-2.77,2.74-3.27,1.22-.6,3.63.1,4.17,1.27l3.17,6.89-9.81,4.84c-2.97-.61-5.17.95-7.48,2.66-2.77,1.6-4.02,5.25-3.72,8.23.86-.23,1.7.94,2.31.96,4.85-.86,5.33,1.94,7.7,5.29l13.73-8.66c4.36-2.75,8.06-5.91,12.9-7.76l-9.26,9.22c-4.23,4.21-8.15,8.34-11.07,13.92,2.94.13,5.21,1.09,7.41,2.81,2.75-.22,6.76,1.41,6.8,4.27.03,1.84,1.94,5.36,4.14,3.67,1.95-1.5.67-6-.43-7.73,2.26-2.13,4.92-3.03,7.42-4.49l4.61-2.69c.25,4.67-5.44,6.43-5.39,8.93.02.95.39,2.43,1.42,3.1l7.41,4.74c.84.54,2.97.08,3.79,1.24,2.37-.76,5.3-1.48,7.72-.19.94,1.13.61,2.65,2.66,3.77l2.92-25.11c1.03-8.82,1.46-17.32,1.41-25.88.64.09.95.11.89.27-.13.37.34,1,.37,1.27l2.19,18.19c1.16,9.61,1.63,19.07,3.8,28.66.13.57-.6,1.14.3,1.29.43.07-.43.84-.89.92l.56.52-.56-.52c-1-.94-2.21-2.22-3.88-2.29-1.16,1.06-1.06,3.19-2.51,4-2.11,1.17-23.76,4.92-29.64,4.22l-26.91-3.21c-4.28-.51-8.3-1.54-12.64-.83-1.01.17-1.84.08-2.48-.35-1.08-.72-2.51-11.79-5.03-19.97-.63-2.06-.71-4.42-.42-6.58l2.23-17.05,1.07-15.1Z"/>
|
||||
<path class="cls-1" d="M138.74,206.71c-.44,1.03-1.52,1.13-2.79.95l2.89,11.57.5,4.51c-1.72.04-2.98-.04-5.42-.41l-1.42,10.88c-.14,1.09,1.45,2.61.56,3.76-.8,1.04-3.08,2.63-4.49,2.9-2.41-10.77-4.78-21.47-4.96-32.84-.01-.81-.89-1-1.61-.47-.4.29-.56-.28-.69.82-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19l-.45-.56-.41.43-.35.37.35-.37.41-.43,47.79-50.52c5.56-.03,11.1.34,15.74,2.42Z"/>
|
||||
<path class="cls-7" d="M219.43,260.9c-6.75,1.75-1.99-9.85-7.05-9.19l.16,6.2c-1.73-1.22-2.65.59-5.16-.15-.69-.2-1.03-1.02-1.7-1.94l-3.5-4.78c1.82-2.7,4.06-7.11,4.01-7.24-.92-2.28-4.34-.47-5.18-1.09-.68-.5-.45-2.03.23-2.68,4.5-4.32,10.41.05,10.61-6.97.07-2.46.77-4.86-.98-6.82l-6.17.17c-1.05.03-2.64-2.58-1.92-3.32.92-.95,1.73-2.91,1.05-3.8h-3.49c-1.81.02-3.18.6-4.6,1.67-2.03-1.33-4.15-2.04-6.42-1.88-.15-1.38-.29-1.92.19-3.24-1.91.44-3.55.07-5.82-.02l-7.62,10.14-7.89,16.39c-.62,1.07-.78.08-1.37-.7l3.61-10.39c1.27-3.66,2.3-6.46,1.9-10.36l2.45-11.42,2.01-.07c.39,1.47,1.68,1.42,2.71,3.07l2.72-3.52c1.28-1.66,2.13-2.36,4.21-2.02,4.76.8,11.51-5.31,15.84-3.2l5.09.33,6.04.66c1.5.16,3.24-.48,4.43,1.23.92,1.32.41,2.84.7,4.58l2.01,12.15c.75,4.53-.52,9.03-.78,12.69l-.41,5.63-.11,12.64c-.02,2.58.65,4.91.18,7.23Z"/>
|
||||
<path class="cls-5" d="M194.84,352.09c-.02-.68-.3-1.47-.09-2.48-.86.88-4.06.43-4.07-.81,0-1.21.92-2.02,1.53-2.3.72-.33,2.21-.11,3.24-.26.61-.09.09-.75.29-.87.23-.14.84-.25,1.51-.37.55-.1-.4-.49-.24-.65l.94-.92c1.83-1.8,6.66-2.07,9.21-6.15,3.22-5.14,6.99-5.06,7.11-9.5,0-.33.77-.62.62-1-.13-.33.02-1.01-1.05-.83-.72-2.69-2.89-4.8-5.34-6.49-2.46-2.53-4.96-4.63-8.19-6.23-5.84-7.33-13.89-12-23.36-13.99,1.63-2.47,3.8-1.08,5.81-.36l25.61,9.14c1.72.61,3.72-.09,5.42,1.5.29.27-.19.56-.46.76-.25.19.18.36.29.96l-2.06,1.95c1.07.86,1.21,1.79,2.05,2.05,1.4.43,2.37,1.45,3.11,2.99.49,1.04-.89,2.78-.4,4.19l3.22,9.28-1.02,21.79-10.21-.23-13.46-1.16Z"/>
|
||||
<path class="cls-3" d="M128.56,240.87l2.6,11.6-22.02.39-13.85,1.37-24.02,6.49c-.14-.79.69-1.08.78-1.42.12-.46-.48-.95-.89-.93l3.19-2.78.1.02.35-.37.41-.43.45.56c1.45.49,2.55-.4,4.04-1.19,10.61-5.66,22.12-8.74,34.02-9.94l6.62-.66c1.07-.11,1.92.74,2.58-.15.52-.71.86-1.29.66-2.45l-1.81-10.39c-1.32-7.6-1.36-14.87-.45-22.21.14-1.1.29-.53.69-.82.72-.53,1.6-.34,1.61.47.18,11.37,2.55,22.07,4.96,32.84Z"/>
|
||||
<path class="cls-8" d="M216.88,268.6l-1.26,4.05c1.5,1.91,1.96,3.93,2.21,5.96l-21.9-1.79-28.81,1.28,26.09-5.6,23.67-3.92Z"/>
|
||||
<path class="cls-8" d="M73.54,277.75c.29-.24,1.01-.76.61-1.11-.21-.18-.23-.75-.3-1.07l6.8,1.66,10.78.52,34.67.55c.38.92-.45,1.04-1.17,1.12l-23.43,2.65-13.76,1.11c-5.64.46-11.23.57-16.65,2.11.22-3.96,6.26-5.09,2.43-7.55Z"/>
|
||||
<path class="cls-10" d="M145.9,206.7c.16-.14.65-.09.55-.8.43.49.82.97.36,1.29-.29.21-.61.58-.44.92l-1.78,21.14c-.64,9.11-1.2,17.72-1.32,27.3-2.05-2.51-1.23-5.51-1.49-8.37l-1.87-20.67-.57-3.78-.5-4.51-2.89-11.57c1.27.18,2.35.08,2.79-.95,1.34.6,2.38,2.57,3.92,2.39s2.25-1.55,3.24-2.4Z"/>
|
||||
<path class="cls-4" d="M179.83,348.02c-3.45-2.21-1.4,3.53-7.52,4.14.81-5.81.34-11.5-2.18-16.92-1.09-2.33.04-5.47-.01-7.63,1.15-.16,1.51.16.44,1.1l1.79,6.55,7.92,8.31c.73.77,1.03,2.2.78,2.58l-1.22,1.88Z"/>
|
||||
<path class="cls-6" d="M176.79,209.43c-.49-1.84-1.49-6.41-4.17-6.14l-26.18,2.61c.1.71-.38.67-.55.8-.99.85-1.73,2.22-3.24,2.4s-2.58-1.79-3.92-2.39c-4.64-2.08-10.18-2.45-15.74-2.42l-47.79,50.52-.41.43-.45.35-3.19,2.78c-1,.87-2.04,2.4-1.79,4.12l1.83,12.66c.2,1.38,1.57,2.01,2.35,2.59,3.83,2.46-2.21,3.6-2.43,7.55l-.47,8.25-1.07,15.1-2.23,17.05c-.28,2.16-.21,4.53.42,6.58,2.52,8.18,3.95,19.25,5.03,19.97.64.42,1.47.51,2.48.35,4.34-.71,8.36.32,12.64.83l26.91,3.21c5.88.7,27.53-3.05,29.64-4.22,1.45-.81,1.35-2.94,2.51-4,1.67.07,2.88,1.35,3.88,2.29l.56.52c2.02,1.89,4.52,2.55,7.43,2.26l13.46-1.33c6.12-.6,4.07-6.35,7.52-4.14,2.09,2.37,4.82,3.31,8.14,3.56l6.88.51,13.46,1.16,10.21.23,1.02-21.79-3.22-9.28c-.49-1.42.9-3.16.4-4.19-.73-1.54-1.71-2.56-3.11-2.99"/>
|
||||
<path class="cls-6" d="M121.3,208.37c-.91,7.34-.87,14.61.45,22.21l1.81,10.39c.2,1.16-.14,1.74-.66,2.45-.66.89-1.51.05-2.58.15l-6.62.66c-11.9,1.2-23.41,4.28-34.02,9.94-1.49.79-2.59,1.69-4.04,1.19-.41-.14-.87-.19-1.2.24"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 8.8 KiB |
@@ -185,7 +185,8 @@ var RoleSelect = (function () {
|
||||
function () { // dismiss (NVM / outside click)
|
||||
card.classList.remove("guard-active");
|
||||
card.classList.remove("flipped");
|
||||
}
|
||||
},
|
||||
{ invertY: true } // modal grid: tooltip flies away from centre (upper→above, lower→below)
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,6 +18,92 @@
|
||||
scaleTable();
|
||||
}
|
||||
window.addEventListener('resize', scaleTable);
|
||||
window.addEventListener('resize:end', scaleTable);
|
||||
}());
|
||||
|
||||
(function () {
|
||||
// Size the sig-select overlay so the card grid clears the tray handle
|
||||
// (portrait: right strip 48px; landscape: bottom strip 48px) and any
|
||||
// fixed gear/kit buttons that protrude further into the viewport.
|
||||
// Mirrors the scaleTable() pattern — runs on load (after tray.js has
|
||||
// positioned the tray) and on every resize.
|
||||
function sizeSigModal() {
|
||||
var overlay = document.querySelector('.sig-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
var vw = window.innerWidth;
|
||||
var vh = window.innerHeight;
|
||||
var rightInset = 0;
|
||||
var bottomInset = 0;
|
||||
|
||||
var isLandscape = vw > vh;
|
||||
|
||||
// Tray handle: portrait → vertical strip on right; landscape → tray is easily
|
||||
// dismissed, so skip the bottomInset calculation (would over-shrink the modal).
|
||||
var trayHandle = document.getElementById('id_tray_handle');
|
||||
if (trayHandle && !isLandscape) {
|
||||
var hr = trayHandle.getBoundingClientRect();
|
||||
if (hr.width < hr.height) {
|
||||
// Portrait: handle strips the right edge
|
||||
rightInset = vw - hr.left;
|
||||
}
|
||||
}
|
||||
|
||||
// Gear / kit buttons: update right inset if near right edge.
|
||||
// In landscape they sit at bottom-right but are also dismissible — skip bottomInset.
|
||||
document.querySelectorAll('.room-page > .gear-btn').forEach(function (btn) {
|
||||
var br = btn.getBoundingClientRect();
|
||||
if (br.right > vw - 30) {
|
||||
rightInset = Math.max(rightInset, vw - br.left);
|
||||
}
|
||||
if (!isLandscape && br.bottom > vh - 30) {
|
||||
bottomInset = Math.max(bottomInset, vh - br.top);
|
||||
}
|
||||
});
|
||||
|
||||
// Landscape: right clears gear/kit buttons; bottom is fixed 60px for the
|
||||
// kit-bag handle strip — tray is ignored so the stage has room to breathe.
|
||||
// At ≥1800px the right sidebar doubles to 8rem so clear 128px.
|
||||
if (isLandscape) {
|
||||
var xlBreak = vw >= 1800;
|
||||
rightInset = Math.max(rightInset, xlBreak ? 128 : 64);
|
||||
bottomInset = 60;
|
||||
}
|
||||
|
||||
overlay.style.paddingRight = rightInset + 'px';
|
||||
overlay.style.paddingBottom = bottomInset + 'px';
|
||||
|
||||
// Stage card: smaller of 40% stage width OR (80% stage height × 5/8 aspect).
|
||||
// libsass can't handle cqw/cqh inside min(), so we compute it here.
|
||||
var stageEl = overlay.querySelector('.sig-stage');
|
||||
if (stageEl) {
|
||||
var sw = stageEl.offsetWidth - 24; // subtract padding (0.75rem × 2)
|
||||
var sh = stageEl.offsetHeight - 24;
|
||||
if (sw > 0 && sh > 0) {
|
||||
// Clamp between 90px (never tiny in landscape) and 160px (never
|
||||
// dominant on very wide/tall viewports). In portrait, skip the
|
||||
// floor so small modals still scale down naturally.
|
||||
var cardW = Math.min(sw * 0.4, sh * 0.8 * 5 / 8, 160);
|
||||
if (isLandscape) { cardW = Math.max(cardW, 90); }
|
||||
overlay.style.setProperty('--sig-card-w', cardW + 'px');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('load', sizeSigModal);
|
||||
window.addEventListener('resize', sizeSigModal);
|
||||
window.addEventListener('resize:end', sizeSigModal);
|
||||
}());
|
||||
|
||||
// Dispatch a custom 'resize:end' event 500 ms after the last 'resize' fires.
|
||||
// scaleTable, sizeSigModal, and Tray._reposition all subscribe to it so they
|
||||
// re-measure with settled viewport dimensions after rapid resize sequences.
|
||||
(function () {
|
||||
var t;
|
||||
window.addEventListener('resize', function () {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(function () { window.dispatchEvent(new Event('resize:end')); }, 500);
|
||||
});
|
||||
}());
|
||||
|
||||
(function () {
|
||||
@@ -27,6 +113,7 @@
|
||||
const roomId = roomPage.dataset.roomId;
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
||||
window._roomSocket = ws; // exposed for sig-select.js hover broadcast
|
||||
|
||||
ws.onmessage = function (event) {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
@@ -1,96 +1,760 @@
|
||||
var SigSelect = (function () {
|
||||
var SIG_ORDER = ['PC', 'NC', 'EC', 'SC', 'AC', 'BC'];
|
||||
// Polarity → three roles in fixed left/mid/right cursor order
|
||||
var POLARITY_ROLES = {
|
||||
levity: ['PC', 'NC', 'SC'],
|
||||
gravity: ['BC', 'EC', 'AC'],
|
||||
};
|
||||
|
||||
var sigDeck, selectUrl, userRole;
|
||||
var overlay, deckGrid, stage, stageCard, statBlock;
|
||||
var cautionEl, cautionEffect, cautionPrev, cautionNext, cautionIndexEl;
|
||||
var _flipBtn, _cautionBtn, _flipOrigLabel, _cautionOrigLabel;
|
||||
var reserveUrl, readyUrl, userRole, userPolarity;
|
||||
|
||||
function getActiveRole() {
|
||||
for (var i = 0; i < SIG_ORDER.length; i++) {
|
||||
var seat = document.querySelector('.table-seat[data-role="' + SIG_ORDER[i] + '"]');
|
||||
if (seat && !seat.dataset.sigDone) return SIG_ORDER[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
var _isReady = false;
|
||||
var _takeSigBtn = null;
|
||||
var _glowTimer = null;
|
||||
var _glowPeak = false;
|
||||
var _countdownTimer = null;
|
||||
var _countdownSecondsLeft = 0;
|
||||
|
||||
function isEligible() {
|
||||
return !!(userRole && userRole === getActiveRole());
|
||||
}
|
||||
var _cautionData = [];
|
||||
var _cautionIdx = 0;
|
||||
|
||||
var _focusedCardEl = null; // card currently shown in stage
|
||||
var _reservedCardId = null; // card with active reservation
|
||||
var _stageFrozen = false; // true after OK — stage locks on reserved card
|
||||
var _requestInFlight = false;
|
||||
|
||||
var _floatingCursors = {}; // key: cardId+posClass → portal <i> element (hover)
|
||||
var _reservedFloats = {}; // key: role → portal <i> element (thumbs-up, frozen)
|
||||
var _cursorPortal = null;
|
||||
|
||||
function getCsrf() {
|
||||
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||
return m ? m[1] : '';
|
||||
}
|
||||
|
||||
function applySelection(cardId, role, deckType) {
|
||||
// Remove only the specific pile copy (levity or gravity) of this card
|
||||
var selector = '.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]';
|
||||
sigDeck.querySelectorAll(selector).forEach(function (c) { c.remove(); });
|
||||
// ── Stage ──────────────────────────────────────────────────────────────
|
||||
|
||||
// Mark this seat done, remove active
|
||||
var seat = document.querySelector('.table-seat[data-role="' + role + '"]');
|
||||
if (seat) {
|
||||
seat.classList.remove('active');
|
||||
seat.dataset.sigDone = '1';
|
||||
function _populateKeywordList(listEl, csv) {
|
||||
var keywords = csv ? csv.split(',').filter(Boolean) : [];
|
||||
listEl.innerHTML = keywords.map(function (k) {
|
||||
return '<li>' + k.trim() + '</li>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// Advance active to next seat
|
||||
var nextRole = getActiveRole();
|
||||
if (nextRole) {
|
||||
var nextSeat = document.querySelector('.table-seat[data-role="' + nextRole + '"]');
|
||||
if (nextSeat) nextSeat.classList.add('active');
|
||||
// ── Caution tooltip ───────────────────────────────────────────────────
|
||||
|
||||
function _renderCaution() {
|
||||
if (_cautionData.length === 0) {
|
||||
cautionEffect.innerHTML = '<em>Rival interactions pending.</em>';
|
||||
cautionPrev.disabled = true;
|
||||
cautionNext.disabled = true;
|
||||
cautionIndexEl.textContent = '';
|
||||
return;
|
||||
}
|
||||
cautionEffect.innerHTML = _cautionData[_cautionIdx];
|
||||
cautionPrev.disabled = (_cautionData.length <= 1);
|
||||
cautionNext.disabled = (_cautionData.length <= 1);
|
||||
cautionIndexEl.textContent = _cautionData.length > 1
|
||||
? (_cautionIdx + 1) + ' / ' + _cautionData.length
|
||||
: '';
|
||||
}
|
||||
|
||||
// Place a card placeholder in inventory
|
||||
var invSlot = document.getElementById('id_inv_sig_card');
|
||||
if (invSlot) {
|
||||
var card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
invSlot.appendChild(card);
|
||||
function _openCaution() {
|
||||
if (!_focusedCardEl) return;
|
||||
try {
|
||||
_cautionData = JSON.parse(_focusedCardEl.dataset.cautions || '[]');
|
||||
} catch (e) {
|
||||
_cautionData = [];
|
||||
}
|
||||
_cautionIdx = 0;
|
||||
_renderCaution();
|
||||
_flipBtn.classList.add('btn-disabled');
|
||||
_cautionBtn.classList.add('btn-disabled');
|
||||
_flipBtn.textContent = '\u00D7';
|
||||
_cautionBtn.textContent = '\u00D7';
|
||||
stage.classList.add('sig-caution-open');
|
||||
}
|
||||
|
||||
function _closeCaution() {
|
||||
stage.classList.remove('sig-caution-open');
|
||||
if (_flipBtn) {
|
||||
_flipBtn.classList.remove('btn-disabled');
|
||||
_cautionBtn.classList.remove('btn-disabled');
|
||||
_flipBtn.textContent = _flipOrigLabel;
|
||||
_cautionBtn.textContent = _cautionOrigLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function updateStage(cardEl) {
|
||||
if (_stageFrozen) return;
|
||||
_closeCaution();
|
||||
if (!cardEl) {
|
||||
stageCard.style.display = 'none';
|
||||
stage.classList.remove('sig-stage--active');
|
||||
_focusedCardEl = null;
|
||||
return;
|
||||
}
|
||||
_focusedCardEl = cardEl;
|
||||
|
||||
var rank = cardEl.dataset.cornerRank || '';
|
||||
var icon = cardEl.dataset.suitIcon || '';
|
||||
var group = cardEl.dataset.nameGroup || '';
|
||||
var title = cardEl.dataset.nameTitle || '';
|
||||
var arcana= cardEl.dataset.arcana || '';
|
||||
var corr = cardEl.dataset.correspondence || '';
|
||||
|
||||
stageCard.querySelectorAll('.fan-corner-rank').forEach(function (el) { el.textContent = rank; });
|
||||
stageCard.querySelectorAll('.stage-suit-icon').forEach(function (el) {
|
||||
if (icon) {
|
||||
el.className = 'fa-solid ' + icon + ' stage-suit-icon';
|
||||
el.style.display = '';
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
}
|
||||
});
|
||||
stageCard.querySelector('.fan-card-name-group').textContent = group;
|
||||
stageCard.querySelector('.fan-card-arcana').textContent = arcana;
|
||||
stageCard.querySelector('.fan-card-correspondence').textContent = ''; // shown in game-kit only
|
||||
|
||||
var qualifier = userPolarity === 'levity' ? 'Leavened' : 'Graven';
|
||||
var isMajor = arcana.toLowerCase().indexOf('major') !== -1;
|
||||
// Major arcana: qualifier sits below the title — append comma so it reads as a subtitle.
|
||||
stageCard.querySelector('.fan-card-name').textContent = isMajor ? title + ',' : title;
|
||||
stageCard.querySelector('.sig-qualifier-above').textContent = isMajor ? '' : qualifier;
|
||||
stageCard.querySelector('.sig-qualifier-below').textContent = isMajor ? qualifier : '';
|
||||
|
||||
// Populate stat block keyword faces and reset to upright
|
||||
statBlock.classList.remove('is-reversed');
|
||||
_populateKeywordList(
|
||||
statBlock.querySelector('#id_stat_keywords_upright'),
|
||||
cardEl.dataset.keywordsUpright
|
||||
);
|
||||
_populateKeywordList(
|
||||
statBlock.querySelector('#id_stat_keywords_reversed'),
|
||||
cardEl.dataset.keywordsReversed
|
||||
);
|
||||
|
||||
stageCard.style.display = '';
|
||||
stage.classList.add('sig-stage--active');
|
||||
}
|
||||
|
||||
// ── Focus a card (click/tap) — shows OK overlay on the card ──────────
|
||||
|
||||
function focusCard(cardEl) {
|
||||
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
if (c !== cardEl) c.classList.remove('sig-focused');
|
||||
});
|
||||
cardEl.classList.add('sig-focused');
|
||||
updateStage(cardEl);
|
||||
}
|
||||
|
||||
// ── Hover events ──────────────────────────────────────────────────────
|
||||
|
||||
function onCardEnter(e) {
|
||||
var card = e.currentTarget;
|
||||
if (!_stageFrozen) updateStage(card);
|
||||
sendHover(card.dataset.cardId, true);
|
||||
}
|
||||
|
||||
function onCardLeave(e) {
|
||||
if (!_stageFrozen) updateStage(null);
|
||||
sendHover(e.currentTarget.dataset.cardId, false);
|
||||
}
|
||||
|
||||
// ── Reserve / release ─────────────────────────────────────────────────
|
||||
|
||||
function doReserve(cardEl) {
|
||||
if (_requestInFlight) return;
|
||||
var cardId = cardEl.dataset.cardId;
|
||||
_requestInFlight = true;
|
||||
fetch(reserveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=reserve&card_id=' + encodeURIComponent(cardId),
|
||||
}).then(function (res) {
|
||||
_requestInFlight = false;
|
||||
if (res.ok) applyReservation(cardId, userRole, true);
|
||||
}).catch(function () { _requestInFlight = false; });
|
||||
}
|
||||
|
||||
function doRelease() {
|
||||
if (_requestInFlight || !_reservedCardId) return;
|
||||
var cardId = _reservedCardId;
|
||||
_requestInFlight = true;
|
||||
fetch(reserveUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=release&card_id=' + encodeURIComponent(cardId),
|
||||
}).then(function (res) {
|
||||
_requestInFlight = false;
|
||||
if (res.ok) applyReservation(cardId, userRole, false);
|
||||
}).catch(function () { _requestInFlight = false; });
|
||||
}
|
||||
|
||||
// ── Apply reservation state (local + from WS) ─────────────────────────
|
||||
|
||||
function _placeReservedFloat(cardId, cardEl, role) {
|
||||
// Remove any pre-existing reserved float for this role (e.g. page-load replay)
|
||||
if (_reservedFloats[role]) { _reservedFloats[role].remove(); }
|
||||
|
||||
// Retire ALL hover floats for this role — may be on a different card than reserved
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
var idx = roles.indexOf(role);
|
||||
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||
Object.keys(_floatingCursors).forEach(function (key) {
|
||||
if (key.slice(-posClass.length) === posClass) {
|
||||
_floatingCursors[key].remove();
|
||||
var hCid = key.slice(0, key.length - posClass.length);
|
||||
var hEl = deckGrid.querySelector('.sig-card[data-card-id="' + hCid + '"]');
|
||||
if (hEl) {
|
||||
var a = hEl.querySelector('.sig-cursor' + posClass);
|
||||
if (a) a.classList.remove('active');
|
||||
}
|
||||
delete _floatingCursors[key];
|
||||
}
|
||||
});
|
||||
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var xFractions = [0.15, 0.5, 0.85];
|
||||
var fc = document.createElement('i');
|
||||
fc.className = 'fa-solid fa-thumbs-up sig-cursor-float sig-cursor-float--reserved';
|
||||
fc.dataset.role = role;
|
||||
fc.style.left = (rect.left + rect.width * xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
_ensureCursorPortal().appendChild(fc);
|
||||
_reservedFloats[role] = fc;
|
||||
}
|
||||
|
||||
function applyReservation(cardId, role, reserved) {
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
|
||||
if (reserved) {
|
||||
cardEl.dataset.reservedBy = role;
|
||||
cardEl.classList.add('sig-reserved');
|
||||
if (role === userRole) {
|
||||
_reservedCardId = cardId;
|
||||
cardEl.classList.add('sig-reserved--own');
|
||||
cardEl.classList.remove('sig-focused');
|
||||
// Freeze stage on this card (temporarily unfreeze to populate it)
|
||||
_stageFrozen = false;
|
||||
updateStage(cardEl);
|
||||
_stageFrozen = true;
|
||||
stage.classList.add('sig-stage--frozen');
|
||||
_showTakeSigBtn();
|
||||
}
|
||||
// Thumbs-up float for all reservations — own role sees their own indicator too
|
||||
_placeReservedFloat(cardId, cardEl, role);
|
||||
} else {
|
||||
delete cardEl.dataset.reservedBy;
|
||||
cardEl.classList.remove('sig-reserved', 'sig-reserved--own');
|
||||
if (role === userRole) {
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
stage.classList.remove('sig-stage--frozen');
|
||||
_hideTakeSigBtn();
|
||||
}
|
||||
// Remove thumbs-up float for all releases — own role included
|
||||
if (_reservedFloats[role]) {
|
||||
_reservedFloats[role].remove();
|
||||
delete _reservedFloats[role];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Apply hover cursor (WS only — own hover is CSS :hover) ────────────
|
||||
//
|
||||
// Cursor icons are portaled to document root so they escape overflow/clip
|
||||
// contexts in the deck grid. The in-card anchor elements only carry the
|
||||
// .active class (for test assertions and the :has() z-index rule).
|
||||
|
||||
function _ensureCursorPortal() {
|
||||
if (!_cursorPortal || !document.body.contains(_cursorPortal)) {
|
||||
_cursorPortal = document.getElementById('id_sig_cursor_portal');
|
||||
if (!_cursorPortal) {
|
||||
_cursorPortal = document.createElement('div');
|
||||
_cursorPortal.id = 'id_sig_cursor_portal';
|
||||
document.body.appendChild(_cursorPortal);
|
||||
}
|
||||
}
|
||||
return _cursorPortal;
|
||||
}
|
||||
|
||||
function applyHover(cardId, role, active) {
|
||||
if (role === userRole) return;
|
||||
if (_reservedFloats[role]) return; // role already has thumbs-up — ignore hover
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
var idx = roles.indexOf(role);
|
||||
var posClass = ['--left', '--mid', '--right'][idx] || '--left';
|
||||
var anchor = cardEl.querySelector('.sig-cursor' + posClass);
|
||||
if (!anchor) return;
|
||||
|
||||
var key = cardId + posClass;
|
||||
|
||||
if (active) {
|
||||
anchor.classList.add('active'); // kept for test assertions + :has() z-index
|
||||
|
||||
// Place a fixed-position clone in the portal, positioned from card bounds
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var xFractions = [0.15, 0.5, 0.85];
|
||||
var fc = document.createElement('i');
|
||||
fc.className = 'fa-solid fa-hand-pointer sig-cursor-float';
|
||||
fc.dataset.role = role;
|
||||
fc.style.left = (rect.left + rect.width * xFractions[idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
_ensureCursorPortal().appendChild(fc);
|
||||
_floatingCursors[key] = fc;
|
||||
} else {
|
||||
anchor.classList.remove('active');
|
||||
if (_floatingCursors[key]) {
|
||||
_floatingCursors[key].remove();
|
||||
delete _floatingCursors[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Reposition floats after resize ────────────────────────────────────
|
||||
//
|
||||
// Both reserved (thumbs-up) and hover (hand-pointer) floats are stamped with
|
||||
// fixed pixel coords at placement time. When the viewport changes size the
|
||||
// cards reflow but the icons stay put. Re-measure from the card's current
|
||||
// bounding rect and update left/top in-place.
|
||||
|
||||
var _posClasses = ['--left', '--mid', '--right'];
|
||||
var _xFractions = [0.15, 0.5, 0.85];
|
||||
|
||||
function _repositionFloats() {
|
||||
if (!deckGrid) return;
|
||||
var roles = POLARITY_ROLES[userPolarity] || [];
|
||||
|
||||
Object.keys(_reservedFloats).forEach(function (role) {
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-reserved-by="' + role + '"]');
|
||||
if (!cardEl) return;
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var idx = roles.indexOf(role);
|
||||
var fc = _reservedFloats[role];
|
||||
fc.style.left = (rect.left + rect.width * _xFractions[idx < 0 ? 1 : idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
});
|
||||
|
||||
Object.keys(_floatingCursors).forEach(function (key) {
|
||||
var posClass = _posClasses.find(function (p) {
|
||||
return key.slice(-p.length) === p;
|
||||
});
|
||||
if (!posClass) return;
|
||||
var cardId = key.slice(0, key.length - posClass.length);
|
||||
var cardEl = deckGrid.querySelector('.sig-card[data-card-id="' + cardId + '"]');
|
||||
if (!cardEl) return;
|
||||
var rect = cardEl.getBoundingClientRect();
|
||||
var idx = _posClasses.indexOf(posClass);
|
||||
var fc = _floatingCursors[key];
|
||||
fc.style.left = (rect.left + rect.width * _xFractions[idx]) + 'px';
|
||||
fc.style.top = rect.bottom + 'px';
|
||||
});
|
||||
}
|
||||
|
||||
// ── TAKE SIG / WAIT NVM button ─────────────────────────────────────────
|
||||
|
||||
function _onTakeSigClick() {
|
||||
if (_isReady) {
|
||||
var body = 'action=unready';
|
||||
if (_countdownTimer !== null) {
|
||||
body += '&seconds_remaining=' + _countdownSecondsLeft;
|
||||
}
|
||||
fetch(readyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: body,
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
_isReady = false;
|
||||
if (_countdownTimer !== null) {
|
||||
clearInterval(_countdownTimer);
|
||||
_countdownTimer = null;
|
||||
if (_takeSigBtn) _takeSigBtn.style.fontSize = '';
|
||||
}
|
||||
if (_takeSigBtn) _takeSigBtn.textContent = 'TAKE SIG';
|
||||
_stopWaitNoGlow();
|
||||
_stopCountdownGlow();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
fetch(readyUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRFToken': getCsrf() },
|
||||
body: 'action=ready',
|
||||
}).then(function (res) {
|
||||
if (res.ok) {
|
||||
_isReady = true;
|
||||
// countdown_start WS may arrive before this response for the
|
||||
// gamer who triggered the countdown — don't clobber the numeral.
|
||||
if (_countdownTimer === null) {
|
||||
if (_takeSigBtn) _takeSigBtn.textContent = 'WAIT NVM';
|
||||
_startWaitNoGlow();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function _showTakeSigBtn() {
|
||||
if (_takeSigBtn || !stage) return;
|
||||
_takeSigBtn = document.createElement('button');
|
||||
_takeSigBtn.id = 'id_take_sig_btn';
|
||||
_takeSigBtn.className = 'btn btn-primary sig-take-sig-btn';
|
||||
_takeSigBtn.type = 'button';
|
||||
_takeSigBtn.textContent = 'TAKE SIG';
|
||||
_takeSigBtn.addEventListener('click', _onTakeSigClick);
|
||||
stage.appendChild(_takeSigBtn);
|
||||
}
|
||||
|
||||
function _startWaitNoGlow() {
|
||||
if (_glowTimer !== null) return;
|
||||
_glowPeak = false;
|
||||
_glowTimer = setInterval(function () {
|
||||
if (!_takeSigBtn) { _stopWaitNoGlow(); return; }
|
||||
_glowPeak = !_glowPeak;
|
||||
if (_glowPeak) {
|
||||
_takeSigBtn.classList.add('btn-cancel');
|
||||
_takeSigBtn.style.boxShadow =
|
||||
'0 0 0.8rem 0.2rem rgba(var(--terOr), 0.75), ' +
|
||||
'0 0 2rem 0.4rem rgba(var(--terOr), 0.35)';
|
||||
} else {
|
||||
_takeSigBtn.classList.remove('btn-cancel');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function _stopWaitNoGlow() {
|
||||
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.classList.remove('btn-cancel');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
_glowPeak = false;
|
||||
}
|
||||
|
||||
function _startCountdownGlow() {
|
||||
if (_glowTimer !== null) return;
|
||||
_glowPeak = false;
|
||||
_glowTimer = setInterval(function () {
|
||||
if (!_takeSigBtn) { _stopCountdownGlow(); return; }
|
||||
_glowPeak = !_glowPeak;
|
||||
if (_glowPeak) {
|
||||
_takeSigBtn.classList.add('btn-danger');
|
||||
_takeSigBtn.style.boxShadow =
|
||||
'0 0 0.8rem 0.2rem rgba(var(--terRd), 0.75), ' +
|
||||
'0 0 2rem 0.4rem rgba(var(--terRd), 0.35)';
|
||||
} else {
|
||||
_takeSigBtn.classList.remove('btn-danger');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
function _stopCountdownGlow() {
|
||||
if (_glowTimer !== null) { clearInterval(_glowTimer); _glowTimer = null; }
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.classList.remove('btn-danger');
|
||||
_takeSigBtn.style.boxShadow = '';
|
||||
}
|
||||
_glowPeak = false;
|
||||
}
|
||||
|
||||
function _hideTakeSigBtn() {
|
||||
if (!_takeSigBtn) return;
|
||||
_stopWaitNoGlow();
|
||||
_takeSigBtn.removeEventListener('click', _onTakeSigClick);
|
||||
_takeSigBtn.remove();
|
||||
_takeSigBtn = null;
|
||||
_isReady = false;
|
||||
}
|
||||
|
||||
// ── Polarity countdown ────────────────────────────────────────────────
|
||||
|
||||
function _showCountdown(seconds) {
|
||||
_countdownSecondsLeft = seconds;
|
||||
_stopWaitNoGlow();
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.textContent = _countdownSecondsLeft;
|
||||
_takeSigBtn.style.fontSize = '2em';
|
||||
}
|
||||
_startCountdownGlow();
|
||||
if (_countdownTimer !== null) clearInterval(_countdownTimer);
|
||||
_countdownTimer = setInterval(function () {
|
||||
_countdownSecondsLeft -= 1;
|
||||
if (_takeSigBtn) _takeSigBtn.textContent = _countdownSecondsLeft;
|
||||
if (_countdownSecondsLeft <= 0) {
|
||||
clearInterval(_countdownTimer);
|
||||
_countdownTimer = null;
|
||||
_stopCountdownGlow(); // server drives the transition via Celery task
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function _hideCountdown() {
|
||||
if (_countdownTimer !== null) {
|
||||
clearInterval(_countdownTimer);
|
||||
_countdownTimer = null;
|
||||
}
|
||||
_stopCountdownGlow();
|
||||
if (_takeSigBtn) {
|
||||
_takeSigBtn.style.fontSize = '';
|
||||
if (_isReady) {
|
||||
// Countdown cancelled by another gamer — restore WAIT NVM state
|
||||
_takeSigBtn.textContent = 'WAIT NVM';
|
||||
_startWaitNoGlow();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Overlay dismiss + waiting message ─────────────────────────────────
|
||||
|
||||
function _dismissSigOverlay() {
|
||||
_hideCountdown();
|
||||
_hideTakeSigBtn();
|
||||
var backdrop = document.querySelector('.sig-backdrop');
|
||||
if (backdrop) backdrop.remove();
|
||||
if (overlay) { overlay.remove(); overlay = null; }
|
||||
// Remove all floating cursors (hover + thumbs-up) from the portal
|
||||
Object.keys(_reservedFloats).forEach(function (role) {
|
||||
_reservedFloats[role].remove();
|
||||
});
|
||||
_reservedFloats = {};
|
||||
Object.keys(_floatingCursors).forEach(function (key) {
|
||||
_floatingCursors[key].remove();
|
||||
});
|
||||
_floatingCursors = {};
|
||||
}
|
||||
|
||||
function _showWaitingMsg(pendingPolarity) {
|
||||
if (document.getElementById('id_hex_waiting_msg')) return;
|
||||
var msg = document.createElement('p');
|
||||
msg.id = 'id_hex_waiting_msg';
|
||||
msg.textContent = pendingPolarity === 'gravity'
|
||||
? 'Gravity settling . . .'
|
||||
: 'Levity appraising . . .';
|
||||
var center = document.querySelector('.table-center');
|
||||
if (center) center.appendChild(msg);
|
||||
}
|
||||
|
||||
// ── WS events ─────────────────────────────────────────────────────────
|
||||
|
||||
window.addEventListener('room:sig_reserved', function (e) {
|
||||
if (!deckGrid) return;
|
||||
applyReservation(String(e.detail.card_id), e.detail.role, e.detail.reserved);
|
||||
});
|
||||
|
||||
window.addEventListener('room:sig_hover', function (e) {
|
||||
if (!deckGrid) return;
|
||||
applyHover(String(e.detail.card_id), e.detail.role, e.detail.active);
|
||||
});
|
||||
|
||||
window.addEventListener('room:countdown_start', function (e) {
|
||||
if (!overlay) return;
|
||||
_showCountdown(e.detail.seconds);
|
||||
});
|
||||
|
||||
window.addEventListener('room:countdown_cancel', function (e) {
|
||||
_hideCountdown();
|
||||
_countdownSecondsLeft = e.detail.seconds_remaining;
|
||||
});
|
||||
|
||||
window.addEventListener('room:polarity_room_done', function (e) {
|
||||
if (!overlay) return;
|
||||
if (e.detail.polarity !== userPolarity) return;
|
||||
var pendingPolarity = userPolarity === 'levity' ? 'gravity' : 'levity';
|
||||
_dismissSigOverlay();
|
||||
_showWaitingMsg(pendingPolarity);
|
||||
});
|
||||
|
||||
window.addEventListener('room:pick_sky_available', function () {
|
||||
var msg = document.getElementById('id_hex_waiting_msg');
|
||||
if (msg) msg.remove();
|
||||
var btn = document.getElementById('id_pick_sky_btn');
|
||||
if (btn) btn.style.display = '';
|
||||
});
|
||||
|
||||
window.addEventListener('resize:end', _repositionFloats);
|
||||
|
||||
// ── WS send ───────────────────────────────────────────────────────────
|
||||
|
||||
function sendHover(cardId, active) {
|
||||
if (!window._roomSocket || window._roomSocket.readyState !== WebSocket.OPEN) return;
|
||||
window._roomSocket.send(JSON.stringify({
|
||||
type: 'sig_hover', card_id: cardId, role: userRole, active: active,
|
||||
}));
|
||||
}
|
||||
|
||||
// ── Init ──────────────────────────────────────────────────────────────
|
||||
|
||||
function init() {
|
||||
sigDeck = document.getElementById('id_sig_deck');
|
||||
if (!sigDeck) return;
|
||||
selectUrl = sigDeck.dataset.selectSigUrl;
|
||||
userRole = sigDeck.dataset.userRole;
|
||||
overlay = document.querySelector('.sig-overlay');
|
||||
if (!overlay) return;
|
||||
|
||||
sigDeck.addEventListener('click', function (e) {
|
||||
deckGrid = overlay.querySelector('.sig-deck-grid');
|
||||
stage = overlay.querySelector('.sig-stage');
|
||||
stageCard = stage.querySelector('.sig-stage-card');
|
||||
statBlock = stage.querySelector('.sig-stat-block');
|
||||
|
||||
_flipBtn = statBlock.querySelector('.sig-flip-btn');
|
||||
_cautionBtn = statBlock.querySelector('.sig-caution-btn');
|
||||
_flipOrigLabel = _flipBtn.textContent;
|
||||
_cautionOrigLabel = _cautionBtn.textContent;
|
||||
|
||||
_flipBtn.addEventListener('click', function () {
|
||||
if (_flipBtn.classList.contains('btn-disabled')) return;
|
||||
statBlock.classList.toggle('is-reversed');
|
||||
});
|
||||
|
||||
cautionEl = stage.querySelector('.sig-caution-tooltip');
|
||||
cautionEffect = cautionEl.querySelector('.sig-caution-effect');
|
||||
cautionPrev = statBlock.querySelector('.sig-caution-prev');
|
||||
cautionNext = statBlock.querySelector('.sig-caution-next');
|
||||
cautionIndexEl = cautionEl.querySelector('.sig-caution-index');
|
||||
|
||||
// Clicking the tooltip (not nav buttons) dismisses it
|
||||
cautionEl.addEventListener('click', function () {
|
||||
_closeCaution();
|
||||
});
|
||||
|
||||
_cautionBtn.addEventListener('click', function () {
|
||||
if (_cautionBtn.classList.contains('btn-disabled')) return;
|
||||
stage.classList.contains('sig-caution-open') ? _closeCaution() : _openCaution();
|
||||
});
|
||||
cautionPrev.addEventListener('click', function () {
|
||||
_cautionIdx = (_cautionIdx - 1 + _cautionData.length) % _cautionData.length;
|
||||
_renderCaution();
|
||||
});
|
||||
cautionNext.addEventListener('click', function () {
|
||||
_cautionIdx = (_cautionIdx + 1) % _cautionData.length;
|
||||
_renderCaution();
|
||||
});
|
||||
|
||||
reserveUrl = overlay.dataset.reserveUrl;
|
||||
readyUrl = overlay.dataset.readyUrl;
|
||||
|
||||
userRole = overlay.dataset.userRole;
|
||||
userPolarity= overlay.dataset.polarity;
|
||||
|
||||
// PICK SKY btn is rendered hidden during SIG_SELECT; reveal on pick_sky_available
|
||||
var pickSkyBtn = document.getElementById('id_pick_sky_btn');
|
||||
if (pickSkyBtn) {
|
||||
pickSkyBtn.addEventListener('click', function () {
|
||||
if (typeof Tray !== 'undefined') Tray.open();
|
||||
});
|
||||
}
|
||||
|
||||
// Restore reservations from server-rendered JSON (page-load state).
|
||||
// Deferred to 'load' so sizeSigModal() (also a 'load' listener, registered
|
||||
// in room.js before this script) has already applied paddingBottom and
|
||||
// --sig-card-w before _placeReservedFloat calls getBoundingClientRect().
|
||||
try {
|
||||
var existing = JSON.parse(overlay.dataset.reservations || '{}');
|
||||
if (Object.keys(existing).length) {
|
||||
var _replayReservations = function () {
|
||||
Object.keys(existing).forEach(function (cardId) {
|
||||
applyReservation(cardId, existing[cardId], true);
|
||||
});
|
||||
// Restore WAIT NVM state if gamer was already ready before page load
|
||||
if (overlay.dataset.ready === 'true' && _takeSigBtn) {
|
||||
_isReady = true;
|
||||
_takeSigBtn.textContent = 'WAIT NVM';
|
||||
_startWaitNoGlow();
|
||||
}
|
||||
};
|
||||
if (document.readyState === 'complete') {
|
||||
_replayReservations();
|
||||
} else {
|
||||
window.addEventListener('load', _replayReservations, { once: true });
|
||||
}
|
||||
}
|
||||
} catch (e) { /* malformed JSON — ignore */ }
|
||||
|
||||
// Hover: update stage preview + broadcast cursor
|
||||
deckGrid.querySelectorAll('.sig-card').forEach(function (card) {
|
||||
card.addEventListener('mouseenter', onCardEnter);
|
||||
card.addEventListener('mouseleave', onCardLeave);
|
||||
card.addEventListener('touchstart', function (e) {
|
||||
var card = e.currentTarget;
|
||||
if (_reservedCardId) return; // locked until NVM — no preventDefault either
|
||||
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||
if (reservedByOther || isOwnReserved) return;
|
||||
// If the tap is on the OK button, let the synthetic click fire normally
|
||||
if (e.target.closest('.sig-ok-btn')) return;
|
||||
focusCard(card);
|
||||
e.preventDefault(); // prevent ghost click on card body
|
||||
}, { passive: false });
|
||||
});
|
||||
|
||||
// Touch outside the grid — dismiss stage preview (unfocused state only).
|
||||
// Card touchstart doesn't stop propagation, so we guard with closest().
|
||||
overlay.addEventListener('touchstart', function (e) {
|
||||
if (_stageFrozen || !_focusedCardEl) return;
|
||||
if (e.target.closest('.sig-deck-grid')) return;
|
||||
deckGrid.querySelectorAll('.sig-card.sig-focused').forEach(function (c) {
|
||||
c.classList.remove('sig-focused');
|
||||
});
|
||||
updateStage(null);
|
||||
}, { passive: true });
|
||||
|
||||
// Click delegation: card body → focus (shows OK); OK/NVM buttons → reserve/release
|
||||
deckGrid.addEventListener('click', function (e) {
|
||||
if (e.target.closest('.sig-ok-btn')) {
|
||||
if (_reservedCardId) return; // already holding — must NVM first
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (card) doReserve(card);
|
||||
return;
|
||||
}
|
||||
if (e.target.closest('.sig-nvm-btn')) {
|
||||
doRelease();
|
||||
return;
|
||||
}
|
||||
var card = e.target.closest('.sig-card');
|
||||
if (!card) return;
|
||||
if (!isEligible()) return;
|
||||
var activeRole = getActiveRole();
|
||||
var cardId = card.dataset.cardId;
|
||||
var deckType = card.dataset.deck;
|
||||
window.showGuard(card, 'Select this significator?', function () {
|
||||
fetch(selectUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'X-CSRFToken': getCsrf(),
|
||||
},
|
||||
body: 'card_id=' + encodeURIComponent(cardId) + '&deck_type=' + encodeURIComponent(deckType),
|
||||
}).then(function (response) {
|
||||
if (response.ok) {
|
||||
applySelection(cardId, activeRole, deckType);
|
||||
}
|
||||
});
|
||||
});
|
||||
if (_reservedCardId) return; // locked until NVM
|
||||
var reservedByOther = card.dataset.reservedBy && card.dataset.reservedBy !== userRole;
|
||||
var isOwnReserved = card.classList.contains('sig-reserved--own');
|
||||
if (reservedByOther || isOwnReserved) return;
|
||||
focusCard(card);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('room:sig_selected', function (e) {
|
||||
if (!sigDeck) return;
|
||||
var cardId = String(e.detail.card_id);
|
||||
var role = e.detail.role;
|
||||
var deckType = e.detail.deck_type;
|
||||
// Idempotent — skip if this copy already removed (local selector already did it)
|
||||
if (!sigDeck.querySelector('.sig-card.' + deckType + '-deck[data-card-id="' + cardId + '"]')) return;
|
||||
applySelection(cardId, role, deckType);
|
||||
});
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', init);
|
||||
} else {
|
||||
init();
|
||||
}
|
||||
|
||||
// ── Test API ──────────────────────────────────────────────────────────
|
||||
return {
|
||||
_testInit: function () {
|
||||
_focusedCardEl = null;
|
||||
_reservedCardId = null;
|
||||
_stageFrozen = false;
|
||||
_requestInFlight = false;
|
||||
_cautionData = [];
|
||||
_cautionIdx = 0;
|
||||
Object.keys(_floatingCursors).forEach(function (k) { _floatingCursors[k].remove(); });
|
||||
_floatingCursors = {};
|
||||
Object.keys(_reservedFloats).forEach(function (k) { _reservedFloats[k].remove(); });
|
||||
_reservedFloats = {};
|
||||
_cursorPortal = null;
|
||||
_isReady = false;
|
||||
_stopWaitNoGlow();
|
||||
_hideTakeSigBtn();
|
||||
_hideCountdown();
|
||||
_countdownSecondsLeft = 0;
|
||||
init();
|
||||
},
|
||||
_setFrozen: function (v) { _stageFrozen = v; },
|
||||
_setReservedCardId: function (id) { _reservedCardId = id; },
|
||||
};
|
||||
}());
|
||||
|
||||
@@ -14,6 +14,13 @@ var Tray = (function () {
|
||||
var _tray = null;
|
||||
var _grid = null;
|
||||
|
||||
// Role code → scrawl SVG name mapping for tray card display.
|
||||
var _ROLE_SCRAWL = {
|
||||
PC: 'Player', NC: 'Narrator', EC: 'Economist',
|
||||
SC: 'Shepherd', AC: 'Alchemist', BC: 'Builder'
|
||||
};
|
||||
var _roleIconsUrl = null;
|
||||
|
||||
// Portrait bounds (X axis)
|
||||
var _minLeft = 0;
|
||||
var _maxLeft = 0;
|
||||
@@ -94,12 +101,7 @@ var Tray = (function () {
|
||||
// Closed: tray hidden above viewport, handle visible at y=0.
|
||||
_maxTop = -(gearBtnTop - handleH);
|
||||
} else {
|
||||
// Portrait: slide on X axis.
|
||||
// Wrap width is pinned to viewportW (JS) so its right edge only
|
||||
// reaches the viewport boundary when left = 0 (fully open).
|
||||
// This mirrors landscape: the open edge appears only at the last moment.
|
||||
// Open: left = 0 → wrap right = viewportW exactly.
|
||||
// Closed: left = viewportW - handleW → tray fully off-screen right.
|
||||
// Portrait: wrap width = full viewport; handle parks at right edge.
|
||||
var handleW = _btn.offsetWidth || 48;
|
||||
if (_wrap) _wrap.style.width = window.innerWidth + 'px';
|
||||
_minLeft = 0;
|
||||
@@ -251,7 +253,13 @@ var Tray = (function () {
|
||||
|
||||
firstCell.classList.add('tray-role-card');
|
||||
firstCell.dataset.role = roleCode;
|
||||
firstCell.textContent = roleCode;
|
||||
firstCell.textContent = '';
|
||||
if (_roleIconsUrl) {
|
||||
var img = document.createElement('img');
|
||||
img.src = _roleIconsUrl + 'starter-role-' + (_ROLE_SCRAWL[roleCode] || 'Blank') + '.svg';
|
||||
img.alt = roleCode;
|
||||
firstCell.appendChild(img);
|
||||
}
|
||||
|
||||
open();
|
||||
_arcIn(firstCell, function () {
|
||||
@@ -290,11 +298,48 @@ var Tray = (function () {
|
||||
}
|
||||
}
|
||||
|
||||
// Force-close and reposition to settled bounds. Called on both 'resize'
|
||||
// (snap without transition to avoid flicker during continuous events) and
|
||||
// 'resize:end' (re-measures after the viewport has stopped moving).
|
||||
function _reposition() {
|
||||
_cancelPendingHide();
|
||||
_open = false;
|
||||
if (_btn) _btn.classList.remove('open');
|
||||
if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||
|
||||
if (_isLandscape()) {
|
||||
// Ensure tray is visible before measuring bounds.
|
||||
if (_tray) _tray.style.display = 'grid';
|
||||
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; }
|
||||
_computeBounds();
|
||||
_computeCellSize();
|
||||
if (_wrap) {
|
||||
_wrap.classList.add('tray-dragging');
|
||||
_wrap.style.top = _maxTop + 'px';
|
||||
void _wrap.offsetWidth; // flush reflow so position lands before transition restored
|
||||
_wrap.classList.remove('tray-dragging');
|
||||
}
|
||||
} else {
|
||||
if (_tray) _tray.style.display = 'none';
|
||||
if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; _wrap.style.width = ''; }
|
||||
_computeBounds();
|
||||
_applyVerticalBounds();
|
||||
_computeCellSize();
|
||||
if (_wrap) {
|
||||
_wrap.classList.add('tray-dragging');
|
||||
_wrap.style.left = _maxLeft + 'px';
|
||||
void _wrap.offsetWidth; // flush reflow
|
||||
_wrap.classList.remove('tray-dragging');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function init() {
|
||||
_wrap = document.getElementById('id_tray_wrap');
|
||||
_btn = document.getElementById('id_tray_btn');
|
||||
_tray = document.getElementById('id_tray');
|
||||
_grid = document.getElementById('id_tray_grid');
|
||||
_roleIconsUrl = (_grid && _grid.dataset.roleIconsUrl) || null;
|
||||
if (!_btn) return;
|
||||
|
||||
if (_isLandscape()) {
|
||||
@@ -306,8 +351,8 @@ var Tray = (function () {
|
||||
if (_wrap) _wrap.style.top = _maxTop + 'px';
|
||||
_computeCellSize();
|
||||
} else {
|
||||
// Clear landscape's inline top so portrait CSS applies.
|
||||
if (_wrap) _wrap.style.top = '';
|
||||
// Clear landscape's inline top/height/width so portrait CSS applies.
|
||||
if (_wrap) { _wrap.style.top = ''; _wrap.style.width = ''; }
|
||||
_applyVerticalBounds();
|
||||
_computeCellSize(); // wrap has correct height after _applyVerticalBounds
|
||||
_computeBounds();
|
||||
@@ -403,42 +448,8 @@ var Tray = (function () {
|
||||
};
|
||||
_btn.addEventListener('click', _onBtnClick);
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
// Always close on resize: bounds change invalidates current position.
|
||||
// Cancel any in-flight close animation, then force-close state.
|
||||
_cancelPendingHide();
|
||||
_open = false;
|
||||
if (_btn) _btn.classList.remove('open');
|
||||
if (_wrap) _wrap.classList.remove('wobble', 'snap', 'tray-dragging');
|
||||
|
||||
if (_isLandscape()) {
|
||||
// Ensure tray is visible before measuring bounds.
|
||||
if (_tray) _tray.style.display = 'grid';
|
||||
if (_wrap) { _wrap.style.left = ''; _wrap.style.bottom = ''; _wrap.style.width = ''; }
|
||||
_computeBounds();
|
||||
_computeCellSize();
|
||||
// Snap to closed without transition (resize fires continuously).
|
||||
if (_wrap) {
|
||||
_wrap.classList.add('tray-dragging');
|
||||
_wrap.style.top = _maxTop + 'px';
|
||||
void _wrap.offsetWidth; // flush reflow so position lands before transition restored
|
||||
_wrap.classList.remove('tray-dragging');
|
||||
}
|
||||
} else {
|
||||
if (_tray) _tray.style.display = 'none';
|
||||
if (_wrap) { _wrap.style.top = ''; _wrap.style.height = ''; }
|
||||
_computeBounds();
|
||||
_applyVerticalBounds();
|
||||
_computeCellSize();
|
||||
// Snap to closed without transition.
|
||||
if (_wrap) {
|
||||
_wrap.classList.add('tray-dragging');
|
||||
_wrap.style.left = _maxLeft + 'px';
|
||||
void _wrap.offsetWidth; // flush reflow
|
||||
_wrap.classList.remove('tray-dragging');
|
||||
}
|
||||
}
|
||||
});
|
||||
window.addEventListener('resize', _reposition);
|
||||
window.addEventListener('resize:end', _reposition);
|
||||
}
|
||||
|
||||
// reset() — restores module state; used by Jasmine afterEach
|
||||
|
||||
95
src/apps/epic/tasks.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Countdown scheduler for the polarity-room TAKE SIG gate.
|
||||
|
||||
Uses threading.Timer so no separate Celery worker is needed in development.
|
||||
Single-process only — swap for a Celery task if production uses multiple
|
||||
web workers (gunicorn -w N with N > 1).
|
||||
"""
|
||||
import threading
|
||||
import uuid
|
||||
|
||||
from asgiref.sync import async_to_sync
|
||||
from channels.layers import get_channel_layer
|
||||
from django.core.cache import cache
|
||||
|
||||
_LEVITY_ROLES = {'PC', 'NC', 'SC'}
|
||||
_GRAVITY_ROLES = {'BC', 'EC', 'AC'}
|
||||
|
||||
# In-process registry of pending timers: "{room_id}_{polarity}" → Timer
|
||||
_timers = {}
|
||||
|
||||
|
||||
def _cache_key(room_id, polarity):
|
||||
return f'sig_countdown_{room_id}_{polarity}'
|
||||
|
||||
|
||||
def _group_send(room_id, msg):
|
||||
async_to_sync(get_channel_layer().group_send)(f'room_{room_id}', msg)
|
||||
|
||||
|
||||
def _fire(room_id, polarity, token):
|
||||
"""Callback run by threading.Timer after the countdown expires."""
|
||||
# Token guard: if cancelled or superseded, cache entry will differ
|
||||
if cache.get(_cache_key(room_id, polarity)) != token:
|
||||
return
|
||||
|
||||
from apps.epic.models import Room, SigReservation
|
||||
|
||||
try:
|
||||
room = Room.objects.get(id=room_id)
|
||||
except Room.DoesNotExist:
|
||||
return
|
||||
|
||||
if room.table_status != Room.SIG_SELECT:
|
||||
return
|
||||
|
||||
polarity_roles = _LEVITY_ROLES if polarity == SigReservation.LEVITY else _GRAVITY_ROLES
|
||||
|
||||
# Idempotency: seats already assigned
|
||||
if not room.table_seats.filter(role__in=polarity_roles, significator__isnull=True).exists():
|
||||
return
|
||||
|
||||
# Safety: all three must still be ready
|
||||
ready_reservations = list(
|
||||
SigReservation.objects.filter(room=room, polarity=polarity, ready=True)
|
||||
.select_related('seat', 'card')
|
||||
)
|
||||
if len(ready_reservations) < 3:
|
||||
return
|
||||
|
||||
for res in ready_reservations:
|
||||
if res.seat:
|
||||
res.seat.significator = res.card
|
||||
res.seat.save(update_fields=['significator'])
|
||||
|
||||
SigReservation.objects.filter(room=room, polarity=polarity).update(countdown_remaining=None)
|
||||
|
||||
_group_send(room_id, {'type': 'polarity_room_done', 'polarity': polarity})
|
||||
|
||||
if not room.table_seats.filter(significator__isnull=True).exists():
|
||||
Room.objects.filter(id=room_id).update(table_status=Room.SKY_SELECT)
|
||||
_group_send(room_id, {'type': 'pick_sky_available'})
|
||||
|
||||
cache.delete(_cache_key(room_id, polarity))
|
||||
_timers.pop(f'{room_id}_{polarity}', None)
|
||||
|
||||
|
||||
def schedule_polarity_confirm(room_id, polarity, seconds):
|
||||
"""Schedule a polarity confirm `seconds` seconds from now. Cancels any prior timer."""
|
||||
cancel_polarity_confirm(room_id, polarity)
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
cache.set(_cache_key(room_id, polarity), token, timeout=int(seconds) + 60)
|
||||
|
||||
timer = threading.Timer(seconds, _fire, args=[str(room_id), polarity, token])
|
||||
timer.daemon = True
|
||||
timer.start()
|
||||
_timers[f'{room_id}_{polarity}'] = timer
|
||||
|
||||
|
||||
def cancel_polarity_confirm(room_id, polarity):
|
||||
"""Cancel any pending confirm for this room + polarity."""
|
||||
timer = _timers.pop(f'{room_id}_{polarity}', None)
|
||||
if timer:
|
||||
timer.cancel()
|
||||
cache.delete(_cache_key(room_id, polarity))
|
||||
@@ -164,3 +164,157 @@ class CursorMoveConsumerTest(TransactionTestCase):
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await bc_comm.disconnect()
|
||||
|
||||
|
||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||
class MissingConsumerHandlersTest(SimpleTestCase):
|
||||
"""Covers the simple pass-through handlers not exercised by other tests."""
|
||||
|
||||
async def _send_and_receive(self, room_path, group_name, msg):
|
||||
communicator = WebsocketCommunicator(application, room_path)
|
||||
await communicator.connect()
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(group_name, msg)
|
||||
response = await communicator.receive_json_from()
|
||||
await communicator.disconnect()
|
||||
return response
|
||||
|
||||
async def test_receives_sig_selected_broadcast(self):
|
||||
room_id = "00000000-0000-0000-0000-000000000002"
|
||||
response = await self._send_and_receive(
|
||||
f"/ws/room/{room_id}/",
|
||||
f"room_{room_id}",
|
||||
{"type": "sig_selected", "card_id": "abc"},
|
||||
)
|
||||
self.assertEqual(response["type"], "sig_selected")
|
||||
|
||||
async def test_receives_countdown_start_broadcast(self):
|
||||
room_id = "00000000-0000-0000-0000-000000000002"
|
||||
response = await self._send_and_receive(
|
||||
f"/ws/room/{room_id}/",
|
||||
f"room_{room_id}",
|
||||
{"type": "countdown_start", "polarity": "levity", "seconds": 12},
|
||||
)
|
||||
self.assertEqual(response["type"], "countdown_start")
|
||||
|
||||
async def test_receives_countdown_cancel_broadcast(self):
|
||||
room_id = "00000000-0000-0000-0000-000000000002"
|
||||
response = await self._send_and_receive(
|
||||
f"/ws/room/{room_id}/",
|
||||
f"room_{room_id}",
|
||||
{"type": "countdown_cancel", "polarity": "levity", "seconds_remaining": 7},
|
||||
)
|
||||
self.assertEqual(response["type"], "countdown_cancel")
|
||||
|
||||
async def test_receives_polarity_room_done_broadcast(self):
|
||||
room_id = "00000000-0000-0000-0000-000000000002"
|
||||
response = await self._send_and_receive(
|
||||
f"/ws/room/{room_id}/",
|
||||
f"room_{room_id}",
|
||||
{"type": "polarity_room_done", "polarity": "levity"},
|
||||
)
|
||||
self.assertEqual(response["type"], "polarity_room_done")
|
||||
|
||||
async def test_receives_pick_sky_available_broadcast(self):
|
||||
room_id = "00000000-0000-0000-0000-000000000002"
|
||||
response = await self._send_and_receive(
|
||||
f"/ws/room/{room_id}/",
|
||||
f"room_{room_id}",
|
||||
{"type": "pick_sky_available"},
|
||||
)
|
||||
self.assertEqual(response["type"], "pick_sky_available")
|
||||
|
||||
|
||||
@tag('channels')
|
||||
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||
class SigHoverConsumerTest(TransactionTestCase):
|
||||
"""sig_hover messages sent by a client are forwarded within the polarity group only."""
|
||||
|
||||
async def _make_communicator(self, user, room):
|
||||
client = Client()
|
||||
await database_sync_to_async(client.force_login)(user)
|
||||
session_key = await database_sync_to_async(lambda: client.session.session_key)()
|
||||
comm = WebsocketCommunicator(
|
||||
application,
|
||||
f"/ws/room/{room.id}/",
|
||||
headers=[(b"cookie", f"sessionid={session_key}".encode())],
|
||||
)
|
||||
connected, _ = await comm.connect()
|
||||
self.assertTrue(connected)
|
||||
return comm
|
||||
|
||||
async def test_sig_hover_forwarded_to_polarity_group(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||
)
|
||||
|
||||
pc_comm = await self._make_communicator(pc_user, room)
|
||||
nc_comm = await self._make_communicator(nc_user, room)
|
||||
|
||||
await pc_comm.send_json_to({
|
||||
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||
})
|
||||
|
||||
msg = await nc_comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "sig_hover")
|
||||
self.assertEqual(msg["card_id"], "abc-123")
|
||||
self.assertEqual(msg["role"], "PC")
|
||||
self.assertTrue(msg["active"])
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await nc_comm.disconnect()
|
||||
|
||||
async def test_sig_hover_not_forwarded_to_other_polarity(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
bc_user = await database_sync_to_async(User.objects.create)(email="bc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=bc_user, slot_number=2, role="BC"
|
||||
)
|
||||
|
||||
pc_comm = await self._make_communicator(pc_user, room)
|
||||
bc_comm = await self._make_communicator(bc_user, room)
|
||||
|
||||
await pc_comm.send_json_to({
|
||||
"type": "sig_hover", "card_id": "abc-123", "role": "PC", "active": True
|
||||
})
|
||||
|
||||
self.assertTrue(await bc_comm.receive_nothing(timeout=1))
|
||||
|
||||
await pc_comm.disconnect()
|
||||
await bc_comm.disconnect()
|
||||
|
||||
async def test_sig_reserved_broadcast_received_by_polarity_group(self):
|
||||
pc_user = await database_sync_to_async(User.objects.create)(email="pc@test.io")
|
||||
nc_user = await database_sync_to_async(User.objects.create)(email="nc@test.io")
|
||||
room = await database_sync_to_async(Room.objects.create)(name="T", owner=pc_user)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=pc_user, slot_number=1, role="PC"
|
||||
)
|
||||
await database_sync_to_async(TableSeat.objects.create)(
|
||||
room=room, gamer=nc_user, slot_number=2, role="NC"
|
||||
)
|
||||
|
||||
nc_comm = await self._make_communicator(nc_user, room)
|
||||
|
||||
channel_layer = get_channel_layer()
|
||||
await channel_layer.group_send(
|
||||
f"cursors_{room.id}_levity",
|
||||
{"type": "sig_reserved", "card_id": "card-xyz", "role": "PC", "reserved": True},
|
||||
)
|
||||
|
||||
msg = await nc_comm.receive_json_from(timeout=2)
|
||||
self.assertEqual(msg["type"], "sig_reserved")
|
||||
self.assertEqual(msg["card_id"], "card-xyz")
|
||||
self.assertTrue(msg["reserved"])
|
||||
|
||||
await nc_comm.disconnect()
|
||||
|
||||
@@ -4,10 +4,14 @@ from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, sig_seat_order, active_sig_seat,
|
||||
AspectType, Character, DeckVariant, GateSlot, HouseLabel, Planet, Room, RoomInvite,
|
||||
SigReservation, Sign, TableSeat, TarotCard,
|
||||
debit_token, select_token, sig_deck_cards, levity_sig_cards, gravity_sig_cards,
|
||||
sig_seat_order, active_sig_seat,
|
||||
)
|
||||
|
||||
|
||||
@@ -266,16 +270,16 @@ class SigDeckCompositionTest(TestCase):
|
||||
cards = sig_deck_cards(self.room)
|
||||
self.assertEqual(len(cards), 36)
|
||||
|
||||
def test_sc_ac_contribute_court_cards_of_swords_and_cups(self):
|
||||
def test_sc_ac_contribute_court_cards_of_blades_and_grails(self):
|
||||
cards = sig_deck_cards(self.room)
|
||||
sc_ac = [c for c in cards if c.suit in ("SWORDS", "CUPS")]
|
||||
sc_ac = [c for c in cards if c.suit in ("BLADES", "GRAILS")]
|
||||
# M/J/Q/K × 2 suits × 2 roles = 16
|
||||
self.assertEqual(len(sc_ac), 16)
|
||||
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in sc_ac))
|
||||
|
||||
def test_pc_bc_contribute_court_cards_of_wands_and_pentacles(self):
|
||||
def test_pc_bc_contribute_court_cards_of_brands_and_crowns(self):
|
||||
cards = sig_deck_cards(self.room)
|
||||
pc_bc = [c for c in cards if c.suit in ("WANDS", "PENTACLES")]
|
||||
pc_bc = [c for c in cards if c.suit in ("BRANDS", "CROWNS")]
|
||||
self.assertEqual(len(pc_bc), 16)
|
||||
self.assertTrue(all(c.number in (11, 12, 13, 14) for c in pc_bc))
|
||||
|
||||
@@ -339,7 +343,7 @@ class SigCardFieldTest(TestCase):
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
self.card = TarotCard.objects.get(
|
||||
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11,
|
||||
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11,
|
||||
)
|
||||
owner = User.objects.create(email="owner@test.io")
|
||||
room = Room.objects.create(name="Field Test", owner=owner)
|
||||
@@ -360,3 +364,342 @@ class SigCardFieldTest(TestCase):
|
||||
self.card.delete()
|
||||
self.seat.refresh_from_db()
|
||||
self.assertIsNone(self.seat.significator)
|
||||
|
||||
|
||||
# ── SigReservation model ──────────────────────────────────────────────────────
|
||||
|
||||
def _make_sig_card(deck_variant, suit, number):
|
||||
name_map = {11: "Maid", 12: "Jack", 13: "Queen", 14: "King"}
|
||||
card, _ = TarotCard.objects.get_or_create(
|
||||
deck_variant=deck_variant,
|
||||
slug=f"{name_map[number].lower()}-of-{suit.lower()}-em",
|
||||
defaults={
|
||||
"arcana": "MINOR", "suit": suit, "number": number,
|
||||
"name": f"{name_map[number]} of {suit.capitalize()}",
|
||||
},
|
||||
)
|
||||
return card
|
||||
|
||||
|
||||
class SigReservationModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.earthman, _ = DeckVariant.objects.get_or_create(
|
||||
slug="earthman",
|
||||
defaults={"name": "Earthman Deck", "card_count": 108, "is_default": True},
|
||||
)
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.room = Room.objects.create(name="Sig Room", owner=self.owner)
|
||||
self.card = _make_sig_card(self.earthman, "WANDS", 14)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.owner, slot_number=1, role="PC"
|
||||
)
|
||||
|
||||
def test_can_create_sig_reservation(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
self.assertEqual(res.role, "PC")
|
||||
self.assertEqual(res.polarity, "levity")
|
||||
self.assertIsNotNone(res.reserved_at)
|
||||
|
||||
def test_one_reservation_per_gamer_per_room(self):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
card2 = _make_sig_card(self.earthman, "CUPS", 13)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=card2, role="PC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_blocked_within_same_polarity(self):
|
||||
gamer2 = User.objects.create(email="nc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="NC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
with self.assertRaises(IntegrityError):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="NC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_same_card_allowed_across_polarity(self):
|
||||
"""A gravity gamer may reserve the same card instance as a levity gamer
|
||||
— each polarity has its own independent pile."""
|
||||
gamer2 = User.objects.create(email="bc@test.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=gamer2, slot_number=2, role="BC")
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res2 = SigReservation.objects.create(
|
||||
room=self.room, gamer=gamer2, card=self.card, role="BC", polarity="gravity"
|
||||
)
|
||||
self.assertIsNotNone(res2.pk)
|
||||
|
||||
def test_deleting_reservation_clears_slot(self):
|
||||
res = SigReservation.objects.create(
|
||||
room=self.room, gamer=self.owner, card=self.card, role="PC", polarity="levity"
|
||||
)
|
||||
res.delete()
|
||||
self.assertFalse(SigReservation.objects.filter(room=self.room, gamer=self.owner).exists())
|
||||
|
||||
|
||||
class SigCardHelperTest(TestCase):
|
||||
"""levity_sig_cards() and gravity_sig_cards() return 18 cards each.
|
||||
Relies on the Earthman deck seeded by migrations (no manual card creation).
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
# Earthman deck is already seeded by migrations
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
self.owner = User.objects.create(email="founder@test.io")
|
||||
self.owner.equipped_deck = self.earthman
|
||||
self.owner.save()
|
||||
self.room = Room.objects.create(name="Card Test", owner=self.owner)
|
||||
|
||||
def test_levity_sig_cards_returns_18(self):
|
||||
cards = levity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_gravity_sig_cards_returns_18(self):
|
||||
cards = gravity_sig_cards(self.room)
|
||||
self.assertEqual(len(cards), 18)
|
||||
|
||||
def test_levity_and_gravity_share_same_card_objects(self):
|
||||
"""Both piles draw from the same 18 TarotCard instances — visual distinction
|
||||
comes from CSS polarity class, not separate card model records."""
|
||||
levity = levity_sig_cards(self.room)
|
||||
gravity = gravity_sig_cards(self.room)
|
||||
self.assertEqual(
|
||||
sorted(c.pk for c in levity),
|
||||
sorted(c.pk for c in gravity),
|
||||
)
|
||||
|
||||
def test_returns_empty_when_no_equipped_deck(self):
|
||||
self.owner.equipped_deck = None
|
||||
self.owner.save()
|
||||
self.assertEqual(levity_sig_cards(self.room), [])
|
||||
self.assertEqual(gravity_sig_cards(self.room), [])
|
||||
|
||||
|
||||
class TarotCardCautionsTest(TestCase):
|
||||
"""TarotCard.cautions JSONField — field existence and Schizo seed data."""
|
||||
|
||||
def setUp(self):
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
|
||||
def test_cautions_field_saves_and_retrieves_list(self):
|
||||
card = TarotCard.objects.create(
|
||||
deck_variant=self.earthman,
|
||||
arcana="MINOR",
|
||||
suit="CROWNS",
|
||||
number=99,
|
||||
name="Test Card",
|
||||
slug="test-card-cautions",
|
||||
cautions=["First caution.", "Second caution."],
|
||||
)
|
||||
card.refresh_from_db()
|
||||
self.assertEqual(card.cautions, ["First caution.", "Second caution."])
|
||||
|
||||
def test_cautions_defaults_to_empty_list(self):
|
||||
card = TarotCard.objects.create(
|
||||
deck_variant=self.earthman,
|
||||
arcana="MINOR",
|
||||
suit="CROWNS",
|
||||
number=98,
|
||||
name="Default Cautions Card",
|
||||
slug="default-cautions-card",
|
||||
)
|
||||
self.assertEqual(card.cautions, [])
|
||||
|
||||
def test_schizo_has_4_cautions(self):
|
||||
schizo = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||
)
|
||||
self.assertEqual(len(schizo.cautions), 4)
|
||||
|
||||
def test_schizo_caution_references_the_pervert(self):
|
||||
schizo = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||
)
|
||||
self.assertIn("The Pervert", schizo.cautions[0])
|
||||
|
||||
def test_schizo_cautions_use_reverse_language(self):
|
||||
schizo = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MAJOR", number=1
|
||||
)
|
||||
for caution in schizo.cautions:
|
||||
self.assertIn("reverse", caution)
|
||||
self.assertNotIn("transform", caution)
|
||||
|
||||
|
||||
# ── SigReservation ready gate ─────────────────────────────────────────────────
|
||||
|
||||
class SigReservationReadyGateTest(TestCase):
|
||||
"""SigReservation.ready and countdown_remaining fields."""
|
||||
|
||||
def setUp(self):
|
||||
self.earthman = DeckVariant.objects.get(slug="earthman")
|
||||
owner = User.objects.create(email="owner@test.io")
|
||||
room = Room.objects.create(name="R", owner=owner)
|
||||
card = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||
)
|
||||
self.res = SigReservation.objects.create(
|
||||
room=room, gamer=owner, card=card, role="PC", polarity="levity"
|
||||
)
|
||||
|
||||
def test_ready_defaults_to_false(self):
|
||||
self.assertFalse(self.res.ready)
|
||||
|
||||
def test_countdown_remaining_defaults_to_none(self):
|
||||
self.assertIsNone(self.res.countdown_remaining)
|
||||
|
||||
def test_ready_can_be_set_true(self):
|
||||
self.res.ready = True
|
||||
self.res.save()
|
||||
self.res.refresh_from_db()
|
||||
self.assertTrue(self.res.ready)
|
||||
|
||||
def test_countdown_remaining_can_be_saved(self):
|
||||
self.res.countdown_remaining = 7
|
||||
self.res.save()
|
||||
self.res.refresh_from_db()
|
||||
self.assertEqual(self.res.countdown_remaining, 7)
|
||||
|
||||
|
||||
# ── Room SKY_SELECT status ────────────────────────────────────────────────────
|
||||
|
||||
class RoomSkySelectStatusTest(TestCase):
|
||||
"""Room.SKY_SELECT constant and sig_select_started_at field."""
|
||||
|
||||
def setUp(self):
|
||||
owner = User.objects.create(email="owner@test.io")
|
||||
self.room = Room.objects.create(name="R", owner=owner)
|
||||
|
||||
def test_sky_select_constant_value(self):
|
||||
self.assertEqual(Room.SKY_SELECT, "SKY_SELECT")
|
||||
|
||||
def test_sky_select_is_valid_table_status_choice(self):
|
||||
choices = [c[0] for c in Room.TABLE_STATUS_CHOICES]
|
||||
self.assertIn(Room.SKY_SELECT, choices)
|
||||
|
||||
def test_sig_select_started_at_defaults_to_none(self):
|
||||
self.assertIsNone(self.room.sig_select_started_at)
|
||||
|
||||
def test_sig_select_started_at_can_be_set(self):
|
||||
from django.utils import timezone
|
||||
now = timezone.now()
|
||||
self.room.sig_select_started_at = now
|
||||
self.room.save()
|
||||
self.room.refresh_from_db()
|
||||
self.assertIsNotNone(self.room.sig_select_started_at)
|
||||
|
||||
|
||||
# ── TarotDeck.draw / shuffle ──────────────────────────────────────────────────
|
||||
|
||||
class TarotDeckDrawTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="dealer@test.io")
|
||||
self.room = Room.objects.create(name="R", owner=self.user)
|
||||
|
||||
def test_draw_raises_value_error_when_too_few_cards_remain(self):
|
||||
from apps.epic.models import TarotDeck
|
||||
deck_variant = DeckVariant.objects.first()
|
||||
all_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True))
|
||||
td = TarotDeck.objects.create(
|
||||
room=self.room,
|
||||
deck_variant=deck_variant,
|
||||
drawn_card_ids=all_ids,
|
||||
)
|
||||
with self.assertRaises(ValueError):
|
||||
td.draw(1)
|
||||
|
||||
def test_shuffle_resets_drawn_card_ids(self):
|
||||
from apps.epic.models import TarotDeck
|
||||
deck_variant = DeckVariant.objects.first()
|
||||
some_ids = list(TarotCard.objects.filter(deck_variant=deck_variant).values_list('id', flat=True)[:3])
|
||||
td = TarotDeck.objects.create(
|
||||
room=self.room,
|
||||
deck_variant=deck_variant,
|
||||
drawn_card_ids=some_ids,
|
||||
)
|
||||
td.shuffle()
|
||||
td.refresh_from_db()
|
||||
self.assertEqual(td.drawn_card_ids, [])
|
||||
|
||||
|
||||
# ── sig_deck_cards with no equipped deck ─────────────────────────────────────
|
||||
|
||||
class SigDeckCardsNoEquippedDeckTest(TestCase):
|
||||
def test_returns_empty_list_when_owner_has_no_equipped_deck(self):
|
||||
user = User.objects.create(email="nodeck@test.io")
|
||||
user.equipped_deck = None
|
||||
user.save(update_fields=["equipped_deck"])
|
||||
room = Room.objects.create(name="R", owner=user)
|
||||
self.assertEqual(sig_deck_cards(room), [])
|
||||
|
||||
|
||||
# ── Astrology model __str__ methods ──────────────────────────────────────────
|
||||
|
||||
class AstrologyModelStrTest(TestCase):
|
||||
def test_zodiac_sign_str(self):
|
||||
sign = Sign.objects.first()
|
||||
if sign is None:
|
||||
self.skipTest("No Sign rows")
|
||||
self.assertEqual(str(sign), sign.name)
|
||||
|
||||
def test_planet_str(self):
|
||||
planet = Planet.objects.first()
|
||||
if planet is None:
|
||||
self.skipTest("No Planet rows")
|
||||
self.assertEqual(str(planet), planet.name)
|
||||
|
||||
def test_aspect_type_str(self):
|
||||
aspect = AspectType.objects.first()
|
||||
if aspect is None:
|
||||
self.skipTest("No AspectType rows")
|
||||
self.assertEqual(str(aspect), aspect.name)
|
||||
|
||||
def test_house_label_str(self):
|
||||
label = HouseLabel.objects.first()
|
||||
if label is None:
|
||||
self.skipTest("No HouseLabel rows")
|
||||
self.assertIn(str(label.number), str(label))
|
||||
|
||||
|
||||
# ── Character model ───────────────────────────────────────────────────────────
|
||||
|
||||
class CharacterModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="char@test.io")
|
||||
self.room = Room.objects.create(name="R", owner=self.user)
|
||||
self.seat = TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=1, role="PC")
|
||||
|
||||
def test_draft_str(self):
|
||||
char = Character.objects.create(seat=self.seat)
|
||||
self.assertIn("draft", str(char))
|
||||
|
||||
def test_confirmed_str(self):
|
||||
char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
|
||||
self.assertIn("confirmed", str(char))
|
||||
|
||||
def test_is_confirmed_false_for_draft(self):
|
||||
char = Character.objects.create(seat=self.seat)
|
||||
self.assertFalse(char.is_confirmed)
|
||||
|
||||
def test_is_confirmed_true_when_confirmed_at_set(self):
|
||||
char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
|
||||
self.assertTrue(char.is_confirmed)
|
||||
|
||||
def test_is_active_true_when_confirmed_and_not_retired(self):
|
||||
char = Character.objects.create(seat=self.seat, confirmed_at=timezone.now())
|
||||
self.assertTrue(char.is_active)
|
||||
|
||||
def test_is_active_false_when_retired(self):
|
||||
char = Character.objects.create(
|
||||
seat=self.seat,
|
||||
confirmed_at=timezone.now(),
|
||||
retired_at=timezone.now(),
|
||||
)
|
||||
self.assertFalse(char.is_active)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import ANY, patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
@@ -8,7 +8,7 @@ from django.utils import timezone
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.lyric.models import Token, User
|
||||
from apps.epic.models import (
|
||||
DeckVariant, GateSlot, Room, RoomInvite, TableSeat, TarotCard,
|
||||
DeckVariant, GateSlot, Room, RoomInvite, SigReservation, TableSeat, TarotCard,
|
||||
)
|
||||
|
||||
|
||||
@@ -926,7 +926,7 @@ def _full_sig_setUp(test_case, role_order=None):
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
card_in_deck = TarotCard.objects.get(
|
||||
deck_variant=earthman, arcana="MINOR", suit="WANDS", number=11
|
||||
deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||
)
|
||||
test_case.client.force_login(founder)
|
||||
return room, gamers, earthman, card_in_deck
|
||||
@@ -943,9 +943,9 @@ class SigSelectRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "id_sig_deck")
|
||||
|
||||
def test_sig_deck_contains_36_sig_cards(self):
|
||||
def test_sig_deck_contains_18_sig_cards(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.content.decode().count('sig-card'), 36)
|
||||
self.assertEqual(response.content.decode().count('data-card-id='), 18)
|
||||
|
||||
def test_seats_rendered_in_pc_nc_ec_sc_ac_bc_order(self):
|
||||
response = self.client.get(self.url)
|
||||
@@ -963,6 +963,32 @@ class SigSelectRenderingTest(TestCase):
|
||||
response = self.client.get(self.url)
|
||||
self.assertNotContains(response, "id_sig_deck")
|
||||
|
||||
def test_sig_cards_render_keyword_data_attributes(self):
|
||||
response = self.client.get(self.url)
|
||||
content = response.content.decode()
|
||||
self.assertIn("data-keywords-upright=", content)
|
||||
self.assertIn("data-keywords-reversed=", content)
|
||||
|
||||
def test_sig_stat_block_structure_rendered(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "sig-stat-block")
|
||||
self.assertContains(response, "sig-flip-btn")
|
||||
self.assertContains(response, "stat-face--upright")
|
||||
self.assertContains(response, "stat-face--reversed")
|
||||
|
||||
def test_sig_cards_render_cautions_data_attribute(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "data-cautions=")
|
||||
|
||||
def test_sig_caution_tooltip_structure_rendered(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "sig-caution-tooltip")
|
||||
self.assertContains(response, "sig-caution-btn")
|
||||
self.assertContains(response, "sig-caution-effect")
|
||||
self.assertContains(response, "sig-caution-index")
|
||||
self.assertContains(response, "sig-caution-prev")
|
||||
self.assertContains(response, "sig-caution-next")
|
||||
|
||||
|
||||
class SelectSigCardViewTest(TestCase):
|
||||
"""select_sig view — records choice, enforces turn order, rejects bad input."""
|
||||
@@ -1000,8 +1026,8 @@ class SelectSigCardViewTest(TestCase):
|
||||
def test_select_sig_card_not_in_deck_returns_400(self):
|
||||
# Create a pip card (number=5) — not in the sig deck (only court 11–14 + major 0–1)
|
||||
other = TarotCard.objects.create(
|
||||
deck_variant=self.earthman, arcana="MINOR", suit="WANDS", number=5,
|
||||
name="Five of Wands Test", slug="five-of-wands-test",
|
||||
deck_variant=self.earthman, arcana="MINOR", suit="BRANDS", number=5,
|
||||
name="Five of Brands Test", slug="five-of-brands-test",
|
||||
keywords_upright=[], keywords_reversed=[],
|
||||
)
|
||||
response = self._post(card_id=other.id)
|
||||
@@ -1119,3 +1145,700 @@ class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||
|
||||
|
||||
# ── sig_reserve view ──────────────────────────────────────────────────────────
|
||||
|
||||
class SigReserveViewTest(TestCase):
|
||||
"""sig_reserve — provisional card hold; OK/NVM flow."""
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman, self.card = _full_sig_setUp(self)
|
||||
# founder (gamers[0]) is PC — levity polarity
|
||||
self.client.force_login(self.gamers[0])
|
||||
self.url = reverse("epic:sig_reserve", kwargs={"room_id": self.room.id})
|
||||
|
||||
def _reserve(self, card_id=None, action="reserve", client=None):
|
||||
c = client or self.client
|
||||
return c.post(self.url, data={
|
||||
"card_id": card_id or self.card.id,
|
||||
"action": action,
|
||||
})
|
||||
|
||||
# ── happy-path reserve ────────────────────────────────────────────────
|
||||
|
||||
def test_reserve_creates_sig_reservation(self):
|
||||
self._reserve()
|
||||
self.assertTrue(SigReservation.objects.filter(
|
||||
room=self.room, gamer=self.gamers[0], card=self.card
|
||||
).exists())
|
||||
|
||||
def test_reserve_returns_200(self):
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_reservation_has_correct_polarity(self):
|
||||
self._reserve()
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||
self.assertEqual(res.polarity, "levity")
|
||||
|
||||
def test_gravity_gamer_reservation_has_gravity_polarity(self):
|
||||
# gamers[3] is SC (index 3 → role SC → but _full_sig_setUp uses SIG_SEAT_ORDER
|
||||
# which assigns PC→NC→EC→SC→AC→BC, so slot 4 = SC, slot 5 = AC, slot 6 = BC)
|
||||
# gamers[5] is BC → gravity
|
||||
bc_client = self.client.__class__()
|
||||
bc_client.force_login(self.gamers[5])
|
||||
self._reserve(client=bc_client)
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[5])
|
||||
self.assertEqual(res.polarity, "gravity")
|
||||
|
||||
# ── conflict handling ─────────────────────────────────────────────────
|
||||
|
||||
def test_reserve_taken_card_same_polarity_returns_409(self):
|
||||
# NC (gamers[1]) reserves the same card first — both are levity
|
||||
nc_client = self.client.__class__()
|
||||
nc_client.force_login(self.gamers[1])
|
||||
self._reserve(client=nc_client)
|
||||
# Now PC tries to grab the same card — should be blocked
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
def test_reserve_taken_card_cross_polarity_succeeds(self):
|
||||
# BC (gamers[5], gravity) reserves the same card — different polarity, allowed
|
||||
bc_client = self.client.__class__()
|
||||
bc_client.force_login(self.gamers[5])
|
||||
self._reserve(client=bc_client)
|
||||
response = self._reserve() # PC (levity) grabs same card
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_reserve_different_card_while_holding_returns_409(self):
|
||||
"""Cannot OK a different card while holding one — must NVM first."""
|
||||
card_b = TarotCard.objects.filter(
|
||||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||||
).first()
|
||||
self._reserve() # PC grabs card A → 200
|
||||
response = self._reserve(card_id=card_b.id) # tries card B → 409
|
||||
self.assertEqual(response.status_code, 409)
|
||||
# Original reservation still intact
|
||||
reservations = SigReservation.objects.filter(room=self.room, gamer=self.gamers[0])
|
||||
self.assertEqual(reservations.count(), 1)
|
||||
self.assertEqual(reservations.first().card, self.card)
|
||||
|
||||
def test_reserve_same_card_again_is_idempotent(self):
|
||||
"""Re-POSTing the same card while already holding it returns 200 (no-op)."""
|
||||
self._reserve()
|
||||
response = self._reserve() # same card again
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(
|
||||
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).count(), 1
|
||||
)
|
||||
|
||||
def test_reserve_blocked_then_unblocked_after_release(self):
|
||||
"""After NVM, a new card can be OK'd."""
|
||||
card_b = TarotCard.objects.filter(
|
||||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=12
|
||||
).first()
|
||||
self._reserve() # hold card A
|
||||
self._reserve(action="release") # NVM
|
||||
response = self._reserve(card_id=card_b.id) # now card B → 200
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(SigReservation.objects.filter(
|
||||
room=self.room, gamer=self.gamers[0], card=card_b
|
||||
).exists())
|
||||
|
||||
# ── release ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_release_deletes_reservation(self):
|
||||
self._reserve()
|
||||
self._reserve(action="release")
|
||||
self.assertFalse(SigReservation.objects.filter(
|
||||
room=self.room, gamer=self.gamers[0]
|
||||
).exists())
|
||||
|
||||
def test_release_returns_200(self):
|
||||
self._reserve()
|
||||
response = self._reserve(action="release")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_release_with_no_reservation_still_200(self):
|
||||
"""NVM when nothing held is harmless."""
|
||||
response = self._reserve(action="release")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_release_while_ready_records_sig_unready(self):
|
||||
"""Releasing a ready reservation implicitly acts as WAIT NVM and records SIG_UNREADY."""
|
||||
self._reserve()
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||
res.ready = True
|
||||
res.save()
|
||||
self._reserve(action="release")
|
||||
self.assertTrue(self.room.events.filter(
|
||||
actor=self.gamers[0], verb=GameEvent.SIG_UNREADY
|
||||
).exists())
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_reserve_non_post_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_reserve_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/accounts/login/", response.url)
|
||||
|
||||
def test_reserve_requires_seated_gamer(self):
|
||||
outsider = User.objects.create(email="outsider@test.io")
|
||||
outsider_client = self.client.__class__()
|
||||
outsider_client.force_login(outsider)
|
||||
response = self._reserve(client=outsider_client)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_reserve_wrong_phase_returns_400(self):
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
response = self._reserve()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_reserve_broadcasts_ws(self):
|
||||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||
self._reserve()
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
def test_release_broadcasts_ws(self):
|
||||
self._reserve()
|
||||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||
self._reserve(action="release")
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
def test_release_broadcasts_card_id_so_second_browser_can_clear_it(self):
|
||||
"""WS release event must include the card_id; otherwise the receiving
|
||||
browser can't find the card element to remove .sig-reserved--own."""
|
||||
self._reserve()
|
||||
with patch("apps.epic.views._notify_sig_reserved") as mock_notify:
|
||||
self._reserve(action="release")
|
||||
args, kwargs = mock_notify.call_args
|
||||
self.assertEqual(args[1], self.card.pk) # card_id must not be None
|
||||
self.assertFalse(kwargs['reserved']) # reserved=False
|
||||
|
||||
|
||||
# ── sig_ready view ────────────────────────────────────────────────────────────
|
||||
|
||||
def _make_levity_reservations(room, gamers, earthman, ready=False):
|
||||
"""Create SigReservations for the three levity gamers (PC, NC, SC).
|
||||
Returns the three reservations in PC→NC→SC order."""
|
||||
cards = [
|
||||
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="BRANDS", number=n)
|
||||
for n in (11, 12, 13)
|
||||
]
|
||||
roles = ["PC", "NC", "SC"]
|
||||
# gamers[0]=PC, gamers[1]=NC, gamers[3]=SC
|
||||
gamer_indices = [0, 1, 3]
|
||||
reservations = []
|
||||
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||||
seat = TableSeat.objects.get(room=room, role=role)
|
||||
res = SigReservation.objects.create(
|
||||
room=room, gamer=gamers[gamer_idx], card=card,
|
||||
role=role, polarity="levity", seat=seat, ready=ready,
|
||||
)
|
||||
reservations.append(res)
|
||||
return reservations
|
||||
|
||||
|
||||
class SigReadyViewTest(TestCase):
|
||||
"""sig_ready — toggle ready/unready for the polarity-room countdown."""
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||
self.reservations = _make_levity_reservations(self.room, self.gamers, self.earthman)
|
||||
self.url = reverse("epic:sig_ready", kwargs={"room_id": self.room.id})
|
||||
|
||||
def _post(self, action="ready", seconds_remaining=None, client=None):
|
||||
c = client or self.client
|
||||
data = {"action": action}
|
||||
if seconds_remaining is not None:
|
||||
data["seconds_remaining"] = seconds_remaining
|
||||
return c.post(self.url, data=data)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sig_ready_non_post_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_sig_ready_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/accounts/login/", response.url)
|
||||
|
||||
def test_sig_ready_requires_seated_gamer(self):
|
||||
outsider = User.objects.create(email="outsider@test.io")
|
||||
outsider_client = self.client.__class__()
|
||||
outsider_client.force_login(outsider)
|
||||
response = self._post(client=outsider_client)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_sig_ready_wrong_phase_returns_400(self):
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_sig_ready_without_reservation_returns_400(self):
|
||||
"""Can't go ready without an OK'd card."""
|
||||
SigReservation.objects.filter(room=self.room, gamer=self.gamers[0]).delete()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── happy-path ready ──────────────────────────────────────────────────
|
||||
|
||||
def test_sig_ready_sets_ready_true_on_reservation(self):
|
||||
self._post(action="ready")
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||
self.assertTrue(res.ready)
|
||||
|
||||
def test_sig_ready_returns_200(self):
|
||||
response = self._post(action="ready")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_sig_ready_already_ready_is_idempotent(self):
|
||||
"""Re-posting ready when already ready returns 200 without re-triggering countdown."""
|
||||
self.reservations[0].ready = True
|
||||
self.reservations[0].save()
|
||||
response = self._post(action="ready")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# ── unready ──────────────────────────────────────────────────────────
|
||||
|
||||
def test_sig_unready_sets_ready_false(self):
|
||||
self.reservations[0].ready = True
|
||||
self.reservations[0].save()
|
||||
self._post(action="unready")
|
||||
res = SigReservation.objects.get(room=self.room, gamer=self.gamers[0])
|
||||
self.assertFalse(res.ready)
|
||||
|
||||
def test_sig_unready_when_not_ready_is_harmless(self):
|
||||
response = self._post(action="unready")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# ── countdown mechanics ───────────────────────────────────────────────
|
||||
|
||||
def test_sig_ready_broadcasts_countdown_start_when_all_three_polarity_ready(self):
|
||||
"""When all three levity gamers are ready, countdown_start broadcasts."""
|
||||
# Make NC and SC ready first
|
||||
for res in self.reservations[1:]:
|
||||
res.ready = True
|
||||
res.save()
|
||||
# PC (founder) goes ready — triggers all-three condition
|
||||
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||
self._post(action="ready")
|
||||
mock_notify.assert_called_once()
|
||||
args = mock_notify.call_args[0]
|
||||
self.assertIn("levity", args) # polarity in call
|
||||
|
||||
def test_sig_ready_does_not_broadcast_countdown_when_only_two_ready(self):
|
||||
self.reservations[1].ready = True
|
||||
self.reservations[1].save()
|
||||
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||
self._post(action="ready")
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
def test_sig_unready_invalid_seconds_defaults_to_12(self):
|
||||
"""Non-numeric seconds_remaining falls back to 12."""
|
||||
self.reservations[0].ready = True
|
||||
self.reservations[0].save()
|
||||
self._post(action="unready", seconds_remaining="abc")
|
||||
self.reservations[0].refresh_from_db()
|
||||
self.assertEqual(self.reservations[0].countdown_remaining, 12)
|
||||
|
||||
def test_sig_unready_saves_seconds_remaining_on_all_polarity_reservations(self):
|
||||
for res in self.reservations:
|
||||
res.ready = True
|
||||
res.save()
|
||||
self._post(action="unready", seconds_remaining=7)
|
||||
for res in self.reservations:
|
||||
res.refresh_from_db()
|
||||
self.assertEqual(res.countdown_remaining, 7)
|
||||
|
||||
def test_sig_unready_broadcasts_countdown_cancel(self):
|
||||
for res in self.reservations:
|
||||
res.ready = True
|
||||
res.save()
|
||||
with patch("apps.epic.views._notify_countdown_cancel") as mock_notify:
|
||||
self._post(action="unready", seconds_remaining=7)
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
def test_sig_ready_uses_saved_seconds_for_countdown_restart(self):
|
||||
"""If countdown_remaining is saved (e.g. 7), countdown_start sends 7 not 12."""
|
||||
for res in self.reservations:
|
||||
res.ready = True
|
||||
res.countdown_remaining = 7
|
||||
res.save()
|
||||
# One unreadied; now goes ready again — all 3 ready → start from 7
|
||||
self.reservations[0].ready = False
|
||||
self.reservations[0].save()
|
||||
with patch("apps.epic.views._notify_countdown_start") as mock_notify:
|
||||
self._post(action="ready")
|
||||
mock_notify.assert_called_once()
|
||||
args, kwargs = mock_notify.call_args
|
||||
seconds_sent = kwargs.get("seconds") or args[1]
|
||||
self.assertEqual(seconds_sent, 7)
|
||||
|
||||
|
||||
# ── sig_confirm view ──────────────────────────────────────────────────────────
|
||||
|
||||
def _make_gravity_reservations(room, gamers, earthman, ready=False):
|
||||
"""Create SigReservations for the three gravity gamers (EC, AC, BC)."""
|
||||
cards = [
|
||||
TarotCard.objects.get(deck_variant=earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||
for n in (11, 12, 13)
|
||||
]
|
||||
roles = ["EC", "AC", "BC"]
|
||||
# gamers[2]=EC, gamers[4]=AC, gamers[5]=BC
|
||||
gamer_indices = [2, 4, 5]
|
||||
reservations = []
|
||||
for gamer_idx, role, card in zip(gamer_indices, roles, cards):
|
||||
seat = TableSeat.objects.get(room=room, role=role)
|
||||
res = SigReservation.objects.create(
|
||||
room=room, gamer=gamers[gamer_idx], card=card,
|
||||
role=role, polarity="gravity", seat=seat, ready=ready,
|
||||
)
|
||||
reservations.append(res)
|
||||
return reservations
|
||||
|
||||
|
||||
class SigConfirmViewTest(TestCase):
|
||||
"""sig_confirm — finalize polarity group once countdown reaches zero."""
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||
# All three levity gamers are ready
|
||||
self.lev_res = _make_levity_reservations(
|
||||
self.room, self.gamers, self.earthman, ready=True
|
||||
)
|
||||
# founder (PC) is already logged in from _full_sig_setUp
|
||||
self.url = reverse("epic:sig_confirm", kwargs={"room_id": self.room.id})
|
||||
|
||||
def _post(self, polarity="levity", client=None):
|
||||
c = client or self.client
|
||||
return c.post(self.url, data={"polarity": polarity})
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_sig_confirm_non_post_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_sig_confirm_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertIn("/accounts/login/", response.url)
|
||||
|
||||
def test_sig_confirm_requires_seated_gamer(self):
|
||||
outsider = User.objects.create(email="outsider@test.io")
|
||||
outsider_client = self.client.__class__()
|
||||
outsider_client.force_login(outsider)
|
||||
response = self._post(client=outsider_client)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_sig_confirm_wrong_phase_returns_400(self):
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_sig_confirm_not_all_polarity_ready_returns_400(self):
|
||||
"""If any of the three in the polarity group isn't ready, reject."""
|
||||
self.lev_res[1].ready = False
|
||||
self.lev_res[1].save()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── happy-path ────────────────────────────────────────────────────────
|
||||
|
||||
def test_sig_confirm_sets_significator_on_seats_from_reservations(self):
|
||||
self._post()
|
||||
for res in self.lev_res:
|
||||
seat = TableSeat.objects.get(room=self.room, role=res.role)
|
||||
self.assertEqual(seat.significator, res.card)
|
||||
|
||||
def test_sig_confirm_returns_200(self):
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_sig_confirm_broadcasts_polarity_room_done(self):
|
||||
with patch("apps.epic.views._notify_polarity_room_done") as mock_notify:
|
||||
self._post()
|
||||
mock_notify.assert_called_once()
|
||||
args = mock_notify.call_args[0]
|
||||
self.assertIn("levity", args)
|
||||
|
||||
def test_sig_confirm_is_idempotent_if_significators_already_set(self):
|
||||
"""Second call from another browser returns 200 without re-running logic."""
|
||||
self._post()
|
||||
response = self._post()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# ── both polarities done ──────────────────────────────────────────────
|
||||
|
||||
def test_sig_confirm_broadcasts_pick_sky_available_when_both_polarities_done(self):
|
||||
"""After both levity and gravity confirm, pick_sky_available fires."""
|
||||
# Pre-set gravity seats to already have significators (simulating earlier confirm)
|
||||
grav_cards = [
|
||||
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||
for n in (11, 12, 13)
|
||||
]
|
||||
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||||
seat = TableSeat.objects.get(room=self.room, role=role)
|
||||
seat.significator = card
|
||||
seat.save()
|
||||
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||||
self._post(polarity="levity")
|
||||
mock_notify.assert_called_once()
|
||||
|
||||
def test_sig_confirm_sets_room_to_sky_select_when_both_polarities_done(self):
|
||||
grav_cards = [
|
||||
TarotCard.objects.get(deck_variant=self.earthman, arcana="MIDDLE", suit="GRAILS", number=n)
|
||||
for n in (11, 12, 13)
|
||||
]
|
||||
for role, card in zip(["EC", "AC", "BC"], grav_cards):
|
||||
seat = TableSeat.objects.get(room=self.room, role=role)
|
||||
seat.significator = card
|
||||
seat.save()
|
||||
self._post(polarity="levity")
|
||||
self.room.refresh_from_db()
|
||||
self.assertEqual(self.room.table_status, Room.SKY_SELECT)
|
||||
|
||||
def test_sig_confirm_does_not_broadcast_pick_sky_available_when_only_one_polarity_done(self):
|
||||
with patch("apps.epic.views._notify_pick_sky_available") as mock_notify:
|
||||
self._post(polarity="levity")
|
||||
mock_notify.assert_not_called()
|
||||
|
||||
|
||||
# ── SKY_SELECT rendering ──────────────────────────────────────────────────────
|
||||
|
||||
class PickSkyRenderingTest(TestCase):
|
||||
"""Room page at SKY_SELECT renders PICK SKY btn and sig card in tray cell 2."""
|
||||
|
||||
def setUp(self):
|
||||
self.room, self.gamers, self.earthman, _ = _full_sig_setUp(self)
|
||||
self.room.table_status = Room.SKY_SELECT
|
||||
self.room.save()
|
||||
self.sig_card = TarotCard.objects.get(
|
||||
deck_variant=self.earthman, arcana="MIDDLE", suit="BRANDS", number=11
|
||||
)
|
||||
pc_seat = TableSeat.objects.get(room=self.room, role="PC")
|
||||
pc_seat.significator = self.sig_card
|
||||
pc_seat.save()
|
||||
self.url = reverse("epic:room", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_pick_sky_btn_present_in_sky_select_phase(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "id_pick_sky_btn")
|
||||
|
||||
def test_tray_cell_2_contains_sig_card_icon_in_sky_select(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, "tray-sig-card")
|
||||
|
||||
def test_pick_sky_btn_hidden_during_sig_select(self):
|
||||
# Rendered hidden (display:none) so JS can reveal it on pick_sky_available WS event
|
||||
self.room.table_status = Room.SIG_SELECT
|
||||
self.room.save()
|
||||
response = self.client.get(self.url)
|
||||
self.assertContains(response, 'id="id_pick_sky_btn"')
|
||||
self.assertContains(response, 'style="display:none"')
|
||||
|
||||
|
||||
# ── select_role GET redirect ──────────────────────────────────────────────────
|
||||
|
||||
class SelectRoleGetRedirectTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gamer@sr.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="R", owner=self.user)
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
|
||||
def test_get_redirects_to_room(self):
|
||||
response = self.client.get(reverse("epic:select_role", kwargs={"room_id": self.room.id}))
|
||||
self.assertRedirects(response, reverse("epic:room", kwargs={"room_id": self.room.id}),
|
||||
fetch_redirect_response=False)
|
||||
|
||||
|
||||
# ── sig_reserve / sig_ready / sig_confirm / select_sig helpers ────────────────
|
||||
|
||||
def _make_sig_room(owner, *extra_gamers):
|
||||
room = Room.objects.create(name="SR", owner=owner)
|
||||
seat_map = {}
|
||||
gamers = [owner] + list(extra_gamers)
|
||||
roles = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
|
||||
seat = TableSeat.objects.create(room=room, gamer=gamer, slot_number=i, role=role)
|
||||
seat_map[role] = seat
|
||||
room.table_status = Room.SIG_SELECT
|
||||
room.save()
|
||||
return room, seat_map
|
||||
|
||||
|
||||
class SelectSigViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="pc@selsig.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room, self.seats = _make_sig_room(self.user)
|
||||
|
||||
def test_non_post_redirects(self):
|
||||
response = self.client.get(reverse("epic:select_sig", kwargs={"room_id": self.room.id}))
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
def test_nonexistent_card_returns_400(self):
|
||||
response = self.client.post(
|
||||
reverse("epic:select_sig", kwargs={"room_id": self.room.id}),
|
||||
{"card_id": "99999999"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
# ── natus_preview (epic) ──────────────────────────────────────────────────────
|
||||
|
||||
class NatusPreviewViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="pc@natus.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room, _ = _make_sig_room(self.user)
|
||||
self.room.table_status = Room.SKY_SELECT
|
||||
self.room.save()
|
||||
self.url = reverse("epic:natus_preview", kwargs={"room_id": self.room.id})
|
||||
|
||||
def test_missing_params_returns_400(self):
|
||||
response = self.client.get(self.url, {"date": "1990-06-15"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_non_numeric_lat_returns_400(self):
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "abc", "lon": "0"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_out_of_range_lat_returns_400(self):
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "999", "lon": "0"})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_invalid_tz_string_returns_400(self):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"date": "1990-06-15", "lat": "51.5", "lon": "-0.1", "tz": "Not/Real"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_bad_date_format_returns_400(self):
|
||||
response = self.client.get(
|
||||
self.url,
|
||||
{"date": "baddate", "time": "09:00", "lat": "51.5", "lon": "-0.1", "tz": "UTC"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@patch("apps.epic.views.http_requests")
|
||||
def test_pyswiss_failure_returns_502(self, mock_requests):
|
||||
from unittest.mock import MagicMock
|
||||
tz_r = MagicMock()
|
||||
tz_r.json.return_value = {"timezone": "UTC"}
|
||||
tz_r.raise_for_status = MagicMock()
|
||||
chart_r = MagicMock()
|
||||
chart_r.raise_for_status.side_effect = Exception("timeout")
|
||||
mock_requests.get.side_effect = [tz_r, chart_r]
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||||
self.assertEqual(response.status_code, 502)
|
||||
|
||||
@patch("apps.epic.views.http_requests")
|
||||
def test_success_returns_chart_distinctions_timezone(self, mock_requests):
|
||||
from unittest.mock import MagicMock
|
||||
payload = {
|
||||
"planets": {"Sun": {"degree": 84.5}},
|
||||
"houses": {"cusps": [0] * 12},
|
||||
"elements": {"Earth": 1},
|
||||
"house_system": "O",
|
||||
}
|
||||
tz_r = MagicMock()
|
||||
tz_r.json.return_value = {"timezone": "Europe/London"}
|
||||
tz_r.raise_for_status = MagicMock()
|
||||
ch_r = MagicMock()
|
||||
ch_r.json.return_value = payload
|
||||
ch_r.raise_for_status = MagicMock()
|
||||
mock_requests.get.side_effect = [tz_r, ch_r]
|
||||
response = self.client.get(self.url, {"date": "1990-06-15", "lat": "51.5", "lon": "-0.1"})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("distinctions", data)
|
||||
self.assertIn("Stone", data["elements"])
|
||||
self.assertNotIn("Earth", data["elements"])
|
||||
|
||||
|
||||
# ── tarot_deal ────────────────────────────────────────────────────────────────
|
||||
|
||||
class TarotDealViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="dealer@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room, _ = _make_sig_room(self.user)
|
||||
|
||||
def test_non_post_redirects_to_tarot_deck(self):
|
||||
response = self.client.get(
|
||||
reverse("epic:tarot_deal", kwargs={"room_id": self.room.id})
|
||||
)
|
||||
self.assertRedirects(
|
||||
response,
|
||||
reverse("epic:tarot_deck", kwargs={"room_id": self.room.id}),
|
||||
fetch_redirect_response=False,
|
||||
)
|
||||
|
||||
|
||||
# ── natus_save (epic) ─────────────────────────────────────────────────────────
|
||||
|
||||
class NatusSaveViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="pc@natussave.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room, _ = _make_sig_room(self.user)
|
||||
self.room.table_status = Room.SKY_SELECT
|
||||
self.room.save()
|
||||
self.url = reverse("epic:natus_save", kwargs={"room_id": self.room.id})
|
||||
|
||||
def _post(self, payload):
|
||||
import json as _json
|
||||
return self.client.post(self.url, data=_json.dumps(payload), content_type="application/json")
|
||||
|
||||
def test_get_returns_405(self):
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def test_invalid_json_returns_400(self):
|
||||
response = self.client.post(self.url, data="not json", content_type="application/json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_save_draft_returns_id_and_not_confirmed(self):
|
||||
response = self._post({
|
||||
"birth_dt": "1990-06-15T09:00:00Z",
|
||||
"birth_lat": 51.5,
|
||||
"birth_lon": -0.1,
|
||||
"birth_place": "London",
|
||||
"house_system": "O",
|
||||
"chart_data": {},
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
data = response.json()
|
||||
self.assertIn("id", data)
|
||||
self.assertFalse(data["confirmed"])
|
||||
|
||||
def test_confirm_action_locks_character(self):
|
||||
response = self._post({
|
||||
"birth_dt": "1990-06-15T09:00:00Z",
|
||||
"birth_lat": 51.5,
|
||||
"birth_lon": -0.1,
|
||||
"birth_place": "",
|
||||
"house_system": "O",
|
||||
"chart_data": {},
|
||||
"action": "confirm",
|
||||
})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["confirmed"])
|
||||
|
||||
204
src/apps/epic/tests/unit/test_tasks.py
Normal file
@@ -0,0 +1,204 @@
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.epic.models import Room, SigReservation, TableSeat, TarotCard
|
||||
from apps.lyric.models import User
|
||||
from apps.epic.tasks import (
|
||||
_cache_key, cancel_polarity_confirm, schedule_polarity_confirm,
|
||||
)
|
||||
|
||||
|
||||
class CacheKeyTest(TestCase):
|
||||
def test_cache_key_format(self):
|
||||
self.assertEqual(_cache_key("room-1", "levity"), "sig_countdown_room-1_levity")
|
||||
|
||||
|
||||
class CancelPolarityConfirmTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="owner@tasks.io")
|
||||
self.room = Room.objects.create(name="R", owner=self.user)
|
||||
|
||||
def test_cancel_with_no_timer_is_a_noop(self):
|
||||
cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY)
|
||||
|
||||
def test_cancel_clears_cache_entry(self):
|
||||
from django.core.cache import cache
|
||||
key = _cache_key(str(self.room.id), SigReservation.LEVITY)
|
||||
cache.set(key, "sometoken", timeout=60)
|
||||
cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY)
|
||||
self.assertIsNone(cache.get(key))
|
||||
|
||||
@patch("apps.epic.tasks._timers")
|
||||
def test_cancel_calls_timer_cancel_when_present(self, mock_timers):
|
||||
mock_timer = MagicMock()
|
||||
key = f"{self.room.id}_levity"
|
||||
mock_timers.pop.return_value = mock_timer
|
||||
cancel_polarity_confirm(str(self.room.id), SigReservation.LEVITY)
|
||||
mock_timer.cancel.assert_called_once()
|
||||
|
||||
|
||||
class FireFunctionTest(TestCase):
|
||||
"""Tests for the _fire() callback executed by threading.Timer."""
|
||||
|
||||
def setUp(self):
|
||||
self.owner = User.objects.create(email="owner@fire.io")
|
||||
self.room = Room.objects.create(name="R", owner=self.owner)
|
||||
self.room.table_status = Room.SIG_SELECT
|
||||
self.room.save()
|
||||
roles = ["PC", "NC", "SC"]
|
||||
self.gamers = [self.owner]
|
||||
for i, role in enumerate(roles):
|
||||
if i == 0:
|
||||
TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1, role=role)
|
||||
else:
|
||||
g = User.objects.create(email=f"g{i}@fire.io")
|
||||
self.gamers.append(g)
|
||||
TableSeat.objects.create(room=self.room, gamer=g, slot_number=i+1, role=role)
|
||||
# Gravity seats (no significators needed for levity test)
|
||||
grav_roles = ["BC", "EC", "AC"]
|
||||
for i, role in enumerate(grav_roles, start=4):
|
||||
g = User.objects.create(email=f"grav{i}@fire.io")
|
||||
TableSeat.objects.create(room=self.room, gamer=g, slot_number=i, role=role)
|
||||
|
||||
def _set_token(self):
|
||||
from django.core.cache import cache
|
||||
import uuid
|
||||
token = str(uuid.uuid4())
|
||||
cache.set(_cache_key(str(self.room.id), SigReservation.LEVITY), token, 120)
|
||||
return token
|
||||
|
||||
@patch("apps.epic.tasks._group_send")
|
||||
def test_fire_does_nothing_if_token_mismatch(self, mock_send):
|
||||
from apps.epic.tasks import _fire
|
||||
self._set_token()
|
||||
_fire(str(self.room.id), SigReservation.LEVITY, "wrong-token")
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("apps.epic.tasks._group_send")
|
||||
def test_fire_does_nothing_if_room_not_sig_select(self, mock_send):
|
||||
from apps.epic.tasks import _fire
|
||||
token = self._set_token()
|
||||
self.room.table_status = Room.ROLE_SELECT
|
||||
self.room.save()
|
||||
_fire(str(self.room.id), SigReservation.LEVITY, token)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("apps.epic.tasks._group_send")
|
||||
def test_fire_does_nothing_if_fewer_than_3_ready(self, mock_send):
|
||||
from apps.epic.tasks import _fire
|
||||
token = self._set_token()
|
||||
cards = list(TarotCard.objects.all()[:2])
|
||||
seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC"]))
|
||||
for i, seat in enumerate(seats):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=seat.gamer, card=cards[i],
|
||||
polarity=SigReservation.LEVITY, seat=seat, ready=True,
|
||||
)
|
||||
_fire(str(self.room.id), SigReservation.LEVITY, token)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("apps.epic.tasks._group_send")
|
||||
def test_fire_assigns_significators_and_broadcasts_when_all_ready(self, mock_send):
|
||||
from apps.epic.tasks import _fire
|
||||
token = self._set_token()
|
||||
cards = list(TarotCard.objects.all()[:3])
|
||||
seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"]))
|
||||
for i, seat in enumerate(seats):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=seat.gamer, card=cards[i],
|
||||
polarity=SigReservation.LEVITY, seat=seat, ready=True,
|
||||
)
|
||||
_fire(str(self.room.id), SigReservation.LEVITY, token)
|
||||
self.assertTrue(mock_send.called)
|
||||
levity_seats = TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"])
|
||||
for i, seat in enumerate(levity_seats):
|
||||
seat.refresh_from_db()
|
||||
self.assertEqual(seat.significator, cards[i])
|
||||
|
||||
def test_fire_does_nothing_for_nonexistent_room(self):
|
||||
from apps.epic.tasks import _fire
|
||||
from django.core.cache import cache
|
||||
fake_id = "00000000-0000-0000-0000-000000000000"
|
||||
token = "known-token"
|
||||
cache.set(_cache_key(fake_id, SigReservation.LEVITY), token, 60)
|
||||
_fire(fake_id, SigReservation.LEVITY, token)
|
||||
|
||||
@patch("apps.epic.tasks._group_send")
|
||||
def test_fire_does_nothing_if_all_sigs_already_assigned(self, mock_send):
|
||||
from apps.epic.tasks import _fire
|
||||
token = self._set_token()
|
||||
cards = list(TarotCard.objects.all()[:3])
|
||||
seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"]))
|
||||
for i, seat in enumerate(seats):
|
||||
seat.significator = cards[i]
|
||||
seat.save(update_fields=["significator"])
|
||||
_fire(str(self.room.id), SigReservation.LEVITY, token)
|
||||
mock_send.assert_not_called()
|
||||
|
||||
@patch("apps.epic.tasks._group_send")
|
||||
def test_fire_broadcasts_pick_sky_when_all_polarity_sigs_assigned(self, mock_send):
|
||||
"""When both levity AND gravity seats all have significators, fire() triggers SKY_SELECT."""
|
||||
from apps.epic.tasks import _fire
|
||||
token = self._set_token()
|
||||
cards = list(TarotCard.objects.all()[:6])
|
||||
# Give gravity seats significators so the all-assigned check passes
|
||||
gravity_seats = list(TableSeat.objects.filter(room=self.room, role__in=["BC", "EC", "AC"]))
|
||||
for i, seat in enumerate(gravity_seats):
|
||||
seat.significator = cards[i]
|
||||
seat.save(update_fields=["significator"])
|
||||
# Create ready levity reservations (different cards from gravity)
|
||||
levity_seats = list(TableSeat.objects.filter(room=self.room, role__in=["PC", "NC", "SC"]))
|
||||
levity_cards = cards[3:6]
|
||||
for i, seat in enumerate(levity_seats):
|
||||
SigReservation.objects.create(
|
||||
room=self.room, gamer=seat.gamer, card=levity_cards[i],
|
||||
polarity=SigReservation.LEVITY, seat=seat, ready=True,
|
||||
)
|
||||
_fire(str(self.room.id), SigReservation.LEVITY, token)
|
||||
call_types = [c.args[1]["type"] for c in mock_send.call_args_list]
|
||||
self.assertIn("polarity_room_done", call_types)
|
||||
self.assertIn("pick_sky_available", call_types)
|
||||
self.room.refresh_from_db()
|
||||
self.assertEqual(self.room.table_status, "SKY_SELECT")
|
||||
|
||||
|
||||
class SchedulePolarityConfirmTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="owner@schedule.io")
|
||||
self.room = Room.objects.create(name="R", owner=self.user)
|
||||
|
||||
def test_schedule_sets_cache_token(self):
|
||||
from django.core.cache import cache
|
||||
schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60)
|
||||
token = cache.get(_cache_key(str(self.room.id), SigReservation.LEVITY))
|
||||
self.assertIsNotNone(token)
|
||||
|
||||
def test_schedule_registers_timer(self):
|
||||
from apps.epic.tasks import _timers
|
||||
key = f"{self.room.id}_{SigReservation.LEVITY}"
|
||||
schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60)
|
||||
self.assertIn(key, _timers)
|
||||
_timers[key].cancel() # clean up
|
||||
|
||||
def test_schedule_cancels_prior_timer_before_scheduling(self):
|
||||
from apps.epic.tasks import _timers
|
||||
schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60)
|
||||
key = f"{self.room.id}_{SigReservation.LEVITY}"
|
||||
first_timer = _timers.get(key)
|
||||
schedule_polarity_confirm(str(self.room.id), SigReservation.LEVITY, 60)
|
||||
second_timer = _timers.get(key)
|
||||
self.assertIsNotNone(second_timer)
|
||||
self.assertIsNot(first_timer, second_timer)
|
||||
second_timer.cancel()
|
||||
|
||||
|
||||
class GroupSendTest(TestCase):
|
||||
@patch("apps.epic.tasks.async_to_sync")
|
||||
def test_group_send_calls_async_to_sync(self, mock_a2s):
|
||||
from apps.epic.tasks import _group_send
|
||||
mock_fn = MagicMock()
|
||||
mock_a2s.return_value = mock_fn
|
||||
_group_send("room-abc", {"type": "test"})
|
||||
mock_a2s.assert_called_once()
|
||||
mock_fn.assert_called_once_with("room_room-abc", {"type": "test"})
|
||||
13
src/apps/epic/tests/unit/test_utils.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.epic.utils import _planet_house
|
||||
|
||||
|
||||
class PlanetHouseFallbackTest(SimpleTestCase):
|
||||
def test_returns_1_when_no_cusp_matches(self):
|
||||
# Pathological cusps list: all 12 cusps identical (zero-width arcs).
|
||||
# No range has start < end, and the wrap-around condition is also
|
||||
# never satisfied, so the loop exhausts without returning — hitting
|
||||
# the fallback `return 1`.
|
||||
cusps = [0.0] * 12
|
||||
self.assertEqual(_planet_house(180.0, cusps), 1)
|
||||