Compare commits

..

42 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
Disco DeDisco
d1c715ab0c .woodpecker.yaml FT step now covers collectstatic
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-11 13:26:55 -05:00
Disco DeDisco
0222aa9ea2 .woodpecker.yaml pipeline updated with first-push FT testing
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-11 12:54:49 -05:00
Disco DeDisco
5ff0e4ec10 commented out second step of .woodpecker.yaml to confirm working test pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-10 22:44:10 -05:00
Disco DeDisco
b94f1f48aa Dockerfile.ci & debug-ci now build working container for CI test image 2026-02-10 22:42:45 -05:00
Disco DeDisco
9fa428c63a added cssselect to requirements.txt to accompany lxml library from previous commit
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-10 21:45:49 -05:00
Disco DeDisco
5a633dccee apps.lyric.views and .tests.test_views updated for better Mailgun API post mocking; UTs passing locally
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-10 21:42:15 -05:00
Disco DeDisco
fec1cfcb30 Gitea repo init; Woodpecker pipeline init; much CI/CD structure outlined in ./infra, incl. docker-compose.yaml, cicd-playbook.yaml, new cicd vault, & config files for gitea, woodpecker & ansible
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-10 21:16:00 -05:00
Disco DeDisco
4e1feddb45 new name @property in List model, replete w. proper testing in tests.test_models 2026-02-08 22:50:03 -05:00
Disco DeDisco
94a161fe09 List objects now container owner values, saved upon creation, linked to user fk; apps.dashboard.views updated accordingly; 36 UTs passing (2 new) 2026-02-08 22:33:15 -05:00
Disco DeDisco
6c0e9bb6ec new_list() FBV tries to assign List owner, but List() model has no such attr 2026-02-08 22:23:43 -05:00
Disco DeDisco
c176fe6cb3 new UT ensures correct list ownership from apps.dashboard.views 2026-02-08 22:18:41 -05:00
Disco DeDisco
fad8657c97 placeholder view & code for my_lists.html implemented 2026-02-08 21:52:04 -05:00
Disco DeDisco
306b4c8e5e new template _partials for apps.dashboard, incl. _form.html & _scripts.html; respective form, table & content code offloaded from base.html, home.html & list.html; functional_tests.test_my_list TODO resumed (only FT not currently passing, but this is as expected) 2026-02-08 21:43:58 -05:00
Disco DeDisco
8190317c21 nginx compatibility added to serve static files on server; whitenoise installed to catch static file serving in local docker container, also added to core.settings middleware; console logs & print statements removed from dashboard.js & functional_tests.container_commands; ansible playbook and nginx config file support nginx w.in deployment workflow 2026-02-08 17:55:09 -05:00
Disco DeDisco
07a76cb32d added new template apps/dashboard/my_lists.html; all FTs passing green locally, tho half og .test_my_lists TODO'd out; test_login uses mock-patch architecture to avoid Mailgun and DigitalOcean magic login link testing restraints 2026-02-07 22:47:04 -05:00
Disco DeDisco
0c413a9cc2 pre–db-reset commit; attempting to update User model w. uuid pk enumeration 2026-02-07 20:15:27 -05:00
Disco DeDisco
58b526f434 add_list_item() helper added to functional_tests.base; further propagated into .test_layout_and_styling, .test_list_item_validation, .test_my_lists & .test_simple_list_creation; all FTs passing locally (tho js-dependent FT still require nginx to serve scripts properly in docker or on server) 2026-02-07 19:44:47 -05:00
85 changed files with 16391 additions and 423 deletions

View File

@@ -1 +1,2 @@
src/db.sqlite3
.claude

1
.gitattributes vendored Normal file
View File

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

11
.gitignore vendored
View File

@@ -1,6 +1,9 @@
# Created by https://www.toptal.com/developers/gitignore/api/django
# Edit at https://www.toptal.com/developers/gitignore?templates=django
### Claude ###
.claude
### Django ###
*.log
*.pot
@@ -11,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.
@@ -45,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.

62
.woodpecker.yaml Normal file
View File

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

8
Dockerfile.ci Normal file
View File

@@ -0,0 +1,8 @@
FROM python:3.13-slim
COPY requirements.dev.txt requirements.dev.txt
RUN pip install -r requirements.dev.txt
RUN apt update -y && apt install -y firefox-esr
COPY infra/debug-ci.py debug-ci.py
CMD ["python", "debug-ci.py"]

