Compare commits

...

25 Commits

Author SHA1 Message Date
Disco DeDisco
4b558020af added staging & prod https support to core.settings
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 13:33:11 -05:00
Disco DeDisco
5106b04175 updated User creation method in functional_tests.management.commands.create_session
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-19 20:50:31 -05:00
Disco DeDisco
025a59938b reenabled admin area; outfitted apps.lyric.models w. AbstractBaseUser instead of custom user class; many other fns & several models updated to accomodate, such as set_unusable_password() method to base user model; reset staging db to prepare for refreshed lyric migrations to accomodate for retrofitted pw field
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-19 20:31:29 -05:00
Disco DeDisco
d26196a7f1 added CSRF_TRUSTED_ORIGINS to core.settings, now that https added
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-18 23:32:43 -05:00
Disco DeDisco
84fd0554bd moved adman magic link to howdy.earthmanrpg.com, in anticipation of having to mirror the prod server location; staging server preserved, along w. gitea & woodpecker, on earthmanrpg.me
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-18 23:04:21 -05:00
Disco DeDisco
55f2a043c6 postgres integration complete thru woodpecker pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-18 21:12:01 -05:00
Disco DeDisco
a1e7ae8071 added coverage dependency; 99 percent test coverage (only lacking admin, currently by design); commenced postgresql db integration
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-18 20:18:56 -05:00
Disco DeDisco
a06fce26ef reorganized and reclassified old 'unit tests' for ea. app into dirs for UTs & ITs; abandoning effort to refactor any test_views into UTs; all tests passing
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-18 19:07:02 -05:00
Disco DeDisco
c41624152a added headless option to .test_sharing for woodpecker FT run
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-18 15:24:55 -05:00
Disco DeDisco
64d4ba9542 added to apps.dashboard.views, share_list() FBV, a try/except catch that accounts for nonexistent users; .test.test_views ensures functionality
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-18 15:14:35 -05:00
Disco DeDisco
0370f36e9e list sharing implemented w. passing UTs & FTs; changes to apps.dashboard.urls, .models, .views & .tests.test_views to accomodate; functional_tests.test_sharing also ensures sharing visible in UX; templates/apps/dashboard/list.html & /my_lists.html updated with django templating & for loops 2026-02-18 13:53:05 -05:00
Disco DeDisco
a85e0597d7 created functional_tests.my_lists_page to contain helpers for viewing shared lists; refactored several other FTs to passing 2026-02-17 23:27:54 -05:00
Disco DeDisco
e32c6bbfd6 created functional_tests.list_page to handle common FT helpers; almost every FT file affected & less reliant on .base, which no longer contains those helpers 2026-02-17 23:07:12 -05:00
Disco DeDisco
e26ee5af1d new functional_tests.test_sharing FT for sharing lists; create_pre_authenticated_session() moved from .test_my_lists to .base 2026-02-17 21:19:24 -05:00
Disco DeDisco
d74189f0b7 ensured my_list is viewable by auth user only
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-17 20:26:42 -05:00
Disco DeDisco
877e3f35cf hoping to sidestep CD permissions issue w. python call in infra/deploy.sh.j2
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-17 18:21:50 -05:00
Disco DeDisco
9a1a79fb9a added .gitattributes for real this time
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-17 17:57:20 -05:00
Disco DeDisco
2a9ac4c0f0 renormalized to LF end of line sequences for all files for CD compatibility; created .gitattributes @ project root to manage it; defined {{ ansible_user }} more explicitly in the ansible playbook
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-17 17:48:09 -05:00
Disco DeDisco
510874b87c created CD pipeline to complement the existing gitea/woodpecker CI pipeline: build, push to registry, deploy to staging on main
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-13 15:27:59 -05:00
Disco DeDisco
e02385118f intentially broke functional_tests.test_login to ensure screendumps functionality works on test runner failure in pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2026-02-11 15:29:02 -05:00
Disco DeDisco
71b35242eb intentially broke functional_tests.test_login to ensure screendumps functionality works on test runner failure in pipeline
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-11 15:20:25 -05:00
Disco DeDisco
bcadb28017 functional_tests.base updated w. many helper functions to save screendumps on server after test failures; woodpecker pipeline updated accordingly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-11 15:12:04 -05:00
Disco DeDisco
7f61dbd205 inadvertently committed Jasmine while most of Jasmine was ignored under .gitignore
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-11 14:50:32 -05:00
Disco DeDisco
5d0a1401d8 created new dir for STATICFILES_DIRS in core.settings; all Jasmine test files transferred to new dir src/static_src/; new FT test_jasmine executes Jasmine test runner as part of CI pipeline & base FT has dobules MAX_WAIT to 10s; some cleanup of unused imports in text_simple_list_creation
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-11 14:42:38 -05:00
Disco DeDisco
a98a0fcd53 added Jasmine test suite from inside src/static dir to git repo 2026-02-11 13:43:59 -05:00
60 changed files with 15817 additions and 201 deletions

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
* text=auto eol=lf

8
.gitignore vendored
View File

