Compare commits
131 Commits
18898c7a0f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a24021739 | ||
|
|
bd9a2fdae3 | ||
|
|
4f8e52890b | ||
|
|
abf8be8861 | ||
|
|
127f4a092d | ||
|
|
2910012b67 | ||
|
|
db9ac9cb24 | ||
|
|
d3e4638233 | ||
|
|
10a6809dcf | ||
|
|
de4ac60aec | ||
|
|
71ef3dcb7f | ||
|
|
9beb21bffe | ||
|
|
6248d95bf3 | ||
|
|
44cf399352 | ||
|
|
df2b353ebd | ||
|
|
3fd1f5e990 | ||
|
|
02a7a0ef2e | ||
|
|
cc2ab869f1 | ||
|
|
8c711ac674 | ||
|
|
b8af0041cc | ||
|
|
97ec2f6ee6 | ||
|
|
0a135c2149 | ||
|
|
f1e9a9657b | ||
|
|
32d8d97360 | ||
|
|
df421fb6c0 | ||
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab | ||
|
|
d4518a0671 | ||
|
|
74f63a7721 | ||
|
|
bd3d7fc7bd | ||
|
|
c00288e256 | ||
|
|
b5de96660a | ||
|
|
96bb05a4ba | ||
|
|
4e07fcf38b | ||
|
|
b74f8e1bb1 | ||
|
|
188365f412 | ||
|
|
824f35590b | ||
|
|
43cb84e8f4 | ||
|
|
afe8e2b32c | ||
|
|
ca38875660 | ||
|
|
8538f76b13 | ||
|
|
2a7d4c7410 | ||
|
|
ed10e58383 | ||
|
|
b65cba5ed2 | ||
|
|
afe79f1a48 | ||
|
|
0e5e39b0dc | ||
|
|
4860b6ee2a | ||
|
|
c025a38709 | ||
|
|
581ea7e349 | ||
|
|
596175cd1c | ||
|
|
1aaf353066 | ||
|
|
441def9a34 | ||
|
|
736b59b5c0 | ||
|
|
a8592aeaec | ||
|
|
8b006be138 | ||
|
|
299a806862 | ||
|
|
fb782cf5ef | ||
|
|
224f5e2ad0 | ||
|
|
96379934d7 | ||
|
|
29a5658b01 | ||
|
|
73135df7a6 | ||
|
|
57f47cc77e | ||
|
|
5d21e79be5 | ||
|
|
ff0883002b | ||
|
|
7f927741d4 | ||
|
|
3bf48546e3 | ||
|
|
6817323f8e | ||
|
|
11283118d6 | ||
|
|
6c91ec0385 | ||
|
|
39db59c71a | ||
|
|
5f643350c5 | ||
|
|
ab41797e57 | ||
|
|
e35855f472 | ||
|
|
0e5805efd2 | ||
|
|
de99b538d2 | ||
|
|
c08b5b764e | ||
|
|
d63a4bec4a | ||
|
|
b35c9b483e | ||
|
|
30ea0fad9d | ||
|
|
62d5c738f9 | ||
|
|
f0f419ff7e | ||
|
|
0494710ce0 | ||
|
|
713e24863d | ||
|
|
b3bc422f46 | ||
|
|
c0016418cc | ||
|
|
4d52c4f54d | ||
|
|
db1608fa38 | ||
|
|
4728cde771 | ||
|
|
2f6fc1ff20 | ||
|
|
9698d70164 | ||
|
|
7370fd611f | ||
|
|
f5a5ed9d8d | ||
|
|
a5d71925fc | ||
|
|
b03ba09b65 | ||
|
|
befa61e1e9 | ||
|
|
15ac3216ff | ||
|
|
2896efa8e0 | ||
|
|
588358a20f | ||
|
|
11c85d56d1 | ||
|
|
8bab26e003 | ||
|
|
bc78d2c470 | ||
|
|
2447315fd3 | ||
|
|
cde231d43c | ||
|
|
a0f8aeb791 | ||
|
|
2ca4e9d39f | ||
|
|
c71f4eb68c | ||
|
|
189d329e76 |
@@ -22,6 +22,33 @@ steps:
|
||||
- python manage.py test apps
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: test-two-browser-FTs
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
environment:
|
||||
HEADLESS: 1
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
STRIPE_SECRET_KEY:
|
||||
from_secret: stripe_secret_key
|
||||
STRIPE_PUBLISHABLE_KEY:
|
||||
from_secret: stripe_publishable_key
|
||||
commands:
|
||||
- pip install -r requirements.txt
|
||||
- cd ./src
|
||||
- python manage.py collectstatic --noinput
|
||||
- python manage.py test functional_tests --tag=two-browser
|
||||
- python manage.py test functional_tests --tag=channels
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: test-FTs
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
@@ -37,10 +64,13 @@ steps:
|
||||
- pip install -r requirements.txt
|
||||
- cd ./src
|
||||
- python manage.py collectstatic --noinput
|
||||
- python manage.py test functional_tests --parallel --exclude-tag=channels
|
||||
- python manage.py test functional_tests --tag=channels
|
||||
- python manage.py test functional_tests --parallel --exclude-tag=channels --exclude-tag=two-browser
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: screendumps
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
@@ -49,6 +79,10 @@ steps:
|
||||
when:
|
||||
- event: push
|
||||
status: failure
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: build-and-push
|
||||
image: docker:cli
|
||||
@@ -62,8 +96,13 @@ steps:
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- "Dockerfile"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: deploy
|
||||
- name: deploy-staging
|
||||
image: alpine
|
||||
environment:
|
||||
SSH_KEY:
|
||||
@@ -77,4 +116,23 @@ steps:
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- "Dockerfile"
|
||||
- "infra/**"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: deploy-prod
|
||||
image: alpine
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: deploy_ssh_key
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
|
||||
when:
|
||||
- event: tag
|
||||
33
.woodpecker/pyswiss.yaml
Normal file
33
.woodpecker/pyswiss.yaml
Normal file
@@ -0,0 +1,33 @@
|
||||
steps:
|
||||
- name: test-pyswiss
|
||||
image: python:3.13-slim
|
||||
environment:
|
||||
SWISSEPH_PATH: /tmp/ephe
|
||||
commands:
|
||||
- apt-get update -qq && apt-get install -y -q gcc g++
|
||||
- pip install -r pyswiss/requirements.txt
|
||||
- cd ./pyswiss
|
||||
- python manage.py test apps.charts
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "pyswiss/**"
|
||||
- ".woodpecker/pyswiss.yaml"
|
||||
|
||||
- name: deploy-pyswiss
|
||||
image: alpine
|
||||
environment:
|
||||
SSH_KEY:
|
||||
from_secret: pyswiss_deploy
|
||||
commands:
|
||||
- apk add --no-cache openssh-client
|
||||
- mkdir -p ~/.ssh
|
||||
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
|
||||
- chmod 600 ~/.ssh/id_ed25519
|
||||
- ssh -o StrictHostKeyChecking=no discoman@167.172.154.66 /home/discoman/deploy.sh
|
||||
when:
|
||||
- branch: main
|
||||
event: push
|
||||
path:
|
||||
- "pyswiss/**"
|
||||
- ".woodpecker/pyswiss.yaml"
|
||||
23
CLAUDE.md
23
CLAUDE.md
@@ -73,6 +73,14 @@ src/
|
||||
functional_tests/
|
||||
```
|
||||
|
||||
### Template directory convention
|
||||
Templates live under `templates/apps/<frontend-app>/`, not under the backend app that owns the view logic. Specifically:
|
||||
- `lyric/` views → `templates/apps/dashboard/`
|
||||
- `epic/` views → `templates/apps/gameboard/`
|
||||
- `drama/` views → `templates/apps/billboard/`
|
||||
|
||||
Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory.
|
||||
|
||||
## Dev Commands
|
||||
```bash
|
||||
# Dev server (ASGI — required for WebSockets; no npm/webpack build step)
|
||||
@@ -89,6 +97,19 @@ python src/manage.py test src/functional_tests
|
||||
python src/manage.py test src
|
||||
```
|
||||
|
||||
### Multi-user manual testing — `setup_sig_session`
|
||||
`src/functional_tests/management/commands/setup_sig_session.py`
|
||||
|
||||
Creates (or reuses) a room with all 6 gate slots filled, roles assigned, and `table_status=SIG_SELECT`. Prints one pre-auth URL per gamer for pasting into 6 Firefox Multi-Account Container tabs.
|
||||
|
||||
```bash
|
||||
python src/manage.py setup_sig_session
|
||||
python src/manage.py setup_sig_session --base-url http://localhost:8000
|
||||
python src/manage.py setup_sig_session --room <uuid> # reuse existing room
|
||||
```
|
||||
|
||||
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all created as superusers with Earthman deck equipped. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
|
||||
|
||||
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
||||
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
||||
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
||||
@@ -103,7 +124,7 @@ Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows
|
||||
- Push to `main` triggers Woodpecker → deploys to staging
|
||||
|
||||
## SCSS Import Order
|
||||
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → billboard → game-kit → wallet-tokens`
|
||||
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens`
|
||||
|
||||
## Critical Gotchas
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@
|
||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
|
||||
REDIS_URL: "redis://gamearray_redis:6379/1"
|
||||
PYSWISS_URL: "{{ pyswiss_url }}"
|
||||
networks:
|
||||
- name: gamearray_net
|
||||
ports:
|
||||
@@ -160,6 +161,7 @@
|
||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
|
||||
REDIS_URL: "redis://gamearray_redis:6379/1"
|
||||
PYSWISS_URL: "{{ pyswiss_url }}"
|
||||
networks:
|
||||
- name: gamearray_net
|
||||
command: "python -m celery -A core worker -l info"
|
||||
|
||||
@@ -9,4 +9,5 @@ STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }}
|
||||
STRIPE_SECRET_KEY={{ stripe_secret_key }}
|
||||
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
|
||||
REDIS_URL=redis://gamearray_redis:6379/1
|
||||
PYSWISS_URL=https://charts.earthmanrpg.me
|
||||
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
$ANSIBLE_VAULT;1.1;AES256
|
||||
38383061343764656262613934313230656462366163363263653462333338333863326338343838
|
||||
3664646437643462346636623231633639396239333532340a363338313839353734326238643735
|
||||
39343237396433336436366430626332343666666461613636656433363838613432393539386266
|
||||
3237336434346333350a663530623334633438616135376437666631313064333735653633396461
|
||||
31306163343838336465626663373661343839653037333235313361633335646337353339616333
|
||||
35343233346562346236636364316265313936646235373866636333353866623161663935626637
|
||||
31633864366339653930626365373237326531366632626337636163333266656434323063333365
|
||||
38373437383261613439306666373764633737623466626235356465636365646337306534326535
|
||||
36633866663161613632613434666134343465383663633165663330376535653537333763376232
|
||||
61653265303134656338393033303834663630653064666134633638393235346631346461633030
|
||||
35343332393961363361613661633633613262663231366236396663636239326534373134623762
|
||||
30653139333134616236666238616466633733656633326331386138363839653566333434346534
|
||||
63326539333461383265316332336333656365386531393630663537363365643061363263313738
|
||||
37633564363533633762393736636333306433306534393539636231656162343562383232663932
|
||||
62646339363266303564383438636636373661656465666663613863396639633732636635326166
|
||||
39323738303338373466366236623665633538363134616565326665386564613735393638656630
|
||||
31326431316163376132623064376634643737313864336464623431333834663361336133353838
|
||||
32303635663261333732306137383133623134373363613837306637663566303634653863343766
|
||||
33613936626362653466333537666462373633313038376565623363666631353162643634653730
|
||||
30323532623261643136666237316561353038323265303930336364633731333533386563623133
|
||||
31343965643336613933663431626435333235366639363334653065303434386165333739336632
|
||||
61363030376664643638653365626365623936623864666663326534343863613962616431376666
|
||||
39363837386639393235316339323932326466616330303165613032663637616232656162653335
|
||||
61613266376262626234383135306238313366346330656333383465383861663962653638303362
|
||||
34353833646461383839386238626661346263363131643438343461393739336132386466373665
|
||||
32646238633161363064666335626639653335306236613866333934646366323564306133396131
|
||||
36343032623964316138386538333863363530396330646431373466646538663063326330663639
|
||||
32323762356632336364333162336133336335623865323861663131626232633066643238333237
|
||||
32343938353166353037316162653832663433343534626331633936633866356666653932656665
|
||||
38396533356131326262633431653435306362633966383531356236396639376437396333616130
|
||||
35666435393461316232323234653865346338326330623065373461323961393663306262313066
|
||||
30313430353065616230356135333565333338373663643434353561363438656233383739663233
|
||||
35653832353062396634613832353837333835636461616234343462626239636634613430373931
|
||||
31656534343764643065643733326637343631356633653531313062633362663461313732633331
|
||||
35626364393563373339636466346339383032383635303865306636623737343237333863353238
|
||||
63306132396262656365323833323635633563653735366630313363386236613231346339643430
|
||||
63396230353566633830383932666335373665356434656438336338633035653465613665613862
|
||||
31663565653338376662323866613538363566306635333735646363363730646331306234353839
|
||||
30346363393231623563646439623261643634663831313338393761343865303930373133633733
|
||||
31656466303365316164396463373335396464643130643337656361333339653238333633373662
|
||||
6539
|
||||
33643937613637343765356165333337356138326236356334363238366632633935363563383232
|
||||
6263396663316461353035393836313535353133336132650a643062656239633635373930366131
|
||||
63363566666263336337356161663231343266383333613261666534653438666661303761653063
|
||||
6163333239313430620a613665393231356535666530613731303536613537333464613533616663
|
||||
30373935366138643939316563346364376333646333396264653537643666393835353964303031
|
||||
30366366666163383263663961383037386264393939306235646532636439383838343237303339
|
||||
62333965323763323233303239343132383830303130306265333330333434663337363930653161
|
||||
30646133333530333330653365306437313839636535333163346263343064376436633432623061
|
||||
39343332643836333932316439636166333831393864363434663837646339666638353835393964
|
||||
61363430303637633239373031396535383730623862386464316633393361306561613933353830
|
||||
66313835306563643733366135353062623635663165303833373563663063323731313162323133
|
||||
61373837353732656266336461663165626435383234336461343365396561623037353566356339
|
||||
32366336396638626166616362613230323933666565613561393431393035376465343739333739
|
||||
36313934313636386465306435353132373364653562666162613033373130623430656632396635
|
||||
39373437353838313734636166323336376534373765623332356638666234376464383033326433
|
||||
33636336376231313062643237636534363838326264333930383635373761346532393664363038
|
||||
34633334653464313430363735666435373535363465343134333636303536303265333931343138
|
||||
35633864623930386661316264383865373930316233653238323437363836643236333236336537
|
||||
37353565313434383733333861626566623363316335666230373435633163356566616366663339
|
||||
64323533366265396164303937323036323037383637643332326361363864333334653232376134
|
||||
33346366343865336437383138396639393238353633343562356435306537633830303361333730
|
||||
30386133396565613539653931663961303534613566626265376135386461383162396334393733
|
||||
39343466336136643565656332336562643933383330343830633264396436383065373032646664
|
||||
34643939613962653137303238663535633565363961336263316631313737663036336331663133
|
||||
61323538376434396432633565613135376163636233373832366461353665633266373435396436
|
||||
30376539366264306661353863313165323839646536393466623838393862396530326466363936
|
||||
36373865316165393665353737643561663863353630373333313936653163386136623831396637
|
||||
36306236626337303561376366376639613337396136313336383131303634623364316234376432
|
||||
65383362346363336639366665333436346234383566643937643130363261656662653763313639
|
||||
66396162356234343163633633376639623736643066643030626232633634616261303530623032
|
||||
38393032643963386133393534616133396135303531333839643063613331643334323762653933
|
||||
39646234366564333935366335363964666337383264333263326561636231303164356532323163
|
||||
63323430363337353339353739363638366136326231666335343830363838663366613432303735
|
||||
34323431343336643566346365333062363862646138396535633036653737643462323235326265
|
||||
39306336396238653063353939613966323466306335346635353964613535313961353263303235
|
||||
35646330366534386330333135316437313435376331343630643330323030626432343034323861
|
||||
39363437333137386137323036333336613238613530316338343930616137666261383733653432
|
||||
63316266323664396335363334663465636262663366346139383535626236653765323038343366
|
||||
64386639373536306638323036386364373465313037393431663965646633613838303566663139
|
||||
31663162313166636262313663363061666531636432366536343063336439636465663032356563
|
||||
30656562336565303237663332303230306637353465616136346233636464616666383734303938
|
||||
32666466366363346232653461333263366164313130336331326339366361326139636635646630
|
||||
376264626331393262653961663566383866
|
||||
|
||||
0
pyswiss/apps/charts/__init__.py
Normal file
0
pyswiss/apps/charts/__init__.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChartsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'apps.charts'
|
||||
130
pyswiss/apps/charts/calc.py
Normal file
130
pyswiss/apps/charts/calc.py
Normal file
@@ -0,0 +1,130 @@
|
||||
"""
|
||||
Core ephemeris calculation logic — shared by views and management commands.
|
||||
"""
|
||||
from django.conf import settings as django_settings
|
||||
import swisseph as swe
|
||||
|
||||
|
||||
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
|
||||
|
||||
SIGNS = [
|
||||
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
|
||||
]
|
||||
|
||||
SIGN_ELEMENT = {
|
||||
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
|
||||
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
|
||||
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
|
||||
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
|
||||
}
|
||||
|
||||
ASPECTS = [
|
||||
('Conjunction', 0, 8.0),
|
||||
('Sextile', 60, 6.0),
|
||||
('Square', 90, 8.0),
|
||||
('Trine', 120, 8.0),
|
||||
('Opposition', 180, 10.0),
|
||||
]
|
||||
|
||||
PLANET_CODES = {
|
||||
'Sun': swe.SUN,
|
||||
'Moon': swe.MOON,
|
||||
'Mercury': swe.MERCURY,
|
||||
'Venus': swe.VENUS,
|
||||
'Mars': swe.MARS,
|
||||
'Jupiter': swe.JUPITER,
|
||||
'Saturn': swe.SATURN,
|
||||
'Uranus': swe.URANUS,
|
||||
'Neptune': swe.NEPTUNE,
|
||||
'Pluto': swe.PLUTO,
|
||||
}
|
||||
|
||||
|
||||
def set_ephe_path():
|
||||
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
|
||||
if ephe_path:
|
||||
swe.set_ephe_path(ephe_path)
|
||||
|
||||
|
||||
def get_sign(lon):
|
||||
return SIGNS[int(lon // 30) % 12]
|
||||
|
||||
|
||||
def get_julian_day(dt):
|
||||
return swe.julday(
|
||||
dt.year, dt.month, dt.day,
|
||||
dt.hour + dt.minute / 60 + dt.second / 3600,
|
||||
)
|
||||
|
||||
|
||||
def get_planet_positions(jd):
|
||||
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
|
||||
planets = {}
|
||||
for name, code in PLANET_CODES.items():
|
||||
pos, _ = swe.calc_ut(jd, code, flag)
|
||||
degree = pos[0]
|
||||
planets[name] = {
|
||||
'sign': get_sign(degree),
|
||||
'degree': degree,
|
||||
'retrograde': pos[3] < 0,
|
||||
}
|
||||
return planets
|
||||
|
||||
|
||||
def get_element_counts(planets):
|
||||
sign_counts = {s: 0 for s in SIGNS}
|
||||
counts = {'Fire': 0, 'Water': 0, 'Earth': 0, 'Air': 0}
|
||||
|
||||
for data in planets.values():
|
||||
sign = data['sign']
|
||||
counts[SIGN_ELEMENT[sign]] += 1
|
||||
sign_counts[sign] += 1
|
||||
|
||||
# Time: highest planet concentration in a single sign, minus 1
|
||||
counts['Time'] = max(sign_counts.values()) - 1
|
||||
|
||||
# Space: longest consecutive run of occupied signs (circular), minus 1
|
||||
indices = [i for i, s in enumerate(SIGNS) if sign_counts[s] > 0]
|
||||
max_seq = 0
|
||||
for start in range(len(indices)):
|
||||
seq_len = 1
|
||||
for offset in range(1, len(indices)):
|
||||
if (indices[start] + offset) % len(SIGNS) in indices:
|
||||
seq_len += 1
|
||||
else:
|
||||
break
|
||||
max_seq = max(max_seq, seq_len)
|
||||
counts['Space'] = max_seq - 1
|
||||
|
||||
return counts
|
||||
|
||||
|
||||
def calculate_aspects(planets):
|
||||
"""Return a list of aspects between all planet pairs.
|
||||
|
||||
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
|
||||
Only the first matching aspect type is reported per pair (aspects are
|
||||
well-separated enough that at most one can apply with standard orbs).
|
||||
"""
|
||||
names = list(planets.keys())
|
||||
aspects = []
|
||||
for i, name1 in enumerate(names):
|
||||
for name2 in names[i + 1:]:
|
||||
deg1 = planets[name1]['degree']
|
||||
deg2 = planets[name2]['degree']
|
||||
angle = abs(deg1 - deg2)
|
||||
if angle > 180:
|
||||
angle = 360 - angle
|
||||
for aspect_name, target, max_orb in ASPECTS:
|
||||
orb = abs(angle - target)
|
||||
if orb <= max_orb:
|
||||
aspects.append({
|
||||
'planet1': name1,
|
||||
'planet2': name2,
|
||||
'type': aspect_name,
|
||||
'angle': round(angle, 2),
|
||||
'orb': round(orb, 2),
|
||||
})
|
||||
break
|
||||
return aspects
|
||||
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path
|
||||
from apps.charts.models import EphemerisSnapshot
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).'
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)')
|
||||
parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)')
|
||||
|
||||
def handle(self, *args, **options):
|
||||
set_ephe_path()
|
||||
|
||||
date_from = date.fromisoformat(options['date_from'])
|
||||
date_to = date.fromisoformat(options['date_to'])
|
||||
|
||||
current = date_from
|
||||
count = 0
|
||||
while current <= date_to:
|
||||
dt = datetime(current.year, current.month, current.day,
|
||||
12, 0, 0, tzinfo=timezone.utc)
|
||||
jd = get_julian_day(dt)
|
||||
planets = get_planet_positions(jd)
|
||||
elements = get_element_counts(planets)
|
||||
|
||||
EphemerisSnapshot.objects.update_or_create(
|
||||
dt=dt,
|
||||
defaults={
|
||||
'fire': elements['Fire'],
|
||||
'water': elements['Water'],
|
||||
'earth': elements['Earth'],
|
||||
'air': elements['Air'],
|
||||
'time_el': elements['Time'],
|
||||
'space_el': elements['Space'],
|
||||
'chart_data': {'planets': planets},
|
||||
},
|
||||
)
|
||||
current += timedelta(days=1)
|
||||
count += 1
|
||||
|
||||
if options['verbosity'] > 0:
|
||||
self.stdout.write(
|
||||
self.style.SUCCESS(f'Created/updated {count} snapshot(s).')
|
||||
)
|
||||
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0.4 on 2026-04-13 20:40
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='EphemerisSnapshot',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('dt', models.DateTimeField(db_index=True, unique=True)),
|
||||
('fire', models.PositiveSmallIntegerField()),
|
||||
('water', models.PositiveSmallIntegerField()),
|
||||
('earth', models.PositiveSmallIntegerField()),
|
||||
('air', models.PositiveSmallIntegerField()),
|
||||
('time_el', models.PositiveSmallIntegerField()),
|
||||
('space_el', models.PositiveSmallIntegerField()),
|
||||
('chart_data', models.JSONField()),
|
||||
],
|
||||
options={
|
||||
'ordering': ['dt'],
|
||||
},
|
||||
),
|
||||
]
|
||||
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
36
pyswiss/apps/charts/models.py
Normal file
36
pyswiss/apps/charts/models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from django.db import models
|
||||
|
||||
|
||||
class EphemerisSnapshot(models.Model):
|
||||
"""Pre-computed chart data for a single point in time.
|
||||
|
||||
Element counts are stored as denormalised columns for fast DB-level range
|
||||
filtering. Full planet/house data lives in chart_data (JSONField) for
|
||||
response serialisation.
|
||||
"""
|
||||
|
||||
dt = models.DateTimeField(unique=True, db_index=True)
|
||||
|
||||
# Denormalised element counts — indexed for range queries
|
||||
fire = models.PositiveSmallIntegerField()
|
||||
water = models.PositiveSmallIntegerField()
|
||||
earth = models.PositiveSmallIntegerField()
|
||||
air = models.PositiveSmallIntegerField()
|
||||
time_el = models.PositiveSmallIntegerField()
|
||||
space_el = models.PositiveSmallIntegerField()
|
||||
|
||||
# Full chart payload
|
||||
chart_data = models.JSONField()
|
||||
|
||||
class Meta:
|
||||
ordering = ['dt']
|
||||
|
||||
def elements_dict(self):
|
||||
return {
|
||||
'Fire': self.fire,
|
||||
'Water': self.water,
|
||||
'Earth': self.earth,
|
||||
'Air': self.air,
|
||||
'Time': self.time_el,
|
||||
'Space': self.space_el,
|
||||
}
|
||||
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
|
||||
|
||||
These tests drive the EphemerisSnapshot model and list view.
|
||||
Snapshots are created directly in setUp — no live ephemeris calc needed.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.charts.models import EphemerisSnapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CHART_DATA_STUB = {
|
||||
'planets': {
|
||||
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
|
||||
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
|
||||
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
|
||||
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
|
||||
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
|
||||
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
|
||||
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
|
||||
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
|
||||
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
|
||||
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
|
||||
},
|
||||
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
|
||||
}
|
||||
|
||||
|
||||
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
|
||||
chart_data=None):
|
||||
return EphemerisSnapshot.objects.create(
|
||||
dt=dt_str,
|
||||
fire=fire, water=water, earth=earth, air=air,
|
||||
time_el=time_el, space_el=space_el,
|
||||
chart_data=chart_data or CHART_DATA_STUB,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ChartsListApiTest(TestCase):
|
||||
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
|
||||
|
||||
def setUp(self):
|
||||
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
|
||||
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
|
||||
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
|
||||
# Outside the usual date range — should not appear in filtered results
|
||||
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
|
||||
|
||||
def _get(self, params=None):
|
||||
return self.client.get('/api/charts/', params or {})
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_400_if_date_from_missing(self):
|
||||
response = self._get({'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_if_date_to_missing(self):
|
||||
response = self._get({'date_from': '2000-01-01'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_for_invalid_date_from(self):
|
||||
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_charts_returns_400_if_date_to_before_date_from(self):
|
||||
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_200_for_valid_params(self):
|
||||
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_charts_response_is_json(self):
|
||||
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_charts_response_has_results_and_count(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
self.assertIn('results', data)
|
||||
self.assertIn('count', data)
|
||||
|
||||
def test_each_result_has_dt_and_elements(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
for result in data['results']:
|
||||
with self.subTest(dt=result.get('dt')):
|
||||
self.assertIn('dt', result)
|
||||
self.assertIn('elements', result)
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
self.assertIn(key, result['elements'])
|
||||
|
||||
def test_each_result_has_planets(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
for result in data['results']:
|
||||
with self.subTest(dt=result.get('dt')):
|
||||
self.assertIn('planets', result)
|
||||
|
||||
# ── date range filtering ──────────────────────────────────────────────
|
||||
|
||||
def test_charts_returns_only_snapshots_in_date_range(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
self.assertEqual(data['count'], 3)
|
||||
|
||||
def test_charts_count_matches_results_length(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
|
||||
self.assertEqual(data['count'], len(data['results']))
|
||||
|
||||
def test_charts_date_range_is_inclusive(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_results_ordered_by_dt(self):
|
||||
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||
dts = [r['dt'] for r in data['results']]
|
||||
self.assertEqual(dts, sorted(dts))
|
||||
|
||||
# ── element range filtering ───────────────────────────────────────────
|
||||
|
||||
def test_charts_filters_by_fire_min(self):
|
||||
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_filters_by_water_min(self):
|
||||
# Only the Jan 2 snapshot has water=4
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_filters_by_earth_min(self):
|
||||
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 1)
|
||||
|
||||
def test_charts_multiple_element_filters_are_conjunctive(self):
|
||||
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
|
||||
data = self._get({
|
||||
'date_from': '2000-01-01', 'date_to': '2000-01-31',
|
||||
'fire_min': 2, 'water_min': 2,
|
||||
}).json()
|
||||
self.assertEqual(data['count'], 2)
|
||||
215
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
215
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,215 @@
|
||||
"""
|
||||
Integration tests for the PySwiss chart calculation API.
|
||||
|
||||
These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/.
|
||||
They verify the HTTP contract using Django's test client.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import TestCase
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
|
||||
J2000 = '2000-01-01T12:00:00Z'
|
||||
LONDON = {'lat': 51.5074, 'lon': -0.1278}
|
||||
|
||||
# Well-known coordinates with unambiguous timezone results
|
||||
NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York
|
||||
TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo
|
||||
REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik
|
||||
|
||||
|
||||
class ChartApiTest(TestCase):
|
||||
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
|
||||
|
||||
def _get(self, params):
|
||||
return self.client.get('/api/chart/', params)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_400_if_dt_missing(self):
|
||||
response = self._get({'lat': 51.5074, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_if_lat_missing(self):
|
||||
response = self._get({'dt': J2000, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_if_lon_missing(self):
|
||||
response = self._get({'dt': J2000, 'lat': 51.5074})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_for_invalid_dt_format(self):
|
||||
response = self._get({'dt': 'not-a-date', **LONDON})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_chart_returns_400_for_out_of_range_lat(self):
|
||||
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_200_for_valid_params(self):
|
||||
response = self._get({'dt': J2000, **LONDON})
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_chart_response_is_json(self):
|
||||
response = self._get({'dt': J2000, **LONDON})
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_chart_returns_all_ten_planets(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
expected = {
|
||||
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
|
||||
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
|
||||
}
|
||||
self.assertEqual(set(data['planets'].keys()), expected)
|
||||
|
||||
def test_each_planet_has_sign_degree_and_retrograde(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for name, planet in data['planets'].items():
|
||||
with self.subTest(planet=name):
|
||||
self.assertIn('sign', planet)
|
||||
self.assertIn('degree', planet)
|
||||
self.assertIn('retrograde', planet)
|
||||
|
||||
def test_chart_returns_houses(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
houses = data['houses']
|
||||
self.assertEqual(len(houses['cusps']), 12)
|
||||
self.assertIn('asc', houses)
|
||||
self.assertIn('mc', houses)
|
||||
|
||||
def test_chart_returns_six_element_counts(self):
|
||||
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||
with self.subTest(element=key):
|
||||
self.assertIn(key, data['elements'])
|
||||
|
||||
def test_chart_reports_active_house_system(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('house_system', data)
|
||||
|
||||
# ── calculation correctness ───────────────────────────────────────────
|
||||
|
||||
def test_sun_is_in_capricorn_at_j2000(self):
|
||||
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
sun = data['planets']['Sun']
|
||||
self.assertEqual(sun['sign'], 'Capricorn')
|
||||
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
|
||||
|
||||
def test_sun_is_not_retrograde(self):
|
||||
"""The Sun never goes retrograde."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertFalse(data['planets']['Sun']['retrograde'])
|
||||
|
||||
def test_element_counts_sum_to_ten(self):
|
||||
"""All 10 planets are assigned to exactly one classical element."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
classical = sum(
|
||||
data['elements'][e] for e in ('Fire', 'Water', 'Earth', 'Air')
|
||||
)
|
||||
self.assertEqual(classical, 10)
|
||||
|
||||
# ── house system ──────────────────────────────────────────────────────
|
||||
|
||||
def test_default_house_system_is_porphyry(self):
|
||||
"""Porphyry ('O') is the project default — no param needed."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertEqual(data['house_system'], 'O')
|
||||
|
||||
def test_non_superuser_cannot_override_house_system(self):
|
||||
"""House system override is superuser-only; plain requests get 403."""
|
||||
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
# ── aspects ───────────────────────────────────────────────────────────
|
||||
|
||||
def test_chart_returns_aspects_list(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
self.assertIn('aspects', data)
|
||||
self.assertIsInstance(data['aspects'], list)
|
||||
|
||||
def test_each_aspect_has_required_fields(self):
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
for aspect in data['aspects']:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn('planet1', aspect)
|
||||
self.assertIn('planet2', aspect)
|
||||
self.assertIn('type', aspect)
|
||||
self.assertIn('angle', aspect)
|
||||
self.assertIn('orb', aspect)
|
||||
|
||||
def test_sun_saturn_trine_present_at_j2000(self):
|
||||
"""Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine."""
|
||||
data = self._get({'dt': J2000, **LONDON}).json()
|
||||
pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']}
|
||||
self.assertIn(('Sun', 'Saturn', 'Trine'), pairs)
|
||||
|
||||
|
||||
class TimezoneApiTest(TestCase):
|
||||
"""GET /api/tz/ — resolve IANA timezone from lat/lon coordinates."""
|
||||
|
||||
def _get(self, params):
|
||||
return self.client.get('/api/tz/', params)
|
||||
|
||||
# ── guards ────────────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_400_if_lat_missing(self):
|
||||
response = self._get({'lon': -74.0060})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_if_lon_missing(self):
|
||||
response = self._get({'lat': 40.7128})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_for_invalid_lat(self):
|
||||
response = self._get({'lat': 'abc', 'lon': -74.0060})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_for_out_of_range_lat(self):
|
||||
response = self._get({'lat': 999, 'lon': -74.0060})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_returns_400_for_out_of_range_lon(self):
|
||||
response = self._get({'lat': 40.7128, 'lon': 999})
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
# ── response shape ────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_200_for_valid_coords(self):
|
||||
response = self._get(NEW_YORK)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_response_is_json(self):
|
||||
response = self._get(NEW_YORK)
|
||||
self.assertIn('application/json', response['Content-Type'])
|
||||
|
||||
def test_response_contains_timezone_key(self):
|
||||
data = self._get(NEW_YORK).json()
|
||||
self.assertIn('timezone', data)
|
||||
|
||||
def test_timezone_is_a_string(self):
|
||||
data = self._get(NEW_YORK).json()
|
||||
self.assertIsInstance(data['timezone'], str)
|
||||
|
||||
# ── correctness ───────────────────────────────────────────────────────
|
||||
|
||||
def test_new_york_timezone(self):
|
||||
data = self._get(NEW_YORK).json()
|
||||
self.assertEqual(data['timezone'], 'America/New_York')
|
||||
|
||||
def test_tokyo_timezone(self):
|
||||
data = self._get(TOKYO).json()
|
||||
self.assertEqual(data['timezone'], 'Asia/Tokyo')
|
||||
|
||||
def test_reykjavik_timezone(self):
|
||||
data = self._get(REYKJAVIK).json()
|
||||
self.assertEqual(data['timezone'], 'Atlantic/Reykjavik')
|
||||
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
148
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Unit tests for calc.py helper functions.
|
||||
|
||||
These tests verify pure calculation logic without hitting the database
|
||||
or the Swiss Ephemeris — all inputs are fixed synthetic data.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.charts.calc import calculate_aspects
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthetic planet data — degrees chosen for predictable aspects
|
||||
# Matches FAKE_PLANETS in test_populate_ephemeris.py
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
FAKE_PLANETS = {
|
||||
'Sun': {'degree': 10.0}, # Aries
|
||||
'Moon': {'degree': 130.0}, # Leo — 120° from Sun → Trine
|
||||
'Mercury': {'degree': 250.0}, # Sagittarius — 120° from Sun → Trine
|
||||
'Venus': {'degree': 40.0}, # Taurus — 90° from Moon → Square
|
||||
'Mars': {'degree': 160.0}, # Virgo — 60° from Neptune → Sextile
|
||||
'Jupiter': {'degree': 280.0}, # Capricorn — 120° from Mars → Trine
|
||||
'Saturn': {'degree': 70.0}, # Gemini — 120° from Uranus → Trine
|
||||
'Uranus': {'degree': 310.0}, # Aquarius — 60° from Sun (wrap) → Sextile
|
||||
'Neptune': {'degree': 100.0}, # Cancer
|
||||
'Pluto': {'degree': 340.0}, # Pisces
|
||||
}
|
||||
|
||||
|
||||
def _aspect_pairs(aspects):
|
||||
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
|
||||
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
|
||||
|
||||
|
||||
class CalculateAspectsTest(SimpleTestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.aspects = calculate_aspects(FAKE_PLANETS)
|
||||
|
||||
# ── return shape ──────────────────────────────────────────────────────
|
||||
|
||||
def test_returns_a_list(self):
|
||||
self.assertIsInstance(self.aspects, list)
|
||||
|
||||
def test_each_aspect_has_required_keys(self):
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn('planet1', aspect)
|
||||
self.assertIn('planet2', aspect)
|
||||
self.assertIn('type', aspect)
|
||||
self.assertIn('angle', aspect)
|
||||
self.assertIn('orb', aspect)
|
||||
|
||||
def test_each_aspect_type_is_a_known_name(self):
|
||||
known = {'Conjunction', 'Sextile', 'Square', 'Trine', 'Opposition'}
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIn(aspect['type'], known)
|
||||
|
||||
def test_angle_and_orb_are_floats(self):
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertIsInstance(aspect['angle'], float)
|
||||
self.assertIsInstance(aspect['orb'], float)
|
||||
|
||||
def test_no_self_aspects(self):
|
||||
for aspect in self.aspects:
|
||||
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
|
||||
|
||||
def test_no_duplicate_pairs(self):
|
||||
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
|
||||
self.assertEqual(len(pairs), len(set(pairs)))
|
||||
|
||||
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
|
||||
|
||||
def test_sun_moon_trine(self):
|
||||
"""Moon at 130° is exactly 120° from Sun at 10°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
|
||||
|
||||
def test_sun_mercury_trine(self):
|
||||
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
|
||||
|
||||
def test_moon_mercury_trine(self):
|
||||
"""Moon 130° → Mercury 250° = 120°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
|
||||
|
||||
def test_moon_venus_square(self):
|
||||
"""Moon 130° → Venus 40° = 90°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
|
||||
|
||||
def test_venus_neptune_sextile(self):
|
||||
"""Venus 40° → Neptune 100° = 60°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
|
||||
|
||||
def test_mars_neptune_sextile(self):
|
||||
"""Mars 160° → Neptune 100° = 60°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
|
||||
|
||||
def test_sun_uranus_sextile(self):
|
||||
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
|
||||
|
||||
def test_mars_jupiter_trine(self):
|
||||
"""Mars 160° → Jupiter 280° = 120°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
|
||||
|
||||
def test_saturn_uranus_trine(self):
|
||||
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
|
||||
pairs = _aspect_pairs(self.aspects)
|
||||
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
|
||||
|
||||
# ── orb bounds ────────────────────────────────────────────────────────
|
||||
|
||||
def test_orb_is_within_allowed_maximum(self):
|
||||
max_orbs = {
|
||||
'Conjunction': 8.0,
|
||||
'Sextile': 6.0,
|
||||
'Square': 8.0,
|
||||
'Trine': 8.0,
|
||||
'Opposition': 10.0,
|
||||
}
|
||||
for aspect in self.aspects:
|
||||
with self.subTest(aspect=aspect):
|
||||
self.assertLessEqual(
|
||||
aspect['orb'], max_orbs[aspect['type']],
|
||||
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
|
||||
)
|
||||
|
||||
def test_exact_trine_has_zero_orb(self):
|
||||
"""Sun-Moon at exactly 120° should report orb of 0.0."""
|
||||
sun_moon = next(
|
||||
a for a in self.aspects
|
||||
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
|
||||
)
|
||||
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)
|
||||
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
Unit tests for the populate_ephemeris management command.
|
||||
|
||||
pyswisseph calls are mocked — these tests verify date iteration,
|
||||
snapshot persistence, and idempotency without touching the ephemeris.
|
||||
|
||||
Run:
|
||||
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.charts.models import EphemerisSnapshot
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 10 planets covering Fire×3, Earth×3, Air×2, Water×2 (one per sign)
|
||||
# Expected: fire=3, water=2, earth=3, air=2, time=0, space=9
|
||||
FAKE_PLANETS = {
|
||||
'Sun': {'sign': 'Aries', 'degree': 10.0, 'retrograde': False},
|
||||
'Moon': {'sign': 'Leo', 'degree': 130.0, 'retrograde': False},
|
||||
'Mercury': {'sign': 'Sagittarius', 'degree': 250.0, 'retrograde': False},
|
||||
'Venus': {'sign': 'Taurus', 'degree': 40.0, 'retrograde': False},
|
||||
'Mars': {'sign': 'Virgo', 'degree': 160.0, 'retrograde': False},
|
||||
'Jupiter': {'sign': 'Capricorn', 'degree': 280.0, 'retrograde': False},
|
||||
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'retrograde': False},
|
||||
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'retrograde': False},
|
||||
'Neptune': {'sign': 'Cancer', 'degree': 100.0, 'retrograde': False},
|
||||
'Pluto': {'sign': 'Pisces', 'degree': 340.0, 'retrograde': False},
|
||||
}
|
||||
|
||||
PATCH_TARGET = (
|
||||
'apps.charts.management.commands.populate_ephemeris.get_planet_positions'
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class PopulateEphemerisCommandTest(TestCase):
|
||||
|
||||
def _run(self, date_from, date_to):
|
||||
with patch(PATCH_TARGET, return_value=FAKE_PLANETS):
|
||||
call_command('populate_ephemeris',
|
||||
date_from=date_from, date_to=date_to,
|
||||
verbosity=0)
|
||||
|
||||
# ── date iteration ────────────────────────────────────────────────────
|
||||
|
||||
def test_creates_one_snapshot_per_day(self):
|
||||
self._run('2000-01-01', '2000-01-03')
|
||||
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
|
||||
|
||||
def test_single_day_range_creates_one_snapshot(self):
|
||||
self._run('2000-01-01', '2000-01-01')
|
||||
self.assertEqual(EphemerisSnapshot.objects.count(), 1)
|
||||
|
||||
def test_snapshots_are_at_noon_utc(self):
|
||||
self._run('2000-01-01', '2000-01-01')
|
||||
snap = EphemerisSnapshot.objects.get()
|
||||
self.assertEqual(snap.dt, datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||
|
||||
# ── idempotency ───────────────────────────────────────────────────────
|
||||
|
||||
def test_rerunning_does_not_create_duplicates(self):
|
||||
self._run('2000-01-01', '2000-01-03')
|
||||
self._run('2000-01-01', '2000-01-03')
|
||||
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
|
||||
|
||||
def test_overlapping_ranges_do_not_duplicate(self):
|
||||
self._run('2000-01-01', '2000-01-03')
|
||||
self._run('2000-01-02', '2000-01-05')
|
||||
self.assertEqual(EphemerisSnapshot.objects.count(), 5)
|
||||
|
||||
# ── element counts ────────────────────────────────────────────────────
|
||||
|
||||
def test_element_counts_are_persisted(self):
|
||||
self._run('2000-01-01', '2000-01-01')
|
||||
snap = EphemerisSnapshot.objects.get()
|
||||
self.assertEqual(snap.fire, 3)
|
||||
self.assertEqual(snap.water, 2)
|
||||
self.assertEqual(snap.earth, 3)
|
||||
self.assertEqual(snap.air, 2)
|
||||
self.assertEqual(snap.time_el, 0)
|
||||
self.assertEqual(snap.space_el, 9)
|
||||
|
||||
# ── chart_data payload ────────────────────────────────────────────────
|
||||
|
||||
def test_chart_data_contains_planets(self):
|
||||
self._run('2000-01-01', '2000-01-01')
|
||||
snap = EphemerisSnapshot.objects.get()
|
||||
self.assertEqual(snap.chart_data['planets'], FAKE_PLANETS)
|
||||
8
pyswiss/apps/charts/urls.py
Normal file
8
pyswiss/apps/charts/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
from . import views
|
||||
|
||||
urlpatterns = [
|
||||
path('chart/', views.chart, name='chart'),
|
||||
path('charts/', views.charts_list, name='charts_list'),
|
||||
path('tz/', views.timezone_lookup, name='timezone_lookup'),
|
||||
]
|
||||
143
pyswiss/apps/charts/views.py
Normal file
143
pyswiss/apps/charts/views.py
Normal file
@@ -0,0 +1,143 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from timezonefinder import TimezoneFinder
|
||||
|
||||
import swisseph as swe
|
||||
|
||||
from .calc import (
|
||||
DEFAULT_HOUSE_SYSTEM,
|
||||
calculate_aspects,
|
||||
get_element_counts,
|
||||
get_julian_day,
|
||||
get_planet_positions,
|
||||
set_ephe_path,
|
||||
)
|
||||
from .models import EphemerisSnapshot
|
||||
|
||||
|
||||
def chart(request):
|
||||
dt_str = request.GET.get('dt')
|
||||
lat_str = request.GET.get('lat')
|
||||
lon_str = request.GET.get('lon')
|
||||
|
||||
if not dt_str or lat_str is None or lon_str is None:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
lat = float(lat_str)
|
||||
lon = float(lon_str)
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if not (-90 <= lat <= 90):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
house_system_param = request.GET.get('house_system')
|
||||
if house_system_param is not None:
|
||||
if not (hasattr(request, 'user') and request.user.is_authenticated
|
||||
and request.user.is_superuser):
|
||||
return HttpResponse(status=403)
|
||||
house_system = house_system_param
|
||||
else:
|
||||
house_system = DEFAULT_HOUSE_SYSTEM
|
||||
|
||||
set_ephe_path()
|
||||
|
||||
jd = get_julian_day(dt)
|
||||
planets = get_planet_positions(jd)
|
||||
|
||||
cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode())
|
||||
houses = {
|
||||
'cusps': list(cusps),
|
||||
'asc': ascmc[0],
|
||||
'mc': ascmc[1],
|
||||
}
|
||||
|
||||
return JsonResponse({
|
||||
'planets': planets,
|
||||
'houses': houses,
|
||||
'elements': get_element_counts(planets),
|
||||
'aspects': calculate_aspects(planets),
|
||||
'house_system': house_system,
|
||||
})
|
||||
|
||||
|
||||
_tf = TimezoneFinder()
|
||||
|
||||
|
||||
def timezone_lookup(request):
|
||||
"""GET /api/tz/ — resolve IANA timezone string from lat/lon.
|
||||
|
||||
Query params: lat (float), lon (float)
|
||||
Returns: { "timezone": "America/New_York" }
|
||||
Returns 404 JSON { "timezone": null } if coordinates fall in international
|
||||
waters (no timezone found) — not an error, just no result.
|
||||
"""
|
||||
lat_str = request.GET.get('lat')
|
||||
lon_str = request.GET.get('lon')
|
||||
|
||||
if lat_str is None or lon_str is None:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
lat = float(lat_str)
|
||||
lon = float(lon_str)
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||
return HttpResponse(status=400)
|
||||
|
||||
tz = _tf.timezone_at(lat=lat, lng=lon)
|
||||
return JsonResponse({'timezone': tz})
|
||||
|
||||
|
||||
def charts_list(request):
|
||||
date_from_str = request.GET.get('date_from')
|
||||
date_to_str = request.GET.get('date_to')
|
||||
|
||||
if not date_from_str or not date_to_str:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
try:
|
||||
date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace(
|
||||
tzinfo=timezone.utc)
|
||||
date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace(
|
||||
hour=23, minute=59, second=59, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
if date_to < date_from:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to)
|
||||
|
||||
element_fields = {
|
||||
'fire_min': 'fire', 'water_min': 'water',
|
||||
'earth_min': 'earth', 'air_min': 'air',
|
||||
'time_min': 'time_el', 'space_min': 'space_el',
|
||||
}
|
||||
for param, field in element_fields.items():
|
||||
value = request.GET.get(param)
|
||||
if value is not None:
|
||||
try:
|
||||
qs = qs.filter(**{f'{field}__gte': int(value)})
|
||||
except ValueError:
|
||||
return HttpResponse(status=400)
|
||||
|
||||
results = [
|
||||
{
|
||||
'dt': snap.dt.isoformat(),
|
||||
'elements': snap.elements_dict(),
|
||||
'planets': snap.chart_data.get('planets', {}),
|
||||
}
|
||||
for snap in qs
|
||||
]
|
||||
|
||||
return JsonResponse({'results': results, 'count': len(results)})
|
||||
0
pyswiss/core/__init__.py
Normal file
0
pyswiss/core/__init__.py
Normal file
49
pyswiss/core/settings.py
Normal file
49
pyswiss/core/settings.py
Normal file
@@ -0,0 +1,49 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY', 'pyswiss-dev-only-key-replace-in-production')
|
||||
DEBUG = os.environ.get('DEBUG', 'true').lower() != 'false'
|
||||
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'corsheaders',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.auth',
|
||||
'apps.charts',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
'corsheaders.middleware.CorsMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
]
|
||||
|
||||
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||
r'^https://.*\.earthmanrpg\.me$',
|
||||
r'^http://localhost(:\d+)?$',
|
||||
]
|
||||
|
||||
ROOT_URLCONF = 'core.urls'
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': BASE_DIR / 'db.sqlite3',
|
||||
}
|
||||
}
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
STATIC_URL = '/static/'
|
||||
MEDIA_URL = '/media/'
|
||||
|
||||
USE_TZ = True
|
||||
TIME_ZONE = 'UTC'
|
||||
|
||||
# Swiss Ephemeris data files.
|
||||
# Override via SWISSEPH_PATH env var on staging/production.
|
||||
SWISSEPH_PATH = os.environ.get(
|
||||
'SWISSEPH_PATH',
|
||||
r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe',
|
||||
)
|
||||
5
pyswiss/core/urls.py
Normal file
5
pyswiss/core/urls.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from django.urls import path, include
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include('apps.charts.urls')),
|
||||
]
|
||||
6
pyswiss/core/wsgi.py
Normal file
6
pyswiss/core/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
||||
import os
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
20
pyswiss/manage.py
Normal file
20
pyswiss/manage.py
Normal file
@@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def main():
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and available "
|
||||
"on your PYTHONPATH environment variable? Did you forget to activate "
|
||||
"a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
5
pyswiss/requirements.txt
Normal file
5
pyswiss/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
django==6.0.4
|
||||
django-cors-headers==4.3.1
|
||||
gunicorn==23.0.0
|
||||
pyswisseph==2.10.3.2
|
||||
timezonefinder==8.2.2
|
||||
@@ -6,6 +6,7 @@ channels
|
||||
channels-redis
|
||||
charset-normalizer==3.4.4
|
||||
coverage
|
||||
cryptography
|
||||
cssselect==1.3.0
|
||||
daphne
|
||||
dj-database-url
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
celery
|
||||
cryptography
|
||||
channels
|
||||
channels-redis
|
||||
cssselect==1.3.0
|
||||
|
||||
0
src/apps/ap/__init__.py
Normal file
0
src/apps/ap/__init__.py
Normal file
7
src/apps/ap/apps.py
Normal file
7
src/apps/ap/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ApConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.ap"
|
||||
label = "ap"
|
||||
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
@@ -0,0 +1,119 @@
|
||||
import json
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class WebFingerTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||
|
||||
def test_returns_jrd_for_known_user(self):
|
||||
response = self.client.get(
|
||||
"/.well-known/webfinger",
|
||||
{"resource": "acct:actor@earthmanrpg.me"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "application/jrd+json")
|
||||
|
||||
def test_jrd_links_to_actor_url(self):
|
||||
response = self.client.get(
|
||||
"/.well-known/webfinger",
|
||||
{"resource": "acct:actor@earthmanrpg.me"},
|
||||
)
|
||||
data = json.loads(response.content)
|
||||
hrefs = [link["href"] for link in data["links"]]
|
||||
self.assertTrue(any("/ap/users/actor/" in href for href in hrefs))
|
||||
|
||||
def test_returns_404_for_unknown_user(self):
|
||||
response = self.client.get(
|
||||
"/.well-known/webfinger",
|
||||
{"resource": "acct:nobody@earthmanrpg.me"},
|
||||
)
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
def test_returns_400_for_missing_resource(self):
|
||||
response = self.client.get("/.well-known/webfinger")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
|
||||
class ActorViewTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||
|
||||
def test_returns_200_for_known_user(self):
|
||||
response = self.client.get("/ap/users/actor/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_returns_activity_json_content_type(self):
|
||||
response = self.client.get("/ap/users/actor/")
|
||||
self.assertEqual(response["Content-Type"], "application/activity+json")
|
||||
|
||||
def test_actor_has_required_fields(self):
|
||||
response = self.client.get("/ap/users/actor/")
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data["type"], "Person")
|
||||
self.assertIn("id", data)
|
||||
self.assertIn("outbox", data)
|
||||
self.assertIn("publicKey", data)
|
||||
|
||||
def test_requires_no_authentication(self):
|
||||
# AP Actor endpoints must be publicly accessible
|
||||
self.client.logout()
|
||||
response = self.client.get("/ap/users/actor/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_returns_404_for_unknown_user(self):
|
||||
response = self.client.get("/ap/users/nobody/")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
||||
|
||||
class OutboxViewTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin", renewal_days=7,
|
||||
)
|
||||
record(
|
||||
self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||
role="PC", slot_number=1, role_display="Player",
|
||||
)
|
||||
# INVITE_SENT is unsupported — should be excluded from outbox
|
||||
record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
||||
|
||||
def test_returns_200(self):
|
||||
response = self.client.get("/ap/users/actor/outbox/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_returns_activity_json_content_type(self):
|
||||
response = self.client.get("/ap/users/actor/outbox/")
|
||||
self.assertEqual(response["Content-Type"], "application/activity+json")
|
||||
|
||||
def test_outbox_is_ordered_collection(self):
|
||||
response = self.client.get("/ap/users/actor/outbox/")
|
||||
data = json.loads(response.content)
|
||||
self.assertEqual(data["type"], "OrderedCollection")
|
||||
|
||||
def test_total_items_excludes_unsupported_verbs(self):
|
||||
response = self.client.get("/ap/users/actor/outbox/")
|
||||
data = json.loads(response.content)
|
||||
# 2 supported events (SLOT_FILLED + ROLE_SELECTED); INVITE_SENT excluded
|
||||
self.assertEqual(data["totalItems"], 2)
|
||||
|
||||
def test_requires_no_authentication(self):
|
||||
self.client.logout()
|
||||
response = self.client.get("/ap/users/actor/outbox/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_returns_404_for_unknown_user(self):
|
||||
response = self.client.get("/ap/users/nobody/outbox/")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
0
src/apps/ap/tests/unit/__init__.py
Normal file
0
src/apps/ap/tests/unit/__init__.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
BASE = "https://earthmanrpg.me"
|
||||
|
||||
|
||||
class ToActivityTest(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io", username="testactor")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def _record(self, verb, **data):
|
||||
return record(self.room, verb, actor=self.user, **data)
|
||||
|
||||
def test_slot_filled_returns_join_gate_activity(self):
|
||||
event = self._record(
|
||||
GameEvent.SLOT_FILLED,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin", renewal_days=7,
|
||||
)
|
||||
activity = event.to_activity(BASE)
|
||||
self.assertIsNotNone(activity)
|
||||
self.assertEqual(activity["type"], "earthman:JoinGate")
|
||||
|
||||
def test_role_selected_returns_select_role_activity(self):
|
||||
event = self._record(
|
||||
GameEvent.ROLE_SELECTED,
|
||||
role="PC", slot_number=1, role_display="Player",
|
||||
)
|
||||
activity = event.to_activity(BASE)
|
||||
self.assertIsNotNone(activity)
|
||||
self.assertEqual(activity["type"], "earthman:SelectRole")
|
||||
|
||||
def test_room_created_returns_create_activity(self):
|
||||
event = self._record(GameEvent.ROOM_CREATED)
|
||||
activity = event.to_activity(BASE)
|
||||
self.assertIsNotNone(activity)
|
||||
self.assertEqual(activity["type"], "Create")
|
||||
|
||||
def test_unsupported_verb_returns_none(self):
|
||||
event = self._record(GameEvent.INVITE_SENT)
|
||||
self.assertIsNone(event.to_activity(BASE))
|
||||
|
||||
def test_activity_contains_actor_url(self):
|
||||
event = self._record(
|
||||
GameEvent.ROLE_SELECTED,
|
||||
role="PC", slot_number=1, role_display="Player",
|
||||
)
|
||||
activity = event.to_activity(BASE)
|
||||
self.assertIn(BASE, activity["actor"])
|
||||
|
||||
def test_activity_contains_object_url(self):
|
||||
event = self._record(
|
||||
GameEvent.SLOT_FILLED,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin", renewal_days=7,
|
||||
)
|
||||
activity = event.to_activity(BASE)
|
||||
self.assertIn(str(self.room.id), activity["object"])
|
||||
|
||||
|
||||
class EnsureKeypairTest(TestCase):
|
||||
|
||||
def test_ensure_keypair_populates_both_fields(self):
|
||||
user = User.objects.create(email="keys@test.io")
|
||||
self.assertEqual(user.ap_public_key, "")
|
||||
self.assertEqual(user.ap_private_key, "")
|
||||
user.ensure_keypair()
|
||||
self.assertTrue(user.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||
self.assertTrue(user.ap_private_key.startswith("-----BEGIN PRIVATE KEY-----"))
|
||||
|
||||
def test_ensure_keypair_persists_to_db(self):
|
||||
user = User.objects.create(email="persist@test.io")
|
||||
user.ensure_keypair()
|
||||
refreshed = User.objects.get(pk=user.pk)
|
||||
self.assertTrue(refreshed.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||
|
||||
def test_ensure_keypair_is_idempotent(self):
|
||||
user = User.objects.create(email="idem@test.io")
|
||||
user.ensure_keypair()
|
||||
original_pub = user.ap_public_key
|
||||
user.ensure_keypair()
|
||||
self.assertEqual(user.ap_public_key, original_pub)
|
||||
10
src/apps/ap/urls.py
Normal file
10
src/apps/ap/urls.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from . import views
|
||||
|
||||
app_name = "ap"
|
||||
|
||||
urlpatterns = [
|
||||
path("users/<str:username>/", views.actor, name="actor"),
|
||||
path("users/<str:username>/outbox/", views.outbox, name="outbox"),
|
||||
]
|
||||
83
src/apps/ap/views.py
Normal file
83
src/apps/ap/views.py
Normal file
@@ -0,0 +1,83 @@
|
||||
import json
|
||||
|
||||
from django.http import HttpResponse, JsonResponse
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
AP_CONTEXT = [
|
||||
"https://www.w3.org/ns/activitystreams",
|
||||
{"earthman": "https://earthmanrpg.me/ns#"},
|
||||
]
|
||||
|
||||
|
||||
def _base_url(request):
|
||||
return f"{request.scheme}://{request.get_host()}"
|
||||
|
||||
|
||||
def _ap_response(data):
|
||||
return HttpResponse(
|
||||
json.dumps(data),
|
||||
content_type="application/activity+json",
|
||||
)
|
||||
|
||||
|
||||
def webfinger(request):
|
||||
resource = request.GET.get("resource", "")
|
||||
if not resource:
|
||||
return HttpResponse(status=400)
|
||||
# Expect acct:username@host
|
||||
if not resource.startswith("acct:"):
|
||||
return HttpResponse(status=400)
|
||||
username = resource[len("acct:"):].split("@")[0]
|
||||
user = get_object_or_404(User, username=username)
|
||||
base = _base_url(request)
|
||||
data = {
|
||||
"subject": resource,
|
||||
"links": [
|
||||
{
|
||||
"rel": "self",
|
||||
"type": "application/activity+json",
|
||||
"href": f"{base}/ap/users/{user.username}/",
|
||||
}
|
||||
],
|
||||
}
|
||||
return HttpResponse(json.dumps(data), content_type="application/jrd+json")
|
||||
|
||||
|
||||
def actor(request, username):
|
||||
user = get_object_or_404(User, username=username)
|
||||
user.ensure_keypair()
|
||||
base = _base_url(request)
|
||||
actor_url = f"{base}/ap/users/{username}/"
|
||||
data = {
|
||||
"@context": AP_CONTEXT,
|
||||
"id": actor_url,
|
||||
"type": "Person",
|
||||
"preferredUsername": username,
|
||||
"inbox": f"{actor_url}inbox/",
|
||||
"outbox": f"{actor_url}outbox/",
|
||||
"publicKey": {
|
||||
"id": f"{actor_url}#main-key",
|
||||
"owner": actor_url,
|
||||
"publicKeyPem": user.ap_public_key,
|
||||
},
|
||||
}
|
||||
return _ap_response(data)
|
||||
|
||||
|
||||
def outbox(request, username):
|
||||
user = get_object_or_404(User, username=username)
|
||||
base = _base_url(request)
|
||||
events = user.game_events.select_related("room").order_by("timestamp")
|
||||
activities = [a for e in events if (a := e.to_activity(base)) is not None]
|
||||
actor_url = f"{base}/ap/users/{username}/"
|
||||
data = {
|
||||
"@context": AP_CONTEXT,
|
||||
"id": f"{actor_url}outbox/",
|
||||
"type": "OrderedCollection",
|
||||
"totalItems": len(activities),
|
||||
"orderedItems": activities,
|
||||
}
|
||||
return _ap_response(data)
|
||||
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def seed_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
for slug, name, cols, rows in [
|
||||
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||
("billboard-my-contacts", "Contacts", 4, 3),
|
||||
("billboard-most-recent", "Most Recent", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||
)
|
||||
|
||||
|
||||
def remove_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug__in=[
|
||||
"billboard-my-scrolls",
|
||||
"billboard-my-contacts",
|
||||
"billboard-most-recent",
|
||||
]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0005_gameboard_applet_heights"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="applet",
|
||||
name="context",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("dashboard", "Dashboard"),
|
||||
("gameboard", "Gameboard"),
|
||||
("wallet", "Wallet"),
|
||||
("billboard", "Billboard"),
|
||||
],
|
||||
default="dashboard",
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
|
||||
]
|
||||
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def fix_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
# billboard-scroll belongs only to the billscroll page template, not the grid
|
||||
Applet.objects.filter(slug="billboard-scroll").delete()
|
||||
# Rename "My Contacts" → "Contacts"
|
||||
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
|
||||
|
||||
|
||||
def reverse_fix_billboard_applets(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.get_or_create(
|
||||
slug="billboard-scroll",
|
||||
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
|
||||
)
|
||||
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0006_billboard_applets"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
|
||||
]
|
||||
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed_game_kit_applets(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
for slug, name in [
|
||||
('gk-trinkets', 'Trinkets'),
|
||||
('gk-tokens', 'Tokens'),
|
||||
('gk-decks', 'Card Decks'),
|
||||
('gk-dice', 'Dice Sets'),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={'name': name, 'grid_cols': 3, 'grid_rows': 3, 'context': 'game-kit'},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('applets', '0007_fix_billboard_applets'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_game_kit_applets, migrations.RunPython.noop)
|
||||
]
|
||||
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal file
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed_my_sky_applet(apps, schema_editor):
|
||||
Applet = apps.get_model('applets', 'Applet')
|
||||
Applet.objects.get_or_create(
|
||||
slug='my-sky',
|
||||
defaults={
|
||||
'name': 'My Sky',
|
||||
'grid_cols': 6,
|
||||
'grid_rows': 6,
|
||||
'context': 'dashboard',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [
|
||||
('applets', '0008_game_kit_applets'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_my_sky_applet, migrations.RunPython.noop)
|
||||
]
|
||||
@@ -4,10 +4,12 @@ class Applet(models.Model):
|
||||
DASHBOARD = "dashboard"
|
||||
GAMEBOARD = "gameboard"
|
||||
WALLET = "wallet"
|
||||
BILLBOARD = "billboard"
|
||||
CONTEXT_CHOICES = [
|
||||
(DASHBOARD, "Dashboard"),
|
||||
(GAMEBOARD, "Gameboard"),
|
||||
(WALLET, "Wallet"),
|
||||
(BILLBOARD, "Billboard"),
|
||||
]
|
||||
|
||||
slug = models.SlugField(unique=True)
|
||||
|
||||
@@ -28,6 +28,7 @@ document.addEventListener('DOMContentLoaded', initGearMenus);
|
||||
const appletContainerIds = new Set([
|
||||
'id_applets_container',
|
||||
'id_game_applets_container',
|
||||
'id_gk_sections_container',
|
||||
'id_wallet_applets_container',
|
||||
]);
|
||||
|
||||
|
||||
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
189
src/apps/billboard/tests/integrated/test_views.py
Normal file
189
src/apps/billboard/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,189 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.applets.models import Applet
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
def _seed_billboard_applets():
|
||||
for slug, name, cols, rows in [
|
||||
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||
("billboard-my-contacts", "Contacts", 4, 3),
|
||||
("billboard-most-recent", "Most Recent", 8, 6),
|
||||
]:
|
||||
Applet.objects.get_or_create(
|
||||
slug=slug,
|
||||
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||
)
|
||||
|
||||
|
||||
class BillboardViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@billboard.io")
|
||||
self.client.force_login(self.user)
|
||||
_seed_billboard_applets()
|
||||
|
||||
def test_uses_billboard_template(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
|
||||
|
||||
def test_passes_applets_context(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertIn("applets", response.context)
|
||||
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||
self.assertIn("billboard-my-scrolls", slugs)
|
||||
self.assertIn("billboard-my-contacts", slugs)
|
||||
self.assertIn("billboard-most-recent", slugs)
|
||||
|
||||
def test_passes_my_rooms_context(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertIn(room, response.context["my_rooms"])
|
||||
|
||||
def test_passes_recent_room_and_events(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertEqual(response.context["recent_room"], room)
|
||||
self.assertEqual(len(response.context["recent_events"]), 1)
|
||||
|
||||
def test_recent_events_capped_at_36(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
for i in range(40):
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertEqual(len(response.context["recent_events"]), 36)
|
||||
|
||||
def test_recent_events_in_chronological_order(self):
|
||||
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
for _ in range(3):
|
||||
record(
|
||||
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
response = self.client.get("/billboard/")
|
||||
events = response.context["recent_events"]
|
||||
timestamps = [e.timestamp for e in events]
|
||||
self.assertEqual(timestamps, sorted(timestamps))
|
||||
|
||||
def test_recent_room_is_none_when_no_events(self):
|
||||
response = self.client.get("/billboard/")
|
||||
self.assertIsNone(response.context["recent_room"])
|
||||
self.assertEqual(list(response.context["recent_events"]), [])
|
||||
|
||||
|
||||
class ToggleBillboardAppletsTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@toggle.io")
|
||||
self.client.force_login(self.user)
|
||||
_seed_billboard_applets()
|
||||
|
||||
def test_toggle_hides_unchecked_applets(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": ["billboard-my-scrolls"]},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
from apps.applets.models import UserApplet
|
||||
contacts = Applet.objects.get(slug="billboard-my-contacts")
|
||||
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||
self.assertFalse(ua.visible)
|
||||
|
||||
def test_toggle_returns_partial_on_htmx(self):
|
||||
response = self.client.post(
|
||||
reverse("billboard:toggle_applets"),
|
||||
{"applets": ["billboard-my-scrolls"]},
|
||||
HTTP_HX_REQUEST="true",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
||||
|
||||
|
||||
class BillscrollViewTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@billscroll.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
record(
|
||||
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||
slot_number=1, token_type="coin",
|
||||
token_display="Coin-on-a-String", renewal_days=7,
|
||||
)
|
||||
|
||||
def test_uses_room_scroll_template(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertTemplateUsed(response, "apps/billboard/room_scroll.html")
|
||||
|
||||
def test_passes_events_context(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertIn("events", response.context)
|
||||
self.assertEqual(response.context["events"].count(), 1)
|
||||
|
||||
def test_passes_page_class_billscroll(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertEqual(response.context["page_class"], "page-billscroll")
|
||||
|
||||
def test_passes_scroll_position_zero_when_none_saved(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertEqual(response.context["scroll_position"], 0)
|
||||
|
||||
def test_passes_saved_scroll_position_in_context(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertEqual(response.context["scroll_position"], 250)
|
||||
|
||||
def test_scroll_renders_event_body_and_time_columns(self):
|
||||
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||
self.assertContains(response, 'class="drama-event-body"')
|
||||
self.assertContains(response, 'class="drama-event-time"')
|
||||
|
||||
|
||||
class SaveScrollPositionTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="test@savescroll.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_post_saves_scroll_position(self):
|
||||
self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 300},
|
||||
)
|
||||
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
|
||||
self.assertEqual(sp.position, 300)
|
||||
|
||||
def test_post_updates_existing_position(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||
self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 450},
|
||||
)
|
||||
self.assertEqual(
|
||||
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
|
||||
)
|
||||
|
||||
def test_post_returns_204(self):
|
||||
response = self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 100},
|
||||
)
|
||||
self.assertEqual(response.status_code, 204)
|
||||
|
||||
def test_post_requires_login(self):
|
||||
self.client.logout()
|
||||
response = self.client.post(
|
||||
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||
{"position": 100},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
@@ -6,5 +6,7 @@ app_name = "billboard"
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.billboard, name="billboard"),
|
||||
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||
]
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import render
|
||||
from django.db.models import Max, Q
|
||||
from django.shortcuts import redirect, render
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
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
|
||||
|
||||
|
||||
@@ -13,19 +15,78 @@ def billboard(request):
|
||||
Q(gate_slots__gamer=request.user) |
|
||||
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
||||
).distinct().order_by("-created_at")
|
||||
|
||||
recent_room = (
|
||||
Room.objects.filter(
|
||||
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
|
||||
)
|
||||
.annotate(last_event=Max("events__timestamp"))
|
||||
.filter(last_event__isnull=False)
|
||||
.order_by("-last_event")
|
||||
.distinct()
|
||||
.first()
|
||||
)
|
||||
recent_events = (
|
||||
list(
|
||||
recent_room.events
|
||||
.select_related("actor")
|
||||
.exclude(verb=GameEvent.SIG_UNREADY)
|
||||
.exclude(verb=GameEvent.SIG_READY, data__retracted=True)
|
||||
.order_by("-timestamp")[:36]
|
||||
)[::-1]
|
||||
if recent_room else []
|
||||
)
|
||||
|
||||
return render(request, "apps/billboard/billboard.html", {
|
||||
"my_rooms": my_rooms,
|
||||
"recent_room": recent_room,
|
||||
"recent_events": recent_events,
|
||||
"viewer": request.user,
|
||||
"applets": applet_context(request.user, "billboard"),
|
||||
"page_class": "page-billboard",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def toggle_billboard_applets(request):
|
||||
checked = request.POST.getlist("applets")
|
||||
for applet in Applet.objects.filter(context="billboard"):
|
||||
UserApplet.objects.update_or_create(
|
||||
user=request.user,
|
||||
applet=applet,
|
||||
defaults={"visible": applet.slug in checked},
|
||||
)
|
||||
if request.headers.get("HX-Request"):
|
||||
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||
"applets": applet_context(request.user, "billboard"),
|
||||
})
|
||||
return redirect("billboard:billboard")
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def room_scroll(request, room_id):
|
||||
room = Room.objects.get(id=room_id)
|
||||
events = room.events.select_related("actor").all()
|
||||
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
|
||||
return render(request, "apps/billboard/room_scroll.html", {
|
||||
"room": room,
|
||||
"events": events,
|
||||
"viewer": request.user,
|
||||
"page_class": "page-billboard",
|
||||
"scroll_position": sp.position if sp else 0,
|
||||
"page_class": "page-billscroll",
|
||||
})
|
||||
|
||||
|
||||
@login_required(login_url="/")
|
||||
def save_scroll_position(request, room_id):
|
||||
if request.method != "POST":
|
||||
from django.http import HttpResponseNotAllowed
|
||||
return HttpResponseNotAllowed(["POST"])
|
||||
room = Room.objects.get(id=room_id)
|
||||
position = int(request.POST.get("position", 0))
|
||||
ScrollPosition.objects.update_or_create(
|
||||
user=request.user, room=room,
|
||||
defaults={"position": position},
|
||||
)
|
||||
from django.http import HttpResponse
|
||||
return HttpResponse(status=204)
|
||||
|
||||
@@ -9,6 +9,15 @@ const initialize = (inputSelector) => {
|
||||
};
|
||||
};
|
||||
|
||||
const bindPaletteWheel = () => {
|
||||
document.querySelectorAll('.palette-scroll').forEach(el => {
|
||||
el.addEventListener('wheel', (e) => {
|
||||
e.preventDefault();
|
||||
el.scrollLeft += e.deltaY;
|
||||
}, { passive: false });
|
||||
});
|
||||
};
|
||||
|
||||
const bindPaletteForms = () => {
|
||||
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
|
||||
@@ -58,6 +58,27 @@
|
||||
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
|
||||
});
|
||||
|
||||
function attachTooltip(el) {
|
||||
el.addEventListener('mouseenter', function () {
|
||||
var tooltip = el.querySelector('.tt');
|
||||
if (!tooltip) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.left = rect.left + 'px';
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
el.addEventListener('mouseleave', function () {
|
||||
var tooltip = el.querySelector('.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 () {
|
||||
@@ -69,22 +90,10 @@
|
||||
var slot = document.querySelector('.token-slot');
|
||||
if (slot) slot.classList.add('ready');
|
||||
});
|
||||
|
||||
card.addEventListener('mouseenter', function () {
|
||||
var tooltip = card.querySelector('.token-tooltip');
|
||||
if (!tooltip) return;
|
||||
var rect = card.getBoundingClientRect();
|
||||
tooltip.style.position = 'fixed';
|
||||
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||
tooltip.style.left = rect.left + 'px';
|
||||
tooltip.style.display = 'block';
|
||||
});
|
||||
|
||||
card.addEventListener('mouseleave', function () {
|
||||
var tooltip = card.querySelector('.token-tooltip');
|
||||
if (tooltip) tooltip.style.display = '';
|
||||
});
|
||||
attachTooltip(card);
|
||||
});
|
||||
|
||||
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
152
src/apps/dashboard/tests/integrated/test_sky_views.py
Normal file
152
src/apps/dashboard/tests/integrated/test_sky_views.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""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
|
||||
"""
|
||||
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
|
||||
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):
|
||||
chart = {"planets": {}, "houses": {}, "elements": {}}
|
||||
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": chart,
|
||||
}
|
||||
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")
|
||||
self.assertEqual(self.user.sky_chart_data, chart)
|
||||
|
||||
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"])
|
||||
@@ -14,4 +14,7 @@ urlpatterns = [
|
||||
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'),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
@@ -181,6 +186,7 @@ def kit_bag(request):
|
||||
)
|
||||
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||
return render(request, "core/_partials/_kit_bag_panel.html", {
|
||||
"equipped_deck": request.user.equipped_deck,
|
||||
"equipped_trinket": request.user.equipped_trinket,
|
||||
"free_token": free_tokens[0] if free_tokens else None,
|
||||
"free_count": len(free_tokens),
|
||||
@@ -235,3 +241,118 @@ 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)
|
||||
|
||||
from apps.epic.views import _compute_distinctions
|
||||
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):
|
||||
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": request.user.sky_chart_data,
|
||||
"saved_birth_dt": request.user.sky_birth_dt,
|
||||
"saved_birth_place": request.user.sky_birth_place,
|
||||
"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_dt_str = body.get('birth_dt', '')
|
||||
if birth_dt_str:
|
||||
try:
|
||||
naive = datetime.fromisoformat(birth_dt_str.replace('Z', '+00:00'))
|
||||
user.sky_birth_dt = naive if naive.tzinfo else naive.replace(
|
||||
tzinfo=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_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_house_system', 'sky_chart_data',
|
||||
])
|
||||
return JsonResponse({"saved": True})
|
||||
|
||||
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 6.0 on 2026-03-24 21:36
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('drama', '0001_initial'),
|
||||
('epic', '0006_table_status_and_table_seat'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='ScrollPosition',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('position', models.PositiveIntegerField(default=0)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to='epic.room')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'unique_together': {('user', 'room')},
|
||||
},
|
||||
),
|
||||
]
|
||||
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
18
src/apps/drama/migrations/0003_alter_gameevent_verb.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-04-12 23:38
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('drama', '0002_scrollposition'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='gameevent',
|
||||
name='verb',
|
||||
field=models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed'), ('sig_ready', 'Sig claim staked'), ('sig_unready', 'Sig claim withdrawn')], max_length=30),
|
||||
),
|
||||
]
|
||||
@@ -1,6 +1,12 @@
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
# ── Default gender-neutral pronouns (Baltimore original) ──────────────────────
|
||||
# Later: replace with per-actor lookup when User model gains a pronouns field.
|
||||
PRONOUN_SUBJ = "yo"
|
||||
PRONOUN_OBJ = "yo"
|
||||
PRONOUN_POSS = "yos"
|
||||
|
||||
|
||||
class GameEvent(models.Model):
|
||||
# Gate phase
|
||||
@@ -14,6 +20,9 @@ class GameEvent(models.Model):
|
||||
ROLE_SELECT_STARTED = "role_select_started"
|
||||
ROLE_SELECTED = "role_selected"
|
||||
ROLES_REVEALED = "roles_revealed"
|
||||
# Sig Select phase
|
||||
SIG_READY = "sig_ready"
|
||||
SIG_UNREADY = "sig_unready"
|
||||
|
||||
VERB_CHOICES = [
|
||||
(ROOM_CREATED, "Room created"),
|
||||
@@ -25,6 +34,8 @@ class GameEvent(models.Model):
|
||||
(ROLE_SELECT_STARTED, "Role select started"),
|
||||
(ROLE_SELECTED, "Role selected"),
|
||||
(ROLES_REVEALED, "Roles revealed"),
|
||||
(SIG_READY, "Sig claim staked"),
|
||||
(SIG_UNREADY, "Sig claim withdrawn"),
|
||||
]
|
||||
|
||||
room = models.ForeignKey(
|
||||
@@ -53,7 +64,7 @@ class GameEvent(models.Model):
|
||||
token = d.get("token_display") or _token_names.get(code, code)
|
||||
days = d.get("renewal_days", 7)
|
||||
slot = d.get("slot_number", "?")
|
||||
return f"deposits a {token} for slot {slot} ({days} days)"
|
||||
return f"deposits a {token} for slot {slot} (expires in {days} days)."
|
||||
if self.verb == self.SLOT_RESERVED:
|
||||
return "reserves a seat"
|
||||
if self.verb == self.SLOT_RETURNED:
|
||||
@@ -71,18 +82,89 @@ 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"starts as {role}"
|
||||
try:
|
||||
ordinal = _ordinals[_chair_order.index(code)]
|
||||
except ValueError:
|
||||
ordinal = "?"
|
||||
return f"assumes {ordinal} Chair; yo will start the game as the {role}."
|
||||
if self.verb == self.ROLES_REVEALED:
|
||||
return "All roles assigned"
|
||||
if self.verb == self.SIG_READY:
|
||||
card_name = d.get("card_name", "a card")
|
||||
corner_rank = d.get("corner_rank", "")
|
||||
suit_icon = d.get("suit_icon", "")
|
||||
if corner_rank:
|
||||
icon_html = f' <i class="fa-solid {suit_icon}"></i>' if suit_icon else ""
|
||||
abbrev = f" ({corner_rank}{icon_html})"
|
||||
else:
|
||||
abbrev = ""
|
||||
return f"embodies as {PRONOUN_POSS} Significator the {card_name}{abbrev}."
|
||||
if self.verb == self.SIG_UNREADY:
|
||||
return f"disembodies {PRONOUN_POSS} Significator."
|
||||
return self.verb
|
||||
|
||||
@property
|
||||
def struck(self):
|
||||
"""True when this SIG_READY event was subsequently retracted (WAIT NVM)."""
|
||||
return self.data.get("retracted", False)
|
||||
|
||||
def to_activity(self, base_url):
|
||||
"""Serialise this event as an AS2 Activity dict, or None if unsupported."""
|
||||
if not self.actor or not self.actor.username:
|
||||
return None
|
||||
actor_url = f"{base_url}/ap/users/{self.actor.username}/"
|
||||
room_url = f"{base_url}/gameboard/room/{self.room_id}/"
|
||||
if self.verb == self.SLOT_FILLED:
|
||||
return {
|
||||
"type": "earthman:JoinGate",
|
||||
"actor": actor_url,
|
||||
"object": room_url,
|
||||
"summary": self.to_prose(),
|
||||
}
|
||||
if self.verb == self.ROLE_SELECTED:
|
||||
return {
|
||||
"type": "earthman:SelectRole",
|
||||
"actor": actor_url,
|
||||
"object": room_url,
|
||||
"summary": self.to_prose(),
|
||||
}
|
||||
if self.verb == self.ROOM_CREATED:
|
||||
return {
|
||||
"type": "Create",
|
||||
"actor": actor_url,
|
||||
"object": room_url,
|
||||
"summary": self.to_prose(),
|
||||
}
|
||||
return None
|
||||
|
||||
def __str__(self):
|
||||
actor = self.actor.email if self.actor else "system"
|
||||
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||
|
||||
|
||||
class ScrollPosition(models.Model):
|
||||
user = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||
related_name="scroll_positions",
|
||||
)
|
||||
room = models.ForeignKey(
|
||||
"epic.Room", on_delete=models.CASCADE,
|
||||
related_name="scroll_positions",
|
||||
)
|
||||
position = models.PositiveIntegerField(default=0)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = [("user", "room")]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.email} @ {self.room.name}: {self.position}px"
|
||||
|
||||
|
||||
def record(room, verb, actor=None, **data):
|
||||
"""Record a game event in the drama log."""
|
||||
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||
|
||||
116
src/apps/drama/tests/integrated/test_models.py
Normal file
116
src/apps/drama/tests/integrated/test_models.py
Normal file
@@ -0,0 +1,116 @@
|
||||
from django.test import TestCase
|
||||
from django.db import IntegrityError
|
||||
|
||||
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class GameEventModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_record_creates_game_event(self):
|
||||
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
|
||||
self.assertEqual(GameEvent.objects.count(), 1)
|
||||
self.assertEqual(event.room, self.room)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
|
||||
|
||||
def test_record_without_actor(self):
|
||||
event = record(self.room, GameEvent.ROOM_CREATED)
|
||||
self.assertIsNone(event.actor)
|
||||
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
||||
|
||||
def test_events_ordered_by_timestamp(self):
|
||||
record(self.room, GameEvent.ROOM_CREATED)
|
||||
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
|
||||
verbs = list(GameEvent.objects.values_list("verb", flat=True))
|
||||
self.assertEqual(verbs, [
|
||||
GameEvent.ROOM_CREATED,
|
||||
GameEvent.SLOT_RESERVED,
|
||||
GameEvent.SLOT_FILLED,
|
||||
])
|
||||
|
||||
def test_str_includes_actor_and_verb(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||||
self.assertIn("actor@test.io", str(event))
|
||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||
|
||||
# ── to_prose — ROLE_SELECTED ──────────────────────────────────────────
|
||||
|
||||
def test_role_selected_prose_uses_ordinal_chair(self):
|
||||
for role, ordinal in [("PC", "1st"), ("NC", "2nd"), ("EC", "3rd"),
|
||||
("SC", "4th"), ("AC", "5th"), ("BC", "6th")]:
|
||||
with self.subTest(role=role):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||
role=role, role_display="")
|
||||
self.assertIn(f"assumes {ordinal} Chair", event.to_prose())
|
||||
|
||||
def test_role_selected_prose_includes_role_name(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||
role="PC", role_display="Player")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("Player", prose)
|
||||
self.assertIn("yo will start the game", prose)
|
||||
|
||||
# ── to_prose — SIG_READY ─────────────────────────────────────────────
|
||||
|
||||
def test_sig_ready_prose_embodies_card_with_rank_and_icon(self):
|
||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||
card_name="Maid of Brands", corner_rank="M",
|
||||
suit_icon="fa-wand-sparkles")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||
self.assertIn("(M", prose)
|
||||
self.assertIn("fa-wand-sparkles", prose)
|
||||
|
||||
def test_sig_ready_prose_omits_icon_when_none(self):
|
||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||
card_name="The Wanderer", corner_rank="0", suit_icon="")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("embodies as yos Significator the The Wanderer (0)", prose)
|
||||
self.assertNotIn("fa-", prose)
|
||||
|
||||
def test_sig_ready_prose_degrades_without_corner_rank(self):
|
||||
# Old events recorded before this change have no corner_rank key
|
||||
event = record(self.room, GameEvent.SIG_READY, actor=self.user,
|
||||
card_name="Maid of Brands")
|
||||
prose = event.to_prose()
|
||||
self.assertIn("embodies as yos Significator the Maid of Brands", prose)
|
||||
self.assertNotIn("(", prose)
|
||||
|
||||
def test_str_without_actor_shows_system(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertIn("system", str(event))
|
||||
|
||||
|
||||
class ScrollPositionModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="reader@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_can_save_scroll_position(self):
|
||||
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
|
||||
self.assertEqual(ScrollPosition.objects.count(), 1)
|
||||
self.assertEqual(sp.position, 150)
|
||||
|
||||
def test_default_position_is_zero(self):
|
||||
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
|
||||
self.assertEqual(sp.position, 0)
|
||||
|
||||
def test_unique_per_user_and_room(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||
with self.assertRaises(IntegrityError):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||
|
||||
def test_upsert_updates_existing_position(self):
|
||||
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||
ScrollPosition.objects.update_or_create(
|
||||
user=self.user, room=self.room,
|
||||
defaults={"position": 200},
|
||||
)
|
||||
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
|
||||
@@ -1,77 +0,0 @@
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.drama.models import GameEvent
|
||||
from apps.epic.models import GateSlot, Room, TableSeat
|
||||
from apps.lyric.models import Token, User
|
||||
|
||||
|
||||
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="gamer@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||
self.slot.gamer = self.user
|
||||
self.slot.status = GateSlot.RESERVED
|
||||
self.slot.reserved_at = timezone.now()
|
||||
self.slot.save()
|
||||
|
||||
def test_confirm_token_records_slot_filled_event(self):
|
||||
session = self.client.session
|
||||
session["kit_token_id"] = str(self.token.id)
|
||||
session.save()
|
||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.data["slot_number"], 1)
|
||||
self.assertEqual(event.data["token_type"], Token.TITHE)
|
||||
|
||||
def test_no_event_recorded_if_no_reserved_slot(self):
|
||||
self.slot.gamer = None
|
||||
self.slot.status = GateSlot.EMPTY
|
||||
self.slot.save()
|
||||
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
||||
|
||||
|
||||
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="player@test.io")
|
||||
self.client.force_login(self.user)
|
||||
self.room = Room.objects.create(
|
||||
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
||||
)
|
||||
self.seat = TableSeat.objects.create(
|
||||
room=self.room, gamer=self.user, slot_number=1
|
||||
)
|
||||
|
||||
def test_select_role_records_role_selected_event(self):
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.data["role"], "PC")
|
||||
self.assertEqual(event.data["slot_number"], 1)
|
||||
|
||||
def test_roles_revealed_event_recorded_when_all_seats_assigned(self):
|
||||
# Only one seat — assigning it triggers roles_revealed
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertTrue(
|
||||
GameEvent.objects.filter(room=self.room, verb=GameEvent.ROLES_REVEALED).exists()
|
||||
)
|
||||
|
||||
def test_no_event_if_role_already_taken(self):
|
||||
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
||||
self.client.post(
|
||||
reverse("epic:select_role", args=[self.room.id]),
|
||||
data={"role": "PC"},
|
||||
)
|
||||
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||
@@ -1,44 +0,0 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.drama.models import GameEvent, record
|
||||
from apps.epic.models import Room
|
||||
from apps.lyric.models import User
|
||||
|
||||
|
||||
class GameEventModelTest(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create(email="actor@test.io")
|
||||
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||
|
||||
def test_record_creates_game_event(self):
|
||||
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
|
||||
self.assertEqual(GameEvent.objects.count(), 1)
|
||||
self.assertEqual(event.room, self.room)
|
||||
self.assertEqual(event.actor, self.user)
|
||||
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
|
||||
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
|
||||
|
||||
def test_record_without_actor(self):
|
||||
event = record(self.room, GameEvent.ROOM_CREATED)
|
||||
self.assertIsNone(event.actor)
|
||||
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
||||
|
||||
def test_events_ordered_by_timestamp(self):
|
||||
record(self.room, GameEvent.ROOM_CREATED)
|
||||
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
|
||||
verbs = list(GameEvent.objects.values_list("verb", flat=True))
|
||||
self.assertEqual(verbs, [
|
||||
GameEvent.ROOM_CREATED,
|
||||
GameEvent.SLOT_RESERVED,
|
||||
GameEvent.SLOT_FILLED,
|
||||
])
|
||||
|
||||
def test_str_includes_actor_and_verb(self):
|
||||
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||||
self.assertIn("actor@test.io", str(event))
|
||||
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||
|
||||
def test_str_without_actor_shows_system(self):
|
||||
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||
self.assertIn("system", str(event))
|
||||
@@ -1,3 +1,18 @@
|
||||
from django.contrib import admin
|
||||
|
||||
# Register your models here.
|
||||
from .models import DeckVariant, TarotCard
|
||||
|
||||
|
||||
@admin.register(DeckVariant)
|
||||
class DeckVariantAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "slug", "card_count", "is_default"]
|
||||
prepopulated_fields = {"slug": ["name"]}
|
||||
|
||||
|
||||
@admin.register(TarotCard)
|
||||
class TarotCardAdmin(admin.ModelAdmin):
|
||||
list_display = ["name", "deck_variant", "arcana", "suit", "number", "group", "slug"]
|
||||
list_filter = ["deck_variant", "arcana", "suit"]
|
||||
search_fields = ["name", "slug", "correspondence", "group"]
|
||||
readonly_fields = ["slug", "correspondence", "group"]
|
||||
ordering = ["deck_variant", "arcana", "suit", "number"]
|
||||
|
||||
@@ -1,18 +1,58 @@
|
||||
from channels.db import database_sync_to_async
|
||||
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||
|
||||
|
||||
LEVITY_ROLES = {"PC", "NC", "SC"}
|
||||
GRAVITY_ROLES = {"BC", "EC", "AC"}
|
||||
|
||||
|
||||
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def connect(self):
|
||||
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
|
||||
self.group_name = f"room_{self.room_id}"
|
||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||
|
||||
self.cursor_group = None
|
||||
user = self.scope.get("user")
|
||||
if user and user.is_authenticated:
|
||||
seat = await self._get_seat(user)
|
||||
if seat:
|
||||
if seat.role in LEVITY_ROLES:
|
||||
self.cursor_group = f"cursors_{self.room_id}_levity"
|
||||
elif seat.role in GRAVITY_ROLES:
|
||||
self.cursor_group = f"cursors_{self.room_id}_gravity"
|
||||
if self.cursor_group:
|
||||
await self.channel_layer.group_add(self.cursor_group, self.channel_name)
|
||||
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||
if self.cursor_group:
|
||||
await self.channel_layer.group_discard(self.cursor_group, self.channel_name)
|
||||
|
||||
async def receive_json(self, content):
|
||||
pass # handlers added as events introduced
|
||||
msg_type = content.get("type")
|
||||
if msg_type == "cursor_move" and self.cursor_group:
|
||||
await self.channel_layer.group_send(
|
||||
self.cursor_group,
|
||||
{"type": "cursor_move", "x": content.get("x"), "y": content.get("y")},
|
||||
)
|
||||
elif msg_type == "sig_hover" and self.cursor_group:
|
||||
await self.channel_layer.group_send(
|
||||
self.cursor_group,
|
||||
{
|
||||
"type": "sig_hover",
|
||||
"card_id": content.get("card_id"),
|
||||
"role": content.get("role"),
|
||||
"active": content.get("active"),
|
||||
},
|
||||
)
|
||||
|
||||
@database_sync_to_async
|
||||
def _get_seat(self, user):
|
||||
from apps.epic.models import TableSeat
|
||||
return TableSeat.objects.filter(room_id=self.room_id, gamer=user).first()
|
||||
|
||||
async def gate_update(self, event):
|
||||
await self.send_json(event)
|
||||
@@ -23,5 +63,32 @@ class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||
async def turn_changed(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def roles_revealed(self, event):
|
||||
async def all_roles_filled(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_select_started(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_selected(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_hover(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def sig_reserved(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def countdown_start(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def countdown_cancel(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def polarity_room_done(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def pick_sky_available(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
async def cursor_move(self, event):
|
||||
await self.send_json(event)
|
||||
|
||||
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal file
39
src/apps/epic/migrations/0007_tarotcard_tarotdeck.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 6.0 on 2026-03-24 23:33
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0006_table_status_and_table_seat'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='TarotCard',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('arcana', models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana')], max_length=5)),
|
||||
('suit', models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True)),
|
||||
('number', models.IntegerField()),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('keywords_upright', models.JSONField(default=list)),
|
||||
('keywords_reversed', models.JSONField(default=list)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['arcana', 'suit', 'number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='TarotDeck',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('drawn_card_ids', models.JSONField(default=list)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('room', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='tarot_deck', to='epic.room')),
|
||||
],
|
||||
),
|
||||
]
|
||||
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal file
164
src/apps/epic/migrations/0008_seed_tarot_cards.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from django.db import migrations
|
||||
|
||||
MAJOR_ARCANA = [
|
||||
(0, "The Fool", "the-fool", ["beginnings", "spontaneity", "freedom"], ["recklessness", "naivety", "risk"]),
|
||||
(1, "The Magician", "the-magician", ["willpower", "skill", "resourcefulness"], ["manipulation", "untapped potential", "deceit"]),
|
||||
(2, "The High Priestess", "the-high-priestess", ["intuition", "mystery", "inner knowledge"], ["secrets", "disconnection", "withdrawal"]),
|
||||
(3, "The Empress", "the-empress", ["fertility", "abundance", "nurturing"], ["dependence", "smothering", "creative block"]),
|
||||
(4, "The Emperor", "the-emperor", ["authority", "structure", "stability"], ["rigidity", "domination", "inflexibility"]),
|
||||
(5, "The Hierophant", "the-hierophant", ["tradition", "conformity", "institutions"], ["rebellion", "unconventionality", "challenge"]),
|
||||
(6, "The Lovers", "the-lovers", ["love", "harmony", "choice"], ["disharmony", "imbalance", "misalignment"]),
|
||||
(7, "The Chariot", "the-chariot", ["control", "willpower", "victory"], ["aggression", "lack of direction", "defeat"]),
|
||||
(8, "Strength", "strength", ["courage", "patience", "compassion"], ["self-doubt", "weakness", "insecurity"]),
|
||||
(9, "The Hermit", "the-hermit", ["introspection", "guidance", "solitude"], ["isolation", "loneliness", "withdrawal"]),
|
||||
(10, "Wheel of Fortune", "wheel-of-fortune", ["change", "cycles", "fate"], ["bad luck", "resistance", "clinging to control"]),
|
||||
(11, "Justice", "justice", ["fairness", "truth", "cause and effect"], ["injustice", "dishonesty", "avoidance"]),
|
||||
(12, "The Hanged Man", "the-hanged-man", ["pause", "surrender", "new perspective"], ["stalling", "resistance", "indecision"]),
|
||||
(13, "Death", "death", ["endings", "transition", "transformation"], ["fear of change", "stagnation", "resistance"]),
|
||||
(14, "Temperance", "temperance", ["balance", "patience", "moderation"], ["imbalance", "excess", "lack of harmony"]),
|
||||
(15, "The Devil", "the-devil", ["bondage", "materialism", "shadow self"], ["detachment", "freedom", "releasing control"]),
|
||||
(16, "The Tower", "the-tower", ["sudden change", "upheaval", "revelation"], ["avoidance", "fear of change", "delaying disaster"]),
|
||||
(17, "The Star", "the-star", ["hope", "renewal", "inspiration"], ["despair", "insecurity", "hopelessness"]),
|
||||
(18, "The Moon", "the-moon", ["illusion", "fear", "the unconscious"], ["confusion", "misinterpretation", "clarity"]),
|
||||
(19, "The Sun", "the-sun", ["positivity", "success", "vitality"], ["negativity", "depression", "sadness"]),
|
||||
(20, "Judgement", "judgement", ["reflection", "reckoning", "absolution"], ["self-doubt", "lack of self-awareness", "loathing"]),
|
||||
(21, "The World", "the-world", ["completion", "integration", "accomplishment"], ["incompletion", "no closure", "shortcuts"]),
|
||||
]
|
||||
|
||||
MINOR_SUITS = [
|
||||
("WANDS", "wands"),
|
||||
("CUPS", "cups"),
|
||||
("SWORDS", "swords"),
|
||||
("PENTACLES", "pentacles"),
|
||||
]
|
||||
|
||||
MINOR_NAMES = [
|
||||
(1, "Ace", "ace"),
|
||||
(2, "Two", "two"),
|
||||
(3, "Three", "three"),
|
||||
(4, "Four", "four"),
|
||||
(5, "Five", "five"),
|
||||
(6, "Six", "six"),
|
||||
(7, "Seven", "seven"),
|
||||
(8, "Eight", "eight"),
|
||||
(9, "Nine", "nine"),
|
||||
(10, "Ten", "ten"),
|
||||
(11, "Page", "page"),
|
||||
(12, "Knight", "knight"),
|
||||
(13, "Queen", "queen"),
|
||||
(14, "King", "king"),
|
||||
]
|
||||
|
||||
# Keywords: [suit][number-1] → (upright_list, reversed_list)
|
||||
MINOR_KEYWORDS = {
|
||||
"WANDS": [
|
||||
(["inspiration", "new venture", "spark"], ["delays", "lack of motivation", "false start"]),
|
||||
(["planning", "progress", "decisions"], ["impatience", "lack of planning", "hesitation"]),
|
||||
(["expansion", "foresight", "enterprise"], ["obstacles", "lack of foresight", "delays"]),
|
||||
(["celebration", "harmony", "homecoming"], ["lack of support", "transience", "home conflicts"]),
|
||||
(["conflict", "competition", "tension"], ["avoiding conflict", "compromise", "truce"]),
|
||||
(["victory", "recognition", "progress"], ["excess pride", "lack of recognition", "fall"]),
|
||||
(["challenge", "courage", "competition"], ["anxiety", "giving up", "overwhelmed"]),
|
||||
(["rapid action", "adventure", "change"], ["haste", "scattered energy", "delays"]),
|
||||
(["resilience", "persistence", "last stand"], ["exhaustion", "giving up", "surrender"]),
|
||||
(["completion", "celebration", "travel"], ["burdens", "oppression", "carrying too much"]),
|
||||
(["exploration", "enthusiasm", "adventure"], ["hasty decisions", "scattered energy", "immaturity"]),
|
||||
(["energy", "passion", "adventure"], ["scattered energy", "frustration", "aggression"]),
|
||||
(["confidence", "independence", "courage"], ["selfishness", "jealousy", "insecurity"]),
|
||||
(["big picture", "leadership", "vision"], ["impulsiveness", "haste", "overconfidence"]),
|
||||
],
|
||||
"CUPS": [
|
||||
(["new feelings", "intuition", "opportunity"], ["blocked creativity", "emptiness", "hesitation"]),
|
||||
(["partnership", "unity", "celebration"], ["imbalance", "broken bonds", "misalignment"]),
|
||||
(["creativity", "community", "abundance"], ["independence", "isolation", "looking inward"]),
|
||||
(["contemplation", "apathy", "reevaluation"], ["withdrawal", "boredom", "seeking motivation"]),
|
||||
(["loss", "grief", "disappointment"], ["acceptance", "moving on", "forgiveness"]),
|
||||
(["nostalgia", "reunion", "joy"], ["living in the past", "naivety", "unrealistic"]),
|
||||
(["illusion", "fantasy", "wishful thinking"], ["alignment", "clarity", "sobriety"]),
|
||||
(["disappointment", "abandonment", "walking away"], ["hopelessness", "aimlessness", "stagnation"]),
|
||||
(["contentment", "fulfilment", "satisfaction"], ["inner happiness", "materialism", "indulgence"]),
|
||||
(["divine love", "bliss", "fulfilment"], ["inner happiness", "alignment", "personal values"]),
|
||||
(["sensitivity", "creativity", "intuition"], ["insecurity", "emotional immaturity", "creative blocks"]),
|
||||
(["compassion", "romanticism", "diplomacy"], ["moodiness", "emotional manipulation", "deception"]),
|
||||
(["compassion", "empathy", "nurturing"], ["emotional insecurity", "over-giving", "neglect"]),
|
||||
(["emotional maturity", "diplomacy", "wisdom"], ["manipulation", "moodiness", "coldness"]),
|
||||
],
|
||||
"SWORDS": [
|
||||
(["raw power", "breakthrough", "clarity"], ["confusion", "brutality", "mental chaos"]),
|
||||
(["difficult choices", "stalemate", "truce"], ["indecision", "lies", "confusion"]),
|
||||
(["heartbreak", "sorrow", "grief"], ["recovery", "forgiveness", "moving on"]),
|
||||
(["rest", "restoration", "retreat"], ["restlessness", "burnout", "illness"]),
|
||||
(["defeat", "change", "transition"], ["resistance to change", "inability to move"]),
|
||||
(["victory", "success", "ambition"], ["an eye for an eye", "dishonour", "manipulation"]),
|
||||
(["deception", "trickery", "tactics"], ["imposter syndrome", "coming clean", "rethinking"]),
|
||||
(["restriction", "isolation", "imprisonment"], ["self-limiting beliefs", "inner critic", "opening up"]),
|
||||
(["anxiety", "worry", "fear"], ["recovery from anxiety", "inner turmoil", "secrets"]),
|
||||
(["ruin", "painful endings", "loss"], ["recovery", "regeneration", "resisting an end"]),
|
||||
(["new ideas", "mental agility", "curiosity"], ["manipulation", "all talk no action", "ruthlessness"]),
|
||||
(["action", "impulsiveness", "ambition"], ["no direction", "disregard for consequences"]),
|
||||
(["clarity", "directness", "structure"], ["coldness", "cruelty", "manipulation"]),
|
||||
(["mental clarity", "truth", "authority"], ["abuse of power", "manipulation", "coldness"]),
|
||||
],
|
||||
"PENTACLES": [
|
||||
(["opportunity", "new venture", "manifestation"], ["lost opportunity", "lack of planning", "scarcity"]),
|
||||
(["juggling resources", "flexibility", "fun"], ["imbalance", "disorganisation", "overwhelm"]),
|
||||
(["teamwork", "building", "apprenticeship"], ["lack of teamwork", "disharmony", "misalignment"]),
|
||||
(["stability", "security", "conservation"], ["greed", "stinginess", "possessiveness"]),
|
||||
(["isolation", "insecurity", "worry"], ["recovery from loss", "overcoming hardship"]),
|
||||
(["generosity", "charity", "community"], ["strings attached", "power dynamics", "inequality"]),
|
||||
(["hard work", "perseverance", "diligence"], ["lack of reward", "laziness", "low quality"]),
|
||||
(["apprenticeship", "education", "skill"], ["perfectionism", "misdirected activity", "misuse"]),
|
||||
(["abundance", "luxury", "self-sufficiency"], ["overindulgence", "superficiality", "materialism"]),
|
||||
(["wealth", "financial security", "achievement"], ["financial failure", "greed", "lost success"]),
|
||||
(["ambition", "diligence", "management"], ["underhandedness", "greediness", "unethical"]),
|
||||
(["hard work", "productivity", "routine"], ["laziness", "obsession with work", "burnout"]),
|
||||
(["nurturing", "practical", "abundance"], ["financial dependence", "smothering", "insecurity"]),
|
||||
(["abundance", "prosperity", "security"], ["greed", "indulgence", "sensual obsession"]),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def seed_tarot_cards(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
|
||||
# Major Arcana
|
||||
for number, name, slug, upright, reversed_ in MAJOR_ARCANA:
|
||||
TarotCard.objects.create(
|
||||
name=name,
|
||||
arcana="MAJOR",
|
||||
suit=None,
|
||||
number=number,
|
||||
slug=slug,
|
||||
keywords_upright=upright,
|
||||
keywords_reversed=reversed_,
|
||||
)
|
||||
|
||||
# Minor Arcana
|
||||
for suit_code, suit_slug in MINOR_SUITS:
|
||||
for number, rank_name, rank_slug in MINOR_NAMES:
|
||||
upright, reversed_ = MINOR_KEYWORDS[suit_code][number - 1]
|
||||
TarotCard.objects.create(
|
||||
name=f"{rank_name} of {suit_code.capitalize()}",
|
||||
arcana="MINOR",
|
||||
suit=suit_code,
|
||||
number=number,
|
||||
slug=f"{rank_slug}-of-{suit_slug}",
|
||||
keywords_upright=upright,
|
||||
keywords_reversed=reversed_,
|
||||
)
|
||||
|
||||
|
||||
def unseed_tarot_cards(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
TarotCard.objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0007_tarotcard_tarotdeck"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed_tarot_cards, reverse_code=unseed_tarot_cards),
|
||||
]
|
||||
@@ -0,0 +1,68 @@
|
||||
# Generated by Django 6.0 on 2026-03-25 00:48
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0008_seed_tarot_cards'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DeckVariant',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100, unique=True)),
|
||||
('slug', models.SlugField(unique=True)),
|
||||
('card_count', models.IntegerField()),
|
||||
('description', models.TextField(blank=True)),
|
||||
('is_default', models.BooleanField(default=False)),
|
||||
],
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='tarotcard',
|
||||
options={'ordering': ['deck_variant', 'arcana', 'suit', 'number']},
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='correspondence',
|
||||
field=models.CharField(blank=True, max_length=200),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='group',
|
||||
field=models.CharField(blank=True, max_length=100),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='name',
|
||||
field=models.CharField(max_length=200),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='slug',
|
||||
field=models.SlugField(max_length=120),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('COINS', 'Coins')], max_length=10, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='deck_variant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='cards', to='epic.deckvariant'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='tarotdeck',
|
||||
name='deck_variant',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='active_decks', to='epic.deckvariant'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='tarotcard',
|
||||
unique_together={('deck_variant', 'slug')},
|
||||
),
|
||||
]
|
||||
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal file
202
src/apps/epic/migrations/0010_seed_deck_variants_and_earthman.py
Normal file
@@ -0,0 +1,202 @@
|
||||
"""
|
||||
Data migration:
|
||||
1. Create DeckVariant records (Fiorentine Minchiate + Earthman).
|
||||
2. Backfill the 78 existing TarotCards → Fiorentine Minchiate.
|
||||
3. Seed all 108 Earthman cards (52 major + 56 minor).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
# ── Earthman Major Arcana (52 cards, numbers 0–51) ──────────────────────────
|
||||
# (name, slug, group, correspondence)
|
||||
EARTHMAN_MAJOR = [
|
||||
# ── The Schiz ──────────────────────────────────────────────────────────
|
||||
(0, "The Schiz", "the-schiz", "", "The Fool / Il Matto"),
|
||||
|
||||
# ── The Popes ──────────────────────────────────────────────────────────
|
||||
(1, "Pope I: President", "pope-i-president", "The Popes", "The Magician / Il Bagatto"),
|
||||
(2, "Pope II: Tsar", "pope-ii-tsar", "The Popes", "The Popess / La Papessa"),
|
||||
(3, "Pope III: Chairman", "pope-iii-chairman", "The Popes", "The Empress / L'Imperatrice"),
|
||||
(4, "Pope IV: Emperor", "pope-iv-emperor", "The Popes", "The Emperor / L'Imperatore"),
|
||||
(5, "Pope V: Chancellor", "pope-v-chancellor", "The Popes", "The Pope / Il Papa"),
|
||||
|
||||
# ── The Virtues, Implicit (cardinal / acquired) ────────────────────────
|
||||
(6, "Virtue VI: Controlled Folly", "virtue-vi-controlled-folly", "The Virtues, Implicit", "Fortitude / La Fortezza"),
|
||||
(7, "Virtue VII: Not-Doing", "virtue-vii-not-doing", "The Virtues, Implicit", "Justice / La Giustizia"),
|
||||
(8, "Virtue VIII: Losing Self-Importance","virtue-viii-losing-self-importance","The Virtues, Implicit", "Temperance / La Temperanza"),
|
||||
(9, "Virtue IX: Erasing Personal History","virtue-ix-erasing-personal-history","The Virtues, Implicit", "Prudence / La Prudenza"),
|
||||
|
||||
# ── Wheel ──────────────────────────────────────────────────────────────
|
||||
(10, "Wheel of Fortune", "wheel-of-fortune-em", "", "La Ruota della Fortuna"),
|
||||
|
||||
# ── Solo cards ─────────────────────────────────────────────────────────
|
||||
(11, "The Junkboat", "the-junkboat", "", "The Chariot / Il Carro"),
|
||||
(12, "The Junkman", "the-junkman", "", "The Hanged Man / L'Appeso"),
|
||||
(13, "Death", "death-em", "", "La Morte"),
|
||||
(14, "The Traitor", "the-traitor", "", "The Devil / Il Diavolo"),
|
||||
(15, "Disco Inferno", "disco-inferno", "", "The Tower / La Torre"),
|
||||
(16, "Torre Terrestre", "torre-terrestre", "", "Purgatorio"),
|
||||
(17, "Fantasia Celestia", "fantasia-celestia", "", "Paradiso"),
|
||||
|
||||
# ── The Virtues, Explicit (theological / infused) ─────────────────────
|
||||
(18, "Virtue XVIII: Stalking", "virtue-xviii-stalking", "The Virtues, Explicit", "Love / Charity / La Carità"),
|
||||
(19, "Virtue XIX: Intent", "virtue-xix-intent", "The Virtues, Explicit", "Hope / La Speranza"),
|
||||
(20, "Virtue XX: Dreaming", "virtue-xx-dreaming", "The Virtues, Explicit", "Faith / La Fede"),
|
||||
|
||||
# ── The Elements, Classical ────────────────────────────────────────────
|
||||
(21, "Element XXI: Fire", "element-xxi-fire", "The Elements, Classical", "Ardor [Ar]"),
|
||||
(22, "Element XXII: Earth", "element-xxii-earth", "The Elements, Classical", "Ossum [Om]"),
|
||||
(23, "Element XXIII: Air", "element-xxiii-air", "The Elements, Classical", "Pneuma [Pn]"),
|
||||
(24, "Element XXIV: Water", "element-xxiv-water", "The Elements, Classical", "Humor [Hm]"),
|
||||
|
||||
# ── The Zodiac ─────────────────────────────────────────────────────────
|
||||
(25, "Zodiac XXV: Aries", "zodiac-xxv-aries", "The Zodiac", "The Ram"),
|
||||
(26, "Zodiac XXVI: Taurus", "zodiac-xxvi-taurus", "The Zodiac", "The Bull"),
|
||||
(27, "Zodiac XXVII: Gemini", "zodiac-xxvii-gemini", "The Zodiac", "The Twins"),
|
||||
(28, "Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer", "The Zodiac", "The Crab"),
|
||||
(29, "Zodiac XXIX: Leo", "zodiac-xxix-leo", "The Zodiac", "The Lion"),
|
||||
(30, "Zodiac XXX: Virgo", "zodiac-xxx-virgo", "The Zodiac", "The Maiden"),
|
||||
(31, "Zodiac XXXI: Libra", "zodiac-xxxi-libra", "The Zodiac", "The Scales"),
|
||||
(32, "Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio", "The Zodiac", "The Scorpion"),
|
||||
(33, "Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius", "The Zodiac", "The Archer"),
|
||||
(34, "Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn", "The Zodiac", "The Sea-Goat"),
|
||||
(35, "Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius", "The Zodiac", "The Water-Bearer"),
|
||||
(36, "Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces", "The Zodiac", "The Fish"),
|
||||
|
||||
# ── The Elements, Absolute ─────────────────────────────────────────────
|
||||
(37, "Element XXXVII: Time", "element-xxxvii-time", "The Elements, Absolute", "Tempo [Tp]"),
|
||||
(38, "Element XXXVIII: Space", "element-xxxviii-space", "The Elements, Absolute", "Nexus [Nx]"),
|
||||
|
||||
# ── The Wanderers ──────────────────────────────────────────────────────
|
||||
(39, "Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar", "The Wanderers", "The Star / Le Stelle"),
|
||||
(40, "Wanderer XL: The Antichthon", "wanderer-xl-antichthon", "The Wanderers", "The Moon / La Luna"),
|
||||
(41, "Wanderer XLI: The Corestar", "wanderer-xli-corestar", "The Wanderers", "The Sun / Il Sole"),
|
||||
(42, "Wanderer XLII: Mercury", "wanderer-xlii-mercury", "The Wanderers", "Mercurio"),
|
||||
(43, "Wanderer XLIII: Venus", "wanderer-xliii-venus", "The Wanderers", "Venere"),
|
||||
(44, "Wanderer XLIV: Mars", "wanderer-xliv-mars", "The Wanderers", "Marte"),
|
||||
(45, "Wanderer XLV: Jupiter", "wanderer-xlv-jupiter", "The Wanderers", "Giove"),
|
||||
(46, "Wanderer XLVI: Saturn", "wanderer-xlvi-saturn", "The Wanderers", "Saturno"),
|
||||
(47, "Wanderer XLVII: Uranus", "wanderer-xlvii-uranus", "The Wanderers", "Urano"),
|
||||
(48, "Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune", "The Wanderers", "Nettuno"),
|
||||
(49, "Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades", "The Wanderers", "The Binary / Plutone-Proserpina"),
|
||||
|
||||
# ── Finale ─────────────────────────────────────────────────────────────
|
||||
(50, "The Eagle", "the-eagle", "", "Judgement / L'Angelo"),
|
||||
(51, "Divine Calculus", "divine-calculus", "", "The World / Il Mondo"),
|
||||
]
|
||||
|
||||
# ── Earthman Minor Arcana ────────────────────────────────────────────────────
|
||||
# 4 suits × 14 cards. Suits: WANDS / CUPS / SWORDS / COINS
|
||||
# Court cards: Jack (11) / Cavalier (12) / Queen (13) / King (14)
|
||||
EARTHMAN_SUITS = [
|
||||
("WANDS", "wands", "Ardor [Ar] — Fire"),
|
||||
("CUPS", "cups", "Humor [Hm] — Water"),
|
||||
("SWORDS","swords","Pneuma [Pn] — Air"),
|
||||
("COINS", "coins", "Ossum [Om] — Stone"),
|
||||
]
|
||||
|
||||
EARTHMAN_RANKS = [
|
||||
(1, "Ace", "ace"),
|
||||
(2, "2", "two"),
|
||||
(3, "3", "three"),
|
||||
(4, "4", "four"),
|
||||
(5, "5", "five"),
|
||||
(6, "6", "six"),
|
||||
(7, "7", "seven"),
|
||||
(8, "8", "eight"),
|
||||
(9, "9", "nine"),
|
||||
(10, "10", "ten"),
|
||||
(11, "Jack", "jack"),
|
||||
(12, "Cavalier", "cavalier"),
|
||||
(13, "Queen", "queen"),
|
||||
(14, "King", "king"),
|
||||
]
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
# ── 1. Create DeckVariant records ────────────────────────────────────
|
||||
fiorentine = DeckVariant.objects.create(
|
||||
name="Fiorentine Minchiate",
|
||||
slug="fiorentine-minchiate",
|
||||
card_count=78,
|
||||
description="Standard 78-card Minchiate deck. Alt / lite play mode.",
|
||||
is_default=False,
|
||||
)
|
||||
earthman = DeckVariant.objects.create(
|
||||
name="Earthman Deck",
|
||||
slug="earthman",
|
||||
card_count=108,
|
||||
description=(
|
||||
"Primary 108-card Earthman deck. "
|
||||
"52 Major Arcana (The Schiz through Divine Calculus) "
|
||||
"+ 56 Minor Arcana across Wands, Cups, Swords, Coins."
|
||||
),
|
||||
is_default=True,
|
||||
)
|
||||
|
||||
# ── 2. Backfill existing 78 Fiorentine cards ─────────────────────────
|
||||
TarotCard.objects.filter(deck_variant__isnull=True).update(
|
||||
deck_variant=fiorentine
|
||||
)
|
||||
|
||||
# ── 3. Seed Earthman Major Arcana ────────────────────────────────────
|
||||
for number, name, slug, group, correspondence in EARTHMAN_MAJOR:
|
||||
TarotCard.objects.create(
|
||||
deck_variant=earthman,
|
||||
name=name,
|
||||
arcana="MAJOR",
|
||||
suit=None,
|
||||
number=number,
|
||||
slug=slug,
|
||||
group=group,
|
||||
correspondence=correspondence,
|
||||
keywords_upright=[],
|
||||
keywords_reversed=[],
|
||||
)
|
||||
|
||||
# ── 4. Seed Earthman Minor Arcana ────────────────────────────────────
|
||||
for suit_code, suit_slug, _element in EARTHMAN_SUITS:
|
||||
for number, rank_name, rank_slug in EARTHMAN_RANKS:
|
||||
name = f"{rank_name} of {suit_code.capitalize()}"
|
||||
slug = f"{rank_slug}-of-{suit_slug}-em"
|
||||
TarotCard.objects.create(
|
||||
deck_variant=earthman,
|
||||
name=name,
|
||||
arcana="MINOR",
|
||||
suit=suit_code,
|
||||
number=number,
|
||||
slug=slug,
|
||||
group="",
|
||||
correspondence="",
|
||||
keywords_upright=[],
|
||||
keywords_reversed=[],
|
||||
)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
# Remove Earthman cards and clear FK from Fiorentine cards
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if earthman:
|
||||
TarotCard.objects.filter(deck_variant=earthman).delete()
|
||||
|
||||
fiorentine = DeckVariant.objects.filter(slug="fiorentine-minchiate").first()
|
||||
if fiorentine:
|
||||
TarotCard.objects.filter(deck_variant=fiorentine).update(deck_variant=None)
|
||||
|
||||
DeckVariant.objects.filter(slug__in=["earthman", "fiorentine-minchiate"]).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0009_deckvariant_alter_tarotcard_options_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
82
src/apps/epic/migrations/0011_rename_earthman_court_cards.py
Normal file
82
src/apps/epic/migrations/0011_rename_earthman_court_cards.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Data migration: rename Earthman court cards at positions 11 and 12.
|
||||
|
||||
Old naming (from 0010): Jack (11) / Cavalier (12)
|
||||
New naming: Maid (11) / Jack (12)
|
||||
|
||||
Must rename 11 → Maid first so the "jack-of-*-em" slugs are free
|
||||
before the 12s claim them.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
SUITS = ["Wands", "Cups", "Swords", "Coins"]
|
||||
|
||||
|
||||
def rename_court_cards(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# Step 1: Jack (11) → Maid — frees up jack-of-*-em slugs
|
||||
for suit in SUITS:
|
||||
suit_slug = suit.lower()
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, number=11, slug=f"jack-of-{suit_slug}-em"
|
||||
).update(
|
||||
name=f"Maid of {suit}",
|
||||
slug=f"maid-of-{suit_slug}-em",
|
||||
)
|
||||
|
||||
# Step 2: Cavalier (12) → Jack — takes the now-free jack-of-*-em slugs
|
||||
for suit in SUITS:
|
||||
suit_slug = suit.lower()
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, number=12, slug=f"cavalier-of-{suit_slug}-em"
|
||||
).update(
|
||||
name=f"Jack of {suit}",
|
||||
slug=f"jack-of-{suit_slug}-em",
|
||||
)
|
||||
|
||||
|
||||
def reverse_court_cards(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# Step 1: Jack (12) → Cavalier — frees up jack-of-*-em slugs
|
||||
for suit in SUITS:
|
||||
suit_slug = suit.lower()
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, number=12, slug=f"jack-of-{suit_slug}-em"
|
||||
).update(
|
||||
name=f"Cavalier of {suit}",
|
||||
slug=f"cavalier-of-{suit_slug}-em",
|
||||
)
|
||||
|
||||
# Step 2: Maid (11) → Jack
|
||||
for suit in SUITS:
|
||||
suit_slug = suit.lower()
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, number=11, slug=f"maid-of-{suit_slug}-em"
|
||||
).update(
|
||||
name=f"Jack of {suit}",
|
||||
slug=f"jack-of-{suit_slug}-em",
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0010_seed_deck_variants_and_earthman"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_court_cards, reverse_code=reverse_court_cards),
|
||||
]
|
||||
@@ -0,0 +1,162 @@
|
||||
"""
|
||||
Data migration:
|
||||
1. Rename grouped Earthman major arcana to use group-relative ordinals
|
||||
(e.g. "Virtue VI: Controlled Folly" → "Implicit Virtue 1: Controlled Folly").
|
||||
2. Spell out Earthman minor arcana pip names 2–10
|
||||
(e.g. "2 of Wands" → "Two of Wands").
|
||||
|
||||
Corner ranks (Roman numerals of absolute card number) are a property on the model
|
||||
and are unchanged — this only affects the stored name / slug fields.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
# ── Major arcana: (new_name, new_slug) keyed by card number ─────────────────
|
||||
|
||||
MAJOR_RENAMES = {
|
||||
# Implicit Virtues (cards 6–9)
|
||||
6: ("Implicit Virtue 1: Controlled Folly", "implicit-virtue-1-controlled-folly"),
|
||||
7: ("Implicit Virtue 2: Not-Doing", "implicit-virtue-2-not-doing"),
|
||||
8: ("Implicit Virtue 3: Losing Self-Importance", "implicit-virtue-3-losing-self-importance"),
|
||||
9: ("Implicit Virtue 4: Erasing Personal History", "implicit-virtue-4-erasing-personal-history"),
|
||||
# Explicit Virtues (cards 18–20)
|
||||
18: ("Explicit Virtue 1: Stalking", "explicit-virtue-1-stalking"),
|
||||
19: ("Explicit Virtue 2: Intent", "explicit-virtue-2-intent"),
|
||||
20: ("Explicit Virtue 3: Dreaming", "explicit-virtue-3-dreaming"),
|
||||
# Classical Elements (cards 21–24)
|
||||
21: ("Classical Element 1: Fire", "classical-element-1-fire"),
|
||||
22: ("Classical Element 2: Earth", "classical-element-2-earth"),
|
||||
23: ("Classical Element 3: Air", "classical-element-3-air"),
|
||||
24: ("Classical Element 4: Water", "classical-element-4-water"),
|
||||
# Zodiac (cards 25–36)
|
||||
25: ("Zodiac 1: Aries", "zodiac-1-aries"),
|
||||
26: ("Zodiac 2: Taurus", "zodiac-2-taurus"),
|
||||
27: ("Zodiac 3: Gemini", "zodiac-3-gemini"),
|
||||
28: ("Zodiac 4: Cancer", "zodiac-4-cancer"),
|
||||
29: ("Zodiac 5: Leo", "zodiac-5-leo"),
|
||||
30: ("Zodiac 6: Virgo", "zodiac-6-virgo"),
|
||||
31: ("Zodiac 7: Libra", "zodiac-7-libra"),
|
||||
32: ("Zodiac 8: Scorpio", "zodiac-8-scorpio"),
|
||||
33: ("Zodiac 9: Sagittarius", "zodiac-9-sagittarius"),
|
||||
34: ("Zodiac 10: Capricorn", "zodiac-10-capricorn"),
|
||||
35: ("Zodiac 11: Aquarius", "zodiac-11-aquarius"),
|
||||
36: ("Zodiac 12: Pisces", "zodiac-12-pisces"),
|
||||
# Absolute Elements (cards 37–38)
|
||||
37: ("Absolute Element 1: Time", "absolute-element-1-time"),
|
||||
38: ("Absolute Element 2: Space", "absolute-element-2-space"),
|
||||
# Wanderers (cards 39–49)
|
||||
39: ("Wanderer 1: The Polestar", "wanderer-1-polestar"),
|
||||
40: ("Wanderer 2: The Antichthon", "wanderer-2-antichthon"),
|
||||
41: ("Wanderer 3: The Corestar", "wanderer-3-corestar"),
|
||||
42: ("Wanderer 4: Mercury", "wanderer-4-mercury"),
|
||||
43: ("Wanderer 5: Venus", "wanderer-5-venus"),
|
||||
44: ("Wanderer 6: Mars", "wanderer-6-mars"),
|
||||
45: ("Wanderer 7: Jupiter", "wanderer-7-jupiter"),
|
||||
46: ("Wanderer 8: Saturn", "wanderer-8-saturn"),
|
||||
47: ("Wanderer 9: Uranus", "wanderer-9-uranus"),
|
||||
48: ("Wanderer 10: Neptune", "wanderer-10-neptune"),
|
||||
49: ("Wanderer 11: The King & Queen of Hades", "wanderer-11-king-queen-hades"),
|
||||
}
|
||||
|
||||
# Original (name, slug) pairs for reversal
|
||||
MAJOR_ORIGINALS = {
|
||||
6: ("Virtue VI: Controlled Folly", "virtue-vi-controlled-folly"),
|
||||
7: ("Virtue VII: Not-Doing", "virtue-vii-not-doing"),
|
||||
8: ("Virtue VIII: Losing Self-Importance", "virtue-viii-losing-self-importance"),
|
||||
9: ("Virtue IX: Erasing Personal History", "virtue-ix-erasing-personal-history"),
|
||||
18: ("Virtue XVIII: Stalking", "virtue-xviii-stalking"),
|
||||
19: ("Virtue XIX: Intent", "virtue-xix-intent"),
|
||||
20: ("Virtue XX: Dreaming", "virtue-xx-dreaming"),
|
||||
21: ("Element XXI: Fire", "element-xxi-fire"),
|
||||
22: ("Element XXII: Earth", "element-xxii-earth"),
|
||||
23: ("Element XXIII: Air", "element-xxiii-air"),
|
||||
24: ("Element XXIV: Water", "element-xxiv-water"),
|
||||
25: ("Zodiac XXV: Aries", "zodiac-xxv-aries"),
|
||||
26: ("Zodiac XXVI: Taurus", "zodiac-xxvi-taurus"),
|
||||
27: ("Zodiac XXVII: Gemini", "zodiac-xxvii-gemini"),
|
||||
28: ("Zodiac XXVIII: Cancer", "zodiac-xxviii-cancer"),
|
||||
29: ("Zodiac XXIX: Leo", "zodiac-xxix-leo"),
|
||||
30: ("Zodiac XXX: Virgo", "zodiac-xxx-virgo"),
|
||||
31: ("Zodiac XXXI: Libra", "zodiac-xxxi-libra"),
|
||||
32: ("Zodiac XXXII: Scorpio", "zodiac-xxxii-scorpio"),
|
||||
33: ("Zodiac XXXIII: Sagittarius", "zodiac-xxxiii-sagittarius"),
|
||||
34: ("Zodiac XXXIV: Capricorn", "zodiac-xxxiv-capricorn"),
|
||||
35: ("Zodiac XXXV: Aquarius", "zodiac-xxxv-aquarius"),
|
||||
36: ("Zodiac XXXVI: Pisces", "zodiac-xxxvi-pisces"),
|
||||
37: ("Element XXXVII: Time", "element-xxxvii-time"),
|
||||
38: ("Element XXXVIII: Space", "element-xxxviii-space"),
|
||||
39: ("Wanderer XXXIX: The Polestar", "wanderer-xxxix-polestar"),
|
||||
40: ("Wanderer XL: The Antichthon", "wanderer-xl-antichthon"),
|
||||
41: ("Wanderer XLI: The Corestar", "wanderer-xli-corestar"),
|
||||
42: ("Wanderer XLII: Mercury", "wanderer-xlii-mercury"),
|
||||
43: ("Wanderer XLIII: Venus", "wanderer-xliii-venus"),
|
||||
44: ("Wanderer XLIV: Mars", "wanderer-xliv-mars"),
|
||||
45: ("Wanderer XLV: Jupiter", "wanderer-xlv-jupiter"),
|
||||
46: ("Wanderer XLVI: Saturn", "wanderer-xlvi-saturn"),
|
||||
47: ("Wanderer XLVII: Uranus", "wanderer-xlvii-uranus"),
|
||||
48: ("Wanderer XLVIII: Neptune", "wanderer-xlviii-neptune"),
|
||||
49: ("Wanderer XLIX: The King & Queen of Hades", "wanderer-xlix-king-queen-hades"),
|
||||
}
|
||||
|
||||
# Pip number → spelled-out word (slugs already use the word form, only name changes)
|
||||
PIP_SPELLINGS = {
|
||||
2: "Two", 3: "Three", 4: "Four", 5: "Five",
|
||||
6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten",
|
||||
}
|
||||
|
||||
SUITS = ["WANDS", "CUPS", "SWORDS", "COINS"]
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# 1. Rename grouped major arcana to group-relative ordinals
|
||||
for number, (new_name, new_slug) in MAJOR_RENAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=new_name, slug=new_slug)
|
||||
|
||||
# 2. Spell out pip names 2–10
|
||||
for number, word in PIP_SPELLINGS.items():
|
||||
for suit in SUITS:
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
|
||||
).update(name=f"{word} of {suit.capitalize()}")
|
||||
|
||||
|
||||
def rename_reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# 1. Restore original major arcana names
|
||||
for number, (old_name, old_slug) in MAJOR_ORIGINALS.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=old_name, slug=old_slug)
|
||||
|
||||
# 2. Restore numeric pip names (slugs unchanged)
|
||||
for number, _word in PIP_SPELLINGS.items():
|
||||
for suit in SUITS:
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MINOR", suit=suit, number=number
|
||||
).update(name=f"{number} of {suit.capitalize()}")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0011_rename_earthman_court_cards"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||
]
|
||||
55
src/apps/epic/migrations/0013_earthman_coins_to_pentacles.py
Normal file
55
src/apps/epic/migrations/0013_earthman_coins_to_pentacles.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
Data migration: rename Earthman 4th-suit cards from COINS → PENTACLES.
|
||||
|
||||
Updates:
|
||||
- suit field: "COINS" → "PENTACLES"
|
||||
- name: "X of Coins" → "X of Pentacles"
|
||||
- slug: "x-of-coins-em" → "x-of-pentacles-em"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def coins_to_pentacles(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit="COINS")
|
||||
for card in cards:
|
||||
card.suit = "PENTACLES"
|
||||
card.name = card.name.replace(" of Coins", " of Pentacles")
|
||||
card.slug = card.slug.replace("-of-coins-em", "-of-pentacles-em")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
def pentacles_to_coins(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# Only reverse cards that came from Earthman (identified by -em slug suffix)
|
||||
cards = TarotCard.objects.filter(
|
||||
deck_variant=earthman, suit="PENTACLES", slug__endswith="-em"
|
||||
)
|
||||
for card in cards:
|
||||
card.suit = "COINS"
|
||||
card.name = card.name.replace(" of Pentacles", " of Coins")
|
||||
card.slug = card.slug.replace("-of-pentacles-em", "-of-coins-em")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0012_rename_earthman_major_groups_and_pip_spellings"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(coins_to_pentacles, reverse_code=pentacles_to_coins),
|
||||
]
|
||||
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Data migration: rename the five Pope cards to use Arabic group-relative ordinals,
|
||||
matching the convention set for other grouped major arcana.
|
||||
|
||||
"Pope I: President" → "Pope 1: President"
|
||||
"Pope II: Tsar" → "Pope 2: Tsar"
|
||||
etc.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
POPE_RENAMES = {
|
||||
1: ("Pope 1: President", "pope-1-president"),
|
||||
2: ("Pope 2: Tsar", "pope-2-tsar"),
|
||||
3: ("Pope 3: Chairman", "pope-3-chairman"),
|
||||
4: ("Pope 4: Emperor", "pope-4-emperor"),
|
||||
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
|
||||
}
|
||||
|
||||
POPE_ORIGINALS = {
|
||||
1: ("Pope I: President", "pope-i-president"),
|
||||
2: ("Pope II: Tsar", "pope-ii-tsar"),
|
||||
3: ("Pope III: Chairman", "pope-iii-chairman"),
|
||||
4: ("Pope IV: Emperor", "pope-iv-emperor"),
|
||||
5: ("Pope V: Chancellor", "pope-v-chancellor"),
|
||||
}
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (new_name, new_slug) in POPE_RENAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=new_name, slug=new_slug)
|
||||
|
||||
|
||||
def rename_reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (old_name, old_slug) in POPE_ORIGINALS.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=old_name, slug=old_slug)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0013_earthman_coins_to_pentacles"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Data migration: rename Earthman card 22 from "Classical Element 2: Earth"
|
||||
to "Classical Element 2: Stone" (Stone = Ossum, the Earthman name for Earth).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=22
|
||||
).update(name="Classical Element 2: Stone", slug="classical-element-2-stone")
|
||||
|
||||
|
||||
def rename_reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=22
|
||||
).update(name="Classical Element 2: Earth", slug="classical-element-2-earth")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0014_rename_earthman_popes_arabic_ordinals"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||
]
|
||||
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal file
63
src/apps/epic/migrations/0016_reorder_earthman_popes.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Data migration: reorder the five Pope cards.
|
||||
|
||||
New assignment (card number → title):
|
||||
1 → Chancellor 2 → President 3 → Tsar 4 → Chairman 5 → Emperor
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
POPE_RENAMES = {
|
||||
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
|
||||
2: ("Pope 2: President", "pope-2-president"),
|
||||
3: ("Pope 3: Tsar", "pope-3-tsar"),
|
||||
4: ("Pope 4: Chairman", "pope-4-chairman"),
|
||||
5: ("Pope 5: Emperor", "pope-5-emperor"),
|
||||
}
|
||||
|
||||
POPE_ORIGINALS = {
|
||||
1: ("Pope 1: President", "pope-1-president"),
|
||||
2: ("Pope 2: Tsar", "pope-2-tsar"),
|
||||
3: ("Pope 3: Chairman", "pope-3-chairman"),
|
||||
4: ("Pope 4: Emperor", "pope-4-emperor"),
|
||||
5: ("Pope 5: Chancellor", "pope-5-chancellor"),
|
||||
}
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (new_name, new_slug) in POPE_RENAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=new_name, slug=new_slug)
|
||||
|
||||
|
||||
def rename_reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (old_name, old_slug) in POPE_ORIGINALS.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=old_name, slug=old_slug)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0015_rename_classical_element_earth_to_stone"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||
]
|
||||
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal file
19
src/apps/epic/migrations/0017_tableseat_significator_fk.py
Normal file
@@ -0,0 +1,19 @@
|
||||
# Generated by Django 6.0 on 2026-03-25 05:46
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0016_reorder_earthman_popes'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tableseat',
|
||||
name='significator',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='significator_seats', to='epic.tarotcard'),
|
||||
),
|
||||
]
|
||||
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0018_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-04-01 17:37
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0017_tableseat_significator_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
Data migration: rename The Schiz (card 0) and the five Pope cards (cards 1–5)
|
||||
in the Earthman deck.
|
||||
|
||||
0: "The Schiz" → "The Nomad"
|
||||
1: "Pope 1: Chancellor" → "Pope 1: The Schizo"
|
||||
2: "Pope 2: President" → "Pope 2: The Despot"
|
||||
3: "Pope 3: Tsar" → "Pope 3: The Capitalist"
|
||||
4: "Pope 4: Chairman" → "Pope 4: The Fascist"
|
||||
5: "Pope 5: Emperor" → "Pope 5: The War Machine"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
NEW_NAMES = {
|
||||
0: ("The Nomad", "the-nomad"),
|
||||
1: ("Pope 1: The Schizo", "pope-1-the-schizo"),
|
||||
2: ("Pope 2: The Despot", "pope-2-the-despot"),
|
||||
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
|
||||
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
|
||||
5: ("Pope 5: The War Machine","pope-5-the-war-machine"),
|
||||
}
|
||||
|
||||
OLD_NAMES = {
|
||||
0: ("The Schiz", "the-schiz"),
|
||||
1: ("Pope 1: Chancellor", "pope-1-chancellor"),
|
||||
2: ("Pope 2: President", "pope-2-president"),
|
||||
3: ("Pope 3: Tsar", "pope-3-tsar"),
|
||||
4: ("Pope 4: Chairman", "pope-4-chairman"),
|
||||
5: ("Pope 5: Emperor", "pope-5-emperor"),
|
||||
}
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (new_name, new_slug) in NEW_NAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=new_name, slug=new_slug)
|
||||
|
||||
|
||||
def rename_reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (old_name, old_slug) in OLD_NAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=old_name, slug=old_slug)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0018_alter_tarotcard_suit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""
|
||||
Data migration: rename Pope cards 2–5 in the Earthman deck.
|
||||
|
||||
2: "Pope 2: The Despot" → "Pope 2: The Occultist"
|
||||
3: "Pope 3: The Capitalist" → "Pope 3: The Despot"
|
||||
4: "Pope 4: The Fascist" → "Pope 4: The Capitalist"
|
||||
5: "Pope 5: The War Machine" → "Pope 5: The Fascist"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
NEW_NAMES = {
|
||||
2: ("Pope 2: The Occultist", "pope-2-the-occultist"),
|
||||
3: ("Pope 3: The Despot", "pope-3-the-despot"),
|
||||
4: ("Pope 4: The Capitalist","pope-4-the-capitalist"),
|
||||
5: ("Pope 5: The Fascist", "pope-5-the-fascist"),
|
||||
}
|
||||
|
||||
OLD_NAMES = {
|
||||
2: ("Pope 2: The Despot", "pope-2-the-despot"),
|
||||
3: ("Pope 3: The Capitalist", "pope-3-the-capitalist"),
|
||||
4: ("Pope 4: The Fascist", "pope-4-the-fascist"),
|
||||
5: ("Pope 5: The War Machine", "pope-5-the-war-machine"),
|
||||
}
|
||||
|
||||
|
||||
def rename_forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (new_name, new_slug) in NEW_NAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=new_name, slug=new_slug)
|
||||
|
||||
|
||||
def rename_reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
for number, (old_name, old_slug) in OLD_NAMES.items():
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(name=old_name, slug=old_slug)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0019_rename_earthman_schiz_and_popes"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename_forward, reverse_code=rename_reverse),
|
||||
]
|
||||
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Data migration: rename/update six Earthman Major Arcana cards.
|
||||
|
||||
13 name: "Death" → "King Death & the Cosmic Tree"
|
||||
14 name: "The Traitor" → "The Great Hunt"
|
||||
15 correspondence: "The Tower / La Torre" → "The House of the Devil / Inferno"
|
||||
16 correspondence: "Purgatorio" → "The Tower / La Torre / Purgatorio"
|
||||
50 name/slug: "The Eagle" → "The Mould of Man"
|
||||
51 name/slug: "Divine Calculus" → "The Eagle"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
FORWARD = {
|
||||
13: dict(name="King Death & the Cosmic Tree", slug="king-death-and-the-cosmic-tree"),
|
||||
14: dict(name="The Great Hunt", slug="the-great-hunt"),
|
||||
15: dict(correspondence="The House of the Devil / Inferno"),
|
||||
16: dict(correspondence="The Tower / La Torre / Purgatorio"),
|
||||
50: dict(name="The Mould of Man", slug="the-mould-of-man"),
|
||||
51: dict(name="The Eagle", slug="the-eagle"),
|
||||
}
|
||||
|
||||
REVERSE = {
|
||||
13: dict(name="Death", slug="death-em"),
|
||||
14: dict(name="The Traitor", slug="the-traitor"),
|
||||
15: dict(correspondence="The Tower / La Torre"),
|
||||
16: dict(correspondence="Purgatorio"),
|
||||
50: dict(name="The Eagle", slug="the-eagle"),
|
||||
51: dict(name="Divine Calculus",slug="divine-calculus"),
|
||||
}
|
||||
|
||||
|
||||
def apply(changes):
|
||||
def fn(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
# Process in sorted order so card 50 vacates "the-eagle" slug before card 51 claims it.
|
||||
for number in sorted(changes):
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=number
|
||||
).update(**changes[number])
|
||||
return fn
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0020_rename_earthman_pope_cards_2_5"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(apply(FORWARD), reverse_code=apply(REVERSE)),
|
||||
]
|
||||
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
31
src/apps/epic/migrations/0022_sig_reservation.py
Normal file
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 6.0 on 2026-04-06 00:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0021_rename_earthman_major_arcana_batch_2'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='SigReservation',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('role', models.CharField(max_length=2)),
|
||||
('polarity', models.CharField(choices=[('levity', 'Levity'), ('gravity', 'Gravity')], max_length=7)),
|
||||
('reserved_at', models.DateTimeField(auto_now_add=True)),
|
||||
('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.tarotcard')),
|
||||
('gamer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to=settings.AUTH_USER_MODEL)),
|
||||
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sig_reservations', to='epic.room')),
|
||||
],
|
||||
options={
|
||||
'constraints': [models.UniqueConstraint(fields=('room', 'gamer'), name='one_sig_reservation_per_gamer_per_room'), models.UniqueConstraint(fields=('room', 'card', 'polarity'), name='one_reservation_per_card_per_polarity_per_room')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 6.0 on 2026-04-06 02:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0022_sig_reservation'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='tarotcard',
|
||||
name='icon',
|
||||
field=models.CharField(blank=True, default='', max_length=50),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='arcana',
|
||||
field=models.CharField(choices=[('MAJOR', 'Major Arcana'), ('MINOR', 'Minor Arcana'), ('MIDDLE', 'Middle Arcana')], max_length=6),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
Data migration: rename Earthman 4th-suit cards from PENTACLES → CROWNS.
|
||||
|
||||
Updates for every Earthman card where suit="PENTACLES":
|
||||
- suit: "PENTACLES" → "CROWNS"
|
||||
- name: " of Pentacles" → " of Crowns"
|
||||
- slug: "pentacles" → "crowns"
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def pentacles_to_crowns(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, suit="PENTACLES"):
|
||||
card.suit = "CROWNS"
|
||||
card.name = card.name.replace(" of Pentacles", " of Crowns")
|
||||
card.slug = card.slug.replace("pentacles", "crowns")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
def crowns_to_pentacles(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, suit="CROWNS"):
|
||||
card.suit = "PENTACLES"
|
||||
card.name = card.name.replace(" of Crowns", " of Pentacles")
|
||||
card.slug = card.slug.replace("crowns", "pentacles")
|
||||
card.save(update_fields=["suit", "name", "slug"])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0023_tarotcard_icon_alter_tarotcard_arcana_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(pentacles_to_crowns, reverse_code=crowns_to_pentacles),
|
||||
]
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
Data migration: Earthman deck — court cards and major arcana icons.
|
||||
|
||||
1. Court cards (numbers 11–14, all suits): arcana "MINOR" → "MIDDLE"
|
||||
2. Major arcana icons (stored in TarotCard.icon):
|
||||
0 (Nomad) → fa-hat-cowboy-side
|
||||
1 (Schizo) → fa-hat-wizard
|
||||
2–51 (rest) → fa-hand-dots
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
MAJOR_ICONS = {
|
||||
0: "fa-hat-cowboy-side",
|
||||
1: "fa-hat-wizard",
|
||||
}
|
||||
DEFAULT_MAJOR_ICON = "fa-hand-dots"
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
# Court cards → MIDDLE
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MINOR", number__in=[11, 12, 13, 14]
|
||||
).update(arcana="MIDDLE")
|
||||
|
||||
# Major arcana icons
|
||||
for card in TarotCard.objects.filter(deck_variant=earthman, arcana="MAJOR"):
|
||||
card.icon = MAJOR_ICONS.get(card.number, DEFAULT_MAJOR_ICON)
|
||||
card.save(update_fields=["icon"])
|
||||
|
||||
|
||||
def backward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
earthman = DeckVariant.objects.filter(slug="earthman").first()
|
||||
if not earthman:
|
||||
return
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MIDDLE", number__in=[11, 12, 13, 14]
|
||||
).update(arcana="MINOR")
|
||||
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR"
|
||||
).update(icon="")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0024_earthman_pentacles_to_crowns"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=backward),
|
||||
]
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
Data migration — Earthman deck:
|
||||
1. Rename three suit codes (and card names) for Earthman cards:
|
||||
WANDS → BRANDS (Wands → Brands)
|
||||
CUPS → GRAILS (Cups → Grails)
|
||||
SWORDS → BLADES (Swords → Blades)
|
||||
CROWNS stays CROWNS.
|
||||
2. Copy keywords_upright / keywords_reversed from the Fiorentine Minchiate
|
||||
deck to corresponding Earthman cards:
|
||||
• Major: explicit number-to-number map based on card correspondences.
|
||||
• Minor/Middle: same number, suit mapped (BRANDS→WANDS, GRAILS→CUPS,
|
||||
BLADES→SWORDS, CROWNS→PENTACLES). Cards with no Fiorentine counterpart
|
||||
stay with empty keyword lists.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
# ── 1. Suit rename map ────────────────────────────────────────────────────────
|
||||
|
||||
SUIT_RENAMES = {
|
||||
"WANDS": "BRANDS",
|
||||
"CUPS": "GRAILS",
|
||||
"SWORDS": "BLADES",
|
||||
}
|
||||
|
||||
# ── 2. Major arcana: Earthman number → Fiorentine number ─────────────────────
|
||||
# Cards without a Fiorentine counterpart are omitted (keywords stay empty).
|
||||
|
||||
MAJOR_KEYWORD_MAP = {
|
||||
0: 0, # The Schiz → The Fool
|
||||
1: 1, # Pope I (President) → The Magician
|
||||
2: 2, # Pope II (Tsar) → The High Priestess
|
||||
3: 3, # Pope III (Chairman) → The Empress
|
||||
4: 4, # Pope IV (Emperor) → The Emperor
|
||||
5: 5, # Pope V (Chancellor) → The Hierophant
|
||||
6: 8, # Virtue VI (Controlled Folly) → Strength
|
||||
7: 11, # Virtue VII (Not-Doing) → Justice
|
||||
8: 14, # Virtue VIII (Losing Self-Importance) → Temperance
|
||||
# 9: Prudence — no Fiorentine equivalent
|
||||
10: 10, # Wheel of Fortune → Wheel of Fortune
|
||||
11: 7, # The Junkboat → The Chariot
|
||||
12: 12, # The Junkman → The Hanged Man
|
||||
13: 13, # Death → Death
|
||||
14: 15, # The Traitor → The Devil
|
||||
15: 16, # Disco Inferno → The Tower
|
||||
# 16: Torre Terrestre (Purgatory) — no equivalent
|
||||
# 17: Fantasia Celestia (Paradise) — no equivalent
|
||||
18: 6, # Virtue XVIII (Stalking) → The Lovers
|
||||
# 19: Virtue XIX (Intent / Hope) — no equivalent
|
||||
# 20: Virtue XX (Dreaming / Faith)— no equivalent
|
||||
# 21–38: Classical Elements + Zodiac — no equivalents
|
||||
39: 17, # Wanderer XXXIX (Polestar) → The Star
|
||||
40: 18, # Wanderer XL (Antichthon) → The Moon
|
||||
41: 19, # Wanderer XLI (Corestar) → The Sun
|
||||
# 42–49: Planets + The Binary — no equivalents
|
||||
50: 20, # The Eagle → Judgement
|
||||
51: 21, # Divine Calculus → The World
|
||||
}
|
||||
|
||||
# ── 3. Minor suit map: Earthman (post-rename) → Fiorentine ───────────────────
|
||||
|
||||
MINOR_SUIT_MAP = {
|
||||
"BRANDS": "WANDS",
|
||||
"GRAILS": "CUPS",
|
||||
"BLADES": "SWORDS",
|
||||
"CROWNS": "PENTACLES",
|
||||
}
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
fiorentine = DeckVariant.objects.get(slug="fiorentine-minchiate")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return # decks not seeded — nothing to do
|
||||
|
||||
# ── Step 1: rename Earthman suit codes + card names ───────────────────────
|
||||
for old_suit, new_suit in SUIT_RENAMES.items():
|
||||
old_display = old_suit.capitalize() # e.g. "Wands"
|
||||
new_display = new_suit.capitalize() # e.g. "Brands"
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit=old_suit)
|
||||
for card in cards:
|
||||
card.name = card.name.replace(f" of {old_display}", f" of {new_display}")
|
||||
card.suit = new_suit
|
||||
card.save()
|
||||
|
||||
# ── Step 2: copy major arcana keywords ───────────────────────────────────
|
||||
fio_major = {
|
||||
card.number: card
|
||||
for card in TarotCard.objects.filter(deck_variant=fiorentine, arcana="MAJOR")
|
||||
}
|
||||
for em_num, fio_num in MAJOR_KEYWORD_MAP.items():
|
||||
fio_card = fio_major.get(fio_num)
|
||||
if not fio_card:
|
||||
continue
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=em_num
|
||||
).update(
|
||||
keywords_upright=fio_card.keywords_upright,
|
||||
keywords_reversed=fio_card.keywords_reversed,
|
||||
)
|
||||
|
||||
# ── Step 3: copy minor/middle arcana keywords ─────────────────────────────
|
||||
for em_suit, fio_suit in MINOR_SUIT_MAP.items():
|
||||
fio_by_number = {
|
||||
card.number: card
|
||||
for card in TarotCard.objects.filter(deck_variant=fiorentine, suit=fio_suit)
|
||||
}
|
||||
for em_card in TarotCard.objects.filter(deck_variant=earthman, suit=em_suit):
|
||||
fio_card = fio_by_number.get(em_card.number)
|
||||
if fio_card:
|
||||
em_card.keywords_upright = fio_card.keywords_upright
|
||||
em_card.keywords_reversed = fio_card.keywords_reversed
|
||||
em_card.save()
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
|
||||
# Reverse suit renames
|
||||
reverse_renames = {new: old for old, new in SUIT_RENAMES.items()}
|
||||
for new_suit, old_suit in reverse_renames.items():
|
||||
new_display = new_suit.capitalize()
|
||||
old_display = old_suit.capitalize()
|
||||
cards = TarotCard.objects.filter(deck_variant=earthman, suit=new_suit)
|
||||
for card in cards:
|
||||
card.name = card.name.replace(f" of {new_display}", f" of {old_display}")
|
||||
card.suit = old_suit
|
||||
card.save()
|
||||
|
||||
# Clear all Earthman keywords
|
||||
TarotCard.objects.filter(deck_variant=earthman).update(
|
||||
keywords_upright=[],
|
||||
keywords_reversed=[],
|
||||
)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0025_earthman_middle_arcana_and_major_icons"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
65
src/apps/epic/migrations/0027_tarotcard_cautions.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
Schema + data migration:
|
||||
1. Add `cautions` JSONField (list, default=[]) to TarotCard.
|
||||
2. Seed The Schizo (Earthman MAJOR #1) with 4 rival-interaction cautions.
|
||||
All other cards default to [] — the UI shows a placeholder when empty.
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
SCHIZO_CAUTIONS = [
|
||||
'This card will reverse into <span class="card-ref">The Pervert</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Occultist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Pestilence</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Paranoiac</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Despot</span>, which in turn'
|
||||
' reverses into <span class="card-ref">War</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Neurotic</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Capitalist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Famine</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">The Suicidal</span> when it'
|
||||
' comes under dominion of <span class="card-ref">The Fascist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">Death</span>.',
|
||||
]
|
||||
|
||||
|
||||
def seed_schizo_cautions(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=SCHIZO_CAUTIONS)
|
||||
|
||||
|
||||
def clear_schizo_cautions(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=[])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0026_earthman_suit_renames_and_keywords"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name="tarotcard",
|
||||
name="cautions",
|
||||
field=models.JSONField(default=list),
|
||||
),
|
||||
migrations.RunPython(seed_schizo_cautions, reverse_code=clear_schizo_cautions),
|
||||
]
|
||||
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
18
src/apps/epic/migrations/0028_alter_tarotcard_suit.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-04-07 03:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0027_tarotcard_cautions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='tarotcard',
|
||||
name='suit',
|
||||
field=models.CharField(blank=True, choices=[('WANDS', 'Wands'), ('CUPS', 'Cups'), ('SWORDS', 'Swords'), ('PENTACLES', 'Pentacles'), ('CROWNS', 'Crowns'), ('BRANDS', 'Brands'), ('GRAILS', 'Grails'), ('BLADES', 'Blades')], max_length=10, null=True),
|
||||
),
|
||||
]
|
||||
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
61
src/apps/epic/migrations/0029_fix_schizo_cautions.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Data fix: clear Schizo cautions from The Nomad (number=0) if present,
|
||||
and ensure they land on The Schizo (number=1).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
SCHIZO_CAUTIONS = [
|
||||
'This card will reverse into <span class="card-ref">I. The Pervert</span> when it'
|
||||
' comes under dominion of <span class="card-ref">II. The Occultist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">II. Pestilence</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Paranoiac</span> when it'
|
||||
' comes under dominion of <span class="card-ref">III. The Despot</span>, which in turn'
|
||||
' reverses into <span class="card-ref">III. War</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Neurotic</span> when it'
|
||||
' comes under dominion of <span class="card-ref">IV. The Capitalist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">IV. Famine</span>.',
|
||||
|
||||
'This card will reverse into <span class="card-ref">I. The Suicidal</span> when it'
|
||||
' comes under dominion of <span class="card-ref">V. The Fascist</span>, which in turn'
|
||||
' reverses into <span class="card-ref">V. Death</span>.',
|
||||
]
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=0
|
||||
).update(cautions=[])
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=SCHIZO_CAUTIONS)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
TarotCard = apps.get_model("epic", "TarotCard")
|
||||
DeckVariant = apps.get_model("epic", "DeckVariant")
|
||||
try:
|
||||
earthman = DeckVariant.objects.get(slug="earthman")
|
||||
except DeckVariant.DoesNotExist:
|
||||
return
|
||||
TarotCard.objects.filter(
|
||||
deck_variant=earthman, arcana="MAJOR", number=1
|
||||
).update(cautions=[])
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("epic", "0028_alter_tarotcard_suit"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
23
src/apps/epic/migrations/0030_sigreservation_seat_fk.py
Normal file
@@ -0,0 +1,23 @@
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0029_fix_schizo_cautions'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='seat',
|
||||
field=models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name='sig_reservation',
|
||||
to='epic.tableseat',
|
||||
),
|
||||
),
|
||||
]
|
||||
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
33
src/apps/epic/migrations/0031_sig_ready_sky_select.py
Normal file
@@ -0,0 +1,33 @@
|
||||
# Generated by Django 6.0 on 2026-04-09 04:53
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0030_sigreservation_seat_fk'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='room',
|
||||
name='sig_select_started_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='countdown_remaining',
|
||||
field=models.IntegerField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='sigreservation',
|
||||
name='ready',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='room',
|
||||
name='table_status',
|
||||
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('SKY_SELECT', 'Sky Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||
),
|
||||
]
|
||||
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal file
65
src/apps/epic/migrations/0032_astro_reference_tables.py
Normal file
@@ -0,0 +1,65 @@
|
||||
# Generated by Django 6.0 on 2026-04-14 05:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0031_sig_ready_sky_select'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='AspectType',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True)),
|
||||
('symbol', models.CharField(max_length=5)),
|
||||
('angle', models.PositiveSmallIntegerField()),
|
||||
('orb', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'ordering': ['angle'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='HouseLabel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('number', models.PositiveSmallIntegerField(unique=True)),
|
||||
('name', models.CharField(max_length=30)),
|
||||
('keywords', models.CharField(blank=True, max_length=100)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['number'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Planet',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True)),
|
||||
('symbol', models.CharField(max_length=5)),
|
||||
('order', models.PositiveSmallIntegerField(unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Sign',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=20, unique=True)),
|
||||
('symbol', models.CharField(max_length=5)),
|
||||
('element', models.CharField(choices=[('Fire', 'Fire'), ('Earth', 'Earth'), ('Air', 'Air'), ('Water', 'Water')], max_length=5)),
|
||||
('modality', models.CharField(choices=[('Cardinal', 'Cardinal'), ('Fixed', 'Fixed'), ('Mutable', 'Mutable')], max_length=8)),
|
||||
('order', models.PositiveSmallIntegerField(unique=True)),
|
||||
('start_degree', models.FloatField()),
|
||||
],
|
||||
options={
|
||||
'ordering': ['order'],
|
||||
},
|
||||
),
|
||||
]
|
||||
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal file
106
src/apps/epic/migrations/0033_seed_astro_reference_tables.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Data migration: seed Sign, Planet, AspectType, and HouseLabel tables.
|
||||
|
||||
These are stable astrological reference rows — never user-edited.
|
||||
The data matches the constants in pyswiss/apps/charts/calc.py so that
|
||||
the proxy view and D3 wheel share a single source of truth.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
# ── Signs ────────────────────────────────────────────────────────────────────
|
||||
# (order, name, symbol, element, modality, start_degree)
|
||||
SIGNS = [
|
||||
(0, 'Aries', '♈', 'Fire', 'Cardinal', 0.0),
|
||||
(1, 'Taurus', '♉', 'Earth', 'Fixed', 30.0),
|
||||
(2, 'Gemini', '♊', 'Air', 'Mutable', 60.0),
|
||||
(3, 'Cancer', '♋', 'Water', 'Cardinal', 90.0),
|
||||
(4, 'Leo', '♌', 'Fire', 'Fixed', 120.0),
|
||||
(5, 'Virgo', '♍', 'Earth', 'Mutable', 150.0),
|
||||
(6, 'Libra', '♎', 'Air', 'Cardinal', 180.0),
|
||||
(7, 'Scorpio', '♏', 'Water', 'Fixed', 210.0),
|
||||
(8, 'Sagittarius', '♐', 'Fire', 'Mutable', 240.0),
|
||||
(9, 'Capricorn', '♑', 'Earth', 'Cardinal', 270.0),
|
||||
(10, 'Aquarius', '♒', 'Air', 'Fixed', 300.0),
|
||||
(11, 'Pisces', '♓', 'Water', 'Mutable', 330.0),
|
||||
]
|
||||
|
||||
# ── Planets ───────────────────────────────────────────────────────────────────
|
||||
# (order, name, symbol)
|
||||
PLANETS = [
|
||||
(0, 'Sun', '☉'),
|
||||
(1, 'Moon', '☽'),
|
||||
(2, 'Mercury', '☿'),
|
||||
(3, 'Venus', '♀'),
|
||||
(4, 'Mars', '♂'),
|
||||
(5, 'Jupiter', '♃'),
|
||||
(6, 'Saturn', '♄'),
|
||||
(7, 'Uranus', '♅'),
|
||||
(8, 'Neptune', '♆'),
|
||||
(9, 'Pluto', '♇'),
|
||||
]
|
||||
|
||||
# ── Aspect types ──────────────────────────────────────────────────────────────
|
||||
# (name, symbol, angle, orb) — mirrors ASPECTS constant in pyswiss calc.py
|
||||
ASPECT_TYPES = [
|
||||
('Conjunction', '☌', 0, 8.0),
|
||||
('Sextile', '⚹', 60, 6.0),
|
||||
('Square', '□', 90, 8.0),
|
||||
('Trine', '△', 120, 8.0),
|
||||
('Opposition', '☍', 180, 10.0),
|
||||
]
|
||||
|
||||
# ── House labels (distinctions) ───────────────────────────────────────────────
|
||||
# (number, name, keywords)
|
||||
HOUSE_LABELS = [
|
||||
(1, 'Self', 'identity, appearance, first impressions'),
|
||||
(2, 'Worth', 'possessions, values, finances'),
|
||||
(3, 'Education', 'communication, siblings, short journeys'),
|
||||
(4, 'Family', 'home, roots, ancestry'),
|
||||
(5, 'Creation', 'creativity, romance, children, pleasure'),
|
||||
(6, 'Ritual', 'service, health, daily routines'),
|
||||
(7, 'Cooperation', 'partnerships, marriage, open enemies'),
|
||||
(8, 'Regeneration', 'transformation, shared resources, death'),
|
||||
(9, 'Enterprise', 'philosophy, travel, higher learning'),
|
||||
(10, 'Career', 'public life, reputation, authority'),
|
||||
(11, 'Reward', 'friends, groups, aspirations'),
|
||||
(12, 'Reprisal', 'hidden matters, karma, self-undoing'),
|
||||
]
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Sign = apps.get_model('epic', 'Sign')
|
||||
Planet = apps.get_model('epic', 'Planet')
|
||||
AspectType = apps.get_model('epic', 'AspectType')
|
||||
HouseLabel = apps.get_model('epic', 'HouseLabel')
|
||||
|
||||
for order, name, symbol, element, modality, start_degree in SIGNS:
|
||||
Sign.objects.create(
|
||||
order=order, name=name, symbol=symbol,
|
||||
element=element, modality=modality, start_degree=start_degree,
|
||||
)
|
||||
|
||||
for order, name, symbol in PLANETS:
|
||||
Planet.objects.create(order=order, name=name, symbol=symbol)
|
||||
|
||||
for name, symbol, angle, orb in ASPECT_TYPES:
|
||||
AspectType.objects.create(name=name, symbol=symbol, angle=angle, orb=orb)
|
||||
|
||||
for number, name, keywords in HOUSE_LABELS:
|
||||
HouseLabel.objects.create(number=number, name=name, keywords=keywords)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
for model_name in ('Sign', 'Planet', 'AspectType', 'HouseLabel'):
|
||||
apps.get_model('epic', model_name).objects.all().delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0032_astro_reference_tables'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse_code=reverse),
|
||||
]
|
||||
35
src/apps/epic/migrations/0034_character_model.py
Normal file
35
src/apps/epic/migrations/0034_character_model.py
Normal file
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 6.0 on 2026-04-14 05:29
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('epic', '0033_seed_astro_reference_tables'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Character',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('birth_dt', models.DateTimeField(blank=True, null=True)),
|
||||
('birth_lat', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('birth_lon', models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True)),
|
||||
('birth_place', models.CharField(blank=True, max_length=200)),
|
||||
('house_system', models.CharField(choices=[('O', 'Porphyry'), ('P', 'Placidus'), ('K', 'Koch'), ('W', 'Whole Sign')], default='O', max_length=1)),
|
||||
('chart_data', models.JSONField(blank=True, null=True)),
|
||||
('celtic_cross', models.JSONField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True)),
|
||||
('retired_at', models.DateTimeField(blank=True, null=True)),
|
||||
('seat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='characters', to='epic.tableseat')),
|
||||
('significator', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='character_significators', to='epic.tarotcard')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,9 @@
|
||||
import random
|
||||
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
|
||||
@@ -31,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"),
|
||||
]
|
||||
|
||||
@@ -48,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)
|
||||
@@ -147,6 +152,9 @@ def debit_token(user, slot, token):
|
||||
room.save()
|
||||
|
||||
|
||||
SIG_SEAT_ORDER = ["PC", "NC", "EC", "SC", "AC", "BC"]
|
||||
|
||||
|
||||
class TableSeat(models.Model):
|
||||
PC = "PC"
|
||||
BC = "BC"
|
||||
@@ -173,3 +181,430 @@ class TableSeat(models.Model):
|
||||
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
||||
role_revealed = models.BooleanField(default=False)
|
||||
seat_position = models.IntegerField(null=True, blank=True)
|
||||
significator = models.ForeignKey(
|
||||
"TarotCard", null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name="significator_seats",
|
||||
)
|
||||
|
||||
|
||||
class DeckVariant(models.Model):
|
||||
"""A named deck variant, e.g. Earthman (108 cards) or Fiorentine Minchiate (78 cards)."""
|
||||
|
||||
name = models.CharField(max_length=100, unique=True)
|
||||
slug = models.SlugField(unique=True)
|
||||
card_count = models.IntegerField()
|
||||
description = models.TextField(blank=True)
|
||||
is_default = models.BooleanField(default=False)
|
||||
|
||||
@property
|
||||
def short_key(self):
|
||||
"""First dash-separated word of slug — used as an HTML id component."""
|
||||
return self.slug.split('-')[0]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.name} ({self.card_count} cards)"
|
||||
|
||||
|
||||
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(
|
||||
DeckVariant, null=True, blank=True,
|
||||
on_delete=models.CASCADE, related_name="cards",
|
||||
)
|
||||
name = models.CharField(max_length=200)
|
||||
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"]
|
||||
unique_together = [("deck_variant", "slug")]
|
||||
|
||||
@staticmethod
|
||||
def _to_roman(n):
|
||||
if n == 0:
|
||||
return '0'
|
||||
val = [50, 40, 10, 9, 5, 4, 1]
|
||||
syms = ['L','XL','X','IX','V','IV','I']
|
||||
result = ''
|
||||
for v, s in zip(val, syms):
|
||||
while n >= v:
|
||||
result += s
|
||||
n -= v
|
||||
return result
|
||||
|
||||
@property
|
||||
def corner_rank(self):
|
||||
if self.arcana == self.MAJOR:
|
||||
return self._to_roman(self.number)
|
||||
court = {11: 'M', 12: 'J', 13: 'Q', 14: 'K'}
|
||||
return court.get(self.number, str(self.number))
|
||||
|
||||
@property
|
||||
def name_group(self):
|
||||
"""Returns 'Group N:' prefix if the name contains ': ', else ''."""
|
||||
if ': ' in self.name:
|
||||
return self.name.split(': ', 1)[0] + ':'
|
||||
return ''
|
||||
|
||||
@property
|
||||
def name_title(self):
|
||||
"""Returns the title after 'Group N: ', or the full name if no colon."""
|
||||
if ': ' in self.name:
|
||||
return self.name.split(': ', 1)[1]
|
||||
return self.name
|
||||
|
||||
@property
|
||||
def suit_icon(self):
|
||||
if self.icon:
|
||||
return self.icon
|
||||
if self.arcana == self.MAJOR:
|
||||
return ''
|
||||
return {
|
||||
self.WANDS: 'fa-wand-sparkles',
|
||||
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
|
||||
|
||||
|
||||
class TarotDeck(models.Model):
|
||||
"""One shuffled deck per room, scoped to the founder's chosen DeckVariant."""
|
||||
|
||||
room = models.OneToOneField(Room, on_delete=models.CASCADE, related_name="tarot_deck")
|
||||
deck_variant = models.ForeignKey(
|
||||
DeckVariant, null=True, blank=True,
|
||||
on_delete=models.SET_NULL, related_name="active_decks",
|
||||
)
|
||||
drawn_card_ids = models.JSONField(default=list)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
|
||||
@property
|
||||
def remaining_count(self):
|
||||
total = self.deck_variant.card_count if self.deck_variant else 0
|
||||
return total - len(self.drawn_card_ids)
|
||||
|
||||
def draw(self, n=1):
|
||||
"""Draw n cards at random. Returns list of (TarotCard, reversed: bool) tuples."""
|
||||
available = list(
|
||||
TarotCard.objects.filter(deck_variant=self.deck_variant)
|
||||
.exclude(id__in=self.drawn_card_ids)
|
||||
)
|
||||
if len(available) < n:
|
||||
raise ValueError(
|
||||
f"Not enough cards remaining: {len(available)} available, {n} requested"
|
||||
)
|
||||
drawn = random.sample(available, n)
|
||||
self.drawn_card_ids = self.drawn_card_ids + [card.id for card in drawn]
|
||||
self.save(update_fields=["drawn_card_ids"])
|
||||
return [(card, random.choice([True, False])) for card in drawn]
|
||||
|
||||
def shuffle(self):
|
||||
"""Reset the deck so all variant cards are available again."""
|
||||
self.drawn_card_ids = []
|
||||
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 → 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_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],
|
||||
))
|
||||
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)}
|
||||
seats = list(room.table_seats.all())
|
||||
return sorted(seats, key=lambda s: _order.get(s.role, 99))
|
||||
|
||||
|
||||
def active_sig_seat(room):
|
||||
"""Return the first seat without a significator in canonical order, or None."""
|
||||
for seat in sig_seat_order(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 |
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user