3
ansible.cfg Normal file
View File

@@ -0,0 +1,3 @@
[defaults]
inventory = infra/inventory.ini
vault_password_file = ~/.vault-pass.txt

Binary file not shown.

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.

114
infra/cicd-playbook.yaml Normal file
View File

@@ -0,0 +1,114 @@
- hosts: cicd
tasks:
- name: Install Docker
ansible.builtin.apt:
name: docker.io
state: latest
update_cache: true
become: true
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: latest
become: true
- name: Add out user to the docker group, so we don't need sudo/become
ansible.builtin.user:
name: '{{ ansible_user }}'
groups: docker
append: true # don't remove any existing groups
become: true
- name: Reset ssh connection to allow the user/group change to take effect
ansible.builtin.meta: reset_connection
- name: Install docker-compose-plugin & certbot
ansible.builtin.apt:
name:
- docker-compose-v2
- certbot
- python3-certbot-nginx
state: latest
become: true
- name: Create /opt/cicd/ directory tree
ansible.builtin.file:
path: "/opt/cicd/nginx"
state: directory
become: true
- name: Cp docker-compose.yaml to server
ansible.builtin.copy:
src: cicd/docker-compose.yaml
dest: /opt/cicd/docker-compose.yaml
become: true
- name: Template .env to /opt/cicd/
ansible.builtin.template:
src: cicd/.env.j2
dest: /opt/cicd/.env
mode: "0600"
become: true
- name: Deploy nginx config (Gitea)
ansible.builtin.copy:
src: cicd/nginx/gitea.conf
dest: /etc/nginx/sites-available/gitea
become: true
notify: Restart nginx
- name: Deploy nginx config (Woodpecker)
ansible.builtin.copy:
src: cicd/nginx/woodpecker.conf
dest: /etc/nginx/sites-available/woodpecker
become: true
notify: Restart nginx
- name: Enable nginx site (Gitea)
ansible.builtin.file:
src: /etc/nginx/sites-available/gitea
dest: /etc/nginx/sites-enabled/gitea
state: link
become: true
notify: Restart nginx
- name: Enable nginx site (Woodpecker)
ansible.builtin.file:
src: /etc/nginx/sites-available/woodpecker
dest: /etc/nginx/sites-enabled/woodpecker
state: link
become: true
notify: Restart nginx
- name: Remove default nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
become: true
notify: Restart nginx
- name: Obtain SSL certs via certbot
ansible.builtin.command:
cmd: >
certbot --nginx
-d gitea.earthmanrpg.me
-d ci.earthmanrpg.me
--non-interactive
--agree-tos
-m discodedisco@outlook.com
creates: /etc/letsencrypt/live/gitea.earthmanrpg.me/fullchain.pem
become: true
- name: Run docker compose -f /opt/cicd/docker-compose.yaml up -d
ansible.builtin.command:
cmd: docker compose -f /opt/cicd/docker-compose.yaml up -d
become: true
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
become: true

4
infra/cicd/.env.j2 Normal file
View File

@@ -0,0 +1,4 @@
WOODPECKER_ADMIN={{ woodpecker_admin }}
WOODPECKER_AGENT_SECRET={{ woodpecker_agent_secret }}
WOODPECKER_GITEA_CLIENT={{ woodpecker_gitea_client }}
WOODPECKER_GITEA_SECRET={{ woodpecker_gitea_secret }}

View File