@@ -14,7 +14,6 @@ db.sqlite3
db.sqlite3-journal
container.db.sqlite3
media
src/static
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly.
@@ -48,6 +47,13 @@ share/python-wheels/
*.egg
MANIFEST
# Static files
src/static/*
!src/static/tests/
!src/static/tests/**
!/src/static_src/tests/lib/
src/functional_tests/screendumps
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.

View File

@@ -1,6 +1,16 @@
services:
- name: postgres
image: postgres:16
environment:
POSTGRES_DB: python_tdd_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
steps:
- name: test-UTs
- name: test-UTs-n-ITs
image: python:3.13-slim
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
commands:
- pip install -r requirements.txt
- cd ./src
@@ -14,4 +24,39 @@ steps:
- cd ./src
- python manage.py collectstatic --noinput
- python manage.py test functional_tests
- name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
when:
- status: failure
commands:
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
- name: build-and-push
image: docker:cli
environment:
REGISTRY_PASSWORD:
from_secret: gitea_registry_password
commands:
- echo "$REGISTRY_PASSWORD" | docker login gitea.earthmanrpg.me -u discoman --password-stdin
- docker build -t gitea.earthmanrpg.me/discoman/gamearray:latest .
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
when:
- branch: main
- event: push
- name: deploy
image: alpine
environment:
SSH_KEY:
from_secret: deploy_ssh_key
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
when:
- branch: main
- event: push

209
infra/CD-PLAN.md Normal file
View File

@@ -0,0 +1,209 @@
# Continuous Deployment: Auto-deploy to staging when CI passes
## Context
CI is complete (Percival ch. 25). The Woodpecker pipeline runs 57 UTs + 9 FTs on every push to Gitea, but deployment to staging is still manual (`ansible-playbook infra/deploy-playbook.yaml`). This plan adds CD: when tests pass on the `main` branch, the pipeline automatically builds the Docker image, pushes it to the Gitea container registry, and deploys it to `staging.earthmanrpg.me`.
**Architecture:** Build in CI pipeline -> push to Gitea registry -> SSH to staging -> pull + restart
---
## Stage 1: Enable Docker socket in Woodpecker pipeline steps
The Woodpecker agent already has the Docker socket mounted (line 53 of `infra/cicd/docker-compose.yaml`), but pipeline steps (containers the agent launches) don't inherit it. We need to tell the agent to pass it through.
### 1a. Edit `infra/cicd/docker-compose.yaml` -- add env var to `woodpecker-agent`
Add this to the agent's `environment:` block:
```yaml
- WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock
```
**Why:** This makes the host Docker socket available inside every pipeline step container. Without it, the `docker build` and `docker push` commands in our pipeline step would fail with "cannot connect to Docker daemon."
### 1b. Re-deploy CI stack
From WSL:
```bash
ansible-playbook infra/cicd-playbook.yaml -i infra/inventory.ini --ask-vault-pass
```
This uploads the updated docker-compose and restarts the CI services.
---
## Stage 2: Prepare staging server for CD
Currently `deploy-playbook.yaml` does everything in one shot: installs Docker, builds the image locally, uploads it, runs it with inline `env:` vars, copies static files, migrates. For CD, we need to split "one-time setup" from "per-deploy actions."
### 2a. Create `infra/gamearray.env.j2` -- container env file template (new file)
```
DJANGO_DEBUG_FALSE=1
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
DJANGO_DB_PATH=/home/nonroot/db.sqlite3
EMAIL_HOST_USER={{ email_host_user }}
EMAIL_HOST_PASSWORD={{ email_host_password }}
MAILGUN_API_KEY={{ mailgun_api_key }}
```
**Why:** Instead of passing env vars inline in `docker run`, we template a `.env` file onto the staging server. The deploy script references it with `--env-file`. This keeps secrets out of the pipeline YAML and on the server, managed by Ansible vault.
### 2b. Create `infra/deploy.sh.j2` -- deploy script template (new file)
```bash
#!/bin/bash
set -euo pipefail
IMAGE=gitea.earthmanrpg.me/discoman/gamearray:latest
echo "==> Pulling latest image..."
docker pull "$IMAGE"
echo "==> Stopping old container..."
docker stop gamearray 2>/dev/null || true
docker rm gamearray 2>/dev/null || true
echo "==> Starting new container..."
docker run -d --name gamearray \
--env-file /opt/gamearray/gamearray.env \
-p 127.0.0.1:8888:8888 \
"$IMAGE"
echo "==> Running migrations..."
docker exec gamearray ./manage.py migrate
echo "==> Copying static files..."
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
echo "==> Deploy complete."
```
**Why:** A single script the pipeline calls via SSH. Keeps the pipeline YAML clean and lets us test deploys manually too (`ssh staging /opt/gamearray/deploy.sh`).
### 2c. Update `infra/deploy-playbook.yaml` -- add env file, deploy script, and registry login
Add these tasks (after the existing "Read secret key" task):
1. **Create `/opt/gamearray/` directory** on staging
2. **Template `gamearray.env.j2`** to `/opt/gamearray/gamearray.env` (mode 0600)
3. **Template `deploy.sh.j2`** to `/opt/gamearray/deploy.sh` (mode 0755)
4. **Docker login to Gitea registry** -- so the deploy script can `docker pull`
For the Docker login, use a Gitea access token (created in the browser, stored in the staging vault):
```yaml
- name: Login to Gitea container registry
ansible.builtin.command:
cmd: docker login gitea.earthmanrpg.me -u discoman -p {{ gitea_registry_token }}
no_log: true
```
**Vault additions** needed for staging (`infra/group_vars/staging/vault.yaml`):
- `gitea_registry_token` -- a Gitea personal access token with `package:read` scope
### 2d. Run the deploy playbook
```bash
ansible-playbook infra/deploy-playbook.yaml -i infra/inventory.ini -l staging --ask-vault-pass
```
This sets up the staging server: env file, deploy script, and registry auth.
---
## Stage 3: Woodpecker secrets
Two secrets need to be added via the Woodpecker UI (`https://ci.earthmanrpg.me` -> repo settings -> Secrets):
### 3a. `deploy_ssh_key`
Generate a dedicated SSH key pair for CD:
```bash
ssh-keygen -t ed25519 -f ~/.ssh/id_ed25519_cd_deploy -C "woodpecker-cd"
```
- Add the **public key** to `~/.ssh/authorized_keys` on staging (as `discoman` user)
- Paste the **private key** contents into a Woodpecker secret named `deploy_ssh_key`
### 3b. `gitea_registry_password`
The Gitea access token (same one from Stage 2c, or a separate one with `package:read,package:write` scope).
- Paste into a Woodpecker secret named `gitea_registry_password`
- The `build-and-push` step will use this to `docker login` + `docker push`
---
## Stage 4: Update `.woodpecker.yaml`
Add two new steps after `screendumps`:
```yaml
- name: build-and-push
image: docker:cli
environment:
REGISTRY_PASSWORD:
from_secret: gitea_registry_password
commands:
- echo "$REGISTRY_PASSWORD" | docker login gitea.earthmanrpg.me -u discoman --password-stdin
- docker build -t gitea.earthmanrpg.me/discoman/gamearray:latest .
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest
when:
- branch: main
- event: push
- name: deploy
image: alpine
environment:
SSH_KEY:
from_secret: deploy_ssh_key
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
- chmod 600 ~/.ssh/id_ed25519
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
when:
- branch: main
- event: push
```
**Why `docker:cli` instead of a Docker plugin?** Using plain Docker CLI with the socket pass-through is more transparent than a third-party plugin. You can see exactly what's happening.
**Why `alpine` + manual SSH instead of `appleboy/drone-ssh`?** Same reason -- fewer moving parts, easier to debug. The `printf` + `ssh` pattern is dead simple.
**Branch gating:** Both steps only run on pushes to `main`. Feature branch pushes still run tests but don't deploy.
---
## Stage 5: Test end-to-end
1. Push a small visible change (e.g., tweak a template's page title) to `main` on Gitea
2. Watch the Woodpecker pipeline at `https://ci.earthmanrpg.me`:
- `test-UTs` passes
- `test-FTs` passes
- `build-and-push` builds the image and pushes to the registry
- `deploy` SSHes to staging and runs the deploy script
3. Visit `https://staging.earthmanrpg.me` and verify the change is live
---
## Files modified
| File | Action |
|------|--------|
| `infra/cicd/docker-compose.yaml` | Add `WOODPECKER_BACKEND_DOCKER_VOLUMES` to agent |
| `infra/gamearray.env.j2` | **New** -- container env file template |
| `infra/deploy.sh.j2` | **New** -- deploy script template |
| `infra/deploy-playbook.yaml` | Add tasks for env file, deploy script, registry login |
| `infra/group_vars/staging/vault.yaml` | Add `gitea_registry_token` |
| `.woodpecker.yaml` | Add `build-and-push` and `deploy` steps |
## Known limitation
**Ephemeral database:** The SQLite database still lives inside the container (deferred issue from memory). Every deploy wipes it. This is fine for staging right now but will need a volume mount or PostgreSQL before production CD.

View File

@@ -49,6 +49,7 @@ services:
- WOODPECKER_SERVER=woodpecker-server:9000
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
- WOODPECKER_MAX_WORKFLOWS=2
- WOODPECKER_BACKEND_DOCKER_VOLUMES=/var/run/docker.sock:/var/run/docker.sock
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:

View File

@@ -50,37 +50,6 @@
- name: Reset ssh connection to allow the user/group change to take effect
ansible.builtin.meta: reset_connection
- name: Build container image locally
community.docker.docker_image:
name: gamearray
source: build
state: present
build:
path: /mnt/d/cosmovault/latticework/oreilly/percival/python-tdd
platform: linux/amd64
force_source: true
delegate_to: 127.0.0.1
- name: Export container image locally
community.docker.docker_image:
name: gamearray
archive_path: /tmp/gamearray-img.tar
source: local
delegate_to: 127.0.0.1
- name: Upload image to server
ansible.builtin.copy:
src: /tmp/gamearray-img.tar
dest: /tmp/gamearray-img.tar
- name: Import container image on server
community.docker.docker_image:
name: gamearray
load_path: /tmp/gamearray-img.tar
source: load
force_source: true
state: present
- name: Ensure .secret-key files exists
# the intention is that this only happens once per server
ansible.builtin.copy:
@@ -94,27 +63,71 @@
src: ~/.secret-key
register: secret_key
- name: Ensure db.sqlite3 file exists outside container
- name: Create /opt/gamearray/ directory
ansible.builtin.file:
path: "{{ ansible_env.HOME }}/db.sqlite3"
state: touch
owner: 1234 # so nonroot user can access it in container
become: true # needed for ownership change
path: /opt/gamearray
state: directory
become: true
- name: Template gamearray.env to server
ansible.builtin.template:
src: gamearray.env.j2
dest: /opt/gamearray/gamearray.env
owner: "{{ ansible_user }}"
mode: "0600"
become: true
- name: Template deploy script to server
ansible.builtin.template:
src: deploy.sh.j2
dest: /opt/gamearray/deploy.sh
mode: "0755"
become: true
- name: Login to Gitea container registry
ansible.builtin.command:
cmd: docker login gitea.earthmanrpg.me -u discoman -p {{ gitea_registry_token }}
no_log: true
- name: Create Docker network
community.docker.docker_network:
name: gamearray_net
state: present
- name: Create Postgres data volume
community.docker.docker_volume:
name: gamearray_postgres_data
state: present
- name: Start Postgres container
community.docker.docker_container:
name: gamearray_postgres
image: postgres:16
state: started
restart_policy: unless-stopped
networks:
- name: gamearray_net
volumes:
- gamearray_postgres_data:/var/lib/postgresql/data
env:
POSTGRES_DB: gamearray
POSTGRES_USER: gamearray
POSTGRES_PASSWORD: "{{ postgres_password }}"
- name: Run container
community.docker.docker_container:
name: gamearray
image: gamearray
image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started
recreate: true
env:
DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
DJANGO_DB_PATH: "/home/nonroot/db.sqlite3"
EMAIL_HOST_USER: "{{ email_host_user }}"
EMAIL_HOST_PASSWORD: "{{ email_host_password }}"
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
networks:
- name: gamearray_net
ports:
127.0.0.1:8888:8888
@@ -134,7 +147,7 @@
- name: Run migration inside container
community.docker.docker_container_exec:
container: gamearray
command: ./manage.py migrate
command: python manage.py migrate
handlers:
- name: Restart nginx

26
infra/deploy.sh.j2 Normal file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -euo pipefail
IMAGE=gitea.earthmanrpg.me/discoman/gamearray:latest
echo "==> Pulling latest image..."
docker pull "$IMAGE"
echo "==> Stopping old container..."
docker stop gamearray 2>/dev/null || true
docker rm gamearray 2>/dev/null || true
echo "==> Starting new container..."
docker run -d --name gamearray \
--env-file /opt/gamearray/gamearray.env \
--network gamearray_net \
-p 127.0.0.1:8888:8888 \
"$IMAGE"
echo "==> Running migrations..."
docker exec gamearray python ./manage.py migrate
echo "==> Copying static files..."
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/
echo "==> Deploy complete."

5
infra/gamearray.env.j2 Normal file
View File

@@ -0,0 +1,5 @@
DJANGO_DEBUG_FALSE=1
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
DJANGO_ALLOWED_HOST={{ django_allowed_host }}
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
MAILGUN_API_KEY={{ mailgun_api_key }}

View File

@@ -1,22 +1,23 @@
$ANSIBLE_VAULT;1.1;AES256
33333861646565323863616363316265346132643135656533613763336462656361353562396163
6461313530323231383264373737323934346239316232340a356130646163636436663162386438
30636530373663643166616665313738303437636562643035303138613861656165663939613433
3465316130343732660a376536373136636162316665386536363536306130383033623735396634
66383262643630356536633366373933313637646539643137356330316463356139613738386661
34663231656232666630653162653732363431613461396464623133303965636432626536306634
64373530313532353531633263383335653239386530343330326237343862386436633666646235
31613530383834656462366535643030356562313237363735386337356165663564336564623862
34623863623738653735393734336635616135383036306231623464653432616265626233306230
63646466323135363466393832636466646434303564653032323366346430306336363435653761
30336437373231376261326264616131653833616236623365393334303834626162343761623037
36666665663866643263383835626336353030626337303461396665343731666465653662396164
34306261356130363037643637303632663830383331346334313336663163303730306265393031
39653231373139616465326561313633306433653461653931663164363565363636316433323933
61333536323936306538343336663966633161633565666231393261643062636239323264623364
38376266393937376133366561663931356236396131376137653536636539613738356466363334
32326165316434636631613366376235633337356135333531623861343039346261656239613036
65303836323538373832646531343234666330363161343337623539633464303161343765363331
35386233303563346662633239346363373931333764383233623161313965623266656364383037
32393738356532346665613031346338363738666265303765363438663062663237353033393262
6137
33616230376431343735626631623932393166343538653732383533323436326335343463646664
6565373531623465613661613533376231373837326438300a393665613839646231633737313938
64633035336663313163333634623732323537326363646132313136376131636666636538323066
3037373930303537320a313062646166353862633836373466316261363939633433663039323866
62333739303662343836306538393734343830366336323265393138343438363533353166383031
32313461313137643039376237346633316466646136353038633861333031663164656233366634
38303363383130376264373861393863623330623733643135643461383132613339376633353031
32313863323039646534633733383661333361313832333830383066633130396239626661643264
65636335303339613432326533343337366261356632313639623634386633383836333733663536
39383361353530646166643531333535356636326535383534326237666638326137616162646261
65316466323335653932636338653565383038313531383638393839313736643739363037353230
35653632353531656435396663316537333133653632366437613339303033333536643937353166
64363037653733303332643931343362303261643432366531326262383465313965633064356338
31336333373665373035656533633864316139303934623030383934393434356334643962666163
33343739366336613263333764306365333566363536616662383733616237396563346132336633
38663239613339376335386233386330396634323033343332366130616162666339393861306336
35383566383831356530633130313732356331616164646132626665646235396635386237313538
38656631336261646530303761643334303937613036363766303637376262373466316431323731
38666462313639353131303134646434646135366136343361353932326165626666306361393431
62646238323265346263386363373462313766616333326366366461346436383064336535376339
31356566356336386262393831616631666233633930393263623563386265343237323133313832
3430363635363332303963316530663765613666306233376463

View File

@@ -1,6 +1,6 @@
server {
listen 80;
server_name {{ django_allowed_host }};
server_name {{ django_allowed_host | replace(',', ' ')}};
location /static/ {
alias /var/www/gamearray/static/;

View File

@@ -3,7 +3,9 @@ attrs==25.4.0
certifi==2025.11.12
cffi==2.0.0
charset-normalizer==3.4.4
coverage
cssselect==1.3.0
dj-database-url
Django==6.0
django-stubs==5.2.8
django-stubs-ext==5.2.8

View File

@@ -1,8 +1,10 @@
cssselect==1.3.0
Django==6.0
dj-database-url
django-stubs==5.2.8
django-stubs-ext==5.2.8
gunicorn==23.0.0
lxml==6.0.2
psycopg2-binary
requests==2.31.0
whitenoise==6.11.0

8
src/.coveragerc Normal file
View File

@@ -0,0 +1,8 @@
[run]
source = apps
omit =
*/migrations/*
*/tests/*
[report]
show_missing = true

View File

@@ -0,0 +1,20 @@
# 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),
),
]

View File

@@ -10,6 +10,12 @@ class List(models.Model):
on_delete=models.CASCADE,
)
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_lists",
blank=True,
)
@property
def name(self):
return self.item_set.first().text

View File

@@ -1,18 +1,15 @@
from django.test import TestCase
from ..forms import (
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
ExistingListItemForm,
ItemForm,
)
from ..models import Item, List
from apps.dashboard.models import Item, List
class ItemFormTest(TestCase):
def test_form_validation_for_blank_items(self):
form = ItemForm(data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])
def test_form_save_handles_saving_to_a_list(self):
mylist = List.objects.create()
form = ItemForm(data={"text": "do re mi"})

View File

@@ -1,14 +1,12 @@
from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError
from django.test import TestCase
from ..models import Item, List
from apps.dashboard.models import Item, List
from apps.lyric.models import User
class ItemModelTest(TestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
def test_item_is_related_to_list(self):
mylist = List.objects.create()
item = Item()
@@ -42,10 +40,6 @@ class ItemModelTest(TestCase):
item = Item(list=list2, text="nojk")
item.full_clean() # should not raise
def test_string_representation(self):
item = Item(text="sample text")
self.assertEqual(str(item), "sample text")
class ListModelTest(TestCase):
def test_get_absolute_url(self):
mylist = List.objects.create()

View File

@@ -1,14 +1,17 @@
import lxml.html
from unittest import skip
from django.test import TestCase
from django.utils import html
from unittest import skip
from ..forms import (
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR,
)
from ..models import Item, List
from apps.dashboard.models import Item, List
from apps.lyric.models import User
class HomePageTest(TestCase):
def test_uses_home_template(self):
response = self.client.get('/')
@@ -149,12 +152,14 @@ class ListViewTest(TestCase):
class MyListsTest(TestCase):
def test_my_lists_url_renders_my_lists_template(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
response = self.client.get(f"/apps/dashboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_lists.html")
def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrong@owner.com")
User.objects.create(email="wrongowner@example.com")
correct_user = User.objects.create(email="a@b.cde")
self.client.force_login(correct_user)
response = self.client.get(f"/apps/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user)
@@ -164,3 +169,44 @@ class MyListsTest(TestCase):
self.client.post("/apps/dashboard/new_list", data={"text": "new item"})
new_list = List.objects.get()
self.assertEqual(new_list.owner, user)
def test_my_lists_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/apps/dashboard/users/{user.id}/")
self.assertRedirects(response, "/")
def test_my_lists_returns_403_for_wrong_user(self):
# create two users, login as user_a, request user_b's my_lists url
user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2)
response = self.client.get(f"/apps/dashboard/users/{user1.id}/")
# assert 403
self.assertEqual(response.status_code, 403)
class ShareListTest(TestCase):
def test_post_to_share_list_url_redirects_to_list(self):
our_list = List.objects.create()
alice = User.objects.create(email="alice@example.com")
response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_list = List.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_list.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_list(self):
our_list = List.objects.create()
response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")

View File

@@ -0,0 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.forms import (
EMPTY_ITEM_ERROR,
ItemForm,
)
class SimpleItemFormTest(SimpleTestCase):
def test_form_validation_for_blank_items(self):
form = ItemForm(data={"text": ""})
self.assertFalse(form.is_valid())
self.assertEqual(form.errors["text"], [EMPTY_ITEM_ERROR])

View File

@@ -0,0 +1,13 @@
from django.test import SimpleTestCase
from apps.dashboard.models import Item
class SimpleItemModelTest(SimpleTestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
def test_string_representation(self):
item = Item(text="sample text")
self.assertEqual(str(item), "sample text")

View File

@@ -5,4 +5,5 @@ urlpatterns = [
path('new_list', views.new_list, name='new_list'),
path('<int:list_id>/', views.view_list, name='view_list'),
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
path('<int:list_id>/share_list', views.share_list, name="share_list"),
]

View File

@@ -1,3 +1,4 @@
from django.http import HttpResponseForbidden
from django.shortcuts import redirect, render
from .forms import ExistingListItemForm, ItemForm
from .models import Item, List
@@ -31,4 +32,17 @@ def view_list(request, list_id):
def my_lists(request, user_id):
owner = User.objects.get(id=user_id)
if not request.user.is_authenticated:
return redirect("/")
if request.user.id != owner.id:
return HttpResponseForbidden()
return render(request, "apps/dashboard/my_lists.html", {"owner": owner})
def share_list(request, list_id):
our_list = List.objects.get(id=list_id)
try:
recipient = User.objects.get(email=request.POST["recipient"])
our_list.shared_with.add(recipient)
except User.DoesNotExist:
pass
return redirect(our_list)

View File

@@ -1,3 +1,6 @@
from django.contrib import admin
from .models import Token, User
# Register your models here.
admin.site.register(User)
admin.site.register(Token)

View File

@@ -13,7 +13,7 @@ class PasswordlessAuthenticationBackend:
try:
return User.objects.get(email=token.email)
except User.DoesNotExist:
return User.objects.create(email=token.email)
return User.objects.create_user(email=token.email)
def get_user(self, user_id):
try:

View File

@@ -1,4 +1,4 @@
# Generated by Django 6.0 on 2026-02-08 01:19
# Generated by Django 6.0 on 2026-02-20 00:48
import uuid
from django.db import migrations, models
@@ -15,9 +15,16 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='User',
fields=[
('password', models.CharField(max_length=128, verbose_name='password')),
('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('email', models.EmailField(max_length=254, unique=True)),
('is_staff', models.BooleanField(default=False)),
('is_superuser', models.BooleanField(default=False)),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='Token',

View File

@@ -1,16 +1,38 @@
import uuid
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.db import models
class UserManager(BaseUserManager):
def create_user(self, email):
user = self.model(email=email)
user.set_unusable_password()
user.save(using=self._db)
return user
def create_superuser(self, email, password):
user = self.model(email=email, is_staff=True, is_superuser=True)
user.set_password(password)
user.save(using=self._db)
return user
class Token(models.Model):
email = models.EmailField()
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class User(models.Model):
class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
objects = UserManager()
REQUIRED_FIELDS = []
USERNAME_FIELD = "email"
def has_perm(self, perm, obj=None):
return self.is_superuser
is_authenticated = True
is_anonymous =False
def has_module_perms(self, app_label):
return self.is_superuser

View File

@@ -2,17 +2,11 @@ import uuid
from django.http import HttpRequest
from django.test import TestCase
from ..authentication import PasswordlessAuthenticationBackend
from ..models import Token, User
from apps.lyric.authentication import PasswordlessAuthenticationBackend
from apps.lyric.models import Token, User
class AuthenticateTest(TestCase):
def test_returns_None_if_token_is_invalid_uuid(self):
result = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), "no-such-token"
)
self.assertIsNone(result)
def test_returns_None_if_token_uuid_not_found(self):
uid = uuid.uuid4()
result = PasswordlessAuthenticationBackend().authenticate(

View File

@@ -1,7 +1,9 @@
import uuid
from django.contrib import auth
from django.test import TestCase
from ..models import Token, User
from apps.lyric.models import Token, User
class UserModelTest(TestCase):
def test_model_is_configured_for_django_auth(self):
@@ -9,6 +11,7 @@ class UserModelTest(TestCase):
def test_user_is_valid_with_email_only(self):
user = User(email="a@b.cde")
user.set_unusable_password()
user.full_clean() # should not raise
def test_id_is_primary_key(self):
@@ -21,3 +24,19 @@ class TokenModelTest(TestCase):
token2 = Token.objects.create(email="v@w.xyz")
self.assertNotEqual(token1.pk, token2.pk)
self.assertIsInstance(token1.pk, uuid.UUID)
class UserManagerTest(TestCase):
def test_create_superuser_sets_is_staff_and_is_superuser(self):
user = User.objects.create_superuser(
email="admin@example.com",
password="correct-password",
)
self.assertTrue(user.is_staff)
self.assertTrue(user.is_superuser)
def test_create_superuser_sets_usable_password(self):
user = User.objects.create_superuser(
email="admin@example.com",
password="correct-password",
)
self.assertTrue(user.check_password("correct-password"))

View File

@@ -1,7 +1,10 @@
from django.contrib import auth
from django.test import TestCase
from unittest import mock
from ..models import Token
from apps.lyric.models import Token
class SendLoginEmailViewTest(TestCase):
def test_redirects_to_home_page(self):
@@ -19,7 +22,7 @@ class SendLoginEmailViewTest(TestCase):
self.assertEqual(mock_post.called, True)
data = mock_post.call_args.kwargs["data"]
self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.me")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.com")
self.assertEqual(data["to"], "discoman@example.com")
def test_adds_success_message(self):

View File

View File

@@ -0,0 +1,31 @@
from django.http import HttpRequest
from django.test import SimpleTestCase
from apps.lyric.authentication import PasswordlessAuthenticationBackend
from apps.lyric.models import User
class SimpleAuthenticateTest(SimpleTestCase):
def test_returns_None_if_token_is_invalid_uuid(self):
result = PasswordlessAuthenticationBackend().authenticate(
HttpRequest(), "no-such-token"
)
self.assertIsNone(result)
def test_returns_None_if_no_uuid(self):
result = PasswordlessAuthenticationBackend().authenticate(HttpRequest())
self.assertIsNone(result)
class UserPermissionsTest(SimpleTestCase):
def test_superuser_has_perm(self):
user = User(is_superuser=True)
self.assertTrue(user.has_perm("any.permission"))
def test_superuser_has_module_perms(self):
user = User(is_superuser=True)
self.assertTrue(user.has_module_perms("any_app"))
def test_non_superuser_has_no_perm(self):
user = User(is_superuser=False)
self.assertFalse(user.has_perm("any.permission"))

View File

@@ -18,7 +18,7 @@ def send_login_email(request):
f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages",
auth=("api", settings.MAILGUN_API_KEY),
data={
"from": "adman@howdy.earthmanrpg.me",
"from": "adman@howdy.earthmanrpg.com",
"to": email,
"subject": "A magic login link to your Dashboard",
"text": message_body,

View File

@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
from pathlib import Path
import os
import dj_database_url
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
@@ -26,20 +27,25 @@ if 'DJANGO_DEBUG_FALSE' in os.environ:
DEBUG = False
SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
ALLOWED_HOSTS = [host.strip() for host in os.environ['DJANGO_ALLOWED_HOST'].split(',')]
db_path = os.environ['DJANGO_DB_PATH']
CSRF_TRUSTED_ORIGINS = [f'https://{host.strip()}' for host in os.environ['DJANGO_ALLOWED_HOST'].split(',')]
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_HSTS_SECONDS = 60
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
else:
DEBUG = True
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-&9b_h=qpjy=sshhnsyg98&jp7(t6*v78__y%h2l$b#_@6z$-9r'
ALLOWED_HOSTS = []
db_path = BASE_DIR / 'db.sqlite3'
# Application definition
INSTALLED_APPS = [
# Django apps
# 'django.contrib.admin',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
@@ -90,12 +96,16 @@ ASGI_APPLICATION = 'core.asgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': db_path,
DATABASE_URL = os.environ.get('DATABASE_URL')
if DATABASE_URL:
DATABASES = {'default': dj_database_url.config(conn_max_age=600)}
else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
}
# Password validation
@@ -119,6 +129,7 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = "lyric.User"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"apps.lyric.authentication.PasswordlessAuthenticationBackend",
]
@@ -140,6 +151,9 @@ USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'
STATICFILES_DIRS = [
BASE_DIR / 'static_src',
]
LOGGING = {
"version": 1,
@@ -152,12 +166,6 @@ LOGGING = {
},
}
# Email Settings
EMAIL_HOST = "smtp.mailgun.org"
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER") # switch back to .environ[] when collectstatic moved outside docker build process
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD") # switch back to .environ[]
EMAIL_PORT = 587
EMAIL_USE_TLS = True
# Mailgun API settings (for HTTP API instead of SMTP)
MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY")
MAILGUN_DOMAIN = "howdy.earthmanrpg.me" # Your Mailgun domain
MAILGUN_DOMAIN = "howdy.earthmanrpg.com" # Your Mailgun domain

View File

@@ -1,10 +1,10 @@
# from django.contrib import admin
from django.contrib import admin
from django.http import HttpResponse
from django.urls import include, path
from apps.dashboard import views as dash_views
urlpatterns = [
# path('admin/', admin.site.urls),
path('admin/', admin.site.urls),
path('', dash_views.home_page, name='home'),
path('apps/dashboard/', include('apps.dashboard.urls')),
path('apps/lyric/', include('apps.lyric.urls')),

View File

@@ -1,15 +1,22 @@
import os
import time
from datetime import datetime
from django.conf import settings
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from pathlib import Path
from selenium import webdriver
from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .container_commands import reset_database
from .container_commands import create_session_on_server, reset_database
from .management.commands.create_session import create_pre_authenticated_session
MAX_WAIT = 5
MAX_WAIT = 10
SCREEN_DUMP_LOCATION = Path(__file__).absolute().parent / "screendumps"
# Decorator fns
@@ -25,7 +32,7 @@ def wait(fn):
time.sleep(0.5)
return modified_fn
# Functional Tests
class FunctionalTest(StaticLiveServerTestCase):
# Helper methods
def setUp(self):
@@ -39,26 +46,53 @@ class FunctionalTest(StaticLiveServerTestCase):
reset_database(self.test_server)
def tearDown(self):
if self._test_has_failed():
if not SCREEN_DUMP_LOCATION.exists():
SCREEN_DUMP_LOCATION.mkdir(parents=True)
self.take_screenshot()
self.dump_html()
self.browser.quit()
super().tearDown()
def _test_has_failed(self):
return self._outcome.result.failures or self._outcome.result.errors
def take_screenshot(self):
path = SCREEN_DUMP_LOCATION / self._get_filename("png")
print("screendumping to", path)
self.browser.get_screenshot_as_file(str(path))
def dump_html(self):
path = SCREEN_DUMP_LOCATION / self._get_filename("html")
print("dumping page html to", path)
path.write_text(self.browser.page_source)
def _get_filename(self, extension):
timestamp = datetime.now().isoformat().replace(":", ".")
return (
f"{self.__class__.__name__}.{self._testMethodName}-{timestamp}.{extension}"
)
@wait
def wait_for(self, fn):
return fn()
def get_item_input_box(self):
return self.browser.find_element(By.ID, "id_text")
@wait
def wait_for_row_in_list_table(self, row_text):
rows = self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr")
self.assertIn(row_text, [row.text for row in rows])
def add_list_item(self, item_text):
num_rows = len(self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr"))
self.get_item_input_box().send_keys(item_text)
self.get_item_input_box().send_keys(Keys.ENTER)
item_number = num_rows + 1
self.wait_for_row_in_list_table(f"{item_number}. {item_text}")
def create_pre_authenticated_session(self, email):
if self.test_server:
session_key = create_session_on_server(self.test_server, email)
else:
session_key = create_pre_authenticated_session(email)
## to set a cookie we need to first visit the domain
## 404 pages load the quickest!
self.browser.get(self.live_server_url + "/404_no_such_url/")
self.browser.add_cookie(
dict(
name=settings.SESSION_COOKIE_NAME,
value=session_key,
path="/",
)
)
@wait
def wait_to_be_logged_in(self, email):

View File

@@ -1,5 +1,6 @@
import subprocess
USER = "discoman"

View File

@@ -0,0 +1,52 @@
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import wait
class ListPage:
def __init__(self, test):
self.test = test
def get_table_rows(self):
return self.test.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr")
@wait
def wait_for_row_in_list_table(self, item_text, item_number):
expected_row_text = f"{item_number}. {item_text}"
rows = self.get_table_rows()
self.test.assertIn(expected_row_text, [row.text for row in rows])
def get_item_input_box(self):
return self.test.browser.find_element(By.ID, "id_text")
def add_list_item(self, item_text):
new_item_no = len(self.get_table_rows()) + 1
self.get_item_input_box().send_keys(item_text)
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table(item_text, new_item_no)
return self
def get_share_box(self):
return self.test.browser.find_element(
By.CSS_SELECTOR,
'input[name="recipient"]',
)
def get_shared_with_list(self):
return self.test.browser.find_elements(
By.CSS_SELECTOR,
".list-recipient"
)
def share_list_with(self, email):
self.get_share_box().send_keys(email)
self.get_share_box().send_keys(Keys.ENTER)
self.test.wait_for(
lambda: self.test.assertIn(
email, [item.text for item in self.get_shared_with_list()]
)
)
def get_list_owner(self):
return self.test.browser.find_element(By.ID, "id_list_owner").text

View File

@@ -1,5 +1,10 @@
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
from django.contrib.auth import (
BACKEND_SESSION_KEY,
HASH_SESSION_KEY,
SESSION_KEY,
get_user_model,
)
from django.contrib.sessions.backends.db import SessionStore
from django.core.management.base import BaseCommand
@@ -15,9 +20,10 @@ class Command(BaseCommand):
self.stdout.write(session_key)
def create_pre_authenticated_session(email):
user = User.objects.create(email=email)
user = User.objects.create_user(email=email)
session = SessionStore()
session[SESSION_KEY] = str(user.pk) # Convert UUID to string for JSON serialization
session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
session[BACKEND_SESSION_KEY] = "apps.lyric.authentication.PasswordlessAuthenticationBackend"
session[HASH_SESSION_KEY] = user.get_session_auth_hash()
session.save()
return session.session_key

View File

@@ -0,0 +1,17 @@
from selenium.webdriver.common.by import By
class MyListsPage:
def __init__(self, test):
self.test = test
def go_to_my_lists_page(self, email):
self.test.browser.get(self.test.live_server_url)
self.test.browser.find_element(By.LINK_TEXT, "My lists").click()
self.test.wait_for(
lambda: self.test.assertIn(
email,
self.test.browser.find_element(By.TAG_NAME, "h2").text,
)
)
return self

View File

@@ -0,0 +1,28 @@
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from apps.lyric.models import User
class AdminLoginTest(FunctionalTest):
def setUp(self):
super().setUp()
self.superuser = User.objects.create_superuser(
email="admin@example.com",
password="correct-password",
)
def test_can_access_admin(self):
self.browser.get(self.live_server_url + "/admin/")
self.wait_for(lambda: self.browser.find_element(By.ID, "id_username"))
self.browser.find_element(By.ID, "id_username").send_keys("admin@example.com")
self.browser.find_element(By.ID, "id_password").send_keys("correct-password")
self.browser.find_element(By.CSS_SELECTOR, "input[type=submit]").click()
body = self.wait_for(
lambda: self.browser.find_element(By.TAG_NAME, "body")
)
self.assertIn("Site administration", body.text)
self.assertIn("Users", body.text)
self.assertIn("Tokens", body.text)

View File

@@ -0,0 +1,13 @@
from selenium.webdriver.common.by import By
from .base import FunctionalTest
class JasmineTest(FunctionalTest):
def test_jasmine_specs_pass(self):
self.browser.get(self.live_server_url + "/static/tests/SpecRunner.html")
def check_results():
result = self.browser.find_element(By.CSS_SELECTOR, ".jasmine-overall-result")
self.assertIn("0 failures", result.text)
self.wait_for(check_results)

View File

@@ -2,23 +2,26 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from .list_page import ListPage
class LayoutAndStylingTest(FunctionalTest):
def test_layout_and_styling(self):
self.browser.get(self.live_server_url)
list_page = ListPage(self)
self.browser.set_window_size(1024, 768)
# print("Viewport width:", self.browser.execute_script("return window.innerWidth"))
inputbox = self.get_item_input_box()
inputbox = list_page.get_item_input_box()
self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2,
512,
delta=10,
)
self.add_list_item("testing")
inputbox = self.get_item_input_box()
list_page.add_list_item("testing")
inputbox = list_page.get_item_input_box()
self.assertAlmostEqual(
inputbox.location['x'] + inputbox.size['width'] / 2,
512,

View File

@@ -2,6 +2,8 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from .list_page import ListPage
class ItemValidationTest(FunctionalTest):
# Helper functions
@@ -11,43 +13,45 @@ class ItemValidationTest(FunctionalTest):
# Test methods
def test_cannot_add_empty_list_items(self):
self.browser.get(self.live_server_url)
self.get_item_input_box().send_keys(Keys.ENTER)
list_page = ListPage(self)
list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
)
self.get_item_input_box().send_keys("Purchase milk")
list_page.get_item_input_box().send_keys("Purchase milk")
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:valid")
)
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("1. Purchase milk")
list_page.get_item_input_box().send_keys(Keys.ENTER)
list_page.wait_for_row_in_list_table("Purchase milk", 1)
self.get_item_input_box().send_keys(Keys.ENTER)
list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("1. Purchase milk")
list_page.wait_for_row_in_list_table("Purchase milk", 1)
self.wait_for(
lambda: self.browser.find_element(By.CSS_SELECTOR, "#id_text:invalid")
)
self.get_item_input_box().send_keys("Make tea")
list_page.get_item_input_box().send_keys("Make tea")
self.wait_for(
lambda: self.browser.find_element(
By.CSS_SELECTOR,
"#id_text:valid",
)
)
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("2. Make tea")
list_page.get_item_input_box().send_keys(Keys.ENTER)
list_page.wait_for_row_in_list_table("Make tea", 2)
def test_cannot_add_duplicate_items(self):
self.browser.get(self.live_server_url)
self.add_list_item("Witness divinity")
list_page = ListPage(self)
list_page.add_list_item("Witness divinity")
self.get_item_input_box().send_keys("Witness divinity")
self.get_item_input_box().send_keys(Keys.ENTER)
list_page.get_item_input_box().send_keys("Witness divinity")
list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.assertEqual(
@@ -58,14 +62,15 @@ class ItemValidationTest(FunctionalTest):
def test_error_messages_are_cleared_on_input(self):
self.browser.get(self.live_server_url)
self.add_list_item("Gobbledygook")
self.get_item_input_box().send_keys("Gobbledygook")
self.get_item_input_box().send_keys(Keys.ENTER)
list_page = ListPage(self)
list_page.add_list_item("Gobbledygook")
list_page.get_item_input_box().send_keys("Gobbledygook")
list_page.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for(
lambda: self.assertTrue(self.get_error_element().is_displayed())
)
self.get_item_input_box().send_keys("a")
list_page.get_item_input_box().send_keys("a")
self.wait_for(
lambda: self.assertFalse(self.get_error_element().is_displayed())

View File

@@ -2,11 +2,14 @@ import re
from unittest.mock import patch
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
TEST_EMAIL = "discoman@example.com"
SUBJECT = "A magic login link to your Dashboard"
class LoginTest(FunctionalTest):
@patch('apps.lyric.views.requests.post')
def test_login_using_magic_link(self, mock_post):

View File

@@ -1,43 +1,22 @@
from django.conf import settings
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .container_commands import create_session_on_server
from .management.commands.create_session import create_pre_authenticated_session
from .list_page import ListPage
from .my_lists_page import MyListsPage
class MyListsTest(FunctionalTest):
def create_pre_authenticated_session(self, email):
if self.test_server:
session_key = create_session_on_server(self.test_server, email)
else:
session_key = create_pre_authenticated_session(email)
## to set a cookie we need to first visit the domain
## 404 pages load the quickest!
self.browser.get(self.live_server_url + "/404_no_such_url/")
self.browser.add_cookie(
dict(
name=settings.SESSION_COOKIE_NAME,
value=session_key,
path="/",
)
)
def test_logged_in_users_lists_are_saved_as_my_lists(self):
self.create_pre_authenticated_session("discoman@example.com")
self.browser.get(self.live_server_url)
self.add_list_item("Reticulate splines")
self.add_list_item("Regurgitate spines")
list_page = ListPage(self)
list_page.add_list_item("Reticulate splines")
list_page.add_list_item("Regurgitate spines")
first_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click()
self.wait_for(
lambda: self.assertIn(
"discoman@example.com",
self.browser.find_element(By.CSS_SELECTOR, "h2").text,
)
)
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
@@ -48,17 +27,14 @@ class MyListsTest(FunctionalTest):
)
self.browser.get(self.live_server_url)
self.add_list_item("Ribbon of death")
list_page.add_list_item("Ribbon of death")
second_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click()
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
)
self.browser.find_element(By.LINK_TEXT, "Ribbon of death").click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, second_list_url)
)
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for(

View File

@@ -0,0 +1,59 @@
import os
from selenium import webdriver
from selenium.webdriver.common.by import By
from .base import FunctionalTest
from .list_page import ListPage
from .my_lists_page import MyListsPage
# Helper fns
def quit_if_possible(browser):
try:
browser.quit()
except:
pass
# Test mdls
class SharingTest(FunctionalTest):
def test_can_share_a_list_with_another_user(self):
self.create_pre_authenticated_session("discoman@example.com")
disco_browser = self.browser
self.addCleanup(lambda: quit_if_possible(disco_browser))
options = webdriver.FirefoxOptions()
if os.environ.get("HEADLESS"):
options.add_argument("--headless")
ali_browser = webdriver.Firefox(options=options)
self.addCleanup(lambda: quit_if_possible(ali_browser))
self.browser = ali_browser
self.create_pre_authenticated_session("alice@example.com")
self.browser = disco_browser
self.browser.get(self.live_server_url)
list_page = ListPage(self).add_list_item("Send help")
share_box = list_page.get_share_box()
self.assertEqual(
share_box.get_attribute("placeholder"),
"friend@example.com",
)
list_page.share_list_with("alice@example.com")
self.browser = ali_browser
MyListsPage(self).go_to_my_lists_page("alice@example.com")
self.browser.find_element(By.LINK_TEXT, "Send help").click()
self.wait_for(
lambda: self.assertEqual(list_page.get_list_owner(), "discoman@example.com")
)
list_page.add_list_item("At your command, Disco King")
self.browser = disco_browser
self.browser.refresh()
list_page.wait_for_row_in_list_table("At your command, Disco King", 2)

View File

@@ -2,34 +2,36 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest
from .list_page import ListPage
MAX_WAIT = 5
class NewVisitorTest(FunctionalTest):
# Test methods
def test_can_start_a_todo_list(self):
self.browser.get(self.live_server_url)
list_page = ListPage(self)
self.assertIn('Earthman RPG', self.browser.title)
header_text = self.browser.find_element(By.TAG_NAME, 'h1').text
self.assertIn('Welcome', header_text)
inputbox = self.get_item_input_box()
inputbox = list_page.get_item_input_box()
self.assertEqual(inputbox.get_attribute('placeholder'), 'Enter a to-do item')
inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. Buy peacock feathers')
list_page.wait_for_row_in_list_table("Buy peacock feathers", 1)
self.add_list_item("Use peacock feathers to make a fly")
list_page.add_list_item("Use peacock feathers to make a fly")
self.wait_for_row_in_list_table('2. Use peacock feathers to make a fly')
self.wait_for_row_in_list_table('1. Buy peacock feathers')
list_page.wait_for_row_in_list_table("Use peacock feathers to make a fly", 2)
list_page.wait_for_row_in_list_table("Buy peacock feathers", 1)
def test_multiple_users_can_start_lists_at_different_urls(self):
self.browser.get(self.live_server_url)
self.add_list_item("Buy peacock feathers")
list_page = ListPage(self)
list_page.add_list_item("Buy peacock feathers")
edith_dash_url = self.browser.current_url
self.assertRegex(edith_dash_url, '/apps/dashboard/.+')
@@ -37,10 +39,11 @@ class NewVisitorTest(FunctionalTest):
self.browser.delete_all_cookies()
self.browser.get(self.live_server_url)
list_page = ListPage(self)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text
self.assertNotIn('Buy peacock feathers', page_text)
self.add_list_item("Buy milk")
list_page.add_list_item("Buy milk")
francis_dash_url = self.browser.current_url
self.assertRegex(francis_dash_url, '/apps/dashboard/.+')

View File

@@ -0,0 +1,21 @@
Copyright (c) 2008-2019 Pivotal Labs
Copyright (c) 2008-2026 The Jasmine developers
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,57 @@
console.log("Spec.js is loading");
describe("GameArray JavaScript", () => {
const inputId= "id_text";
const errorClass = "invalid-feedback";
const inputSelector = `#${inputId}`;
const errorSelector = `.${errorClass}`;
let testDiv;
let textInput;
let errorMsg;
beforeEach(() => {
console.log("beforeEach");
testDiv = document.createElement("div");
testDiv.innerHTML = `
<form>
<input
id="${inputId}"
name="text"
class="form-control form-control-lg is-invalid"
placeholder="Enter a to-do item"
value="Value as submitted"
aria-describedby="id_text_feedback"
required
/>
<div id="id_text_feedback" class="${errorClass}">An error message</div>
</form>
`;
document.body.appendChild(testDiv);
textInput = document.querySelector(inputSelector);
errorMsg = document.querySelector(errorSelector);
});
afterEach(() => {
testDiv.remove();
});
it("should have a useful html fixture", () => {
console.log("in test 1");
expect(errorMsg.checkVisibility()).toBe(true);
});
it("should hide error message on input", () => {
console.log("in test 2");
initialize(inputSelector);
textInput.dispatchEvent(new InputEvent("input"));
expect(errorMsg.checkVisibility()).toBe(false);
});
it("should not hide error message before event is fired", () => {
console.log("in test 3");
initialize(inputSelector);
expect(errorMsg.checkVisibility()).toBe(true);
});
});

View File

@@ -0,0 +1,34 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Disco DeDisco">
<meta name="robots" content="noindex, nofollow">
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"/>
<link rel="stylesheet" href="lib/jasmine-6.0.1/jasmine.css">
<title>Jasmine Spec Runner</title>
<link rel="stylesheet" href="lib/jasmine.css">
<!-- Jasmine -->
<script src="lib/jasmine-6.0.1/jasmine.js"></script>
<script src="lib/jasmine-6.0.1/jasmine-html.js"></script>
<script src="lib/jasmine-6.0.1/boot0.js"></script>
<!-- spec files -->
<script src="Spec.js"></script>
<!-- src files -->
<script src="/static/apps/scripts/dashboard.js"></script>
<!-- Jasmine env config (optional) -->
<script src="lib/jasmine-6.0.1/boot1.js"></script>
</head>
<body>
</body>
</html>

View File

@@ -0,0 +1,68 @@
/*
Copyright (c) 2008-2019 Pivotal Labs
Copyright (c) 2008-2026 The Jasmine developers
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
'use strict';
/**
This file starts the process of "booting" Jasmine. It initializes Jasmine,
makes its globals available, and creates the env. This file should be loaded
after `jasmine.js` and `jasmine_html.js`, but before `boot1.js` or any project
source files or spec files are loaded.
*/
(function() {
const jasmineRequire = window.jasmineRequire || require('./jasmine.js');
/**
* ## Require &amp; Instantiate
*
* Require Jasmine's core files. Specifically, this requires and attaches all of Jasmine's code to the `jasmine` reference.
*/
const jasmine = jasmineRequire.core(jasmineRequire),
global = jasmine.getGlobal();
global.jasmine = jasmine;
/**
* Since this is being run in a browser and the results should populate to an HTML page, require the HTML-specific Jasmine code, injecting the same reference.
*/
jasmineRequire.html(jasmine);
/**
* Create the Jasmine environment. This is used to run all specs in a project.
*/
const env = jasmine.getEnv();
/**
* ## The Global Interface
*
* Build up the functions that will be exposed as the Jasmine public interface. A project can customize, rename or alias any of these functions as desired, provided the implementation remains unchanged.
*/
const jasmineInterface = jasmineRequire.interface(jasmine, env);
/**
* Add all of the Jasmine global/public interface to the global scope, so a project can use the public interface directly. For example, calling `describe` in specs instead of `jasmine.getEnv().describe`.
*/
for (const property in jasmineInterface) {
global[property] = jasmineInterface[property];
}
})();

