Compare commits
128 Commits
pre-drf
...
11c85d56d1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
5
.gitignore
vendored
5
.gitignore
vendored
@@ -10,9 +10,8 @@
|
|||||||
*.pyc
|
*.pyc
|
||||||
__pycache__/
|
__pycache__/
|
||||||
local_settings.py
|
local_settings.py
|
||||||
db.sqlite3
|
*.sqlite3
|
||||||
db.sqlite3-journal
|
*.sqlite3-journal
|
||||||
container.db.sqlite3
|
|
||||||
media
|
media
|
||||||
|
|
||||||
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
|
||||||
|
|||||||
@@ -6,31 +6,49 @@ services:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
||||||
|
- name: redis
|
||||||
|
image: redis:7
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: test-UTs-n-ITs
|
- name: test-UTs-n-ITs
|
||||||
image: python:3.13-slim
|
image: python:3.13-slim
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
|
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
REDIS_URL: redis://redis:6379/1
|
||||||
commands:
|
commands:
|
||||||
- pip install -r requirements.txt
|
- pip install -r requirements.txt
|
||||||
- cd ./src
|
- cd ./src
|
||||||
- python manage.py test apps
|
- python manage.py test apps
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
|
||||||
- name: test-FTs
|
- name: test-FTs
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
environment:
|
environment:
|
||||||
HEADLESS: 1
|
HEADLESS: 1
|
||||||
|
CELERY_BROKER_URL: redis://redis:6379/0
|
||||||
|
REDIS_URL: redis://redis:6379/1
|
||||||
|
STRIPE_SECRET_KEY:
|
||||||
|
from_secret: stripe_secret_key
|
||||||
|
STRIPE_PUBLISHABLE_KEY:
|
||||||
|
from_secret: stripe_publishable_key
|
||||||
commands:
|
commands:
|
||||||
|
- pip install -r requirements.txt
|
||||||
- cd ./src
|
- cd ./src
|
||||||
- python manage.py collectstatic --noinput
|
- python manage.py collectstatic --noinput
|
||||||
- python manage.py test functional_tests
|
- python manage.py test functional_tests --parallel --exclude-tag=channels
|
||||||
|
- python manage.py test functional_tests --tag=channels
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
|
||||||
- name: screendumps
|
- name: screendumps
|
||||||
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
|
||||||
when:
|
|
||||||
- status: failure
|
|
||||||
commands:
|
commands:
|
||||||
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
|
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
|
||||||
|
when:
|
||||||
|
- event: push
|
||||||
|
status: failure
|
||||||
|
|
||||||
- name: build-and-push
|
- name: build-and-push
|
||||||
image: docker:cli
|
image: docker:cli
|
||||||
@@ -43,7 +61,7 @@ steps:
|
|||||||
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
|
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
- event: push
|
event: push
|
||||||
|
|
||||||
- name: deploy
|
- name: deploy
|
||||||
image: alpine
|
image: alpine
|
||||||
@@ -58,5 +76,5 @@ steps:
|
|||||||
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
|
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
|
||||||
when:
|
when:
|
||||||
- branch: main
|
- branch: main
|
||||||
- event: push
|
event: push
|
||||||
|
|
||||||
|
|||||||
140
CLAUDE.md
Normal file
140
CLAUDE.md
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
# 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 that lets Claude observe and interact with the browser directly.
|
||||||
|
|
||||||
|
### Tool names
|
||||||
|
Tools are available as `mcp__claudezilla__firefox_*`, e.g.:
|
||||||
|
- `mcp__claudezilla__firefox_screenshot` — capture current tab
|
||||||
|
- `mcp__claudezilla__firefox_navigate` — navigate to URL
|
||||||
|
- `mcp__claudezilla__firefox_get_page_state` — structured JSON (faster than screenshot)
|
||||||
|
- `mcp__claudezilla__firefox_create_window` — open new tab (returns `tabId`)
|
||||||
|
- `mcp__claudezilla__firefox_diagnose` — check connection status
|
||||||
|
- `mcp__claudezilla__firefox_set_private_mode` — disable private mode to use session cookies
|
||||||
|
|
||||||
|
All tools require a `tabId` except `firefox_create_window` and `firefox_diagnose`.
|
||||||
|
|
||||||
|
### If tools aren't available in a session
|
||||||
|
MCP servers load at session startup only. **Start a new Claude Code conversation** (hit "+" in the sidebar) — no need to reboot VSCode, just open a fresh chat. Always call `firefox_diagnose` first to confirm the connection is live.
|
||||||
|
|
||||||
|
### Correct startup sequence
|
||||||
|
1. Firefox open with Claudezilla extension active (native host must be running)
|
||||||
|
2. Open a new Claude Code conversation → tools appear as `mcp__claudezilla__firefox_*`
|
||||||
|
3. Call `firefox_diagnose` to confirm before depending on any tool
|
||||||
|
|
||||||
|
### Setup (already done — for reference)
|
||||||
|
The native messaging host requires a `.bat` wrapper on Windows (Firefox can't execute `.js` directly):
|
||||||
|
- Wrapper: `E:\ClaudeLibrary\claudezilla\host\claudezilla.bat` — contains `@echo off` / `node "%~dp0index.js" %*`
|
||||||
|
- Manifest: `C:\Users\adamc\AppData\Roaming\claudezilla\claudezilla.json` — points to the `.bat` file
|
||||||
|
- Registry: `HKCU\SOFTWARE\Mozilla\NativeMessagingHosts\claudezilla` → manifest path
|
||||||
|
- MCP server: registered in `~/.claude.json` (NOT `~/.claude/settings.json` or `~/.claude/mcp.json`) — use the CLI to register:
|
||||||
|
```
|
||||||
|
claude mcp add --scope user claudezilla "D:/Program Files/nodejs/node.exe" "E:/ClaudeLibrary/claudezilla/mcp/server.js"
|
||||||
|
```
|
||||||
|
- Permission: `mcp__claudezilla__*` in `~/.claude/settings.json` `permissions.allow`
|
||||||
|
|
||||||
|
**Config file gotcha:** The Claude Code CLI and VSCode extension read user-level MCP servers from `~/.claude.json` (home dir, single file) — NOT from `~/.claude/settings.json` or `~/.claude/mcp.json`. Always use `claude mcp add --scope user` to register; never hand-edit. Verify registration with `claude mcp list`.
|
||||||
|
|
||||||
|
**BOM gotcha:** PowerShell writes JSON files with a UTF-8 BOM, which causes `JSON.parse` to throw. Never use PowerShell `Set-Content` to write any Claude config JSON — use the Write tool or the CLI instead.
|
||||||
|
|
||||||
|
Native host: `E:\ClaudeLibrary\claudezilla\host\`. Extension: `claudezilla@boot.industries`.
|
||||||
|
|
||||||
|
## 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/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 only (from project root — targets src/apps, skips src/functional_tests)
|
||||||
|
python src/manage.py test src/apps
|
||||||
|
|
||||||
|
# Functional tests only
|
||||||
|
python src/manage.py test src/functional_tests
|
||||||
|
|
||||||
|
# All tests (integration + unit + FT)
|
||||||
|
python src/manage.py test src
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test tags:** The only tag in use is `channels` — for async consumer tests that require a live Redis channel layer.
|
||||||
|
- Exclude channels tests: `python src/manage.py test src/apps --exclude-tag=channels`
|
||||||
|
- Channels tests only: `python src/manage.py test src/apps --tag=channels`
|
||||||
|
|
||||||
|
FTs are isolated by **directory path** (`src/functional_tests/`), not by tag.
|
||||||
|
|
||||||
|
Test runner is `core.runner.RobustCompressorTestRunner` — handles the Windows compressor PermissionError by deleting stale CACHE at startup + monkey-patching retry logic.
|
||||||
|
|
||||||
|
## CI/CD
|
||||||
|
- 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
|
||||||
|
|
||||||
|
## SCSS Import Order
|
||||||
|
`core.scss`: `rootvars → applets → base → button-pad → dashboard → gameboard → palette-picker → room → billboard → game-kit → wallet-tokens`
|
||||||
|
|
||||||
|
## Critical Gotchas
|
||||||
|
|
||||||
|
### TransactionTestCase flushes migration data
|
||||||
|
FTs use `TransactionTestCase` which flushes migration-seeded rows. Any IT/FT `setUp` that needs `Applet` rows must call `Applet.objects.get_or_create(slug=..., defaults={...})` — never rely on migration data surviving.
|
||||||
|
|
||||||
|
### Static files in tests
|
||||||
|
`StaticLiveServerTestCase` serves via `STATICFILES_FINDERS` only — NOT from `STATIC_ROOT`. JS/CSS that lives only in `src/static/` (STATIC_ROOT, gitignored) is a silent 404 in CI. All app JS must live in `src/apps/<app>/static/` or `src/static_src/`.
|
||||||
|
|
||||||
|
### msgpack integer key bug (Django Channels)
|
||||||
|
`channels_redis` uses msgpack with `strict_map_key=True` — integer dict keys in `group_send` payloads raise `ValueError` and crash consumers. Always use `str(slot_number)` as dict keys.
|
||||||
|
|
||||||
|
### Multi-browser FTs in CI
|
||||||
|
Any FT opening a second browser must pass `FirefoxOptions` with `--headless` when `HEADLESS` env var is set. Bare `webdriver.Firefox()` crashes in headless CI with `Process unexpectedly closed with status 1`.
|
||||||
|
|
||||||
|
### Selenium + CSS text-transform
|
||||||
|
Selenium `.text` returns visually rendered text. CSS `text-transform: uppercase` causes `assertIn("Test Room", body.text)` to fail. Assert against the uppercased string or use `body.text.upper()`.
|
||||||
|
|
||||||
|
### Tooltip portal pattern
|
||||||
|
`mask-image` on grid containers clips `position: absolute` tooltips. Use `#id_tooltip_portal` (`position: fixed; z-index: 9999`) at page root. See `gameboard.js` + `wallet.js`.
|
||||||
|
|
||||||
|
### 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`
|
||||||
|
|
||||||
|
## Teaching Style
|
||||||
|
User prefers guided learning: describe what to type and why, let them write the code, then review together. But for now, given user's accelerated timetable, please respond with complete code snippets for user to transcribe directly.
|
||||||
@@ -15,7 +15,9 @@ RUN python manage.py collectstatic --noinput
|
|||||||
|
|
||||||
ENV DJANGO_DEBUG_FALSE=1
|
ENV DJANGO_DEBUG_FALSE=1
|
||||||
|
|
||||||
|
RUN DJANGO_SECRET_KEY=build-dummy DJANGO_ALLOWED_HOST=localhost python manage.py compress
|
||||||
|
|
||||||
RUN adduser --uid 1234 nonroot
|
RUN adduser --uid 1234 nonroot
|
||||||
|
|
||||||
USER nonroot
|
USER nonroot
|
||||||
CMD ["gunicorn", "--bind", ":8888", "core.wsgi:application"]
|
CMD ["gunicorn", "--bind", ":8888", "-k", "uvicorn.workers.UvicornWorker", "core.asgi:application"]
|
||||||
@@ -114,22 +114,56 @@
|
|||||||
POSTGRES_USER: gamearray
|
POSTGRES_USER: gamearray
|
||||||
POSTGRES_PASSWORD: "{{ postgres_password }}"
|
POSTGRES_PASSWORD: "{{ postgres_password }}"
|
||||||
|
|
||||||
|
- name: Start Redis container
|
||||||
|
community.docker.docker_container:
|
||||||
|
name: gamearray_redis
|
||||||
|
image: redis:7
|
||||||
|
state: started
|
||||||
|
restart_policy: unless-stopped
|
||||||
|
networks:
|
||||||
|
- name: gamearray_net
|
||||||
|
|
||||||
- name: Run container
|
- name: Run container
|
||||||
community.docker.docker_container:
|
community.docker.docker_container:
|
||||||
name: gamearray
|
name: gamearray
|
||||||
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
state: started
|
state: started
|
||||||
recreate: true
|
recreate: true
|
||||||
|
restart_policy: unless-stopped
|
||||||
|
env:
|
||||||
|
DJANGO_DEBUG_FALSE: "1"
|
||||||
|
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||||
|
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
||||||
|
DJANGO_SUPERUSER_EMAIL: "{{ django_superuser_email }}"
|
||||||
|
DJANGO_SUPERUSER_PASSWORD: "{{ django_superuser_password }}"
|
||||||
|
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
|
||||||
|
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||||
|
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
|
||||||
|
REDIS_URL: "redis://gamearray_redis:6379/1"
|
||||||
|
networks:
|
||||||
|
- name: gamearray_net
|
||||||
|
ports:
|
||||||
|
127.0.0.1:8888:8888
|
||||||
|
|
||||||
|
- name: Start Celery worker container
|
||||||
|
community.docker.docker_container:
|
||||||
|
name: gamearray_celery
|
||||||
|
image: gitea.earthmanrpg.me/discoman/gamearray:latest
|
||||||
|
state: started
|
||||||
|
recreate: true
|
||||||
|
restart_policy: unless-stopped
|
||||||
env:
|
env:
|
||||||
DJANGO_DEBUG_FALSE: "1"
|
DJANGO_DEBUG_FALSE: "1"
|
||||||
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
|
||||||
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
|
||||||
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
|
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
|
||||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||||
|
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
|
||||||
|
REDIS_URL: "redis://gamearray_redis:6379/1"
|
||||||
networks:
|
networks:
|
||||||
- name: gamearray_net
|
- name: gamearray_net
|
||||||
ports:
|
command: "python -m celery -A core worker -l info"
|
||||||
127.0.0.1:8888:8888
|
|
||||||
|
|
||||||
- name: Create static files directory
|
- name: Create static files directory
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
@@ -149,6 +183,11 @@
|
|||||||
container: gamearray
|
container: gamearray
|
||||||
command: python manage.py migrate
|
command: python manage.py migrate
|
||||||
|
|
||||||
|
- name: Ensure superuser exists
|
||||||
|
community.docker.docker_container_exec:
|
||||||
|
container: gamearray
|
||||||
|
command: python manage.py ensure_superuser
|
||||||
|
|
||||||
handlers:
|
handlers:
|
||||||
- name: Restart nginx
|
- name: Restart nginx
|
||||||
ansible.builtin.service:
|
ansible.builtin.service:
|
||||||
|
|||||||
@@ -12,14 +12,29 @@ docker rm gamearray 2>/dev/null || true
|
|||||||
|
|
||||||
echo "==> Starting new container..."
|
echo "==> Starting new container..."
|
||||||
docker run -d --name gamearray \
|
docker run -d --name gamearray \
|
||||||
|
--restart unless-stopped \
|
||||||
--env-file /opt/gamearray/gamearray.env \
|
--env-file /opt/gamearray/gamearray.env \
|
||||||
--network gamearray_net \
|
--network gamearray_net \
|
||||||
-p 127.0.0.1:8888:8888 \
|
-p 127.0.0.1:8888:8888 \
|
||||||
"$IMAGE"
|
"$IMAGE"
|
||||||
|
|
||||||
|
echo "==> Stopping old celery worker..."
|
||||||
|
docker stop gamearray_celery 2>/dev/null || true
|
||||||
|
docker rm gamearray_celery 2>/dev/null || true
|
||||||
|
|
||||||
|
echo "==> Starting new celery worker..."
|
||||||
|
docker run -d --name gamearray_celery \
|
||||||
|
--restart unless-stopped \
|
||||||
|
--env-file /opt/gamearray/gamearray.env \
|
||||||
|
--network gamearray_net \
|
||||||
|
"$IMAGE" python -m celery -A core worker -l info
|
||||||
|
|
||||||
echo "==> Running migrations..."
|
echo "==> Running migrations..."
|
||||||
docker exec gamearray python ./manage.py migrate
|
docker exec gamearray python ./manage.py migrate
|
||||||
|
|
||||||
|
echo "==> Ensuring superuser exists..."
|
||||||
|
docker exec gamearray python manage.py ensure_superuser
|
||||||
|
|
||||||
echo "==> Copying static files..."
|
echo "==> Copying static files..."
|
||||||
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
DJANGO_DEBUG_FALSE=1
|
DJANGO_DEBUG_FALSE=1
|
||||||
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
|
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
|
||||||
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
|
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
|
||||||
|
DJANGO_SUPERUSER_EMAIL={{ django_superuser_email }}
|
||||||
|
DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }}
|
||||||
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
|
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
|
||||||
MAILGUN_API_KEY={{ mailgun_api_key }}
|
MAILGUN_API_KEY={{ mailgun_api_key }}
|
||||||
|
STRIPE_PUBLISHABLE_KEY={{ stripe_publishable_key }}
|
||||||
|
STRIPE_SECRET_KEY={{ stripe_secret_key }}
|
||||||
|
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
|
||||||
|
REDIS_URL=redis://gamearray_redis:6379/1
|
||||||
|
|
||||||
|
|||||||
@@ -1,23 +1,42 @@
|
|||||||
$ANSIBLE_VAULT;1.1;AES256
|
$ANSIBLE_VAULT;1.1;AES256
|
||||||
33616230376431343735626631623932393166343538653732383533323436326335343463646664
|
38383061343764656262613934313230656462366163363263653462333338333863326338343838
|
||||||
6565373531623465613661613533376231373837326438300a393665613839646231633737313938
|
3664646437643462346636623231633639396239333532340a363338313839353734326238643735
|
||||||
64633035336663313163333634623732323537326363646132313136376131636666636538323066
|
39343237396433336436366430626332343666666461613636656433363838613432393539386266
|
||||||
3037373930303537320a313062646166353862633836373466316261363939633433663039323866
|
3237336434346333350a663530623334633438616135376437666631313064333735653633396461
|
||||||
62333739303662343836306538393734343830366336323265393138343438363533353166383031
|
31306163343838336465626663373661343839653037333235313361633335646337353339616333
|
||||||
32313461313137643039376237346633316466646136353038633861333031663164656233366634
|
35343233346562346236636364316265313936646235373866636333353866623161663935626637
|
||||||
38303363383130376264373861393863623330623733643135643461383132613339376633353031
|
31633864366339653930626365373237326531366632626337636163333266656434323063333365
|
||||||
32313863323039646534633733383661333361313832333830383066633130396239626661643264
|
38373437383261613439306666373764633737623466626235356465636365646337306534326535
|
||||||
65636335303339613432326533343337366261356632313639623634386633383836333733663536
|
36633866663161613632613434666134343465383663633165663330376535653537333763376232
|
||||||
39383361353530646166643531333535356636326535383534326237666638326137616162646261
|
61653265303134656338393033303834663630653064666134633638393235346631346461633030
|
||||||
65316466323335653932636338653565383038313531383638393839313736643739363037353230
|
35343332393961363361613661633633613262663231366236396663636239326534373134623762
|
||||||
35653632353531656435396663316537333133653632366437613339303033333536643937353166
|
30653139333134616236666238616466633733656633326331386138363839653566333434346534
|
||||||
64363037653733303332643931343362303261643432366531326262383465313965633064356338
|
63326539333461383265316332336333656365386531393630663537363365643061363263313738
|
||||||
31336333373665373035656533633864316139303934623030383934393434356334643962666163
|
37633564363533633762393736636333306433306534393539636231656162343562383232663932
|
||||||
33343739366336613263333764306365333566363536616662383733616237396563346132336633
|
62646339363266303564383438636636373661656465666663613863396639633732636635326166
|
||||||
38663239613339376335386233386330396634323033343332366130616162666339393861306336
|
39323738303338373466366236623665633538363134616565326665386564613735393638656630
|
||||||
35383566383831356530633130313732356331616164646132626665646235396635386237313538
|
31326431316163376132623064376634643737313864336464623431333834663361336133353838
|
||||||
38656631336261646530303761643334303937613036363766303637376262373466316431323731
|
32303635663261333732306137383133623134373363613837306637663566303634653863343766
|
||||||
38666462313639353131303134646434646135366136343361353932326165626666306361393431
|
33613936626362653466333537666462373633313038376565623363666631353162643634653730
|
||||||
62646238323265346263386363373462313766616333326366366461346436383064336535376339
|
30323532623261643136666237316561353038323265303930336364633731333533386563623133
|
||||||
31356566356336386262393831616631666233633930393263623563386265343237323133313832
|
31343965643336613933663431626435333235366639363334653065303434386165333739336632
|
||||||
3430363635363332303963316530663765613666306233376463
|
61363030376664643638653365626365623936623864666663326534343863613962616431376666
|
||||||
|
39363837386639393235316339323932326466616330303165613032663637616232656162653335
|
||||||
|
61613266376262626234383135306238313366346330656333383465383861663962653638303362
|
||||||
|
34353833646461383839386238626661346263363131643438343461393739336132386466373665
|
||||||
|
32646238633161363064666335626639653335306236613866333934646366323564306133396131
|
||||||
|
36343032623964316138386538333863363530396330646431373466646538663063326330663639
|
||||||
|
32323762356632336364333162336133336335623865323861663131626232633066643238333237
|
||||||
|
32343938353166353037316162653832663433343534626331633936633866356666653932656665
|
||||||
|
38396533356131326262633431653435306362633966383531356236396639376437396333616130
|
||||||
|
35666435393461316232323234653865346338326330623065373461323961393663306262313066
|
||||||
|
30313430353065616230356135333565333338373663643434353561363438656233383739663233
|
||||||
|
35653832353062396634613832353837333835636461616234343462626239636634613430373931
|
||||||
|
31656534343764643065643733326637343631356633653531313062633362663461313732633331
|
||||||
|
35626364393563373339636466346339383032383635303865306636623737343237333863353238
|
||||||
|
63306132396262656365323833323635633563653735366630313363386236613231346339643430
|
||||||
|
63396230353566633830383932666335373665356434656438336338633035653465613665613862
|
||||||
|
31663565653338376662323866613538363566306635333735646363363730646331306234353839
|
||||||
|
30346363393231623563646439623261643634663831313338393761343865303930373133633733
|
||||||
|
31656466303365316164396463373335396464643130643337656361333339653238333633373662
|
||||||
|
6539
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
[staging]
|
[staging]
|
||||||
staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd letsencrypt_domain=staging.earthmanrpg.me
|
||||||
|
|
||||||
[production]
|
[production]
|
||||||
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
server_name {{ django_allowed_host | replace(',', ' ')}};
|
server_name {{ django_allowed_host | replace(',', ' ')}};
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name {{ django_allowed_host | replace(',', ' ') }};
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/{{ letsencrypt_domain }}/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/{{ letsencrypt_domain }}/privkey.pem;
|
||||||
|
|
||||||
location /static/ {
|
location /static/ {
|
||||||
alias /var/www/gamearray/static/;
|
alias /var/www/gamearray/static/;
|
||||||
@@ -8,9 +17,12 @@ server {
|
|||||||
|
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:8888;
|
proxy_pass http://127.0.0.1:8888;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,13 +2,20 @@ asgiref==3.11.0
|
|||||||
attrs==25.4.0
|
attrs==25.4.0
|
||||||
certifi==2025.11.12
|
certifi==2025.11.12
|
||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
|
channels
|
||||||
|
channels-redis
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
coverage
|
coverage
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
dj-database-url
|
dj-database-url
|
||||||
Django==6.0
|
Django==6.0
|
||||||
|
django-compressor
|
||||||
|
django-htmx
|
||||||
|
django-libsass
|
||||||
django-stubs==5.2.8
|
django-stubs==5.2.8
|
||||||
django-stubs-ext==5.2.8
|
django-stubs-ext==5.2.8
|
||||||
|
djangorestframework
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
idna==3.11
|
idna==3.11
|
||||||
@@ -17,17 +24,21 @@ outcome==1.3.0.post0
|
|||||||
packaging==25.0
|
packaging==25.0
|
||||||
pycparser==2.23
|
pycparser==2.23
|
||||||
PySocks==1.7.1
|
PySocks==1.7.1
|
||||||
|
python-dotenv
|
||||||
requests==2.32.5
|
requests==2.32.5
|
||||||
|
scipy
|
||||||
selenium==4.39.0
|
selenium==4.39.0
|
||||||
sniffio==1.3.1
|
sniffio==1.3.1
|
||||||
sortedcontainers==2.4.0
|
sortedcontainers==2.4.0
|
||||||
sqlparse==0.5.5
|
sqlparse==0.5.5
|
||||||
|
stripe
|
||||||
trio==0.32.0
|
trio==0.32.0
|
||||||
trio-websocket==0.12.2
|
trio-websocket==0.12.2
|
||||||
types-PyYAML==6.0.12.20250915
|
types-PyYAML==6.0.12.20250915
|
||||||
typing_extensions==4.15.0
|
typing_extensions==4.15.0
|
||||||
tzdata==2025.3
|
tzdata==2025.3
|
||||||
urllib3==2.6.2
|
urllib3==2.6.2
|
||||||
|
uvicorn[standard]
|
||||||
websocket-client==1.9.0
|
websocket-client==1.9.0
|
||||||
whitenoise==6.11.0
|
whitenoise==6.11.0
|
||||||
wsproto==1.3.2
|
wsproto==1.3.2
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
|
celery
|
||||||
|
channels
|
||||||
|
channels-redis
|
||||||
cssselect==1.3.0
|
cssselect==1.3.0
|
||||||
|
daphne
|
||||||
Django==6.0
|
Django==6.0
|
||||||
dj-database-url
|
dj-database-url
|
||||||
|
django-compressor
|
||||||
|
django-htmx
|
||||||
|
django-libsass
|
||||||
django-stubs==5.2.8
|
django-stubs==5.2.8
|
||||||
django-stubs-ext==5.2.8
|
django-stubs-ext==5.2.8
|
||||||
|
djangorestframework
|
||||||
gunicorn==23.0.0
|
gunicorn==23.0.0
|
||||||
lxml==6.0.2
|
lxml==6.0.2
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
|
redis
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
scipy
|
||||||
|
stripe
|
||||||
whitenoise==6.11.0
|
whitenoise==6.11.0
|
||||||
|
uvicorn[standard]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ source = apps
|
|||||||
omit =
|
omit =
|
||||||
*/migrations/*
|
*/migrations/*
|
||||||
*/tests/*
|
*/tests/*
|
||||||
|
*/routing.py
|
||||||
|
|
||||||
[report]
|
[report]
|
||||||
show_missing = true
|
show_missing = true
|
||||||
0
src/apps/api/__init__.py
Normal file
0
src/apps/api/__init__.py
Normal file
32
src/apps/api/serializers.py
Normal file
32
src/apps/api/serializers.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
from apps.dashboard.models import Item, Note
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class ItemSerializer(serializers.ModelSerializer):
|
||||||
|
text = serializers.CharField()
|
||||||
|
|
||||||
|
def validate_text(self, value):
|
||||||
|
note = self.context["note"]
|
||||||
|
if note.item_set.filter(text=value).exists():
|
||||||
|
raise serializers.ValidationError("duplicate")
|
||||||
|
return value
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Item
|
||||||
|
fields = ["id", "text"]
|
||||||
|
|
||||||
|
class NoteSerializer(serializers.ModelSerializer):
|
||||||
|
name = serializers.ReadOnlyField()
|
||||||
|
url = serializers.CharField(source="get_absolute_url", read_only=True)
|
||||||
|
items = ItemSerializer(many=True, read_only=True, source="item_set")
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Note
|
||||||
|
fields = ["id", "name", "url", "items"]
|
||||||
|
|
||||||
|
class UserSerializer(serializers.ModelSerializer):
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = ["id", "username"]
|
||||||
0
src/apps/api/tests/__init__.py
Normal file
0
src/apps/api/tests/__init__.py
Normal file
0
src/apps/api/tests/integrated/__init__.py
Normal file
0
src/apps/api/tests/integrated/__init__.py
Normal file
115
src/apps/api/tests/integrated/test_views.py
Normal file
115
src/apps/api/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
|
||||||
|
from apps.dashboard.models import Item, Note
|
||||||
|
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 NoteDetailAPITest(BaseAPITest):
|
||||||
|
def test_returns_note_with_items(self):
|
||||||
|
note = Note.objects.create(owner=self.user)
|
||||||
|
Item.objects.create(text="item 1", note=note)
|
||||||
|
Item.objects.create(text="item 2", note=note)
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/notes/{note.id}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.data["id"], str(note.id))
|
||||||
|
self.assertEqual(len(response.data["items"]), 2)
|
||||||
|
|
||||||
|
class NoteItemsAPITest(BaseAPITest):
|
||||||
|
def test_can_add_item_to_note(self):
|
||||||
|
note = Note.objects.create(owner=self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/notes/{note.id}/items/",
|
||||||
|
{"text": "a new item"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(Item.objects.count(), 1)
|
||||||
|
self.assertEqual(Item.objects.first().text, "a new item")
|
||||||
|
|
||||||
|
def test_cannot_add_empty_item_to_note(self):
|
||||||
|
note = Note.objects.create(owner=self.user)
|
||||||
|
|
||||||
|
response = self.client.post(
|
||||||
|
f"/api/notes/{note.id}/items/",
|
||||||
|
{"text": ""},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 400)
|
||||||
|
self.assertEqual(Item.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_cannot_add_duplicate_item_to_note(self):
|
||||||
|
note = Note.objects.create(owner=self.user)
|
||||||
|
Item.objects.create(text="note item", note=note)
|
||||||
|
duplicate_response = self.client.post(
|
||||||
|
f"/api/notes/{note.id}/items/",
|
||||||
|
{"text": "note item"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(duplicate_response.status_code, 400)
|
||||||
|
self.assertEqual(Item.objects.count(), 1)
|
||||||
|
|
||||||
|
class NotesAPITest(BaseAPITest):
|
||||||
|
def test_get_returns_only_users_notes(self):
|
||||||
|
note1 = Note.objects.create(owner=self.user)
|
||||||
|
Item.objects.create(text="item 1", note=note1)
|
||||||
|
other_user = User.objects.create_user("other@example.com")
|
||||||
|
Note.objects.create(owner=other_user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/notes/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(len(response.data), 1)
|
||||||
|
self.assertEqual(response.data[0]["id"], str(note1.id))
|
||||||
|
|
||||||
|
def test_post_creates_note_with_item(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/api/notes/",
|
||||||
|
{"text": "first item"},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 201)
|
||||||
|
self.assertEqual(Note.objects.count(), 1)
|
||||||
|
self.assertEqual(Note.objects.first().owner, self.user)
|
||||||
|
self.assertEqual(Item.objects.first().text, "first item")
|
||||||
|
|
||||||
|
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 ItemSerializer, NoteSerializer
|
||||||
|
|
||||||
|
|
||||||
|
class ItemSerializerTest(SimpleTestCase):
|
||||||
|
def test_fields(self):
|
||||||
|
serializer = ItemSerializer()
|
||||||
|
self.assertEqual(
|
||||||
|
set(serializer.fields.keys()),
|
||||||
|
{"id", "text"},
|
||||||
|
)
|
||||||
|
|
||||||
|
class NoteSerializerTest(SimpleTestCase):
|
||||||
|
def test_fields(self):
|
||||||
|
serializer = NoteSerializer()
|
||||||
|
self.assertEqual(
|
||||||
|
set(serializer.fields.keys()),
|
||||||
|
{"id", "name", "url", "items"},
|
||||||
|
)
|
||||||
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('notes/', views.NotesAPI.as_view(), name='api_notes'),
|
||||||
|
path('notes/<uuid:note_id>/', views.NoteDetailAPI.as_view(), name='api_note_detail'),
|
||||||
|
path('notes/<uuid:note_id>/items/', views.NoteItemsAPI.as_view(), name='api_note_items'),
|
||||||
|
path('users/', views.UserSearchAPI.as_view(), name='api_users'),
|
||||||
|
]
|
||||||
45
src/apps/api/views.py
Normal file
45
src/apps/api/views.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from rest_framework.views import APIView
|
||||||
|
from rest_framework.response import Response
|
||||||
|
|
||||||
|
from apps.api.serializers import ItemSerializer, NoteSerializer, UserSerializer
|
||||||
|
from apps.dashboard.models import Item, Note
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class NoteDetailAPI(APIView):
|
||||||
|
def get(self, request, note_id):
|
||||||
|
note = get_object_or_404(Note, id=note_id)
|
||||||
|
serializer = NoteSerializer(note)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
class NoteItemsAPI(APIView):
|
||||||
|
def post(self, request, note_id):
|
||||||
|
note = get_object_or_404(Note, id=note_id)
|
||||||
|
serializer = ItemSerializer(data=request.data, context={"note": note})
|
||||||
|
if serializer.is_valid():
|
||||||
|
serializer.save(note=note)
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
return Response(serializer.errors, status=400)
|
||||||
|
|
||||||
|
class NotesAPI(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
notes = Note.objects.filter(owner=request.user)
|
||||||
|
serializer = NoteSerializer(notes, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
note = Note.objects.create(owner=request.user)
|
||||||
|
item = Item.objects.create(text=request.data.get("text", ""), note=note)
|
||||||
|
serializer = NoteSerializer(note)
|
||||||
|
return Response(serializer.data, status=201)
|
||||||
|
|
||||||
|
class UserSearchAPI(APIView):
|
||||||
|
def get(self, request):
|
||||||
|
q = request.query_params.get("q", "")
|
||||||
|
users = User.objects.filter(
|
||||||
|
username__icontains=q,
|
||||||
|
searchable=True,
|
||||||
|
)
|
||||||
|
serializer = UserSerializer(users, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
0
src/apps/applets/__init__.py
Normal file
0
src/apps/applets/__init__.py
Normal file
11
src/apps/applets/admin.py
Normal file
11
src/apps/applets/admin.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Applet)
|
||||||
|
class AppletAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ['slug', 'name', 'default_visible', 'grid_cols', 'grid_rows']
|
||||||
|
list_editable = ['grid_cols', 'grid_rows']
|
||||||
|
|
||||||
|
admin.site.register(UserApplet)
|
||||||
5
src/apps/applets/apps.py
Normal file
5
src/apps/applets/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class AppletsConfig(AppConfig):
|
||||||
|
name = 'apps.applets'
|
||||||
36
src/apps/applets/migrations/0001_initial.py
Normal file
36
src/apps/applets/migrations/0001_initial.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import django.db.models.deletion
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Applet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slug', models.SlugField(unique=True)),
|
||||||
|
('name', models.CharField(max_length=100)),
|
||||||
|
('context', models.CharField(choices=[('dashboard', 'Dashboard'), ('gameboard', 'Gameboard')], default='dashboard', max_length=20)),
|
||||||
|
('default_visible', models.BooleanField(default=True)),
|
||||||
|
('grid_cols', models.PositiveSmallIntegerField(default=12)),
|
||||||
|
('grid_rows', models.PositiveSmallIntegerField(default=3)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserApplet',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('visible', models.BooleanField(default=True)),
|
||||||
|
('applet', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='applets.applet')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_applets', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={'unique_together': {('user', 'applet')}},
|
||||||
|
),
|
||||||
|
]
|
||||||
29
src/apps/applets/migrations/0002_seed_applets.py
Normal file
29
src/apps/applets/migrations/0002_seed_applets.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def seed_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name, cols, rows, context in [
|
||||||
|
('wallet', 'Wallet', 12, 3, 'dashboard'),
|
||||||
|
('new-list', 'New List', 9, 3, 'dashboard'),
|
||||||
|
('my-lists', 'My Lists', 3, 3, 'dashboard'),
|
||||||
|
('username', 'Username', 6, 3, 'dashboard'),
|
||||||
|
('palette', 'Palette', 6, 3, 'dashboard'),
|
||||||
|
('new-game', 'New Game', 4, 2, 'gameboard'),
|
||||||
|
('my-games', 'My Games', 4, 4, 'gameboard'),
|
||||||
|
('game-kit', 'Game Kit', 4, 2, 'gameboard'),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={'name': name, 'grid_cols': cols, 'grid_rows': rows, 'context': context},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0001_initial')
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(seed_applets, migrations.RunPython.noop)
|
||||||
|
]
|
||||||
37
src/apps/applets/migrations/0003_wallet_applets.py
Normal file
37
src/apps/applets/migrations/0003_wallet_applets.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_wallet_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
('wallet-balances', 'Wallet Balances', 3, 3),
|
||||||
|
('wallet-tokens', 'Wallet Tokens', 3, 3),
|
||||||
|
('wallet-payment', 'Payment Methods', 6, 2),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={'name': name, 'grid_cols': cols, 'grid_rows': rows, 'context': 'wallet'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0002_seed_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='applet',
|
||||||
|
name='context',
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
('dashboard', 'Dashboard'),
|
||||||
|
('gameboard', 'Gameboard'),
|
||||||
|
('wallet', 'Wallet'),
|
||||||
|
],
|
||||||
|
default='dashboard',
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_wallet_applets, migrations.RunPython.noop),
|
||||||
|
]
|
||||||
24
src/apps/applets/migrations/0004_rename_list_applet_slugs.py
Normal file
24
src/apps/applets/migrations/0004_rename_list_applet_slugs.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def rename_list_slugs(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='new-list').update(slug='new-note', name='New Note')
|
||||||
|
Applet.objects.filter(slug='my-lists').update(slug='my-notes', name='My Notes')
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_rename_list_slugs(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug='new-note').update(slug='new-list', name='New List')
|
||||||
|
Applet.objects.filter(slug='my-notes').update(slug='my-lists', name='My Lists')
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0003_wallet_applets'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(rename_list_slugs, reverse_rename_list_slugs),
|
||||||
|
]
|
||||||
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal file
24
src/apps/applets/migrations/0005_gameboard_applet_heights.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def increase_gameboard_applet_heights(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=3)
|
||||||
|
|
||||||
|
|
||||||
|
def revert_gameboard_applet_heights(apps, schema_editor):
|
||||||
|
Applet = apps.get_model('applets', 'Applet')
|
||||||
|
Applet.objects.filter(slug__in=['new-game', 'game-kit', 'wallet-payment']).update(grid_rows=2)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
dependencies = [
|
||||||
|
('applets', '0004_rename_list_applet_slugs')
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(
|
||||||
|
increase_gameboard_applet_heights,
|
||||||
|
revert_gameboard_applet_heights,
|
||||||
|
)
|
||||||
|
]
|
||||||
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
48
src/apps/applets/migrations/0006_billboard_applets.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def seed_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||||
|
("billboard-my-contacts", "Contacts", 4, 3),
|
||||||
|
("billboard-most-recent", "Most Recent", 8, 6),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def remove_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.filter(slug__in=[
|
||||||
|
"billboard-my-scrolls",
|
||||||
|
"billboard-my-contacts",
|
||||||
|
"billboard-most-recent",
|
||||||
|
]).delete()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0005_gameboard_applet_heights"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="applet",
|
||||||
|
name="context",
|
||||||
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("dashboard", "Dashboard"),
|
||||||
|
("gameboard", "Gameboard"),
|
||||||
|
("wallet", "Wallet"),
|
||||||
|
("billboard", "Billboard"),
|
||||||
|
],
|
||||||
|
default="dashboard",
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(seed_billboard_applets, remove_billboard_applets),
|
||||||
|
]
|
||||||
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
29
src/apps/applets/migrations/0007_fix_billboard_applets.py
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
def fix_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
# billboard-scroll belongs only to the billscroll page template, not the grid
|
||||||
|
Applet.objects.filter(slug="billboard-scroll").delete()
|
||||||
|
# Rename "My Contacts" → "Contacts"
|
||||||
|
Applet.objects.filter(slug="billboard-my-contacts").update(name="Contacts")
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_fix_billboard_applets(apps, schema_editor):
|
||||||
|
Applet = apps.get_model("applets", "Applet")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="billboard-scroll",
|
||||||
|
defaults={"name": "Billscroll", "grid_cols": 12, "grid_rows": 6, "context": "billboard"},
|
||||||
|
)
|
||||||
|
Applet.objects.filter(slug="billboard-my-contacts").update(name="My Contacts")
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("applets", "0006_billboard_applets"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(fix_billboard_applets, reverse_fix_billboard_applets),
|
||||||
|
]
|
||||||
0
src/apps/applets/migrations/__init__.py
Normal file
0
src/apps/applets/migrations/__init__.py
Normal file
38
src/apps/applets/models.py
Normal file
38
src/apps/applets/models.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from django.db import models
|
||||||
|
|
||||||
|
class Applet(models.Model):
|
||||||
|
DASHBOARD = "dashboard"
|
||||||
|
GAMEBOARD = "gameboard"
|
||||||
|
WALLET = "wallet"
|
||||||
|
BILLBOARD = "billboard"
|
||||||
|
CONTEXT_CHOICES = [
|
||||||
|
(DASHBOARD, "Dashboard"),
|
||||||
|
(GAMEBOARD, "Gameboard"),
|
||||||
|
(WALLET, "Wallet"),
|
||||||
|
(BILLBOARD, "Billboard"),
|
||||||
|
]
|
||||||
|
|
||||||
|
slug = models.SlugField(unique=True)
|
||||||
|
name = models.CharField(max_length=100)
|
||||||
|
context = models.CharField(max_length=20, choices=CONTEXT_CHOICES, default=DASHBOARD)
|
||||||
|
default_visible = models.BooleanField(default=True)
|
||||||
|
grid_cols = models.PositiveSmallIntegerField(default=12)
|
||||||
|
grid_rows = models.PositiveSmallIntegerField(default=3)
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
class UserApplet(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="user_applets",
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
applet = models.ForeignKey(
|
||||||
|
Applet,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
visible = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("user", "applet")
|
||||||
41
src/apps/applets/static/apps/applets/applets.js
Normal file
41
src/apps/applets/static/apps/applets/applets.js
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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_wallet_applets_container',
|
||||||
|
]);
|
||||||
|
|
||||||
|
document.body.addEventListener('htmx:afterSwap', (e) => {
|
||||||
|
if (!e.detail.target || !appletContainerIds.has(e.detail.target.id)) return;
|
||||||
|
document.querySelectorAll('.gear-btn').forEach(gear => {
|
||||||
|
const menu = document.getElementById(gear.dataset.menuTarget);
|
||||||
|
if (menu) menu.style.display = 'none';
|
||||||
|
gear.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
0
src/apps/applets/tests/__init__.py
Normal file
0
src/apps/applets/tests/__init__.py
Normal file
0
src/apps/applets/tests/integrated/__init__.py
Normal file
0
src/apps/applets/tests/integrated/__init__.py
Normal file
65
src/apps/applets/tests/integrated/test_models.py
Normal file
65
src/apps/applets/tests/integrated/test_models.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from django.db.utils import IntegrityError
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
from apps.applets.utils import applet_context
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class AppletModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.applet = Applet.objects.create(
|
||||||
|
slug="my-applet", name="My Applet", default_visible=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_applet_can_be_created(self):
|
||||||
|
self.assertEqual(Applet.objects.get(slug="my-applet"), self.applet)
|
||||||
|
|
||||||
|
def test_applet_slug_is_unique(self):
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
Applet.objects.create(slug="my-applet", name="Second")
|
||||||
|
|
||||||
|
def test_applet_str(self):
|
||||||
|
self.assertEqual(str(self.applet), "My Applet")
|
||||||
|
|
||||||
|
def test_applet_grid_defaults(self):
|
||||||
|
self.assertEqual(self.applet.grid_cols, 12)
|
||||||
|
self.assertEqual(self.applet.grid_rows, 3)
|
||||||
|
|
||||||
|
|
||||||
|
class UserAppletModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
|
self.applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
|
||||||
|
def test_user_applet_links_user_to_applet(self):
|
||||||
|
ua = UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
|
||||||
|
self.assertIn(ua, self.user.user_applets.all())
|
||||||
|
|
||||||
|
def test_user_applet_unique_per_user_and_applet(self):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.applet, visible=True)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.applet, visible=False)
|
||||||
|
|
||||||
|
class AppletContextTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
|
self.dash_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username", "context": "dashboard"})
|
||||||
|
self.game_applet, _ = Applet.objects.get_or_create(slug="new-game", defaults={"name": "New Game", "context": "gameboard"})
|
||||||
|
|
||||||
|
def test_filters_by_context(self):
|
||||||
|
result = applet_context(self.user, "dashboard")
|
||||||
|
slugs = [e["applet"].slug for e in result]
|
||||||
|
self.assertIn("username", slugs)
|
||||||
|
self.assertNotIn("new-game", slugs)
|
||||||
|
|
||||||
|
def test_defaults_to_applet_default_visible(self):
|
||||||
|
result = applet_context(self.user, "dashboard")
|
||||||
|
[entry] = [e for e in result if e["applet"].slug == "username"]
|
||||||
|
self.assertTrue(entry["visible"])
|
||||||
|
|
||||||
|
def test_respects_user_applet_visible_false(self):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.dash_applet, visible=False)
|
||||||
|
result = applet_context(self.user, "dashboard")
|
||||||
|
[entry] = [e for e in result if e["applet"].slug == "username"]
|
||||||
|
self.assertFalse(entry["visible"])
|
||||||
11
src/apps/applets/utils.py
Normal file
11
src/apps/applets/utils.py
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
|
||||||
|
|
||||||
|
def applet_context(user, context):
|
||||||
|
ua_map = {ua.applet_id: ua.visible for ua in user.user_applets.all()}
|
||||||
|
applets = {a.slug: a for a in Applet.objects.filter(context=context)}
|
||||||
|
return [
|
||||||
|
{"applet": applets[slug], "visible": ua_map.get(applets[slug].pk, applets[slug].default_visible)}
|
||||||
|
for slug in applets
|
||||||
|
if slug in applets
|
||||||
|
]
|
||||||
0
src/apps/billboard/__init__.py
Normal file
0
src/apps/billboard/__init__.py
Normal file
6
src/apps/billboard/apps.py
Normal file
6
src/apps/billboard/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class BillboardConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.billboard'
|
||||||
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
0
src/apps/billboard/tests/integrated/__init__.py
Normal file
184
src/apps/billboard/tests/integrated/test_billboard_views.py
Normal file
184
src/apps/billboard/tests/integrated/test_billboard_views.py
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from apps.applets.models import Applet
|
||||||
|
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_billboard_applets():
|
||||||
|
for slug, name, cols, rows in [
|
||||||
|
("billboard-my-scrolls", "My Scrolls", 4, 3),
|
||||||
|
("billboard-my-contacts", "Contacts", 4, 3),
|
||||||
|
("billboard-most-recent", "Most Recent", 8, 6),
|
||||||
|
]:
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug=slug,
|
||||||
|
defaults={"name": name, "grid_cols": cols, "grid_rows": rows, "context": "billboard"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class BillboardViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@billboard.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
_seed_billboard_applets()
|
||||||
|
|
||||||
|
def test_uses_billboard_template(self):
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/billboard.html")
|
||||||
|
|
||||||
|
def test_passes_applets_context(self):
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertIn("applets", response.context)
|
||||||
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||||
|
self.assertIn("billboard-my-scrolls", slugs)
|
||||||
|
self.assertIn("billboard-my-contacts", slugs)
|
||||||
|
self.assertIn("billboard-most-recent", slugs)
|
||||||
|
|
||||||
|
def test_passes_my_rooms_context(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertIn(room, response.context["my_rooms"])
|
||||||
|
|
||||||
|
def test_passes_recent_room_and_events(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertEqual(response.context["recent_room"], room)
|
||||||
|
self.assertEqual(len(response.context["recent_events"]), 1)
|
||||||
|
|
||||||
|
def test_recent_events_capped_at_36(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
for i in range(40):
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertEqual(len(response.context["recent_events"]), 36)
|
||||||
|
|
||||||
|
def test_recent_events_in_chronological_order(self):
|
||||||
|
room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
for _ in range(3):
|
||||||
|
record(
|
||||||
|
room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
events = response.context["recent_events"]
|
||||||
|
timestamps = [e.timestamp for e in events]
|
||||||
|
self.assertEqual(timestamps, sorted(timestamps))
|
||||||
|
|
||||||
|
def test_recent_room_is_none_when_no_events(self):
|
||||||
|
response = self.client.get("/billboard/")
|
||||||
|
self.assertIsNone(response.context["recent_room"])
|
||||||
|
self.assertEqual(list(response.context["recent_events"]), [])
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleBillboardAppletsTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@toggle.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
_seed_billboard_applets()
|
||||||
|
|
||||||
|
def test_toggle_hides_unchecked_applets(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": ["billboard-my-scrolls"]},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
from apps.applets.models import UserApplet
|
||||||
|
contacts = Applet.objects.get(slug="billboard-my-contacts")
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=contacts)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_toggle_returns_partial_on_htmx(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("billboard:toggle_applets"),
|
||||||
|
{"applets": ["billboard-my-scrolls"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/_partials/_applets.html")
|
||||||
|
|
||||||
|
|
||||||
|
class BillscrollViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@billscroll.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
record(
|
||||||
|
self.room, GameEvent.SLOT_FILLED, actor=self.user,
|
||||||
|
slot_number=1, token_type="coin",
|
||||||
|
token_display="Coin-on-a-String", renewal_days=7,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_uses_room_scroll_template(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertTemplateUsed(response, "apps/billboard/room_scroll.html")
|
||||||
|
|
||||||
|
def test_passes_events_context(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertIn("events", response.context)
|
||||||
|
self.assertEqual(response.context["events"].count(), 1)
|
||||||
|
|
||||||
|
def test_passes_page_class_billscroll(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["page_class"], "page-billscroll")
|
||||||
|
|
||||||
|
def test_passes_scroll_position_zero_when_none_saved(self):
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["scroll_position"], 0)
|
||||||
|
|
||||||
|
def test_passes_saved_scroll_position_in_context(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=250)
|
||||||
|
response = self.client.get(f"/billboard/room/{self.room.id}/scroll/")
|
||||||
|
self.assertEqual(response.context["scroll_position"], 250)
|
||||||
|
|
||||||
|
|
||||||
|
class SaveScrollPositionTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="test@savescroll.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def test_post_saves_scroll_position(self):
|
||||||
|
self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 300},
|
||||||
|
)
|
||||||
|
sp = ScrollPosition.objects.get(user=self.user, room=self.room)
|
||||||
|
self.assertEqual(sp.position, 300)
|
||||||
|
|
||||||
|
def test_post_updates_existing_position(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||||
|
self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 450},
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
ScrollPosition.objects.get(user=self.user, room=self.room).position, 450
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_returns_204(self):
|
||||||
|
response = self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 100},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 204)
|
||||||
|
|
||||||
|
def test_post_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
f"/billboard/room/{self.room.id}/scroll-position/",
|
||||||
|
{"position": 100},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
12
src/apps/billboard/urls.py
Normal file
12
src/apps/billboard/urls.py
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from apps.billboard import views
|
||||||
|
|
||||||
|
app_name = "billboard"
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("", views.billboard, name="billboard"),
|
||||||
|
path("toggle-applets", views.toggle_billboard_applets, name="toggle_applets"),
|
||||||
|
path("room/<uuid:room_id>/scroll/", views.room_scroll, name="scroll"),
|
||||||
|
path("room/<uuid:room_id>/scroll-position/", views.save_scroll_position, name="save_scroll_position"),
|
||||||
|
]
|
||||||
86
src/apps/billboard/views.py
Normal file
86
src/apps/billboard/views.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Max, Q
|
||||||
|
from django.shortcuts import redirect, render
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
from apps.applets.utils import applet_context
|
||||||
|
from apps.drama.models import GameEvent, ScrollPosition
|
||||||
|
from apps.epic.models import GateSlot, Room, RoomInvite
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def billboard(request):
|
||||||
|
my_rooms = Room.objects.filter(
|
||||||
|
Q(owner=request.user) |
|
||||||
|
Q(gate_slots__gamer=request.user) |
|
||||||
|
Q(invites__invitee_email=request.user.email, invites__status=RoomInvite.PENDING)
|
||||||
|
).distinct().order_by("-created_at")
|
||||||
|
|
||||||
|
recent_room = (
|
||||||
|
Room.objects.filter(
|
||||||
|
Q(owner=request.user) | Q(gate_slots__gamer=request.user)
|
||||||
|
)
|
||||||
|
.annotate(last_event=Max("events__timestamp"))
|
||||||
|
.filter(last_event__isnull=False)
|
||||||
|
.order_by("-last_event")
|
||||||
|
.distinct()
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
recent_events = (
|
||||||
|
list(recent_room.events.select_related("actor").order_by("-timestamp")[:36])[::-1]
|
||||||
|
if recent_room else []
|
||||||
|
)
|
||||||
|
|
||||||
|
return render(request, "apps/billboard/billboard.html", {
|
||||||
|
"my_rooms": my_rooms,
|
||||||
|
"recent_room": recent_room,
|
||||||
|
"recent_events": recent_events,
|
||||||
|
"viewer": request.user,
|
||||||
|
"applets": applet_context(request.user, "billboard"),
|
||||||
|
"page_class": "page-billboard",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_billboard_applets(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
for applet in Applet.objects.filter(context="billboard"):
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked},
|
||||||
|
)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/billboard/_partials/_applets.html", {
|
||||||
|
"applets": applet_context(request.user, "billboard"),
|
||||||
|
})
|
||||||
|
return redirect("billboard:billboard")
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def room_scroll(request, room_id):
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
events = room.events.select_related("actor").all()
|
||||||
|
sp = ScrollPosition.objects.filter(user=request.user, room=room).first()
|
||||||
|
return render(request, "apps/billboard/room_scroll.html", {
|
||||||
|
"room": room,
|
||||||
|
"events": events,
|
||||||
|
"viewer": request.user,
|
||||||
|
"scroll_position": sp.position if sp else 0,
|
||||||
|
"page_class": "page-billscroll",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def save_scroll_position(request, room_id):
|
||||||
|
if request.method != "POST":
|
||||||
|
from django.http import HttpResponseNotAllowed
|
||||||
|
return HttpResponseNotAllowed(["POST"])
|
||||||
|
room = Room.objects.get(id=room_id)
|
||||||
|
position = int(request.POST.get("position", 0))
|
||||||
|
ScrollPosition.objects.update_or_create(
|
||||||
|
user=request.user, room=room,
|
||||||
|
defaults={"position": position},
|
||||||
|
)
|
||||||
|
from django.http import HttpResponse
|
||||||
|
return HttpResponse(status=204)
|
||||||
@@ -1,3 +1 @@
|
|||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
|
||||||
# Register your models here.
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ from django import forms
|
|||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from .models import Item
|
from .models import Item
|
||||||
|
|
||||||
DUPLICATE_ITEM_ERROR = "You've already logged this to your list"
|
DUPLICATE_ITEM_ERROR = "You've already logged this to your note"
|
||||||
EMPTY_ITEM_ERROR = "You can't have an empty list item"
|
EMPTY_ITEM_ERROR = "You can't have an empty note item"
|
||||||
|
|
||||||
class ItemForm(forms.Form):
|
class ItemForm(forms.Form):
|
||||||
text = forms.CharField(
|
text = forms.CharField(
|
||||||
@@ -11,22 +11,22 @@ class ItemForm(forms.Form):
|
|||||||
required=True,
|
required=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
def save(self, for_list):
|
def save(self, for_note):
|
||||||
return Item.objects.create(
|
return Item.objects.create(
|
||||||
list=for_list,
|
note=for_note,
|
||||||
text=self.cleaned_data["text"],
|
text=self.cleaned_data["text"],
|
||||||
)
|
)
|
||||||
|
|
||||||
class ExistingListItemForm(ItemForm):
|
class ExistingNoteItemForm(ItemForm):
|
||||||
def __init__(self, for_list, *args, **kwargs):
|
def __init__(self, for_note, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
self._for_list = for_list
|
self._for_note = for_note
|
||||||
|
|
||||||
def clean_text(self):
|
def clean_text(self):
|
||||||
text = self.cleaned_data["text"]
|
text = self.cleaned_data["text"]
|
||||||
if self._for_list.item_set.filter(text=text).exists():
|
if self._for_note.item_set.filter(text=text).exists():
|
||||||
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
|
raise forms.ValidationError(DUPLICATE_ITEM_ERROR)
|
||||||
return text
|
return text
|
||||||
|
|
||||||
def save(self):
|
def save(self):
|
||||||
return super().save(for_list=self._for_list)
|
return super().save(for_note=self._for_note)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# Generated by Django 6.0 on 2026-02-08 01:19
|
# Generated by Django 6.0 on 2026-02-23 04:30
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
|
import uuid
|
||||||
|
from django.conf import settings
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -9,13 +11,16 @@ class Migration(migrations.Migration):
|
|||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='List',
|
name='List',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('shared_with', models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-02-09 03:29
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0001_initial'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='list',
|
|
||||||
name='owner',
|
|
||||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
20
src/apps/dashboard/migrations/0002_rename_list_to_note.py
Normal file
20
src/apps/dashboard/migrations/0002_rename_list_to_note.py
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RenameModel(
|
||||||
|
old_name='List',
|
||||||
|
new_name='Note',
|
||||||
|
),
|
||||||
|
migrations.RenameField(
|
||||||
|
model_name='Item',
|
||||||
|
old_name='list',
|
||||||
|
new_name='note',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-12 19:30
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0002_rename_list_to_note'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='notes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='note',
|
||||||
|
name='shared_with',
|
||||||
|
field=models.ManyToManyField(blank=True, related_name='shared_notes', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-02-18 18:13
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0002_list_owner'),
|
|
||||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='list',
|
|
||||||
name='shared_with',
|
|
||||||
field=models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
class List(models.Model):
|
|
||||||
|
class Note(models.Model):
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
"lyric.User",
|
"lyric.User",
|
||||||
related_name="lists",
|
related_name="notes",
|
||||||
blank=True,
|
blank=True,
|
||||||
null=True,
|
null=True,
|
||||||
on_delete=models.CASCADE,
|
on_delete=models.CASCADE,
|
||||||
@@ -12,7 +16,7 @@ class List(models.Model):
|
|||||||
|
|
||||||
shared_with = models.ManyToManyField(
|
shared_with = models.ManyToManyField(
|
||||||
"lyric.User",
|
"lyric.User",
|
||||||
related_name="shared_lists",
|
related_name="shared_notes",
|
||||||
blank=True,
|
blank=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,16 +25,15 @@ class List(models.Model):
|
|||||||
return self.item_set.first().text
|
return self.item_set.first().text
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("view_list", args=[self.id])
|
return reverse("view_note", args=[self.id])
|
||||||
|
|
||||||
class Item(models.Model):
|
class Item(models.Model):
|
||||||
text = models.TextField(default="")
|
text = models.TextField(default="")
|
||||||
list = models.ForeignKey(List, default=None, on_delete=models.CASCADE)
|
note = models.ForeignKey(Note, default=None, on_delete=models.CASCADE)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ("id",)
|
ordering = ("id",)
|
||||||
unique_together = ("list", "text")
|
unique_together = ("note", "text")
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.text
|
return self.text
|
||||||
|
|
||||||
|
|||||||
34
src/apps/dashboard/static/apps/dashboard/dashboard.js
Normal file
34
src/apps/dashboard/static/apps/dashboard/dashboard.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// console.log("apps/scripts/dashboard.js loading");
|
||||||
|
const initialize = (inputSelector) => {
|
||||||
|
// console.log("initialize called!");
|
||||||
|
const textInput = document.querySelector(inputSelector);
|
||||||
|
if (!textInput) return;
|
||||||
|
textInput.oninput = () => {
|
||||||
|
// console.log("oninput triggered");
|
||||||
|
textInput.classList.remove("is-invalid");
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindPaletteForms = () => {
|
||||||
|
document.querySelectorAll('form[action*="set_palette"]').forEach(form => {
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const resp = await fetch(form.action, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Accept": "application/json" },
|
||||||
|
body: new FormData(form, e.submitter),
|
||||||
|
});
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const { palette } = await resp.json();
|
||||||
|
// Swap body palette class
|
||||||
|
[...document.body.classList]
|
||||||
|
.filter(c => c.startsWith("palette-"))
|
||||||
|
.forEach(c => document.body.classList.remove(c));
|
||||||
|
document.body.classList.add(palette);
|
||||||
|
// Update active swatch indicator
|
||||||
|
document.querySelectorAll(".swatch").forEach(sw => {
|
||||||
|
sw.classList.toggle("active", sw.classList.contains(palette));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
91
src/apps/dashboard/static/apps/dashboard/game-kit.js
Normal file
91
src/apps/dashboard/static/apps/dashboard/game-kit.js
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
(function () {
|
||||||
|
var btn = document.getElementById('id_kit_btn');
|
||||||
|
var dialog = document.getElementById('id_kit_bag_dialog');
|
||||||
|
if (!btn || !dialog) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
if (dialog.hasAttribute('open')) {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fetch(btn.dataset.kitUrl, {
|
||||||
|
headers: { 'X-Requested-With': 'XMLHttpRequest' },
|
||||||
|
})
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (html) {
|
||||||
|
dialog.innerHTML = html;
|
||||||
|
attachCardListeners();
|
||||||
|
btn.classList.add('active');
|
||||||
|
dialog.setAttribute('open', '');
|
||||||
|
})
|
||||||
|
.catch(function () {
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Escape key
|
||||||
|
document.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Escape' && dialog.hasAttribute('open')) {
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Click outside (but not on the rails button — let that flow through)
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
if (!dialog.hasAttribute('open')) return;
|
||||||
|
if (dialog.contains(e.target)) return;
|
||||||
|
if (e.target === btn || btn.contains(e.target)) return;
|
||||||
|
if (e.target.closest('button.token-rails')) return;
|
||||||
|
dialog.removeAttribute('open');
|
||||||
|
btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Inject token_id before token-rails form submits
|
||||||
|
document.addEventListener('click', function (e) {
|
||||||
|
var rails = e.target.closest('button.token-rails');
|
||||||
|
if (!rails || !window._kitTokenId) return;
|
||||||
|
var form = rails.closest('form');
|
||||||
|
if (!form) return;
|
||||||
|
var existing = form.querySelector('input[name="token_id"]');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
var hidden = document.createElement('input');
|
||||||
|
hidden.type = 'hidden';
|
||||||
|
hidden.name = 'token_id';
|
||||||
|
hidden.value = window._kitTokenId;
|
||||||
|
form.appendChild(hidden);
|
||||||
|
if (dialog.hasAttribute('open')) dialog.removeAttribute('open');
|
||||||
|
});
|
||||||
|
|
||||||
|
function attachCardListeners() {
|
||||||
|
dialog.querySelectorAll('.token[data-token-id]').forEach(function (card) {
|
||||||
|
card.addEventListener('click', function () {
|
||||||
|
dialog.querySelectorAll('.token[data-token-id].selected').forEach(function (c) {
|
||||||
|
c.classList.remove('selected');
|
||||||
|
});
|
||||||
|
card.classList.add('selected');
|
||||||
|
window._kitTokenId = card.dataset.tokenId;
|
||||||
|
var slot = document.querySelector('.token-slot');
|
||||||
|
if (slot) slot.classList.add('ready');
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseenter', function () {
|
||||||
|
var tooltip = card.querySelector('.token-tooltip');
|
||||||
|
if (!tooltip) return;
|
||||||
|
var rect = card.getBoundingClientRect();
|
||||||
|
tooltip.style.position = 'fixed';
|
||||||
|
tooltip.style.bottom = (window.innerHeight - rect.top + 8) + 'px';
|
||||||
|
tooltip.style.left = rect.left + 'px';
|
||||||
|
tooltip.style.display = 'block';
|
||||||
|
});
|
||||||
|
|
||||||
|
card.addEventListener('mouseleave', function () {
|
||||||
|
var tooltip = card.querySelector('.token-tooltip');
|
||||||
|
if (tooltip) tooltip.style.display = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}());
|
||||||
94
src/apps/dashboard/static/apps/dashboard/wallet.js
Normal file
94
src/apps/dashboard/static/apps/dashboard/wallet.js
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
const initWallet = () => {
|
||||||
|
let stripe, elements;
|
||||||
|
|
||||||
|
const addBtn = document.getElementById('id_add_payment_method');
|
||||||
|
const saveBtn = document.getElementById('id_save_payment_method');
|
||||||
|
const cancelBtn = document.getElementById('id_cancel_payment_method');
|
||||||
|
if (!addBtn) return;
|
||||||
|
|
||||||
|
const getCsrf = () => document.cookie.match(/csrftoken=([^;]+)/)[1];
|
||||||
|
|
||||||
|
addBtn.addEventListener('click', async () => {
|
||||||
|
const res = await fetch('/dashboard/wallet/setup-intent', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'X-CSRFToken': getCsrf()},
|
||||||
|
});
|
||||||
|
const {client_secret, publishable_key} = await res.json();
|
||||||
|
stripe = Stripe(publishable_key);
|
||||||
|
elements = stripe.elements({clientSecret: client_secret});
|
||||||
|
const paymentEl = elements.create('payment');
|
||||||
|
paymentEl.mount('#id_stripe_payment_element');
|
||||||
|
saveBtn.hidden = false;
|
||||||
|
cancelBtn.hidden = false;
|
||||||
|
const section = addBtn.closest('section');
|
||||||
|
section.style.setProperty('--applet-rows', '15');
|
||||||
|
});
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', async () => {
|
||||||
|
const {error, setupIntent} = await stripe.confirmSetup({
|
||||||
|
elements,
|
||||||
|
redirect: 'if_required',
|
||||||
|
});
|
||||||
|
if (error) { console.error(error); return; }
|
||||||
|
const res = await fetch('/dashboard/wallet/save-payment-method', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRFToken': getCsrf(),
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: `payment_method_id=${setupIntent.payment_method}`,
|
||||||
|
});
|
||||||
|
const {last4, brand} = await res.json();
|
||||||
|
const pm = document.createElement('div');
|
||||||
|
pm.textContent = `${brand} ····${last4}`;
|
||||||
|
document.getElementById('id_payment_methods').appendChild(pm);
|
||||||
|
elements.getElement('payment').unmount();
|
||||||
|
elements = null;
|
||||||
|
stripe = null;
|
||||||
|
saveBtn.hidden = true;
|
||||||
|
cancelBtn.hidden = true;
|
||||||
|
const section = cancelBtn.closest('section');
|
||||||
|
section.style.setProperty('--applet-rows', '3');
|
||||||
|
});
|
||||||
|
|
||||||
|
cancelBtn.addEventListener('click', () => {
|
||||||
|
if (elements) {
|
||||||
|
elements.getElement('payment').unmount();
|
||||||
|
elements = null;
|
||||||
|
stripe = null;
|
||||||
|
}
|
||||||
|
saveBtn.hidden = true;
|
||||||
|
cancelBtn.hidden = true;
|
||||||
|
const section = cancelBtn.closest('section');
|
||||||
|
section.style.setProperty('--applet-rows', '3');
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
function initWalletTooltips() {
|
||||||
|
const portal = document.getElementById('id_tooltip_portal');
|
||||||
|
if (!portal) return;
|
||||||
|
|
||||||
|
document.querySelectorAll('.wallet-tokens .token').forEach(token => {
|
||||||
|
const tooltip = token.querySelector('.token-tooltip');
|
||||||
|
if (!tooltip) return;
|
||||||
|
|
||||||
|
token.addEventListener('mouseenter', () => {
|
||||||
|
const rect = token.getBoundingClientRect();
|
||||||
|
portal.innerHTML = tooltip.innerHTML;
|
||||||
|
portal.classList.add('active');
|
||||||
|
const halfW = portal.offsetWidth / 2;
|
||||||
|
const rawLeft = rect.left + rect.width / 2;
|
||||||
|
const clampedLeft = Math.max(halfW + 8, Math.min(rawLeft, window.innerWidth - halfW - 8));
|
||||||
|
portal.style.left = Math.round(clampedLeft) + 'px';
|
||||||
|
portal.style.top = Math.round(rect.top) + 'px';
|
||||||
|
portal.style.transform = 'translate(-50%, calc(-100% - 0.5rem))';
|
||||||
|
});
|
||||||
|
|
||||||
|
token.addEventListener('mouseleave', () => {
|
||||||
|
portal.classList.remove('active');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', initWallet);
|
||||||
|
document.addEventListener('DOMContentLoaded', initWalletTooltips);
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
// console.log("apps/scripts/dashboard.js loading");
|
|
||||||
const initialize = (inputSelector) => {
|
|
||||||
// console.log("initialize called!");
|
|
||||||
const textInput = document.querySelector(inputSelector);
|
|
||||||
textInput.oninput = () => {
|
|
||||||
// console.log("oninput triggered");
|
|
||||||
textInput.classList.remove("is-invalid");
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -3,39 +3,39 @@ from django.test import TestCase
|
|||||||
from apps.dashboard.forms import (
|
from apps.dashboard.forms import (
|
||||||
DUPLICATE_ITEM_ERROR,
|
DUPLICATE_ITEM_ERROR,
|
||||||
EMPTY_ITEM_ERROR,
|
EMPTY_ITEM_ERROR,
|
||||||
ExistingListItemForm,
|
ExistingNoteItemForm,
|
||||||
ItemForm,
|
ItemForm,
|
||||||
)
|
)
|
||||||
from apps.dashboard.models import Item, List
|
from apps.dashboard.models import Item, Note
|
||||||
|
|
||||||
|
|
||||||
class ItemFormTest(TestCase):
|
class ItemFormTest(TestCase):
|
||||||
def test_form_save_handles_saving_to_a_list(self):
|
def test_form_save_handles_saving_to_a_note(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
form = ItemForm(data={"text": "do re mi"})
|
form = ItemForm(data={"text": "do re mi"})
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
new_item = form.save(for_list=mylist)
|
new_item = form.save(for_note=mynote)
|
||||||
self.assertEqual(new_item, Item.objects.get())
|
self.assertEqual(new_item, Item.objects.get())
|
||||||
self.assertEqual(new_item.text, "do re mi")
|
self.assertEqual(new_item.text, "do re mi")
|
||||||
self.assertEqual(new_item.list, mylist)
|
self.assertEqual(new_item.note, mynote)
|
||||||
|
|
||||||
class ExistingListItemFormTest(TestCase):
|
class ExistingNoteItemFormTest(TestCase):
|
||||||
def test_form_validation_for_blank_items(self):
|
def test_form_validation_for_blank_items(self):
|
||||||
list_ = List.objects.create()
|
note = Note.objects.create()
|
||||||
form = ExistingListItemForm(for_list=list_, data={"text": ""})
|
form = ExistingNoteItemForm(for_note=note, data={"text": ""})
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
|
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
|
||||||
|
|
||||||
def test_form_validation_for_duplicate_items(self):
|
def test_form_validation_for_duplicate_items(self):
|
||||||
list_ = List.objects.create()
|
note = Note.objects.create()
|
||||||
Item.objects.create(list=list_, text="twins, basil")
|
Item.objects.create(note=note, text="twins, basil")
|
||||||
form = ExistingListItemForm(for_list=list_, data={"text": "twins, basil"})
|
form = ExistingNoteItemForm(for_note=note, data={"text": "twins, basil"})
|
||||||
self.assertFalse(form.is_valid())
|
self.assertFalse(form.is_valid())
|
||||||
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
|
self.assertEqual(form.errors["text"], [DUPLICATE_ITEM_ERROR])
|
||||||
|
|
||||||
def test_form_save(self):
|
def test_form_save(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
form = ExistingListItemForm(for_list=mylist, data={"text": "howdy"})
|
form = ExistingNoteItemForm(for_note=mynote, data={"text": "howdy"})
|
||||||
self.assertTrue(form.is_valid())
|
self.assertTrue(form.is_valid())
|
||||||
new_item = form.save()
|
new_item = form.save()
|
||||||
self.assertEqual(new_item, Item.objects.get())
|
self.assertEqual(new_item, Item.objects.get())
|
||||||
|
|||||||
@@ -2,69 +2,69 @@ from django.core.exceptions import ValidationError
|
|||||||
from django.db.utils import IntegrityError
|
from django.db.utils import IntegrityError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from apps.dashboard.models import Item, List
|
from apps.dashboard.models import Item, Note
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
class ItemModelTest(TestCase):
|
class ItemModelTest(TestCase):
|
||||||
def test_item_is_related_to_list(self):
|
def test_item_is_related_to_note(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
item = Item()
|
item = Item()
|
||||||
item.list = mylist
|
item.note = mynote
|
||||||
item.save()
|
item.save()
|
||||||
self.assertIn(item, mylist.item_set.all())
|
self.assertIn(item, mynote.item_set.all())
|
||||||
|
|
||||||
def test_cannot_save_null_list_items(self):
|
def test_cannot_save_null_note_items(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
item = Item(list=mylist, text=None)
|
item = Item(note=mynote, text=None)
|
||||||
with self.assertRaises(IntegrityError):
|
with self.assertRaises(IntegrityError):
|
||||||
item.save()
|
item.save()
|
||||||
|
|
||||||
def test_cannot_save_empty_list_items(self):
|
def test_cannot_save_empty_note_items(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
item = Item(list=mylist, text="")
|
item = Item(note=mynote, text="")
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
item.full_clean()
|
item.full_clean()
|
||||||
|
|
||||||
def test_duplicate_items_are_invalid(self):
|
def test_duplicate_items_are_invalid(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
Item.objects.create(list=mylist, text="jklol")
|
Item.objects.create(note=mynote, text="jklol")
|
||||||
with self.assertRaises(ValidationError):
|
with self.assertRaises(ValidationError):
|
||||||
item = Item(list=mylist, text="jklol")
|
item = Item(note=mynote, text="jklol")
|
||||||
item.full_clean()
|
item.full_clean()
|
||||||
|
|
||||||
def test_still_can_save_same_item_to_different_lists(self):
|
def test_still_can_save_same_item_to_different_notes(self):
|
||||||
list1 = List.objects.create()
|
note1 = Note.objects.create()
|
||||||
list2 = List.objects.create()
|
note2 = Note.objects.create()
|
||||||
Item.objects.create(list=list1, text="nojk")
|
Item.objects.create(note=note1, text="nojk")
|
||||||
item = Item(list=list2, text="nojk")
|
item = Item(note=note2, text="nojk")
|
||||||
item.full_clean() # should not raise
|
item.full_clean() # should not raise
|
||||||
|
|
||||||
class ListModelTest(TestCase):
|
class NoteModelTest(TestCase):
|
||||||
def test_get_absolute_url(self):
|
def test_get_absolute_url(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
self.assertEqual(mylist.get_absolute_url(), f"/apps/dashboard/{mylist.id}/")
|
self.assertEqual(mynote.get_absolute_url(), f"/dashboard/note/{mynote.id}/")
|
||||||
|
|
||||||
def test_list_items_order(self):
|
def test_note_items_order(self):
|
||||||
list1 = List.objects.create()
|
note1 = Note.objects.create()
|
||||||
item1 = Item.objects.create(list=list1, text="i1")
|
item1 = Item.objects.create(note=note1, text="i1")
|
||||||
item2 = Item.objects.create(list=list1, text="item 2")
|
item2 = Item.objects.create(note=note1, text="item 2")
|
||||||
item3 = Item.objects.create(list=list1, text="3")
|
item3 = Item.objects.create(note=note1, text="3")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
list(list1.item_set.all()),
|
list(note1.item_set.all()),
|
||||||
[item1, item2, item3],
|
[item1, item2, item3],
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_lists_can_have_owners(self):
|
def test_notes_can_have_owners(self):
|
||||||
user = User.objects.create(email="a@b.cde")
|
user = User.objects.create(email="a@b.cde")
|
||||||
mylist = List.objects.create(owner=user)
|
mynote = Note.objects.create(owner=user)
|
||||||
self.assertIn(mylist, user.lists.all())
|
self.assertIn(mynote, user.notes.all())
|
||||||
|
|
||||||
def test_list_owner_is_optional(self):
|
def test_note_owner_is_optional(self):
|
||||||
List.objects.create()
|
Note.objects.create()
|
||||||
|
|
||||||
def test_list_name_is_first_item_text(self):
|
def test_note_name_is_first_item_text(self):
|
||||||
list_ = List.objects.create()
|
note = Note.objects.create()
|
||||||
Item.objects.create(list=list_, text="first item")
|
Item.objects.create(note=note, text="first item")
|
||||||
Item.objects.create(list=list_, text="second item")
|
Item.objects.create(note=note, text="second item")
|
||||||
self.assertEqual(list_.name, "first item")
|
self.assertEqual(note.name, "first item")
|
||||||
|
|||||||
79
src/apps/dashboard/tests/integrated/test_stripe_views.py
Normal file
79
src/apps/dashboard/tests/integrated/test_stripe_views.py
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
from unittest import mock
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.lyric.models import PaymentMethod, User
|
||||||
|
|
||||||
|
|
||||||
|
class SetupIntentViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="capman@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_setup_intent_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post("/dashboard/wallet/setup-intent")
|
||||||
|
self.assertRedirects(
|
||||||
|
response, "/?next=/dashboard/wallet/setup-intent",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("apps.dashboard.views.stripe")
|
||||||
|
def test_returns_client_secret(self, mock_stripe):
|
||||||
|
mock_stripe.Customer.create.return_value = mock.Mock(id="cus_test123")
|
||||||
|
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
|
||||||
|
response = self.client.post("/dashboard/wallet/setup-intent")
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()["client_secret"], "seti_secret")
|
||||||
|
self.assertIn("publishable_key", response.json())
|
||||||
|
|
||||||
|
@mock.patch("apps.dashboard.views.stripe")
|
||||||
|
def test_reuses_existing_stripe_customer(self, mock_stripe):
|
||||||
|
self.user.stripe_customer_id = "cus_existing"
|
||||||
|
self.user.save()
|
||||||
|
mock_stripe.SetupIntent.create.return_value = mock.Mock(client_secret="seti_secret")
|
||||||
|
self.client.post("/dashboard/wallet/setup-intent")
|
||||||
|
mock_stripe.Customer.create.assert_not_called()
|
||||||
|
mock_stripe.SetupIntent.create.assert_called_once_with(customer="cus_existing")
|
||||||
|
|
||||||
|
class SavePaymentMethodViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="capman@test.io")
|
||||||
|
self.user.stripe_customer_id = "cus_test123"
|
||||||
|
self.user.save()
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_save_payment_method_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/wallet/save-payment-method", {"payment_method_id": "pm_test"}
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, "/?next=/dashboard/wallet/save-payment-method",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
@mock.patch("apps.dashboard.views.stripe")
|
||||||
|
def test_creates_payment_method_record(self, mock_stripe):
|
||||||
|
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
|
||||||
|
card=mock.Mock(last4="4242", brand="visa")
|
||||||
|
)
|
||||||
|
self.client.post(
|
||||||
|
"/dashboard/wallet/save-payment-method",
|
||||||
|
{"payment_method_id": "pm_test123"},
|
||||||
|
)
|
||||||
|
pm = PaymentMethod.objects.get(user=self.user)
|
||||||
|
self.assertEqual(pm.last4, "4242")
|
||||||
|
self.assertEqual(pm.brand, "visa")
|
||||||
|
|
||||||
|
@mock.patch("apps.dashboard.views.stripe")
|
||||||
|
def test_returns_json_with_last4_and_brand(self, mock_stripe):
|
||||||
|
mock_stripe.PaymentMethod.retrieve.return_value = mock.Mock(
|
||||||
|
card=mock.Mock(last4="4242", brand="visa")
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/wallet/save-payment-method",
|
||||||
|
{"payment_method_id": "pm_test123"},
|
||||||
|
)
|
||||||
|
data = response.json()
|
||||||
|
self.assertEqual(data["last4"], "4242")
|
||||||
|
self.assertEqual(data["brand"], "visa")
|
||||||
@@ -1,18 +1,25 @@
|
|||||||
import lxml.html
|
import lxml.html
|
||||||
from unittest import skip
|
|
||||||
|
|
||||||
from django.test import TestCase
|
from django.contrib.messages import get_messages
|
||||||
|
from django.test import override_settings, TestCase
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils import html
|
from django.utils import html
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
from apps.dashboard.forms import (
|
from apps.dashboard.forms import (
|
||||||
DUPLICATE_ITEM_ERROR,
|
DUPLICATE_ITEM_ERROR,
|
||||||
EMPTY_ITEM_ERROR,
|
EMPTY_ITEM_ERROR,
|
||||||
)
|
)
|
||||||
from apps.dashboard.models import Item, List
|
from apps.dashboard.models import Item, Note
|
||||||
from apps.lyric.models import User
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
class HomePageTest(TestCase):
|
class HomePageTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
Applet.objects.get_or_create(slug="new-note", defaults={"name": "New Note"})
|
||||||
|
|
||||||
def test_uses_home_template(self):
|
def test_uses_home_template(self):
|
||||||
response = self.client.get('/')
|
response = self.client.get('/')
|
||||||
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
|
self.assertTemplateUsed(response, 'apps/dashboard/home.html')
|
||||||
@@ -21,32 +28,36 @@ class HomePageTest(TestCase):
|
|||||||
response = self.client.get('/')
|
response = self.client.get('/')
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
forms = parsed.cssselect('form[method=POST]')
|
forms = parsed.cssselect('form[method=POST]')
|
||||||
self.assertIn("/apps/dashboard/new_list", [form.get("action") for form in forms])
|
self.assertIn("/dashboard/new_note", [form.get("action") for form in forms])
|
||||||
[form] = [form for form in forms if form.get("action") == "/apps/dashboard/new_list"]
|
[form] = [form for form in forms if form.get("action") == "/dashboard/new_note"]
|
||||||
inputs = form.cssselect("input")
|
inputs = form.cssselect("input")
|
||||||
self.assertIn("text", [input.get("name") for input in inputs])
|
self.assertIn("text", [input.get("name") for input in inputs])
|
||||||
|
|
||||||
class NewListTest(TestCase):
|
class NewNoteTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(user)
|
||||||
|
|
||||||
def test_can_save_a_POST_request(self):
|
def test_can_save_a_POST_request(self):
|
||||||
self. client.post("/apps/dashboard/new_list", data={"text": "A new list item"})
|
self.client.post("/dashboard/new_note", data={"text": "A new note item"})
|
||||||
self.assertEqual(Item.objects.count(), 1)
|
self.assertEqual(Item.objects.count(), 1)
|
||||||
new_item = Item.objects.get()
|
new_item = Item.objects.get()
|
||||||
self.assertEqual(new_item.text, "A new list item")
|
self.assertEqual(new_item.text, "A new note item")
|
||||||
|
|
||||||
def test_redirects_after_POST(self):
|
def test_redirects_after_POST(self):
|
||||||
response = self.client.post("/apps/dashboard/new_list", data={"text": "A new list item"})
|
response = self.client.post("/dashboard/new_note", data={"text": "A new note item"})
|
||||||
new_list = List.objects.get()
|
new_note = Note.objects.get()
|
||||||
self.assertRedirects(response, f"/apps/dashboard/{new_list.id}/")
|
self.assertRedirects(response, f"/dashboard/note/{new_note.id}/")
|
||||||
|
|
||||||
# Post invalid input helper
|
# Post invalid input helper
|
||||||
def post_invalid_input(self):
|
def post_invalid_input(self):
|
||||||
return self.client.post("/apps/dashboard/new_list", data={"text": ""})
|
return self.client.post("/dashboard/new_note", data={"text": ""})
|
||||||
|
|
||||||
def test_for_invalid_input_nothing_saved_to_db(self):
|
def test_for_invalid_input_nothing_saved_to_db(self):
|
||||||
self.post_invalid_input()
|
self.post_invalid_input()
|
||||||
self.assertEqual(Item.objects.count(), 0)
|
self.assertEqual(Item.objects.count(), 0)
|
||||||
|
|
||||||
def test_for_invalid_input_renders_list_template(self):
|
def test_for_invalid_input_renders_home_template(self):
|
||||||
response = self.post_invalid_input()
|
response = self.post_invalid_input()
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTemplateUsed(response, "apps/dashboard/home.html")
|
self.assertTemplateUsed(response, "apps/dashboard/home.html")
|
||||||
@@ -55,15 +66,16 @@ class NewListTest(TestCase):
|
|||||||
response = self.post_invalid_input()
|
response = self.post_invalid_input()
|
||||||
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
|
self.assertContains(response, html.escape(EMPTY_ITEM_ERROR))
|
||||||
|
|
||||||
class ListViewTest(TestCase):
|
@override_settings(COMPRESS_ENABLED=False)
|
||||||
def test_uses_list_template(self):
|
class NoteViewTest(TestCase):
|
||||||
mylist = List.objects.create()
|
def test_uses_note_template(self):
|
||||||
response = self.client.get(f"/apps/dashboard/{mylist.id}/")
|
mynote = Note.objects.create()
|
||||||
self.assertTemplateUsed(response, "apps/dashboard/list.html")
|
response = self.client.get(f"/dashboard/note/{mynote.id}/")
|
||||||
|
self.assertTemplateUsed(response, "apps/dashboard/note.html")
|
||||||
|
|
||||||
def test_renders_input_form(self):
|
def test_renders_input_form(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
url = f"/apps/dashboard/{mylist.id}/"
|
url = f"/dashboard/note/{mynote.id}/"
|
||||||
response = self.client.get(url)
|
response = self.client.get(url)
|
||||||
parsed = lxml.html.fromstring(response.content)
|
parsed = lxml.html.fromstring(response.content)
|
||||||
forms = parsed.cssselect("form[method=POST]")
|
forms = parsed.cssselect("form[method=POST]")
|
||||||
@@ -72,58 +84,58 @@ class ListViewTest(TestCase):
|
|||||||
inputs = form.cssselect("input")
|
inputs = form.cssselect("input")
|
||||||
self.assertIn("text", [input.get("name") for input in inputs])
|
self.assertIn("text", [input.get("name") for input in inputs])
|
||||||
|
|
||||||
def test_displays_only_items_for_that_list(self):
|
def test_displays_only_items_for_that_note(self):
|
||||||
# Given/Arrange
|
# Given/Arrange
|
||||||
correct_list = List.objects.create()
|
correct_note = Note.objects.create()
|
||||||
Item.objects.create(text="itemey 1", list=correct_list)
|
Item.objects.create(text="itemey 1", note=correct_note)
|
||||||
Item.objects.create(text="itemey 2", list=correct_list)
|
Item.objects.create(text="itemey 2", note=correct_note)
|
||||||
other_list = List.objects.create()
|
other_note = Note.objects.create()
|
||||||
Item.objects.create(text="other list item", list=other_list)
|
Item.objects.create(text="other note item", note=other_note)
|
||||||
# When/Act
|
# When/Act
|
||||||
response = self.client.get(f"/apps/dashboard/{correct_list.id}/")
|
response = self.client.get(f"/dashboard/note/{correct_note.id}/")
|
||||||
# Then/Assert
|
# Then/Assert
|
||||||
self.assertContains(response, "itemey 1")
|
self.assertContains(response, "itemey 1")
|
||||||
self.assertContains(response, "itemey 2")
|
self.assertContains(response, "itemey 2")
|
||||||
self.assertNotContains(response, "other list item")
|
self.assertNotContains(response, "other note item")
|
||||||
|
|
||||||
def test_can_save_a_POST_request_to_an_existing_list(self):
|
def test_can_save_a_POST_request_to_an_existing_note(self):
|
||||||
other_list = List.objects.create()
|
other_note = Note.objects.create()
|
||||||
correct_list = List.objects.create()
|
correct_note = Note.objects.create()
|
||||||
|
|
||||||
self.client.post(
|
self.client.post(
|
||||||
f"/apps/dashboard/{correct_list.id}/",
|
f"/dashboard/note/{correct_note.id}/",
|
||||||
data={"text": "A new item for an existing list"},
|
data={"text": "A new item for an existing note"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertEqual(Item.objects.count(), 1)
|
self.assertEqual(Item.objects.count(), 1)
|
||||||
new_item = Item.objects.get()
|
new_item = Item.objects.get()
|
||||||
self.assertEqual(new_item.text, "A new item for an existing list")
|
self.assertEqual(new_item.text, "A new item for an existing note")
|
||||||
self.assertEqual(new_item.list, correct_list)
|
self.assertEqual(new_item.note, correct_note)
|
||||||
|
|
||||||
def test_POST_redirects_to_list_view(self):
|
def test_POST_redirects_to_note_view(self):
|
||||||
other_list = List.objects.create()
|
other_note = Note.objects.create()
|
||||||
correct_list = List.objects.create()
|
correct_note = Note.objects.create()
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"/apps/dashboard/{correct_list.id}/",
|
f"/dashboard/note/{correct_note.id}/",
|
||||||
data={"text": "A new item for an existing list"},
|
data={"text": "A new item for an existing note"},
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertRedirects(response, f"/apps/dashboard/{correct_list.id}/")
|
self.assertRedirects(response, f"/dashboard/note/{correct_note.id}/")
|
||||||
|
|
||||||
# Post invalid input helper
|
# Post invalid input helper
|
||||||
def post_invalid_input(self):
|
def post_invalid_input(self):
|
||||||
mylist = List.objects.create()
|
mynote = Note.objects.create()
|
||||||
return self.client.post(f"/apps/dashboard/{mylist.id}/", data={"text": ""})
|
return self.client.post(f"/dashboard/note/{mynote.id}/", data={"text": ""})
|
||||||
|
|
||||||
def test_for_invalid_input_nothing_saved_to_db(self):
|
def test_for_invalid_input_nothing_saved_to_db(self):
|
||||||
self.post_invalid_input()
|
self.post_invalid_input()
|
||||||
self.assertEqual(Item.objects.count(), 0)
|
self.assertEqual(Item.objects.count(), 0)
|
||||||
|
|
||||||
def test_for_invalid_input_renders_list_template(self):
|
def test_for_invalid_input_renders_note_template(self):
|
||||||
response = self.post_invalid_input()
|
response = self.post_invalid_input()
|
||||||
self.assertEqual(response.status_code, 200)
|
self.assertEqual(response.status_code, 200)
|
||||||
self.assertTemplateUsed(response, "apps/dashboard/list.html")
|
self.assertTemplateUsed(response, "apps/dashboard/note.html")
|
||||||
|
|
||||||
def test_for_invalid_input_shows_error_on_page(self):
|
def test_for_invalid_input_shows_error_on_page(self):
|
||||||
response = self.post_invalid_input()
|
response = self.post_invalid_input()
|
||||||
@@ -135,78 +147,338 @@ class ListViewTest(TestCase):
|
|||||||
[input] = parsed.cssselect("input[name=text]")
|
[input] = parsed.cssselect("input[name=text]")
|
||||||
self.assertIn("is-invalid", set(input.classes))
|
self.assertIn("is-invalid", set(input.classes))
|
||||||
|
|
||||||
def test_duplicate_item_validation_errors_end_up_on_lists_page(self):
|
def test_duplicate_item_validation_errors_end_up_on_note_page(self):
|
||||||
list1 = List.objects.create()
|
note1 = Note.objects.create()
|
||||||
Item.objects.create(list=list1, text="lorem ipsum")
|
Item.objects.create(note=note1, text="lorem ipsum")
|
||||||
|
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"/apps/dashboard/{list1.id}/",
|
f"/dashboard/note/{note1.id}/",
|
||||||
data={"text": "lorem ipsum"},
|
data={"text": "lorem ipsum"},
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_error = html.escape(DUPLICATE_ITEM_ERROR)
|
expected_error = html.escape(DUPLICATE_ITEM_ERROR)
|
||||||
self.assertContains(response, expected_error)
|
self.assertContains(response, expected_error)
|
||||||
self.assertTemplateUsed(response, "apps/dashboard/list.html")
|
self.assertTemplateUsed(response, "apps/dashboard/note.html")
|
||||||
self.assertEqual(Item.objects.all().count(), 1)
|
self.assertEqual(Item.objects.all().count(), 1)
|
||||||
|
|
||||||
class MyListsTest(TestCase):
|
class MyNotesTest(TestCase):
|
||||||
def test_my_lists_url_renders_my_lists_template(self):
|
def test_my_notes_url_renders_my_notes_template(self):
|
||||||
user = User.objects.create(email="a@b.cde")
|
user = User.objects.create(email="a@b.cde")
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
response = self.client.get(f"/apps/dashboard/users/{user.id}/")
|
response = self.client.get(f"/dashboard/users/{user.id}/")
|
||||||
self.assertTemplateUsed(response, "apps/dashboard/my_lists.html")
|
self.assertTemplateUsed(response, "apps/dashboard/my_notes.html")
|
||||||
|
|
||||||
def test_passes_correct_owner_to_template(self):
|
def test_passes_correct_owner_to_template(self):
|
||||||
User.objects.create(email="wrongowner@example.com")
|
User.objects.create(email="wrongowner@example.com")
|
||||||
correct_user = User.objects.create(email="a@b.cde")
|
correct_user = User.objects.create(email="a@b.cde")
|
||||||
self.client.force_login(correct_user)
|
self.client.force_login(correct_user)
|
||||||
response = self.client.get(f"/apps/dashboard/users/{correct_user.id}/")
|
response = self.client.get(f"/dashboard/users/{correct_user.id}/")
|
||||||
self.assertEqual(response.context["owner"], correct_user)
|
self.assertEqual(response.context["owner"], correct_user)
|
||||||
|
|
||||||
def test_list_owner_is_saved_if_user_is_authenticated(self):
|
def test_note_owner_is_saved_if_user_is_authenticated(self):
|
||||||
user = User.objects.create(email="a@b.cde")
|
user = User.objects.create(email="a@b.cde")
|
||||||
self.client.force_login(user)
|
self.client.force_login(user)
|
||||||
self.client.post("/apps/dashboard/new_list", data={"text": "new item"})
|
self.client.post("/dashboard/new_note", data={"text": "new item"})
|
||||||
new_list = List.objects.get()
|
new_note = Note.objects.get()
|
||||||
self.assertEqual(new_list.owner, user)
|
self.assertEqual(new_note.owner, user)
|
||||||
|
|
||||||
def test_my_lists_redirects_if_not_logged_in(self):
|
def test_my_notes_redirects_if_not_logged_in(self):
|
||||||
user = User.objects.create(email="a@b.cde")
|
user = User.objects.create(email="a@b.cde")
|
||||||
response = self.client.get(f"/apps/dashboard/users/{user.id}/")
|
response = self.client.get(f"/dashboard/users/{user.id}/")
|
||||||
self.assertRedirects(response, "/")
|
self.assertRedirects(response, "/")
|
||||||
|
|
||||||
def test_my_lists_returns_403_for_wrong_user(self):
|
def test_my_notes_returns_403_for_wrong_user(self):
|
||||||
# create two users, login as user_a, request user_b's my_lists url
|
# create two users, login as user_a, request user_b's my_notes url
|
||||||
user1 = User.objects.create(email="a@b.cde")
|
user1 = User.objects.create(email="a@b.cde")
|
||||||
user2 = User.objects.create(email="wrongowner@example.com")
|
user2 = User.objects.create(email="wrongowner@example.com")
|
||||||
self.client.force_login(user2)
|
self.client.force_login(user2)
|
||||||
response = self.client.get(f"/apps/dashboard/users/{user1.id}/")
|
response = self.client.get(f"/dashboard/users/{user1.id}/")
|
||||||
# assert 403
|
# assert 403
|
||||||
self.assertEqual(response.status_code, 403)
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
class ShareListTest(TestCase):
|
class ShareNoteTest(TestCase):
|
||||||
def test_post_to_share_list_url_redirects_to_list(self):
|
def test_post_to_share_note_url_redirects_to_note(self):
|
||||||
our_list = List.objects.create()
|
our_note = Note.objects.create()
|
||||||
alice = User.objects.create(email="alice@example.com")
|
alice = User.objects.create(email="alice@example.com")
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"/apps/dashboard/{our_list.id}/share_list",
|
f"/dashboard/note/{our_note.id}/share_note",
|
||||||
data={"recipient": "alice@example.com"},
|
data={"recipient": "alice@example.com"},
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")
|
self.assertRedirects(response, f"/dashboard/note/{our_note.id}/")
|
||||||
|
|
||||||
def test_post_with_email_adds_user_to_shared_with(self):
|
def test_post_with_email_adds_user_to_shared_with(self):
|
||||||
our_list = List.objects.create()
|
our_note = Note.objects.create()
|
||||||
alice = User.objects.create(email="alice@example.com")
|
alice = User.objects.create(email="alice@example.com")
|
||||||
self.client.post(
|
self.client.post(
|
||||||
f"/apps/dashboard/{our_list.id}/share_list",
|
f"/dashboard/note/{our_note.id}/share_note",
|
||||||
data={"recipient": "alice@example.com"},
|
data={"recipient": "alice@example.com"},
|
||||||
)
|
)
|
||||||
self.assertIn(alice, our_list.shared_with.all())
|
self.assertIn(alice, our_note.shared_with.all())
|
||||||
|
|
||||||
def test_post_with_nonexistent_email_redirects_to_list(self):
|
def test_post_with_nonexistent_email_redirects_to_note(self):
|
||||||
our_list = List.objects.create()
|
our_note = Note.objects.create()
|
||||||
response = self.client.post(
|
response = self.client.post(
|
||||||
f"/apps/dashboard/{our_list.id}/share_list",
|
f"/dashboard/note/{our_note.id}/share_note",
|
||||||
data={"recipient": "nobody@example.com"},
|
data={"recipient": "nobody@example.com"},
|
||||||
)
|
)
|
||||||
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")
|
self.assertRedirects(
|
||||||
|
response,
|
||||||
|
f"/dashboard/note/{our_note.id}/",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_share_note_does_not_add_owner_as_recipient(self):
|
||||||
|
owner = User.objects.create(email="owner@example.com")
|
||||||
|
our_note = Note.objects.create(owner=owner)
|
||||||
|
self.client.force_login(owner)
|
||||||
|
self.client.post(reverse("share_note", args=[our_note.id]),
|
||||||
|
data={"recipient": "owner@example.com"})
|
||||||
|
self.assertNotIn(owner, our_note.shared_with.all())
|
||||||
|
|
||||||
|
@override_settings(MESSAGE_STORAGE='django.contrib.messages.storage.session.SessionStorage')
|
||||||
|
def test_share_note_shows_privacy_safe_message(self):
|
||||||
|
our_note = Note.objects.create()
|
||||||
|
response = self.client.post(
|
||||||
|
f"/dashboard/note/{our_note.id}/share_note",
|
||||||
|
data={"recipient": "nobody@example.com"},
|
||||||
|
follow=True,
|
||||||
|
)
|
||||||
|
messages = list(get_messages(response.wsgi_request))
|
||||||
|
self.assertEqual(
|
||||||
|
str(messages[0]),
|
||||||
|
"An invite has been sent if that address is registered.",
|
||||||
|
)
|
||||||
|
|
||||||
|
class ViewAuthNoteTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="disco@example.com")
|
||||||
|
self.our_note = Note.objects.create(owner=self.owner)
|
||||||
|
|
||||||
|
def test_anonymous_user_is_redirected(self):
|
||||||
|
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
|
||||||
|
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_non_owner_non_shared_user_gets_403(self):
|
||||||
|
stranger = User.objects.create(email="stranger@example.com")
|
||||||
|
self.client.force_login(stranger)
|
||||||
|
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
|
||||||
|
self.assertEqual(response.status_code, 403)
|
||||||
|
|
||||||
|
def test_shared_with_user_can_access_note(self):
|
||||||
|
guest = User.objects.create(email="guest@example.com")
|
||||||
|
self.our_note.shared_with.add(guest)
|
||||||
|
self.client.force_login(guest)
|
||||||
|
response = self.client.get(reverse("view_note", args=[self.our_note.id]))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
@override_settings(COMPRESS_ENABLED=False)
|
||||||
|
class SetPaletteTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="a@b.cde")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.url = reverse("home")
|
||||||
|
Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
|
||||||
|
def test_anonymous_user_is_redirected_home(self):
|
||||||
|
response = self.client.post("/dashboard/set_palette")
|
||||||
|
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_set_palette_updates_user_palette(self):
|
||||||
|
User.objects.filter(pk=self.user.pk).update(palette="palette-sheol")
|
||||||
|
self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.palette, "palette-default")
|
||||||
|
|
||||||
|
def test_locked_palette_is_rejected(self):
|
||||||
|
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-nirvana"})
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.palette, "palette-default")
|
||||||
|
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_set_palette_redirects_home(self):
|
||||||
|
response = self.client.post("/dashboard/set_palette", data={"palette": "palette-default"})
|
||||||
|
self.assertRedirects(response, "/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_set_palette_returns_json_when_requested(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/set_palette",
|
||||||
|
data={"palette": "palette-sepia"},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"palette": "palette-sepia"})
|
||||||
|
|
||||||
|
def test_locked_palette_returns_unchanged_json(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/set_palette",
|
||||||
|
data={"palette": "palette-nirvana"},
|
||||||
|
headers={"Accept": "application/json"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json(), {"palette": "palette-default"})
|
||||||
|
|
||||||
|
def test_dashboard_contains_set_palette_form(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
forms = parsed.cssselect('form[action="/dashboard/set_palette"]')
|
||||||
|
unlocked = [p for p in response.context["palettes"] if not p["locked"]]
|
||||||
|
self.assertEqual(len(forms), len(unlocked))
|
||||||
|
|
||||||
|
def test_active_palette_swatch_has_active_class(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[active] = parsed.cssselect(".swatch.active")
|
||||||
|
self.assertIn("palette-default", active.classes)
|
||||||
|
|
||||||
|
def test_locked_palettes_are_not_forms(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
locked = parsed.cssselect(".swatch.locked")
|
||||||
|
expected_locked = [p for p in response.context["palettes"] if p["locked"]]
|
||||||
|
self.assertEqual(len(locked), len(expected_locked))
|
||||||
|
# they mustn't be button els
|
||||||
|
for swatch in locked:
|
||||||
|
self.assertNotEqual(swatch.tag, "button")
|
||||||
|
|
||||||
|
def test_palette_picker_count_matches_context(self):
|
||||||
|
response = self.client.get(self.url)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
swatches = parsed.cssselect(".swatch")
|
||||||
|
self.assertEqual(len(swatches), len(response.context["palettes"]))
|
||||||
|
|
||||||
|
@override_settings(COMPRESS_ENABLED=False)
|
||||||
|
class ProfileViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="discoman@example.com")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_post_username_saves_to_user(self):
|
||||||
|
self.client.post("/dashboard/set_profile", data={"username": "discoman"})
|
||||||
|
self.user.refresh_from_db()
|
||||||
|
self.assertEqual(self.user.username, "discoman")
|
||||||
|
|
||||||
|
def test_post_username_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post("/dashboard/set_profile", data={"username": "somnambulist"})
|
||||||
|
self.assertRedirects(response, "/?next=/dashboard/set_profile", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_dash_renders_username_applet(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[applet] = parsed.cssselect("#id_applet_username")
|
||||||
|
self.assertIn("@", applet.text_content())
|
||||||
|
[input_el] = parsed.cssselect("#id_new_username")
|
||||||
|
self.assertEqual("", input_el.get("value"))
|
||||||
|
|
||||||
|
def test_dash_shows_display_name_in_applet(self):
|
||||||
|
self.user.username = "discoman"
|
||||||
|
self.user.save()
|
||||||
|
response = self.client.get("/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[username_input] = parsed.cssselect("#id_new_username")
|
||||||
|
self.assertEqual("discoman", username_input.get("value"))
|
||||||
|
|
||||||
|
class ToggleDashAppletsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
self.url = reverse("toggle_applets")
|
||||||
|
|
||||||
|
def test_unauthenticated_user_is_redirected(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(self.url)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, f"/?next={self.url}", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_unchecked_applet_gets_user_applet_with_visible_false(self):
|
||||||
|
self.client.post(self.url, {"applets": ["username"]})
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=self.palette_applet)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_redirects_on_normal_post(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url, {"applets": ["username", "palette"]}
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, reverse("home"), fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_returns_200_on_htmx_post(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["username", "palette"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_htmx_post_renders_visible_applets_only(self):
|
||||||
|
response = self.client.post(
|
||||||
|
self.url,
|
||||||
|
{"applets": ["username"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
self.assertEqual(len(parsed.cssselect("#id_applet_username")), 1)
|
||||||
|
self.assertEqual(len(parsed.cssselect("#id_applet_palette")), 0)
|
||||||
|
|
||||||
|
def test_toggle_applets_does_not_affect_gameboard_applets(self):
|
||||||
|
game_applet, _ = Applet.objects.get_or_create(
|
||||||
|
slug="new-game", defaults={"name": "New Game", "context": "gameboard"}
|
||||||
|
)
|
||||||
|
self.client.post(self.url, {"applets": ["username", "palette"]})
|
||||||
|
self.assertFalse(UserApplet.objects.filter(user=self.user, applet=game_applet). exists())
|
||||||
|
|
||||||
|
class AppletVisibilityContextTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.username_applet, _ = Applet.objects.get_or_create(slug="username", defaults={"name": "Username"})
|
||||||
|
self.palette_applet, _ = Applet.objects.get_or_create(slug="palette", defaults={"name": "Palette"})
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.palette_applet, visible=False)
|
||||||
|
|
||||||
|
def test_dash_reflects_user_applet_visibility(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
applet_map = {entry["applet"].slug: entry["visible"] for entry in response.context["applets"]}
|
||||||
|
self.assertFalse(applet_map["palette"])
|
||||||
|
self.assertTrue(applet_map["username"])
|
||||||
|
|
||||||
|
class FooterNavTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_footer_nav_present_on_dashboard(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[nav] = parsed.cssselect("#id_footer_nav")
|
||||||
|
self.assertIsNotNone(nav)
|
||||||
|
|
||||||
|
def test_footer_nav_has_dashboard_link(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[nav] = parsed.cssselect("#id_footer_nav")
|
||||||
|
links = [a.get("href") for a in nav.cssselect("a")]
|
||||||
|
self.assertIn("/", links)
|
||||||
|
|
||||||
|
def test_footer_nav_has_gameboard_link(self):
|
||||||
|
response = self.client.get("/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[nav] = parsed.cssselect("#id_footer_nav")
|
||||||
|
links = [a.get("href") for a in nav.cssselect("a")]
|
||||||
|
self.assertIn("/gameboard/", links)
|
||||||
|
|
||||||
|
class WalletAppletTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="disco@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
Applet.objects.get_or_create(slug="wallet", defaults={"name": "Wallet"})
|
||||||
|
response = self.client.get("/")
|
||||||
|
self.parsed = lxml.html.fromstring(response.content)
|
||||||
|
|
||||||
|
def test_wallet_applet_present_on_dash(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_applet_wallet")
|
||||||
|
|
||||||
|
def test_wallet_applet_has_manage_link(self):
|
||||||
|
[link] = self.parsed.cssselect("#id_applet_wallet a.wallet-manage-link")
|
||||||
|
self.assertEqual(link.get("href"), "/dashboard/wallet/")
|
||||||
|
|||||||
139
src/apps/dashboard/tests/integrated/test_wallet_views.py
Normal file
139
src/apps/dashboard/tests/integrated/test_wallet_views.py
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
import lxml.html
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
from apps.lyric.models import Token, User, Wallet
|
||||||
|
|
||||||
|
|
||||||
|
class WalletViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="capman@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get("/dashboard/wallet/")
|
||||||
|
self.parsed = lxml.html.fromstring(response.content)
|
||||||
|
|
||||||
|
def test_wallet_page_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get("/dashboard/wallet/")
|
||||||
|
self.assertRedirects(
|
||||||
|
response, "/?next=/dashboard/wallet/", fetch_redirect_response=False
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_wallet_page_renders(self):
|
||||||
|
[el] = self.parsed.cssselect("#id_writs_balance")
|
||||||
|
self.assertEqual(el.text_content().strip(), "144")
|
||||||
|
|
||||||
|
def test_wallet_page_shows_esteem_balance(self):
|
||||||
|
[el] = self.parsed.cssselect("#id_esteem_balance")
|
||||||
|
self.assertEqual(el.text_content().strip(), "0")
|
||||||
|
|
||||||
|
def test_wallet_page_shows_coin_on_a_string(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_coin_on_a_string")
|
||||||
|
|
||||||
|
def test_wallet_page_shows_free_token(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_free_token")
|
||||||
|
|
||||||
|
def test_wallet_page_shows_payment_methods_section(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_add_payment_method")
|
||||||
|
|
||||||
|
def test_wallet_page_shows_stripe_payment_element(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_stripe_payment_element")
|
||||||
|
|
||||||
|
def test_wallet_page_shows_tithe_token_shop(self):
|
||||||
|
[_] = self.parsed.cssselect("#id_tithe_token_shop")
|
||||||
|
|
||||||
|
def test_tithe_token_shop_shows_bundle(self):
|
||||||
|
bundles = self.parsed.cssselect("#id_tithe_token_shop .token-bundle")
|
||||||
|
self.assertGreater(len(bundles), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class WalletViewAppletContextTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="walletctx@test.io")
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="wallet-balances",
|
||||||
|
defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="wallet-tokens",
|
||||||
|
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
||||||
|
)
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="wallet-payment",
|
||||||
|
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_wallet_view_passes_applets_context(self):
|
||||||
|
response = self.client.get("/dashboard/wallet/")
|
||||||
|
slugs = [e["applet"].slug for e in response.context["applets"]]
|
||||||
|
self.assertIn("wallet-balances", slugs)
|
||||||
|
self.assertIn("wallet-tokens", slugs)
|
||||||
|
self.assertIn("wallet-payment", slugs)
|
||||||
|
|
||||||
|
def test_wallet_page_renders_applets_container(self):
|
||||||
|
response = self.client.get("/dashboard/wallet/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[_] = parsed.cssselect("#id_wallet_applets_container")
|
||||||
|
|
||||||
|
def test_wallet_page_renders_gear_button(self):
|
||||||
|
response = self.client.get("/dashboard/wallet/")
|
||||||
|
parsed = lxml.html.fromstring(response.content)
|
||||||
|
[_] = parsed.cssselect(".gear-btn")
|
||||||
|
|
||||||
|
|
||||||
|
class ToggleWalletAppletsTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="wallettoggle@test.io")
|
||||||
|
self.balances = Applet.objects.get_or_create(
|
||||||
|
slug="wallet-balances",
|
||||||
|
defaults={"name": "Wallet Balances", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
||||||
|
)[0]
|
||||||
|
self.tokens = Applet.objects.get_or_create(
|
||||||
|
slug="wallet-tokens",
|
||||||
|
defaults={"name": "Wallet Tokens", "grid_cols": 3, "grid_rows": 3, "context": "wallet"},
|
||||||
|
)[0]
|
||||||
|
Applet.objects.get_or_create(
|
||||||
|
slug="wallet-payment",
|
||||||
|
defaults={"name": "Payment Methods", "grid_cols": 6, "grid_rows": 3, "context": "wallet"},
|
||||||
|
)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_toggle_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post("/dashboard/wallet/toggle-applets", {})
|
||||||
|
self.assertRedirects(
|
||||||
|
response, "/?next=/dashboard/wallet/toggle-applets",
|
||||||
|
fetch_redirect_response=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_toggle_redirects_to_wallet(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, "/dashboard/wallet/", fetch_redirect_response=False)
|
||||||
|
|
||||||
|
def test_toggle_hides_unchecked_applet(self):
|
||||||
|
self.client.post(
|
||||||
|
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
|
||||||
|
)
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=self.tokens)
|
||||||
|
self.assertFalse(ua.visible)
|
||||||
|
|
||||||
|
def test_toggle_shows_checked_applet(self):
|
||||||
|
UserApplet.objects.create(user=self.user, applet=self.balances, visible=False)
|
||||||
|
self.client.post(
|
||||||
|
"/dashboard/wallet/toggle-applets", {"applets": ["wallet-balances"]}
|
||||||
|
)
|
||||||
|
ua = UserApplet.objects.get(user=self.user, applet=self.balances)
|
||||||
|
self.assertTrue(ua.visible)
|
||||||
|
|
||||||
|
def test_toggle_htmx_returns_container_partial(self):
|
||||||
|
response = self.client.post(
|
||||||
|
"/dashboard/wallet/toggle-applets",
|
||||||
|
{"applets": ["wallet-balances"]},
|
||||||
|
HTTP_HX_REQUEST="true",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "id_wallet_applets_container")
|
||||||
9
src/apps/dashboard/tests/unit/test_templates.py
Normal file
9
src/apps/dashboard/tests/unit/test_templates.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from datetime import date
|
||||||
|
from django.test import SimpleTestCase
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
|
||||||
|
|
||||||
|
class FooterTemplateTest(SimpleTestCase):
|
||||||
|
def test_footer_shows_current_year(self):
|
||||||
|
rendered = render_to_string("core/_partials/_footer.html")
|
||||||
|
self.assertIn(str(date.today().year), rendered)
|
||||||
@@ -2,8 +2,16 @@ from django.urls import path
|
|||||||
from . import views
|
from . import views
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('new_list', views.new_list, name='new_list'),
|
path('new_note', views.new_note, name='new_note'),
|
||||||
path('<int:list_id>/', views.view_list, name='view_list'),
|
path('note/<uuid:note_id>/', views.view_note, name='view_note'),
|
||||||
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
|
path('note/<uuid:note_id>/share_note', views.share_note, name="share_note"),
|
||||||
path('<int:list_id>/share_list', views.share_list, name="share_list"),
|
path('set_palette', views.set_palette, name='set_palette'),
|
||||||
|
path('set_profile', views.set_profile, name='set_profile'),
|
||||||
|
path('users/<uuid:user_id>/', views.my_notes, name='my_notes'),
|
||||||
|
path('toggle_applets', views.toggle_applets, name="toggle_applets"),
|
||||||
|
path('wallet/', views.wallet, name='wallet'),
|
||||||
|
path('wallet/toggle-applets', views.toggle_wallet_applets, name='toggle_wallet_applets'),
|
||||||
|
path('wallet/setup-intent', views.setup_intent, name='setup_intent'),
|
||||||
|
path('wallet/save-payment-method', views.save_payment_method, name='save_payment_method'),
|
||||||
|
path('kit-bag/', views.kit_bag, name='kit_bag'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,48 +1,237 @@
|
|||||||
from django.http import HttpResponseForbidden
|
import stripe
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib import messages
|
||||||
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.db.models import Max, Q
|
||||||
|
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
|
||||||
from django.shortcuts import redirect, render
|
from django.shortcuts import redirect, render
|
||||||
from .forms import ExistingListItemForm, ItemForm
|
from django.utils import timezone
|
||||||
from .models import Item, List
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from apps.lyric.models import User
|
|
||||||
|
from apps.applets.models import Applet, UserApplet
|
||||||
|
from apps.applets.utils import applet_context
|
||||||
|
from apps.dashboard.forms import ExistingNoteItemForm, ItemForm
|
||||||
|
from apps.dashboard.models import Item, Note
|
||||||
|
from apps.lyric.models import PaymentMethod, Token, User, Wallet
|
||||||
|
|
||||||
|
|
||||||
|
APPLET_ORDER = ["wallet", "new-note", "my-notes", "username", "palette"]
|
||||||
|
UNLOCKED_PALETTES = frozenset([
|
||||||
|
"palette-default",
|
||||||
|
"palette-sepia",
|
||||||
|
"palette-oblivion-light",
|
||||||
|
"palette-monochrome-dark",
|
||||||
|
])
|
||||||
|
PALETTES = [
|
||||||
|
{"name": "palette-default", "label": "Earthman", "locked": False},
|
||||||
|
{"name": "palette-sepia", "label": "Sepia", "locked": False},
|
||||||
|
{"name": "palette-oblivion-light", "label": "Oblivion (Light)", "locked": False},
|
||||||
|
{"name": "palette-monochrome-dark", "label": "Monochrome (Dark)", "locked": False},
|
||||||
|
{"name": "palette-nirvana", "label": "Nirvana", "locked": True},
|
||||||
|
{"name": "palette-sheol", "label": "Sheol", "locked": True},
|
||||||
|
{"name": "palette-inferno", "label": "Inferno", "locked": True},
|
||||||
|
{"name": "palette-terrestre", "label": "Terrestre", "locked": True},
|
||||||
|
{"name": "palette-celestia", "label": "Celestia", "locked": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _recent_notes(user, limit=3):
|
||||||
|
return (
|
||||||
|
Note
|
||||||
|
.objects
|
||||||
|
.filter(Q(owner=user) | Q(shared_with=user))
|
||||||
|
.annotate(last_item=Max('item__id'))
|
||||||
|
.order_by('-last_item')
|
||||||
|
.distinct()[:limit]
|
||||||
|
)
|
||||||
|
|
||||||
def home_page(request):
|
def home_page(request):
|
||||||
return render(request, "apps/dashboard/home.html", {"form": ItemForm()})
|
context = {
|
||||||
|
"form": ItemForm(),
|
||||||
|
"palettes": PALETTES,
|
||||||
|
"page_class": "page-dashboard",
|
||||||
|
}
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
context["applets"] = applet_context(request.user, "dashboard")
|
||||||
|
context["recent_notes"] = _recent_notes(request.user)
|
||||||
|
return render(request, "apps/dashboard/home.html", context)
|
||||||
|
|
||||||
def new_list(request):
|
def new_note(request):
|
||||||
form = ItemForm(data=request.POST)
|
form = ItemForm(data=request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
nulist = List.objects.create()
|
nunote = Note.objects.create()
|
||||||
if request.user.is_authenticated:
|
if request.user.is_authenticated:
|
||||||
nulist.owner = request.user
|
nunote.owner = request.user
|
||||||
nulist.save()
|
nunote.save()
|
||||||
form.save(for_list=nulist)
|
form.save(for_note=nunote)
|
||||||
return redirect(nulist)
|
return redirect(nunote)
|
||||||
else:
|
else:
|
||||||
return render(request, "apps/dashboard/home.html", {"form": form})
|
context = {
|
||||||
|
"form": form,
|
||||||
|
"palettes": PALETTES,
|
||||||
|
"page_class": "page-dashboard",
|
||||||
|
}
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
context["applets"] = applet_context(request.user, "dashboard")
|
||||||
|
context["recent_notes"] = _recent_notes(request.user)
|
||||||
|
return render(request, "apps/dashboard/home.html", context)
|
||||||
|
|
||||||
def view_list(request, list_id):
|
def view_note(request, note_id):
|
||||||
our_list = List.objects.get(id=list_id)
|
our_note = Note.objects.get(id=note_id)
|
||||||
form = ExistingListItemForm(for_list=our_list)
|
|
||||||
|
if our_note.owner:
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return redirect("/")
|
||||||
|
if request.user != our_note.owner and request.user not in our_note.shared_with.all():
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
form = ExistingNoteItemForm(for_note=our_note)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
form = ExistingListItemForm(for_list=our_list, data=request.POST)
|
form = ExistingNoteItemForm(for_note=our_note, data=request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
form.save()
|
form.save()
|
||||||
return redirect(our_list)
|
return redirect(our_note)
|
||||||
return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form})
|
return render(request, "apps/dashboard/note.html", {"note": our_note, "form": form})
|
||||||
|
|
||||||
def my_lists(request, user_id):
|
def my_notes(request, user_id):
|
||||||
owner = User.objects.get(id=user_id)
|
owner = User.objects.get(id=user_id)
|
||||||
if not request.user.is_authenticated:
|
if not request.user.is_authenticated:
|
||||||
return redirect("/")
|
return redirect("/")
|
||||||
if request.user.id != owner.id:
|
if request.user.id != owner.id:
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
return render(request, "apps/dashboard/my_lists.html", {"owner": owner})
|
return render(request, "apps/dashboard/my_notes.html", {"owner": owner})
|
||||||
|
|
||||||
def share_list(request, list_id):
|
def share_note(request, note_id):
|
||||||
our_list = List.objects.get(id=list_id)
|
our_note = Note.objects.get(id=note_id)
|
||||||
try:
|
try:
|
||||||
recipient = User.objects.get(email=request.POST["recipient"])
|
recipient = User.objects.get(email=request.POST["recipient"])
|
||||||
our_list.shared_with.add(recipient)
|
if recipient == request.user:
|
||||||
|
return redirect(our_note)
|
||||||
|
our_note.shared_with.add(recipient)
|
||||||
except User.DoesNotExist:
|
except User.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
return redirect(our_list)
|
messages.success(request, "An invite has been sent if that address is registered.")
|
||||||
|
return redirect(our_note)
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def set_palette(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
palette = request.POST.get("palette", "")
|
||||||
|
if palette in UNLOCKED_PALETTES:
|
||||||
|
request.user.palette = palette
|
||||||
|
request.user.save(update_fields=["palette"])
|
||||||
|
if "application/json" in request.headers.get("Accept", ""):
|
||||||
|
return JsonResponse({"palette": request.user.palette})
|
||||||
|
return redirect("home")
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def set_profile(request):
|
||||||
|
if request.method == "POST":
|
||||||
|
username = request.POST.get("username", "")
|
||||||
|
request.user.username = username
|
||||||
|
request.user.save(update_fields=["username"])
|
||||||
|
return redirect("/")
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_applets(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
for applet in Applet.objects.filter(context="dashboard"):
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked},
|
||||||
|
)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/dashboard/_partials/_applets.html", {
|
||||||
|
"applets": applet_context(request.user, "dashboard"),
|
||||||
|
"palettes": PALETTES,
|
||||||
|
"form": ItemForm(),
|
||||||
|
"recent_notes": _recent_notes(request.user),
|
||||||
|
})
|
||||||
|
return redirect("home")
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
@ensure_csrf_cookie
|
||||||
|
def wallet(request):
|
||||||
|
return render(request, "apps/dashboard/wallet.html", {
|
||||||
|
"wallet": request.user.wallet,
|
||||||
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
||||||
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
|
"free_tokens": list(request.user.tokens.filter(
|
||||||
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
|
).order_by("expires_at")),
|
||||||
|
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||||
|
"free_count": request.user.tokens.filter(
|
||||||
|
token_type=Token.FREE, expires_at__gt=timezone.now()
|
||||||
|
).count(),
|
||||||
|
"tithe_count": request.user.tokens.filter(token_type=Token.TITHE).count(),
|
||||||
|
"applets": applet_context(request.user, "wallet"),
|
||||||
|
"page_class": "page-wallet",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def kit_bag(request):
|
||||||
|
tokens = list(request.user.tokens.all())
|
||||||
|
free_tokens = sorted(
|
||||||
|
[t for t in tokens if t.token_type == Token.FREE and t.expires_at and t.expires_at > timezone.now()],
|
||||||
|
key=lambda t: t.expires_at,
|
||||||
|
)
|
||||||
|
tithe_tokens = [t for t in tokens if t.token_type == Token.TITHE]
|
||||||
|
return render(request, "core/_partials/_kit_bag_panel.html", {
|
||||||
|
"equipped_trinket": request.user.equipped_trinket,
|
||||||
|
"free_token": free_tokens[0] if free_tokens else None,
|
||||||
|
"free_count": len(free_tokens),
|
||||||
|
"tithe_token": tithe_tokens[0] if tithe_tokens else None,
|
||||||
|
"tithe_count": len(tithe_tokens),
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def toggle_wallet_applets(request):
|
||||||
|
checked = request.POST.getlist("applets")
|
||||||
|
for applet in Applet.objects.filter(context="wallet"):
|
||||||
|
UserApplet.objects.update_or_create(
|
||||||
|
user=request.user,
|
||||||
|
applet=applet,
|
||||||
|
defaults={"visible": applet.slug in checked},
|
||||||
|
)
|
||||||
|
if request.headers.get("HX-Request"):
|
||||||
|
return render(request, "apps/wallet/_partials/_applets.html", {
|
||||||
|
"applets": applet_context(request.user, "wallet"),
|
||||||
|
"wallet": request.user.wallet,
|
||||||
|
"pass_token": request.user.tokens.filter(token_type=Token.PASS).first(),
|
||||||
|
"coin": request.user.tokens.filter(token_type=Token.COIN).first(),
|
||||||
|
"free_tokens": list(request.user.tokens.filter(token_type=Token.FREE)),
|
||||||
|
"tithe_tokens": list(request.user.tokens.filter(token_type=Token.TITHE)),
|
||||||
|
})
|
||||||
|
return redirect("wallet")
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def setup_intent(request):
|
||||||
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
user = request.user
|
||||||
|
if not user.stripe_customer_id:
|
||||||
|
customer = stripe.Customer.create(email=user.email)
|
||||||
|
user.stripe_customer_id = customer.id
|
||||||
|
user.save(update_fields=["stripe_customer_id"])
|
||||||
|
intent = stripe.SetupIntent.create(customer=user.stripe_customer_id)
|
||||||
|
return JsonResponse({
|
||||||
|
"client_secret": intent.client_secret,
|
||||||
|
"publishable_key": settings.STRIPE_PUBLISHABLE_KEY,
|
||||||
|
})
|
||||||
|
|
||||||
|
@login_required(login_url="/")
|
||||||
|
def save_payment_method(request):
|
||||||
|
stripe.api_key = settings.STRIPE_SECRET_KEY
|
||||||
|
pm_id = request.POST.get("payment_method_id")
|
||||||
|
pm = stripe.PaymentMethod.retrieve(pm_id)
|
||||||
|
stripe.PaymentMethod.attach(pm_id, customer=request.user.stripe_customer_id)
|
||||||
|
PaymentMethod.objects.create(
|
||||||
|
user=request.user,
|
||||||
|
stripe_pm_id=pm_id,
|
||||||
|
last4=pm.card.last4,
|
||||||
|
brand=pm.card.brand,
|
||||||
|
)
|
||||||
|
return JsonResponse({"last4": pm.card.last4, "brand": pm.card.brand})
|
||||||
|
|||||||
0
src/apps/drama/__init__.py
Normal file
0
src/apps/drama/__init__.py
Normal file
19
src/apps/drama/admin.py
Normal file
19
src/apps/drama/admin.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(GameEvent)
|
||||||
|
class GameEventAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("timestamp", "room", "actor", "verb")
|
||||||
|
list_filter = ("verb",)
|
||||||
|
readonly_fields = ("room", "actor", "verb", "data", "timestamp")
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_delete_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return False
|
||||||
6
src/apps/drama/apps.py
Normal file
6
src/apps/drama/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class DramaConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'apps.drama'
|
||||||
32
src/apps/drama/migrations/0001_initial.py
Normal file
32
src/apps/drama/migrations/0001_initial.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-19 18:34
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0006_table_status_and_table_seat'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GameEvent',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('verb', models.CharField(choices=[('room_created', 'Room created'), ('slot_reserved', 'Gate slot reserved'), ('slot_filled', 'Gate slot filled'), ('slot_returned', 'Gate slot returned'), ('slot_released', 'Gate slot released'), ('invite_sent', 'Invite sent'), ('role_select_started', 'Role select started'), ('role_selected', 'Role selected'), ('roles_revealed', 'Roles revealed')], max_length=30)),
|
||||||
|
('data', models.JSONField(default=dict)),
|
||||||
|
('timestamp', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('actor', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='game_events', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='events', to='epic.room')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['timestamp'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
30
src/apps/drama/migrations/0002_scrollposition.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-24 21:36
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('drama', '0001_initial'),
|
||||||
|
('epic', '0006_table_status_and_table_seat'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='ScrollPosition',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('position', models.PositiveIntegerField(default=0)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to='epic.room')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scroll_positions', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'unique_together': {('user', 'room')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/apps/drama/migrations/__init__.py
Normal file
0
src/apps/drama/migrations/__init__.py
Normal file
107
src/apps/drama/models.py
Normal file
107
src/apps/drama/models.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class GameEvent(models.Model):
|
||||||
|
# Gate phase
|
||||||
|
ROOM_CREATED = "room_created"
|
||||||
|
SLOT_RESERVED = "slot_reserved"
|
||||||
|
SLOT_FILLED = "slot_filled"
|
||||||
|
SLOT_RETURNED = "slot_returned"
|
||||||
|
SLOT_RELEASED = "slot_released"
|
||||||
|
INVITE_SENT = "invite_sent"
|
||||||
|
# Role Select phase
|
||||||
|
ROLE_SELECT_STARTED = "role_select_started"
|
||||||
|
ROLE_SELECTED = "role_selected"
|
||||||
|
ROLES_REVEALED = "roles_revealed"
|
||||||
|
|
||||||
|
VERB_CHOICES = [
|
||||||
|
(ROOM_CREATED, "Room created"),
|
||||||
|
(SLOT_RESERVED, "Gate slot reserved"),
|
||||||
|
(SLOT_FILLED, "Gate slot filled"),
|
||||||
|
(SLOT_RETURNED, "Gate slot returned"),
|
||||||
|
(SLOT_RELEASED, "Gate slot released"),
|
||||||
|
(INVITE_SENT, "Invite sent"),
|
||||||
|
(ROLE_SELECT_STARTED, "Role select started"),
|
||||||
|
(ROLE_SELECTED, "Role selected"),
|
||||||
|
(ROLES_REVEALED, "Roles revealed"),
|
||||||
|
]
|
||||||
|
|
||||||
|
room = models.ForeignKey(
|
||||||
|
"epic.Room", on_delete=models.CASCADE, related_name="events",
|
||||||
|
)
|
||||||
|
actor = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="game_events",
|
||||||
|
)
|
||||||
|
verb = models.CharField(max_length=30, choices=VERB_CHOICES)
|
||||||
|
data = models.JSONField(default=dict)
|
||||||
|
timestamp = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ["timestamp"]
|
||||||
|
|
||||||
|
def to_prose(self):
|
||||||
|
"""Return a human-readable action description (actor rendered separately in template)."""
|
||||||
|
d = self.data
|
||||||
|
if self.verb == self.SLOT_FILLED:
|
||||||
|
_token_names = {
|
||||||
|
"coin": "Coin-on-a-String", "Free": "Free Token",
|
||||||
|
"tithe": "Tithe Token", "pass": "Backstage Pass", "carte": "Carte Blanche",
|
||||||
|
}
|
||||||
|
code = d.get("token_type", "token")
|
||||||
|
token = d.get("token_display") or _token_names.get(code, code)
|
||||||
|
days = d.get("renewal_days", 7)
|
||||||
|
slot = d.get("slot_number", "?")
|
||||||
|
return f"deposits a {token} for slot {slot} ({days} days)"
|
||||||
|
if self.verb == self.SLOT_RESERVED:
|
||||||
|
return "reserves a seat"
|
||||||
|
if self.verb == self.SLOT_RETURNED:
|
||||||
|
return "withdraws from the gate"
|
||||||
|
if self.verb == self.SLOT_RELEASED:
|
||||||
|
return f"releases slot {d.get('slot_number', '?')}"
|
||||||
|
if self.verb == self.ROOM_CREATED:
|
||||||
|
return "opens this room"
|
||||||
|
if self.verb == self.INVITE_SENT:
|
||||||
|
return "sends an invitation"
|
||||||
|
if self.verb == self.ROLE_SELECT_STARTED:
|
||||||
|
return "Role selection begins"
|
||||||
|
if self.verb == self.ROLE_SELECTED:
|
||||||
|
_role_names = {
|
||||||
|
"PC": "Player", "BC": "Builder", "SC": "Shepherd",
|
||||||
|
"AC": "Alchemist", "NC": "Narrator", "EC": "Economist",
|
||||||
|
}
|
||||||
|
code = d.get("role", "?")
|
||||||
|
role = d.get("role_display") or _role_names.get(code, code)
|
||||||
|
return f"elects to start as {role}"
|
||||||
|
if self.verb == self.ROLES_REVEALED:
|
||||||
|
return "All roles assigned"
|
||||||
|
return self.verb
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
actor = self.actor.email if self.actor else "system"
|
||||||
|
return f"[{self.timestamp:%Y-%m-%d %H:%M}] {actor} → {self.verb}"
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollPosition(models.Model):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE,
|
||||||
|
related_name="scroll_positions",
|
||||||
|
)
|
||||||
|
room = models.ForeignKey(
|
||||||
|
"epic.Room", on_delete=models.CASCADE,
|
||||||
|
related_name="scroll_positions",
|
||||||
|
)
|
||||||
|
position = models.PositiveIntegerField(default=0)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = [("user", "room")]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.email} @ {self.room.name}: {self.position}px"
|
||||||
|
|
||||||
|
|
||||||
|
def record(room, verb, actor=None, **data):
|
||||||
|
"""Record a game event in the drama log."""
|
||||||
|
return GameEvent.objects.create(room=room, actor=actor, verb=verb, data=data)
|
||||||
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
0
src/apps/drama/tests/integrated/__init__.py
Normal file
77
src/apps/drama/tests/integrated/test_views.py
Normal file
77
src/apps/drama/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent
|
||||||
|
from apps.epic.models import GateSlot, Room, TableSeat
|
||||||
|
from apps.lyric.models import Token, User
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmTokenRecordsSlotFilledTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
self.token = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.slot.gamer = self.user
|
||||||
|
self.slot.status = GateSlot.RESERVED
|
||||||
|
self.slot.reserved_at = timezone.now()
|
||||||
|
self.slot.save()
|
||||||
|
|
||||||
|
def test_confirm_token_records_slot_filled_event(self):
|
||||||
|
session = self.client.session
|
||||||
|
session["kit_token_id"] = str(self.token.id)
|
||||||
|
session.save()
|
||||||
|
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||||
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.SLOT_FILLED)
|
||||||
|
self.assertEqual(event.actor, self.user)
|
||||||
|
self.assertEqual(event.data["slot_number"], 1)
|
||||||
|
self.assertEqual(event.data["token_type"], Token.TITHE)
|
||||||
|
|
||||||
|
def test_no_event_recorded_if_no_reserved_slot(self):
|
||||||
|
self.slot.gamer = None
|
||||||
|
self.slot.status = GateSlot.EMPTY
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(reverse("epic:confirm_token", args=[self.room.id]))
|
||||||
|
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.SLOT_FILLED).count(), 0)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectRoleRecordsRoleSelectedTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="player@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
self.room = Room.objects.create(
|
||||||
|
name="Role Room", owner=self.user, table_status=Room.ROLE_SELECT
|
||||||
|
)
|
||||||
|
self.seat = TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=self.user, slot_number=1
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_select_role_records_role_selected_event(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", args=[self.room.id]),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
event = GameEvent.objects.get(room=self.room, verb=GameEvent.ROLE_SELECTED)
|
||||||
|
self.assertEqual(event.actor, self.user)
|
||||||
|
self.assertEqual(event.data["role"], "PC")
|
||||||
|
self.assertEqual(event.data["slot_number"], 1)
|
||||||
|
|
||||||
|
def test_roles_revealed_event_recorded_when_all_seats_assigned(self):
|
||||||
|
# Only one seat — assigning it triggers roles_revealed
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", args=[self.room.id]),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
GameEvent.objects.filter(room=self.room, verb=GameEvent.ROLES_REVEALED).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_event_if_role_already_taken(self):
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=self.user, slot_number=2, role="PC")
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", args=[self.room.id]),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.assertEqual(GameEvent.objects.filter(verb=GameEvent.ROLE_SELECTED).count(), 0)
|
||||||
0
src/apps/drama/tests/unit/__init__.py
Normal file
0
src/apps/drama/tests/unit/__init__.py
Normal file
73
src/apps/drama/tests/unit/test_models.py
Normal file
73
src/apps/drama/tests/unit/test_models.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.db import IntegrityError
|
||||||
|
|
||||||
|
from apps.drama.models import GameEvent, ScrollPosition, record
|
||||||
|
from apps.epic.models import Room
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class GameEventModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="actor@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def test_record_creates_game_event(self):
|
||||||
|
event = record(self.room, GameEvent.SLOT_FILLED, actor=self.user, slot_number=1, token_type="tithe")
|
||||||
|
self.assertEqual(GameEvent.objects.count(), 1)
|
||||||
|
self.assertEqual(event.room, self.room)
|
||||||
|
self.assertEqual(event.actor, self.user)
|
||||||
|
self.assertEqual(event.verb, GameEvent.SLOT_FILLED)
|
||||||
|
self.assertEqual(event.data, {"slot_number": 1, "token_type": "tithe"})
|
||||||
|
|
||||||
|
def test_record_without_actor(self):
|
||||||
|
event = record(self.room, GameEvent.ROOM_CREATED)
|
||||||
|
self.assertIsNone(event.actor)
|
||||||
|
self.assertEqual(event.verb, GameEvent.ROOM_CREATED)
|
||||||
|
|
||||||
|
def test_events_ordered_by_timestamp(self):
|
||||||
|
record(self.room, GameEvent.ROOM_CREATED)
|
||||||
|
record(self.room, GameEvent.SLOT_RESERVED, actor=self.user)
|
||||||
|
record(self.room, GameEvent.SLOT_FILLED, actor=self.user)
|
||||||
|
verbs = list(GameEvent.objects.values_list("verb", flat=True))
|
||||||
|
self.assertEqual(verbs, [
|
||||||
|
GameEvent.ROOM_CREATED,
|
||||||
|
GameEvent.SLOT_RESERVED,
|
||||||
|
GameEvent.SLOT_FILLED,
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_str_includes_actor_and_verb(self):
|
||||||
|
event = record(self.room, GameEvent.ROLE_SELECTED, actor=self.user, role="PC")
|
||||||
|
self.assertIn("actor@test.io", str(event))
|
||||||
|
self.assertIn(GameEvent.ROLE_SELECTED, str(event))
|
||||||
|
|
||||||
|
def test_str_without_actor_shows_system(self):
|
||||||
|
event = record(self.room, GameEvent.ROLES_REVEALED)
|
||||||
|
self.assertIn("system", str(event))
|
||||||
|
|
||||||
|
|
||||||
|
class ScrollPositionModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="reader@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.user)
|
||||||
|
|
||||||
|
def test_can_save_scroll_position(self):
|
||||||
|
sp = ScrollPosition.objects.create(user=self.user, room=self.room, position=150)
|
||||||
|
self.assertEqual(ScrollPosition.objects.count(), 1)
|
||||||
|
self.assertEqual(sp.position, 150)
|
||||||
|
|
||||||
|
def test_default_position_is_zero(self):
|
||||||
|
sp = ScrollPosition.objects.create(user=self.user, room=self.room)
|
||||||
|
self.assertEqual(sp.position, 0)
|
||||||
|
|
||||||
|
def test_unique_per_user_and_room(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||||
|
with self.assertRaises(IntegrityError):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=100)
|
||||||
|
|
||||||
|
def test_upsert_updates_existing_position(self):
|
||||||
|
ScrollPosition.objects.create(user=self.user, room=self.room, position=50)
|
||||||
|
ScrollPosition.objects.update_or_create(
|
||||||
|
user=self.user, room=self.room,
|
||||||
|
defaults={"position": 200},
|
||||||
|
)
|
||||||
|
self.assertEqual(ScrollPosition.objects.get(user=self.user, room=self.room).position, 200)
|
||||||
0
src/apps/epic/__init__.py
Normal file
0
src/apps/epic/__init__.py
Normal file
3
src/apps/epic/admin.py
Normal file
3
src/apps/epic/admin.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
# Register your models here.
|
||||||
5
src/apps/epic/apps.py
Normal file
5
src/apps/epic/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EpicConfig(AppConfig):
|
||||||
|
name = 'apps.epic'
|
||||||
27
src/apps/epic/consumers.py
Normal file
27
src/apps/epic/consumers.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from channels.generic.websocket import AsyncJsonWebsocketConsumer
|
||||||
|
|
||||||
|
|
||||||
|
class RoomConsumer(AsyncJsonWebsocketConsumer):
|
||||||
|
async def connect(self):
|
||||||
|
self.room_id = self.scope["url_route"]["kwargs"]["room_id"]
|
||||||
|
self.group_name = f"room_{self.room_id}"
|
||||||
|
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||||
|
await self.accept()
|
||||||
|
|
||||||
|
async def disconnect(self, close_code):
|
||||||
|
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||||
|
|
||||||
|
async def receive_json(self, content):
|
||||||
|
pass # handlers added as events introduced
|
||||||
|
|
||||||
|
async def gate_update(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def role_select_start(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def turn_changed(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
|
|
||||||
|
async def roles_revealed(self, event):
|
||||||
|
await self.send_json(event)
|
||||||
0
src/apps/epic/forms.py
Normal file
0
src/apps/epic/forms.py
Normal file
45
src/apps/epic/migrations/0001_initial.py
Normal file
45
src/apps/epic/migrations/0001_initial.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-12 19:46
|
||||||
|
|
||||||
|
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='Room',
|
||||||
|
fields=[
|
||||||
|
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
('name', models.CharField(max_length=200)),
|
||||||
|
('visibility', models.CharField(choices=[('PRIVATE', 'Private'), ('PUBLIC', 'Public'), ('INVITE ONLY', 'Invite Only')], default='PRIVATE', max_length=20)),
|
||||||
|
('gate_status', models.CharField(choices=[('GATHERING', 'Gathering'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20)),
|
||||||
|
('renewal_period', models.DurationField(blank=True, null=True)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('board_state', models.JSONField(default=dict)),
|
||||||
|
('seed_count', models.IntegerField(default=12)),
|
||||||
|
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_rooms', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='GateSlot',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slot_number', models.IntegerField()),
|
||||||
|
('status', models.CharField(choices=[('EMPTY', 'Empty'), ('RESERVED', 'Reserved'), ('FILLED', 'Filled')], default='EMPTY', max_length=10)),
|
||||||
|
('reserved_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('filled_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('funded_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='funded_slots', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='gate_slots', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gate_slots', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
19
src/apps/epic/migrations/0002_alter_room_renewal_period.py
Normal file
19
src/apps/epic/migrations/0002_alter_room_renewal_period.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-13 20:32
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='renewal_period',
|
||||||
|
field=models.DurationField(blank=True, default=datetime.timedelta(days=7), null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
27
src/apps/epic/migrations/0003_roominvite.py
Normal file
27
src/apps/epic/migrations/0003_roominvite.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-13 22:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0002_alter_room_renewal_period'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='RoomInvite',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('invitee_email', models.EmailField(max_length=254)),
|
||||||
|
('status', models.CharField(choices=[('PENDING', 'Pending'), ('ACCEPTED', 'Accepted'), ('DECLINED', 'Declined')], default='PENDING', max_length=10)),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_invites', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invites', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
18
src/apps/epic/migrations/0004_alter_room_gate_status.py
Normal file
18
src/apps/epic/migrations/0004_alter_room_gate_status.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-15 00:32
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0003_roominvite'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='room',
|
||||||
|
name='gate_status',
|
||||||
|
field=models.CharField(choices=[('GATHERING', 'GATHERING GAMERS'), ('OPEN', 'Open'), ('RENEWAL_DUE', 'Renewal Due')], default='GATHERING', max_length=20),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0004_alter_room_gate_status'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='gateslot',
|
||||||
|
name='debited_token_type',
|
||||||
|
field=models.CharField(max_length=8, null=True, blank=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='gateslot',
|
||||||
|
name='debited_token_expires_at',
|
||||||
|
field=models.DateTimeField(null=True, blank=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
33
src/apps/epic/migrations/0006_table_status_and_table_seat.py
Normal file
33
src/apps/epic/migrations/0006_table_status_and_table_seat.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-03-17 00:14
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('epic', '0005_gateslot_debited_token_fields'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='room',
|
||||||
|
name='table_status',
|
||||||
|
field=models.CharField(blank=True, choices=[('ROLE_SELECT', 'Role Select'), ('SIG_SELECT', 'Significator Select'), ('IN_GAME', 'In Game')], max_length=20, null=True),
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='TableSeat',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('slot_number', models.IntegerField()),
|
||||||
|
('role', models.CharField(blank=True, choices=[('PC', 'Player'), ('BC', 'Builder'), ('SC', 'Shepherd'), ('AC', 'Alchemist'), ('NC', 'Narrator'), ('EC', 'Economist')], max_length=2, null=True)),
|
||||||
|
('role_revealed', models.BooleanField(default=False)),
|
||||||
|
('seat_position', models.IntegerField(blank=True, null=True)),
|
||||||
|
('gamer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='table_seats', to=settings.AUTH_USER_MODEL)),
|
||||||
|
('room', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='table_seats', to='epic.room')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
0
src/apps/epic/migrations/__init__.py
Normal file
0
src/apps/epic/migrations/__init__.py
Normal file
175
src/apps/epic/models.py
Normal file
175
src/apps/epic/models.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import uuid
|
||||||
|
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.db import models
|
||||||
|
from django.db.models.signals import post_save
|
||||||
|
from django.dispatch import receiver
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.lyric.models import Token
|
||||||
|
|
||||||
|
|
||||||
|
class Room(models.Model):
|
||||||
|
GATHERING = "GATHERING"
|
||||||
|
OPEN = "OPEN"
|
||||||
|
RENEWAL_DUE = "RENEWAL_DUE"
|
||||||
|
GATE_STATUS_CHOICES = [
|
||||||
|
(GATHERING, "GATHERING GAMERS"),
|
||||||
|
(OPEN, "Open"),
|
||||||
|
(RENEWAL_DUE, "Renewal Due"),
|
||||||
|
]
|
||||||
|
|
||||||
|
PRIVATE = "PRIVATE"
|
||||||
|
PUBLIC = "PUBLIC"
|
||||||
|
INVITE_ONLY = "INVITE ONLY"
|
||||||
|
VISIBILITY_CHOICES = [
|
||||||
|
(PRIVATE, "Private"),
|
||||||
|
(PUBLIC, "Public"),
|
||||||
|
(INVITE_ONLY, "Invite Only"),
|
||||||
|
]
|
||||||
|
|
||||||
|
ROLE_SELECT = "ROLE_SELECT"
|
||||||
|
SIG_SELECT = "SIG_SELECT"
|
||||||
|
IN_GAME = "IN_GAME"
|
||||||
|
TABLE_STATUS_CHOICES = [
|
||||||
|
(ROLE_SELECT, "Role Select"),
|
||||||
|
(SIG_SELECT, "Significator Select"),
|
||||||
|
(IN_GAME, "In Game"),
|
||||||
|
]
|
||||||
|
|
||||||
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
|
name = models.CharField(max_length=200)
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="owned_rooms"
|
||||||
|
)
|
||||||
|
visibility = models.CharField(max_length=20, choices=VISIBILITY_CHOICES, default=PRIVATE)
|
||||||
|
gate_status = models.CharField(max_length=20, choices=GATE_STATUS_CHOICES, default=GATHERING)
|
||||||
|
table_status = models.CharField(
|
||||||
|
max_length=20, choices=TABLE_STATUS_CHOICES, null=True, blank=True
|
||||||
|
)
|
||||||
|
renewal_period = models.DurationField(null=True, blank=True, default=timedelta(days=7))
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
board_state = models.JSONField(default=dict)
|
||||||
|
seed_count = models.IntegerField(default=12)
|
||||||
|
|
||||||
|
|
||||||
|
class GateSlot(models.Model):
|
||||||
|
EMPTY = "EMPTY"
|
||||||
|
RESERVED = "RESERVED"
|
||||||
|
FILLED = "FILLED"
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(EMPTY, "Empty"),
|
||||||
|
(RESERVED, "Reserved"),
|
||||||
|
(FILLED, "Filled"),
|
||||||
|
]
|
||||||
|
|
||||||
|
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="gate_slots")
|
||||||
|
slot_number = models.IntegerField()
|
||||||
|
gamer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="gate_slots"
|
||||||
|
)
|
||||||
|
funded_by = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="funded_slots"
|
||||||
|
)
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=EMPTY)
|
||||||
|
reserved_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
filled_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
debited_token_type = models.CharField(max_length=8, null=True, blank=True)
|
||||||
|
debited_token_expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomInvite(models.Model):
|
||||||
|
PENDING = "PENDING"
|
||||||
|
ACCEPTED = "ACCEPTED"
|
||||||
|
DECLINED = "DECLINED"
|
||||||
|
STATUS_CHOICES = [
|
||||||
|
(PENDING, "Pending"),
|
||||||
|
(ACCEPTED, "Accepted"),
|
||||||
|
(DECLINED, "Declined"),
|
||||||
|
]
|
||||||
|
|
||||||
|
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="invites")
|
||||||
|
inviter = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="sent_invites"
|
||||||
|
)
|
||||||
|
invitee_email = models.EmailField()
|
||||||
|
status = models.CharField(max_length=10, choices=STATUS_CHOICES, default=PENDING)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
|
||||||
|
@receiver(post_save, sender=Room)
|
||||||
|
def create_gate_slots(sender, instance, created, **kwargs):
|
||||||
|
if created:
|
||||||
|
for i in range(1, 7):
|
||||||
|
GateSlot.objects.create(room=instance, slot_number=i)
|
||||||
|
|
||||||
|
|
||||||
|
def select_token(user):
|
||||||
|
if user.is_staff:
|
||||||
|
pass_token = user.tokens.filter(token_type=Token.PASS).first()
|
||||||
|
if pass_token:
|
||||||
|
return pass_token
|
||||||
|
coin = user.tokens.filter(token_type=Token.COIN, current_room__isnull=True).first()
|
||||||
|
if coin:
|
||||||
|
return coin
|
||||||
|
free = user.tokens.filter(
|
||||||
|
token_type=Token.FREE,
|
||||||
|
expires_at__gt=timezone.now(),
|
||||||
|
).order_by("expires_at").first()
|
||||||
|
if free:
|
||||||
|
return free
|
||||||
|
return user.tokens.filter(token_type=Token.TITHE).first()
|
||||||
|
|
||||||
|
|
||||||
|
def debit_token(user, slot, token):
|
||||||
|
slot.debited_token_type = token.token_type
|
||||||
|
if token.token_type == Token.COIN:
|
||||||
|
token.current_room = slot.room
|
||||||
|
period = slot.room.renewal_period or timedelta(days=7)
|
||||||
|
token.next_ready_at = timezone.now() + period
|
||||||
|
token.save()
|
||||||
|
elif token.token_type == Token.CARTE:
|
||||||
|
pass # current_room already set in drop_token; token not consumed
|
||||||
|
elif token.token_type != Token.PASS:
|
||||||
|
slot.debited_token_expires_at = token.expires_at
|
||||||
|
token.delete()
|
||||||
|
slot.gamer = user
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.filled_at = timezone.now()
|
||||||
|
slot.save()
|
||||||
|
|
||||||
|
room = slot.room
|
||||||
|
if not room.gate_slots.filter(status=GateSlot.EMPTY).exists():
|
||||||
|
room.gate_status = Room.OPEN
|
||||||
|
room.save()
|
||||||
|
|
||||||
|
|
||||||
|
class TableSeat(models.Model):
|
||||||
|
PC = "PC"
|
||||||
|
BC = "BC"
|
||||||
|
SC = "SC"
|
||||||
|
AC = "AC"
|
||||||
|
NC = "NC"
|
||||||
|
EC = "EC"
|
||||||
|
ROLE_CHOICES = [
|
||||||
|
(PC, "Player"),
|
||||||
|
(BC, "Builder"),
|
||||||
|
(SC, "Shepherd"),
|
||||||
|
(AC, "Alchemist"),
|
||||||
|
(NC, "Narrator"),
|
||||||
|
(EC, "Economist"),
|
||||||
|
]
|
||||||
|
PARTNER_MAP = {PC: BC, BC: PC, SC: AC, AC: SC, NC: EC, EC: NC}
|
||||||
|
|
||||||
|
room = models.ForeignKey(Room, on_delete=models.CASCADE, related_name="table_seats")
|
||||||
|
gamer = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL, null=True, blank=True,
|
||||||
|
on_delete=models.SET_NULL, related_name="table_seats"
|
||||||
|
)
|
||||||
|
slot_number = models.IntegerField()
|
||||||
|
role = models.CharField(max_length=2, choices=ROLE_CHOICES, null=True, blank=True)
|
||||||
|
role_revealed = models.BooleanField(default=False)
|
||||||
|
seat_position = models.IntegerField(null=True, blank=True)
|
||||||
8
src/apps/epic/routing.py
Normal file
8
src/apps/epic/routing.py
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import consumers
|
||||||
|
|
||||||
|
|
||||||
|
websocket_urlpatterns = [
|
||||||
|
path('ws/room/<uuid:room_id>/', consumers.RoomConsumer.as_asgi()),
|
||||||
|
]
|
||||||
12
src/apps/epic/static/apps/epic/gatekeeper.js
Normal file
12
src/apps/epic/static/apps/epic/gatekeeper.js
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
(function () {
|
||||||
|
window.addEventListener('room:gate_update', function () {
|
||||||
|
const wrapper = document.getElementById('id_gate_wrapper');
|
||||||
|
if (!wrapper) return;
|
||||||
|
|
||||||
|
fetch(wrapper.dataset.gateStatusUrl)
|
||||||
|
.then(function (r) { return r.text(); })
|
||||||
|
.then(function (html) {
|
||||||
|
wrapper.outerHTML = html;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}());
|
||||||
204
src/apps/epic/static/apps/epic/role-select.js
Normal file
204
src/apps/epic/static/apps/epic/role-select.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
var RoleSelect = (function () {
|
||||||
|
var ROLES = [
|
||||||
|
{ code: "PC", name: "Player", element: "Fire" },
|
||||||
|
{ code: "BC", name: "Builder", element: "Stone" },
|
||||||
|
{ code: "SC", name: "Shepherd", element: "Air" },
|
||||||
|
{ code: "AC", name: "Alchemist", element: "Water" },
|
||||||
|
{ code: "NC", name: "Narrator", element: "Time" },
|
||||||
|
{ code: "EC", name: "Economist", element: "Space" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function getSelectRoleUrl() {
|
||||||
|
var el = document.querySelector("[data-select-role-url]");
|
||||||
|
return el ? el.dataset.selectRoleUrl : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCsrf() {
|
||||||
|
var m = document.cookie.match(/csrftoken=([^;]+)/);
|
||||||
|
return m ? m[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeFan() {
|
||||||
|
var backdrop = document.querySelector(".role-select-backdrop");
|
||||||
|
if (backdrop) backdrop.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectRole(roleCode, cardEl) {
|
||||||
|
var invCard = cardEl.cloneNode(true);
|
||||||
|
invCard.classList.add("flipped");
|
||||||
|
// strip old event listeners from the clone by replacing with a clean copy
|
||||||
|
var clean = invCard.cloneNode(true);
|
||||||
|
|
||||||
|
closeFan();
|
||||||
|
|
||||||
|
var invSlot = document.getElementById("id_inv_role_card");
|
||||||
|
if (invSlot) invSlot.appendChild(clean);
|
||||||
|
|
||||||
|
// Immediately lock the stack — do not wait for WS turn_changed
|
||||||
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
|
if (stack) {
|
||||||
|
stack.dataset.state = "ineligible";
|
||||||
|
stack.removeEventListener("click", openFan);
|
||||||
|
var current = stack.dataset.starterRoles;
|
||||||
|
stack.dataset.starterRoles = current ? current + "," + roleCode : roleCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
var url = getSelectRoleUrl();
|
||||||
|
if (!url) return;
|
||||||
|
fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
"X-CSRFToken": getCsrf(),
|
||||||
|
},
|
||||||
|
body: "role=" + encodeURIComponent(roleCode),
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
// Server rejected (role already taken) — undo optimistic update
|
||||||
|
if (invSlot && invSlot.contains(clean)) invSlot.removeChild(clean);
|
||||||
|
if (stack) {
|
||||||
|
stack.dataset.starterRoles = stack.dataset.starterRoles
|
||||||
|
.split(",").filter(function (r) { return r.trim() !== roleCode; }).join(",");
|
||||||
|
}
|
||||||
|
openFan();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStarterRoles() {
|
||||||
|
var stack = document.querySelector(".card-stack[data-starter-roles]");
|
||||||
|
if (!stack) return [];
|
||||||
|
var raw = stack.dataset.starterRoles;
|
||||||
|
return raw ? raw.split(",").map(function (s) { return s.trim(); }) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFan() {
|
||||||
|
if (document.querySelector(".role-select-backdrop")) return;
|
||||||
|
|
||||||
|
var taken = getStarterRoles();
|
||||||
|
var available = ROLES.filter(function (r) { return taken.indexOf(r.code) === -1; });
|
||||||
|
|
||||||
|
var backdrop = document.createElement("div");
|
||||||
|
backdrop.className = "role-select-backdrop";
|
||||||
|
|
||||||
|
var modal = document.createElement("div");
|
||||||
|
modal.id = "id_role_select";
|
||||||
|
|
||||||
|
available.forEach(function (role) {
|
||||||
|
var card = document.createElement("div");
|
||||||
|
card.className = "card";
|
||||||
|
card.dataset.role = role.code;
|
||||||
|
|
||||||
|
var back = document.createElement("div");
|
||||||
|
back.className = "card-back";
|
||||||
|
back.textContent = "?";
|
||||||
|
|
||||||
|
var front = document.createElement("div");
|
||||||
|
front.className = "card-front";
|
||||||
|
front.innerHTML = '<div class="card-role-name">' + role.name + "</div>";
|
||||||
|
|
||||||
|
card.appendChild(back);
|
||||||
|
card.appendChild(front);
|
||||||
|
|
||||||
|
card.addEventListener("mouseenter", function () {
|
||||||
|
card.classList.add("flipped");
|
||||||
|
});
|
||||||
|
card.addEventListener("mouseleave", function () {
|
||||||
|
if (!card.classList.contains("guard-active")) {
|
||||||
|
card.classList.remove("flipped");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
card.addEventListener("click", function (e) {
|
||||||
|
e.stopPropagation();
|
||||||
|
card.classList.add("flipped");
|
||||||
|
card.classList.add("guard-active");
|
||||||
|
window.showGuard(
|
||||||
|
card,
|
||||||
|
"Start round 1 as<br>" + role.name + " (" + role.code + ") …?",
|
||||||
|
function () { // confirm
|
||||||
|
card.classList.remove("guard-active");
|
||||||
|
selectRole(role.code, card);
|
||||||
|
},
|
||||||
|
function () { // dismiss (NVM / outside click)
|
||||||
|
card.classList.remove("guard-active");
|
||||||
|
card.classList.remove("flipped");
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
modal.appendChild(card);
|
||||||
|
});
|
||||||
|
|
||||||
|
backdrop.appendChild(modal);
|
||||||
|
backdrop.addEventListener("click", closeFan);
|
||||||
|
document.body.appendChild(backdrop);
|
||||||
|
}
|
||||||
|
|
||||||
|
function init() {
|
||||||
|
var stack = document.querySelector(".card-stack[data-state='eligible']");
|
||||||
|
if (!stack) return;
|
||||||
|
stack.addEventListener("click", openFan);
|
||||||
|
}
|
||||||
|
|
||||||
|
var _reload = function () { window.location.reload(); };
|
||||||
|
|
||||||
|
function handleRolesRevealed() {
|
||||||
|
_reload();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTurnChanged(event) {
|
||||||
|
var active = String(event.detail.active_slot);
|
||||||
|
var invSlot = document.getElementById("id_inv_role_card");
|
||||||
|
if (invSlot) invSlot.innerHTML = "";
|
||||||
|
|
||||||
|
var stack = document.querySelector(".card-stack[data-user-slots]");
|
||||||
|
if (stack) {
|
||||||
|
// Sync starter-roles from server so the fan reflects actual DB state
|
||||||
|
if (event.detail.starter_roles) {
|
||||||
|
stack.dataset.starterRoles = event.detail.starter_roles.join(",");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update eligibility and ban icon together
|
||||||
|
var userSlots = stack.dataset.userSlots
|
||||||
|
? stack.dataset.userSlots.split(",") : [];
|
||||||
|
if (userSlots.indexOf(active) !== -1) {
|
||||||
|
stack.dataset.state = "eligible";
|
||||||
|
var ban = stack.querySelector(".fa-ban");
|
||||||
|
if (ban) ban.remove();
|
||||||
|
stack.removeEventListener("click", openFan);
|
||||||
|
stack.addEventListener("click", openFan);
|
||||||
|
} else {
|
||||||
|
stack.dataset.state = "ineligible";
|
||||||
|
stack.removeEventListener("click", openFan);
|
||||||
|
if (!stack.querySelector(".fa-ban")) {
|
||||||
|
var icon = document.createElement("i");
|
||||||
|
icon.className = "fa-solid fa-ban";
|
||||||
|
stack.appendChild(icon);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move .active to the newly active seat
|
||||||
|
document.querySelectorAll(".table-seat.active").forEach(function (s) {
|
||||||
|
s.classList.remove("active");
|
||||||
|
});
|
||||||
|
var activeSeat = document.querySelector(".table-seat[data-slot='" + active + "']");
|
||||||
|
if (activeSeat) activeSeat.classList.add("active");
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("room:role_select_start", init);
|
||||||
|
window.addEventListener("room:turn_changed", handleTurnChanged);
|
||||||
|
window.addEventListener("room:roles_revealed", handleRolesRevealed);
|
||||||
|
|
||||||
|
if (document.readyState === "loading") {
|
||||||
|
document.addEventListener("DOMContentLoaded", init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
openFan: openFan,
|
||||||
|
closeFan: closeFan,
|
||||||
|
setReload: function (fn) { _reload = fn; },
|
||||||
|
};
|
||||||
|
}());
|
||||||
19
src/apps/epic/static/apps/epic/room.js
Normal file
19
src/apps/epic/static/apps/epic/room.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
(function () {
|
||||||
|
const roomPage = document.querySelector('.room-page');
|
||||||
|
if (!roomPage) return;
|
||||||
|
|
||||||
|
const roomId = roomPage.dataset.roomId;
|
||||||
|
const wsScheme = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const ws = new WebSocket(`${wsScheme}://${window.location.host}/ws/room/${roomId}/`);
|
||||||
|
|
||||||
|
ws.onmessage = function (event) {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
window.dispatchEvent(new CustomEvent('room:' + data.type, { detail: data }));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = function (event) {
|
||||||
|
if (!event.wasClean) {
|
||||||
|
console.warn('Room WebSocket closed unexpectedly');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}());
|
||||||
0
src/apps/epic/tests/__init__.py
Normal file
0
src/apps/epic/tests/__init__.py
Normal file
0
src/apps/epic/tests/integrated/__init__.py
Normal file
0
src/apps/epic/tests/integrated/__init__.py
Normal file
85
src/apps/epic/tests/integrated/test_consumers.py
Normal file
85
src/apps/epic/tests/integrated/test_consumers.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
from channels.testing.websocket import WebsocketCommunicator
|
||||||
|
from channels.layers import get_channel_layer
|
||||||
|
from django.test import SimpleTestCase, override_settings
|
||||||
|
|
||||||
|
from core.asgi import application
|
||||||
|
|
||||||
|
|
||||||
|
TEST_CHANNEL_LAYERS = {
|
||||||
|
"default": {
|
||||||
|
"BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override_settings(CHANNEL_LAYERS=TEST_CHANNEL_LAYERS)
|
||||||
|
class RoomConsumerTest(SimpleTestCase):
|
||||||
|
async def test_can_connect_and_disconnect(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
connected, _ = await communicator.connect()
|
||||||
|
self.assertTrue(connected)
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_receives_role_select_start_broadcast(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
await communicator.connect()
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
|
{"type": "role_select_start", "slot_order": [1, 2, 3, 4, 5, 6]},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "role_select_start")
|
||||||
|
self.assertEqual(response["slot_order"], [1, 2, 3, 4, 5, 6])
|
||||||
|
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_receives_turn_changed_broadcast(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
await communicator.connect()
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
|
{"type": "turn_changed", "active_slot": 2},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "turn_changed")
|
||||||
|
self.assertEqual(response["active_slot"], 2)
|
||||||
|
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_receives_roles_revealed_broadcast(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
await communicator.connect()
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
|
{"type": "roles_revealed", "assignments": {"1": "PC", "2": "BC"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "roles_revealed")
|
||||||
|
self.assertIn("assignments", response)
|
||||||
|
|
||||||
|
await communicator.disconnect()
|
||||||
|
|
||||||
|
async def test_receives_gate_update_broadcast(self):
|
||||||
|
communicator = WebsocketCommunicator(application, "/ws/room/00000000-0000-0000-0000-000000000001/")
|
||||||
|
await communicator.connect()
|
||||||
|
|
||||||
|
channel_layer = get_channel_layer()
|
||||||
|
await channel_layer.group_send(
|
||||||
|
"room_00000000-0000-0000-0000-000000000001",
|
||||||
|
{"type": "gate_update", "gate_state": "some_state"},
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await communicator.receive_json_from()
|
||||||
|
self.assertEqual(response["type"], "gate_update")
|
||||||
|
self.assertEqual(response["gate_state"], "some_state")
|
||||||
|
|
||||||
|
await communicator.disconnect()
|
||||||
216
src/apps/epic/tests/integrated/test_models.py
Normal file
216
src/apps/epic/tests/integrated/test_models.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.lyric.models import Token, User
|
||||||
|
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat, debit_token, select_token
|
||||||
|
|
||||||
|
|
||||||
|
class RoomCreationTest(TestCase):
|
||||||
|
def test_creating_a_room_generates_six_gate_slots(self):
|
||||||
|
owner = User.objects.create(email="founder@example.com")
|
||||||
|
room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
self.assertEqual(GateSlot.objects.filter(room=room).count(), 6)
|
||||||
|
|
||||||
|
|
||||||
|
class DebitTokenTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="founder@example.com")
|
||||||
|
self.room = Room.objects.create(
|
||||||
|
name="Test Room",
|
||||||
|
owner=self.owner,
|
||||||
|
renewal_period=timedelta(days=7)
|
||||||
|
)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
|
||||||
|
def test_debit_free_token_consumes_token_and_fills_slot(self):
|
||||||
|
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
|
||||||
|
debit_token(self.owner, self.slot, free_token)
|
||||||
|
self.assertFalse(Token.objects.filter(pk=free_token.pk).exists())
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||||||
|
self.assertEqual(self.slot.gamer, self.owner)
|
||||||
|
|
||||||
|
def test_debit_coin_does_not_consume_token(self):
|
||||||
|
coin_token = Token.objects.get(user=self.owner, token_type=Token.COIN)
|
||||||
|
debit_token(self.owner, self.slot, coin_token)
|
||||||
|
self.assertTrue(Token.objects.filter(pk=coin_token.pk).exists())
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||||||
|
self.assertEqual(self.slot.gamer, self.owner)
|
||||||
|
|
||||||
|
def test_debit_fills_last_slot_and_opens_gate(self):
|
||||||
|
for i in range(2, 7):
|
||||||
|
gamer = User.objects.create(email=f"g{i}@test.io")
|
||||||
|
slot = self.room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
free_token = Token.objects.get(user=self.owner, token_type=Token.FREE)
|
||||||
|
debit_token(self.owner, self.slot, free_token)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.gate_status, Room.OPEN)
|
||||||
|
|
||||||
|
|
||||||
|
class CoinTokenInUseTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="founder@example.com")
|
||||||
|
self.room = Room.objects.create(
|
||||||
|
name="Dragon's Den",
|
||||||
|
owner=self.owner,
|
||||||
|
renewal_period=timedelta(days=7),
|
||||||
|
)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.coin = Token.objects.get(user=self.owner, token_type=Token.COIN)
|
||||||
|
debit_token(self.owner, self.slot, self.coin)
|
||||||
|
self.coin.refresh_from_db()
|
||||||
|
|
||||||
|
def test_coin_tooltip_expiry_shows_next_ready_date(self):
|
||||||
|
expected_date = self.coin.next_ready_at.strftime("%Y-%m-%d")
|
||||||
|
self.assertIn(expected_date, self.coin.tooltip_expiry())
|
||||||
|
|
||||||
|
def test_coin_tooltip_room_html_contains_anchor(self):
|
||||||
|
room_url = reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
html = self.coin.tooltip_room_html()
|
||||||
|
self.assertIn(f'href="{room_url}"', html)
|
||||||
|
self.assertIn(self.room.name, html)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectTokenTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@test.io")
|
||||||
|
self.other_room = Room.objects.create(name="Other Room", owner=self.user)
|
||||||
|
self.coin = Token.objects.get(user=self.user, token_type=Token.COIN)
|
||||||
|
|
||||||
|
def test_returns_coin_when_available(self):
|
||||||
|
token = select_token(self.user)
|
||||||
|
self.assertEqual(token.token_type, Token.COIN)
|
||||||
|
|
||||||
|
def test_returns_free_token_when_coin_in_use(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
token = select_token(self.user)
|
||||||
|
self.assertEqual(token.token_type, Token.FREE)
|
||||||
|
|
||||||
|
def test_free_token_selection_is_fefo(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
|
||||||
|
soon = Token.objects.create(
|
||||||
|
user=self.user, token_type=Token.FREE,
|
||||||
|
expires_at=timezone.now() + timedelta(days=2),
|
||||||
|
)
|
||||||
|
Token.objects.create(
|
||||||
|
user=self.user, token_type=Token.FREE,
|
||||||
|
expires_at=timezone.now() + timedelta(days=6),
|
||||||
|
)
|
||||||
|
token = select_token(self.user)
|
||||||
|
self.assertEqual(token.pk, soon.pk)
|
||||||
|
|
||||||
|
def test_returns_tithe_when_coin_in_use_and_no_free_tokens(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
|
||||||
|
tithe = Token.objects.create(user=self.user, token_type=Token.TITHE)
|
||||||
|
token = select_token(self.user)
|
||||||
|
self.assertEqual(token.pk, tithe.pk)
|
||||||
|
|
||||||
|
def test_returns_none_when_all_depleted(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
Token.objects.filter(user=self.user, token_type=Token.FREE).delete()
|
||||||
|
token = select_token(self.user)
|
||||||
|
self.assertIsNone(token)
|
||||||
|
|
||||||
|
def test_returns_pass_for_staff(self):
|
||||||
|
self.user.is_staff = True
|
||||||
|
self.user.save()
|
||||||
|
pass_token = Token.objects.create(user=self.user, token_type=Token.PASS)
|
||||||
|
token = select_token(self.user)
|
||||||
|
self.assertEqual(token.token_type, Token.PASS)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomTableStatusTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||||||
|
|
||||||
|
def test_table_status_defaults_to_blank(self):
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertFalse(self.room.table_status)
|
||||||
|
|
||||||
|
def test_room_has_role_select_constant(self):
|
||||||
|
self.assertEqual(Room.ROLE_SELECT, "ROLE_SELECT")
|
||||||
|
|
||||||
|
def test_room_has_sig_select_constant(self):
|
||||||
|
self.assertEqual(Room.SIG_SELECT, "SIG_SELECT")
|
||||||
|
|
||||||
|
def test_room_has_in_game_constant(self):
|
||||||
|
self.assertEqual(Room.IN_GAME, "IN_GAME")
|
||||||
|
|
||||||
|
def test_table_status_accepts_role_select(self):
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||||||
|
|
||||||
|
|
||||||
|
class TableSeatModelTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||||||
|
|
||||||
|
def test_table_seat_can_be_created(self):
|
||||||
|
seat = TableSeat.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
gamer=self.owner,
|
||||||
|
slot_number=1,
|
||||||
|
)
|
||||||
|
self.assertEqual(seat.slot_number, 1)
|
||||||
|
self.assertIsNone(seat.role)
|
||||||
|
self.assertFalse(seat.role_revealed)
|
||||||
|
self.assertIsNone(seat.seat_position)
|
||||||
|
|
||||||
|
def test_table_seat_role_choices_cover_all_six(self):
|
||||||
|
role_codes = [c[0] for c in TableSeat.ROLE_CHOICES]
|
||||||
|
for code in ["PC", "BC", "SC", "AC", "NC", "EC"]:
|
||||||
|
self.assertIn(code, role_codes)
|
||||||
|
|
||||||
|
def test_partner_map_pairs_are_mutual(self):
|
||||||
|
for a, b in [(TableSeat.PC, TableSeat.BC), (TableSeat.SC, TableSeat.AC), (TableSeat.NC, TableSeat.EC)]:
|
||||||
|
self.assertEqual(TableSeat.PARTNER_MAP[a], b)
|
||||||
|
self.assertEqual(TableSeat.PARTNER_MAP[b], a)
|
||||||
|
|
||||||
|
def test_room_table_seats_reverse_relation(self):
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=self.owner, slot_number=1)
|
||||||
|
self.assertEqual(self.room.table_seats.count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class RoomInviteTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="founder@example.com")
|
||||||
|
self.room = Room.objects.create(name="Dragon's Den", owner=self.founder)
|
||||||
|
|
||||||
|
def test_founder_can_invite_by_email(self):
|
||||||
|
invite = RoomInvite.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
inviter=self.founder,
|
||||||
|
invitee_email="friend@example.com",
|
||||||
|
)
|
||||||
|
self.assertEqual(invite.status, RoomInvite.PENDING)
|
||||||
|
|
||||||
|
def test_invited_room_appears_in_my_games_queryset(self):
|
||||||
|
friend = User.objects.create(email="friend@example.com")
|
||||||
|
RoomInvite.objects.create(
|
||||||
|
room=self.room,
|
||||||
|
inviter=self.founder,
|
||||||
|
invitee_email=friend.email,
|
||||||
|
)
|
||||||
|
rooms = Room.objects.filter(
|
||||||
|
Q(owner=friend) |
|
||||||
|
Q(gate_slots__gamer=friend) |
|
||||||
|
Q(invites__invitee_email=friend.email, invites__status=RoomInvite.PENDING)
|
||||||
|
).distinct()
|
||||||
|
self.assertIn(self.room, rooms)
|
||||||
768
src/apps/epic/tests/integrated/test_views.py
Normal file
768
src/apps/epic/tests/integrated/test_views.py
Normal file
@@ -0,0 +1,768 @@
|
|||||||
|
from datetime import timedelta
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.urls import reverse
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from apps.lyric.models import Token, User
|
||||||
|
from apps.epic.models import GateSlot, Room, RoomInvite, TableSeat
|
||||||
|
|
||||||
|
|
||||||
|
class RoomCreationViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="founder@test.io")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_post_creates_room_and_redirects_to_gatekeeper(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:create_room"),
|
||||||
|
data={"name": "Test Room"},
|
||||||
|
)
|
||||||
|
room = Room.objects.get(owner=self.user)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse(
|
||||||
|
"epic:gatekeeper",
|
||||||
|
args=[room.id],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_post_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:create_room"),
|
||||||
|
data={"name": "Test Room"},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_room_get_redirects_to_gameboard(self):
|
||||||
|
response = self.client.get(reverse("epic:create_room"))
|
||||||
|
self.assertRedirects(response, "/gameboard/")
|
||||||
|
|
||||||
|
|
||||||
|
class MyGamesContextTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create(email="gamer@example.com")
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def test_gameboard_context_includes_owned_rooms(self):
|
||||||
|
room = Room.objects.create(name="Durango", owner=self.user)
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
self.assertIn(room, response.context["my_games"])
|
||||||
|
|
||||||
|
def test_gameboard_context_includes_rooms_with_filled_slot(self):
|
||||||
|
other = User.objects.create(email="friend@example.com")
|
||||||
|
room = Room.objects.create(name="Their Room", owner=other)
|
||||||
|
slot = room.gate_slots.get(slot_number=2)
|
||||||
|
slot.gamer = self.user
|
||||||
|
slot.status = "FILLED"
|
||||||
|
slot.save()
|
||||||
|
response = self.client.get("/gameboard/")
|
||||||
|
self.assertIn(room, response.context["my_games"])
|
||||||
|
|
||||||
|
|
||||||
|
class GateStatusViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="founder@test.io")
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||||||
|
|
||||||
|
def test_gate_status_returns_launch_btn_when_open(self):
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.save()
|
||||||
|
response = self.client.get(reverse("epic:gate_status", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "launch-game-btn")
|
||||||
|
|
||||||
|
def test_gate_status_returns_partial_when_gathering(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gate_status", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, "gate-modal")
|
||||||
|
|
||||||
|
|
||||||
|
class DropTokenViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
|
||||||
|
def test_drop_token_reserves_lowest_empty_slot(self):
|
||||||
|
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||||||
|
slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.assertEqual(slot.status, GateSlot.RESERVED)
|
||||||
|
self.assertEqual(slot.gamer, self.gamer)
|
||||||
|
|
||||||
|
def test_drop_token_skips_already_filled_slots(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
slot1 = self.room.gate_slots.get(slot_number=1)
|
||||||
|
slot1.gamer = other
|
||||||
|
slot1.status = GateSlot.FILLED
|
||||||
|
slot1.save()
|
||||||
|
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||||||
|
slot2 = self.room.gate_slots.get(slot_number=2)
|
||||||
|
self.assertEqual(slot2.status, GateSlot.RESERVED)
|
||||||
|
self.assertEqual(slot2.gamer, self.gamer)
|
||||||
|
|
||||||
|
def test_drop_token_blocked_when_another_slot_reserved(self):
|
||||||
|
other = User.objects.create(email="other@test.io")
|
||||||
|
slot1 = self.room.gate_slots.get(slot_number=1)
|
||||||
|
slot1.gamer = other
|
||||||
|
slot1.status = GateSlot.RESERVED
|
||||||
|
slot1.reserved_at = timezone.now()
|
||||||
|
slot1.save()
|
||||||
|
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||||||
|
# Slot 2 should remain EMPTY — lock held by other user
|
||||||
|
slot2 = self.room.gate_slots.get(slot_number=2)
|
||||||
|
self.assertEqual(slot2.status, GateSlot.EMPTY)
|
||||||
|
|
||||||
|
def test_drop_token_blocked_when_user_already_has_filled_slot(self):
|
||||||
|
slot1 = self.room.gate_slots.get(slot_number=1)
|
||||||
|
slot1.gamer = self.gamer
|
||||||
|
slot1.status = GateSlot.FILLED
|
||||||
|
slot1.save()
|
||||||
|
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||||||
|
slot2 = self.room.gate_slots.get(slot_number=2)
|
||||||
|
self.assertEqual(slot2.status, GateSlot.EMPTY)
|
||||||
|
|
||||||
|
def test_drop_token_sets_reserved_at(self):
|
||||||
|
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||||||
|
slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.assertIsNotNone(slot.reserved_at)
|
||||||
|
|
||||||
|
def test_drop_token_redirects_to_gatekeeper(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmTokenViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.slot.gamer = self.gamer
|
||||||
|
self.slot.status = GateSlot.RESERVED
|
||||||
|
self.slot.reserved_at = timezone.now()
|
||||||
|
self.slot.save()
|
||||||
|
Token.objects.create(user=self.gamer, token_type=Token.FREE)
|
||||||
|
|
||||||
|
def test_confirm_marks_slot_filled(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, GateSlot.FILLED)
|
||||||
|
|
||||||
|
def test_confirm_sets_gate_open_when_all_slots_filled(self):
|
||||||
|
# Fill slots 2–6 via ORM
|
||||||
|
for i in range(2, 7):
|
||||||
|
other = User.objects.create(email=f"g{i}@test.io")
|
||||||
|
s = self.room.gate_slots.get(slot_number=i)
|
||||||
|
s.gamer = other
|
||||||
|
s.status = GateSlot.FILLED
|
||||||
|
s.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.gate_status, Room.OPEN)
|
||||||
|
|
||||||
|
def test_confirm_redirects_to_gatekeeper(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_confirm_does_nothing_without_reserved_slot(self):
|
||||||
|
self.slot.status = GateSlot.EMPTY
|
||||||
|
self.slot.gamer = None
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:confirm_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||||||
|
|
||||||
|
|
||||||
|
class ReturnTokenViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.slot.gamer = self.gamer
|
||||||
|
self.slot.status = GateSlot.RESERVED
|
||||||
|
self.slot.reserved_at = timezone.now()
|
||||||
|
self.slot.save()
|
||||||
|
|
||||||
|
def test_return_clears_reserved_slot(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||||||
|
self.assertIsNone(self.slot.gamer)
|
||||||
|
self.assertIsNone(self.slot.reserved_at)
|
||||||
|
|
||||||
|
def test_return_after_confirm_clears_filled_slot(self):
|
||||||
|
self.slot.status = GateSlot.FILLED
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, GateSlot.EMPTY)
|
||||||
|
self.assertIsNone(self.slot.gamer)
|
||||||
|
|
||||||
|
def test_return_redirects_to_gatekeeper(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_return_restores_coin_token(self):
|
||||||
|
coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||||||
|
coin.current_room = self.room
|
||||||
|
coin.next_ready_at = timezone.now() + timedelta(days=7)
|
||||||
|
coin.save()
|
||||||
|
self.slot.status = GateSlot.FILLED
|
||||||
|
self.slot.debited_token_type = Token.COIN
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
coin.refresh_from_db()
|
||||||
|
self.assertIsNone(coin.current_room)
|
||||||
|
self.assertIsNone(coin.next_ready_at)
|
||||||
|
|
||||||
|
def test_return_restores_free_token(self):
|
||||||
|
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||||||
|
expires = timezone.now() + timedelta(days=3)
|
||||||
|
self.slot.status = GateSlot.FILLED
|
||||||
|
self.slot.debited_token_type = Token.FREE
|
||||||
|
self.slot.debited_token_expires_at = expires
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
restored = Token.objects.filter(user=self.gamer, token_type=Token.FREE).first()
|
||||||
|
self.assertIsNotNone(restored)
|
||||||
|
self.assertEqual(restored.expires_at, expires)
|
||||||
|
|
||||||
|
def test_return_restores_tithe_token(self):
|
||||||
|
self.slot.status = GateSlot.FILLED
|
||||||
|
self.slot.debited_token_type = Token.TITHE
|
||||||
|
self.slot.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:return_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertTrue(
|
||||||
|
Token.objects.filter(user=self.gamer, token_type=Token.TITHE).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DropTokenAvailabilityViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
self.other_room = Room.objects.create(name="Other Room", owner=owner)
|
||||||
|
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||||||
|
|
||||||
|
def test_drop_reserves_slot_when_tokens_available(self):
|
||||||
|
self.client.post(reverse("epic:drop_token", kwargs={"room_id": self.room.id}))
|
||||||
|
slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.assertEqual(slot.status, GateSlot.RESERVED)
|
||||||
|
# token not debited yet — that happens at confirm
|
||||||
|
self.coin.refresh_from_db()
|
||||||
|
self.assertIsNone(self.coin.current_room)
|
||||||
|
|
||||||
|
def test_drop_returns_402_when_all_tokens_depleted(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:drop_token", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 402)
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmTokenPriorityViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
self.other_room = Room.objects.create(name="Other Room", owner=owner)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.slot.gamer = self.gamer
|
||||||
|
self.slot.status = GateSlot.RESERVED
|
||||||
|
self.slot.reserved_at = timezone.now()
|
||||||
|
self.slot.save()
|
||||||
|
self.coin = Token.objects.get(user=self.gamer, token_type=Token.COIN)
|
||||||
|
|
||||||
|
def test_confirm_leases_coin_to_room(self):
|
||||||
|
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||||||
|
self.coin.refresh_from_db()
|
||||||
|
self.assertEqual(self.coin.current_room, self.room)
|
||||||
|
self.assertTrue(Token.objects.filter(pk=self.coin.pk).exists())
|
||||||
|
|
||||||
|
def test_confirm_uses_free_token_when_coin_in_use(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertEqual(
|
||||||
|
Token.objects.filter(user=self.gamer, token_type=Token.FREE).count(), 0
|
||||||
|
)
|
||||||
|
self.coin.refresh_from_db()
|
||||||
|
self.assertEqual(self.coin.current_room, self.other_room)
|
||||||
|
|
||||||
|
def test_confirm_uses_tithe_when_free_tokens_exhausted(self):
|
||||||
|
self.coin.current_room = self.other_room
|
||||||
|
self.coin.save()
|
||||||
|
Token.objects.filter(user=self.gamer, token_type=Token.FREE).delete()
|
||||||
|
tithe = Token.objects.create(user=self.gamer, token_type=Token.TITHE)
|
||||||
|
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertFalse(Token.objects.filter(pk=tithe.pk).exists())
|
||||||
|
|
||||||
|
def test_pass_not_consumed_and_coin_not_leased(self):
|
||||||
|
self.gamer.is_staff = True
|
||||||
|
self.gamer.save()
|
||||||
|
pass_token = Token.objects.create(user=self.gamer, token_type=Token.PASS)
|
||||||
|
self.client.post(reverse("epic:confirm_token", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertTrue(Token.objects.filter(pk=pass_token.pk).exists())
|
||||||
|
self.coin.refresh_from_db()
|
||||||
|
self.assertIsNone(self.coin.current_room)
|
||||||
|
|
||||||
|
|
||||||
|
class RoleSelectRenderingTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||||||
|
self.gamers = [self.founder]
|
||||||
|
for i in range(2, 7):
|
||||||
|
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||||||
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
|
slot = self.room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||||||
|
|
||||||
|
def test_room_view_includes_card_stack_when_role_select(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, "card-stack")
|
||||||
|
|
||||||
|
def test_card_stack_eligible_for_slot1_gamer(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, 'data-state="eligible"')
|
||||||
|
|
||||||
|
def test_card_stack_ineligible_for_slot2_gamer(self):
|
||||||
|
self.client.force_login(self.gamers[1])
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, 'data-state="ineligible"')
|
||||||
|
|
||||||
|
def test_card_stack_ineligible_shows_fa_ban(self):
|
||||||
|
self.client.force_login(self.gamers[1])
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, "fa-ban")
|
||||||
|
|
||||||
|
def test_card_stack_eligible_omits_fa_ban(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertNotContains(response, "fa-ban")
|
||||||
|
|
||||||
|
def test_gatekeeper_overlay_absent_when_role_select(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertNotContains(response, "gate-overlay")
|
||||||
|
|
||||||
|
def test_six_table_seats_rendered(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, "table-seat", count=6)
|
||||||
|
|
||||||
|
def test_active_table_seat_has_active_class(self):
|
||||||
|
self.client.force_login(self.founder) # slot 1 is active
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, 'class="table-seat active"')
|
||||||
|
|
||||||
|
def test_inactive_table_seat_lacks_active_class(self):
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
# Slots 2–6 are not active, so at least one plain table-seat exists
|
||||||
|
self.assertContains(response, 'class="table-seat"')
|
||||||
|
|
||||||
|
def test_card_stack_has_data_user_slots_for_eligible_gamer(self):
|
||||||
|
self.client.force_login(self.founder) # founder is slot 1 only
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, 'data-user-slots="1"')
|
||||||
|
|
||||||
|
def test_card_stack_has_data_user_slots_for_ineligible_gamer(self):
|
||||||
|
self.client.force_login(self.gamers[1]) # slot 2 gamer
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, 'data-user-slots="2"')
|
||||||
|
|
||||||
|
|
||||||
|
class PickRolesViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="founder@test.io")
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||||||
|
for i in range(1, 7):
|
||||||
|
gamer = self.founder if i == 1 else User.objects.create(email=f"g{i}@test.io")
|
||||||
|
slot = self.room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.save()
|
||||||
|
|
||||||
|
def test_pick_roles_transitions_room_to_role_select(self):
|
||||||
|
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.ROLE_SELECT)
|
||||||
|
|
||||||
|
def test_pick_roles_creates_one_table_seat_per_filled_slot(self):
|
||||||
|
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
|
||||||
|
|
||||||
|
def test_pick_roles_table_seats_carry_gamer_and_slot_number(self):
|
||||||
|
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||||||
|
self.assertEqual(seat.gamer, self.founder)
|
||||||
|
|
||||||
|
def test_only_open_room_can_start_role_select(self):
|
||||||
|
self.room.gate_status = Room.GATHERING
|
||||||
|
self.room.save()
|
||||||
|
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertIsNone(self.room.table_status)
|
||||||
|
|
||||||
|
def test_pick_roles_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_pick_roles_redirects_to_room(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_pick_roles_notifies_channel_layer(self):
|
||||||
|
with patch("apps.epic.views._notify_role_select_start") as mock_notify:
|
||||||
|
self.client.post(reverse("epic:pick_roles", kwargs={"room_id": self.room.id}))
|
||||||
|
mock_notify.assert_called_once_with(self.room.id)
|
||||||
|
|
||||||
|
def test_pick_roles_idempotent_no_duplicate_seats(self):
|
||||||
|
url = reverse("epic:pick_roles", kwargs={"room_id": self.room.id})
|
||||||
|
self.client.post(url)
|
||||||
|
self.client.post(url) # second call must be a no-op
|
||||||
|
self.assertEqual(TableSeat.objects.filter(room=self.room).count(), 6)
|
||||||
|
|
||||||
|
|
||||||
|
class SelectRoleViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||||||
|
self.gamers = [self.founder]
|
||||||
|
for i in range(2, 7):
|
||||||
|
self.gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||||||
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
|
slot = self.room.gate_slots.get(slot_number=i)
|
||||||
|
slot.gamer = gamer
|
||||||
|
slot.status = GateSlot.FILLED
|
||||||
|
slot.save()
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.table_status = Room.ROLE_SELECT
|
||||||
|
self.room.save()
|
||||||
|
for i, gamer in enumerate(self.gamers, start=1):
|
||||||
|
TableSeat.objects.create(room=self.room, gamer=gamer, slot_number=i)
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
|
||||||
|
def test_select_role_records_choice(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=1)
|
||||||
|
self.assertEqual(seat.role, "PC")
|
||||||
|
|
||||||
|
def test_select_role_wrong_turn_makes_no_change(self):
|
||||||
|
self.client.force_login(self.gamers[1]) # slot 2 — not their turn
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BC"},
|
||||||
|
)
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=2)
|
||||||
|
self.assertIsNone(seat.role)
|
||||||
|
|
||||||
|
def test_turn_advances_after_selection(self):
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
next_active = TableSeat.objects.filter(
|
||||||
|
room=self.room, role__isnull=True
|
||||||
|
).order_by("slot_number").first()
|
||||||
|
self.assertEqual(next_active.slot_number, 2)
|
||||||
|
|
||||||
|
def test_all_selected_sets_sig_select(self):
|
||||||
|
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||||||
|
seat.role = role
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.gamers[5]) # slot 6 — last
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "EC"},
|
||||||
|
)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.table_status, Room.SIG_SELECT)
|
||||||
|
|
||||||
|
def test_select_role_notifies_turn_changed(self):
|
||||||
|
with patch("apps.epic.views._notify_turn_changed") as mock_notify:
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
mock_notify.assert_called_once_with(self.room.id)
|
||||||
|
|
||||||
|
def test_select_role_notifies_roles_revealed_when_last(self):
|
||||||
|
roles = ["PC", "BC", "SC", "AC", "NC"]
|
||||||
|
for i, role in enumerate(roles):
|
||||||
|
seat = TableSeat.objects.get(room=self.room, slot_number=i + 1)
|
||||||
|
seat.role = role
|
||||||
|
seat.save()
|
||||||
|
self.client.force_login(self.gamers[5])
|
||||||
|
with patch("apps.epic.views._notify_roles_revealed") as mock_notify:
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "EC"},
|
||||||
|
)
|
||||||
|
mock_notify.assert_called_once_with(self.room.id)
|
||||||
|
|
||||||
|
def test_select_role_requires_login(self):
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertIn("/accounts/login/", response.url)
|
||||||
|
|
||||||
|
def test_select_role_returns_ok(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
|
||||||
|
def test_select_role_returns_409_for_duplicate_role(self):
|
||||||
|
TableSeat.objects.filter(room=self.room, slot_number=2).update(role="BC")
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BC"},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 409)
|
||||||
|
|
||||||
|
def test_select_role_redirects_when_not_role_select_phase(self):
|
||||||
|
self.room.table_status = None
|
||||||
|
self.room.save()
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_select_role_redirects_for_invalid_role_code(self):
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BOGUS"},
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_same_gamer_cannot_double_pick_sequentially(self):
|
||||||
|
"""A second POST from the active gamer — after their role has been
|
||||||
|
saved — must redirect rather than assign a second role."""
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "PC"},
|
||||||
|
)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:select_role", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"role": "BC"},
|
||||||
|
)
|
||||||
|
self.assertRedirects(
|
||||||
|
response, reverse("epic:gatekeeper", args=[self.room.id])
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
TableSeat.objects.filter(room=self.room, role__isnull=False).count(), 1
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RevealPhaseRenderingTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.founder = User.objects.create(email="founder@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.founder)
|
||||||
|
gamers = [self.founder]
|
||||||
|
for i in range(2, 7):
|
||||||
|
gamers.append(User.objects.create(email=f"g{i}@test.io"))
|
||||||
|
roles = ["PC", "BC", "SC", "AC", "NC", "EC"]
|
||||||
|
for i, (gamer, role) in enumerate(zip(gamers, roles), start=1):
|
||||||
|
TableSeat.objects.create(
|
||||||
|
room=self.room, gamer=gamer, slot_number=i,
|
||||||
|
role=role, role_revealed=True,
|
||||||
|
)
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.table_status = Room.SIG_SELECT
|
||||||
|
self.room.save()
|
||||||
|
self.client.force_login(self.founder)
|
||||||
|
|
||||||
|
def test_face_up_role_cards_rendered_when_sig_select(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, "face-up")
|
||||||
|
|
||||||
|
def test_inv_role_card_slot_present(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, "id_inv_role_card")
|
||||||
|
|
||||||
|
def test_partner_indicator_present_when_sig_select(self):
|
||||||
|
response = self.client.get(
|
||||||
|
reverse("epic:gatekeeper", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertContains(response, "partner-indicator")
|
||||||
|
|
||||||
|
|
||||||
|
class RoomActionsViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=self.owner)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=2)
|
||||||
|
self.slot.gamer = self.gamer
|
||||||
|
self.slot.status = "FILLED"
|
||||||
|
self.slot.save()
|
||||||
|
RoomInvite.objects.create(
|
||||||
|
room=self.room, inviter=self.owner,
|
||||||
|
invitee_email=self.gamer.email
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_owner_delete_removes_room(self):
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertFalse(Room.objects.filter(pk=self.room.pk).exists())
|
||||||
|
|
||||||
|
def test_non_owner_delete_does_not_remove_room(self):
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
self.client.post(reverse("epic:delete_room", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertTrue(Room.objects.filter(pk=self.room.pk).exists())
|
||||||
|
|
||||||
|
def test_delete_redirects_to_gameboard(self):
|
||||||
|
self.client.force_login(self.owner)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:delete_room", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, "/gameboard/")
|
||||||
|
|
||||||
|
def test_abandon_clears_slot(self):
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
|
||||||
|
self.slot.refresh_from_db()
|
||||||
|
self.assertEqual(self.slot.status, "EMPTY")
|
||||||
|
self.assertIsNone(self.slot.gamer)
|
||||||
|
|
||||||
|
def test_abandon_deletes_pending_invite(self):
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
self.client.post(reverse("epic:abandon_room", kwargs={"room_id": self.room.id}))
|
||||||
|
self.assertFalse(
|
||||||
|
RoomInvite.objects.filter(
|
||||||
|
room=self.room, invitee_email=self.gamer.email
|
||||||
|
).exists()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_abandon_redirects_to_gameboard(self):
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse("epic:abandon_room", kwargs={"room_id": self.room.id})
|
||||||
|
)
|
||||||
|
self.assertRedirects(response, "/gameboard/")
|
||||||
|
|
||||||
|
|
||||||
|
class ReleaseSlotViewTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.gamer = User.objects.create(email="gamer@test.io")
|
||||||
|
self.client.force_login(self.gamer)
|
||||||
|
owner = User.objects.create(email="owner@test.io")
|
||||||
|
self.room = Room.objects.create(name="Test Room", owner=owner)
|
||||||
|
self.slot = self.room.gate_slots.get(slot_number=1)
|
||||||
|
self.slot.gamer = self.gamer
|
||||||
|
self.slot.status = GateSlot.FILLED
|
||||||
|
self.slot.debited_token_type = Token.CARTE
|
||||||
|
self.slot.save()
|
||||||
|
|
||||||
|
def test_release_slot_downgrades_open_room_to_gathering(self):
|
||||||
|
self.room.gate_status = Room.OPEN
|
||||||
|
self.room.save()
|
||||||
|
self.client.post(
|
||||||
|
reverse("epic:release_slot", kwargs={"room_id": self.room.id}),
|
||||||
|
data={"slot_number": self.slot.slot_number},
|
||||||
|
)
|
||||||
|
self.room.refresh_from_db()
|
||||||
|
self.assertEqual(self.room.gate_status, Room.GATHERING)
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user