@@ -0,0 +1,59 @@
services:
gitea:
image: docker.gitea.com/gitea:1.24
restart: unless-stopped
environment:
- USER_UID=1000
- USER_GID=1000
- GITEA__server__ROOT_URL=https://gitea.earthmanrpg.me/
- GITEA__server__DOMAIN=gitea.earthmanrpg.me
- GITEA__server__SSH_DOMAIN=gitea.earthmanrpg.me
- GITEA__webhook__ALLOWED_HOST_LIST=external,loopback
volumes:
- ./data/gitea:/data # Gitea stores repos, db, config here
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "127.0.0.1:3000:3000" # http (only localhost, nginx proxies)
- "222:22" # ssh (public, for git push)
networks:
- cicd
woodpecker-server:
image: woodpeckerci/woodpecker-server:v3
restart: unless-stopped
depends_on:
- gitea
environment:
- WOODPECKER_HOST=https://ci.earthmanrpg.me
- WOODPECKER_OPEN=false
- WOODPECKER_ADMIN=${WOODPECKER_ADMIN}
- WOODPECKER_GITEA=true
- WOODPECKER_GITEA_URL=https://gitea.earthmanrpg.me
- WOODPECKER_GITEA_CLIENT=${WOODPECKER_GITEA_CLIENT}
- WOODPECKER_GITEA_SECRET=${WOODPECKER_GITEA_SECRET}
- WOODPECKER_AGENT_SECRET=${WOODPECKER_AGENT_SECRET}
volumes:
- ./data/woodpecker-server:/var/lib/woodpecker
ports:
- "127.0.0.1:8000:8000" # (only nginx proxies)
networks:
- cicd
woodpecker-agent:
image: woodpeckerci/woodpecker-agent:v3
restart: unless-stopped
depends_on:
- woodpecker-server
environment:
- 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:
- cicd
networks:
cicd:

View File

@@ -0,0 +1,14 @@
server {
listen 80;
server_name gitea.earthmanrpg.me;
client_max_body_size 100m;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name ci.earthmanrpg.me;
location / {
proxy_pass http://127.0.0.1:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# WebSocket support (Woodpecker live log streaming)
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}

5
infra/debug-ci.py Normal file
View File

@@ -0,0 +1,5 @@
from selenium import webdriver
options = webdriver.FirefoxOptions()
options.add_argument("--headless")
webdriver.Firefox(options=options).quit()

View File

@@ -1,4 +1,4 @@
- hosts: staging
- hosts: all
tasks:
- name: Debug django_allowed_host
@@ -12,6 +12,34 @@
update_cache: true
become: true
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: latest
become: true
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/gamearray
become: true
notify: Restart nginx
- name: Enable nginx site
ansible.builtin.file:
src: /etc/nginx/sites-available/gamearray
dest: /etc/nginx/sites-enabled/gamearray
state: link
become: true
notify: Restart nginx
- name: Remove default nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
become: true
notify: Restart nginx
- name: Add our user to the docker group, so we don't need sudo/become
ansible.builtin.user:
name: '{{ ansible_user }}'
@@ -22,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:
@@ -66,31 +63,95 @@
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:
80:8888 # container port 80 (standard http port) maps to server port 8888 (arbitrary internal port)
127.0.0.1:8888:8888
- name: Create static files directory
ansible.builtin.file:
path: /var/www/gamearray/static
state: directory
owner: www-data
group: www-data
become: true
- name: Copy static files from container to host
ansible.builtin.command:
cmd: docker cp gamearray:/src/static/. /var/www/gamearray/static/
become: true
- 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
ansible.builtin.service:
name: nginx
state: restarted
become: true

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

@@ -0,0 +1,18 @@
$ANSIBLE_VAULT;1.1;AES256
32353432336330376365646535626462343239653737383565353163623230393338633536653231
3536303839613765643136366535333962376665306662620a383634393561653139306139366631
34663365316264656235396538653035366437663030383962373936336464306632333662623737
3834366363383135330a643064383065653565636334636336366138383464663562363030313636
30356332663332613565313339633036653463636264643531383231613739653061333134333337
36643365303637653635613838626466353163386331383864623936326636303734326566626561
32633562336663303866376335363935343031363465353834303133376363376665333363343535
35616532616264663631626135353134623965613539336562353963363534336261663530343331
30636332633538313838363434386331323837376663666463393736383830363966373662316539
39343466626633313734363732386135333636663735646430616130353765616133393165366231
62613130306164323938373964313938613634396665363161646264323332316664656265643231
35393032343263316464333530623365303430623235363862393435646433616566336136353236
34636630373530393464663839656661313364333830383966353739346335643635393030396335
30653332306163346431353535613338323163333032333435613139383366336338326635363165
34353033346362646131353339666561353766366239643661313334646365666236616232623865
37636162613334353632346664343034303861393833363535343063623831613735303762643064
31303161306364356436393665363939643736353238333037393936303136336566

View File

@@ -1,22 +0,0 @@
$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

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,7 +1,10 @@
[staging]
staging.earthmanrpg.me
staging.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
[production]
www.earthmanrpg.me
earthmanrpg.me
dashboard.earthmanrpg.me
www.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
dashboard.earthmanrpg.me ansible_user=discoman ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd
[cicd]
gitea.earthmanrpg.me ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_ed25519_wsl_python-tdd

16
infra/nginx.conf.j2 Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name {{ django_allowed_host | replace(',', ' ')}};
location /static/ {
alias /var/www/gamearray/static/;
}
location / {
proxy_pass http://127.0.0.1:8888;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

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
@@ -27,4 +29,5 @@ typing_extensions==4.15.0
tzdata==2025.3
urllib3==2.6.2
websocket-client==1.9.0
whitenoise==6.11.0
wsproto==1.3.2

View File

@@ -1,5 +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

@@ -1,5 +1,6 @@
# Generated by Django 6.0 on 2025-12-31 05:18
# Generated by Django 6.0 on 2026-02-08 01:19
import django.db.models.deletion
from django.db import migrations, models
@@ -12,9 +13,21 @@ class Migration(migrations.Migration):
operations = [
migrations.CreateModel(
name='Item',
name='List',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
),
migrations.CreateModel(
name='Item',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('text', models.TextField(default='')),
('list', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='dashboard.list')),
],
options={
'ordering': ('id',),
'unique_together': {('list', 'text')},
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 6.0 on 2025-12-31 05:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='item',
name='text',
field=models.TextField(default=''),
),
]

View File

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

View File

@@ -1,25 +0,0 @@
# Generated by Django 6.0 on 2026-01-03 03:05
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0002_item_text'),
]
operations = [
migrations.CreateModel(
name='List',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
],
),
migrations.AddField(
model_name='item',
name='list',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='dashboard.list'),
),
]

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

