Compare commits
295 Commits
pre-drf
...
c3f0342a2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c3f0342a2d | ||
|
|
ad7a354f8c | ||
|
|
7fcb6f307c | ||
|
|
e2515d9b44 | ||
|
|
5aaff6240b | ||
|
|
c78ecb61bf | ||
|
|
5655342d9f | ||
|
|
2088fedeee | ||
|
|
6ebb2fbd51 | ||
|
|
b86a4ddd73 | ||
|
|
214120ef2d | ||
|
|
7d4389a74a | ||
|
|
cd5252c185 | ||
|
|
e8687dc050 | ||
|
|
48aad6ce35 | ||
|
|
473e6bc45a | ||
|
|
6d9d3d4f54 | ||
|
|
565f727aa6 | ||
|
|
3cc9f5a527 | ||
|
|
be061f6bc2 | ||
|
|
83ce238a2f | ||
|
|
6069d86ec5 | ||
|
|
a44727c559 | ||
|
|
0b2320e39b | ||
|
|
5c05bd6552 | ||
|
|
b5a92ddf77 | ||
|
|
bb1cda9c9c | ||
|
|
3974fdac82 | ||
|
|
b8ac004fb6 | ||
|
|
02975d79d3 | ||
|
|
04f0e87eba | ||
|
|
ebc460fe67 | ||
|
|
7c249500bd | ||
|
|
ea2bfa6ce1 | ||
|
|
9c7d58f0b3 | ||
|
|
4761d3f939 | ||
|
|
2be330e698 | ||
|
|
fbf260b148 | ||
|
|
09ed64080b | ||
|
|
f15b17f7bd | ||
|
|
122de3bc80 | ||
|
|
6e995647e4 | ||
|
|
d7d20f25e3 | ||
|
|
758c9c5377 | ||
|
|
7c03bded8d | ||
|
|
8a24021739 | ||
|
|
bd9a2fdae3 | ||
|
|
4f8e52890b | ||
|
|
abf8be8861 | ||
|
|
127f4a092d | ||
|
|
2910012b67 | ||
|
|
db9ac9cb24 | ||
|
|
d3e4638233 | ||
|
|
10a6809dcf | ||
|
|
de4ac60aec | ||
|
|
71ef3dcb7f | ||
|
|
9beb21bffe | ||
|
|
6248d95bf3 | ||
|
|
44cf399352 | ||
|
|
df2b353ebd | ||
|
|
3fd1f5e990 | ||
|
|
02a7a0ef2e | ||
|
|
cc2ab869f1 | ||
|
|
8c711ac674 | ||
|
|
b8af0041cc | ||
|
|
97ec2f6ee6 | ||
|
|
0a135c2149 | ||
|
|
f1e9a9657b | ||
|
|
32d8d97360 | ||
|
|
df421fb6c0 | ||
|
|
3800c5bdad | ||
|
|
12d575a84b | ||
|
|
c14b6d7062 | ||
|
|
a7c5468cbc | ||
|
|
4da8750c60 | ||
|
|
cf40f626e6 | ||
|
|
99a826f6c9 | ||
|
|
51fe2614fa | ||
|
|
56dc094b45 | ||
|
|
520fdf7862 | ||
|
|
e2cc38686f | ||
|
|
0bcc7567bb | ||
|
|
6654785f25 | ||
|
|
99a69202b9 | ||
|
|
55bb450d27 | ||
|
|
e28d55ad58 | ||
|
|
b110bb6d01 | ||
|
|
2892b51101 | ||
|
|
871e94b298 | ||
|
|
c3ab78cc57 | ||
|
|
c7370bda03 | ||
|
|
a15d91dfe6 | ||
|
|
fecb1fddca | ||
|
|
2028f1a544 | ||
|
|
40c747a837 | ||
|
|
40a55721ab | ||
|
|
d4518a0671 | ||
|
|
74f63a7721 | ||
|
|
bd3d7fc7bd | ||
|
|
c00288e256 | ||
|
|
b5de96660a | ||
|
|
96bb05a4ba | ||
|
|
4e07fcf38b | ||
|
|
b74f8e1bb1 | ||
|
|
188365f412 | ||
|
|
824f35590b | ||
|
|
43cb84e8f4 | ||
|
|
afe8e2b32c | ||
|
|
ca38875660 | ||
|
|
8538f76b13 | ||
|
|
2a7d4c7410 | ||
|
|
ed10e58383 | ||
|
|
b65cba5ed2 | ||
|
|
afe79f1a48 | ||
|
|
0e5e39b0dc | ||
|
|
4860b6ee2a | ||
|
|
c025a38709 | ||
|
|
581ea7e349 | ||
|
|
596175cd1c | ||
|
|
1aaf353066 | ||
|
|
441def9a34 | ||
|
|
736b59b5c0 | ||
|
|
a8592aeaec | ||
|
|
8b006be138 | ||
|
|
299a806862 | ||
|
|
fb782cf5ef | ||
|
|
224f5e2ad0 | ||
|
|
96379934d7 | ||
|
|
29a5658b01 | ||
|
|
73135df7a6 | ||
|
|
57f47cc77e | ||
|
|
5d21e79be5 | ||
|
|
ff0883002b | ||
|
|
7f927741d4 | ||
|
|
3bf48546e3 | ||
|
|
6817323f8e | ||
|
|
11283118d6 | ||
|
|
6c91ec0385 | ||
|
|
39db59c71a | ||
|
|
5f643350c5 | ||
|
|
ab41797e57 | ||
|
|
e35855f472 | ||
|
|
0e5805efd2 | ||
|
|
de99b538d2 | ||
|
|
c08b5b764e | ||
|
|
d63a4bec4a | ||
|
|
b35c9b483e | ||
|
|
30ea0fad9d | ||
|
|
62d5c738f9 | ||
|
|
f0f419ff7e | ||
|
|
0494710ce0 | ||
|
|
713e24863d | ||
|
|
b3bc422f46 | ||
|
|
c0016418cc | ||
|
|
4d52c4f54d | ||
|
|
db1608fa38 | ||
|
|
4728cde771 | ||
|
|
2f6fc1ff20 | ||
|
|
9698d70164 | ||
|
|
7370fd611f | ||
|
|
f5a5ed9d8d | ||
|
|
a5d71925fc | ||
|
|
b03ba09b65 | ||
|
|
befa61e1e9 | ||
|
|
15ac3216ff | ||
|
|
2896efa8e0 | ||
|
|
588358a20f | ||
|
|
11c85d56d1 | ||
|
|
8bab26e003 | ||
|
|
bc78d2c470 | ||
|
|
2447315fd3 | ||
|
|
cde231d43c | ||
|
|
a0f8aeb791 | ||
|
|
2ca4e9d39f | ||
|
|
c71f4eb68c | ||
|
|
189d329e76 | ||
|
|
18898c7a0f | ||
|
|
f347af7eff | ||
|
|
e59d5fd4c0 | ||
|
|
62f6c27806 | ||
|
|
cc02419e8d | ||
|
|
c331e72de6 | ||
|
|
a1f8d294a3 | ||
|
|
5607f70852 | ||
|
|
eecb6c2be6 | ||
|
|
2fd3ec9ab2 | ||
|
|
cad3744a57 | ||
|
|
ffb374c81c | ||
|
|
3b905e0436 | ||
|
|
f1b5ba2a71 | ||
|
|
184854a2de | ||
|
|
f5c2cf4636 | ||
|
|
91e0eaad8e | ||
|
|
5a811d0079 | ||
|
|
8c2a5d24ec | ||
|
|
4f076165ef | ||
|
|
3a87a17017 | ||
|
|
4e63323019 | ||
|
|
8b2c4e1bdc | ||
|
|
10d717a3ba | ||
|
|
e9f50810da | ||
|
|
67697fa90e | ||
|
|
97b406c7e0 | ||
|
|
568497d09d | ||
|
|
1558bb02b4 | ||
|
|
01de6e7548 | ||
|
|
c9defa5a81 | ||
|
|
462155f07b | ||
|
|
fa46fc18d7 | ||
|
|
4239245902 | ||
|
|
b49218b45b | ||
|
|
ace9a4888e | ||
|
|
435bec7988 | ||
|
|
12146037f0 | ||
|
|
ff7b71792f | ||
|
|
2e24175ec8 | ||
|
|
18ba242647 | ||
|
|
6d1b358b7c | ||
|
|
2140bd8206 | ||
|
|
52e171cb20 | ||
|
|
74d1a43559 | ||
|
|
2d453dbc78 | ||
|
|
4baaa63430 | ||
|
|
26b6d4e7db | ||
|
|
f4dfce826b | ||
|
|
53d9f79476 | ||
|
|
ed48d18c1d | ||
|
|
f76c6d0fe5 | ||
|
|
d9feb80b2a | ||
|
|
d780115515 | ||
|
|
af3523c9bb | ||
|
|
dddffd22d5 | ||
|
|
e0d1f51bf1 | ||
|
|
6a42b91420 | ||
|
|
5773462b4c | ||
|
|
681a1a4cd0 | ||
|
|
69fea65bf9 | ||
|
|
068b99d030 | ||
|
|
8807d31274 | ||
|
|
50ee983e27 | ||
|
|
f45740d8b3 | ||
|
|
aa1cef6e7b | ||
|
|
791510b46d | ||
|
|
fe6d2c5db1 | ||
|
|
d2861077a4 | ||
|
|
645b265c80 | ||
|
|
382dd5958f | ||
|
|
47d84b6bf2 | ||
|
|
97601586c5 | ||
|
|
2c445c0e76 | ||
|
|
a53dc41367 | ||
|
|
251b3bf778 | ||
|
|
bb2116ae9f | ||
|
|
bd72135a2f | ||
|
|
ad0caa7c17 | ||
|
|
076d75effe | ||
|
|
571f659b19 | ||
|
|
10dbd07cb9 | ||
|
|
314da3e246 | ||
|
|
672de8a994 | ||
|
|
13940ca834 | ||
|
|
b5d6912b26 | ||
|
|
02d0adef78 | ||
|
|
4c502e40f8 | ||
|
|
17ee6c1f08 | ||
|
|
86e70b7256 | ||
|
|
9aea1ccb56 | ||
|
|
42a9049c0a | ||
|
|
9936275443 | ||
|
|
20c5f6f589 | ||
|
|
c099479740 | ||
|
|
ca835059c2 | ||
|
|
9548a2cd15 | ||
|
|
a218391ea5 | ||
|
|
fd59b02c3a | ||
|
|
649bd39df9 | ||
|
|
1c894f8ae6 | ||
|
|
105b8f1e34 | ||
|
|
06f85d4c54 | ||
|
|
b53c0b9849 | ||
|
|
eebc355f95 | ||
|
|
e142e5d4d7 | ||
|
|
143e81fc41 | ||
|
|
4aa63c74e2 | ||
|
|
168c877970 | ||
|
|
94f3120add | ||
|
|
a8c199b719 | ||
|
|
17eb83c760 | ||
|
|
44c335b089 | ||
|
|
87ef197823 | ||
|
|
a9e635f40e | ||
|
|
04e28b96c8 | ||
|
|
880fcb5bcf | ||
|
|
9bdc358e59 | ||
|
|
ed21730a38 |
8
.gitignore
vendored
8
.gitignore
vendored
@@ -10,9 +10,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
*.sqlite3
|
||||||
db.sqlite3-journal
|
*.sqlite3-journal
|
||||||
container.db.sqlite3
|
|
||||||
media
|
media
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
@@ -184,3 +183,6 @@ cython_debug/
|
|||||||
#.idea/
|
#.idea/
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/django
|
# End of https://www.toptal.com/developers/gitignore/api/django
|
||||||
|
|
||||||
|
# Local dev utilities (Windows-only, not part of the app)
|
||||||
|
*.ps1
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
services:
|
|
||||||
- name: postgres
|
|
||||||
image: postgres:16
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: python_tdd_test
|
|
||||||
POSTGRES_USER: postgres
|
|
||||||
POSTGRES_PASSWORD: postgres
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: test-UTs-n-ITs
|
|
||||||
image: python:3.13-slim
|
|
||||||
environment:
|
|
||||||
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
|
|
||||||
commands:
|
|
||||||
- pip install -r requirements.txt
|
|
||||||
- cd ./src
|
|
||||||
- python manage.py test apps
|
|
||||||
|
|
||||||
- name: test-FTs
|
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
|
||||||
environment:
|
|
||||||
HEADLESS: 1
|
|
||||||
commands:
|
|
||||||
- cd ./src
|
|
||||||
- python manage.py collectstatic --noinput
|
|
||||||
- python manage.py test functional_tests
|
|
||||||
|
|
||||||
- name: screendumps
|
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
|
||||||
when:
|
|
||||||
- status: failure
|
|
||||||
commands:
|
|
||||||
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
|
|
||||||
|
|
||||||
- name: build-and-push
|
|
||||||
image: docker:cli
|
|
||||||
environment:
|
|
||||||
REGISTRY_PASSWORD:
|
|
||||||
from_secret: gitea_registry_password
|
|
||||||
commands:
|
|
||||||
- echo "$REGISTRY_PASSWORD" | docker login gitea.earthmanrpg.me -u discoman --password-stdin
|
|
||||||
- docker build -t gitea.earthmanrpg.me/discoman/gamearray:latest .
|
|
||||||
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
|
|
||||||
when:
|
|
||||||
- branch: main
|
|
||||||
- event: push
|
|
||||||
|
|
||||||
- name: deploy
|
|
||||||
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:
|
|
||||||
- branch: main
|
|
||||||
- event: push
|
|
||||||
|
|
||||||
139
.woodpecker/main.yaml
Normal file
139
.woodpecker/main.yaml
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
services:
|
||||||
|
- name: postgres
|
||||||
|
image: postgres:16
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: python_tdd_test
|
||||||
|
POSTGRES_USER: postgres
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
|
- name: redis
|
||||||
|
image: redis:7
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: test-UTs-n-ITs
|
||||||
|
image: python:3.13-slim
|
||||||
|
environment:
|
||||||
|
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
REDIS_URL: redis://redis:6379/1
|
||||||
|
commands:
|
||||||
|
- pip install -r requirements.txt
|
||||||
|
- cd ./src
|
||||||
|
- python manage.py test apps
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
|
- name: test-two-browser-FTs
|
||||||
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
|
environment:
|
||||||
|
HEADLESS: 1
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
REDIS_URL: redis://redis:6379/1
|
||||||
|
STRIPE_SECRET_KEY:
|
||||||
|
from_secret: stripe_secret_key
|
||||||
|
STRIPE_PUBLISHABLE_KEY:
|
||||||
|
from_secret: stripe_publishable_key
|
||||||
|
commands:
|
||||||
|
- pip install -r requirements.txt
|
||||||
|
- cd ./src
|
||||||
|
- python manage.py collectstatic --noinput
|
||||||
|
- python manage.py test functional_tests --tag=two-browser
|
||||||
|
- python manage.py test functional_tests --tag=sequential
|
||||||
|
- python manage.py test functional_tests --tag=channels
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
|
- name: test-FTs
|
||||||
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
|
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 --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
|
||||||
|
commands:
|
||||||
|
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
status: failure
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
|
- name: build-and-push
|
||||||
|
image: docker:cli
|
||||||
|
environment:
|
||||||
|
REGISTRY_PASSWORD:
|
||||||
|
from_secret: gitea_registry_password
|
||||||
|
commands:
|
||||||
|
- echo "$REGISTRY_PASSWORD" | docker login gitea.earthmanrpg.me -u discoman --password-stdin
|
||||||
|
- docker build -t gitea.earthmanrpg.me/discoman/gamearray:latest .
|
||||||
|
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
|
when:
|
||||||
|
- branch: main
|
||||||
|
event: push
|
||||||
|
path:
|
||||||
|
- "src/**"
|
||||||
|
- "requirements.txt"
|
||||||
|
- "Dockerfile"
|
||||||
|
- ".woodpecker/main.yaml"
|
||||||
|
|
||||||
|
- name: deploy-staging
|
||||||
|
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:
|
||||||
|
- 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"
|
||||||
143
CLAUDE.md
Normal file
143
CLAUDE.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# EarthmanRPG — Project Context
|
||||||
|
|
||||||
|
Originally built following Harry Percival's *Test-Driven Development with Python* (3rd ed., complete through ch. 25). Now an ongoing game app — EarthmanRPG — extended well beyond the book.
|
||||||
|
|
||||||
|
## Browser Integration
|
||||||
|
**Claudezilla** is installed — a Firefox extension + native host for browser automation.
|
||||||
|
See `.claude/skills/claudezilla-browser/SKILL.md` for tool list, startup protocol, and setup reference.
|
||||||
|
|
||||||
|
**STARTUP RULE:** Call `mcp__claudezilla__firefox_diagnose` at the start of every conversation before any browser tool. If tools aren't listed in a session, open a new Claude Code conversation (MCP servers load at startup only).
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- **Python 3.13 / Django 6.0 / Django Channels** (ASGI via Daphne/uvicorn)
|
||||||
|
- **Celery + Redis** (async email, channel layer)
|
||||||
|
- **django-compressor + SCSS** (`src/static_src/scss/core.scss`)
|
||||||
|
- **Selenium** (functional tests) + Django test framework (integration/unit tests)
|
||||||
|
- **Stripe** (payment, sandbox only so far)
|
||||||
|
- **Hosting:** DigitalOcean staging (`staging.earthmanrpg.me`) | CI: Gitea + Woodpecker
|
||||||
|
|
||||||
|
## Project Layout
|
||||||
|
|
||||||
|
The app pairs follow a tripartite structure:
|
||||||
|
- **1st-person** (personal UX): `lyric` (backend — auth, user, tokens) · `dashboard` (frontend — notes, applets, wallet UI)
|
||||||
|
- **3rd-person** (game table UX): `epic` (backend — rooms, gates, role select, game logic) · `gameboard` (frontend — room listing, gameboard UI)
|
||||||
|
- **2nd-person** (inter-player events): `drama` (backend — activity streams, provenance) · `billboard` (frontend — provenance feed, embeddable in dashboard/gameboard)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
apps/
|
||||||
|
lyric/ # auth (magic-link email), user model, token economy
|
||||||
|
dashboard/ # Notes (formerly Lists), applets, wallet UI [1st-person frontend]
|
||||||
|
epic/ # rooms, gates, role select, game logic [3rd-person backend]
|
||||||
|
gameboard/ # room listing, gameboard UI [3rd-person frontend]
|
||||||
|
drama/ # activity streams, provenance system [2nd-person backend]
|
||||||
|
billboard/ # provenance feed, embeds in dashboard/gameboard [2nd-person frontend]
|
||||||
|
api/ # REST API
|
||||||
|
applets/ # Applet model + context helpers
|
||||||
|
core/ # settings, urls, asgi, runner
|
||||||
|
static_src/ # SCSS source
|
||||||
|
templates/
|
||||||
|
functional_tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Template directory convention
|
||||||
|
Templates live under `templates/apps/<frontend-app>/`, not under the backend app that owns the view logic. Specifically:
|
||||||
|
- `lyric/` views → `templates/apps/dashboard/`
|
||||||
|
- `epic/` views → `templates/apps/gameboard/`
|
||||||
|
- `drama/` views → `templates/apps/billboard/`
|
||||||
|
|
||||||
|
Backend apps (`lyric`, `epic`, `drama`) have **no** `templates/` subdirectory.
|
||||||
|
|
||||||
|
## Dev Commands
|
||||||
|
```bash
|
||||||
|
# Dev server (ASGI — required for WebSockets; no npm/webpack build step)
|
||||||
|
cd src
|
||||||
|
uvicorn core.asgi:application --host 0.0.0.0 --port 8000 --reload --app-dir src
|
||||||
|
|
||||||
|
# Integration + unit tests (exclude channels)
|
||||||
|
python src/manage.py test src/apps --exclude-tag=channels
|
||||||
|
|
||||||
|
# Functional tests
|
||||||
|
python src/manage.py test src/functional_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
See `.claude/skills/TDD/SKILL.md` for the full TDD cycle, test file conventions, base classes, and per-layer run commands. See `.claude/skills/dev-server/SKILL.md` for server startup options.
|
||||||
|
|
||||||
|
### Multi-user manual testing — `setup_sig_session`
|
||||||
|
|
||||||
|
Creates (or reuses) a room at `table_status=SIG_SELECT` with all 6 slots filled. Prints one pre-auth URL per gamer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python src/manage.py setup_sig_session
|
||||||
|
python src/manage.py setup_sig_session --base-url http://localhost:8000
|
||||||
|
python src/manage.py setup_sig_session --room <uuid>
|
||||||
|
```
|
||||||
|
|
||||||
|
Fixed gamers: `founder@test.io` (discoman), `amigo@test.io`, `bud@test.io`, `pal@test.io`, `dude@test.io`, `bro@test.io` — all superusers with Earthman deck. URLs use `/lyric/dev-login/<session_key>/` pre-auth pattern.
|
||||||
|
|
||||||
|
## CI/CD + Hosting
|
||||||
|
- Git remote: `git@gitea:discoman/python-tdd.git` (port 222, key `~/.ssh/gitea_keys/id_ed25519_python-tdd`)
|
||||||
|
- Gitea: `https://gitea.earthmanrpg.me` | Woodpecker CI: `https://ci.earthmanrpg.me`
|
||||||
|
- Push to `main` triggers Woodpecker → deploys to staging (`staging.earthmanrpg.me`)
|
||||||
|
- Prod deploy: `git tag v1.0.0 && git push --tags` → triggers `deploy-prod` step (tag-based gate)
|
||||||
|
- Two CI pipelines run in parallel: `.woodpecker/main.yaml` (main app) + `.woodpecker/pyswiss.yaml` (PySwiss at charts.earthmanrpg.me)
|
||||||
|
- Multi-browser FTs tagged `@tag("two-browser")` run in a dedicated CI stage (`test-two-browser-FTs`) alongside `--tag=channels`; `test-FTs` stage is parallel-only
|
||||||
|
- Hosting: DigitalOcean — main app on staging droplet; PySwiss on separate droplet (167.172.154.66)
|
||||||
|
- Email: Mailgun (`adman@howdy.earthmanrpg.me`) | DNS: NameCheap
|
||||||
|
|
||||||
|
## UI / Layout Conventions
|
||||||
|
|
||||||
|
### Sidebar layout (`$sidebar-w: 4rem`)
|
||||||
|
Navbar is a fixed left sidebar; footer is a fixed right sidebar. Both are `4rem` wide. Main container uses `margin-left: $sidebar-w; margin-right: $sidebar-w`. Landscape layout resets `min-width` to `0` on `.gameboard-page` and `#id_dash_content` (override of the `@media (min-width: 738px)` block that sets `min-width: 666px`).
|
||||||
|
|
||||||
|
### Applet headings + page titles
|
||||||
|
- Section headings: plain `<h2>` — browser default + body color inherited; no extra SCSS needed
|
||||||
|
- Clickable headings: `<h2><a href="...">Text</a></h2>` — global `body a` rule supplies gold + hover glow
|
||||||
|
- Page titles: `<span>Dash</span>suffix` pattern (Dashwallet, Dashnote, Dashnotes)
|
||||||
|
|
||||||
|
### Position vs Seat terminology
|
||||||
|
Circles around the table hex are **positions** (gate slot order, 1–6). After role assignment they become **seats** (PC→NC→EC→SC→AC→BC). CSS carries both: `.table-seat.table-position`. `SLOT_ROLE_LABELS = {1:"PC", 2:"NC", 3:"EC", 4:"SC", 5:"AC", 6:"BC"}` in `epic/views.py`.
|
||||||
|
|
||||||
|
## Game Architecture
|
||||||
|
|
||||||
|
### Token priority chain
|
||||||
|
`select_token(user)` in `apps.epic.models`: **PASS → COIN → FREE → TITHE → None**. `debit_token` handles each type's consumption rules (Coin cooldown, Free/Tithe expiry).
|
||||||
|
|
||||||
|
### Two-step gate token flow
|
||||||
|
Drop → RESERVED → confirm/reject. `_gate_context()` builds slot state; `_expire_reserved_slots()` clears stale reservations after 60s. Views: `confirm_token`, `reject_token` (renamed `return_token`).
|
||||||
|
|
||||||
|
### Room URL routing
|
||||||
|
`epic:room` view at `/gameboard/room/<uuid>/`. `gatekeeper` redirects there when `table_status` is set. Error redirects in `select_role`/`select_sig` use `epic:room` if `table_status` is set, else `epic:gatekeeper`.
|
||||||
|
|
||||||
|
## SCSS Import Order
|
||||||
|
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → card-deck → natus → tray → billboard → tooltips → game-kit → wallet-tokens`
|
||||||
|
|
||||||
|
## Critical Gotchas
|
||||||
|
|
||||||
|
### Tooltip portal pattern
|
||||||
|
`mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`.
|
||||||
|
|
||||||
|
### Applet menus + container-type
|
||||||
|
`container-type: inline-size` creates a containing block for all positioned descendants — applet menus must live OUTSIDE `#id_applets_container` to be viewport-fixed.
|
||||||
|
|
||||||
|
### ABU session auth
|
||||||
|
`create_pre_authenticated_session` must set `HASH_SESSION_KEY = user.get_session_auth_hash()` and hardcode `BACKEND_SESSION_KEY` to the passwordless backend string.
|
||||||
|
|
||||||
|
### Magic login email mock paths
|
||||||
|
- View tests: `apps.lyric.views.send_login_email_task.delay`
|
||||||
|
- Task unit tests: `apps.lyric.tasks.requests.post`
|
||||||
|
- FTs: mock both with `side_effect=send_login_email_task`
|
||||||
|
|
||||||
|
### game-kit.js selection persistence
|
||||||
|
`window._kitTokenId` must NOT be cleared on kit-bag close — users close the dialog before clicking the rails button. Selection persists until page navigation. No `clearSelection()` in `game-kit.js`.
|
||||||
|
|
||||||
|
### Billboard timezone cookie
|
||||||
|
`document.cookie = 'user_tz=' + Intl.DateTimeFormat().resolvedOptions().timeZone` — **no `encodeURIComponent`**. Slashes in TZ names (`America/New_York`) are cookie-safe; encoding breaks the `ZoneInfo` lookup in `TimezoneMiddleware`.
|
||||||
|
|
||||||
|
### CSS `:has()` for child-dependent styling
|
||||||
|
Use `.parent:has(.child-class)` to style a parent based on its contents without template changes. Example: `.gate-slot:has(.drop-token-btn)` makes CARTE OK-button circles match `.reserved` circles.
|
||||||
|
|
||||||
|
### Plausible FT noise
|
||||||
|
Plausible analytics script in `base.html` fires a beacon during Selenium tests → harmless console error. Fix: `{% if not debug %}` guard around the script tag.
|
||||||
|
|
||||||
|
See `.claude/skills/TDD/SKILL.md` for test-specific gotchas (TransactionTestCase flush, static files in tests, Selenium text-transform, multi-browser CI, msgpack integer keys).
|
||||||
@@ -15,7 +15,9 @@ RUN python manage.py collectstatic --noinput
|
|||||||
|
|
||||||
ENV DJANGO_DEBUG_FALSE=1
|
ENV DJANGO_DEBUG_FALSE=1
|
||||||
|
|
||||||
|
RUN DJANGO_SECRET_KEY=build-dummy DJANGO_ALLOWED_HOST=localhost python manage.py compress
|
||||||
|
|
||||||
RUN adduser --uid 1234 nonroot
|
RUN adduser --uid 1234 nonroot
|
||||||
|
|
||||||
USER nonroot
|
USER nonroot
|
||||||
CMD ["gunicorn", "--bind", ":8888", "core.wsgi:application"]
|
CMD ["gunicorn", "--bind", ":8888", "-k", "uvicorn.workers.UvicornWorker", "core.asgi:application"]
|
||||||
@@ -114,22 +114,58 @@
|
|||||||
POSTGRES_USER: gamearray
|
POSTGRES_USER: gamearray
|
||||||
POSTGRES_PASSWORD: "{{ postgres_password }}"
|
POSTGRES_PASSWORD: "{{ postgres_password }}"
|
||||||
|
|
||||||
|
- name: Start Redis container
|
||||||
|
community.docker.docker_container:
|
||||||
|
name: gamearray_redis
|
||||||
|
image: redis:7
|
||||||
|
state: started
|
||||||
|
restart_policy: unless-stopped
|
||||||
|
networks:
|
||||||
|
- name: gamearray_net
|
||||||
|
|
||||||
- name: Run container
|
- name: Run container
|
||||||
community.docker.docker_container:
|
community.docker.docker_container:
|
||||||
name: gamearray
|
name: gamearray
|
||||||
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
state: started
|
state: started
|
||||||
recreate: true
|
recreate: true
|
||||||
|
restart_policy: unless-stopped
|
||||||
|
env:
|
||||||
|
DJANGO_DEBUG_FALSE: "1"
|
||||||
|
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||||
|
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
||||||
|
DJANGO_SUPERUSER_EMAIL: "{{ django_superuser_email }}"
|
||||||
|
DJANGO_SUPERUSER_PASSWORD: "{{ django_superuser_password }}"
|
||||||
|
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
|
||||||
|
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:
|
||||||
|
127.0.0.1:8888:8888
|
||||||
|
|
||||||
|
- name: Start Celery worker container
|
||||||
|
community.docker.docker_container:
|
||||||
|
name: gamearray_celery
|
||||||
|
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
|
state: started
|
||||||
|
recreate: true
|
||||||
|
restart_policy: unless-stopped
|
||||||
env:
|
env:
|
||||||
DJANGO_DEBUG_FALSE: "1"
|
DJANGO_DEBUG_FALSE: "1"
|
||||||
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||||
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
||||||
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
|
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
|
||||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
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:
|
networks:
|
||||||
- name: gamearray_net
|
- name: gamearray_net
|
||||||
ports:
|
command: "python -m celery -A core worker -l info"
|
||||||
127.0.0.1:8888:8888
|
|
||||||
|
|
||||||
- name: Create static files directory
|
- name: Create static files directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
@@ -149,6 +185,11 @@
|
|||||||
container: gamearray
|
container: gamearray
|
||||||
command: python manage.py migrate
|
command: python manage.py migrate
|
||||||
|
|
||||||
|
- name: Ensure superuser exists
|
||||||
|
community.docker.docker_container_exec:
|
||||||
|
container: gamearray
|
||||||
|
command: python manage.py ensure_superuser
|
||||||
|
|
||||||
handlers:
|
handlers:
|
||||||
- name: Restart nginx
|
- name: Restart nginx
|
||||||
ansible.builtin.service:
|
ansible.builtin.service:
|
||||||
|
|||||||
@@ -12,14 +12,29 @@ docker rm gamearray 2>/dev/null || true
|
|||||||
|
|
||||||
echo "==> Starting new container..."
|
echo "==> Starting new container..."
|
||||||
docker run -d --name gamearray \
|
docker run -d --name gamearray \
|
||||||
|
--restart unless-stopped \
|
||||||
--env-file /opt/gamearray/gamearray.env \
|
--env-file /opt/gamearray/gamearray.env \
|
||||||
--network gamearray_net \
|
--network gamearray_net \
|
||||||
-p 127.0.0.1:8888:8888 \
|
-p 127.0.0.1:8888:8888 \
|
||||||
"$IMAGE"
|
"$IMAGE"
|
||||||
|
|
||||||
|
echo "==> Stopping old celery worker..."
|
||||||
|
docker stop gamearray_celery 2>/dev/null || true
|
||||||
|
docker rm gamearray_celery 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> Starting new celery worker..."
|
||||||
|
docker run -d --name gamearray_celery \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--env-file /opt/gamearray/gamearray.env \
|
||||||
|
--network gamearray_net \
|
||||||
|
"$IMAGE" python -m celery -A core worker -l info
|
||||||
|
|
||||||
echo "==> Running migrations..."
|
echo "==> Running migrations..."
|
||||||
docker exec gamearray python ./manage.py migrate
|
docker exec gamearray python ./manage.py migrate
|
||||||
|
|
||||||
|
echo "==> Ensuring superuser exists..."
|
||||||
|
docker exec gamearray python manage.py ensure_superuser
|
||||||
|
|
||||||
echo "==> Copying static files..."
|
echo "==> Copying static files..."
|
||||||
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
DJANGO_DEBUG_FALSE=1
|
DJANGO_DEBUG_FALSE=1
|
||||||
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
|
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
|
||||||
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
|
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
|
||||||
|
DJANGO_SUPERUSER_EMAIL={{ django_superuser_email }}
|
||||||
|
DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }}
|
||||||
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
|
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
|
||||||
MAILGUN_API_KEY={{ mailgun_api_key }}
|
MAILGUN_API_KEY={{ mailgun_api_key }}
|
||||||
|
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,23 +1,44 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
33616230376431343735626631623932393166343538653732383533323436326335343463646664
|
33643937613637343765356165333337356138326236356334363238366632633935363563383232
|
||||||
6565373531623465613661613533376231373837326438300a393665613839646231633737313938
|
6263396663316461353035393836313535353133336132650a643062656239633635373930366131
|
||||||
64633035336663313163333634623732323537326363646132313136376131636666636538323066
|
63363566666263336337356161663231343266383333613261666534653438666661303761653063
|
||||||
3037373930303537320a313062646166353862633836373466316261363939633433663039323866
|
6163333239313430620a613665393231356535666530613731303536613537333464613533616663
|
||||||
62333739303662343836306538393734343830366336323265393138343438363533353166383031
|
30373935366138643939316563346364376333646333396264653537643666393835353964303031
|
||||||
32313461313137643039376237346633316466646136353038633861333031663164656233366634
|
30366366666163383263663961383037386264393939306235646532636439383838343237303339
|
||||||
38303363383130376264373861393863623330623733643135643461383132613339376633353031
|
62333965323763323233303239343132383830303130306265333330333434663337363930653161
|
||||||
32313863323039646534633733383661333361313832333830383066633130396239626661643264
|
30646133333530333330653365306437313839636535333163346263343064376436633432623061
|
||||||
65636335303339613432326533343337366261356632313639623634386633383836333733663536
|
39343332643836333932316439636166333831393864363434663837646339666638353835393964
|
||||||
39383361353530646166643531333535356636326535383534326237666638326137616162646261
|
61363430303637633239373031396535383730623862386464316633393361306561613933353830
|
||||||
65316466323335653932636338653565383038313531383638393839313736643739363037353230
|
66313835306563643733366135353062623635663165303833373563663063323731313162323133
|
||||||
35653632353531656435396663316537333133653632366437613339303033333536643937353166
|
61373837353732656266336461663165626435383234336461343365396561623037353566356339
|
||||||
64363037653733303332643931343362303261643432366531326262383465313965633064356338
|
32366336396638626166616362613230323933666565613561393431393035376465343739333739
|
||||||
31336333373665373035656533633864316139303934623030383934393434356334643962666163
|
36313934313636386465306435353132373364653562666162613033373130623430656632396635
|
||||||
33343739366336613263333764306365333566363536616662383733616237396563346132336633
|
39373437353838313734636166323336376534373765623332356638666234376464383033326433
|
||||||
38663239613339376335386233386330396634323033343332366130616162666339393861306336
|
33636336376231313062643237636534363838326264333930383635373761346532393664363038
|
||||||
35383566383831356530633130313732356331616164646132626665646235396635386237313538
|
34633334653464313430363735666435373535363465343134333636303536303265333931343138
|
||||||
38656631336261646530303761643334303937613036363766303637376262373466316431323731
|
35633864623930386661316264383865373930316233653238323437363836643236333236336537
|
||||||
38666462313639353131303134646434646135366136343361353932326165626666306361393431
|
37353565313434383733333861626566623363316335666230373435633163356566616366663339
|
||||||
62646238323265346263386363373462313766616333326366366461346436383064336535376339
|
64323533366265396164303937323036323037383637643332326361363864333334653232376134
|
||||||
31356566356336386262393831616631666233633930393263623563386265343237323133313832
|
33346366343865336437383138396639393238353633343562356435306537633830303361333730
|
||||||
3430363635363332303963316530663765613666306233376463
|
30386133396565613539653931663961303534613566626265376135386461383162396334393733
|
||||||
|
39343466336136643565656332336562643933383330343830633264396436383065373032646664
|
||||||
|
34643939613962653137303238663535633565363961336263316631313737663036336331663133
|
||||||
|
61323538376434396432633565613135376163636233373832366461353665633266373435396436
|
||||||
|
30376539366264306661353863313165323839646536393466623838393862396530326466363936
|
||||||
|
36373865316165393665353737643561663863353630373333313936653163386136623831396637
|
||||||
|
36306236626337303561376366376639613337396136313336383131303634623364316234376432
|
||||||
|
65383362346363336639366665333436346234383566643937643130363261656662653763313639
|
||||||
|
66396162356234343163633633376639623736643066643030626232633634616261303530623032
|
||||||
|
38393032643963386133393534616133396135303531333839643063613331643334323762653933
|
||||||
|
39646234366564333935366335363964666337383264333263326561636231303164356532323163
|
||||||
|
63323430363337353339353739363638366136326231666335343830363838663366613432303735
|
||||||
|
34323431343336643566346365333062363862646138396535633036653737643462323235326265
|
||||||
|
39306336396238653063353939613966323466306335346635353964613535313961353263303235
|
||||||
|
35646330366534386330333135316437313435376331343630643330323030626432343034323861
|
||||||
|
39363437333137386137323036333336613238613530316338343930616137666261383733653432
|
||||||
|
63316266323664396335363334663465636262663366346139383535626236653765323038343366
|
||||||
|
64386639373536306638323036386364373465313037393431663965646633613838303566663139
|
||||||
|
31663162313166636262313663363061666531636432366536343063336439636465663032356563
|
||||||
|
30656562336565303237663332303230306637353465616136346233636464616666383734303938
|
||||||
|
32666466366363346232653461333263366164313130336331326339366361326139636635646630
|
||||||
|
376264626331393262653961663566383866
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[staging]
|
[staging]
|
||||||
staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd letsencrypt_domain=staging.earthmanrpg.me
|
||||||
|
|
||||||
[production]
|
[production]
|
||||||
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name {{ django_allowed_host | replace(',', ' ')}};
|
server_name {{ django_allowed_host | replace(',', ' ')}};
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name {{ django_allowed_host | replace(',', ' ') }};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{{ letsencrypt_domain }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{{ letsencrypt_domain }}/privkey.pem;
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /var/www/gamearray/static/;
|
alias /var/www/gamearray/static/;
|
||||||
@@ -8,9 +17,12 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8888;
|
proxy_pass http://127.0.0.1:8888;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
0
pyswiss/apps/__init__.py
Normal file
0
pyswiss/apps/__init__.py
Normal file
0
pyswiss/apps/charts/__init__.py
Normal file
0
pyswiss/apps/charts/__init__.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
6
pyswiss/apps/charts/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ChartsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.charts'
|
||||||
178
pyswiss/apps/charts/calc.py
Normal file
178
pyswiss/apps/charts/calc.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
"""
|
||||||
|
Core ephemeris calculation logic — shared by views and management commands.
|
||||||
|
"""
|
||||||
|
from django.conf import settings as django_settings
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_HOUSE_SYSTEM = 'O' # Porphyry
|
||||||
|
|
||||||
|
SIGNS = [
|
||||||
|
'Aries', 'Taurus', 'Gemini', 'Cancer', 'Leo', 'Virgo',
|
||||||
|
'Libra', 'Scorpio', 'Sagittarius', 'Capricorn', 'Aquarius', 'Pisces',
|
||||||
|
]
|
||||||
|
|
||||||
|
SIGN_ELEMENT = {
|
||||||
|
'Aries': 'Fire', 'Leo': 'Fire', 'Sagittarius': 'Fire',
|
||||||
|
'Taurus': 'Earth', 'Virgo': 'Earth', 'Capricorn': 'Earth',
|
||||||
|
'Gemini': 'Air', 'Libra': 'Air', 'Aquarius': 'Air',
|
||||||
|
'Cancer': 'Water', 'Scorpio': 'Water', 'Pisces': 'Water',
|
||||||
|
}
|
||||||
|
|
||||||
|
ASPECTS = [
|
||||||
|
('Conjunction', 0, 8.0),
|
||||||
|
('Semisextile', 30, 4.0),
|
||||||
|
('Sextile', 60, 6.0),
|
||||||
|
('Square', 90, 8.0),
|
||||||
|
('Trine', 120, 8.0),
|
||||||
|
('Quincunx', 150, 5.0),
|
||||||
|
('Opposition', 180, 10.0),
|
||||||
|
# ('Semisquare', 45, 4.0),
|
||||||
|
# ('Sesquiquadrate', 135, 4.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
PLANET_CODES = {
|
||||||
|
'Sun': swe.SUN,
|
||||||
|
'Moon': swe.MOON,
|
||||||
|
'Mercury': swe.MERCURY,
|
||||||
|
'Venus': swe.VENUS,
|
||||||
|
'Mars': swe.MARS,
|
||||||
|
'Jupiter': swe.JUPITER,
|
||||||
|
'Saturn': swe.SATURN,
|
||||||
|
'Uranus': swe.URANUS,
|
||||||
|
'Neptune': swe.NEPTUNE,
|
||||||
|
'Pluto': swe.PLUTO,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_ephe_path():
|
||||||
|
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
|
||||||
|
if ephe_path:
|
||||||
|
swe.set_ephe_path(ephe_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sign(lon):
|
||||||
|
return SIGNS[int(lon // 30) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def get_julian_day(dt):
|
||||||
|
return swe.julday(
|
||||||
|
dt.year, dt.month, dt.day,
|
||||||
|
dt.hour + dt.minute / 60 + dt.second / 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_planet_positions(jd):
|
||||||
|
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
|
||||||
|
planets = {}
|
||||||
|
for name, code in PLANET_CODES.items():
|
||||||
|
pos, _ = swe.calc_ut(jd, code, flag)
|
||||||
|
degree = pos[0]
|
||||||
|
planets[name] = {
|
||||||
|
'sign': get_sign(degree),
|
||||||
|
'degree': degree,
|
||||||
|
'speed': pos[3],
|
||||||
|
'retrograde': pos[3] < 0,
|
||||||
|
}
|
||||||
|
return planets
|
||||||
|
|
||||||
|
|
||||||
|
def get_element_counts(planets):
|
||||||
|
sign_counts = {s: 0 for s in SIGNS}
|
||||||
|
sign_planets = {s: [] for s in SIGNS}
|
||||||
|
classic = {'Fire': [], 'Water': [], 'Earth': [], 'Air': []}
|
||||||
|
|
||||||
|
for name, data in planets.items():
|
||||||
|
sign = data['sign']
|
||||||
|
el = SIGN_ELEMENT[sign]
|
||||||
|
classic[el].append({'planet': name, 'sign': sign})
|
||||||
|
sign_counts[sign] += 1
|
||||||
|
sign_planets[sign].append({'planet': name, 'sign': sign})
|
||||||
|
|
||||||
|
result = {
|
||||||
|
el: {'count': len(contribs), 'contributors': contribs}
|
||||||
|
for el, contribs in classic.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
# Time: stellium — highest concentration in one sign, bonus = size - 1.
|
||||||
|
# Collect all signs tied at the maximum.
|
||||||
|
max_in_sign = max(sign_counts.values())
|
||||||
|
stellia = [
|
||||||
|
{'sign': s, 'planets': sign_planets[s]}
|
||||||
|
for s in SIGNS
|
||||||
|
if sign_counts[s] == max_in_sign and max_in_sign > 1
|
||||||
|
]
|
||||||
|
result['Time'] = {
|
||||||
|
'count': max_in_sign - 1,
|
||||||
|
'stellia': stellia,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Space: parade — longest consecutive run of occupied signs (circular),
|
||||||
|
# bonus = run length - 1. Collect all runs tied at the maximum.
|
||||||
|
index_set = {i for i, s in enumerate(SIGNS) if sign_counts[s] > 0}
|
||||||
|
indices = sorted(index_set)
|
||||||
|
max_seq = 0
|
||||||
|
for start in range(len(indices)):
|
||||||
|
seq_len = 1
|
||||||
|
for offset in range(1, len(indices)):
|
||||||
|
if (indices[start] + offset) % len(SIGNS) in index_set:
|
||||||
|
seq_len += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
max_seq = max(max_seq, seq_len)
|
||||||
|
|
||||||
|
parades = []
|
||||||
|
for start in range(len(indices)):
|
||||||
|
run = []
|
||||||
|
for offset in range(max_seq):
|
||||||
|
idx = (indices[start] + offset) % len(SIGNS)
|
||||||
|
if idx not in index_set:
|
||||||
|
break
|
||||||
|
run.append(idx)
|
||||||
|
else:
|
||||||
|
sign_run = [SIGNS[i] for i in run]
|
||||||
|
parade_planets = [
|
||||||
|
p for s in sign_run for p in sign_planets[s]
|
||||||
|
]
|
||||||
|
parades.append({'signs': sign_run, 'planets': parade_planets})
|
||||||
|
|
||||||
|
result['Space'] = {
|
||||||
|
'count': max_seq - 1,
|
||||||
|
'parades': parades,
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_aspects(planets):
|
||||||
|
"""Return a list of aspects between all planet pairs.
|
||||||
|
|
||||||
|
Each entry: {planet1, planet2, type, angle (actual, rounded), orb (rounded)}.
|
||||||
|
Only the first matching aspect type is reported per pair (aspects are
|
||||||
|
well-separated enough that at most one can apply with standard orbs).
|
||||||
|
"""
|
||||||
|
names = list(planets.keys())
|
||||||
|
aspects = []
|
||||||
|
for i, name1 in enumerate(names):
|
||||||
|
for name2 in names[i + 1:]:
|
||||||
|
deg1 = planets[name1]['degree']
|
||||||
|
deg2 = planets[name2]['degree']
|
||||||
|
angle = abs(deg1 - deg2)
|
||||||
|
if angle > 180:
|
||||||
|
angle = 360 - angle
|
||||||
|
for aspect_name, target, max_orb in ASPECTS:
|
||||||
|
orb = abs(angle - target)
|
||||||
|
if orb <= max_orb:
|
||||||
|
s1 = abs(planets[name1].get('speed', 0))
|
||||||
|
s2 = abs(planets[name2].get('speed', 0))
|
||||||
|
applying = name1 if s1 >= s2 else name2
|
||||||
|
aspects.append({
|
||||||
|
'planet1': name1,
|
||||||
|
'planet2': name2,
|
||||||
|
'type': aspect_name,
|
||||||
|
'angle': round(angle, 2),
|
||||||
|
'orb': round(orb, 2),
|
||||||
|
'applying_planet': applying,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
return aspects
|
||||||
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
0
pyswiss/apps/charts/management/commands/__init__.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
from datetime import date, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
|
||||||
|
from apps.charts.calc import get_element_counts, get_julian_day, get_planet_positions, set_ephe_path
|
||||||
|
from apps.charts.models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = 'Pre-compute ephemeris snapshots for a date range (one per day at noon UTC).'
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument('--date-from', required=True, help='Start date (YYYY-MM-DD)')
|
||||||
|
parser.add_argument('--date-to', required=True, help='End date (YYYY-MM-DD, inclusive)')
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
set_ephe_path()
|
||||||
|
|
||||||
|
date_from = date.fromisoformat(options['date_from'])
|
||||||
|
date_to = date.fromisoformat(options['date_to'])
|
||||||
|
|
||||||
|
current = date_from
|
||||||
|
count = 0
|
||||||
|
while current <= date_to:
|
||||||
|
dt = datetime(current.year, current.month, current.day,
|
||||||
|
12, 0, 0, tzinfo=timezone.utc)
|
||||||
|
jd = get_julian_day(dt)
|
||||||
|
planets = get_planet_positions(jd)
|
||||||
|
elements = get_element_counts(planets)
|
||||||
|
|
||||||
|
EphemerisSnapshot.objects.update_or_create(
|
||||||
|
dt=dt,
|
||||||
|
defaults={
|
||||||
|
'fire': elements['Fire']['count'],
|
||||||
|
'water': elements['Water']['count'],
|
||||||
|
'earth': elements['Earth']['count'],
|
||||||
|
'air': elements['Air']['count'],
|
||||||
|
'time_el': elements['Time']['count'],
|
||||||
|
'space_el': elements['Space']['count'],
|
||||||
|
'chart_data': {'planets': planets},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
current += timedelta(days=1)
|
||||||
|
count += 1
|
||||||
|
|
||||||
|
if options['verbosity'] > 0:
|
||||||
|
self.stdout.write(
|
||||||
|
self.style.SUCCESS(f'Created/updated {count} snapshot(s).')
|
||||||
|
)
|
||||||
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
31
pyswiss/apps/charts/migrations/0001_initial.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0.4 on 2026-04-13 20:40
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='EphemerisSnapshot',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('dt', models.DateTimeField(db_index=True, unique=True)),
|
||||||
|
('fire', models.PositiveSmallIntegerField()),
|
||||||
|
('water', models.PositiveSmallIntegerField()),
|
||||||
|
('earth', models.PositiveSmallIntegerField()),
|
||||||
|
('air', models.PositiveSmallIntegerField()),
|
||||||
|
('time_el', models.PositiveSmallIntegerField()),
|
||||||
|
('space_el', models.PositiveSmallIntegerField()),
|
||||||
|
('chart_data', models.JSONField()),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['dt'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
0
pyswiss/apps/charts/migrations/__init__.py
Normal file
36
pyswiss/apps/charts/models.py
Normal file
36
pyswiss/apps/charts/models.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class EphemerisSnapshot(models.Model):
|
||||||
|
"""Pre-computed chart data for a single point in time.
|
||||||
|
|
||||||
|
Element counts are stored as denormalised columns for fast DB-level range
|
||||||
|
filtering. Full planet/house data lives in chart_data (JSONField) for
|
||||||
|
response serialisation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
dt = models.DateTimeField(unique=True, db_index=True)
|
||||||
|
|
||||||
|
# Denormalised element counts — indexed for range queries
|
||||||
|
fire = models.PositiveSmallIntegerField()
|
||||||
|
water = models.PositiveSmallIntegerField()
|
||||||
|
earth = models.PositiveSmallIntegerField()
|
||||||
|
air = models.PositiveSmallIntegerField()
|
||||||
|
time_el = models.PositiveSmallIntegerField()
|
||||||
|
space_el = models.PositiveSmallIntegerField()
|
||||||
|
|
||||||
|
# Full chart payload
|
||||||
|
chart_data = models.JSONField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['dt']
|
||||||
|
|
||||||
|
def elements_dict(self):
|
||||||
|
return {
|
||||||
|
'Fire': self.fire,
|
||||||
|
'Water': self.water,
|
||||||
|
'Earth': self.earth,
|
||||||
|
'Air': self.air,
|
||||||
|
'Time': self.time_el,
|
||||||
|
'Space': self.space_el,
|
||||||
|
}
|
||||||
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
0
pyswiss/apps/charts/tests/integrated/__init__.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
159
pyswiss/apps/charts/tests/integrated/test_charts_list.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for GET /api/charts/ — ephemeris range/filter queries.
|
||||||
|
|
||||||
|
These tests drive the EphemerisSnapshot model and list view.
|
||||||
|
Snapshots are created directly in setUp — no live ephemeris calc needed.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.charts.models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
CHART_DATA_STUB = {
|
||||||
|
'planets': {
|
||||||
|
'Sun': {'sign': 'Capricorn', 'degree': 280.37, 'retrograde': False},
|
||||||
|
'Moon': {'sign': 'Aries', 'degree': 15.2, 'retrograde': False},
|
||||||
|
'Mercury': {'sign': 'Capricorn', 'degree': 275.1, 'retrograde': False},
|
||||||
|
'Venus': {'sign': 'Sagittarius','degree': 250.3, 'retrograde': False},
|
||||||
|
'Mars': {'sign': 'Aquarius', 'degree': 308.6, 'retrograde': False},
|
||||||
|
'Jupiter': {'sign': 'Aries', 'degree': 25.9, 'retrograde': False},
|
||||||
|
'Saturn': {'sign': 'Taurus', 'degree': 40.5, 'retrograde': False},
|
||||||
|
'Uranus': {'sign': 'Aquarius', 'degree': 314.2, 'retrograde': False},
|
||||||
|
'Neptune': {'sign': 'Capricorn', 'degree': 303.8, 'retrograde': False},
|
||||||
|
'Pluto': {'sign': 'Sagittarius','degree': 248.4, 'retrograde': False},
|
||||||
|
},
|
||||||
|
'houses': {'cusps': [0]*12, 'asc': 180.0, 'mc': 90.0},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def make_snapshot(dt_str, fire=2, water=2, earth=3, air=2, time_el=1, space_el=3,
|
||||||
|
chart_data=None):
|
||||||
|
return EphemerisSnapshot.objects.create(
|
||||||
|
dt=dt_str,
|
||||||
|
fire=fire, water=water, earth=earth, air=air,
|
||||||
|
time_el=time_el, space_el=space_el,
|
||||||
|
chart_data=chart_data or CHART_DATA_STUB,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ChartsListApiTest(TestCase):
|
||||||
|
"""GET /api/charts/ — query pre-computed ephemeris snapshots."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
make_snapshot('2000-01-01T12:00:00Z', fire=3, water=2, earth=3, air=2)
|
||||||
|
make_snapshot('2000-01-02T12:00:00Z', fire=1, water=4, earth=3, air=2)
|
||||||
|
make_snapshot('2000-01-03T12:00:00Z', fire=2, water=2, earth=4, air=2)
|
||||||
|
# Outside the usual date range — should not appear in filtered results
|
||||||
|
make_snapshot('2001-06-15T12:00:00Z', fire=4, water=1, earth=3, air=2)
|
||||||
|
|
||||||
|
def _get(self, params=None):
|
||||||
|
return self.client.get('/api/charts/', params or {})
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_returns_400_if_date_from_missing(self):
|
||||||
|
response = self._get({'date_to': '2000-01-31'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_charts_returns_400_if_date_to_missing(self):
|
||||||
|
response = self._get({'date_from': '2000-01-01'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_charts_returns_400_for_invalid_date_from(self):
|
||||||
|
response = self._get({'date_from': 'not-a-date', 'date_to': '2000-01-31'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_charts_returns_400_if_date_to_before_date_from(self):
|
||||||
|
response = self._get({'date_from': '2000-01-31', 'date_to': '2000-01-01'})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── response shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_returns_200_for_valid_params(self):
|
||||||
|
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_charts_response_is_json(self):
|
||||||
|
response = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'})
|
||||||
|
self.assertIn('application/json', response['Content-Type'])
|
||||||
|
|
||||||
|
def test_charts_response_has_results_and_count(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
self.assertIn('results', data)
|
||||||
|
self.assertIn('count', data)
|
||||||
|
|
||||||
|
def test_each_result_has_dt_and_elements(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
for result in data['results']:
|
||||||
|
with self.subTest(dt=result.get('dt')):
|
||||||
|
self.assertIn('dt', result)
|
||||||
|
self.assertIn('elements', result)
|
||||||
|
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||||
|
self.assertIn(key, result['elements'])
|
||||||
|
|
||||||
|
def test_each_result_has_planets(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
for result in data['results']:
|
||||||
|
with self.subTest(dt=result.get('dt')):
|
||||||
|
self.assertIn('planets', result)
|
||||||
|
|
||||||
|
# ── date range filtering ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_returns_only_snapshots_in_date_range(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
self.assertEqual(data['count'], 3)
|
||||||
|
|
||||||
|
def test_charts_count_matches_results_length(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-12-31'}).json()
|
||||||
|
self.assertEqual(data['count'], len(data['results']))
|
||||||
|
|
||||||
|
def test_charts_date_range_is_inclusive(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-01'}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_results_ordered_by_dt(self):
|
||||||
|
data = self._get({'date_from': '2000-01-01', 'date_to': '2000-01-31'}).json()
|
||||||
|
dts = [r['dt'] for r in data['results']]
|
||||||
|
self.assertEqual(dts, sorted(dts))
|
||||||
|
|
||||||
|
# ── element range filtering ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_charts_filters_by_fire_min(self):
|
||||||
|
# Only the Jan 1 snapshot has fire=3; Jan 2 has fire=1, Jan 3 has fire=2
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'fire_min': 3,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_filters_by_water_min(self):
|
||||||
|
# Only the Jan 2 snapshot has water=4
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'water_min': 4,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_filters_by_earth_min(self):
|
||||||
|
# Jan 3 has earth=4; Jan 1 and Jan 2 have earth=3
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31', 'earth_min': 4,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 1)
|
||||||
|
|
||||||
|
def test_charts_multiple_element_filters_are_conjunctive(self):
|
||||||
|
# fire>=2 AND water>=2: Jan 1 (fire=3,water=2) + Jan 3 (fire=2,water=2); not Jan 2 (fire=1)
|
||||||
|
data = self._get({
|
||||||
|
'date_from': '2000-01-01', 'date_to': '2000-01-31',
|
||||||
|
'fire_min': 2, 'water_min': 2,
|
||||||
|
}).json()
|
||||||
|
self.assertEqual(data['count'], 2)
|
||||||
247
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
247
pyswiss/apps/charts/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""
|
||||||
|
Integration tests for the PySwiss chart calculation API.
|
||||||
|
|
||||||
|
These tests drive the TDD implementation of GET /api/chart/ and GET /api/tz/.
|
||||||
|
They verify the HTTP contract using Django's test client.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# J2000.0 — a well-known reference point: Sun at ~280.37° (Capricorn 10°22')
|
||||||
|
J2000 = '2000-01-01T12:00:00Z'
|
||||||
|
LONDON = {'lat': 51.5074, 'lon': -0.1278}
|
||||||
|
|
||||||
|
# Well-known coordinates with unambiguous timezone results
|
||||||
|
NEW_YORK = {'lat': 40.7128, 'lon': -74.0060} # America/New_York
|
||||||
|
TOKYO = {'lat': 35.6762, 'lon': 139.6503} # Asia/Tokyo
|
||||||
|
REYKJAVIK = {'lat': 64.1355, 'lon': -21.8954} # Atlantic/Reykjavik
|
||||||
|
|
||||||
|
|
||||||
|
class ChartApiTest(TestCase):
|
||||||
|
"""GET /api/chart/ — calculate a natal chart from datetime + coordinates."""
|
||||||
|
|
||||||
|
def _get(self, params):
|
||||||
|
return self.client.get('/api/chart/', params)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_returns_400_if_dt_missing(self):
|
||||||
|
response = self._get({'lat': 51.5074, 'lon': -0.1278})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_if_lat_missing(self):
|
||||||
|
response = self._get({'dt': J2000, 'lon': -0.1278})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_if_lon_missing(self):
|
||||||
|
response = self._get({'dt': J2000, 'lat': 51.5074})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_for_invalid_dt_format(self):
|
||||||
|
response = self._get({'dt': 'not-a-date', **LONDON})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_chart_returns_400_for_out_of_range_lat(self):
|
||||||
|
response = self._get({'dt': J2000, 'lat': 999, 'lon': -0.1278})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── response shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_returns_200_for_valid_params(self):
|
||||||
|
response = self._get({'dt': J2000, **LONDON})
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_chart_response_is_json(self):
|
||||||
|
response = self._get({'dt': J2000, **LONDON})
|
||||||
|
self.assertIn('application/json', response['Content-Type'])
|
||||||
|
|
||||||
|
def test_chart_returns_all_ten_planets(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
expected = {
|
||||||
|
'Sun', 'Moon', 'Mercury', 'Venus', 'Mars',
|
||||||
|
'Jupiter', 'Saturn', 'Uranus', 'Neptune', 'Pluto',
|
||||||
|
}
|
||||||
|
self.assertEqual(set(data['planets'].keys()), expected)
|
||||||
|
|
||||||
|
def test_each_planet_has_sign_degree_and_retrograde(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for name, planet in data['planets'].items():
|
||||||
|
with self.subTest(planet=name):
|
||||||
|
self.assertIn('sign', planet)
|
||||||
|
self.assertIn('degree', planet)
|
||||||
|
self.assertIn('retrograde', planet)
|
||||||
|
|
||||||
|
def test_chart_returns_houses(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
houses = data['houses']
|
||||||
|
self.assertEqual(len(houses['cusps']), 12)
|
||||||
|
self.assertIn('asc', houses)
|
||||||
|
self.assertIn('mc', houses)
|
||||||
|
|
||||||
|
def test_chart_returns_six_element_counts(self):
|
||||||
|
"""Fire/Water/Earth/Air are sign-based counts; Time/Space are emergent."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||||
|
with self.subTest(element=key):
|
||||||
|
self.assertIn(key, data['elements'])
|
||||||
|
|
||||||
|
def test_chart_reports_active_house_system(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertIn('house_system', data)
|
||||||
|
|
||||||
|
# ── calculation correctness ───────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sun_is_in_capricorn_at_j2000(self):
|
||||||
|
"""Regression: Sun at J2000.0 is ~280.37° — Capricorn."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
sun = data['planets']['Sun']
|
||||||
|
self.assertEqual(sun['sign'], 'Capricorn')
|
||||||
|
self.assertAlmostEqual(sun['degree'], 280.37, delta=0.1)
|
||||||
|
|
||||||
|
def test_sun_is_not_retrograde(self):
|
||||||
|
"""The Sun never goes retrograde."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertFalse(data['planets']['Sun']['retrograde'])
|
||||||
|
|
||||||
|
def test_element_counts_sum_to_ten(self):
|
||||||
|
"""All 10 planets are assigned to exactly one classical element."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
classical = sum(
|
||||||
|
data['elements'][e]['count'] for e in ('Fire', 'Water', 'Earth', 'Air')
|
||||||
|
)
|
||||||
|
self.assertEqual(classical, 10)
|
||||||
|
|
||||||
|
def test_each_element_has_count_key(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for key in ('Fire', 'Water', 'Earth', 'Air', 'Time', 'Space'):
|
||||||
|
with self.subTest(element=key):
|
||||||
|
self.assertIn('count', data['elements'][key])
|
||||||
|
|
||||||
|
def test_classic_elements_have_contributors(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for key in ('Fire', 'Water', 'Earth', 'Air'):
|
||||||
|
with self.subTest(element=key):
|
||||||
|
self.assertIn('contributors', data['elements'][key])
|
||||||
|
|
||||||
|
def test_time_has_stellia(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertIn('stellia', data['elements']['Time'])
|
||||||
|
|
||||||
|
def test_space_has_parades(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertIn('parades', data['elements']['Space'])
|
||||||
|
|
||||||
|
def test_each_planet_has_speed(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for name, planet in data['planets'].items():
|
||||||
|
with self.subTest(planet=name):
|
||||||
|
self.assertIn('speed', planet)
|
||||||
|
|
||||||
|
def test_each_aspect_has_applying_planet(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for aspect in data['aspects']:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn('applying_planet', aspect)
|
||||||
|
|
||||||
|
# ── house system ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_default_house_system_is_porphyry(self):
|
||||||
|
"""Porphyry ('O') is the project default — no param needed."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertEqual(data['house_system'], 'O')
|
||||||
|
|
||||||
|
def test_non_superuser_cannot_override_house_system(self):
|
||||||
|
"""House system override is superuser-only; plain requests get 403."""
|
||||||
|
response = self._get({'dt': J2000, **LONDON, 'house_system': 'P'})
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
# ── aspects ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_returns_aspects_list(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
self.assertIn('aspects', data)
|
||||||
|
self.assertIsInstance(data['aspects'], list)
|
||||||
|
|
||||||
|
def test_each_aspect_has_required_fields(self):
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
for aspect in data['aspects']:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn('planet1', aspect)
|
||||||
|
self.assertIn('planet2', aspect)
|
||||||
|
self.assertIn('type', aspect)
|
||||||
|
self.assertIn('angle', aspect)
|
||||||
|
self.assertIn('orb', aspect)
|
||||||
|
|
||||||
|
def test_sun_saturn_trine_present_at_j2000(self):
|
||||||
|
"""Sun ~280.37° (Capricorn) and Saturn ~40.73° (Taurus) are ~120.36° apart — Trine."""
|
||||||
|
data = self._get({'dt': J2000, **LONDON}).json()
|
||||||
|
pairs = {(a['planet1'], a['planet2'], a['type']) for a in data['aspects']}
|
||||||
|
self.assertIn(('Sun', 'Saturn', 'Trine'), pairs)
|
||||||
|
|
||||||
|
|
||||||
|
class TimezoneApiTest(TestCase):
|
||||||
|
"""GET /api/tz/ — resolve IANA timezone from lat/lon coordinates."""
|
||||||
|
|
||||||
|
def _get(self, params):
|
||||||
|
return self.client.get('/api/tz/', params)
|
||||||
|
|
||||||
|
# ── guards ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_400_if_lat_missing(self):
|
||||||
|
response = self._get({'lon': -74.0060})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_if_lon_missing(self):
|
||||||
|
response = self._get({'lat': 40.7128})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_for_invalid_lat(self):
|
||||||
|
response = self._get({'lat': 'abc', 'lon': -74.0060})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_for_out_of_range_lat(self):
|
||||||
|
response = self._get({'lat': 999, 'lon': -74.0060})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
def test_returns_400_for_out_of_range_lon(self):
|
||||||
|
response = self._get({'lat': 40.7128, 'lon': 999})
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
# ── response shape ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_200_for_valid_coords(self):
|
||||||
|
response = self._get(NEW_YORK)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_response_is_json(self):
|
||||||
|
response = self._get(NEW_YORK)
|
||||||
|
self.assertIn('application/json', response['Content-Type'])
|
||||||
|
|
||||||
|
def test_response_contains_timezone_key(self):
|
||||||
|
data = self._get(NEW_YORK).json()
|
||||||
|
self.assertIn('timezone', data)
|
||||||
|
|
||||||
|
def test_timezone_is_a_string(self):
|
||||||
|
data = self._get(NEW_YORK).json()
|
||||||
|
self.assertIsInstance(data['timezone'], str)
|
||||||
|
|
||||||
|
# ── correctness ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_new_york_timezone(self):
|
||||||
|
data = self._get(NEW_YORK).json()
|
||||||
|
self.assertEqual(data['timezone'], 'America/New_York')
|
||||||
|
|
||||||
|
def test_tokyo_timezone(self):
|
||||||
|
data = self._get(TOKYO).json()
|
||||||
|
self.assertEqual(data['timezone'], 'Asia/Tokyo')
|
||||||
|
|
||||||
|
def test_reykjavik_timezone(self):
|
||||||
|
data = self._get(REYKJAVIK).json()
|
||||||
|
self.assertEqual(data['timezone'], 'Atlantic/Reykjavik')
|
||||||
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
0
pyswiss/apps/charts/tests/unit/__init__.py
Normal file
329
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
329
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for calc.py helper functions.
|
||||||
|
|
||||||
|
These tests verify pure calculation logic without hitting the database
|
||||||
|
or the Swiss Ephemeris — all inputs are fixed synthetic data.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
|
||||||
|
from apps.charts.calc import calculate_aspects, get_element_counts
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FAKE_PLANETS_ASPECTS — degrees only; used by calculate_aspects tests.
|
||||||
|
# Each planet also carries a speed (deg/day) for applying_planet tests.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FAKE_PLANETS = {
|
||||||
|
'Sun': {'degree': 10.0, 'speed': 1.00}, # Aries
|
||||||
|
'Moon': {'degree': 130.0, 'speed': 13.00}, # Leo — 120° from Sun → Trine
|
||||||
|
'Mercury': {'degree': 250.0, 'speed': 1.50}, # Sagittarius — 120° from Sun → Trine
|
||||||
|
'Venus': {'degree': 40.0, 'speed': 1.10}, # Taurus — 90° from Moon → Square
|
||||||
|
'Mars': {'degree': 160.0, 'speed': 0.50}, # Virgo — 60° from Neptune → Sextile
|
||||||
|
'Jupiter': {'degree': 280.0, 'speed': 0.08}, # Capricorn — 120° from Mars → Trine
|
||||||
|
'Saturn': {'degree': 70.0, 'speed': 0.03}, # Gemini — 120° from Uranus → Trine
|
||||||
|
'Uranus': {'degree': 310.0, 'speed': 0.01}, # Aquarius — 60° from Sun (wrap) → Sextile
|
||||||
|
'Neptune': {'degree': 100.0, 'speed': 0.006}, # Cancer
|
||||||
|
'Pluto': {'degree': 340.0, 'speed': 0.003}, # Pisces
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# FAKE_PLANETS_ELEMENTS — sign + degree + speed; used by get_element_counts.
|
||||||
|
# Designed to produce a known stellium and parade.
|
||||||
|
#
|
||||||
|
# Occupied signs: Aries(0), Taurus(1), Gemini(2), Leo(4), Virgo(5),
|
||||||
|
# Scorpio(7), Capricorn(9), Aquarius(10)
|
||||||
|
# Gaps at Cancer(3), Libra(6), Sagittarius(8), Pisces(11) prevent wrap-around.
|
||||||
|
#
|
||||||
|
# Consecutive runs: Aries→Taurus→Gemini = 3 ← parade (Space = 2)
|
||||||
|
# Leo→Virgo = 2
|
||||||
|
# Capricorn→Aquarius = 2
|
||||||
|
#
|
||||||
|
# Time = 2 (Aries has Sun+Mercury+Venus → stellium of 3, bonus = 2)
|
||||||
|
# Space = 2 (Aries→Taurus→Gemini = 3-sign parade, bonus = 2)
|
||||||
|
# Classic: Fire=4, Earth=3, Air=2, Water=1
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
FAKE_PLANETS_ELEMENTS = {
|
||||||
|
'Sun': {'sign': 'Aries', 'degree': 10.0, 'speed': 1.00}, # Fire, stellium
|
||||||
|
'Moon': {'sign': 'Taurus', 'degree': 40.0, 'speed': 13.00}, # Earth, parade
|
||||||
|
'Mercury': {'sign': 'Aries', 'degree': 20.0, 'speed': 1.50}, # Fire, stellium
|
||||||
|
'Venus': {'sign': 'Aries', 'degree': 25.0, 'speed': 1.10}, # Fire, stellium
|
||||||
|
'Mars': {'sign': 'Leo', 'degree': 130.0, 'speed': 0.50}, # Fire
|
||||||
|
'Jupiter': {'sign': 'Scorpio', 'degree': 220.0, 'speed': 0.08}, # Water
|
||||||
|
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'speed': 0.03}, # Air, parade
|
||||||
|
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'speed': 0.01}, # Air
|
||||||
|
'Neptune': {'sign': 'Capricorn', 'degree': 270.0, 'speed': 0.006}, # Earth
|
||||||
|
'Pluto': {'sign': 'Virgo', 'degree': 160.0, 'speed': 0.003}, # Earth
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _aspect_pairs(aspects):
|
||||||
|
"""Return a set of (planet1, planet2, type) tuples for easy assertion."""
|
||||||
|
return {(a['planet1'], a['planet2'], a['type']) for a in aspects}
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# get_element_counts — enriched shape
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class GetElementCountsTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.counts = get_element_counts(FAKE_PLANETS_ELEMENTS)
|
||||||
|
|
||||||
|
# ── top-level keys ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_all_six_elements(self):
|
||||||
|
for key in ('Fire', 'Earth', 'Air', 'Water', 'Time', 'Space'):
|
||||||
|
with self.subTest(key=key):
|
||||||
|
self.assertIn(key, self.counts)
|
||||||
|
|
||||||
|
# ── classic four — count + contributors ──────────────────────────────────
|
||||||
|
|
||||||
|
def test_classic_element_has_count_key(self):
|
||||||
|
self.assertIn('count', self.counts['Fire'])
|
||||||
|
|
||||||
|
def test_classic_element_has_contributors_key(self):
|
||||||
|
self.assertIn('contributors', self.counts['Fire'])
|
||||||
|
|
||||||
|
def test_fire_count_is_correct(self):
|
||||||
|
# Sun + Mercury + Venus (Aries) + Mars (Leo) = 4
|
||||||
|
self.assertEqual(self.counts['Fire']['count'], 4)
|
||||||
|
|
||||||
|
def test_earth_count_is_correct(self):
|
||||||
|
# Moon (Taurus) + Neptune (Capricorn) + Pluto (Virgo) = 3
|
||||||
|
self.assertEqual(self.counts['Earth']['count'], 3)
|
||||||
|
|
||||||
|
def test_air_count_is_correct(self):
|
||||||
|
# Saturn (Gemini) + Uranus (Aquarius) = 2
|
||||||
|
self.assertEqual(self.counts['Air']['count'], 2)
|
||||||
|
|
||||||
|
def test_water_count_is_correct(self):
|
||||||
|
# Jupiter (Scorpio) = 1
|
||||||
|
self.assertEqual(self.counts['Water']['count'], 1)
|
||||||
|
|
||||||
|
def test_fire_contributors_contains_expected_planets(self):
|
||||||
|
planets = {c['planet'] for c in self.counts['Fire']['contributors']}
|
||||||
|
self.assertEqual(planets, {'Sun', 'Mercury', 'Venus', 'Mars'})
|
||||||
|
|
||||||
|
def test_contributor_has_planet_and_sign_keys(self):
|
||||||
|
contrib = self.counts['Fire']['contributors'][0]
|
||||||
|
self.assertIn('planet', contrib)
|
||||||
|
self.assertIn('sign', contrib)
|
||||||
|
|
||||||
|
def test_fire_contributor_signs_are_correct(self):
|
||||||
|
sign_map = {c['planet']: c['sign'] for c in self.counts['Fire']['contributors']}
|
||||||
|
self.assertEqual(sign_map['Sun'], 'Aries')
|
||||||
|
self.assertEqual(sign_map['Mercury'], 'Aries')
|
||||||
|
self.assertEqual(sign_map['Venus'], 'Aries')
|
||||||
|
self.assertEqual(sign_map['Mars'], 'Leo')
|
||||||
|
|
||||||
|
# ── Time — count + stellia ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_time_has_count_key(self):
|
||||||
|
self.assertIn('count', self.counts['Time'])
|
||||||
|
|
||||||
|
def test_time_has_stellia_key(self):
|
||||||
|
self.assertIn('stellia', self.counts['Time'])
|
||||||
|
|
||||||
|
def test_time_count_is_correct(self):
|
||||||
|
# Aries has 3 planets → bonus = 2
|
||||||
|
self.assertEqual(self.counts['Time']['count'], 2)
|
||||||
|
|
||||||
|
def test_time_stellia_is_a_list(self):
|
||||||
|
self.assertIsInstance(self.counts['Time']['stellia'], list)
|
||||||
|
|
||||||
|
def test_time_stellia_contains_one_entry(self):
|
||||||
|
self.assertEqual(len(self.counts['Time']['stellia']), 1)
|
||||||
|
|
||||||
|
def test_time_stellium_sign_is_aries(self):
|
||||||
|
self.assertEqual(self.counts['Time']['stellia'][0]['sign'], 'Aries')
|
||||||
|
|
||||||
|
def test_time_stellium_planets_are_correct(self):
|
||||||
|
planet_names = {p['planet'] for p in self.counts['Time']['stellia'][0]['planets']}
|
||||||
|
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus'})
|
||||||
|
|
||||||
|
def test_time_stellium_planet_entries_have_sign(self):
|
||||||
|
for entry in self.counts['Time']['stellia'][0]['planets']:
|
||||||
|
with self.subTest(planet=entry['planet']):
|
||||||
|
self.assertEqual(entry['sign'], 'Aries')
|
||||||
|
|
||||||
|
# ── Space — count + parades ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_space_has_count_key(self):
|
||||||
|
self.assertIn('count', self.counts['Space'])
|
||||||
|
|
||||||
|
def test_space_has_parades_key(self):
|
||||||
|
self.assertIn('parades', self.counts['Space'])
|
||||||
|
|
||||||
|
def test_space_count_is_correct(self):
|
||||||
|
# Aries→Taurus→Gemini = 3 consecutive → bonus = 2
|
||||||
|
self.assertEqual(self.counts['Space']['count'], 2)
|
||||||
|
|
||||||
|
def test_space_parades_is_a_list(self):
|
||||||
|
self.assertIsInstance(self.counts['Space']['parades'], list)
|
||||||
|
|
||||||
|
def test_space_parades_contains_one_entry(self):
|
||||||
|
self.assertEqual(len(self.counts['Space']['parades']), 1)
|
||||||
|
|
||||||
|
def test_space_parade_signs_are_correct(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self.counts['Space']['parades'][0]['signs'],
|
||||||
|
['Aries', 'Taurus', 'Gemini'],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_space_parade_planets_are_correct(self):
|
||||||
|
planet_names = {p['planet'] for p in self.counts['Space']['parades'][0]['planets']}
|
||||||
|
self.assertEqual(planet_names, {'Sun', 'Mercury', 'Venus', 'Moon', 'Saturn'})
|
||||||
|
|
||||||
|
def test_space_parade_planet_entries_have_planet_and_sign(self):
|
||||||
|
for entry in self.counts['Space']['parades'][0]['planets']:
|
||||||
|
with self.subTest(planet=entry['planet']):
|
||||||
|
self.assertIn('planet', entry)
|
||||||
|
self.assertIn('sign', entry)
|
||||||
|
|
||||||
|
|
||||||
|
# ===========================================================================
|
||||||
|
# calculate_aspects
|
||||||
|
# ===========================================================================
|
||||||
|
|
||||||
|
class CalculateAspectsTest(SimpleTestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.aspects = calculate_aspects(FAKE_PLANETS)
|
||||||
|
|
||||||
|
# ── return shape ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_returns_a_list(self):
|
||||||
|
self.assertIsInstance(self.aspects, list)
|
||||||
|
|
||||||
|
def test_each_aspect_has_required_keys(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn('planet1', aspect)
|
||||||
|
self.assertIn('planet2', aspect)
|
||||||
|
self.assertIn('type', aspect)
|
||||||
|
self.assertIn('angle', aspect)
|
||||||
|
self.assertIn('orb', aspect)
|
||||||
|
|
||||||
|
def test_each_aspect_has_applying_planet_key(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn('applying_planet', aspect)
|
||||||
|
|
||||||
|
def test_applying_planet_is_one_of_the_pair(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn(
|
||||||
|
aspect['applying_planet'],
|
||||||
|
(aspect['planet1'], aspect['planet2']),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_applying_planet_is_the_faster_body(self):
|
||||||
|
"""Moon (13.0°/day) applies to Sun (1.0°/day) in their Trine."""
|
||||||
|
sun_moon = next(
|
||||||
|
a for a in self.aspects
|
||||||
|
if {a['planet1'], a['planet2']} == {'Sun', 'Moon'}
|
||||||
|
)
|
||||||
|
self.assertEqual(sun_moon['applying_planet'], 'Moon')
|
||||||
|
|
||||||
|
def test_each_aspect_type_is_a_known_name(self):
|
||||||
|
known = {
|
||||||
|
'Conjunction', 'Semisextile', 'Sextile', 'Square',
|
||||||
|
'Trine', 'Quincunx', 'Opposition',
|
||||||
|
}
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIn(aspect['type'], known)
|
||||||
|
|
||||||
|
def test_angle_and_orb_are_floats(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertIsInstance(aspect['angle'], float)
|
||||||
|
self.assertIsInstance(aspect['orb'], float)
|
||||||
|
|
||||||
|
def test_no_self_aspects(self):
|
||||||
|
for aspect in self.aspects:
|
||||||
|
self.assertNotEqual(aspect['planet1'], aspect['planet2'])
|
||||||
|
|
||||||
|
def test_no_duplicate_pairs(self):
|
||||||
|
pairs = [(a['planet1'], a['planet2']) for a in self.aspects]
|
||||||
|
self.assertEqual(len(pairs), len(set(pairs)))
|
||||||
|
|
||||||
|
# ── known aspects in FAKE_PLANETS ────────────────────────────────────
|
||||||
|
|
||||||
|
def test_sun_moon_trine(self):
|
||||||
|
"""Moon at 130° is exactly 120° from Sun at 10°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Sun', 'Moon', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_sun_mercury_trine(self):
|
||||||
|
"""Mercury at 250° wraps to 120° from Sun at 10° (360-250+10=120)."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Sun', 'Mercury', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_moon_mercury_trine(self):
|
||||||
|
"""Moon 130° → Mercury 250° = 120°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Moon', 'Mercury', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_moon_venus_square(self):
|
||||||
|
"""Moon 130° → Venus 40° = 90°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Moon', 'Venus', 'Square'), pairs)
|
||||||
|
|
||||||
|
def test_venus_neptune_sextile(self):
|
||||||
|
"""Venus 40° → Neptune 100° = 60°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Venus', 'Neptune', 'Sextile'), pairs)
|
||||||
|
|
||||||
|
def test_mars_neptune_sextile(self):
|
||||||
|
"""Mars 160° → Neptune 100° = 60°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Mars', 'Neptune', 'Sextile'), pairs)
|
||||||
|
|
||||||
|
def test_sun_uranus_sextile(self):
|
||||||
|
"""Sun 10° → Uranus 310° — angle = |10-310| = 300° → 360-300 = 60°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Sun', 'Uranus', 'Sextile'), pairs)
|
||||||
|
|
||||||
|
def test_mars_jupiter_trine(self):
|
||||||
|
"""Mars 160° → Jupiter 280° = 120°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Mars', 'Jupiter', 'Trine'), pairs)
|
||||||
|
|
||||||
|
def test_saturn_uranus_trine(self):
|
||||||
|
"""Saturn 70° → Uranus 310° = |70-310| = 240° → 360-240 = 120°."""
|
||||||
|
pairs = _aspect_pairs(self.aspects)
|
||||||
|
self.assertIn(('Saturn', 'Uranus', 'Trine'), pairs)
|
||||||
|
|
||||||
|
# ── orb bounds ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_orb_is_within_allowed_maximum(self):
|
||||||
|
max_orbs = {
|
||||||
|
'Conjunction': 8.0,
|
||||||
|
'Semisextile': 4.0,
|
||||||
|
'Sextile': 6.0,
|
||||||
|
'Square': 8.0,
|
||||||
|
'Trine': 8.0,
|
||||||
|
'Quincunx': 5.0,
|
||||||
|
'Opposition': 10.0,
|
||||||
|
}
|
||||||
|
for aspect in self.aspects:
|
||||||
|
with self.subTest(aspect=aspect):
|
||||||
|
self.assertLessEqual(
|
||||||
|
aspect['orb'], max_orbs[aspect['type']],
|
||||||
|
msg=f"{aspect['planet1']}-{aspect['planet2']} orb exceeds maximum",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_exact_trine_has_zero_orb(self):
|
||||||
|
"""Sun-Moon at exactly 120° should report orb of 0.0."""
|
||||||
|
sun_moon = next(
|
||||||
|
a for a in self.aspects
|
||||||
|
if a['planet1'] == 'Sun' and a['planet2'] == 'Moon'
|
||||||
|
)
|
||||||
|
self.assertAlmostEqual(sun_moon['orb'], 0.0, places=5)
|
||||||
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
99
pyswiss/apps/charts/tests/unit/test_populate_ephemeris.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
"""
|
||||||
|
Unit tests for the populate_ephemeris management command.
|
||||||
|
|
||||||
|
pyswisseph calls are mocked — these tests verify date iteration,
|
||||||
|
snapshot persistence, and idempotency without touching the ephemeris.
|
||||||
|
|
||||||
|
Run:
|
||||||
|
pyswiss/.venv/Scripts/python pyswiss/manage.py test pyswiss/apps/charts
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.charts.models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# 10 planets covering Fire×3, Earth×3, Air×2, Water×2 (one per sign)
|
||||||
|
# Expected: fire=3, water=2, earth=3, air=2, time=0, space=9
|
||||||
|
FAKE_PLANETS = {
|
||||||
|
'Sun': {'sign': 'Aries', 'degree': 10.0, 'retrograde': False},
|
||||||
|
'Moon': {'sign': 'Leo', 'degree': 130.0, 'retrograde': False},
|
||||||
|
'Mercury': {'sign': 'Sagittarius', 'degree': 250.0, 'retrograde': False},
|
||||||
|
'Venus': {'sign': 'Taurus', 'degree': 40.0, 'retrograde': False},
|
||||||
|
'Mars': {'sign': 'Virgo', 'degree': 160.0, 'retrograde': False},
|
||||||
|
'Jupiter': {'sign': 'Capricorn', 'degree': 280.0, 'retrograde': False},
|
||||||
|
'Saturn': {'sign': 'Gemini', 'degree': 70.0, 'retrograde': False},
|
||||||
|
'Uranus': {'sign': 'Aquarius', 'degree': 310.0, 'retrograde': False},
|
||||||
|
'Neptune': {'sign': 'Cancer', 'degree': 100.0, 'retrograde': False},
|
||||||
|
'Pluto': {'sign': 'Pisces', 'degree': 340.0, 'retrograde': False},
|
||||||
|
}
|
||||||
|
|
||||||
|
PATCH_TARGET = (
|
||||||
|
'apps.charts.management.commands.populate_ephemeris.get_planet_positions'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PopulateEphemerisCommandTest(TestCase):
|
||||||
|
|
||||||
|
def _run(self, date_from, date_to):
|
||||||
|
with patch(PATCH_TARGET, return_value=FAKE_PLANETS):
|
||||||
|
call_command('populate_ephemeris',
|
||||||
|
date_from=date_from, date_to=date_to,
|
||||||
|
verbosity=0)
|
||||||
|
|
||||||
|
# ── date iteration ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_creates_one_snapshot_per_day(self):
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
|
||||||
|
|
||||||
|
def test_single_day_range_creates_one_snapshot(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_snapshots_are_at_noon_utc(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
snap = EphemerisSnapshot.objects.get()
|
||||||
|
self.assertEqual(snap.dt, datetime(2000, 1, 1, 12, 0, 0, tzinfo=timezone.utc))
|
||||||
|
|
||||||
|
# ── idempotency ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_rerunning_does_not_create_duplicates(self):
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 3)
|
||||||
|
|
||||||
|
def test_overlapping_ranges_do_not_duplicate(self):
|
||||||
|
self._run('2000-01-01', '2000-01-03')
|
||||||
|
self._run('2000-01-02', '2000-01-05')
|
||||||
|
self.assertEqual(EphemerisSnapshot.objects.count(), 5)
|
||||||
|
|
||||||
|
# ── element counts ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_element_counts_are_persisted(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
snap = EphemerisSnapshot.objects.get()
|
||||||
|
self.assertEqual(snap.fire, 3)
|
||||||
|
self.assertEqual(snap.water, 2)
|
||||||
|
self.assertEqual(snap.earth, 3)
|
||||||
|
self.assertEqual(snap.air, 2)
|
||||||
|
self.assertEqual(snap.time_el, 0)
|
||||||
|
self.assertEqual(snap.space_el, 9)
|
||||||
|
|
||||||
|
# ── chart_data payload ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_chart_data_contains_planets(self):
|
||||||
|
self._run('2000-01-01', '2000-01-01')
|
||||||
|
snap = EphemerisSnapshot.objects.get()
|
||||||
|
self.assertEqual(snap.chart_data['planets'], FAKE_PLANETS)
|
||||||
8
pyswiss/apps/charts/urls.py
Normal file
8
pyswiss/apps/charts/urls.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('chart/', views.chart, name='chart'),
|
||||||
|
path('charts/', views.charts_list, name='charts_list'),
|
||||||
|
path('tz/', views.timezone_lookup, name='timezone_lookup'),
|
||||||
|
]
|
||||||
143
pyswiss/apps/charts/views.py
Normal file
143
pyswiss/apps/charts/views.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from timezonefinder import TimezoneFinder
|
||||||
|
|
||||||
|
import swisseph as swe
|
||||||
|
|
||||||
|
from .calc import (
|
||||||
|
DEFAULT_HOUSE_SYSTEM,
|
||||||
|
calculate_aspects,
|
||||||
|
get_element_counts,
|
||||||
|
get_julian_day,
|
||||||
|
get_planet_positions,
|
||||||
|
set_ephe_path,
|
||||||
|
)
|
||||||
|
from .models import EphemerisSnapshot
|
||||||
|
|
||||||
|
|
||||||
|
def chart(request):
|
||||||
|
dt_str = request.GET.get('dt')
|
||||||
|
lat_str = request.GET.get('lat')
|
||||||
|
lon_str = request.GET.get('lon')
|
||||||
|
|
||||||
|
if not dt_str or lat_str is None or lon_str is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat_str)
|
||||||
|
lon = float(lon_str)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
house_system_param = request.GET.get('house_system')
|
||||||
|
if house_system_param is not None:
|
||||||
|
if not (hasattr(request, 'user') and request.user.is_authenticated
|
||||||
|
and request.user.is_superuser):
|
||||||
|
return HttpResponse(status=403)
|
||||||
|
house_system = house_system_param
|
||||||
|
else:
|
||||||
|
house_system = DEFAULT_HOUSE_SYSTEM
|
||||||
|
|
||||||
|
set_ephe_path()
|
||||||
|
|
||||||
|
jd = get_julian_day(dt)
|
||||||
|
planets = get_planet_positions(jd)
|
||||||
|
|
||||||
|
cusps, ascmc = swe.houses(jd, lat, lon, house_system.encode())
|
||||||
|
houses = {
|
||||||
|
'cusps': list(cusps),
|
||||||
|
'asc': ascmc[0],
|
||||||
|
'mc': ascmc[1],
|
||||||
|
}
|
||||||
|
|
||||||
|
return JsonResponse({
|
||||||
|
'planets': planets,
|
||||||
|
'houses': houses,
|
||||||
|
'elements': get_element_counts(planets),
|
||||||
|
'aspects': calculate_aspects(planets),
|
||||||
|
'house_system': house_system,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
_tf = TimezoneFinder()
|
||||||
|
|
||||||
|
|
||||||
|
def timezone_lookup(request):
|
||||||
|
"""GET /api/tz/ — resolve IANA timezone string from lat/lon.
|
||||||
|
|
||||||
|
Query params: lat (float), lon (float)
|
||||||
|
Returns: { "timezone": "America/New_York" }
|
||||||
|
Returns 404 JSON { "timezone": null } if coordinates fall in international
|
||||||
|
waters (no timezone found) — not an error, just no result.
|
||||||
|
"""
|
||||||
|
lat_str = request.GET.get('lat')
|
||||||
|
lon_str = request.GET.get('lon')
|
||||||
|
|
||||||
|
if lat_str is None or lon_str is None:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
lat = float(lat_str)
|
||||||
|
lon = float(lon_str)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
tz = _tf.timezone_at(lat=lat, lng=lon)
|
||||||
|
return JsonResponse({'timezone': tz})
|
||||||
|
|
||||||
|
|
||||||
|
def charts_list(request):
|
||||||
|
date_from_str = request.GET.get('date_from')
|
||||||
|
date_to_str = request.GET.get('date_to')
|
||||||
|
|
||||||
|
if not date_from_str or not date_to_str:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
try:
|
||||||
|
date_from = datetime.strptime(date_from_str, '%Y-%m-%d').replace(
|
||||||
|
tzinfo=timezone.utc)
|
||||||
|
date_to = datetime.strptime(date_to_str, '%Y-%m-%d').replace(
|
||||||
|
hour=23, minute=59, second=59, tzinfo=timezone.utc)
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
if date_to < date_from:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
qs = EphemerisSnapshot.objects.filter(dt__gte=date_from, dt__lte=date_to)
|
||||||
|
|
||||||
|
element_fields = {
|
||||||
|
'fire_min': 'fire', 'water_min': 'water',
|
||||||
|
'earth_min': 'earth', 'air_min': 'air',
|
||||||
|
'time_min': 'time_el', 'space_min': 'space_el',
|
||||||
|
}
|
||||||
|
for param, field in element_fields.items():
|
||||||
|
value = request.GET.get(param)
|
||||||
|
if value is not None:
|
||||||
|
try:
|
||||||
|
qs = qs.filter(**{f'{field}__gte': int(value)})
|
||||||
|
except ValueError:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
|
||||||
|
results = [
|
||||||
|
{
|
||||||
|
'dt': snap.dt.isoformat(),
|
||||||
|
'elements': snap.elements_dict(),
|
||||||
|
'planets': snap.chart_data.get('planets', {}),
|
||||||
|
}
|
||||||
|
for snap in qs
|
||||||
|
]
|
||||||
|
|
||||||
|
return JsonResponse({'results': results, 'count': len(results)})
|
||||||
0
pyswiss/core/__init__.py
Normal file
0
pyswiss/core/__init__.py
Normal file
49
pyswiss/core/settings.py
Normal file
49
pyswiss/core/settings.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = os.environ.get('SECRET_KEY', 'pyswiss-dev-only-key-replace-in-production')
|
||||||
|
DEBUG = os.environ.get('DEBUG', 'true').lower() != 'false'
|
||||||
|
ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', '*').split(',')
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
'corsheaders',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'apps.charts',
|
||||||
|
]
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
]
|
||||||
|
|
||||||
|
CORS_ALLOWED_ORIGIN_REGEXES = [
|
||||||
|
r'^https://.*\.earthmanrpg\.me$',
|
||||||
|
r'^http://localhost(:\d+)?$',
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'core.urls'
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': 'django.db.backends.sqlite3',
|
||||||
|
'NAME': BASE_DIR / 'db.sqlite3',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
STATIC_URL = '/static/'
|
||||||
|
MEDIA_URL = '/media/'
|
||||||
|
|
||||||
|
USE_TZ = True
|
||||||
|
TIME_ZONE = 'UTC'
|
||||||
|
|
||||||
|
# Swiss Ephemeris data files.
|
||||||
|
# Override via SWISSEPH_PATH env var on staging/production.
|
||||||
|
SWISSEPH_PATH = os.environ.get(
|
||||||
|
'SWISSEPH_PATH',
|
||||||
|
r'D:\OneDrive\Desktop\potentium\implicateOrder\libraries\swisseph-master\ephe',
|
||||||
|
)
|
||||||
5
pyswiss/core/urls.py
Normal file
5
pyswiss/core/urls.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.urls import path, include
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('api/', include('apps.charts.urls')),
|
||||||
|
]
|
||||||
6
pyswiss/core/wsgi.py
Normal file
6
pyswiss/core/wsgi.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import os
|
||||||
|
from django.core.wsgi import get_wsgi_application
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
|
||||||
|
application = get_wsgi_application()
|
||||||
20
pyswiss/manage.py
Normal file
20
pyswiss/manage.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
|
||||||
|
try:
|
||||||
|
from django.core.management import execute_from_command_line
|
||||||
|
except ImportError as exc:
|
||||||
|
raise ImportError(
|
||||||
|
"Couldn't import Django. Are you sure it's installed and available "
|
||||||
|
"on your PYTHONPATH environment variable? Did you forget to activate "
|
||||||
|
"a virtual environment?"
|
||||||
|
) from exc
|
||||||
|
execute_from_command_line(sys.argv)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
5
pyswiss/requirements.txt
Normal file
5
pyswiss/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
django==6.0.4
|
||||||
|
django-cors-headers==4.3.1
|
||||||
|
gunicorn==23.0.0
|
||||||
|
pyswisseph==2.10.3.2
|
||||||
|
timezonefinder==8.2.2
|
||||||
@@ -2,13 +2,21 @@ asgiref==3.11.0
|
|||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
certifi==2025.11.12
|
certifi==2025.11.12
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
|
channels
|
||||||
|
channels-redis
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
coverage
|
coverage
|
||||||
|
cryptography
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
dj-database-url
|
dj-database-url
|
||||||
Django==6.0
|
Django==6.0
|
||||||
|
django-compressor
|
||||||
|
django-htmx
|
||||||
|
django-libsass
|
||||||
django-stubs==5.2.8
|
django-stubs==5.2.8
|
||||||
django-stubs-ext==5.2.8
|
django-stubs-ext==5.2.8
|
||||||
|
djangorestframework
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
@@ -17,17 +25,21 @@ outcome==1.3.0.post0
|
|||||||
packaging==25.0
|
packaging==25.0
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
PySocks==1.7.1
|
PySocks==1.7.1
|
||||||
|
python-dotenv
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
|
scipy
|
||||||
selenium==4.39.0
|
selenium==4.39.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
sortedcontainers==2.4.0
|
sortedcontainers==2.4.0
|
||||||
sqlparse==0.5.5
|
sqlparse==0.5.5
|
||||||
|
stripe
|
||||||
trio==0.32.0
|
trio==0.32.0
|
||||||
trio-websocket==0.12.2
|
trio-websocket==0.12.2
|
||||||
types-PyYAML==6.0.12.20250915
|
types-PyYAML==6.0.12.20250915
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
tzdata==2025.3
|
tzdata==2025.3
|
||||||
urllib3==2.6.2
|
urllib3==2.6.2
|
||||||
|
uvicorn[standard]
|
||||||
websocket-client==1.9.0
|
websocket-client==1.9.0
|
||||||
whitenoise==6.11.0
|
whitenoise==6.11.0
|
||||||
wsproto==1.3.2
|
wsproto==1.3.2
|
||||||
|
|||||||
@@ -1,10 +1,23 @@
|
|||||||
|
celery
|
||||||
|
cryptography
|
||||||
|
channels
|
||||||
|
channels-redis
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
Django==6.0
|
Django==6.0
|
||||||
dj-database-url
|
dj-database-url
|
||||||
|
django-compressor
|
||||||
|
django-htmx
|
||||||
|
django-libsass
|
||||||
django-stubs==5.2.8
|
django-stubs==5.2.8
|
||||||
django-stubs-ext==5.2.8
|
django-stubs-ext==5.2.8
|
||||||
|
djangorestframework
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
lxml==6.0.2
|
lxml==6.0.2
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
redis
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
scipy
|
||||||
|
stripe
|
||||||
whitenoise==6.11.0
|
whitenoise==6.11.0
|
||||||
|
uvicorn[standard]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ source = apps
|
|||||||
omit =
|
omit =
|
||||||
*/migrations/*
|
*/migrations/*
|
||||||
*/tests/*
|
*/tests/*
|
||||||
|
*/routing.py
|
||||||
|
|
||||||
[report]
|
[report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
0
src/apps/ap/__init__.py
Normal file
0
src/apps/ap/__init__.py
Normal file
7
src/apps/ap/apps.py
Normal file
7
src/apps/ap/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApConfig(AppConfig):
|
||||||
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
|
name = "apps.ap"
|
||||||
|
label = "ap"
|
||||||
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
0
src/apps/ap/tests/integrated/__init__.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
119
src/apps/ap/tests/integrated/test_ap_views.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class WebFingerTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||||
|
|
||||||
|
def test_returns_jrd_for_known_user(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
{"resource": "acct:actor@earthmanrpg.me"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response["Content-Type"], "application/jrd+json")
|
||||||
|
|
||||||
|
def test_jrd_links_to_actor_url(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
{"resource": "acct:actor@earthmanrpg.me"},
|
||||||
|
)
|
||||||
|
data = json.loads(response.content)
|
||||||
|
hrefs = [link["href"] for link in data["links"]]
|
||||||
|
self.assertTrue(any("/ap/users/actor/" in href for href in hrefs))
|
||||||
|
|
||||||
|
def test_returns_404_for_unknown_user(self):
|
||||||
|
response = self.client.get(
|
||||||
|
"/.well-known/webfinger",
|
||||||
|
{"resource": "acct:nobody@earthmanrpg.me"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_returns_400_for_missing_resource(self):
|
||||||
|
response = self.client.get("/.well-known/webfinger")
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
|
||||||
|
|
||||||
|
class ActorViewTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||||
|
|
||||||
|
def test_returns_200_for_known_user(self):
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_activity_json_content_type(self):
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
self.assertEqual(response["Content-Type"], "application/activity+json")
|
||||||
|
|
||||||
|
def test_actor_has_required_fields(self):
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data["type"], "Person")
|
||||||
|
self.assertIn("id", data)
|
||||||
|
self.assertIn("outbox", data)
|
||||||
|
self.assertIn("publicKey", data)
|
||||||
|
|
||||||
|
def test_requires_no_authentication(self):
|
||||||
|
# AP Actor endpoints must be publicly accessible
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/ap/users/actor/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_404_for_unknown_user(self):
|
||||||
|
response = self.client.get("/ap/users/nobody/")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxViewTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="actor")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.ROLE_SELECTED, actor=self.user,
|
||||||
|
role="PC", slot_number=1, role_display="Player",
|
||||||
|
)
|
||||||
|
# INVITE_SENT is unsupported — should be excluded from outbox
|
||||||
|
record(self.room, GameEvent.INVITE_SENT, actor=self.user)
|
||||||
|
|
||||||
|
def test_returns_200(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_activity_json_content_type(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
self.assertEqual(response["Content-Type"], "application/activity+json")
|
||||||
|
|
||||||
|
def test_outbox_is_ordered_collection(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
self.assertEqual(data["type"], "OrderedCollection")
|
||||||
|
|
||||||
|
def test_total_items_excludes_unsupported_verbs(self):
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
data = json.loads(response.content)
|
||||||
|
# 2 supported events (SLOT_FILLED + ROLE_SELECTED); INVITE_SENT excluded
|
||||||
|
self.assertEqual(data["totalItems"], 2)
|
||||||
|
|
||||||
|
def test_requires_no_authentication(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/ap/users/actor/outbox/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_returns_404_for_unknown_user(self):
|
||||||
|
response = self.client.get("/ap/users/nobody/outbox/")
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
0
src/apps/ap/tests/unit/__init__.py
Normal file
0
src/apps/ap/tests/unit/__init__.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
88
src/apps/ap/tests/unit/test_activity.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
BASE = "https://earthmanrpg.me"
|
||||||
|
|
||||||
|
|
||||||
|
class ToActivityTest(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io", username="testactor")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def _record(self, verb, **data):
|
||||||
|
return record(self.room, verb, actor=self.user, **data)
|
||||||
|
|
||||||
|
def test_slot_filled_returns_join_gate_activity(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.SLOT_FILLED,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["type"], "earthman:JoinGate")
|
||||||
|
|
||||||
|
def test_role_selected_returns_select_role_activity(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.ROLE_SELECTED,
|
||||||
|
role="PC", slot_number=1, role_display="Player",
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["type"], "earthman:SelectRole")
|
||||||
|
|
||||||
|
def test_room_created_returns_create_activity(self):
|
||||||
|
event = self._record(GameEvent.ROOM_CREATED)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIsNotNone(activity)
|
||||||
|
self.assertEqual(activity["type"], "Create")
|
||||||
|
|
||||||
|
def test_unsupported_verb_returns_none(self):
|
||||||
|
event = self._record(GameEvent.INVITE_SENT)
|
||||||
|
self.assertIsNone(event.to_activity(BASE))
|
||||||
|
|
||||||
|
def test_activity_contains_actor_url(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.ROLE_SELECTED,
|
||||||
|
role="PC", slot_number=1, role_display="Player",
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIn(BASE, activity["actor"])
|
||||||
|
|
||||||
|
def test_activity_contains_object_url(self):
|
||||||
|
event = self._record(
|
||||||
|
GameEvent.SLOT_FILLED,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin", renewal_days=7,
|
||||||
|
)
|
||||||
|
activity = event.to_activity(BASE)
|
||||||
|
self.assertIn(str(self.room.id), activity["object"])
|
||||||
|
|
||||||
|
|
||||||
|
class EnsureKeypairTest(TestCase):
|
||||||
|
|
||||||
|
def test_ensure_keypair_populates_both_fields(self):
|
||||||
|
user = User.objects.create(email="keys@test.io")
|
||||||
|
self.assertEqual(user.ap_public_key, "")
|
||||||
|
self.assertEqual(user.ap_private_key, "")
|
||||||
|
user.ensure_keypair()
|
||||||
|
self.assertTrue(user.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||||
|
self.assertTrue(user.ap_private_key.startswith("-----BEGIN PRIVATE KEY-----"))
|
||||||
|
|
||||||
|
def test_ensure_keypair_persists_to_db(self):
|
||||||
|
user = User.objects.create(email="persist@test.io")
|
||||||
|
user.ensure_keypair()
|
||||||
|
refreshed = User.objects.get(pk=user.pk)
|
||||||
|
self.assertTrue(refreshed.ap_public_key.startswith("-----BEGIN PUBLIC KEY-----"))
|
||||||
|
|
||||||
|
def test_ensure_keypair_is_idempotent(self):
|
||||||
|
user = User.objects.create(email="idem@test.io")
|
||||||
|
user.ensure_keypair()
|
||||||
|
original_pub = user.ap_public_key
|
||||||
|
user.ensure_keypair()
|
||||||
|
self.assertEqual(user.ap_public_key, original_pub)
|
||||||
10
src/apps/ap/urls.py
Normal file
10
src/apps/ap/urls.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
app_name = "ap"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("users/<str:username>/", views.actor, name="actor"),
|
||||||
|
path("users/<str:username>/outbox/", views.outbox, name="outbox"),
|
||||||
|
]
|
||||||
83
src/apps/ap/views.py
Normal file
83
src/apps/ap/views.py
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.http import HttpResponse, JsonResponse
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
AP_CONTEXT = [
|
||||||
|
"https://www.w3.org/ns/activitystreams",
|
||||||
|
{"earthman": "https://earthmanrpg.me/ns#"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _base_url(request):
|
||||||
|
return f"{request.scheme}://{request.get_host()}"
|
||||||
|
|
||||||
|
|
||||||
|
def _ap_response(data):
|
||||||
|
return HttpResponse(
|
||||||
|
json.dumps(data),
|
||||||
|
content_type="application/activity+json",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def webfinger(request):
|
||||||
|
resource = request.GET.get("resource", "")
|
||||||
|
if not resource:
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
# Expect acct:username@host
|
||||||
|
if not resource.startswith("acct:"):
|
||||||
|
return HttpResponse(status=400)
|
||||||
|
username = resource[len("acct:"):].split("@")[0]
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
base = _base_url(request)
|
||||||
|
data = {
|
||||||
|
"subject": resource,
|
||||||
|
"links": [
|
||||||
|
{
|
||||||
|
"rel": "self",
|
||||||
|
"type": "application/activity+json",
|
||||||
|
"href": f"{base}/ap/users/{user.username}/",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
return HttpResponse(json.dumps(data), content_type="application/jrd+json")
|
||||||
|
|
||||||
|
|
||||||
|
def actor(request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
user.ensure_keypair()
|
||||||
|
base = _base_url(request)
|
||||||
|
actor_url = f"{base}/ap/users/{username}/"
|
||||||
|
data = {
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"id": actor_url,
|
||||||
|
"type": "Person",
|
||||||
|
"preferredUsername": username,
|
||||||
|
"inbox": f"{actor_url}inbox/",
|
||||||
|
"outbox": f"{actor_url}outbox/",
|
||||||
|
"publicKey": {
|
||||||
|
"id": f"{actor_url}#main-key",
|
||||||
|
"owner": actor_url,
|
||||||
|
"publicKeyPem": user.ap_public_key,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return _ap_response(data)
|
||||||
|
|
||||||
|
|
||||||
|
def outbox(request, username):
|
||||||
|
user = get_object_or_404(User, username=username)
|
||||||
|
base = _base_url(request)
|
||||||
|
events = user.game_events.select_related("room").order_by("timestamp")
|
||||||
|
activities = [a for e in events if (a := e.to_activity(base)) is not None]
|
||||||
|
actor_url = f"{base}/ap/users/{username}/"
|
||||||
|
data = {
|
||||||
|
"@context": AP_CONTEXT,
|
||||||
|
"id": f"{actor_url}outbox/",
|
||||||
|
"type": "OrderedCollection",
|
||||||
|
"totalItems": len(activities),
|
||||||
|
"orderedItems": activities,
|
||||||
|
}
|
||||||
|
return _ap_response(data)
|
||||||
0
src/apps/api/__init__.py
Normal file
0
src/apps/api/__init__.py
Normal file
32
src/apps/api/serializers.py
Normal file
32
src/apps/api/serializers.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.dashboard.models import Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class LineSerializer(serializers.ModelSerializer):
|
||||||
|
text = serializers.CharField()
|
||||||
|
|
||||||
|
def validate_text(self, value):
|
||||||
|
post = self.context["post"]
|
||||||
|
if post.lines.filter(text=value).exists():
|
||||||
|
raise serializers.ValidationError("duplicate")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Line
|
||||||
|
fields = ["id", "text"]
|
||||||
|
|
||||||
|
class PostSerializer(serializers.ModelSerializer):
|
||||||
|
name = serializers.ReadOnlyField()
|
||||||
|
url = serializers.CharField(source="get_absolute_url", read_only=True)
|
||||||
|
lines = LineSerializer(many=True, read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
fields = ["id", "name", "url", "lines"]
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ["id", "username"]
|
||||||
0
src/apps/api/tests/__init__.py
Normal file
0
src/apps/api/tests/__init__.py
Normal file
0
src/apps/api/tests/integrated/__init__.py
Normal file
0
src/apps/api/tests/integrated/__init__.py
Normal file
115
src/apps/api/tests/integrated/test_views.py
Normal file
115
src/apps/api/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.dashboard.models import Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
class BaseAPITest(TestCase):
|
||||||
|
# Helper fns
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user("test@example.com")
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
class PostDetailAPITest(BaseAPITest):
|
||||||
|
def test_returns_post_with_lines(self):
|
||||||
|
post = Post.objects.create(owner=self.user)
|
||||||
|
Line.objects.create(text="line 1", post=post)
|
||||||
|
Line.objects.create(text="line 2", post=post)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/posts/{post.id}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["id"], str(post.id))
|
||||||
|
self.assertEqual(len(response.data["lines"]), 2)
|
||||||
|
|
||||||
|
class PostLinesAPITest(BaseAPITest):
|
||||||
|
def test_can_add_line_to_post(self):
|
||||||
|
post = Post.objects.create(owner=self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/posts/{post.id}/lines/",
|
||||||
|
{"text": "a new line"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(Line.objects.count(), 1)
|
||||||
|
self.assertEqual(Line.objects.first().text, "a new line")
|
||||||
|
|
||||||
|
def test_cannot_add_empty_line_to_post(self):
|
||||||
|
post = Post.objects.create(owner=self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/posts/{post.id}/lines/",
|
||||||
|
{"text": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(Line.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_cannot_add_duplicate_line_to_post(self):
|
||||||
|
post = Post.objects.create(owner=self.user)
|
||||||
|
Line.objects.create(text="post line", post=post)
|
||||||
|
duplicate_response = self.client.post(
|
||||||
|
f"/api/posts/{post.id}/lines/",
|
||||||
|
{"text": "post line"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(duplicate_response.status_code, 400)
|
||||||
|
self.assertEqual(Line.objects.count(), 1)
|
||||||
|
|
||||||
|
class PostsAPITest(BaseAPITest):
|
||||||
|
def test_get_returns_only_users_posts(self):
|
||||||
|
post1 = Post.objects.create(owner=self.user)
|
||||||
|
Line.objects.create(text="line 1", post=post1)
|
||||||
|
other_user = User.objects.create_user("other@example.com")
|
||||||
|
Post.objects.create(owner=other_user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/posts/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["id"], str(post1.id))
|
||||||
|
|
||||||
|
def test_post_creates_post_with_line(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/posts/",
|
||||||
|
{"text": "first line"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(Post.objects.count(), 1)
|
||||||
|
self.assertEqual(Post.objects.first().owner, self.user)
|
||||||
|
self.assertEqual(Line.objects.first().text, "first line")
|
||||||
|
|
||||||
|
class UserSearchAPITest(BaseAPITest):
|
||||||
|
def test_returns_users_matching_username(self):
|
||||||
|
disco = User.objects.create_user("disco@example.com")
|
||||||
|
disco.username = "discoman"
|
||||||
|
disco.searchable = True
|
||||||
|
disco.save()
|
||||||
|
|
||||||
|
response = self.client.get("/api/users/?q=disc")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["username"], "discoman")
|
||||||
|
|
||||||
|
def test_non_searchable_users_are_excluded(self):
|
||||||
|
alice = User.objects.create_user("alice@example.com")
|
||||||
|
alice.username = "princessAli"
|
||||||
|
alice.save() # searchable defaults to False
|
||||||
|
|
||||||
|
response = self.client.get("/api/users/?q=prin")
|
||||||
|
|
||||||
|
self.assertEqual(response.data, [])
|
||||||
|
|
||||||
|
def test_response_does_not_include_email(self):
|
||||||
|
alice = User.objects.create_user("alice@example.com")
|
||||||
|
alice.username = "princessAli"
|
||||||
|
alice.searchable = True
|
||||||
|
alice.save()
|
||||||
|
|
||||||
|
response = self.client.get("/api/users/?q=prin")
|
||||||
|
|
||||||
|
self.assertNotIn("email", response.data[0])
|
||||||
0
src/apps/api/tests/unit/__init__.py
Normal file
0
src/apps/api/tests/unit/__init__.py
Normal file
19
src/apps/api/tests/unit/test_serializers.py
Normal file
19
src/apps/api/tests/unit/test_serializers.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django.test import SimpleTestCase
|
||||||
|
from apps.api.serializers import LineSerializer, PostSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class LineSerializerTest(SimpleTestCase):
|
||||||
|
def test_fields(self):
|
||||||
|
serializer = LineSerializer()
|
||||||
|
self.assertEqual(
|
||||||
|
set(serializer.fields.keys()),
|
||||||
|
{"id", "text"},
|
||||||
|
)
|
||||||
|
|
||||||
|
class PostSerializerTest(SimpleTestCase):
|
||||||
|
def test_fields(self):
|
||||||
|
serializer = PostSerializer()
|
||||||
|
self.assertEqual(
|
||||||
|
set(serializer.fields.keys()),
|
||||||
|
{"id", "name", "url", "lines"},
|
||||||
|
)
|
||||||
11
src/apps/api/urls.py
Normal file
11
src/apps/api/urls.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('posts/', views.PostsAPI.as_view(), name='api_posts'),
|
||||||
|
path('posts/<uuid:post_id>/', views.PostDetailAPI.as_view(), name='api_post_detail'),
|
||||||
|
path('posts/<uuid:post_id>/lines/', views.PostLinesAPI.as_view(), name='api_post_lines'),
|
||||||
|
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
|
||||||
|
]
|
||||||
45
src/apps/api/views.py
Normal file
45
src/apps/api/views.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.api.serializers import LineSerializer, PostSerializer, UserSerializer
|
||||||
|
from apps.dashboard.models import Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class PostDetailAPI(APIView):
|
||||||
|
def get(self, request, post_id):
|
||||||
|
post = get_object_or_404(Post, id=post_id)
|
||||||
|
serializer = PostSerializer(post)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
class PostLinesAPI(APIView):
|
||||||
|
def post(self, request, post_id):
|
||||||
|
post = get_object_or_404(Post, id=post_id)
|
||||||
|
serializer = LineSerializer(data=request.data, context={"post": post})
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(post=post)
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
class PostsAPI(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
posts = Post.objects.filter(owner=request.user)
|
||||||
|
serializer = PostSerializer(posts, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
post = Post.objects.create(owner=request.user)
|
||||||
|
line = Line.objects.create(text=request.data.get("text", ""), post=post)
|
||||||
|
serializer = PostSerializer(post)
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
|
||||||
|
class UserSearchAPI(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
q = request.query_params.get("q", "")
|
||||||
|
users = User.objects.filter(
|
||||||
|
username__icontains=q,
|
||||||
|
searchable=True,
|
||||||
|
)
|
||||||
|
serializer = UserSerializer(users, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
0
src/apps/applets/__init__.py
Normal file
0
src/apps/applets/__init__.py
Normal file
11
src/apps/applets/admin.py
Normal file
11
src/apps/applets/admin.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Applet)
|
||||||
|
class AppletAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['slug', 'name', 'default_visible', 'grid_cols', 'grid_rows']
|
||||||
|
list_editable = ['grid_cols', 'grid_rows']
|
||||||
|
|
||||||
|
admin.site.register(UserApplet)
|
||||||
5
src/apps/applets/apps.py
Normal file
5
src/apps/applets/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AppletsConfig(AppConfig):
|
||||||
|
name = 'apps.applets'
|
||||||
36
src/apps/applets/migrations/0001_initial.py
Normal file
36
src/apps/applets/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Applet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('context', models.CharField(choices=[('dashboard', 'Dashboard'), ('gameboard', 'Gameboard')], default='dashboard', max_length=20)),
|
||||||
|
('default_visible', models.BooleanField(default=True)),
|
||||||
|
('grid_cols', models.PositiveSmallIntegerField(default=12)),
|
||||||
|
('grid_rows', models.PositiveSmallIntegerField(default=3)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserApplet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('visible', models.BooleanField(default=True)),
|
||||||
|
('applet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applets.applet')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_applets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={'unique_together': {('user', 'applet')}},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
src/apps/applets/migrations/0002_seed_applets.py
Normal file
29
src/apps/applets/migrations/0002_seed_applets.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name, cols, rows, context in [
|
||||||
|
('wallet', 'Wallet', 12, 3, 'dashboard'),
|
||||||
|
('new-list', 'New List', 9, 3, 'dashboard'),
|
||||||
|
('my-lists', 'My Lists', 3, 3, 'dashboard'),
|
||||||
|
('username', 'Username', 6, 3, 'dashboard'),
|
||||||
|
('palette', 'Palette', 6, 3, 'dashboard'),
|
||||||
|
('new-game', 'New Game', 4, 2, 'gameboard'),
|
||||||
|
('my-games', 'My Games', 4, 4, 'gameboard'),
|
||||||
|
('game-kit', 'Game Kit', 4, 2, 'gameboard'),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={'name': name, 'grid_cols': cols, 'grid_rows': rows, 'context': context},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0001_initial')
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_applets, migrations.RunPython.noop)
|
||||||
|
]
|
||||||
37
src/apps/applets/migrations/0003_wallet_applets.py
Normal file
37
src/apps/applets/migrations/0003_wallet_applets.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_wallet_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
('wallet-balances', 'Wallet Balances', 3, 3),
|
||||||
|
('wallet-tokens', 'Wallet Tokens', 3, 3),
|
||||||
|
('wallet-payment', 'Payment Methods', 6, 2),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={'name': name, 'grid_cols': cols, 'grid_rows': rows, 'context': 'wallet'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0002_seed_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='applet',
|
||||||
|
name='context',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('dashboard', 'Dashboard'),
|
||||||
|
('gameboard', 'Gameboard'),
|
||||||
|
('wallet', 'Wallet'),
|
||||||
|
],
|
||||||
|
default='dashboard',
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_wallet_applets, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
24
src/apps/applets/migrations/0004_rename_list_applet_slugs.py
Normal file
24
src/apps/applets/migrations/0004_rename_list_applet_slugs.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def rename_list_slugs(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='new-list').update(slug='new-note', name='New Note')
|
||||||
|
Applet.objects.filter(slug='my-lists').update(slug='my-notes', name='My Notes')
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_rename_list_slugs(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='new-note').update(slug='new-list', name='New List')
|
||||||
|
Applet.objects.filter(slug='my-notes').update(slug='my-lists', name='My Lists')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0003_wallet_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_list_slugs, reverse_rename_list_slugs),
|
||||||
|
]
|
||||||
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal file
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def increase_gameboard_applet_heights(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=3)
|
||||||
|
|
||||||
|
|
||||||
|
def revert_gameboard_applet_heights(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=2)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0004_rename_list_applet_slugs')
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
increase_gameboard_applet_heights,
|
||||||
|
revert_gameboard_applet_heights,
|
||||||
|
)
|
||||||
|
]
|
||||||
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||||
|
("billboard-my-contacts", "Contacts", 4, 3),
|
||||||
|
("billboard-most-recent", "Most Recent", 8, 6),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.filter(slug__in=[
|
||||||
|
"billboard-my-scrolls",
|
||||||
|
"billboard-my-contacts",
|
||||||
|
"billboard-most-recent",
|
||||||
|
]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0005_gameboard_applet_heights"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="applet",
|
||||||
|
name="context",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("dashboard", "Dashboard"),
|
||||||
|
("gameboard", "Gameboard"),
|
||||||
|
("wallet", "Wallet"),
|
||||||
|
("billboard", "Billboard"),
|
||||||
|
],
|
||||||
|
default="dashboard",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
|
||||||
|
]
|
||||||
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fix_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
# billboard-scroll belongs only to the billscroll page template, not the grid
|
||||||
|
Applet.objects.filter(slug="billboard-scroll").delete()
|
||||||
|
# Rename "My Contacts" → "Contacts"
|
||||||
|
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_fix_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="billboard-scroll",
|
||||||
|
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
|
||||||
|
)
|
||||||
|
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0006_billboard_applets"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
|
||||||
|
]
|
||||||
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
25
src/apps/applets/migrations/0008_game_kit_applets.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_game_kit_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name in [
|
||||||
|
('gk-trinkets', 'Trinkets'),
|
||||||
|
('gk-tokens', 'Tokens'),
|
||||||
|
('gk-decks', 'Card Decks'),
|
||||||
|
('gk-dice', 'Dice Sets'),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={'name': name, 'grid_cols': 3, 'grid_rows': 3, 'context': 'game-kit'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0007_fix_billboard_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_game_kit_applets, migrations.RunPython.noop)
|
||||||
|
]
|
||||||
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal file
24
src/apps/applets/migrations/0009_my_sky_applet.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_my_sky_applet(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug='my-sky',
|
||||||
|
defaults={
|
||||||
|
'name': 'My Sky',
|
||||||
|
'grid_cols': 6,
|
||||||
|
'grid_rows': 6,
|
||||||
|
'context': 'dashboard',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0008_game_kit_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_my_sky_applet, migrations.RunPython.noop)
|
||||||
|
]
|
||||||
25
src/apps/applets/migrations/0010_recognition_applet.py
Normal file
25
src/apps/applets/migrations/0010_recognition_applet.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_recognition_applet(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="billboard-recognition",
|
||||||
|
defaults={
|
||||||
|
"name": "Recognition",
|
||||||
|
"grid_cols": 4,
|
||||||
|
"grid_rows": 4,
|
||||||
|
"context": "billboard",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0009_my_sky_applet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_recognition_applet, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def rename_note_applets_to_post(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='new-note').update(slug='new-post', name='New Post', context='billboard')
|
||||||
|
Applet.objects.filter(slug='my-notes').update(slug='my-posts', name='My Posts', context='billboard')
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_rename_note_applets_to_post(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='new-post').update(slug='new-note', name='New Note', context='dashboard')
|
||||||
|
Applet.objects.filter(slug='my-posts').update(slug='my-notes', name='My Notes', context='dashboard')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0010_recognition_applet'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_note_applets_to_post, reverse_rename_note_applets_to_post),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def rename_recognition_applet_to_notes(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='billboard-recognition').update(
|
||||||
|
slug='billboard-notes',
|
||||||
|
name='My Notes',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_rename_recognition_applet_to_notes(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='billboard-notes').update(
|
||||||
|
slug='billboard-recognition',
|
||||||
|
name='Recognition',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0011_rename_note_applets_to_post'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
rename_recognition_applet_to_notes,
|
||||||
|
reverse_rename_recognition_applet_to_notes,
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/apps/applets/migrations/__init__.py
Normal file
0
src/apps/applets/migrations/__init__.py
Normal file
38
src/apps/applets/models.py
Normal file
38
src/apps/applets/models.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
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)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
context = models.CharField(max_length=20, choices=CONTEXT_CHOICES, default=DASHBOARD)
|
||||||
|
default_visible = models.BooleanField(default=True)
|
||||||
|
grid_cols = models.PositiveSmallIntegerField(default=12)
|
||||||
|
grid_rows = models.PositiveSmallIntegerField(default=3)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class UserApplet(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="user_applets",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
applet = models.ForeignKey(
|
||||||
|
Applet,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
visible = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("user", "applet")
|
||||||
42
src/apps/applets/static/apps/applets/applets.js
Normal file
42
src/apps/applets/static/apps/applets/applets.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const initGearMenus = () => {
|
||||||
|
document.querySelectorAll('.gear-btn').forEach(gear => {
|
||||||
|
const menuId = gear.dataset.menuTarget;
|
||||||
|
|
||||||
|
gear.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
if (!menu) return;
|
||||||
|
const opening = menu.style.display === 'none' || menu.style.display === '';
|
||||||
|
menu.style.display = opening ? 'block' : 'none';
|
||||||
|
gear.classList.toggle('active', opening);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const menu = document.getElementById(menuId);
|
||||||
|
if (!menu || menu.style.display === 'none') return;
|
||||||
|
if (e.target.closest('.applet-menu-cancel') || !menu.contains(e.target)) {
|
||||||
|
menu.style.display = 'none';
|
||||||
|
gear.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initGearMenus);
|
||||||
|
|
||||||
|
const appletContainerIds = new Set([
|
||||||
|
'id_applets_container',
|
||||||
|
'id_game_applets_container',
|
||||||
|
'id_gk_sections_container',
|
||||||
|
'id_wallet_applets_container',
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
if (!e.detail.target || !appletContainerIds.has(e.detail.target.id)) return;
|
||||||
|
document.querySelectorAll('.gear-btn').forEach(gear => {
|
||||||
|
const menu = document.getElementById(gear.dataset.menuTarget);
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
gear.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
0
src/apps/applets/tests/__init__.py
Normal file
0
src/apps/applets/tests/__init__.py
Normal file
0
src/apps/applets/tests/integrated/__init__.py
Normal file
0
src/apps/applets/tests/integrated/__init__.py
Normal file
65
src/apps/applets/tests/integrated/test_models.py
Normal file
65
src/apps/applets/tests/integrated/test_models.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from django.db.utils import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
from apps.applets.utils import applet_context
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class AppletModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.applet = Applet.objects.create(
|
||||||
|
slug="my-applet", name="My Applet", default_visible=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_applet_can_be_created(self):
|
||||||
|
self.assertEqual(Applet.objects.get(slug="my-applet"), self.applet)
|
||||||
|
|
||||||
|
def test_applet_slug_is_unique(self):
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
Applet.objects.create(slug="my-applet", name="Second")
|
||||||
|
|
||||||
|
def test_applet_str(self):
|
||||||
|
self.assertEqual(str(self.applet), "My Applet")
|
||||||
|
|
||||||
|
def test_applet_grid_defaults(self):
|
||||||
|
self.assertEqual(self.applet.grid_cols, 12)
|
||||||
|
self.assertEqual(self.applet.grid_rows, 3)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAppletModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
|
self.applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
|
||||||
|
def test_user_applet_links_user_to_applet(self):
|
||||||
|
ua = UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
|
||||||
|
self.assertIn(ua, self.user.user_applets.all())
|
||||||
|
|
||||||
|
def test_user_applet_unique_per_user_and_applet(self):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.applet, visible=False)
|
||||||
|
|
||||||
|
class AppletContextTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
|
self.dash_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username", "context": "dashboard"})
|
||||||
|
self.game_applet, _ = Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
|
||||||
|
|
||||||
|
def test_filters_by_context(self):
|
||||||
|
result = applet_context(self.user, "dashboard")
|
||||||
|
slugs = [e["applet"].slug for e in result]
|
||||||
|
self.assertIn("username", slugs)
|
||||||
|
self.assertNotIn("new-game", slugs)
|
||||||
|
|
||||||
|
def test_defaults_to_applet_default_visible(self):
|
||||||
|
result = applet_context(self.user, "dashboard")
|
||||||
|
[entry] = [e for e in result if e["applet"].slug == "username"]
|
||||||
|
self.assertTrue(entry["visible"])
|
||||||
|
|
||||||
|
def test_respects_user_applet_visible_false(self):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.dash_applet, visible=False)
|
||||||
|
result = applet_context(self.user, "dashboard")
|
||||||
|
[entry] = [e for e in result if e["applet"].slug == "username"]
|
||||||
|
self.assertFalse(entry["visible"])
|
||||||
21
src/apps/applets/utils.py
Normal file
21
src/apps/applets/utils.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
|
||||||
|
|
||||||
|
def apply_applet_toggle(user, context, checked_slugs):
|
||||||
|
"""Persist applet visibility choices for a given context."""
|
||||||
|
for applet in Applet.objects.filter(context=context):
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked_slugs},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def applet_context(user, context):
|
||||||
|
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
||||||
|
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
|
||||||
|
return [
|
||||||
|
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
|
||||||
|
for slug in applets
|
||||||
|
if slug in applets
|
||||||
|
]
|
||||||
0
src/apps/billboard/__init__.py
Normal file
0
src/apps/billboard/__init__.py
Normal file
6
src/apps/billboard/apps.py
Normal file
6
src/apps/billboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillboardConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.billboard'
|
||||||
245
src/apps/billboard/static/apps/billboard/note-page.js
Normal file
245
src/apps/billboard/static/apps/billboard/note-page.js
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var _selectedPalette = null;
|
||||||
|
var _activeItem = null;
|
||||||
|
var _originalPalette = null;
|
||||||
|
var _dismissTimer = null;
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _activeModal() {
|
||||||
|
return _activeItem && _activeItem.querySelector('.note-palette-modal');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _paletteClass(el) {
|
||||||
|
return Array.from(el.classList).find(function (c) { return c.startsWith('palette-'); }) || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _currentBodyPalette() {
|
||||||
|
return Array.from(document.body.classList).find(function (c) { return c.startsWith('palette-'); }) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _swapBodyPalette(paletteName) {
|
||||||
|
var old = _currentBodyPalette();
|
||||||
|
if (old) document.body.classList.remove(old);
|
||||||
|
document.body.classList.add(paletteName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _revertBodyPalette() {
|
||||||
|
var current = _currentBodyPalette();
|
||||||
|
if (current) document.body.classList.remove(current);
|
||||||
|
if (_originalPalette) document.body.classList.add(_originalPalette);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getCsrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _showConfirm(modal) {
|
||||||
|
var el = modal && modal.querySelector('.note-palette-confirm');
|
||||||
|
if (el) el.style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _hideConfirm(modal) {
|
||||||
|
var el = modal && modal.querySelector('.note-palette-confirm');
|
||||||
|
if (el) el.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── modal lifecycle ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _openModal() {
|
||||||
|
var existing = _activeModal();
|
||||||
|
if (!existing) {
|
||||||
|
var tpl = _activeItem.querySelector('.note-palette-modal-tpl');
|
||||||
|
if (!tpl) return;
|
||||||
|
var clone = tpl.content.firstElementChild.cloneNode(true);
|
||||||
|
_activeItem.appendChild(clone);
|
||||||
|
_wireModal();
|
||||||
|
}
|
||||||
|
_activeItem.classList.add('note-item--active');
|
||||||
|
_hideConfirm(_activeModal());
|
||||||
|
}
|
||||||
|
|
||||||
|
function _closeModal() {
|
||||||
|
clearTimeout(_dismissTimer);
|
||||||
|
_dismissTimer = null;
|
||||||
|
var modal = _activeModal();
|
||||||
|
if (modal) modal.remove();
|
||||||
|
if (_activeItem) _activeItem.classList.remove('note-item--active');
|
||||||
|
_activeItem = null;
|
||||||
|
_selectedPalette = null;
|
||||||
|
_originalPalette = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _revertPreview() {
|
||||||
|
clearTimeout(_dismissTimer);
|
||||||
|
_dismissTimer = null;
|
||||||
|
_revertBodyPalette();
|
||||||
|
var modal = _activeModal();
|
||||||
|
if (modal) {
|
||||||
|
modal.querySelectorAll('.note-swatch-body.previewing').forEach(function (s) {
|
||||||
|
s.classList.remove('previewing');
|
||||||
|
});
|
||||||
|
_hideConfirm(modal);
|
||||||
|
}
|
||||||
|
_selectedPalette = null;
|
||||||
|
_originalPalette = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire event listeners onto the freshly-cloned modal DOM.
|
||||||
|
function _wireModal() {
|
||||||
|
var modal = _activeModal();
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
// Swatch body click → preview palette sitewide + show confirm
|
||||||
|
modal.querySelectorAll('.note-swatch-body').forEach(function (body) {
|
||||||
|
body.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (_selectedPalette) _revertPreview();
|
||||||
|
|
||||||
|
_selectedPalette = _paletteClass(body.parentElement);
|
||||||
|
_originalPalette = _currentBodyPalette();
|
||||||
|
|
||||||
|
body.classList.add('previewing');
|
||||||
|
_swapBodyPalette(_selectedPalette);
|
||||||
|
_showConfirm(modal);
|
||||||
|
|
||||||
|
_dismissTimer = setTimeout(function () {
|
||||||
|
_revertPreview();
|
||||||
|
}, 10000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm OK → commit palette sitewide
|
||||||
|
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_doSetPalette();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Confirm NVM → revert preview only; main swatch modal stays open
|
||||||
|
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_revertPreview();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop modal clicks from reaching the body dismiss handler.
|
||||||
|
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── set-palette POST ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _doSetPalette() {
|
||||||
|
var url = _activeItem.dataset.setPaletteUrl;
|
||||||
|
var palette = _selectedPalette;
|
||||||
|
var item = _activeItem;
|
||||||
|
// Read label from swatch row while modal is still in DOM
|
||||||
|
var swatchRow = _activeModal() && _activeModal().querySelector('.' + palette + '[data-palette-label]');
|
||||||
|
var paletteLabel = swatchRow
|
||||||
|
? swatchRow.dataset.paletteLabel
|
||||||
|
: palette.slice(8).replace(/^\w/, function (c) { return c.toUpperCase(); });
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-CSRFToken': _getCsrf(),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ palette: palette }),
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function () {
|
||||||
|
_closeModal();
|
||||||
|
var imageBox = item.querySelector('.note-item__image-box');
|
||||||
|
if (imageBox) {
|
||||||
|
var swatch = document.createElement('div');
|
||||||
|
swatch.className = 'note-item__palette ' + palette;
|
||||||
|
imageBox.parentNode.replaceChild(swatch, imageBox);
|
||||||
|
}
|
||||||
|
var list = item.querySelector('.note-recognitions__list');
|
||||||
|
if (list && !item.querySelector('.note-recognitions__palette-line')) {
|
||||||
|
var li = document.createElement('li');
|
||||||
|
li.className = 'note-recognitions__palette-line';
|
||||||
|
li.innerHTML = '<span class="note-recognitions__dim">Palette:</span> <strong>' + paletteLabel + '</strong>';
|
||||||
|
list.appendChild(li);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _setGreeting(name) {
|
||||||
|
var el = document.getElementById('id_greeting_name');
|
||||||
|
if (el) el.textContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _bindDonDoff(item) {
|
||||||
|
var donBtn = item.querySelector('.note-don-btn');
|
||||||
|
var doffBtn = item.querySelector('.note-doff-btn');
|
||||||
|
if (!donBtn || !doffBtn) return;
|
||||||
|
|
||||||
|
donBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (donBtn.classList.contains('btn-disabled')) return;
|
||||||
|
fetch(item.dataset.donUrl, {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': _getCsrf() },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
_setGreeting(data.title);
|
||||||
|
donBtn.classList.add('btn-disabled');
|
||||||
|
donBtn.textContent = '×';
|
||||||
|
doffBtn.classList.remove('btn-disabled');
|
||||||
|
doffBtn.textContent = 'DOFF';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
doffBtn.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (doffBtn.classList.contains('btn-disabled')) return;
|
||||||
|
fetch(item.dataset.doffUrl, {
|
||||||
|
method: 'POST', credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRFToken': _getCsrf() },
|
||||||
|
})
|
||||||
|
.then(function () {
|
||||||
|
_setGreeting('Earthman');
|
||||||
|
doffBtn.classList.add('btn-disabled');
|
||||||
|
doffBtn.textContent = '×';
|
||||||
|
donBtn.classList.remove('btn-disabled');
|
||||||
|
donBtn.textContent = 'DON';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function _init() {
|
||||||
|
document.querySelectorAll('.note-item__image-box').forEach(function (box) {
|
||||||
|
box.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_activeItem = box.closest('.note-item');
|
||||||
|
_openModal();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelectorAll('.note-item').forEach(function (item) {
|
||||||
|
_bindDonDoff(item);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body click → dismiss modal and revert any preview
|
||||||
|
document.body.addEventListener('click', function () {
|
||||||
|
if (_selectedPalette) _revertPreview();
|
||||||
|
_closeModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', _init);
|
||||||
|
} else {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
}());
|
||||||
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
367
src/apps/billboard/tests/integrated/test_views.py
Normal file
367
src/apps/billboard/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
import json as _json
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.drama.models import GameEvent, Note, 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 SaveScrollPositionViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="reader@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
self.url = f"/billboard/room/{self.room.id}/scroll-position/"
|
||||||
|
|
||||||
|
def test_get_returns_405(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleBillboardAppletsTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@toggle.io")
|
||||||
|
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 NotePageViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="recog@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_returns_200(self):
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_uses_note_page_template(self):
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/my_notes.html")
|
||||||
|
|
||||||
|
def test_passes_notes_in_context(self):
|
||||||
|
recog = Note.objects.create(
|
||||||
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertIn(recog, response.context["notes"])
|
||||||
|
|
||||||
|
def test_excludes_other_users_notes(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
Note.objects.create(
|
||||||
|
user=other, slug="stargazer", earned_at=timezone.now()
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertEqual(list(response.context["notes"]), [])
|
||||||
|
|
||||||
|
def test_renders_recog_list_and_items(self):
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertContains(response, 'class="note-list"')
|
||||||
|
self.assertContains(response, 'class="note-item"')
|
||||||
|
|
||||||
|
def test_renders_recog_item_title_description_image_box(self):
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertContains(response, 'class="note-item__title"')
|
||||||
|
self.assertContains(response, 'class="note-item__description"')
|
||||||
|
self.assertContains(response, 'class="note-item__image-box"')
|
||||||
|
|
||||||
|
def test_palette_modal_renders_swatch_labels(self):
|
||||||
|
"""Each palette option in the swatch modal should display its human-readable
|
||||||
|
label next to the swatch body so the user knows what they are choosing."""
|
||||||
|
Note.objects.create(
|
||||||
|
user=self.user, slug="stargazer", earned_at=timezone.now()
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/my-notes/")
|
||||||
|
self.assertContains(response, 'class="note-swatch-label"')
|
||||||
|
self.assertContains(response, "Bardo")
|
||||||
|
self.assertContains(response, "Sheol")
|
||||||
|
|
||||||
|
|
||||||
|
class NoteSetPaletteViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="setpal@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.note = Note.objects.create(
|
||||||
|
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
self.url = "/billboard/note/stargazer/set-palette"
|
||||||
|
|
||||||
|
def test_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=_json.dumps({"palette": "palette-bardo"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_sets_palette_on_note(self):
|
||||||
|
self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=_json.dumps({"palette": "palette-bardo"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.note.refresh_from_db()
|
||||||
|
self.assertEqual(self.note.palette, "palette-bardo")
|
||||||
|
|
||||||
|
def test_returns_200_with_ok(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=_json.dumps({"palette": "palette-bardo"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
|
||||||
|
def test_returns_404_for_slug_user_does_not_own(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/billboard/note/schizo/set-palette",
|
||||||
|
data=_json.dumps({"palette": "palette-bardo"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_also_saves_user_palette(self):
|
||||||
|
"""note_set_palette must persist the choice to user.palette so the
|
||||||
|
palette survives page navigation (sitewide commitment)."""
|
||||||
|
self.client.post(
|
||||||
|
self.url,
|
||||||
|
data=_json.dumps({"palette": "palette-bardo"}),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.palette, "palette-bardo")
|
||||||
|
|
||||||
|
|
||||||
|
class NoteEquipTitleViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="don@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.note = Note.objects.create(
|
||||||
|
user=self.user, slug="stargazer", earned_at=timezone.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_don_sets_active_title(self):
|
||||||
|
self.client.post("/billboard/note/stargazer/don")
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.active_title, self.note)
|
||||||
|
|
||||||
|
def test_doff_clears_active_title(self):
|
||||||
|
self.user.active_title = self.note
|
||||||
|
self.user.save(update_fields=["active_title"])
|
||||||
|
self.client.post("/billboard/note/stargazer/doff")
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertIsNone(self.user.active_title)
|
||||||
|
|
||||||
|
def test_don_returns_200_with_title(self):
|
||||||
|
response = self.client.post("/billboard/note/stargazer/don")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["title"], "Stargazer")
|
||||||
|
|
||||||
|
def test_doff_returns_200(self):
|
||||||
|
response = self.client.post("/billboard/note/stargazer/doff")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"ok": True})
|
||||||
|
|
||||||
|
def test_don_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post("/billboard/note/stargazer/don")
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_don_returns_404_for_unowned_note(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
Note.objects.create(user=other, slug="stargazer", earned_at=timezone.now())
|
||||||
|
self.client.logout()
|
||||||
|
self.client.force_login(other)
|
||||||
|
response = self.client.post("/billboard/note/stargazer/don")
|
||||||
|
# other user's own note — should work
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveScrollPositionTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@savescroll.io")
|
||||||
|
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)
|
||||||
16
src/apps/billboard/urls.py
Normal file
16
src/apps/billboard/urls.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.billboard import views
|
||||||
|
|
||||||
|
app_name = "billboard"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.billboard, name="billboard"),
|
||||||
|
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||||
|
path("my-notes/", views.my_notes, name="my_notes"),
|
||||||
|
path("note/<slug:slug>/set-palette", views.note_set_palette, name="note_set_palette"),
|
||||||
|
path("note/<slug:slug>/don", views.don_title, name="don_title"),
|
||||||
|
path("note/<slug:slug>/doff", views.doff_title, name="doff_title"),
|
||||||
|
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||||
|
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||||
|
]
|
||||||
193
src/apps/billboard/views.py
Normal file
193
src/apps/billboard/views.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Max, Q
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
|
from apps.applets.utils import applet_context, apply_applet_toggle
|
||||||
|
from apps.dashboard.forms import LineForm
|
||||||
|
from apps.dashboard.models import Post
|
||||||
|
from apps.dashboard.views import _PALETTE_DEFS
|
||||||
|
from apps.drama.models import GameEvent, Note, ScrollPosition
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.epic.utils import rooms_for_user
|
||||||
|
|
||||||
|
_PALETTE_LABELS = {p["name"]: p["label"] for p in _PALETTE_DEFS}
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_posts(user, limit=3):
|
||||||
|
return (
|
||||||
|
Post
|
||||||
|
.objects
|
||||||
|
.filter(Q(owner=user) | Q(shared_with=user))
|
||||||
|
.annotate(last_line=Max('lines__id'))
|
||||||
|
.order_by('-last_line')
|
||||||
|
.distinct()[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def billboard(request):
|
||||||
|
my_rooms = rooms_for_user(request.user).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"),
|
||||||
|
"form": LineForm(),
|
||||||
|
"recent_posts": _recent_posts(request.user),
|
||||||
|
"page_class": "page-billboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_billboard_applets(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
apply_applet_toggle(request.user, "billboard", checked)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||||
|
"applets": applet_context(request.user, "billboard"),
|
||||||
|
"form": LineForm(),
|
||||||
|
"recent_posts": _recent_posts(request.user),
|
||||||
|
})
|
||||||
|
return redirect("billboard:billboard")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def room_scroll(request, room_id):
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
events = room.events.select_related("actor").all()
|
||||||
|
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
|
||||||
|
return render(request, "apps/billboard/room_scroll.html", {
|
||||||
|
"room": room,
|
||||||
|
"events": events,
|
||||||
|
"viewer": request.user,
|
||||||
|
"scroll_position": sp.position if sp else 0,
|
||||||
|
"page_class": "page-billscroll",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _palette_opts(names):
|
||||||
|
return [{"name": n, "label": _PALETTE_LABELS.get(n, n)} for n in names]
|
||||||
|
|
||||||
|
|
||||||
|
_NOTE_META = {
|
||||||
|
"stargazer": {
|
||||||
|
"title": "Stargazer",
|
||||||
|
"description": "You saved your first personal sky chart.",
|
||||||
|
"palette_options": _palette_opts(["palette-bardo", "palette-sheol"]),
|
||||||
|
},
|
||||||
|
"schizo": {
|
||||||
|
"title": "Schizo",
|
||||||
|
"description": "The socius recognizes the line of flight.",
|
||||||
|
"palette_options": [],
|
||||||
|
},
|
||||||
|
"nomad": {
|
||||||
|
"title": "Nomad",
|
||||||
|
"description": "The socius recognizes the smooth space.",
|
||||||
|
"palette_options": [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def note_set_palette(request, slug):
|
||||||
|
from django.http import Http404
|
||||||
|
from apps.dashboard.views import _unlocked_palettes_for_user
|
||||||
|
try:
|
||||||
|
note = Note.objects.get(user=request.user, slug=slug)
|
||||||
|
except Note.DoesNotExist:
|
||||||
|
raise Http404
|
||||||
|
if request.method == "POST":
|
||||||
|
body = json.loads(request.body)
|
||||||
|
palette = body.get("palette", "")
|
||||||
|
note.palette = palette
|
||||||
|
note.save(update_fields=["palette"])
|
||||||
|
# Commit as the user's active sitewide palette now that the Note unlocks it.
|
||||||
|
if palette in _unlocked_palettes_for_user(request.user):
|
||||||
|
request.user.palette = palette
|
||||||
|
request.user.save(update_fields=["palette"])
|
||||||
|
return JsonResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def my_notes(request):
|
||||||
|
qs = Note.objects.filter(user=request.user)
|
||||||
|
active_title = request.user.active_title
|
||||||
|
note_items = [
|
||||||
|
{
|
||||||
|
"obj": n,
|
||||||
|
"title": _NOTE_META.get(n.slug, {}).get("title", n.slug),
|
||||||
|
"description": _NOTE_META.get(n.slug, {}).get("description", ""),
|
||||||
|
"palette_options": _NOTE_META.get(n.slug, {}).get("palette_options", []),
|
||||||
|
"palette_label": _PALETTE_LABELS.get(n.palette, "") if n.palette else "",
|
||||||
|
"is_equipped": active_title is not None and active_title.pk == n.pk,
|
||||||
|
}
|
||||||
|
for n in qs
|
||||||
|
]
|
||||||
|
return render(request, "apps/billboard/my_notes.html", {
|
||||||
|
"notes": qs,
|
||||||
|
"note_items": note_items,
|
||||||
|
"page_class": "page-notes",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def don_title(request, slug):
|
||||||
|
from django.http import Http404
|
||||||
|
try:
|
||||||
|
note = Note.objects.get(user=request.user, slug=slug)
|
||||||
|
except Note.DoesNotExist:
|
||||||
|
raise Http404
|
||||||
|
if request.method == "POST":
|
||||||
|
request.user.active_title = note
|
||||||
|
request.user.save(update_fields=["active_title"])
|
||||||
|
title = _NOTE_META.get(slug, {}).get("title", slug.capitalize())
|
||||||
|
return JsonResponse({"title": title})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def doff_title(request, slug):
|
||||||
|
if request.method == "POST":
|
||||||
|
request.user.active_title = None
|
||||||
|
request.user.save(update_fields=["active_title"])
|
||||||
|
return JsonResponse({"ok": True})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def save_scroll_position(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
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)
|
||||||
@@ -1,3 +1 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
|||||||
@@ -1,32 +1,32 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from .models import Item
|
from .models import Line
|
||||||
|
|
||||||
DUPLICATE_ITEM_ERROR = "You've already logged this to your list"
|
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
|
||||||
EMPTY_ITEM_ERROR = "You can't have an empty list item"
|
EMPTY_LINE_ERROR = "You can't have an empty post line"
|
||||||
|
|
||||||
class ItemForm(forms.Form):
|
class LineForm(forms.Form):
|
||||||
text = forms.CharField(
|
text = forms.CharField(
|
||||||
error_messages = {"required": EMPTY_ITEM_ERROR},
|
error_messages = {"required": EMPTY_LINE_ERROR},
|
||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, for_list):
|
def save(self, for_post):
|
||||||
return Item.objects.create(
|
return Line.objects.create(
|
||||||
list=for_list,
|
post=for_post,
|
||||||
text=self.cleaned_data["text"],
|
text=self.cleaned_data["text"],
|
||||||
)
|
)
|
||||||
|
|
||||||
class ExistingListItemForm(ItemForm):
|
class ExistingPostLineForm(LineForm):
|
||||||
def __init__(self, for_list, *args, **kwargs):
|
def __init__(self, for_post, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._for_list = for_list
|
self._for_post = for_post
|
||||||
|
|
||||||
def clean_text(self):
|
def clean_text(self):
|
||||||
text = self.cleaned_data["text"]
|
text = self.cleaned_data["text"]
|
||||||
if self._for_list.item_set.filter(text=text).exists():
|
if self._for_post.lines.filter(text=text).exists():
|
||||||
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
|
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
return super().save(for_list=self._for_list)
|
return super().save(for_post=self._for_post)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Generated by Django 6.0 on 2026-02-08 01:19
|
# Generated by Django 6.0 on 2026-02-23 04:30
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -9,13 +11,16 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='List',
|
name='List',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('shared_with', models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-02-09 03:29
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='list',
|
|
||||||
name='owner',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
20
src/apps/dashboard/migrations/0002_rename_list_to_note.py
Normal file
20
src/apps/dashboard/migrations/0002_rename_list_to_note.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name='List',
|
||||||
|
new_name='Note',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='Item',
|
||||||
|
old_name='list',
|
||||||
|
new_name='note',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-12 19:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0002_rename_list_to_note'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shared_notes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-02-18 18:13
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0002_list_owner'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='list',
|
|
||||||
name='shared_with',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal file
18
src/apps/dashboard/migrations/0004_rename_note_to_post.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0003_alter_note_owner_alter_note_shared_with'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel('Note', 'Post'),
|
||||||
|
migrations.RenameModel('Item', 'Line'),
|
||||||
|
migrations.RenameField('Line', 'note', 'post'),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='line',
|
||||||
|
unique_together={('post', 'text')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-23 05:42
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0004_rename_note_to_post'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='line',
|
||||||
|
name='post',
|
||||||
|
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='dashboard.post'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='post',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
class List(models.Model):
|
|
||||||
|
class Post(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
"lyric.User",
|
"lyric.User",
|
||||||
related_name="lists",
|
related_name="posts",
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -12,25 +16,24 @@ class List(models.Model):
|
|||||||
|
|
||||||
shared_with = models.ManyToManyField(
|
shared_with = models.ManyToManyField(
|
||||||
"lyric.User",
|
"lyric.User",
|
||||||
related_name="shared_lists",
|
related_name="shared_posts",
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.item_set.first().text
|
return self.lines.first().text
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("view_list", args=[self.id])
|
return reverse("view_post", args=[self.id])
|
||||||
|
|
||||||
class Item(models.Model):
|
class Line(models.Model):
|
||||||
text = models.TextField(default="")
|
text = models.TextField(default="")
|
||||||
list = models.ForeignKey(List, default=None, on_delete=models.CASCADE)
|
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("id",)
|
ordering = ("id",)
|
||||||
unique_together = ("list", "text")
|
unique_together = ("post", "text")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.text
|
return self.text
|
||||||
|
|
||||||
|
|||||||
166
src/apps/dashboard/static/apps/dashboard/dashboard.js
Normal file
166
src/apps/dashboard/static/apps/dashboard/dashboard.js
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
const initialize = (inputSelector) => {
|
||||||
|
const textInput = document.querySelector(inputSelector);
|
||||||
|
if (!textInput) return;
|
||||||
|
textInput.oninput = () => textInput.classList.remove("is-invalid");
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindPaletteWheel = () => {
|
||||||
|
document.querySelectorAll('.palette-scroll').forEach(el => {
|
||||||
|
el.addEventListener('wheel', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
el.scrollLeft += e.deltaY;
|
||||||
|
}, { passive: false });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Palette swatch preview + commit ──────────────────────────────────────────
|
||||||
|
|
||||||
|
const bindPaletteSwatches = () => {
|
||||||
|
const portal = document.getElementById('id_tooltip_portal');
|
||||||
|
let activePreview = null;
|
||||||
|
let originalPalette = null;
|
||||||
|
let dismissTimer = null;
|
||||||
|
|
||||||
|
function currentBodyPalette() {
|
||||||
|
return [...document.body.classList].find(c => c.startsWith('palette-'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function swapPalette(paletteName) {
|
||||||
|
const old = currentBodyPalette();
|
||||||
|
if (old) document.body.classList.remove(old);
|
||||||
|
document.body.classList.add(paletteName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTooltip(swatch) {
|
||||||
|
if (!portal) return;
|
||||||
|
const label = swatch.dataset.label || '';
|
||||||
|
const locked = swatch.dataset.locked === 'true';
|
||||||
|
const description = swatch.dataset.description || '';
|
||||||
|
const unlockedDate = swatch.dataset.unlockedDate || '';
|
||||||
|
const lockIcon = locked ? 'fa-lock' : 'fa-lock-open';
|
||||||
|
const lockText = locked ? 'Locked' : 'Unlocked';
|
||||||
|
|
||||||
|
let dateLine = '';
|
||||||
|
if (unlockedDate) {
|
||||||
|
const dt = new Date(unlockedDate);
|
||||||
|
const dateStr = dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
|
const timeStr = dt.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
|
||||||
|
dateLine = `<p class="tt-date">${dateStr} · ${timeStr}</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
portal.innerHTML = `
|
||||||
|
<h4 class="tt-title">${label}</h4>
|
||||||
|
${description ? `<p class="tt-description"><em>${description}</em></p>` : ''}
|
||||||
|
<p class="tt-lock"><i class="fa-solid ${lockIcon}"></i> ${lockText}</p>
|
||||||
|
${dateLine}`;
|
||||||
|
|
||||||
|
const rect = swatch.getBoundingClientRect();
|
||||||
|
portal.style.display = 'block';
|
||||||
|
portal.style.position = 'fixed';
|
||||||
|
portal.style.top = `${rect.bottom + 8}px`;
|
||||||
|
portal.style.zIndex = '9999';
|
||||||
|
const margin = 8;
|
||||||
|
const ttW = portal.offsetWidth;
|
||||||
|
portal.style.left = `${Math.max(margin, Math.min(rect.left, window.innerWidth - ttW - margin))}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideTooltip() {
|
||||||
|
if (!portal) return;
|
||||||
|
portal.style.display = 'none';
|
||||||
|
portal.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
if (!activePreview) return;
|
||||||
|
clearTimeout(dismissTimer);
|
||||||
|
const paletteName = activePreview.dataset.palette;
|
||||||
|
activePreview.classList.remove('previewing');
|
||||||
|
activePreview.querySelector('.palette-ok').style.display = '';
|
||||||
|
document.body.classList.remove(paletteName);
|
||||||
|
if (originalPalette) document.body.classList.add(originalPalette);
|
||||||
|
activePreview = null;
|
||||||
|
originalPalette = null;
|
||||||
|
hideTooltip();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitPalette(swatch, paletteName) {
|
||||||
|
// Silent commit — no animation, wipe already happened on preview
|
||||||
|
const old = originalPalette;
|
||||||
|
swatch.classList.remove('previewing');
|
||||||
|
swatch.querySelector('.palette-ok').style.display = '';
|
||||||
|
hideTooltip();
|
||||||
|
activePreview = null;
|
||||||
|
originalPalette = null;
|
||||||
|
clearTimeout(dismissTimer);
|
||||||
|
|
||||||
|
// Remove old palette, keep new one (already on body from preview)
|
||||||
|
if (old && old !== paletteName) {
|
||||||
|
document.body.classList.remove(old);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update active indicator
|
||||||
|
document.querySelectorAll('.swatch').forEach(sw => {
|
||||||
|
sw.classList.toggle('active', sw.classList.contains(paletteName));
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST to server
|
||||||
|
const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] || '';
|
||||||
|
await fetch('/dashboard/set_palette', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Accept': 'application/json', 'X-CSRFToken': csrf },
|
||||||
|
body: new URLSearchParams({ palette: paletteName }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.palette-item .swatch').forEach(swatch => {
|
||||||
|
swatch.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (swatch.classList.contains('previewing')) return;
|
||||||
|
|
||||||
|
dismiss(); // clear any existing preview
|
||||||
|
|
||||||
|
originalPalette = currentBodyPalette();
|
||||||
|
activePreview = swatch;
|
||||||
|
|
||||||
|
swatch.classList.add('previewing');
|
||||||
|
showTooltip(swatch);
|
||||||
|
swapPalette(swatch.dataset.palette);
|
||||||
|
swatch.querySelector('.palette-ok').style.display = 'flex';
|
||||||
|
|
||||||
|
// Auto-dismiss after 10s
|
||||||
|
dismissTimer = setTimeout(dismiss, 10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
const okBtn = swatch.querySelector('.btn-confirm.palette-ok');
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.addEventListener('click', async (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await commitPalette(swatch, swatch.dataset.palette);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', () => dismiss());
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindPaletteForms = () => {
|
||||||
|
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Accept": "application/json" },
|
||||||
|
body: new FormData(form, e.submitter),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const { palette } = await resp.json();
|
||||||
|
[...document.body.classList]
|
||||||
|
.filter(c => c.startsWith("palette-"))
|
||||||
|
.forEach(c => document.body.classList.remove(c));
|
||||||
|
document.body.classList.add(palette);
|
||||||
|
document.querySelectorAll(".swatch").forEach(sw => {
|
||||||
|
sw.classList.toggle("active", sw.classList.contains(palette));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
100
src/apps/dashboard/static/apps/dashboard/game-kit.js
Normal file
100
src/apps/dashboard/static/apps/dashboard/game-kit.js
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('id_kit_btn');
|
||||||
|
var dialog = document.getElementById('id_kit_bag_dialog');
|
||||||
|
if (!btn || !dialog) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (dialog.hasAttribute('open')) {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(btn.dataset.kitUrl, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (html) {
|
||||||
|
dialog.innerHTML = html;
|
||||||
|
attachCardListeners();
|
||||||
|
btn.classList.add('active');
|
||||||
|
dialog.setAttribute('open', '');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && dialog.hasAttribute('open')) {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside (but not on the rails button — let that flow through)
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!dialog.hasAttribute('open')) return;
|
||||||
|
if (dialog.contains(e.target)) return;
|
||||||
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
if (e.target.closest('button.token-rails')) return;
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject token_id before token-rails form submits
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var rails = e.target.closest('button.token-rails');
|
||||||
|
if (!rails || !window._kitTokenId) return;
|
||||||
|
var form = rails.closest('form');
|
||||||
|
if (!form) return;
|
||||||
|
var existing = form.querySelector('input[name="token_id"]');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
var hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.name = 'token_id';
|
||||||
|
hidden.value = window._kitTokenId;
|
||||||
|
form.appendChild(hidden);
|
||||||
|
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachTooltip(el) {
|
||||||
|
el.addEventListener('mouseenter', function () {
|
||||||
|
var tooltip = el.querySelector('.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 () {
|
||||||
|
dialog.querySelectorAll('.token[data-token-id].selected').forEach(function (c) {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
});
|
||||||
|
card.classList.add('selected');
|
||||||
|
window._kitTokenId = card.dataset.tokenId;
|
||||||
|
var slot = document.querySelector('.token-slot');
|
||||||
|
if (slot) slot.classList.add('ready');
|
||||||
|
});
|
||||||
|
attachTooltip(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
dialog.querySelectorAll('.kit-bag-deck').forEach(attachTooltip);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}());
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user