View File

@@ -0,0 +1,64 @@
/*
Copyright (c) 2008-2019 Pivotal Labs
Copyright (c) 2008-2026 The Jasmine developers
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
'use strict';
/**
This file finishes 'booting' Jasmine, performing all of the necessary
initialization before executing the loaded environment and all of a project's
specs. This file should be loaded after `boot0.js` but before any project
source files or spec files are loaded. Thus this file can also be used to
customize Jasmine for a project.
If a project is using Jasmine via the standalone distribution, this file can
be customized directly. If you only wish to configure the Jasmine env, you
can load another file that calls `jasmine.getEnv().configure({...})`
after `boot0.js` is loaded and before this file is loaded.
*/
(function() {
const env = jasmine.getEnv();
const urls = new jasmine.HtmlReporterV2Urls();
/**
* Configures Jasmine based on the current set of query parameters. This
* supports all parameters set by the HTML reporter as well as
* spec=partialPath, which filters out specs whose paths don't contain the
* parameter.
*/
env.configure(urls.configFromCurrentUrl());
const currentWindowOnload = window.onload;
window.onload = function() {
if (currentWindowOnload) {
currentWindowOnload();
}
// The HTML reporter needs to be set up here so it can access the DOM. Other
// reporters can be added at any time before env.execute() is called.
const htmlReporter = new jasmine.HtmlReporterV2({ env, urls });
env.addReporter(htmlReporter);
env.execute();
};
})();

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
{% block content %}
<div class="row justify-content-center">
<small>List created by: <span id="id_list_owner">{{ list.owner.email }}</span></small>
<div class="col-lg-6">
<table id="id_list_table" class="table">
{% for item in list.item_set.all %}
@@ -19,6 +20,35 @@
</table>
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-6">
<form method="POST" action="{% url "share_list" list.id %}">
{% csrf_token %}
<input
id="id_recipient"
name="recipient"
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}"
placeholder="friend@example.com"
aria-describedby="id_recipient_feedback"
required
/>
{% if form.errors %}
<div id="id_recipient_feedback" class="invalid-feedback">
{{ form.errors.recipient.0 }}
</div>
{% endif %}
<button type="submit" class="btn btn-primary">Share</button>
</form>
<small>List shared with:
{% for user in list.shared_with.all %}
<span class="list-recipient">{{ user.email }}</span>
{% endfor %}
</small>
</div>
</div>
{% endblock content %}
{% block scripts %}

View File

@@ -9,4 +9,10 @@
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
<h3>Lists shared with me</h3>
<ul>
{% for list in owner.shared_lists.all %}
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
{% endfor %}
</ul>
{% endblock content %}