@@ -1,17 +0,0 @@
# Generated by Django 6.0 on 2026-01-24 02:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0003_list_item_list'),
]
operations = [
migrations.AlterUniqueTogether(
name='item',
unique_together={('list', 'text')},
),
]

View File

@@ -1,17 +0,0 @@
# Generated by Django 6.0 on 2026-01-24 17:54
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0004_alter_item_unique_together'),
]
operations = [
migrations.AlterModelOptions(
name='item',
options={'ordering': ('id',)},
),
]

View File

@@ -2,6 +2,24 @@ from django.db import models
from django.urls import reverse
class List(models.Model):
owner = models.ForeignKey(
"lyric.User",
related_name="lists",
blank=True,
null=True,
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
def get_absolute_url(self):
return reverse("view_list", args=[self.id])

View File

@@ -1,9 +1,9 @@
console.log("apps/scripts/dashboard.js loading");
// console.log("apps/scripts/dashboard.js loading");
const initialize = (inputSelector) => {
console.log("initialize called!");
// console.log("initialize called!");
const textInput = document.querySelector(inputSelector);
textInput.oninput = () => {
console.log("oninput triggered");
// console.log("oninput triggered");
textInput.classList.remove("is-invalid");
};
};

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,13 +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()
@@ -41,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()
@@ -59,3 +54,17 @@ class ListModelTest(TestCase):
list(list1.item_set.all()),
[item1, item2, item3],
)
def test_lists_can_have_owners(self):
user = User.objects.create(email="a@b.cde")
mylist = List.objects.create(owner=user)
self.assertIn(mylist, user.lists.all())
def test_list_owner_is_optional(self):
List.objects.create()
def test_list_name_is_first_item_text(self):
list_ = List.objects.create()
Item.objects.create(list=list_, text="first item")
Item.objects.create(list=list_, text="second item")
self.assertEqual(list_.name, "first item")

View File

@@ -1,12 +1,16 @@
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
import lxml.html
from apps.dashboard.models import Item, List
from apps.lyric.models import User
class HomePageTest(TestCase):
def test_uses_home_template(self):
@@ -144,3 +148,65 @@ class ListViewTest(TestCase):
self.assertContains(response, expected_error)
self.assertTemplateUsed(response, "apps/dashboard/list.html")
self.assertEqual(Item.objects.all().count(), 1)
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="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)
def test_list_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde")
self.client.force_login(user)
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

