Compare commits
493 Commits
pre-drf
...
92df686d80
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92df686d80 | ||
|
|
53cd7afeb4 | ||
|
|
1452de1a76 | ||
|
|
f6093136f1 | ||
|
|
e90f10fe47 | ||
|
|
25f55f728a | ||
|
|
d28cf7b538 | ||
|
|
81b3c112b4 | ||
|
|
410664fb0f | ||
|
|
849ef3c310 | ||
|
|
8e476f5658 | ||
|
|
eb8666ba40 | ||
|
|
ca169be0fb | ||
|
|
8dd4347dbe | ||
|
|
f59c1af89a | ||
|
|
99ffdb3943 | ||
|
|
0f60c73f3b | ||
|
|
97a6da28a5 | ||
|
|
bb44aa326a | ||
|
|
31cb8dfc1d | ||
|
|
899e626265 | ||
|
|
f348a19312 | ||
|
|
bc4565f161 | ||
|
|
4963237420 | ||
|
|
191dad5365 | ||
|
|
611ca9b5b4 | ||
|
|
db443b7533 | ||
|
|
4417b8c972 | ||
|
|
1e37fe1475 | ||
|
|
d2c34d44d3 | ||
|
|
3fc5491372 | ||
|
|
7b7e80520a | ||
|
|
6f901fd9ce | ||
|
|
c1a8133345 | ||
|
|
b76d3c5dff | ||
|
|
31ed2bda0e | ||
|
|
b6e93b9d64 | ||
|
|
ca2a62fd84 | ||
|
|
f154d660bd | ||
|
|
fd5db951a7 | ||
|
|
f5fc1e15f8 | ||
|
|
285597b467 | ||
|
|
de48ae226d | ||
|
|
4d1c74a2af | ||
|
|
c8a603484e | ||
|
|
a636e940b7 | ||
|
|
76e1bfc9ad | ||
|
|
cd0add1e3c | ||
|
|
5b06d902a8 | ||
|
|
ab5b4c95dd | ||
|
|
559bdc2de7 | ||
|
|
39767c72c2 | ||
|
|
66b2947e8c | ||
|
|
400762c0e5 | ||
|
|
5e71b1d5da | ||
|
|
bf44628536 | ||
|
|
df9cf1eee8 | ||
|
|
5e5bc5a6af | ||
|
|
d2491c5e1b | ||
|
|
79706e817a | ||
|
|
8066ac289f | ||
|
|
7165974905 | ||
|
|
fbe6c12ded | ||
|
|
1ccb045889 | ||
|
|
435a192349 | ||
|
|
bc77296dd4 | ||
|
|
3242873625 | ||
|
|
ace8612099 | ||
|
|
f9cd08a510 | ||
|
|
ad0041db74 | ||
|
|
db10f345e4 | ||
|
|
f7fa250804 | ||
|
|
e2040fda8f | ||
|
|
b2f1511c2d | ||
|
|
054b0aa82b | ||
|
|
5beb990623 | ||
|
|
c03fb2bab0 | ||
|
|
c08dd145c3 | ||
|
|
eccb84f92b | ||
|
|
6a7464ee4b | ||
|
|
c64d7b9534 | ||
|
|
4b47dabaf0 | ||
|
|
a21e6aa251 | ||
|
|
f9c05a3eba | ||
|
|
af1a90e76b | ||
|
|
8240de6b45 | ||
|
|
b97c4a0508 | ||
|
|
3932b17256 | ||
|
|
17c4518944 | ||
|
|
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
|
||||
.claude
|
||||
.vscode
|
||||
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -1,18 +1,23 @@
|
||||
# Created by https://www.toptal.com/developers/gitignore/api/django
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=django
|
||||
|
||||
### Remove this once images renamed ###
|
||||
src/apps/epic/static/apps/epic/images/cards-faces/minchiate-fiorentine/
|
||||
|
||||
### Claude ###
|
||||
.claude
|
||||
|
||||
### VS Code ###
|
||||
.vscode
|
||||
|
||||
### Django ###
|
||||
*.log
|
||||
*.pot
|
||||
*.pyc
|
||||
__pycache__/
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
container.db.sqlite3
|
||||
*.sqlite3
|
||||
*.sqlite3-journal
|
||||
media
|
||||
|
||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||
@@ -184,3 +189,6 @@ cython_debug/
|
||||
#.idea/
|
||||
|
||||
# End of https://www.toptal.com/developers/gitignore/api/django
|
||||
|
||||
# Local dev utilities (Windows-only, not part of the app)
|
||||
*.ps1
|
||||
|
||||
@@ -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
|
||||
|
||||
64
.woodpecker/_retry_failed.sh
Normal file
64
.woodpecker/_retry_failed.sh
Normal file
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Usage: bash .woodpecker/_retry_failed.sh <test command args...>
|
||||
#
|
||||
# Runs `python manage.py test "$@"`. If any tests fail/error, parses the
|
||||
# failure labels out of stdout and re-runs ONLY those tests — so a single
|
||||
# Selenium flake at test 90/93 costs ~22s on retry instead of the full
|
||||
# 35-minute step.
|
||||
#
|
||||
# Django's unittest-based runner prints failures in a predictable shape:
|
||||
#
|
||||
# ERROR: test_method (full.dotted.path.TestClass.test_method)
|
||||
# FAIL: test_method (full.dotted.path.TestClass.test_method)
|
||||
#
|
||||
# The dotted path inside the parens is exactly what `manage.py test`
|
||||
# accepts as a label. We grep for those lines + re-run that list.
|
||||
#
|
||||
# Exit semantics:
|
||||
# - First run green → exit 0, no retry.
|
||||
# - First run failed AND label parse found nothing (crashed before any
|
||||
# test reported, e.g. ImportError) → propagate first-run exit code,
|
||||
# no retry. Genuine infra problems shouldn't be silently re-run.
|
||||
# - First run failed AND labels parsed → retry just those; exit with
|
||||
# the retry's exit code. A real (not-flaky) regression fails twice
|
||||
# → step still red, with the focused retry log as the authoritative
|
||||
# report (no need to scroll past the noisy first-run output).
|
||||
#
|
||||
# Run from inside `src/` (Woodpecker preserves cwd across `commands:`,
|
||||
# so the upstream `cd ./src` carries through).
|
||||
|
||||
set +e # do NOT bail on first failure; we WANT to handle it
|
||||
|
||||
LOG=$(mktemp -t ft-retry.XXXXXX.log)
|
||||
trap 'rm -f "$LOG"' EXIT
|
||||
|
||||
echo "──── First run ────"
|
||||
python manage.py test "$@" 2>&1 | tee "$LOG"
|
||||
FIRST=${PIPESTATUS[0]}
|
||||
|
||||
if [ "$FIRST" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse failure labels. Match both FAIL: and ERROR: lines; the dotted
|
||||
# path lives inside the trailing parens. `sort -u` dedupes if a single
|
||||
# test produces multiple lines (rare but possible).
|
||||
FAILED=$(grep -E '^(FAIL|ERROR): ' "$LOG" \
|
||||
| sed -E 's/^.*\(([^)]+)\)[^()]*$/\1/' \
|
||||
| sort -u \
|
||||
| tr '\n' ' ')
|
||||
|
||||
if [ -z "$FAILED" ]; then
|
||||
echo "──── First run failed, but no FAIL/ERROR labels parseable ────"
|
||||
echo "──── Not retrying — likely an infra problem, not a test flake ────"
|
||||
exit "$FIRST"
|
||||
fi
|
||||
|
||||
NUM=$(echo "$FAILED" | wc -w | tr -d ' ')
|
||||
echo ""
|
||||
echo "──── Retry ($NUM failed test(s) from first run) ────"
|
||||
echo "$FAILED" | tr ' ' '\n' | sed 's/^/ /'
|
||||
echo "─────────────────────────────────────────────────────"
|
||||
echo ""
|
||||
|
||||
python manage.py test $FAILED
|
||||
245
.woodpecker/main.yaml
Normal file
245
.woodpecker/main.yaml
Normal file
@@ -0,0 +1,245 @@
|
||||
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: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
environment:
|
||||
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
|
||||
CELERY_BROKER_URL: redis://redis:6379/0
|
||||
REDIS_URL: redis://redis:6379/1
|
||||
PIP_CACHE_DIR: .pip-cache
|
||||
commands:
|
||||
# `requirements.dev.txt` is the pinned superset Dockerfile.ci pre-
|
||||
# installs; pinning here means pip skips resolver+download and just
|
||||
# verifies "already satisfied" (~5-10s) instead of resolving unpinned
|
||||
# requirements.txt against PyPI from scratch (~30-60s). Drift safety
|
||||
# net: if requirements.dev.txt has changed since the CI image was
|
||||
# last rebuilt + pushed, pip installs the delta — slower for that
|
||||
# run but never broken. See TDD SKILL.md § CI dependency discipline.
|
||||
- pip install -r requirements.dev.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
|
||||
depends_on:
|
||||
- test-UTs-n-ITs
|
||||
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
|
||||
PIP_CACHE_DIR: .pip-cache
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- cd ./src
|
||||
# Also collectstatic'd here; output sits in the shared workspace so
|
||||
# the downstream FT steps don't have to repeat it.
|
||||
- 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"
|
||||
|
||||
# ── FT split (stage-parallel, intra-stage sequential) ─────────────────
|
||||
#
|
||||
# test_game_room_* is the heaviest cluster — 9 Selenium-driven room-flow
|
||||
# FTs that historically dominate the FT step wall-clock (~70% of the
|
||||
# ~40-min single-step runs). Split off into its own step (`test-FTs-room`)
|
||||
# so the partition is visible in the pipeline view; the non-room bucket
|
||||
# is `test-FTs-non-room`. Both depend on test-two-browser-FTs only, so
|
||||
# they fan out + run concurrently.
|
||||
#
|
||||
# The previous SQLite-collision blocker (pipeline #296: second step
|
||||
# started against the first step's half-created `src/test_db.sqlite3`
|
||||
# → Django interactive prompt → EOFError under non-interactive CI
|
||||
# stdin) is resolved by giving each step a distinct `DATABASE_URL`
|
||||
# pointing at its own sqlite file under /tmp — outside the shared
|
||||
# workspace mount so the two stages can't see each other's DB.
|
||||
#
|
||||
# `--parallel` is dropped from both steps. Empirically (pipelines
|
||||
# #302-304) it was giving ~1-1.5x speedup at most on these Selenium
|
||||
# FTs — Firefox spawn cost + RAM pressure + SQLite file-lock contention
|
||||
# eat most of the gain — while amplifying every transient-DOM flake
|
||||
# (login-race, gecko-perms, ElementNotInteractable, Jasmine-timeout).
|
||||
# Stage-level parallelism gives the same wall-clock reduction without
|
||||
# contention amplification.
|
||||
|
||||
- name: test-FTs-non-room
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
depends_on:
|
||||
- test-two-browser-FTs
|
||||
environment:
|
||||
HEADLESS: 1
|
||||
# /tmp path (not workspace-relative) so the parallel test-FTs-room
|
||||
# step can't see this DB + vice versa. See split-rationale above.
|
||||
DATABASE_URL: sqlite:////tmp/test_db_non_room.sqlite3
|
||||
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
|
||||
PIP_CACHE_DIR: .pip-cache
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- cd ./src
|
||||
# Every FT file EXCEPT test_game_room_*, test_trinket_*, AND
|
||||
# test_game_my_sea* — all three clusters run in test-FTs-room.
|
||||
# Channels + two-browser tags already covered upstream.
|
||||
# `ls | grep -v | sed` enumerates module dotted-paths from
|
||||
# filenames. (No trailing `_` in the my-sea alternative — the
|
||||
# file is `test_game_my_sea.py` w. no further suffix today.)
|
||||
#
|
||||
# Wrapped in `_retry_failed.sh` so a single Selenium flake (browser
|
||||
# hang, gecko-perms blip, login race) at test N/M doesn't cost the
|
||||
# full step wall-clock on retry — the script parses Django's
|
||||
# FAIL:/ERROR: lines from stdout + re-runs only those labels.
|
||||
- bash ../.woodpecker/_retry_failed.sh --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_*.py | grep -vE 'test_(game_room|trinket)_|test_game_my_sea' | sed 's|/|.|g;s|\.py||')
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: test-FTs-room
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
depends_on:
|
||||
- test-two-browser-FTs
|
||||
environment:
|
||||
HEADLESS: 1
|
||||
# /tmp path (not workspace-relative) so test-FTs-non-room can't see
|
||||
# this DB + vice versa. See split-rationale above.
|
||||
DATABASE_URL: sqlite:////tmp/test_db_room.sqlite3
|
||||
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
|
||||
PIP_CACHE_DIR: .pip-cache
|
||||
commands:
|
||||
- pip install -r requirements.dev.txt
|
||||
- cd ./src
|
||||
# Heavy Selenium room flows — test_game_room_* (deck_contrib,
|
||||
# gatekeeper, invite, select_role/sea/sig/sky, tray, tray_tooltip),
|
||||
# test_trinket_* (carte_blanche, coin_on_a_string, backstage_pass)
|
||||
# since trinket FTs create rooms + load the room template (where
|
||||
# the table hex SCSS + chair geometry live), AND test_game_my_sea*
|
||||
# (49 my-sea FTs that DRY-reuse the room-shell hex + sea-cross
|
||||
# picker — same Selenium surface, so the same parallel-stage
|
||||
# contention concerns apply). Runs in parallel w. test-FTs-non-room
|
||||
# (distinct DATABASE_URL paths under /tmp; see split-rationale).
|
||||
#
|
||||
# `_retry_failed.sh` parses Django FAIL:/ERROR: lines from the first
|
||||
# run's stdout + re-runs just those labels — single-flake retries
|
||||
# cost ~22s instead of the full ~35-min step wall-clock. Genuine
|
||||
# regressions still fail (second run output is the authoritative
|
||||
# report); first-run crashes w. no parseable labels propagate
|
||||
# the original exit code (don't silently mask infra problems).
|
||||
- bash ../.woodpecker/_retry_failed.sh --exclude-tag=channels --exclude-tag=two-browser $(ls functional_tests/test_game_room_*.py functional_tests/test_trinket_*.py functional_tests/test_game_my_sea*.py | sed 's|/|.|g;s|\.py||')
|
||||
when:
|
||||
- event: push
|
||||
path:
|
||||
- "src/**"
|
||||
- "requirements.txt"
|
||||
- ".woodpecker/main.yaml"
|
||||
|
||||
- name: screendumps
|
||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||
depends_on:
|
||||
- test-FTs-non-room
|
||||
- test-FTs-room
|
||||
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
|
||||
depends_on:
|
||||
- test-FTs-non-room
|
||||
- test-FTs-room
|
||||
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
|
||||
depends_on:
|
||||
- build-and-push
|
||||
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
|
||||
depends_on:
|
||||
- build-and-push
|
||||
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
|
||||
|
||||
RUN DJANGO_SECRET_KEY=build-dummy DJANGO_ALLOWED_HOST=localhost python manage.py compress
|
||||
|
||||
RUN adduser --uid 1234 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_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
|
||||
community.docker.docker_container:
|
||||
name: gamearray
|
||||
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||
state: started
|
||||
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:
|
||||
DJANGO_DEBUG_FALSE: "1"
|
||||
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
||||
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
|
||||
command: "python -m celery -A core worker -l info"
|
||||
|
||||
|
||||
- name: Create static files directory
|
||||
ansible.builtin.file:
|
||||
@@ -149,6 +185,11 @@
|
||||
container: gamearray
|
||||
command: python manage.py migrate
|
||||
|
||||
- name: Ensure superuser exists
|
||||
community.docker.docker_container_exec:
|
||||
container: gamearray
|
||||
command: python manage.py ensure_superuser
|
||||
|
||||
handlers:
|
||||
- name: Restart nginx
|
||||
ansible.builtin.service:
|
||||
|
||||
@@ -12,14 +12,29 @@ docker rm gamearray 2>/dev/null || true
|
||||
|
||||
echo "==> Starting new container..."
|
||||
docker run -d --name gamearray \
|
||||
--restart unless-stopped \
|
||||
--env-file /opt/gamearray/gamearray.env \
|
||||
--network gamearray_net \
|
||||
-p 127.0.0.1:8888:8888 \
|
||||
"$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..."
|
||||
docker exec gamearray python ./manage.py migrate
|
||||
|
||||
echo "==> Ensuring superuser exists..."
|
||||
docker exec gamearray python manage.py ensure_superuser
|
||||
|
||||
echo "==> Copying static files..."
|
||||
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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 }}
|
||||
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
|
||||
33616230376431343735626631623932393166343538653732383533323436326335343463646664
|
||||
6565373531623465613661613533376231373837326438300a393665613839646231633737313938
|
||||
64633035336663313163333634623732323537326363646132313136376131636666636538323066
|
||||
3037373930303537320a313062646166353862633836373466316261363939633433663039323866
|
||||
62333739303662343836306538393734343830366336323265393138343438363533353166383031
|
||||
32313461313137643039376237346633316466646136353038633861333031663164656233366634
|
||||
38303363383130376264373861393863623330623733643135643461383132613339376633353031
|
||||
32313863323039646534633733383661333361313832333830383066633130396239626661643264
|
||||
65636335303339613432326533343337366261356632313639623634386633383836333733663536
|
||||
39383361353530646166643531333535356636326535383534326237666638326137616162646261
|
||||
65316466323335653932636338653565383038313531383638393839313736643739363037353230
|
||||
35653632353531656435396663316537333133653632366437613339303033333536643937353166
|
||||
64363037653733303332643931343362303261643432366531326262383465313965633064356338
|
||||
31336333373665373035656533633864316139303934623030383934393434356334643962666163
|
||||
33343739366336613263333764306365333566363536616662383733616237396563346132336633
|
||||
38663239613339376335386233386330396634323033343332366130616162666339393861306336
|
||||
35383566383831356530633130313732356331616164646132626665646235396635386237313538
|
||||
38656631336261646530303761643334303937613036363766303637376262373466316431323731
|
||||
38666462313639353131303134646434646135366136343361353932326165626666306361393431
|
||||
62646238323265346263386363373462313766616333326366366461346436383064336535376339
|
||||
31356566356336386262393831616631666233633930393263623563386265343237323133313832
|
||||
3430363635363332303963316530663765613666306233376463
|
||||
33643937613637343765356165333337356138326236356334363238366632633935363563383232
|
||||
6263396663316461353035393836313535353133336132650a643062656239633635373930366131
|
||||
63363566666263336337356161663231343266383333613261666534653438666661303761653063
|
||||
6163333239313430620a613665393231356535666530613731303536613537333464613533616663
|
||||
30373935366138643939316563346364376333646333396264653537643666393835353964303031
|
||||
30366366666163383263663961383037386264393939306235646532636439383838343237303339
|
||||
62333965323763323233303239343132383830303130306265333330333434663337363930653161
|
||||
30646133333530333330653365306437313839636535333163346263343064376436633432623061
|
||||
39343332643836333932316439636166333831393864363434663837646339666638353835393964
|
||||
61363430303637633239373031396535383730623862386464316633393361306561613933353830
|
||||
66313835306563643733366135353062623635663165303833373563663063323731313162323133
|
||||
61373837353732656266336461663165626435383234336461343365396561623037353566356339
|
||||
32366336396638626166616362613230323933666565613561393431393035376465343739333739
|
||||
36313934313636386465306435353132373364653562666162613033373130623430656632396635
|
||||
39373437353838313734636166323336376534373765623332356638666234376464383033326433
|
||||
33636336376231313062643237636534363838326264333930383635373761346532393664363038
|
||||
34633334653464313430363735666435373535363465343134333636303536303265333931343138
|
||||
35633864623930386661316264383865373930316233653238323437363836643236333236336537
|
||||
37353565313434383733333861626566623363316335666230373435633163356566616366663339
|
||||
64323533366265396164303937323036323037383637643332326361363864333334653232376134
|
||||
33346366343865336437383138396639393238353633343562356435306537633830303361333730
|
||||
30386133396565613539653931663961303534613566626265376135386461383162396334393733
|
||||
39343466336136643565656332336562643933383330343830633264396436383065373032646664
|
||||
34643939613962653137303238663535633565363961336263316631313737663036336331663133
|
||||
61323538376434396432633565613135376163636233373832366461353665633266373435396436
|
||||
30376539366264306661353863313165323839646536393466623838393862396530326466363936
|
||||
36373865316165393665353737643561663863353630373333313936653163386136623831396637
|
||||
36306236626337303561376366376639613337396136313336383131303634623364316234376432
|
||||
65383362346363336639366665333436346234383566643937643130363261656662653763313639
|
||||
66396162356234343163633633376639623736643066643030626232633634616261303530623032
|
||||
38393032643963386133393534616133396135303531333839643063613331643334323762653933
|
||||
39646234366564333935366335363964666337383264333263326561636231303164356532323163
|
||||
63323430363337353339353739363638366136326231666335343830363838663366613432303735
|
||||
34323431343336643566346365333062363862646138396535633036653737643462323235326265
|
||||
39306336396238653063353939613966323466306335346635353964613535313961353263303235
|
||||
35646330366534386330333135316437313435376331343630643330323030626432343034323861
|
||||
39363437333137386137323036333336613238613530316338343930616137666261383733653432
|
||||
63316266323664396335363334663465636262663366346139383535626236653765323038343366
|
||||
64386639373536306638323036386364373465313037393431663965646633613838303566663139
|
||||
31663162313166636262313663363061666531636432366536343063336439636465663032356563
|
||||
30656562336565303237663332303230306637353465616136346233636464616666383734303938
|
||||
32666466366363346232653461333263366164313130336331326339366361326139636635646630
|
||||
376264626331393262653961663566383866
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[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]
|
||||
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
server {
|
||||
listen 80;
|
||||
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/ {
|
||||
alias /var/www/gamearray/static/;
|
||||
@@ -8,9 +17,12 @@ server {
|
||||
|
||||
location / {
|
||||
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 X-Real-IP $remote_addr;
|
||||
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
|
||||
@@ -1,33 +1,48 @@
|
||||
asgiref==3.11.0
|
||||
attrs==25.4.0
|
||||
certifi==2025.11.12
|
||||
celery
|
||||
cffi==2.0.0
|
||||
channels
|
||||
channels-redis
|
||||
charset-normalizer==3.4.4
|
||||
coverage
|
||||
cryptography
|
||||
cssselect==1.3.0
|
||||
daphne
|
||||
dj-database-url
|
||||
Django==6.0
|
||||
django-compressor
|
||||
django-htmx
|
||||
django-libsass
|
||||
django-stubs==5.2.8
|
||||
django-stubs-ext==5.2.8
|
||||
djangorestframework
|
||||
gunicorn==23.0.0
|
||||
h11==0.16.0
|
||||
idna==3.11
|
||||
lxml==6.0.2
|
||||
outcome==1.3.0.post0
|
||||
packaging==25.0
|
||||
psycopg2-binary
|
||||
pycparser==2.23
|
||||
PySocks==1.7.1
|
||||
python-dotenv
|
||||
redis
|
||||
requests==2.32.5
|
||||
scipy
|
||||
selenium==4.39.0
|
||||
sniffio==1.3.1
|
||||
sortedcontainers==2.4.0
|
||||
sqlparse==0.5.5
|
||||
stripe
|
||||
trio==0.32.0
|
||||
trio-websocket==0.12.2
|
||||
types-PyYAML==6.0.12.20250915
|
||||
typing_extensions==4.15.0
|
||||
tzdata==2025.3
|
||||
urllib3==2.6.2
|
||||
uvicorn[standard]
|
||||
websocket-client==1.9.0
|
||||
whitenoise==6.11.0
|
||||
wsproto==1.3.2
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
celery
|
||||
cryptography
|
||||
channels
|
||||
channels-redis
|
||||
cssselect==1.3.0
|
||||
daphne
|
||||
Django==6.0
|
||||
dj-database-url
|
||||
django-compressor
|
||||
django-htmx
|
||||
django-libsass
|
||||
django-stubs==5.2.8
|
||||
django-stubs-ext==5.2.8
|
||||
djangorestframework
|
||||
gunicorn==23.0.0
|
||||
lxml==6.0.2
|
||||
psycopg2-binary
|
||||
requests==2.31.0
|
||||
redis
|
||||
requests==2.32.5
|
||||
scipy
|
||||
stripe
|
||||
whitenoise==6.11.0
|
||||
uvicorn[standard]
|
||||
|
||||
@@ -3,6 +3,8 @@ source = apps
|
||||
omit =
|
||||
*/migrations/*
|
||||
*/tests/*
|
||||
*/routing.py
|
||||
*/reset_staging_db.py
|
||||
|
||||
[report]
|
||||
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),
|
||||
]
|
||||
42
src/apps/applets/migrations/0008_seed_my_sea_applet.py
Normal file
42
src/apps/applets/migrations/0008_seed_my_sea_applet.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Seed the My Sea applet — Sprint 3 of the My Sea roadmap.
|
||||
|
||||
The applet itself is just a shell for now (header + horizontal scroll
|
||||
container w. empty-state placeholder). Sprints 4+ will fill in the
|
||||
sig-select / sea-select / gatekeeper phases that render in the dedicated
|
||||
`my_sea.html` page reachable via the applet's header link.
|
||||
|
||||
Grid: 12 cols × 4 rows — wide horizontal banner so the latest draw's
|
||||
10-card Celtic Cross spread can render left-to-right in the applet
|
||||
aperture, scrollable like My Palette.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.update_or_create(
|
||||
slug="my-sea",
|
||||
defaults={
|
||||
"name": "My Sea",
|
||||
"context": "gameboard",
|
||||
"default_visible": True,
|
||||
"grid_cols": 12,
|
||||
"grid_rows": 4,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="my-sea").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0007_rename_my_buddies_to_my_buds"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
41
src/apps/applets/migrations/0009_seed_my_sig_applet.py
Normal file
41
src/apps/applets/migrations/0009_seed_my_sig_applet.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""Seed the My Sign (a.k.a. My Significator) applet on billboard.
|
||||
|
||||
Sprint 4 of the My Sea roadmap. "Significator" remains the storage-layer
|
||||
term (User.significator FK, room sig-select); the applet itself is "My
|
||||
Sign" (the "My X" convention for applet names) while the standalone page
|
||||
wordmark reads "Game Sign" (the "Game/Dash/Bill X" convention for pages).
|
||||
4×6 (narrow + tall), seeded after all other billboard applets so it
|
||||
renders at the end of the billboard grid. Shows the user's saved
|
||||
significator card or a blank state.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.update_or_create(
|
||||
slug="my-sign",
|
||||
defaults={
|
||||
"name": "My Sign",
|
||||
"context": "billboard",
|
||||
"default_visible": True,
|
||||
"grid_cols": 4,
|
||||
"grid_rows": 6,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="my-sign").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0008_seed_my_sea_applet"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
31
src/apps/applets/migrations/0010_rename_my_sign_applet.py
Normal file
31
src/apps/applets/migrations/0010_rename_my_sign_applet.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Update the My Sign applet's display name — "Game Sign" → "My Sign".
|
||||
|
||||
User clarified naming convention 2026-05-18: **applets** use the "My X"
|
||||
prefix (My Sign, My Sea, My Posts, etc.) while **standalone pages** use
|
||||
the "Game/Dash/Bill X" prefix (Game Sign page, Game Sea page, Game Kit
|
||||
page). Sprint 4a's initial migration (0009) set the applet name to
|
||||
"Game Sign", which the user corrected after seeing the gear-menu toggle
|
||||
list show the wrong word. Slug stays `my-sign` (URL + selectors stable).
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def rename(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="my-sign").update(name="My Sign")
|
||||
|
||||
|
||||
def unrename(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="my-sign").update(name="Game Sign")
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0009_seed_my_sig_applet"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(rename, unrename),
|
||||
]
|
||||
43
src/apps/applets/migrations/0011_seed_wallet_shop_applet.py
Normal file
43
src/apps/applets/migrations/0011_seed_wallet_shop_applet.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Seed the wallet Shop applet — Chunk 2 of the wallet expansion sprint.
|
||||
|
||||
Locked spec from [[project-wallet-shop-expansion]]: 4 rows total in the
|
||||
wallet context (Shop atop, Balances + Tokens + Payment beneath); 12 cols
|
||||
in landscape (full-width row). Mimics the existing wallet applets'
|
||||
grid_cols=12 / grid_rows=3 shape.
|
||||
|
||||
`display_order` is NOT a field on Applet — applet ordering is dictated
|
||||
by the wallet template's include order in `_applets.html` + the applet
|
||||
slug alphabetical fallback in `applet_context()`. The template's include
|
||||
order is set in Chunk 4; this migration just ensures the row exists.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def seed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.update_or_create(
|
||||
slug="wallet-shop",
|
||||
defaults={
|
||||
"name": "Shop",
|
||||
"context": "wallet",
|
||||
"default_visible": True,
|
||||
"grid_cols": 12,
|
||||
"grid_rows": 3,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def unseed(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="wallet-shop").delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0010_rename_my_sign_applet"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(seed, unseed),
|
||||
]
|
||||
18
src/apps/applets/migrations/0012_applet_display_order.py
Normal file
18
src/apps/applets/migrations/0012_applet_display_order.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 6.0 on 2026-05-22 05:27
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('applets', '0011_seed_wallet_shop_applet'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='applet',
|
||||
name='display_order',
|
||||
field=models.PositiveSmallIntegerField(default=100),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Pin the wallet Shop applet atop the wallet row.
|
||||
|
||||
The 4-applet wallet layout (per [[project-wallet-shop-expansion]]) wants
|
||||
Shop first; the other 3 (Balances, Tokens, Payment) keep their historical
|
||||
order via the default `display_order=100` + PK tie-break.
|
||||
|
||||
Idempotent — `update_or_create(slug=…, defaults={display_order: 10})`
|
||||
also covers fresh DBs where `0011_seed_wallet_shop_applet` already ran.
|
||||
"""
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def forward(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="wallet-shop").update(display_order=10)
|
||||
|
||||
|
||||
def reverse(apps, schema_editor):
|
||||
Applet = apps.get_model("applets", "Applet")
|
||||
Applet.objects.filter(slug="wallet-shop").update(display_order=100)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("applets", "0012_applet_display_order"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(forward, reverse),
|
||||
]
|
||||
0
src/apps/applets/migrations/__init__.py
Normal file
0
src/apps/applets/migrations/__init__.py
Normal file
43
src/apps/applets/models.py
Normal file
43
src/apps/applets/models.py
Normal file
@@ -0,0 +1,43 @@
|
||||
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)
|
||||
# Render-time sort key. Lower = earlier in the applets row. Default 100
|
||||
# gives every existing applet a tied position → falls back to PK insertion
|
||||
# order (the historical behavior), so this field is backwards-compatible.
|
||||
# Set to <100 to pin an applet ABOVE the rest (eg. wallet-shop = 10).
|
||||
display_order = models.PositiveSmallIntegerField(default=100)
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
60
src/apps/applets/static/apps/applets/row-lock.js
Normal file
60
src/apps/applets/static/apps/applets/row-lock.js
Normal file
@@ -0,0 +1,60 @@
|
||||
// Click-lock state for `.applet-list-entry.row-3col` rows on the dashboard
|
||||
// applet grids (My Posts / My Buds / My Notes / My Scrolls / My Games).
|
||||
//
|
||||
// Hover styling is pure CSS (`.applet-list-entry.row-3col:hover`); this
|
||||
// module just persists the same highlight on tap/click so touch devices can
|
||||
// pin a row to read it, and mirrors the toggle-off / shift-to-other-row /
|
||||
// click-outside-dismiss behaviour the note-page click-lock established.
|
||||
//
|
||||
// State machine (clicking …):
|
||||
// • a row that's not locked → lock it (clearing any prior lock)
|
||||
// • the currently-locked row again → unlock it
|
||||
// • a different row → move the lock to that row
|
||||
// • anywhere not inside a row → clear the lock
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var _lockedRow = null;
|
||||
var _bound = false;
|
||||
|
||||
function _clearLock() {
|
||||
if (_lockedRow) {
|
||||
_lockedRow.classList.remove('row-locked');
|
||||
_lockedRow = null;
|
||||
}
|
||||
}
|
||||
|
||||
function _onClick(e) {
|
||||
var row = e.target.closest('.applet-list-entry.row-3col');
|
||||
if (row) {
|
||||
if (row === _lockedRow) {
|
||||
_clearLock();
|
||||
} else {
|
||||
_clearLock();
|
||||
row.classList.add('row-locked');
|
||||
_lockedRow = row;
|
||||
}
|
||||
return;
|
||||
}
|
||||
_clearLock();
|
||||
}
|
||||
|
||||
function _init() {
|
||||
if (_bound) return;
|
||||
_bound = true;
|
||||
document.addEventListener('click', _onClick);
|
||||
}
|
||||
|
||||
function _testReset() {
|
||||
_clearLock();
|
||||
}
|
||||
|
||||
window.RowLock = {
|
||||
_init: _init,
|
||||
_testReset: _testReset,
|
||||
get _lockedRow() { return _lockedRow; },
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', _init);
|
||||
}());
|
||||
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"])
|
||||
24
src/apps/applets/utils.py
Normal file
24
src/apps/applets/utils.py
Normal file
@@ -0,0 +1,24 @@
|
||||
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()}
|
||||
# `display_order` (lower = earlier) is the primary sort key; `pk` tie-breaks
|
||||
# so applets at the default order=100 keep their historical insertion-order
|
||||
# rendering. New applets that want pinned positions set order < 100 in
|
||||
# their seed migration (eg. wallet-shop = 10 to render atop the wallet row).
|
||||
applets_qs = Applet.objects.filter(context=context).order_by("display_order", "pk")
|
||||
return [
|
||||
{"applet": a, "visible": ua_map.get(a.pk, a.default_visible)}
|
||||
for a in applets_qs
|
||||
]
|
||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user