Compare commits
404 Commits
pre-drf
...
be919c7aff
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
be919c7aff | ||
|
|
264ed5968e | ||
|
|
7015ddd534 | ||
|
|
880408285a | ||
|
|
df99cad984 | ||
|
|
22d0507c3f | ||
|
|
419e022140 | ||
|
|
4010e452a6 | ||
|
|
72fefe2fc7 | ||
|
|
47871b5b4a | ||
|
|
ad9f7b43ed | ||
|
|
3ab60c67b6 | ||
|
|
c426ca69fa | ||
|
|
e0ace01670 | ||
|
|
eb0369f0b7 | ||
|
|
11ff109d1e | ||
|
|
246e45e55d | ||
|
|
5f6002aa70 | ||
|
|
b3eb14140c | ||
|
|
6f76f6c176 | ||
|
|
ba5f6556c0 | ||
|
|
e465b6a3b3 | ||
|
|
7b2780e642 | ||
|
|
14bab444ff | ||
|
|
fa53bf561a | ||
|
|
7f9ff36d1d | ||
|
|
d192b1522d | ||
|
|
f659a64b91 | ||
|
|
a319318740 | ||
|
|
d58fd1db15 | ||
|
|
846f9ff461 | ||
|
|
1111df8465 | ||
|
|
8a8d1536b1 | ||
|
|
301b4e8201 | ||
|
|
097a5dd437 | ||
|
|
e9bceaab62 | ||
|
|
283b417341 | ||
|
|
c8d7b055d7 | ||
|
|
9ff437012a | ||
|
|
bbd1b22bb0 | ||
|
|
05c9f9c079 | ||
|
|
319b787109 | ||
|
|
3beedc3f0a | ||
|
|
9e68cfd8e4 | ||
|
|
4f2c7d9577 | ||
|
|
cc2a3f3526 | ||
|
|
19b7828ea9 | ||
|
|
a97cd8dcff | ||
|
|
c9563308d8 | ||
|
|
5413e63585 | ||
|
|
29493c4f74 | ||
|
|
599d40decd | ||
|
|
2dc68c41a7 | ||
|
|
b1a11504f5 | ||
|
|
f78177778f | ||
|
|
480cb4aed6 | ||
|
|
9b93b9d31b | ||
|
|
b29bcf5c38 | ||
|
|
08243d109d | ||
|
|
75fcc5b34d | ||
|
|
536a558f26 | ||
|
|
8b0ad545c9 | ||
|
|
3410f073f0 | ||
|
|
c264b6e3ee | ||
|
|
da57106d7a | ||
|
|
270e48ab2c | ||
|
|
2f039559e6 | ||
|
|
61162e36da | ||
|
|
26a3af21fa | ||
|
|
d728900c24 | ||
|
|
2dae861f30 | ||
|
|
98354fd27b | ||
|
|
7712cf1d56 | ||
|
|
e084bcc2d5 | ||
|
|
08aa4dc819 | ||
|
|
2af59b3a7f | ||
|
|
6d75b9541f | ||
|
|
132e60864e | ||
|
|
ff3e4d295c | ||
|
|
39e12d6a3d | ||
|
|
379e0ab80c | ||
|
|
b5fbc3d354 | ||
|
|
4852113fbd | ||
|
|
239da7e5b1 | ||
|
|
ed55e4e529 | ||
|
|
2757ae855f | ||
|
|
505744312b | ||
|
|
0522b5c126 | ||
|
|
759ce8d3e4 | ||
|
|
9eb1c1523e | ||
|
|
c399afa26d | ||
|
|
4b8e02b698 | ||
|
|
478e845ecf | ||
|
|
d79380faa5 | ||
|
|
e78bbb873b | ||
|
|
763d555f0c | ||
|
|
6ad736413b | ||
|
|
1c2b8f96ab | ||
|
|
eaff2a1edb | ||
|
|
e512e94056 | ||
|
|
fa68c74b51 | ||
|
|
94a864b05b | ||
|
|
42be0c63dc | ||
|
|
e6e2bd10c5 | ||
|
|
fd94a72435 | ||
|
|
2b4f20c0e8 | ||
|
|
e2c9dc4e8a | ||
|
|
a724479e60 | ||
|
|
4b2e89c088 | ||
|
|
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 |
@@ -1,2 +1,3 @@
|
|||||||
src/db.sqlite3
|
src/db.sqlite3
|
||||||
.claude
|
.claude
|
||||||
|
.vscode
|
||||||
|
|||||||
11
.gitignore
vendored
11
.gitignore
vendored
@@ -4,15 +4,17 @@
|
|||||||
### Claude ###
|
### Claude ###
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
### VS Code ###
|
||||||
|
.vscode
|
||||||
|
|
||||||
### Django ###
|
### Django ###
|
||||||
*.log
|
*.log
|
||||||
*.pot
|
*.pot
|
||||||
*.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 +186,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"
|
||||||
146
CLAUDE.md
Normal file
146
CLAUDE.md
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
# 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 `/dashboard/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 → sky → 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.
|
||||||
|
|
||||||
|
### JSONField `.exclude(data__key=value)` on SQLite
|
||||||
|
`.exclude(data__retracted=True)` on a row whose `data` has no `retracted` key resolves to `WHERE NOT (NULL = TRUE)` → NULL → SQL filters that row out. The exclude becomes "exclude rows where the key is True OR missing" instead of "exclude rows where the key is True". PostgreSQL evaluates this correctly, so the bug only manifests in local dev / SQLite ITs. If you mean *exclude only when the key exists and equals X*, do the predicate in Python after fetching a buffered queryset (see `_billboard_context` for the pattern). The same trap applies to `.filter(data__key=value)` — you'll silently miss rows where the key is missing.
|
||||||
|
|
||||||
|
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),
|
||||||
|
('Semisquare', 45, 4.0),
|
||||||
|
('Sextile', 60, 6.0),
|
||||||
|
('Square', 90, 8.0),
|
||||||
|
('Trine', 120, 8.0),
|
||||||
|
('Sesquiquadrate', 135, 4.0),
|
||||||
|
('Quincunx', 150, 5.0),
|
||||||
|
('Opposition', 180, 10.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
PLANET_CODES = {
|
||||||
|
'Sun': swe.SUN,
|
||||||
|
'Moon': swe.MOON,
|
||||||
|
'Mercury': swe.MERCURY,
|
||||||
|
'Venus': swe.VENUS,
|
||||||
|
'Mars': swe.MARS,
|
||||||
|
'Jupiter': swe.JUPITER,
|
||||||
|
'Saturn': swe.SATURN,
|
||||||
|
'Uranus': swe.URANUS,
|
||||||
|
'Neptune': swe.NEPTUNE,
|
||||||
|
'Pluto': swe.PLUTO,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def set_ephe_path():
|
||||||
|
ephe_path = getattr(django_settings, 'SWISSEPH_PATH', None)
|
||||||
|
if ephe_path:
|
||||||
|
swe.set_ephe_path(ephe_path)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sign(lon):
|
||||||
|
return SIGNS[int(lon // 30) % 12]
|
||||||
|
|
||||||
|
|
||||||
|
def get_julian_day(dt):
|
||||||
|
return swe.julday(
|
||||||
|
dt.year, dt.month, dt.day,
|
||||||
|
dt.hour + dt.minute / 60 + dt.second / 3600,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_planet_positions(jd):
|
||||||
|
flag = swe.FLG_SWIEPH | swe.FLG_SPEED
|
||||||
|
planets = {}
|
||||||
|
for name, code in PLANET_CODES.items():
|
||||||
|
pos, _ = swe.calc_ut(jd, code, flag)
|
||||||
|
degree = pos[0]
|
||||||
|
planets[name] = {
|
||||||
|
'sign': get_sign(degree),
|
||||||
|
'degree': degree,
|
||||||
|
'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
331
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
331
pyswiss/apps/charts/tests/unit/test_calc.py
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
"""
|
||||||
|
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', 'Semisquare', 'Sextile', 'Square',
|
||||||
|
'Trine', 'Sesquiquadrate', '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,
|
||||||
|
'Semisquare': 4.0,
|
||||||
|
'Sextile': 6.0,
|
||||||
|
'Square': 8.0,
|
||||||
|
'Trine': 8.0,
|
||||||
|
'Sesquiquadrate': 4.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.billboard.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(source="title")
|
||||||
|
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.billboard.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, author=self.user)
|
||||||
|
Line.objects.create(text="line 2", post=post, author=self.user)
|
||||||
|
|
||||||
|
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, author=self.user)
|
||||||
|
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, author=self.user)
|
||||||
|
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'),
|
||||||
|
]
|
||||||
46
src/apps/api/views.py
Normal file
46
src/apps/api/views.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
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.billboard.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, author=request.user)
|
||||||
|
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):
|
||||||
|
text = request.data.get("text", "")
|
||||||
|
post = Post.objects.create(owner=request.user, title=text[:35])
|
||||||
|
Line.objects.create(text=text, post=post, author=request.user)
|
||||||
|
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'
|
||||||
35
src/apps/applets/migrations/0001_initial.py
Normal file
35
src/apps/applets/migrations/0001_initial.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-28 00:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
]
|
||||||
|
|
||||||
|
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'), ('wallet', 'Wallet'), ('billboard', 'Billboard')], 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')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
27
src/apps/applets/migrations/0002_initial.py
Normal file
27
src/apps/applets/migrations/0002_initial.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-04-28 00:59
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='userapplet',
|
||||||
|
name='user',
|
||||||
|
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_applets', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterUniqueTogether(
|
||||||
|
name='userapplet',
|
||||||
|
unique_together={('user', 'applet')},
|
||||||
|
),
|
||||||
|
]
|
||||||
47
src/apps/applets/migrations/0003_seed_applets.py
Normal file
47
src/apps/applets/migrations/0003_seed_applets.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Seed all Applet rows."""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
APPLETS = [
|
||||||
|
# (slug, name, context, default_visible, grid_cols, grid_rows)
|
||||||
|
('wallet', 'Wallet', 'dashboard', True, 12, 3),
|
||||||
|
('new-post', 'New Post', 'billboard', True, 9, 3),
|
||||||
|
('my-posts', 'My Posts', 'billboard', True, 3, 3),
|
||||||
|
('username', 'Username', 'dashboard', True, 6, 3),
|
||||||
|
('palette', 'Palette', 'dashboard', True, 6, 3),
|
||||||
|
('new-game', 'New Game', 'gameboard', True, 4, 3),
|
||||||
|
('my-games', 'My Games', 'gameboard', True, 4, 4),
|
||||||
|
('game-kit', 'Game Kit', 'gameboard', True, 4, 3),
|
||||||
|
('wallet-balances', 'Wallet Balances', 'wallet', True, 3, 3),
|
||||||
|
('wallet-tokens', 'Wallet Tokens', 'wallet', True, 3, 3),
|
||||||
|
('wallet-payment', 'Payment Methods', 'wallet', True, 6, 3),
|
||||||
|
('billboard-my-scrolls', 'My Scrolls', 'billboard', True, 4, 3),
|
||||||
|
('billboard-my-contacts', 'Contacts', 'billboard', True, 4, 3),
|
||||||
|
('billboard-most-recent', 'Most Recent', 'billboard', True, 8, 6),
|
||||||
|
('gk-trinkets', 'Trinkets', 'game-kit', True, 3, 3),
|
||||||
|
('gk-tokens', 'Tokens', 'game-kit', True, 3, 3),
|
||||||
|
('gk-decks', 'Card Decks', 'game-kit', True, 3, 3),
|
||||||
|
('gk-dice', 'Dice Sets', 'game-kit', True, 3, 3),
|
||||||
|
('my-sky', 'My Sky', 'dashboard', True, 6, 6),
|
||||||
|
('billboard-notes', 'My Notes', 'billboard', True, 4, 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def seed(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name, context, default_visible, grid_cols, grid_rows in APPLETS:
|
||||||
|
Applet.objects.create(
|
||||||
|
slug=slug, name=name, context=context,
|
||||||
|
default_visible=default_visible,
|
||||||
|
grid_cols=grid_cols, grid_rows=grid_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0002_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"""Drop the legacy `billboard-` slug prefix from billboard applets and
|
||||||
|
rename Most Recent → Most Recent Scroll.
|
||||||
|
|
||||||
|
The `billboard-` prefix snuck into seed migration 0003 against intent — no
|
||||||
|
other context (dashboard, gameboard, wallet, game-kit) prefixes its applet
|
||||||
|
slugs with the context name, and slugs need to stay portable so users can
|
||||||
|
later rearrange which page hosts which applet.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
RENAMES = [
|
||||||
|
# (old_slug, new_slug, new_name_or_None)
|
||||||
|
('billboard-my-scrolls', 'my-scrolls', None),
|
||||||
|
('billboard-my-contacts', 'my-contacts', None),
|
||||||
|
('billboard-most-recent', 'most-recent-scroll', 'Most Recent Scroll'),
|
||||||
|
('billboard-notes', 'notes', None),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _apply(apps, mapping):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for old_slug, new_slug, new_name in mapping:
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug=old_slug)
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
continue
|
||||||
|
applet.slug = new_slug
|
||||||
|
fields = ['slug']
|
||||||
|
if new_name is not None:
|
||||||
|
applet.name = new_name
|
||||||
|
fields.append('name')
|
||||||
|
applet.save(update_fields=fields)
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
_apply(apps, RENAMES)
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
inverse = [
|
||||||
|
(new, old, 'Most Recent' if old == 'billboard-most-recent' else None)
|
||||||
|
for (old, new, _) in RENAMES
|
||||||
|
]
|
||||||
|
_apply(apps, inverse)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0003_seed_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, backward),
|
||||||
|
]
|
||||||
33
src/apps/applets/migrations/0005_seed_pronouns_applet.py
Normal file
33
src/apps/applets/migrations/0005_seed_pronouns_applet.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Seed the Pronouns applet on the Game Kit page (3x3, default visible)."""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
SLUG = "pronouns"
|
||||||
|
NAME = "Pronouns"
|
||||||
|
CONTEXT = "game-kit"
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=SLUG,
|
||||||
|
defaults={
|
||||||
|
"name": NAME,
|
||||||
|
"context": CONTEXT,
|
||||||
|
"default_visible": True,
|
||||||
|
"grid_cols": 3,
|
||||||
|
"grid_rows": 3,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.filter(slug=SLUG).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0004_rename_billboard_applet_slugs"),
|
||||||
|
]
|
||||||
|
operations = [migrations.RunPython(forward, backward)]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
"""Rename the billboard `my-contacts` applet to `my-buddies` (slug + name).
|
||||||
|
|
||||||
|
User.buddies M2M (lyric/0004) lands at the same time; the applet links
|
||||||
|
to the new /billboard/my-buddies/ page where the user manages their
|
||||||
|
buddy list. "Contacts" was a placeholder name from the original
|
||||||
|
billboard scaffold.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-contacts")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-buddies"
|
||||||
|
applet.name = "My Buddies"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-buddies")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-contacts"
|
||||||
|
applet.name = "Contacts"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0005_seed_pronouns_applet"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, backward),
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"""Rename the My Buddies applet → My Buds (slug + name).
|
||||||
|
|
||||||
|
UI-vocabulary tightening — see lyric/0005_rename_buddies_to_buds for the
|
||||||
|
parallel User.buddies → User.buds field rename. BILLBUDDIES overflowed
|
||||||
|
the page-header band; BILLBUDS fits cleanly.
|
||||||
|
"""
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def forward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-buddies")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-buds"
|
||||||
|
applet.name = "My Buds"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
def backward(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
try:
|
||||||
|
applet = Applet.objects.get(slug="my-buds")
|
||||||
|
except Applet.DoesNotExist:
|
||||||
|
return
|
||||||
|
applet.slug = "my-buddies"
|
||||||
|
applet.name = "My Buddies"
|
||||||
|
applet.save(update_fields=["slug", "name"])
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0006_rename_contacts_to_buddies"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(forward, backward),
|
||||||
|
]
|
||||||
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")
|
||||||
43
src/apps/applets/static/apps/applets/applets.js
Normal file
43
src/apps/applets/static/apps/applets/applets.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
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',
|
||||||
|
'id_billboard_applets_wrapper',
|
||||||
|
]);
|
||||||
|
|
||||||
|
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'
|
||||||
36
src/apps/billboard/forms.py
Normal file
36
src/apps/billboard/forms.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from .models import Line
|
||||||
|
|
||||||
|
|
||||||
|
DUPLICATE_LINE_ERROR = "You've already logged this to your post"
|
||||||
|
EMPTY_LINE_ERROR = "You can't have an empty post line"
|
||||||
|
|
||||||
|
|
||||||
|
class LineForm(forms.Form):
|
||||||
|
text = forms.CharField(
|
||||||
|
error_messages={"required": EMPTY_LINE_ERROR},
|
||||||
|
required=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def save(self, for_post, author):
|
||||||
|
return Line.objects.create(
|
||||||
|
post=for_post,
|
||||||
|
text=self.cleaned_data["text"],
|
||||||
|
author=author,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExistingPostLineForm(LineForm):
|
||||||
|
def __init__(self, for_post, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._for_post = for_post
|
||||||
|
|
||||||
|
def clean_text(self):
|
||||||
|
text = self.cleaned_data["text"]
|
||||||
|
if self._for_post.lines.filter(text=text).exists():
|
||||||
|
raise forms.ValidationError(DUPLICATE_LINE_ERROR)
|
||||||
|
return text
|
||||||
|
|
||||||
|
def save(self, author):
|
||||||
|
return super().save(for_post=self._for_post, author=author)
|
||||||
38
src/apps/billboard/migrations/0001_initial.py
Normal file
38
src/apps/billboard/migrations/0001_initial.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-08 21:11
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Post',
|
||||||
|
fields=[
|
||||||
|
('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='posts', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('shared_with', models.ManyToManyField(blank=True, related_name='shared_posts', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Line',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('text', models.TextField(default='')),
|
||||||
|
('post', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, related_name='lines', to='billboard.post')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('id',),
|
||||||
|
'unique_together': {('post', 'text')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
34
src/apps/billboard/migrations/0002_brief.py
Normal file
34
src/apps/billboard/migrations/0002_brief.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-08 21:34
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billboard', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Brief',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('is_unread', models.BooleanField(default=True)),
|
||||||
|
('kind', models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite')], default='user_post', max_length=32)),
|
||||||
|
('title', models.CharField(blank=True, max_length=255)),
|
||||||
|
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('line', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.line')),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('post', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-created_at'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/billboard/migrations/0003_post_kind.py
Normal file
18
src/apps/billboard/migrations/0003_post_kind.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-08 21:42
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billboard', '0002_brief'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='post',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('note_unlock', 'Note unlocks'), ('user_post', 'User post'), ('share_invite', 'Share invites')], default='user_post', max_length=32),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
# Adds Post.title, Line.author (PROTECT FK to lyric.User), Line.created_at.
|
||||||
|
# Backfills Post.title from first-line text (truncate 32 + "…" past 35 chars,
|
||||||
|
# or hardcoded "Notes & recognitions" for KIND_NOTE_UNLOCK), and Line.author
|
||||||
|
# from Post.owner — except KIND_NOTE_UNLOCK + ownerless rows, which attribute
|
||||||
|
# to the seeded `adman` User. Depends on lyric/0003_seed_adman so adman
|
||||||
|
# exists before backfill runs.
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from django.db.models import deletion
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
_NOTE_UNLOCK_TITLE = "Notes & recognitions"
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_title(text, length=35):
|
||||||
|
if len(text) <= length:
|
||||||
|
return text
|
||||||
|
return text[: length - 3] + "..."
|
||||||
|
|
||||||
|
|
||||||
|
def backfill(apps, schema_editor):
|
||||||
|
Post = apps.get_model("billboard", "Post")
|
||||||
|
Line = apps.get_model("billboard", "Line")
|
||||||
|
User = apps.get_model("lyric", "User")
|
||||||
|
|
||||||
|
adman = User.objects.filter(username="adman").first()
|
||||||
|
|
||||||
|
for post in Post.objects.all():
|
||||||
|
if post.kind == "note_unlock":
|
||||||
|
post.title = _NOTE_UNLOCK_TITLE
|
||||||
|
else:
|
||||||
|
first_line = post.lines.order_by("id").first()
|
||||||
|
post.title = _truncate_title(first_line.text) if first_line else ""
|
||||||
|
post.save(update_fields=["title"])
|
||||||
|
|
||||||
|
now = timezone.now()
|
||||||
|
for line in Line.objects.select_related("post").all():
|
||||||
|
if line.post.kind == "note_unlock":
|
||||||
|
line.author = adman
|
||||||
|
elif line.post.owner_id:
|
||||||
|
line.author_id = line.post.owner_id
|
||||||
|
else:
|
||||||
|
line.author = adman
|
||||||
|
if line.created_at is None:
|
||||||
|
line.created_at = now
|
||||||
|
line.save(update_fields=["author", "created_at"])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("billboard", "0003_post_kind"),
|
||||||
|
("lyric", "0003_seed_adman"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="post",
|
||||||
|
name="title",
|
||||||
|
field=models.CharField(default="", max_length=35),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="line",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(default=timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="line",
|
||||||
|
name="author",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=deletion.PROTECT,
|
||||||
|
related_name="authored_lines",
|
||||||
|
to="lyric.user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill, reverse_noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="line",
|
||||||
|
name="author",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=deletion.PROTECT,
|
||||||
|
related_name="authored_lines",
|
||||||
|
to="lyric.user",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="line",
|
||||||
|
name="created_at",
|
||||||
|
field=models.DateTimeField(auto_now_add=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
34
src/apps/billboard/migrations/0005_line_admin_solicited.py
Normal file
34
src/apps/billboard/migrations/0005_line_admin_solicited.py
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
# Adds Line.admin_solicited (BooleanField) to discriminate
|
||||||
|
# system-authored Lines (Note.grant_if_new) from user writes on
|
||||||
|
# NOTE_UNLOCK Posts. The post_save signal nukes any Line on a
|
||||||
|
# NOTE_UNLOCK Post that lacks admin_solicited=True — defense-in-depth
|
||||||
|
# alongside the view_post POST guard. Backfill: existing NOTE_UNLOCK
|
||||||
|
# Lines (the only system-authored kind at this point) get True; all
|
||||||
|
# others default False.
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def backfill(apps, schema_editor):
|
||||||
|
Line = apps.get_model("billboard", "Line")
|
||||||
|
Line.objects.filter(post__kind="note_unlock").update(admin_solicited=True)
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_noop(apps, schema_editor):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("billboard", "0004_post_title_line_author_created_at"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="line",
|
||||||
|
name="admin_solicited",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
),
|
||||||
|
migrations.RunPython(backfill, reverse_noop),
|
||||||
|
]
|
||||||
17
src/apps/billboard/migrations/0006_alter_line_options.py
Normal file
17
src/apps/billboard/migrations/0006_alter_line_options.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-09 03:00
|
||||||
|
|
||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billboard', '0005_line_admin_solicited'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='line',
|
||||||
|
options={'ordering': ('created_at', 'id')},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-05-09 04:45
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('billboard', '0006_alter_line_options'),
|
||||||
|
('epic', '0008_blades_reversal_fickle'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='brief',
|
||||||
|
name='room',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='epic.room'),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='brief',
|
||||||
|
name='kind',
|
||||||
|
field=models.CharField(choices=[('note_unlock', 'Note unlock'), ('user_post', 'User post'), ('share_invite', 'Share invite'), ('game_invite', 'Game invite')], default='user_post', max_length=32),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='brief',
|
||||||
|
name='post',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='briefs', to='billboard.post'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/apps/billboard/migrations/__init__.py
Normal file
0
src/apps/billboard/migrations/__init__.py
Normal file
196
src/apps/billboard/models.py
Normal file
196
src/apps/billboard/models.py
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Post(models.Model):
|
||||||
|
KIND_NOTE_UNLOCK = "note_unlock"
|
||||||
|
KIND_USER_POST = "user_post"
|
||||||
|
KIND_SHARE_INVITE = "share_invite"
|
||||||
|
KIND_CHOICES = [
|
||||||
|
(KIND_NOTE_UNLOCK, "Note unlocks"),
|
||||||
|
(KIND_USER_POST, "User post"),
|
||||||
|
(KIND_SHARE_INVITE, "Share invites"),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="posts",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
|
shared_with = models.ManyToManyField(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="shared_posts",
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# `kind` discriminates per-category Posts — e.g. Note.grant_if_new appends
|
||||||
|
# to the user's single (owner=user, kind=NOTE_UNLOCK) Post; user-authored
|
||||||
|
# composes default to KIND_USER_POST.
|
||||||
|
kind = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=KIND_CHOICES,
|
||||||
|
default=KIND_USER_POST,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Stored title — set explicitly on creation. Note-unlock Posts hardcode
|
||||||
|
# "Notes & recognitions"; user_post Posts truncate first line to 35 chars
|
||||||
|
# (32 + "..." past length). Replaces the legacy `name` property which
|
||||||
|
# gleaned `lines.first().text` lazily and broke if the first Line was
|
||||||
|
# later edited or deleted.
|
||||||
|
title = models.CharField(max_length=35, default="")
|
||||||
|
|
||||||
|
def get_absolute_url(self):
|
||||||
|
return reverse("billboard:view_post", args=[self.id])
|
||||||
|
|
||||||
|
|
||||||
|
class Line(models.Model):
|
||||||
|
text = models.TextField(default="")
|
||||||
|
post = models.ForeignKey(Post, default=None, on_delete=models.CASCADE, related_name="lines")
|
||||||
|
# `author` PROTECTs against accidental sitewide-entity deletion (notably
|
||||||
|
# `adman`, the system-author for note_unlock + share_invite Lines).
|
||||||
|
# User-typed Lines attribute to the typing User; system-rendered Lines
|
||||||
|
# attribute to adman so the per-line "username" column always renders.
|
||||||
|
author = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name="authored_lines",
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
# System-authored Lines on NOTE_UNLOCK Posts must set this True; the
|
||||||
|
# post_save signal below deletes any Line on a NOTE_UNLOCK Post w.o.
|
||||||
|
# this flag (defense-in-depth alongside view_post's POST guard).
|
||||||
|
admin_solicited = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ("created_at", "id")
|
||||||
|
unique_together = ("post", "text")
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.text
|
||||||
|
|
||||||
|
|
||||||
|
class Brief(models.Model):
|
||||||
|
"""A slide-down notification record. Owner = whose attention; post = where
|
||||||
|
FYI navigates (and where mark-read happens on GET); line = the specific
|
||||||
|
appended Line that triggered it (so the banner can surface its text).
|
||||||
|
|
||||||
|
`kind` discriminates the affordances the banner renders. NOTE_UNLOCK
|
||||||
|
Briefs get a clickable square that jumps direct to my_notes.html;
|
||||||
|
SHARE_INVITE Briefs render the invitation copy; USER_POST is the legacy
|
||||||
|
user-authored compose flow.
|
||||||
|
|
||||||
|
Magic-link confirmation + invalid-link banners use the same Gaussian-glass
|
||||||
|
visual styling but ride no Brief row (transient one-shot).
|
||||||
|
"""
|
||||||
|
KIND_NOTE_UNLOCK = "note_unlock"
|
||||||
|
KIND_USER_POST = "user_post"
|
||||||
|
KIND_SHARE_INVITE = "share_invite"
|
||||||
|
KIND_GAME_INVITE = "game_invite"
|
||||||
|
KIND_CHOICES = [
|
||||||
|
(KIND_NOTE_UNLOCK, "Note unlock"),
|
||||||
|
(KIND_USER_POST, "User post"),
|
||||||
|
(KIND_SHARE_INVITE, "Share invite"),
|
||||||
|
(KIND_GAME_INVITE, "Game invite"),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="briefs",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
# Post is nullable now: KIND_GAME_INVITE briefs ride on a Room FK
|
||||||
|
# instead of a Post (the gatekeeper invite confirmation has no post
|
||||||
|
# to navigate to). Post FKs only set for note_unlock / user_post /
|
||||||
|
# share_invite kinds.
|
||||||
|
post = models.ForeignKey(
|
||||||
|
Post,
|
||||||
|
related_name="briefs",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
# Room FK — set only on KIND_GAME_INVITE briefs; FYI navigates to
|
||||||
|
# the gatekeeper page for that room.
|
||||||
|
room = models.ForeignKey(
|
||||||
|
"epic.Room",
|
||||||
|
related_name="briefs",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
# Line is nullable because a share_invite-style Brief can race ahead of its
|
||||||
|
# async-appended Line write; the post FK alone is enough to navigate.
|
||||||
|
line = models.ForeignKey(
|
||||||
|
Line,
|
||||||
|
related_name="briefs",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
is_unread = models.BooleanField(default=True)
|
||||||
|
kind = models.CharField(
|
||||||
|
max_length=32,
|
||||||
|
choices=KIND_CHOICES,
|
||||||
|
default=KIND_USER_POST,
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=255, blank=True)
|
||||||
|
created_at = models.DateTimeField(default=timezone.now)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["-created_at"]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return (
|
||||||
|
f"Brief({self.kind}, {self.owner.email}, "
|
||||||
|
f"unread={self.is_unread})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_banner_dict(self):
|
||||||
|
"""Shape this Brief for the slide-down banner JS. NOTE_UNLOCK kind
|
||||||
|
carries a square_url pointing at /billboard/my-notes/ so the
|
||||||
|
thumbnail-square inside the banner jumps direct to the user's Note
|
||||||
|
collection. GAME_INVITE kind has no Post — the FYI link navigates
|
||||||
|
to the gatekeeper page for the brief's Room instead."""
|
||||||
|
square_url = ""
|
||||||
|
if self.kind == self.KIND_NOTE_UNLOCK:
|
||||||
|
square_url = reverse("billboard:my_notes")
|
||||||
|
if self.post_id:
|
||||||
|
post_url = self.post.get_absolute_url()
|
||||||
|
elif self.room_id:
|
||||||
|
post_url = reverse("epic:gatekeeper", args=[self.room_id])
|
||||||
|
else:
|
||||||
|
post_url = ""
|
||||||
|
return {
|
||||||
|
"id": str(self.id),
|
||||||
|
"kind": self.kind,
|
||||||
|
"title": self.title,
|
||||||
|
"line_text": self.line.text if self.line else "",
|
||||||
|
"post_url": post_url,
|
||||||
|
"square_url": square_url,
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ── Listener: nuke unsolicited Lines on NOTE_UNLOCK Posts ─────────────────
|
||||||
|
# Defense-in-depth alongside view_post's POST guard. A Line saved on a
|
||||||
|
# NOTE_UNLOCK Post that lacks admin_solicited=True (e.g. a stray ORM-level
|
||||||
|
# write or an API path that bypasses the view) gets deleted right after
|
||||||
|
# the save. Note.grant_if_new sets admin_solicited=True on its Lines so
|
||||||
|
# legitimate system prose survives.
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Line)
|
||||||
|
def _delete_unsolicited_admin_post_lines(sender, instance, created, **kwargs):
|
||||||
|
if not created:
|
||||||
|
return
|
||||||
|
if instance.post.kind == Post.KIND_NOTE_UNLOCK and not instance.admin_solicited:
|
||||||
|
instance.delete()
|
||||||
115
src/apps/billboard/static/apps/billboard/bud-autocomplete.js
Normal file
115
src/apps/billboard/static/apps/billboard/bud-autocomplete.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
// Bud-list autocomplete for #id_recipient inputs (post share panel + my_buds
|
||||||
|
// add panel). Mirrors the sky.html birth-place picker pattern: debounced
|
||||||
|
// fetch on input, top-3 suggestions rendered as buttons, click-to-fill,
|
||||||
|
// Escape closes, click-outside closes. No keyboard arrow/Enter cycling.
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <div class="bud-panel-wrap">
|
||||||
|
// <input id="id_recipient" ...>
|
||||||
|
// <div id="id_bud_suggestions" class="bud-suggestions" hidden></div>
|
||||||
|
// </div>
|
||||||
|
// <script src="{% static 'apps/billboard/bud-autocomplete.js' %}"></script>
|
||||||
|
// <script>bindBudAutocomplete(
|
||||||
|
// document.getElementById('id_recipient'),
|
||||||
|
// document.getElementById('id_bud_suggestions'),
|
||||||
|
// { searchUrl: '{% url "billboard:search_buds" %}' }
|
||||||
|
// );</script>
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var DEBOUNCE_MS = 250;
|
||||||
|
var MIN_CHARS = 1;
|
||||||
|
|
||||||
|
function _esc(s) {
|
||||||
|
var d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : s;
|
||||||
|
return d.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.bindBudAutocomplete = function (input, suggestions, options) {
|
||||||
|
if (!input || !suggestions || !options || !options.searchUrl) return;
|
||||||
|
|
||||||
|
var debounceTimer = null;
|
||||||
|
var lastQuery = '';
|
||||||
|
|
||||||
|
function _hide() {
|
||||||
|
suggestions.hidden = true;
|
||||||
|
suggestions.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _render(buds) {
|
||||||
|
if (!buds || !buds.length) {
|
||||||
|
_hide();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
suggestions.innerHTML = buds.map(function (b) {
|
||||||
|
// data-email + data-username so the click handler can fill the
|
||||||
|
// input with whichever the user originally typed (email if they
|
||||||
|
// started with `@`, else username).
|
||||||
|
return (
|
||||||
|
'<button type="button" class="bud-suggestion-item" ' +
|
||||||
|
'data-email="' + _esc(b.email) + '" ' +
|
||||||
|
'data-username="' + _esc(b.username) + '">' +
|
||||||
|
_esc(b.username) +
|
||||||
|
'</button>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
suggestions.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _fetch(q) {
|
||||||
|
var url = options.searchUrl + '?q=' + encodeURIComponent(q);
|
||||||
|
fetch(url, {
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Accept': 'application/json' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function (data) {
|
||||||
|
// Drop late responses if the user has typed past this query.
|
||||||
|
if (input.value.trim() !== q) return;
|
||||||
|
_render(data.buds || []);
|
||||||
|
})
|
||||||
|
.catch(function () { _hide(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', function () {
|
||||||
|
var q = input.value.trim();
|
||||||
|
lastQuery = q;
|
||||||
|
if (q.length < MIN_CHARS) { _hide(); return; }
|
||||||
|
clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(function () { _fetch(q); }, DEBOUNCE_MS);
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape') _hide();
|
||||||
|
});
|
||||||
|
|
||||||
|
suggestions.addEventListener('click', function (e) {
|
||||||
|
var btn = e.target.closest('.bud-suggestion-item');
|
||||||
|
if (!btn) return;
|
||||||
|
// Stop propagation so the bud-panel's document-level click-
|
||||||
|
// outside handler doesn't fire and close+clear the panel —
|
||||||
|
// _hide() about to detach the target makes a `sg.contains(e.target)`
|
||||||
|
// check at the document level unreliable.
|
||||||
|
e.stopPropagation();
|
||||||
|
// Fill w. whichever form the user was typing (email vs username).
|
||||||
|
// If the input value already contains '@', prefer email; else
|
||||||
|
// prefer username. This keeps the OK-submit semantics consistent
|
||||||
|
// w. what the user intended.
|
||||||
|
var typed = input.value.trim();
|
||||||
|
input.value = typed.indexOf('@') !== -1
|
||||||
|
? btn.dataset.email
|
||||||
|
: btn.dataset.username;
|
||||||
|
_hide();
|
||||||
|
input.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (suggestions.hidden) return;
|
||||||
|
if (suggestions.contains(e.target)) return;
|
||||||
|
if (e.target === input) return;
|
||||||
|
_hide();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}());
|
||||||
137
src/apps/billboard/static/apps/billboard/bud-btn.js
Normal file
137
src/apps/billboard/static/apps/billboard/bud-btn.js
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
// Shared skeleton for the three #id_bud_btn slide-out panels:
|
||||||
|
// • _bud_panel.html — post-share (POSTs to billboard:share_post)
|
||||||
|
// • _bud_invite_panel.html — gatekeeper invite (POSTs to epic:invite_gamer)
|
||||||
|
// • _bud_add_panel.html — My Buds add (POSTs to billboard:add_bud)
|
||||||
|
//
|
||||||
|
// Owns: csrf cookie read, open/close + .bud-open html-class, button click,
|
||||||
|
// Escape, click-outside, Enter-in-input, OK POST + JSON routing, and the
|
||||||
|
// `data.already_present` duplicate-guard branch (error Brief instead of
|
||||||
|
// the normal onSuccess append).
|
||||||
|
//
|
||||||
|
// Each caller drives it with:
|
||||||
|
// bindBudBtn({
|
||||||
|
// submitUrl,
|
||||||
|
// autocompleteUrl?,
|
||||||
|
// onSuccess(data), // success path: line/chip/entry/Brief
|
||||||
|
// duplicateTargetSelector?(data), // selector for .bud-duplicate-flash
|
||||||
|
// })
|
||||||
|
//
|
||||||
|
// `onSuccess(data)` does the panel-specific DOM updates on the new-row path.
|
||||||
|
// `duplicateTargetSelector(data)` returns a CSS selector for the existing
|
||||||
|
// element to highlight when the FYI button on the error Brief is clicked
|
||||||
|
// (.bud-name / .post-recipient / .gate-slot.filled — varies by page).
|
||||||
|
// _close({clear: true}) fires automatically on every successful response.
|
||||||
|
//
|
||||||
|
// `autocompleteUrl` enables bud-autocomplete on the input (post-share +
|
||||||
|
// gatekeeper panels) by binding bud-autocomplete.js to #id_bud_suggestions.
|
||||||
|
// My Buds omits it — the autocomplete pool is request.user.buds, which is
|
||||||
|
// the set you can't usefully re-add.
|
||||||
|
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
window.bindBudBtn = function (opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var btn = document.getElementById('id_bud_btn');
|
||||||
|
var panel = document.getElementById('id_bud_panel');
|
||||||
|
var input = document.getElementById('id_recipient');
|
||||||
|
var ok = document.getElementById('id_bud_ok');
|
||||||
|
var html = document.documentElement;
|
||||||
|
if (!btn || !panel || !input || !ok) return;
|
||||||
|
|
||||||
|
var suggestions = opts.autocompleteUrl
|
||||||
|
? document.getElementById('id_bud_suggestions')
|
||||||
|
: null;
|
||||||
|
|
||||||
|
function _csrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function _open() {
|
||||||
|
html.classList.add('bud-open');
|
||||||
|
btn.classList.add('active');
|
||||||
|
setTimeout(function () { input.focus(); }, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _close(o) {
|
||||||
|
o = o || {};
|
||||||
|
html.classList.remove('bud-open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
if (o.clear !== false) input.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (html.classList.contains('bud-open')) _close();
|
||||||
|
else _open();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && html.classList.contains('bud-open')) _close();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!html.classList.contains('bud-open')) return;
|
||||||
|
if (panel.contains(e.target)) return;
|
||||||
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
// Suggestions live outside the panel (panel has overflow:hidden
|
||||||
|
// for its scaleX slide); a click inside them must NOT close+clear.
|
||||||
|
if (suggestions && suggestions.contains(e.target)) return;
|
||||||
|
_close();
|
||||||
|
});
|
||||||
|
|
||||||
|
ok.addEventListener('click', function () {
|
||||||
|
var recipient = input.value.trim();
|
||||||
|
if (!recipient) return;
|
||||||
|
|
||||||
|
var fd = new FormData();
|
||||||
|
fd.set('recipient', recipient);
|
||||||
|
|
||||||
|
fetch(opts.submitUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'X-CSRFToken': _csrf(),
|
||||||
|
},
|
||||||
|
body: fd,
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(r.status); })
|
||||||
|
.then(function (data) {
|
||||||
|
if (data && data.already_present) {
|
||||||
|
// Skip onSuccess — there's nothing to append. Show the
|
||||||
|
// error Brief instead. target_selector resolves at call
|
||||||
|
// time from the caller's per-page callback.
|
||||||
|
if (window.Brief) {
|
||||||
|
var sel = (typeof opts.duplicateTargetSelector === 'function')
|
||||||
|
? opts.duplicateTargetSelector(data) : null;
|
||||||
|
Brief.showDuplicateBanner({
|
||||||
|
display_name: data.recipient_display,
|
||||||
|
target_selector: sel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (typeof opts.onSuccess === 'function') {
|
||||||
|
opts.onSuccess(data);
|
||||||
|
}
|
||||||
|
_close({ clear: true });
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
// Privacy-safe response shape — even an unregistered/self
|
||||||
|
// recipient is a 200. Network/5xx land here; just close.
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
input.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
ok.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (opts.autocompleteUrl && window.bindBudAutocomplete && suggestions) {
|
||||||
|
window.bindBudAutocomplete(input, suggestions, {
|
||||||
|
searchUrl: opts.autocompleteUrl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}());
|
||||||
291
src/apps/billboard/static/apps/billboard/note-page.js
Normal file
291
src/apps/billboard/static/apps/billboard/note-page.js
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var _selectedPalette = null;
|
||||||
|
var _activeItem = null;
|
||||||
|
var _originalPalette = null;
|
||||||
|
var _dismissTimer = null;
|
||||||
|
var _lockedItem = null; // click-locked note (glow + DON/DOFF pinned)
|
||||||
|
var _donnedItem = null; // currently DONned note (persistent glow)
|
||||||
|
|
||||||
|
// ── 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';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── lock helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _clearLock() {
|
||||||
|
if (_lockedItem) {
|
||||||
|
_lockedItem.classList.remove('note-item--locked');
|
||||||
|
_lockedItem = null;
|
||||||
|
}
|
||||||
|
document.body.classList.remove('notes-locked');
|
||||||
|
}
|
||||||
|
|
||||||
|
function _setGreeting(greeting, name) {
|
||||||
|
var prefix = document.getElementById('id_greeting_prefix');
|
||||||
|
var nameEl = document.getElementById('id_greeting_name');
|
||||||
|
if (prefix) prefix.innerHTML = greeting;
|
||||||
|
if (nameEl) nameEl.textContent = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _wireModal() {
|
||||||
|
var modal = _activeModal();
|
||||||
|
if (!modal) return;
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelectorAll('.note-palette-confirm .btn.btn-confirm').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) { e.stopPropagation(); _doSetPalette(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.querySelectorAll('.note-palette-confirm .btn.btn-cancel').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function (e) { e.stopPropagation(); _revertPreview(); });
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.addEventListener('click', function (e) { e.stopPropagation(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── set-palette POST ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _doSetPalette() {
|
||||||
|
var url = _activeItem.dataset.setPaletteUrl;
|
||||||
|
var palette = _selectedPalette;
|
||||||
|
var item = _activeItem;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── DON/DOFF ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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) {
|
||||||
|
// Auto-DOFF any previously DONned note (UI only — backend replaces active_title)
|
||||||
|
if (_donnedItem && _donnedItem !== item) {
|
||||||
|
_donnedItem.classList.remove('note-item--donned');
|
||||||
|
var prevDon = _donnedItem.querySelector('.note-don-btn');
|
||||||
|
var prevDoff = _donnedItem.querySelector('.note-doff-btn');
|
||||||
|
if (prevDon) { prevDon.classList.remove('btn-disabled'); prevDon.textContent = 'DON'; }
|
||||||
|
if (prevDoff) { prevDoff.classList.add('btn-disabled'); prevDoff.textContent = '×'; }
|
||||||
|
}
|
||||||
|
_donnedItem = item;
|
||||||
|
item.classList.add('note-item--donned');
|
||||||
|
// Clear lock so hover is restored for other notes
|
||||||
|
_clearLock();
|
||||||
|
donBtn.classList.add('btn-disabled'); donBtn.textContent = '×';
|
||||||
|
doffBtn.classList.remove('btn-disabled'); doffBtn.textContent = 'DOFF';
|
||||||
|
_setGreeting(data.greeting || 'Welcome,', data.title || 'Earthman');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
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 (r) { return r.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
_donnedItem = null;
|
||||||
|
item.classList.remove('note-item--donned');
|
||||||
|
_clearLock();
|
||||||
|
doffBtn.classList.add('btn-disabled'); doffBtn.textContent = '×';
|
||||||
|
donBtn.classList.remove('btn-disabled'); donBtn.textContent = 'DON';
|
||||||
|
_setGreeting(data.greeting || 'Welcome,', data.title || 'Earthman');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── init ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function _init() {
|
||||||
|
document.querySelectorAll('.note-item').forEach(function (item) {
|
||||||
|
// Detect already-DONned note on load (DON btn is disabled = currently equipped)
|
||||||
|
var don = item.querySelector('.note-don-btn');
|
||||||
|
if (don && don.classList.contains('btn-disabled')) {
|
||||||
|
item.classList.add('note-item--donned');
|
||||||
|
_donnedItem = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindDonDoff(item);
|
||||||
|
|
||||||
|
// Image box click → palette modal (for notes that have one)
|
||||||
|
var box = item.querySelector('.note-item__image-box:not(.note-item__image-box--label)');
|
||||||
|
if (box) {
|
||||||
|
box.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
_activeItem = item;
|
||||||
|
_openModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note click → toggle lock
|
||||||
|
item.addEventListener('click', function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (_lockedItem === item) {
|
||||||
|
_clearLock();
|
||||||
|
} else {
|
||||||
|
_clearLock();
|
||||||
|
_lockedItem = item;
|
||||||
|
item.classList.add('note-item--locked');
|
||||||
|
document.body.classList.add('notes-locked');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body click → dismiss modal and clear lock
|
||||||
|
document.body.addEventListener('click', function () {
|
||||||
|
if (_selectedPalette) _revertPreview();
|
||||||
|
_closeModal();
|
||||||
|
_clearLock();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', _init);
|
||||||
|
} else {
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expose test API
|
||||||
|
window.NotePage = {
|
||||||
|
_init: _init,
|
||||||
|
_testReset: function () {
|
||||||
|
_selectedPalette = null;
|
||||||
|
_activeItem = null;
|
||||||
|
_originalPalette = null;
|
||||||
|
_dismissTimer = null;
|
||||||
|
_lockedItem = null;
|
||||||
|
_donnedItem = null;
|
||||||
|
document.body.classList.remove('notes-locked');
|
||||||
|
},
|
||||||
|
get _donnedItem() { return _donnedItem; },
|
||||||
|
set _donnedItem(v) { _donnedItem = v; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
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
126
src/apps/billboard/tests/integrated/test_admin_posts.py
Normal file
126
src/apps/billboard/tests/integrated/test_admin_posts.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
"""ITs for admin-Post (kind=NOTE_UNLOCK) write protection.
|
||||||
|
|
||||||
|
Three guards stack:
|
||||||
|
1. post.html input is `readonly` w. "No response needed…" placeholder
|
||||||
|
(FT covers this — `functional_tests/test_admin_post_readonly.py`).
|
||||||
|
2. view_post POST handler hard-rejects writes (HTTP 403). This file's
|
||||||
|
PostRejectsAdminWritesTest.
|
||||||
|
3. post_save signal nukes any Line saved on a NOTE_UNLOCK Post that
|
||||||
|
lacks `admin_solicited=True` — defense-in-depth for paths that
|
||||||
|
bypass the view (raw API, ORM, etc.). UnsolicitedLineListenerTest.
|
||||||
|
|
||||||
|
Bug A — May 2026.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.billboard.models import Brief, Line, Post
|
||||||
|
from apps.drama.models import Note
|
||||||
|
from apps.lyric.models import User, get_or_create_adman
|
||||||
|
|
||||||
|
|
||||||
|
class PostRejectsAdminWritesTest(TestCase):
|
||||||
|
"""POST /billboard/post/<note_unlock>/ → HTTP 403, no Line appended."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="admin-rej@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
Note.grant_if_new(self.user, "stargazer")
|
||||||
|
self.admin_post = Post.objects.get(
|
||||||
|
owner=self.user, kind=Post.KIND_NOTE_UNLOCK,
|
||||||
|
)
|
||||||
|
self.line_count_before = Line.objects.filter(post=self.admin_post).count()
|
||||||
|
|
||||||
|
def test_post_to_admin_post_returns_403(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse("billboard:view_post", args=[self.admin_post.id]),
|
||||||
|
data={"text": "errant response"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
def test_post_to_admin_post_does_not_append_line(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("billboard:view_post", args=[self.admin_post.id]),
|
||||||
|
data={"text": "errant response"},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
Line.objects.filter(post=self.admin_post).count(),
|
||||||
|
self.line_count_before,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_to_user_post_still_succeeds(self):
|
||||||
|
"""Regression: kind=USER_POST still accepts compose."""
|
||||||
|
user_post = Post.objects.create(
|
||||||
|
owner=self.user, kind=Post.KIND_USER_POST, title="composing",
|
||||||
|
)
|
||||||
|
Line.objects.create(post=user_post, text="seed", author=self.user)
|
||||||
|
resp = self.client.post(
|
||||||
|
reverse("billboard:view_post", args=[user_post.id]),
|
||||||
|
data={"text": "valid append"},
|
||||||
|
)
|
||||||
|
# 302 redirect on success
|
||||||
|
self.assertEqual(resp.status_code, 302)
|
||||||
|
self.assertTrue(
|
||||||
|
Line.objects.filter(post=user_post, text="valid append").exists(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class UnsolicitedLineListenerTest(TestCase):
|
||||||
|
"""post_save signal deletes any Line saved on a NOTE_UNLOCK Post without
|
||||||
|
`admin_solicited=True`. Note.grant_if_new sets it; everything else
|
||||||
|
defaults to False, so a stray ORM-level write gets nuked."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="listener@test.io")
|
||||||
|
Note.grant_if_new(self.user, "stargazer")
|
||||||
|
self.admin_post = Post.objects.get(
|
||||||
|
owner=self.user, kind=Post.KIND_NOTE_UNLOCK,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unsolicited_line_on_note_unlock_post_is_deleted(self):
|
||||||
|
unsolicited = Line.objects.create(
|
||||||
|
post=self.admin_post,
|
||||||
|
text="errant ORM write",
|
||||||
|
author=self.user,
|
||||||
|
# admin_solicited defaults to False
|
||||||
|
)
|
||||||
|
# Signal fires post_save; the Line should be gone.
|
||||||
|
self.assertFalse(
|
||||||
|
Line.objects.filter(pk=unsolicited.pk).exists(),
|
||||||
|
"Unsolicited Line on NOTE_UNLOCK Post must be deleted",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_admin_solicited_line_on_note_unlock_post_persists(self):
|
||||||
|
"""The Note grant Lines are admin_solicited=True — must NOT be nuked."""
|
||||||
|
adman = get_or_create_adman()
|
||||||
|
line = Line.objects.create(
|
||||||
|
post=self.admin_post,
|
||||||
|
text="valid system prose",
|
||||||
|
author=adman,
|
||||||
|
admin_solicited=True,
|
||||||
|
)
|
||||||
|
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
|
||||||
|
|
||||||
|
def test_unsolicited_line_on_user_post_persists(self):
|
||||||
|
"""User-typed Lines on user_post posts default to admin_solicited=False
|
||||||
|
and must NOT be nuked — the listener only guards NOTE_UNLOCK."""
|
||||||
|
up = Post.objects.create(
|
||||||
|
owner=self.user, kind=Post.KIND_USER_POST, title="x",
|
||||||
|
)
|
||||||
|
line = Line.objects.create(
|
||||||
|
post=up, text="user-typed line", author=self.user,
|
||||||
|
)
|
||||||
|
self.assertTrue(Line.objects.filter(pk=line.pk).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class NoteGrantSetsAdminSolicitedTest(TestCase):
|
||||||
|
"""Note.grant_if_new must persist Lines with admin_solicited=True so
|
||||||
|
they survive the listener pass."""
|
||||||
|
|
||||||
|
def test_grant_creates_line_with_admin_solicited_true(self):
|
||||||
|
u = User.objects.create(email="grant@test.io")
|
||||||
|
Note.grant_if_new(u, "stargazer")
|
||||||
|
post = Post.objects.get(owner=u, kind=Post.KIND_NOTE_UNLOCK)
|
||||||
|
# Exactly one Line on a fresh grant
|
||||||
|
line = post.lines.get()
|
||||||
|
self.assertTrue(line.admin_solicited)
|
||||||
121
src/apps/billboard/tests/integrated/test_brief.py
Normal file
121
src/apps/billboard/tests/integrated/test_brief.py
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
"""ITs for the Brief model & view_post's mark-read behavior.
|
||||||
|
|
||||||
|
Brief is a notification record — owner + post FK + line FK + is_unread + kind.
|
||||||
|
It rides on a Post (one-Post-per-category, Lines accumulate). Clicking FYI on
|
||||||
|
a Brief banner navigates to billboard:view_post for the underlying Post; that
|
||||||
|
GET is the contract that flips is_unread → False.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.billboard.models import Brief, Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class BriefModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="brief@test.io")
|
||||||
|
self.post = Post.objects.create(owner=self.user)
|
||||||
|
self.line = Line.objects.create(post=self.post, text="Stargazer, 5:21pm", author=self.user)
|
||||||
|
|
||||||
|
def test_brief_defaults_unread(self):
|
||||||
|
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||||
|
self.assertTrue(b.is_unread)
|
||||||
|
|
||||||
|
def test_brief_default_kind_is_user_post(self):
|
||||||
|
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||||
|
self.assertEqual(b.kind, Brief.KIND_USER_POST)
|
||||||
|
|
||||||
|
def test_brief_kind_choices_include_note_unlock_and_share_invite(self):
|
||||||
|
choices = dict(Brief._meta.get_field("kind").choices)
|
||||||
|
self.assertIn(Brief.KIND_NOTE_UNLOCK, choices)
|
||||||
|
self.assertIn(Brief.KIND_USER_POST, choices)
|
||||||
|
self.assertIn(Brief.KIND_SHARE_INVITE, choices)
|
||||||
|
|
||||||
|
def test_brief_line_can_be_null(self):
|
||||||
|
"""A Brief may pre-date its Line (e.g. share-invite spawns the Line
|
||||||
|
async — the Brief should still be persistable while the Line write
|
||||||
|
is pending). Doesn't break the post FK."""
|
||||||
|
b = Brief.objects.create(owner=self.user, post=self.post)
|
||||||
|
self.assertIsNone(b.line)
|
||||||
|
|
||||||
|
def test_brief_owner_required(self):
|
||||||
|
"""Brief without owner is invalid (load-bearing for "whose
|
||||||
|
attention"). Post used to be required too, but became nullable
|
||||||
|
when GAME_INVITE briefs landed (those use Brief.room instead of
|
||||||
|
Brief.post). The view layer enforces "post XOR room" per kind."""
|
||||||
|
from django.db import IntegrityError, transaction
|
||||||
|
with transaction.atomic(), self.assertRaises(IntegrityError):
|
||||||
|
Brief.objects.create(post=self.post, line=self.line)
|
||||||
|
|
||||||
|
def test_brief_carries_title(self):
|
||||||
|
b = Brief.objects.create(
|
||||||
|
owner=self.user, post=self.post, line=self.line,
|
||||||
|
title="Look! — new Note unlocked",
|
||||||
|
)
|
||||||
|
self.assertEqual(b.title, "Look! — new Note unlocked")
|
||||||
|
|
||||||
|
def test_brief_str_includes_owner_kind_unread(self):
|
||||||
|
b = Brief.objects.create(owner=self.user, post=self.post, kind=Brief.KIND_NOTE_UNLOCK)
|
||||||
|
s = str(b)
|
||||||
|
self.assertIn("brief@test.io", s)
|
||||||
|
self.assertIn("note_unlock", s)
|
||||||
|
|
||||||
|
|
||||||
|
class ViewPostMarksReadTest(TestCase):
|
||||||
|
"""GET /billboard/post/<uuid>/ flips every unread Brief on that post for
|
||||||
|
the requesting user to is_unread=False. NVM (banner dismiss client-side
|
||||||
|
without nav) leaves Briefs untouched — that path doesn't hit this view."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="reader@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.post = Post.objects.create(owner=self.user)
|
||||||
|
self.line = Line.objects.create(post=self.post, text="entry one", author=self.user)
|
||||||
|
|
||||||
|
def test_get_view_post_flips_owner_unread_brief_to_read(self):
|
||||||
|
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||||
|
self.assertTrue(b.is_unread)
|
||||||
|
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
b.refresh_from_db()
|
||||||
|
self.assertFalse(b.is_unread)
|
||||||
|
|
||||||
|
def test_get_does_not_flip_other_users_briefs(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
# Both users have a Brief on this post; only the requesting user's flips
|
||||||
|
mine = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||||
|
theirs = Brief.objects.create(owner=other, post=self.post, line=self.line)
|
||||||
|
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
mine.refresh_from_db()
|
||||||
|
theirs.refresh_from_db()
|
||||||
|
self.assertFalse(mine.is_unread)
|
||||||
|
self.assertTrue(theirs.is_unread)
|
||||||
|
|
||||||
|
def test_get_does_not_flip_briefs_on_other_posts(self):
|
||||||
|
other_post = Post.objects.create(owner=self.user)
|
||||||
|
other_line = Line.objects.create(post=other_post, text="other", author=self.user)
|
||||||
|
unrelated = Brief.objects.create(owner=self.user, post=other_post, line=other_line)
|
||||||
|
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
unrelated.refresh_from_db()
|
||||||
|
self.assertTrue(unrelated.is_unread)
|
||||||
|
|
||||||
|
def test_get_idempotent_for_already_read_brief(self):
|
||||||
|
already_read = Brief.objects.create(
|
||||||
|
owner=self.user, post=self.post, line=self.line, is_unread=False,
|
||||||
|
)
|
||||||
|
self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
already_read.refresh_from_db()
|
||||||
|
self.assertFalse(already_read.is_unread)
|
||||||
|
|
||||||
|
def test_post_request_does_not_mark_read(self):
|
||||||
|
"""Posting a new Line to view_post (the legacy compose flow) is not
|
||||||
|
the FYI-read contract — the user is composing, not reviewing. Mark-
|
||||||
|
read happens only on a GET render of post.html."""
|
||||||
|
b = Brief.objects.create(owner=self.user, post=self.post, line=self.line)
|
||||||
|
self.client.post(
|
||||||
|
reverse("billboard:view_post", args=[self.post.id]),
|
||||||
|
data={"text": "appended via POST"},
|
||||||
|
)
|
||||||
|
b.refresh_from_db()
|
||||||
|
self.assertTrue(b.is_unread)
|
||||||
322
src/apps/billboard/tests/integrated/test_buds.py
Normal file
322
src/apps/billboard/tests/integrated/test_buds.py
Normal file
@@ -0,0 +1,322 @@
|
|||||||
|
"""ITs for the My Buds feature (User.buds M2M + my_buds view +
|
||||||
|
add_bud JSON endpoint).
|
||||||
|
|
||||||
|
User.buds is a self M2M (symmetrical=False) — adding Alice to Disco's
|
||||||
|
list does NOT auto-reciprocate. Implicit auto-add on shared events
|
||||||
|
(post-share, gate-invite) is layered separately in those views.
|
||||||
|
|
||||||
|
Privacy: add_bud returns 200 with {bud: null} when the email is
|
||||||
|
unregistered, so the response shape never leaks membership.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserBudsM2MTest(TestCase):
|
||||||
|
"""The buds field is asymmetric — A.buds.add(B) doesn't
|
||||||
|
reciprocate to B.buds, only to B.added_as_bud."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.disco = User.objects.create(email="disco@test.io")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io")
|
||||||
|
|
||||||
|
def test_add_bud_one_way(self):
|
||||||
|
self.disco.buds.add(self.alice)
|
||||||
|
self.assertIn(self.alice, self.disco.buds.all())
|
||||||
|
self.assertNotIn(self.disco, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_added_as_bud_reverse_relation(self):
|
||||||
|
self.disco.buds.add(self.alice)
|
||||||
|
self.assertIn(self.disco, self.alice.added_as_bud.all())
|
||||||
|
|
||||||
|
def test_add_is_idempotent(self):
|
||||||
|
self.disco.buds.add(self.alice)
|
||||||
|
self.disco.buds.add(self.alice)
|
||||||
|
self.assertEqual(self.disco.buds.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class MyBudsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.user.buds.add(self.alice, self.bob)
|
||||||
|
|
||||||
|
def test_my_buds_renders_template(self):
|
||||||
|
response = self.client.get(reverse("billboard:my_buds"))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/my_buds.html")
|
||||||
|
|
||||||
|
def test_my_buds_lists_users_buds(self):
|
||||||
|
response = self.client.get(reverse("billboard:my_buds"))
|
||||||
|
buds = list(response.context["buds"])
|
||||||
|
self.assertIn(self.alice, buds)
|
||||||
|
self.assertIn(self.bob, buds)
|
||||||
|
|
||||||
|
def test_my_buds_does_not_list_others_buds(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
carol = User.objects.create(email="carol@test.io", username="carol")
|
||||||
|
other.buds.add(carol)
|
||||||
|
response = self.client.get(reverse("billboard:my_buds"))
|
||||||
|
self.assertNotIn(carol, list(response.context["buds"]))
|
||||||
|
|
||||||
|
def test_my_buds_redirects_anon_to_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse("billboard:my_buds"))
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class AddBudViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_add_registered_email_adds_to_buds(self):
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIn(alice, self.user.buds.all())
|
||||||
|
|
||||||
|
def test_add_returns_bud_payload_with_username(self):
|
||||||
|
User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
body = response.json()
|
||||||
|
self.assertIsNotNone(body["bud"])
|
||||||
|
self.assertEqual(body["bud"]["username"], "alice")
|
||||||
|
|
||||||
|
def test_add_unregistered_email_returns_null_bud(self):
|
||||||
|
"""Privacy: 200 with bud=null so the response shape doesn't leak
|
||||||
|
whether the address is on the system."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "ghost@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.json()["bud"])
|
||||||
|
self.assertEqual(self.user.buds.count(), 0)
|
||||||
|
|
||||||
|
def test_add_own_email_is_silent_noop(self):
|
||||||
|
"""Adding yourself: no bud added, response carries bud=null."""
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "me@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertIsNone(response.json()["bud"])
|
||||||
|
self.assertNotIn(self.user, self.user.buds.all())
|
||||||
|
|
||||||
|
def test_add_existing_bud_is_idempotent(self):
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.user.buds.add(alice)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
# Still only one bud entry — M2M dedup
|
||||||
|
self.assertEqual(self.user.buds.count(), 1)
|
||||||
|
# Response still carries the bud payload (so the JS can refresh
|
||||||
|
# an entry if a fast double-click bypassed the data-bud-id guard).
|
||||||
|
self.assertIsNotNone(response.json()["bud"])
|
||||||
|
|
||||||
|
def test_add_falls_back_to_email_when_no_username(self):
|
||||||
|
"""Bud payload returns email when bud.username is None — display
|
||||||
|
layer matches the navbar fallback (display_name filter)."""
|
||||||
|
User.objects.create(email="anon@test.io")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "anon@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.json()["bud"]["username"], "anon@test.io")
|
||||||
|
|
||||||
|
def test_get_returns_405(self):
|
||||||
|
response = self.client.get(reverse("billboard:add_bud"))
|
||||||
|
self.assertEqual(response.status_code, 405)
|
||||||
|
|
||||||
|
def test_add_resolves_username_too_not_just_email(self):
|
||||||
|
"""Phase 2: recipient field accepts usernames as well as emails."""
|
||||||
|
alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:add_bud"),
|
||||||
|
data={"recipient": "alice"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["bud"]["username"], "alice")
|
||||||
|
self.assertIn(alice, self.user.buds.all())
|
||||||
|
|
||||||
|
|
||||||
|
class SearchBudsViewTest(TestCase):
|
||||||
|
"""Top-3 prefix-match autocomplete endpoint backing #id_recipient."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.albert = User.objects.create(email="albert@test.io", username="albert")
|
||||||
|
self.alvin = User.objects.create(email="alvin@test.io", username="alvin")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.user.buds.add(self.alice, self.albert, self.alvin, self.bob)
|
||||||
|
|
||||||
|
def test_username_prefix_match(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
usernames = [b["username"] for b in response.json()["buds"]]
|
||||||
|
# alice, albert, alvin all start with "al" — exactly 3 (cap)
|
||||||
|
self.assertEqual(len(usernames), 3)
|
||||||
|
self.assertIn("alice", usernames)
|
||||||
|
self.assertIn("albert", usernames)
|
||||||
|
self.assertIn("alvin", usernames)
|
||||||
|
self.assertNotIn("bob", usernames)
|
||||||
|
|
||||||
|
def test_caps_at_three_results(self):
|
||||||
|
d = User.objects.create(email="alfred@test.io", username="alfred")
|
||||||
|
self.user.buds.add(d)
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
self.assertEqual(len(response.json()["buds"]), 3)
|
||||||
|
|
||||||
|
def test_email_prefix_also_matches(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "bob@"})
|
||||||
|
usernames = [b["username"] for b in response.json()["buds"]]
|
||||||
|
self.assertIn("bob", usernames)
|
||||||
|
|
||||||
|
def test_does_not_leak_non_buds(self):
|
||||||
|
"""Non-buds (other registered users) don't appear in suggestions."""
|
||||||
|
User.objects.create(email="stranger@test.io", username="stranger")
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "str"})
|
||||||
|
self.assertEqual(response.json()["buds"], [])
|
||||||
|
|
||||||
|
def test_empty_q_returns_empty_list(self):
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": ""})
|
||||||
|
self.assertEqual(response.json()["buds"], [])
|
||||||
|
|
||||||
|
def test_anon_redirects(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(reverse("billboard:search_buds"), {"q": "al"})
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
|
||||||
|
class SharePostImplicitAutoAddTest(TestCase):
|
||||||
|
"""Per-spec: when a share lands a recipient on Post.shared_with, the
|
||||||
|
sharer + recipient mutually auto-add each other to their buds lists."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.billboard.models import Post
|
||||||
|
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
|
||||||
|
self.client.force_login(self.sharer)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.post = Post.objects.create(owner=self.sharer)
|
||||||
|
|
||||||
|
def _share(self, recipient):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": recipient},
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_share_adds_recipient_to_sharer_buds(self):
|
||||||
|
self._share("alice@test.io")
|
||||||
|
self.assertIn(self.alice, self.sharer.buds.all())
|
||||||
|
|
||||||
|
def test_share_adds_sharer_to_recipient_buds(self):
|
||||||
|
"""Symmetric on shared events — recipient also gets the sharer."""
|
||||||
|
self._share("alice@test.io")
|
||||||
|
self.assertIn(self.sharer, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_share_with_username_also_auto_adds(self):
|
||||||
|
self._share("alice")
|
||||||
|
self.assertIn(self.alice, self.sharer.buds.all())
|
||||||
|
self.assertIn(self.sharer, self.alice.buds.all())
|
||||||
|
|
||||||
|
def test_unregistered_recipient_does_not_auto_add(self):
|
||||||
|
"""Privacy: unregistered email doesn't touch the buds graph."""
|
||||||
|
self._share("ghost@test.io")
|
||||||
|
self.assertEqual(self.sharer.buds.count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class AddBudAlreadyPresentTest(TestCase):
|
||||||
|
"""Duplicate-add guard: add_bud's JSON response distinguishes "newly
|
||||||
|
added" from "already a bud" so the bud-btn JS can render an error
|
||||||
|
Brief titled `@<username> is already present` instead of the normal
|
||||||
|
bud-entry append. `recipient_user_id` is the highlight target id that
|
||||||
|
the Brief FYI button toggles `.bud-duplicate-flash` onto."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="me@test.io", username="me")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.user.buds.add(self.alice)
|
||||||
|
|
||||||
|
def _add(self, recipient):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("billboard:add_bud"), {"recipient": recipient},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_already_present_true_when_candidate_is_already_a_bud(self):
|
||||||
|
self.assertTrue(self._add("alice@test.io").json()["already_present"])
|
||||||
|
|
||||||
|
def test_already_present_false_for_new_bud(self):
|
||||||
|
User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.assertFalse(self._add("bob@test.io").json()["already_present"])
|
||||||
|
|
||||||
|
def test_already_present_false_for_unregistered_email(self):
|
||||||
|
self.assertFalse(self._add("ghost@test.io").json()["already_present"])
|
||||||
|
|
||||||
|
def test_recipient_display_carries_username_on_duplicate(self):
|
||||||
|
self.assertEqual(self._add("alice@test.io").json()["recipient_display"], "alice")
|
||||||
|
|
||||||
|
def test_recipient_user_id_carries_alice_id_on_duplicate(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._add("alice@test.io").json()["recipient_user_id"],
|
||||||
|
str(self.alice.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_username_typed_recipient_also_detects_duplicate(self):
|
||||||
|
"""A user typing 'alice' (no @) resolves the same way as the email."""
|
||||||
|
self.assertTrue(self._add("alice").json()["already_present"])
|
||||||
|
|
||||||
|
|
||||||
|
class SharePostAlreadyPresentTest(TestCase):
|
||||||
|
"""Duplicate-share guard mirrors AddBudAlreadyPresentTest — when the
|
||||||
|
recipient is already in post.shared_with, response carries
|
||||||
|
already_present + recipient_user_id for the .post-recipient highlight."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from apps.billboard.models import Post
|
||||||
|
self.sharer = User.objects.create(email="sharer@test.io", username="sharer")
|
||||||
|
self.client.force_login(self.sharer)
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.post = Post.objects.create(owner=self.sharer)
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
|
||||||
|
def _share(self, recipient):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": recipient},
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_already_present_true_when_recipient_in_shared_with(self):
|
||||||
|
self.assertTrue(self._share("alice@test.io").json()["already_present"])
|
||||||
|
|
||||||
|
def test_already_present_false_for_new_recipient(self):
|
||||||
|
User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.assertFalse(self._share("bob@test.io").json()["already_present"])
|
||||||
|
|
||||||
|
def test_recipient_user_id_present_on_duplicate(self):
|
||||||
|
self.assertEqual(
|
||||||
|
self._share("alice@test.io").json()["recipient_user_id"],
|
||||||
|
str(self.alice.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recipient_display_already_present_on_duplicate(self):
|
||||||
|
"""`recipient_display` already exists on the success path; on
|
||||||
|
duplicate the same field must carry the matched user's handle."""
|
||||||
|
self.assertEqual(self._share("alice@test.io").json()["recipient_display"], "alice")
|
||||||
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
152
src/apps/billboard/tests/integrated/test_post_invitee_view.py
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
"""ITs for post.html invitee-vs-owner header rendering.
|
||||||
|
|
||||||
|
The "just me, @owner the {title}" / "shared between … & me, @owner …" lines
|
||||||
|
were owner-centric (the legacy phrasing assumed the viewer is the post
|
||||||
|
creator). For an invitee (a user in post.shared_with), that prose is
|
||||||
|
confusing. This view branches the .post-header block:
|
||||||
|
|
||||||
|
• Owner viewing → unchanged existing prose.
|
||||||
|
• Invitee viewing (sole) → "shared with me, @viewer the {title}" +
|
||||||
|
"created by @owner the {owner_title}".
|
||||||
|
• Invitee viewing (multi) → "shared with {other_recipients ...}" +
|
||||||
|
"& me, @viewer the {title}" +
|
||||||
|
"created by @owner the {owner_title}".
|
||||||
|
|
||||||
|
The view layer adds `viewer_is_owner` + `other_recipients` to the
|
||||||
|
context; template branches on those.
|
||||||
|
"""
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.billboard.models import Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class PostInviteeViewContextTest(TestCase):
|
||||||
|
"""Context vars: viewer_is_owner + other_recipients."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.post = Post.objects.create(owner=self.owner, title="Coolio")
|
||||||
|
Line.objects.create(post=self.post, text="seed", author=self.owner)
|
||||||
|
|
||||||
|
def test_owner_viewing_sets_viewer_is_owner_true(self):
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
self.assertTrue(response.context["viewer_is_owner"])
|
||||||
|
|
||||||
|
def test_invitee_viewing_sets_viewer_is_owner_false(self):
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
self.assertFalse(response.context["viewer_is_owner"])
|
||||||
|
|
||||||
|
def test_other_recipients_excludes_viewer(self):
|
||||||
|
"""For an invitee, other_recipients = shared_with minus self."""
|
||||||
|
self.post.shared_with.add(self.alice, self.bob)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
others = list(response.context["other_recipients"])
|
||||||
|
self.assertIn(self.bob, others)
|
||||||
|
self.assertNotIn(self.alice, others)
|
||||||
|
|
||||||
|
def test_other_recipients_empty_for_sole_invitee(self):
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
self.assertEqual(list(response.context["other_recipients"]), [])
|
||||||
|
|
||||||
|
def test_other_recipients_for_owner_is_full_shared_with(self):
|
||||||
|
"""Owner viewing: other_recipients includes everyone (no self exclusion)."""
|
||||||
|
self.post.shared_with.add(self.alice, self.bob)
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
others = list(response.context["other_recipients"])
|
||||||
|
self.assertIn(self.alice, others)
|
||||||
|
self.assertIn(self.bob, others)
|
||||||
|
|
||||||
|
|
||||||
|
class PostInviteeViewTemplateTest(TestCase):
|
||||||
|
"""Template prose: invitee branch shows "shared with me, …" /
|
||||||
|
"created by @owner …" — does NOT show the owner-centric "just me" or
|
||||||
|
"shared between"."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="owner@test.io", username="owner")
|
||||||
|
self.alice = User.objects.create(email="alice@test.io", username="alice")
|
||||||
|
self.bob = User.objects.create(email="bob@test.io", username="bob")
|
||||||
|
self.post = Post.objects.create(owner=self.owner, title="Coolio")
|
||||||
|
Line.objects.create(post=self.post, text="seed", author=self.owner)
|
||||||
|
|
||||||
|
def test_sole_invitee_sees_shared_with_me(self):
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
body = response.content.decode()
|
||||||
|
self.assertIn("shared with me", body)
|
||||||
|
self.assertIn("@alice", body)
|
||||||
|
|
||||||
|
def test_sole_invitee_does_not_see_just_me_or_shared_between(self):
|
||||||
|
"""Scope to .post-header — the bud-panel JS includes 'just me,' as
|
||||||
|
a regex literal in inline script, so a body-wide string match
|
||||||
|
false-positives."""
|
||||||
|
import lxml.html
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||||
|
tree = lxml.html.fromstring(body)
|
||||||
|
header_text = tree.cssselect(".post-header")[0].text_content()
|
||||||
|
self.assertNotIn("just me,", header_text)
|
||||||
|
self.assertNotIn("shared between", header_text)
|
||||||
|
|
||||||
|
def test_invitee_sees_created_by_owner(self):
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||||
|
self.assertIn("created by", body)
|
||||||
|
self.assertIn("@owner", body)
|
||||||
|
|
||||||
|
def test_multi_invitee_sees_shared_with_others_then_amp_me(self):
|
||||||
|
self.post.shared_with.add(self.alice, self.bob)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||||
|
self.assertIn("shared with", body)
|
||||||
|
self.assertIn("@bob", body)
|
||||||
|
self.assertIn("& me", body)
|
||||||
|
self.assertIn("@alice", body)
|
||||||
|
|
||||||
|
def test_multi_invitee_does_not_see_self_in_recipients_line(self):
|
||||||
|
"""The recipients line lists OTHER invitees, not self."""
|
||||||
|
self.post.shared_with.add(self.alice, self.bob)
|
||||||
|
self.client.force_login(self.alice)
|
||||||
|
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||||
|
# Coarse check: "shared with @bob" appears w/o @alice in same line
|
||||||
|
# (since alice's "@alice" is on the "& me" line below). The full
|
||||||
|
# body contains both, but the .post-shared-recipients line should
|
||||||
|
# only list other_recipients (i.e., bob, not alice).
|
||||||
|
# Use a narrower lxml-style assertion.
|
||||||
|
import lxml.html
|
||||||
|
tree = lxml.html.fromstring(body)
|
||||||
|
recipients_p = tree.cssselect(".post-shared-recipients")
|
||||||
|
self.assertEqual(len(recipients_p), 1)
|
||||||
|
rec_text = recipients_p[0].text_content()
|
||||||
|
self.assertIn("@bob", rec_text)
|
||||||
|
self.assertNotIn("@alice", rec_text)
|
||||||
|
|
||||||
|
def test_owner_view_unchanged_when_recipients_present(self):
|
||||||
|
"""Owner sees 'shared between' (old behavior)."""
|
||||||
|
self.post.shared_with.add(self.alice)
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||||
|
self.assertIn("shared between", body)
|
||||||
|
self.assertIn("& me", body)
|
||||||
|
self.assertNotIn("created by", body)
|
||||||
|
|
||||||
|
def test_owner_view_just_me_when_no_recipients(self):
|
||||||
|
"""Owner with no recipients: 'just me, …' (old behavior)."""
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
body = self.client.get(reverse("billboard:view_post", args=[self.post.id])).content.decode()
|
||||||
|
self.assertIn("just me,", body)
|
||||||
|
self.assertNotIn("created by", body)
|
||||||
151
src/apps/billboard/tests/integrated/test_share_post.py
Normal file
151
src/apps/billboard/tests/integrated/test_share_post.py
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
"""ITs for share-post async-Brief flow (C3.b).
|
||||||
|
|
||||||
|
POST /billboard/post/<uuid>/share-post w. Accept: application/json now:
|
||||||
|
- Adds the recipient to Post.shared_with (if registered, not the sharer)
|
||||||
|
- Appends a Line to the Post recording the share event
|
||||||
|
- Spawns a Brief(kind=SHARE_INVITE) for the sharer that JS slide-downs
|
||||||
|
- Returns JSON {brief: {…}, line_text: "…"}; no redirect, no messages
|
||||||
|
|
||||||
|
Legacy form-submit (no Accept: application/json) still redirects + flashes
|
||||||
|
the privacy-safe success message — kept for non-AJAX fallback / older FTs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.billboard.models import Brief, Line, Post
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class SharePostAsyncTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.sharer = User.objects.create(email="sharer@test.io")
|
||||||
|
self.client.force_login(self.sharer)
|
||||||
|
self.post = Post.objects.create(owner=self.sharer)
|
||||||
|
|
||||||
|
def _share_async(self, recipient_email):
|
||||||
|
return self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": recipient_email},
|
||||||
|
HTTP_ACCEPT="application/json",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_async_share_returns_brief_payload(self):
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
response = self._share_async("alice@test.io")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
body = response.json()
|
||||||
|
self.assertIn("brief", body)
|
||||||
|
self.assertIn("line_text", body)
|
||||||
|
|
||||||
|
def test_async_share_appends_line_to_post(self):
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
self.assertEqual(self.post.lines.count(), 0)
|
||||||
|
self._share_async("alice@test.io")
|
||||||
|
self.assertEqual(self.post.lines.count(), 1)
|
||||||
|
line = self.post.lines.first()
|
||||||
|
self.assertIn("alice@test.io", line.text)
|
||||||
|
|
||||||
|
def test_async_share_creates_share_invite_brief_for_sharer(self):
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
self._share_async("alice@test.io")
|
||||||
|
brief = Brief.objects.get(owner=self.sharer)
|
||||||
|
self.assertEqual(brief.kind, Brief.KIND_SHARE_INVITE)
|
||||||
|
self.assertEqual(brief.post, self.post)
|
||||||
|
self.assertIsNotNone(brief.line)
|
||||||
|
self.assertTrue(brief.is_unread)
|
||||||
|
|
||||||
|
def test_async_share_adds_registered_recipient_to_shared_with(self):
|
||||||
|
alice = User.objects.create(email="alice@test.io")
|
||||||
|
self._share_async("alice@test.io")
|
||||||
|
self.assertIn(alice, self.post.shared_with.all())
|
||||||
|
|
||||||
|
def test_async_share_unregistered_recipient_still_appends_line_and_brief(self):
|
||||||
|
"""Privacy: even if the email isn't registered, the sharer gets the
|
||||||
|
same confirmation Brief + Line. Otherwise the response shape would
|
||||||
|
leak whether an address is on the system."""
|
||||||
|
response = self._share_async("ghost@test.io")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(self.post.lines.count(), 1)
|
||||||
|
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 1)
|
||||||
|
|
||||||
|
def test_async_share_does_not_add_owner_as_recipient(self):
|
||||||
|
"""Sharer shares w. their own email — no shared_with add, no Line, no
|
||||||
|
Brief; response carries brief: null so the JS just no-ops."""
|
||||||
|
response = self._share_async("sharer@test.io")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["brief"], None)
|
||||||
|
self.assertEqual(self.post.lines.count(), 0)
|
||||||
|
self.assertEqual(Brief.objects.filter(owner=self.sharer).count(), 0)
|
||||||
|
self.assertNotIn(self.sharer, self.post.shared_with.all())
|
||||||
|
|
||||||
|
def test_async_share_brief_payload_carries_share_invite_kind(self):
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
body = self._share_async("alice@test.io").json()
|
||||||
|
self.assertEqual(body["brief"]["kind"], "share_invite")
|
||||||
|
self.assertIn("alice@test.io", body["line_text"])
|
||||||
|
|
||||||
|
def test_async_reshare_same_recipient_is_silent_noop(self):
|
||||||
|
"""Sharing the same recipient twice is a silent no-op — Post.shared_with
|
||||||
|
M2M is idempotent so a second add is meaningless, and we don't want a
|
||||||
|
duplicate Line cluttering the thread. Response is 200 with brief=null."""
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
self._share_async("alice@test.io")
|
||||||
|
self.assertEqual(self.post.lines.count(), 1)
|
||||||
|
before_brief_count = Brief.objects.filter(owner=self.sharer).count()
|
||||||
|
|
||||||
|
response = self._share_async("alice@test.io")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["brief"], None)
|
||||||
|
# No second Line, no second Brief.
|
||||||
|
self.assertEqual(self.post.lines.count(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
Brief.objects.filter(owner=self.sharer).count(),
|
||||||
|
before_brief_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_async_share_line_text_drops_timestamp(self):
|
||||||
|
"""The share Line's text is plain "Shared with X" — no "at <iso ts>"
|
||||||
|
suffix (timestamp display lives on the per-Line `<time>` element now)."""
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
self._share_async("alice@test.io")
|
||||||
|
line = self.post.lines.first()
|
||||||
|
self.assertEqual(line.text, "Shared with alice@test.io")
|
||||||
|
self.assertNotIn(" at ", line.text)
|
||||||
|
|
||||||
|
def test_async_share_line_author_is_sharer_not_adman(self):
|
||||||
|
"""User-created share Lines attribute to the sharer (the post owner
|
||||||
|
doing the share), not the system adman entity."""
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
self._share_async("alice@test.io")
|
||||||
|
line = self.post.lines.first()
|
||||||
|
self.assertEqual(line.author, self.sharer)
|
||||||
|
|
||||||
|
|
||||||
|
class SharePostLegacyRedirectTest(TestCase):
|
||||||
|
"""Legacy form-submit path (no Accept: application/json) is preserved —
|
||||||
|
redirects + flashes the privacy-safe message + adds shared_with. Existing
|
||||||
|
FTs that submit the share form via Selenium still work."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.sharer = User.objects.create(email="sharer@test.io")
|
||||||
|
self.client.force_login(self.sharer)
|
||||||
|
self.post = Post.objects.create(owner=self.sharer)
|
||||||
|
|
||||||
|
def test_form_submit_still_redirects(self):
|
||||||
|
User.objects.create(email="alice@test.io")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(response["Location"], reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
|
||||||
|
def test_form_submit_still_adds_shared_with(self):
|
||||||
|
alice = User.objects.create(email="alice@test.io")
|
||||||
|
self.client.post(
|
||||||
|
reverse("billboard:share_post", args=[self.post.id]),
|
||||||
|
data={"recipient": "alice@test.io"},
|
||||||
|
)
|
||||||
|
self.assertIn(alice, self.post.shared_with.all())
|
||||||
483
src/apps/billboard/tests/integrated/test_views.py
Normal file
483
src/apps/billboard/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,483 @@
|
|||||||
|
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 [
|
||||||
|
("my-scrolls", "My Scrolls", 4, 3),
|
||||||
|
("my-buds", "My Buds", 4, 3),
|
||||||
|
("most-recent-scroll", "Most Recent Scroll", 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("my-scrolls", slugs)
|
||||||
|
self.assertIn("my-buds", slugs)
|
||||||
|
self.assertIn("most-recent-scroll", 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": ["my-scrolls"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
from apps.applets.models import UserApplet
|
||||||
|
contacts = Applet.objects.get(slug="my-buds")
|
||||||
|
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": ["my-scrolls"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
||||||
|
|
||||||
|
def test_htmx_toggle_response_renders_most_recent_scroll_with_real_events(self):
|
||||||
|
# Seed a room + event so Most Recent Scroll renders prose, not the empty fallback.
|
||||||
|
room = Room.objects.create(name="Sound Chamber", 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.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": [
|
||||||
|
"my-scrolls",
|
||||||
|
"my-buds",
|
||||||
|
"most-recent-scroll",
|
||||||
|
]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "Coin-on-a-String")
|
||||||
|
# And My Scrolls renders the room name (needs my_rooms in context).
|
||||||
|
self.assertContains(response, "Sound Chamber")
|
||||||
|
|
||||||
|
def test_htmx_toggle_response_has_single_applet_menu_div(self):
|
||||||
|
# The response is hx-swapped into the page; if it contains both the menu
|
||||||
|
# div and the applets-container div, the original menu remains and the
|
||||||
|
# next gear-click resurrects stale form state. Response must contain the
|
||||||
|
# menu exactly once (the wrapper) — never two siblings of the same id.
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": ["my-scrolls"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
body = response.content.decode("utf-8")
|
||||||
|
self.assertEqual(body.count('id="id_billboard_applet_menu"'), 1)
|
||||||
|
|
||||||
|
def test_second_toggle_preserves_prior_hidden_state(self):
|
||||||
|
# First toggle: hide My Buds only.
|
||||||
|
self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": [
|
||||||
|
"new-post", "my-posts",
|
||||||
|
"my-scrolls",
|
||||||
|
"most-recent-scroll",
|
||||||
|
]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
# Second toggle: hide Most Recent Scroll additionally — My Buds must stay hidden.
|
||||||
|
self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": [
|
||||||
|
"new-post", "my-posts",
|
||||||
|
"my-scrolls",
|
||||||
|
]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
from apps.applets.models import UserApplet
|
||||||
|
contacts = Applet.objects.get(slug="my-buds")
|
||||||
|
most_recent_scroll = Applet.objects.get(slug="most-recent-scroll")
|
||||||
|
self.assertFalse(
|
||||||
|
UserApplet.objects.get(user=self.user, applet=contacts).visible
|
||||||
|
)
|
||||||
|
self.assertFalse(
|
||||||
|
UserApplet.objects.get(user=self.user, applet=most_recent_scroll).visible
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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_scroll_template(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/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)
|
||||||
|
data = response.json()
|
||||||
|
self.assertTrue(data["ok"])
|
||||||
|
self.assertEqual(data["greeting"], "Welcome,")
|
||||||
|
self.assertEqual(data["title"], "Earthman")
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class PostLineRelativeTimestampTest(TestCase):
|
||||||
|
"""post.html mirrors scroll.html's bucketed `relative_ts` time rendering:
|
||||||
|
same-day Lines show a time; older ones collapse to weekday / month-day /
|
||||||
|
month-day-year. Bypasses `auto_now_add` with a queryset .update() so the
|
||||||
|
test can backdate Lines."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="owner@post-ts.io", username="owner")
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
from apps.billboard.models import Line, Post
|
||||||
|
self.Line = Line
|
||||||
|
self.post = Post.objects.create(owner=self.owner, title="Stamp")
|
||||||
|
|
||||||
|
def _backdate(self, line, **delta):
|
||||||
|
from apps.billboard.models import Line
|
||||||
|
Line.objects.filter(pk=line.pk).update(
|
||||||
|
created_at=timezone.now() - timezone.timedelta(**delta)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_recent_line_renders_clock_time(self):
|
||||||
|
self.Line.objects.create(post=self.post, text="now", author=self.owner)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
self.assertRegex(
|
||||||
|
response.content.decode(),
|
||||||
|
r'class="post-line-time"[^>]*>\s*\d+:\d{2}\s*[ap]\.m\.\s*<',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_two_day_old_line_renders_weekday(self):
|
||||||
|
line = self.Line.objects.create(post=self.post, text="old", author=self.owner)
|
||||||
|
self._backdate(line, days=2)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
import re
|
||||||
|
m = re.search(
|
||||||
|
r'class="post-line-time"[^>]*>\s*(\w+)\s*<', response.content.decode()
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(m, "no .post-line-time cell rendered")
|
||||||
|
self.assertIn(m.group(1), {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"})
|
||||||
|
|
||||||
|
def test_thirty_day_old_line_renders_day_month(self):
|
||||||
|
line = self.Line.objects.create(post=self.post, text="oldr", author=self.owner)
|
||||||
|
self._backdate(line, days=30)
|
||||||
|
response = self.client.get(reverse("billboard:view_post", args=[self.post.id]))
|
||||||
|
self.assertRegex(
|
||||||
|
response.content.decode(),
|
||||||
|
r'class="post-line-time"[^>]*>\s*\d{2}\s\w{3}\s*<',
|
||||||
|
)
|
||||||
24
src/apps/billboard/urls.py
Normal file
24
src/apps/billboard/urls.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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.scroll, name="scroll"),
|
||||||
|
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||||
|
# Post/Line CRUD (relocated from apps.dashboard.urls)
|
||||||
|
path("new-post", views.new_post, name="new_post"),
|
||||||
|
path("post/<uuid:post_id>/", views.view_post, name="view_post"),
|
||||||
|
path("post/<uuid:post_id>/share-post", views.share_post, name="share_post"),
|
||||||
|
path("users/<uuid:user_id>/", views.my_posts, name="my_posts"),
|
||||||
|
path("my-buds/", views.my_buds, name="my_buds"),
|
||||||
|
path("buds/add", views.add_bud, name="add_bud"),
|
||||||
|
path("buds/search", views.search_buds, name="search_buds"),
|
||||||
|
]
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user