@@ -4,4 +4,6 @@ from . import views
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,6 +1,8 @@
from django.http import HttpResponseForbidden
from django.shortcuts import redirect, render
from .forms import ExistingListItemForm, ItemForm
from .models import Item, List
from apps.lyric.models import User
def home_page(request):
return render(request, "apps/dashboard/home.html", {"form": ItemForm()})
@@ -9,6 +11,9 @@ def new_list(request):
form = ItemForm(data=request.POST)
if form.is_valid():
nulist = List.objects.create()
if request.user.is_authenticated:
nulist.owner = request.user
nulist.save()
form.save(for_list=nulist)
return redirect(nulist)
else:
@@ -25,3 +30,19 @@ def view_list(request, list_id):
return redirect(our_list)
return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form})
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,5 +1,6 @@
# Generated by Django 6.0 on 2026-01-30 20:02
# Generated by Django 6.0 on 2026-02-20 00:48
import uuid
from django.db import migrations, models
@@ -14,8 +15,22 @@ class Migration(migrations.Migration):
migrations.CreateModel(
name='User',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('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',
fields=[
('email', models.EmailField(max_length=254)),
('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
],
),
]

View File

@@ -1,21 +0,0 @@
# Generated by Django 6.0 on 2026-01-30 20:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Token',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('email', models.EmailField(max_length=254)),
('uid', models.UUIDField()),
],
),
]

View File

@@ -1,28 +0,0 @@
# Generated by Django 6.0 on 2026-01-31 01:03
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('lyric', '0002_token'),
]
operations = [
migrations.RemoveField(
model_name='token',
name='id',
),
migrations.AlterField(
model_name='token',
name='uid',
field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
),
migrations.AlterField(
model_name='user',
name='id',
field=models.BigAutoField(primary_key=True, serialize=False),
),
]

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):
id = models.BigAutoField(primary_key=True)
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"
is_authenticated = True
is_anonymous =False
def has_perm(self, perm, obj=None):
return self.is_superuser
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):
@@ -10,17 +13,17 @@ class SendLoginEmailViewTest(TestCase):
)
self.assertRedirects(response, "/")
@mock.patch("apps.lyric.views.send_mail")
def test_sends_mail_to_address_from_post(self, mock_send_mail):
@mock.patch("apps.lyric.views.requests.post")
def test_sends_mail_to_address_from_post(self, mock_post):
self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"}
)
self.assertEqual(mock_send_mail.called, True)
(subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
self.assertEqual(subject, "A magic login link to your Dashboard")
self.assertEqual(from_email, "adman@howdy.earthmanrpg.me")
self.assertEqual(to_list, ["discoman@example.com"])
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.com")
self.assertEqual(data["to"], "discoman@example.com")
def test_adds_success_message(self):
response = self.client.post(
@@ -44,16 +47,16 @@ class SendLoginEmailViewTest(TestCase):
self.assertEqual(token.email, "discoman@example.com")
@mock.patch("apps.lyric.views.send_mail")
def test_sends_link_to_login_using_token_uid(self, mock_send_mail):
@mock.patch("apps.lyric.views.requests.post")
def test_sends_link_to_login_using_token_uid(self, mock_post):
self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"}
)
token = Token.objects.get()
expected_url = f"http://testserver/apps/lyric/login?token={token.uid}"
(subject, body, from_email, to_list), kwargs = mock_send_mail.call_args
self.assertIn(expected_url, body)
data = mock_post.call_args.kwargs["data"]
self.assertIn(expected_url, data["text"])
class LoginViewTest(TestCase):
def test_redirects_to_home_page(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',
@@ -57,6 +63,7 @@ INSTALLED_APPS = [
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'whitenoise.middleware.WhiteNoiseMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
@@ -89,10 +96,14 @@ ASGI_APPLICATION = 'core.asgi.application'
# Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases
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': db_path,
'NAME': BASE_DIR / 'db.sqlite3',
}
}
@@ -118,6 +129,7 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = "lyric.User"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"apps.lyric.authentication.PasswordlessAuthenticationBackend",
]
@@ -139,6 +151,9 @@ USE_TZ = True
STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static'
STATICFILES_DIRS = [
BASE_DIR / 'static_src',
]
LOGGING = {
"version": 1,
@@ -151,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,14 +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
@@ -24,30 +32,67 @@ def wait(fn):
time.sleep(0.5)
return modified_fn
# Functional Tests
class FunctionalTest(StaticLiveServerTestCase):
# Helper methods
def setUp(self):
self.browser = webdriver.Firefox()
options = webdriver.FirefoxOptions()
if os.environ.get("HEADLESS"):
options.add_argument("--headless")
self.browser = webdriver.Firefox(options=options)
self.test_server = os.environ.get("TEST_SERVER")
if self.test_server:
self.live_server_url = 'http://' + self.test_server
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 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"
@@ -20,7 +21,7 @@ def _exec_in_container(host, commands):
return _exec_in_container_on_server(host, commands)
def _exec_in_container_locally(commands):
print(f"Running {commands} on inside local docker container")
# print(f"Running {commands} on inside local docker container")
return _run_commands(["docker", "exec", _get_container_id()] + commands)
def _exec_in_container_on_server(host, commands):
@@ -52,5 +53,5 @@ def _run_commands(commands):
result = process.stdout.decode()
if process.returncode != 0:
raise Exception(result)
print(f"Result: {result!r}")
# print(f"Result: {result!r}")
return result.strip()

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] = user.pk
session[BACKEND_SESSION_KEY] = settings.AUTHENTICATION_BACKENDS[0]
session[SESSION_KEY] = str(user.pk) # Convert UUID to string for JSON serialization
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,25 +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,
)
inputbox.send_keys('testing')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. 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,45 +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.get_item_input_box().send_keys("Witness divinity")
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("1. 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(
@@ -60,16 +62,15 @@ class ItemValidationTest(FunctionalTest):
def test_error_messages_are_cleared_on_input(self):
self.browser.get(self.live_server_url)
self.get_item_input_box().send_keys("Banter too thicc")
self.get_item_input_box().send_keys(Keys.ENTER)
self.wait_for_row_in_list_table("1. Banter too thicc")
self.get_item_input_box().send_keys("Banter too thicc")
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

@@ -1,14 +1,21 @@
import re
from django.core import mail
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):
def test_login_using_magic_link(self):
@patch('apps.lyric.views.requests.post')
def test_login_using_magic_link(self, mock_post):
# Mock successful Mailgun API response
mock_post.return_value.status_code = 200
self.browser.get(self.live_server_url)
self.browser.find_element(By.CSS_SELECTOR, "input[name=email]").send_keys(
TEST_EMAIL, Keys.ENTER
@@ -24,14 +31,20 @@ class LoginTest(FunctionalTest):
if self.test_server:
return
email = mail.outbox.pop()
self.assertIn(TEST_EMAIL, email.to)
self.assertEqual(email.subject, SUBJECT)
# Verify Mailgun API was called
self.assertEqual(mock_post.call_count, 1)
call_kwargs = mock_post.call_args.kwargs
self.assertIn("Use this magic link to login to your Dashboard", email.body)
url_search = re.search(r"http://.+/.+$", email.body)
# Check email data
self.assertEqual(call_kwargs['data']['to'], TEST_EMAIL)
self.assertEqual(call_kwargs['data']['subject'], SUBJECT)
# Extract magic link URL from email body
email_body = call_kwargs['data']['text']
self.assertIn("Use this magic link to login to your Dashboard", email_body)
url_search = re.search(r"http://.+/.+$", email_body)
if not url_search:
self.fail(f"Could not find url in email body:\n{email.body}")
self.fail(f"Could not find url in email body:\n{email_body}")
url = url_search.group(0)
self.assertIn(self.live_server_url, url)

View File

@@ -1,35 +1,45 @@
from django.conf import settings
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
from django.contrib.sessions.backends.db import SessionStore
from .base import FunctionalTest
from .container_commands import create_session_on_server
from .management.commands.create_session import create_pre_authenticated_session
from selenium.webdriver.common.by import By
User = get_user_model()
from .base import FunctionalTest
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)
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
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
)
self.browser.find_element(By.LINK_TEXT, "Reticulate splines").click()
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, first_list_url)
)
def test_logged_in_users_are_saved_as_my_lists(self):
email = "discoman@example.com"
self.browser.get(self.live_server_url)
self.wait_to_be_logged_out(email)
list_page.add_list_item("Ribbon of death")
second_list_url = self.browser.current_url
self.create_pre_authenticated_session(email)
self.browser.get(self.live_server_url)
self.wait_to_be_logged_in(email)
self.browser.find_element(By.LINK_TEXT, "My lists").click()
self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
)
MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for(
lambda: self.assertEqual(
self.browser.find_elements(By.LINK_TEXT, "My lists"),
[],
)
)

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,39 +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)
inputbox = self.get_item_input_box()
inputbox.send_keys('Use peacock feathers to make a fly')
inputbox.send_keys(Keys.ENTER)
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)
inputbox = self.get_item_input_box()
inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. 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/.+')
@@ -42,13 +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)
inputbox = self.get_item_input_box()
inputbox.send_keys('Buy milk')
inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. 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

@@ -0,0 +1,17 @@
<form method="POST" action="{{ form_action }}">
{% csrf_token %}
<input
id="id_text"
name="text"
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}"
placeholder="Enter a to-do item"
value="{{ form.text.value | default:'' }}"
aria-describedby="id_text_feedback"
required
/>
{% if form.errors %}
<div id="id_text_feedback" class="invalid-feedback">
{{ form.errors.text.0 }}
</div>
{% endif %}
</form>

View File

@@ -0,0 +1,6 @@
<script src="/static/apps/scripts/dashboard.js"></script>
<script>
window.onload = () => {
initialize("#id_text");
};
</script>

View File

@@ -3,4 +3,11 @@
{% block title_text %}Start a new to-do list{% endblock title_text %}
{% block header_text %}Start a new to-do list{% endblock header_text %}
{% block form_action %}{% url "new_list" %}{% endblock form_action %}
{% block extra_header %}
{% url "new_list" as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
{% endblock extra_header %}
{% block scripts %}
{% include "apps/dashboard/_partials/_scripts.html" %}
{% endblock scripts %}

View File

@@ -3,13 +3,54 @@
{% block title_text %}Your to-do list{% endblock title_text %}
{% block header_text %}Your to-do list{% endblock header_text %}
{% block form_action %}{% url "view_list" list.id %}{% endblock form_action %}
{% block table %}
{% block extra_header %}
{% url "view_list" list.id as form_action %}
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
{% endblock extra_header %}
{% 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 %}
<tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr>
{% endfor %}
</table>
{% endblock 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 %}
{% include "apps/dashboard/_partials/_scripts.html" %}
{% endblock scripts %}

View File

@@ -0,0 +1,18 @@
{% extends "core/base.html" %}
{% block header_text %}{{ user.email }}'s lists{% endblock header_text %}
{% block content %}
<h3>{{ owner.email }}'s lists</h3>
<ul>
{% for list in owner.lists.all %}
<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 %}

View File

@@ -18,6 +18,7 @@
<h1>Welcome, Earthman</h1>
</a>
{% if user.email %}
<a class="navbar-link" href="{% url 'my_lists' user.id %}">My lists</a>
<span class="navbar-text">Logged in as {{ user.email }}</span>
<form method="POST" action="{% url "logout" %}">
{% csrf_token %}
@@ -61,33 +62,13 @@
<div class="row justify-content-center p-5 bg-body-tertiary rounded-3">
<div class="col-lg-6 text-center">
<h2 class="display-1 mb-4">{% block header_text %}{% endblock header_text %}</h2>
<form method="POST" action="{% block form_action %}{% endblock form_action %}">
{% csrf_token %}
<input
id="id_text"
name="text"
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}"
placeholder="Enter a to-do item"
value="{{ form.text.value | default:'' }}"
aria-describedby="id_text_feedback"
required
/>
{% if form.errors %}
<div id="id_text_feedback" class="invalid-feedback">
{{ form.errors.text.0 }}
</div>
{% endif %}
</form>
{% block extra_header %}
{% endblock extra_header %}
</div>
</div>
<div class="row justify-content-center">
<div class="col-lg-6">
{% block table %}
{% endblock table %}
</div>
</div>
{% block content %}
{% endblock content %}
</div>
</body>
@@ -95,12 +76,7 @@
<script
src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js"
></script>
<script src="/static/apps/scripts/dashboard.js"></script>
<script>
window.onload = () => {
initialize("#id_text");
};
</script>
{% block scripts %}
{% endblock scripts %}
</html>