Compare commits

..

25 Commits

Author SHA1 Message Date
Disco DeDisco
168c877970 refactored lists to have more descriptive urlpatterns; cascading changes across API, dashboard app & even FTs; restarted staging server db w. new migrations
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 23:56:29 -05:00
Disco DeDisco
94f3120add refactored to green: all references in urlpatterns thruout project to apps/ dir now skip it & point directly to the app contained w.in (i.e., not apps/lyric/ or apps/dashboard/, but lyric/ or dashboard/ now
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 22:08:34 -05:00
Disco DeDisco
a8c199b719 ensured in apps.dashboard.views, w. passing ITs in .tests.integrated.test_views & passing FT in functional_tests.test_sharing, passes only to recipients & owner 2026-02-22 21:50:25 -05:00
Disco DeDisco
17eb83c760 plugged share_list() FBV ability for user to share list w. self as recipient 2026-02-22 21:18:22 -05:00
Disco DeDisco
44c335b089 added superuser support in apps.lyric.admin & new manage.py cmd ensure_superuser; .tests.integrated.test_admin & .test_management_commands green
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 20:42:33 -05:00
Disco DeDisco
87ef197823 enabled redis alongside celery, but waiting on true caching functionality—flash messages will behave better w. cache_page after they rely on htmx library, not current full-page reload
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-21 23:13:23 -05:00
Disco DeDisco
a9e635f40e fix for functional_tests.test_login, which still relied on old mock logic, no longer in apps.lyric.views, but handled by celery in apps.lyric.tasks
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-21 22:03:03 -05:00
Disco DeDisco
04e28b96c8 offloaded some apps.lyric.views responsibilities to new Celery depend fn in .tasks; core.celery created for celery config; CELERY_BROKER_URL added to .settings & throughout project; some lyric view IT responsibility now accordingly covered by task UT domain
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-21 21:35:15 -05:00
Disco DeDisco
880fcb5bcf more consistent DRF installation in pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 16:58:55 -05:00
Disco DeDisco
9bdc358e59 commenced DRF efforts w. package installation, creation of apps.api, w. UTs & ITs to ensure core efficacy; core.settings & .urls changed to accomodate
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-20 16:37:48 -05:00
Disco DeDisco
ed21730a38 when clause fixes in .woodpecker.yaml
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 15:16:19 -05:00
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
66 changed files with 1054 additions and 302 deletions

View File

@@ -1,26 +1,49 @@
services:
- name: postgres
image: postgres:16
environment:
POSTGRES_DB: python_tdd_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
- name: redis
image: redis:7
steps: steps:
- name: test-UTs - name: test-UTs-n-ITs
image: python:3.13-slim image: python:3.13-slim
environment:
DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
commands: commands:
- pip install -r requirements.txt - pip install -r requirements.txt
- cd ./src - cd ./src
- python manage.py test apps - python manage.py test apps
when:
- event: push
- name: test-FTs - name: test-FTs
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
environment: environment:
HEADLESS: 1 HEADLESS: 1
CELERY_BROKER_URL: redis://redis:6379/0
REDIS_URL: redis://redis:6379/1
commands: commands:
- pip install -r requirements.txt
- cd ./src - cd ./src
- python manage.py collectstatic --noinput - python manage.py collectstatic --noinput
- python manage.py test functional_tests - python manage.py test functional_tests
when:
- event: push
- name: screendumps - name: screendumps
image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest
when:
- status: failure
commands: commands:
- cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found" - cat ./src/functional_tests/screendumps/*.html || echo "No screendumps found"
when:
- event: push
status: failure
- name: build-and-push - name: build-and-push
image: docker:cli image: docker:cli
@@ -33,7 +56,7 @@ steps:
- docker push gitea.earthmanrpg.me/discoman/gamearray:latest - docker push gitea.earthmanrpg.me/discoman/gamearray:latest
when: when:
- branch: main - branch: main
- event: push event: push
- name: deploy - name: deploy
image: alpine image: alpine
@@ -48,5 +71,5 @@ steps:
- ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh - ssh -o StrictHostKeyChecking=no discoman@staging.earthmanrpg.me /opt/gamearray/deploy.sh
when: when:
- branch: main - branch: main
- event: push event: push

View File

@@ -50,37 +50,6 @@
- name: Reset ssh connection to allow the user/group change to take effect - name: Reset ssh connection to allow the user/group change to take effect
ansible.builtin.meta: reset_connection 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 - name: Ensure .secret-key files exists
# the intention is that this only happens once per server # the intention is that this only happens once per server
ansible.builtin.copy: ansible.builtin.copy:
@@ -120,30 +89,80 @@
cmd: docker login gitea.earthmanrpg.me -u discoman -p {{ gitea_registry_token }} cmd: docker login gitea.earthmanrpg.me -u discoman -p {{ gitea_registry_token }}
no_log: true no_log: true
- name: Ensure db.sqlite3 file exists outside container - name: Create Docker network
ansible.builtin.file: community.docker.docker_network:
path: "{{ ansible_env.HOME }}/db.sqlite3" name: gamearray_net
state: touch state: present
owner: 1234 # so nonroot user can access it in container
become: true # needed for ownership change - 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: Start Redis container
community.docker.docker_container:
name: gamearray_redis
image: redis:7
state: started
restart_policy: unless-stopped
networks:
- name: gamearray_net
- name: Run container - name: Run container
community.docker.docker_container: community.docker.docker_container:
name: gamearray name: gamearray
image: gamearray image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started state: started
recreate: true recreate: true
env: env:
DJANGO_DEBUG_FALSE: "1" DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}" DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}" DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
DJANGO_DB_PATH: "/home/nonroot/db.sqlite3" DJANGO_SUPERUSER_EMAIL: "{{ django_superuser_email }}"
EMAIL_HOST_USER: "{{ email_host_user }}" DJANGO_SUPERUSER_PASSWORD: "{{ django_superuser_password }}"
EMAIL_HOST_PASSWORD: "{{ email_host_password }}" DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}" MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
networks:
- name: gamearray_net
ports: ports:
127.0.0.1:8888:8888 127.0.0.1:8888:8888
- name: Start Celery worker container
community.docker.docker_container:
name: gamearray_celery
image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started
recreate: true
env:
DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
networks:
- name: gamearray_net
command: "python -m celery -A core worker -l info"
- name: Create static files directory - name: Create static files directory
ansible.builtin.file: ansible.builtin.file:
path: /var/www/gamearray/static path: /var/www/gamearray/static
@@ -160,7 +179,12 @@
- name: Run migration inside container - name: Run migration inside container
community.docker.docker_container_exec: community.docker.docker_container_exec:
container: gamearray container: gamearray
command: ./manage.py migrate command: python manage.py migrate
- name: Ensure superuser exists
community.docker.docker_container_exec:
container: gamearray
command: python manage.py ensure_superuser
handlers: handlers:
- name: Restart nginx - name: Restart nginx

View File

@@ -13,12 +13,26 @@ docker rm gamearray 2>/dev/null || true
echo "==> Starting new container..." echo "==> Starting new container..."
docker run -d --name gamearray \ docker run -d --name gamearray \
--env-file /opt/gamearray/gamearray.env \ --env-file /opt/gamearray/gamearray.env \
--network gamearray_net \
-p 127.0.0.1:8888:8888 \ -p 127.0.0.1:8888:8888 \
"$IMAGE" "$IMAGE"
echo "==> Stopping old celery worker..."
docker stop gamearray_celery 2>/dev/null || true
docker rm gamearray_celery 2>/dev/null || true
echo "==> Starting new celery worker..."
docker run -d --name gamearray_celery \
--env-file /opt/gamearray/gamearray.env \
--network gamearray_net \
"$IMAGE" python -m celery -A core worker -l info
echo "==> Running migrations..." echo "==> Running migrations..."
docker exec gamearray python ./manage.py migrate docker exec gamearray python ./manage.py migrate
echo "==> Ensuring superuser exists..."
docker exec gamearray python manage.py ensure_superuser
echo "==> Copying static files..." echo "==> Copying static files..."
sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/ sudo docker cp gamearray:/src/static/. /var/www/gamearray/static/

View File

@@ -1,7 +1,10 @@
DJANGO_DEBUG_FALSE=1 DJANGO_DEBUG_FALSE=1
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }} DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
DJANGO_ALLOWED_HOST={{ django_allowed_host }} DJANGO_ALLOWED_HOST={{ django_allowed_host }}
DJANGO_DB_PATH=/home/nonroot/db.sqlite3 DJANGO_SUPERUSER_EMAIL={{ django_superuser_email }}
EMAIL_HOST_USER={{ email_host_user }} DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }}
EMAIL_HOST_PASSWORD={{ email_host_password }} DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
MAILGUN_API_KEY={{ mailgun_api_key }} MAILGUN_API_KEY={{ mailgun_api_key }}
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
REDIS_URL=redis://gamearray_redis:6379/1

View File

@@ -1,28 +1,28 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
36653566363731653435616430626663303038623766663561363231333163336165623863613964 65383061626464353936363564313761663834646361326362613934363565623234636337313363
6164383861643530366438623465613565373032396331350a666163636431636663353162383531 3933313962643261353830333463336166393030313936370a616234626135633432613366633363
34306534656430653533303530613764336438616536343534663236333665323837636337333334 61633265363937326231623365646336333737306634646335376135633031643564666164336230
3432643436636265610a313465396435616263386631353336326464333930613865313934313032 3435353764383936620a396165386538666433356166383661323037333861373632376432313332
38353362623937643234333466323063336535623666613366633263623034616638653566666463 66666236373462363236663335623734633364653539323331396361613738636166323134386466
66323032653034376663623933306162313832643038653764643864666433376236643163663637 66656431663261633036333537373336643866623236643139656662333831366435373837656262
63626334393963343934666665373764393066383866616461333063633664363436613031663036 36333734376363373462643239623437623735373935633732343639313666663436616630363933
61343939343633393138666637646137376537393335663032383839306365613764303833323338 61396530336461393064323161666537646135383462383532363932326132363331633438313138
33343936333730373362393466373238636666343762373134633962383237623335373634656330 61623431326537313637626239653038353263313731303262653537316134383264616661623962
37363039393261313034306166656563333461353034646234323462623631393338383461363961 32333564366362383431336432303964663835363365636434303332613161363930333065336637
33356564633637333630663464613265666264393435363238383530333861636365616362316130 33343466343062306434663765613837343635386630326439303739616166396134393939626434
38353464343064616463636535316339336430323866303161393065363830356431386430666534 62336634303963653230626630636363343730623734626336363039623231633532653330646366
61353961666333313536616661636631643630373337633262653662393863336264636431366634 66613432633834393133386666623466326131386633303264333766306135623337353433306632
32323533383963393435343935616135663262633634356631363632396233383839326365396333 66323733373232383862646661313966366465333463366361366337656537623562613964666631
64333232626465643438313132323661386235313063303036303631376537353666313532323766 65373566316432383134666434393338626138363632633766636561383263333636623530326664
63633834336631633364333334373461333836666630353363343365323033653234356536643939 63333265366132376437396431393535323931383637323833303839336635633735333565333530
30316538663230653636316532393931333936613733336366326239633362353666636436636136 65343263373630633063383931646163323237643436366566363932646566323539373136646433
64656134663733376630316536616138613234383838316138616433353531396363316462626133 37623638333834373537316164633166633738333363656431356163623332396631353864333333
62383431396465333634623066333565643332613935653532613536646632346533383362393330 33306666646532626636376239326438373737383432663539333736363866663938396136383035
38646562393762346434663666313431363037636463306435663263386336343461303839346365 32343534613862653538346430313338326435356230636535343464666262626663376635363835
63326432643662353830383736613636643866363765366132653563363036316265646531623433 65363862663461353464313533313333323863313539643533343431643130383663656161616131
30303131323165653564333331353233373731333539346163613564343331373931633365633631 33323639333564383830346163386362386238323936393832623961646565613961356263356365
66396332653436376430626564316639623362383635633134343234626462333162336464656438 65376431666130356564666236383764316136326366666661326538653133343165326431393564
65393062333631373836303662326436333265373033353339356334633666363065636164343239 36303065366263316232663230343137333231346538633036613066643365616331336135376461
64623535663633653130643764656539643339633061646437643366376261383137613439323934 35613265623134663633303238366363336137383436663836353863623533396236666433303738
37343338336130313339356531333038613334393736353365366662313262653737623533616366 38356361653633323065303035376664326238633066623731623436333332373363636634323433
346430623266646464353639386266313339 393631303539373234386465663630316335

View File

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

View File

@@ -3,10 +3,13 @@ attrs==25.4.0
certifi==2025.11.12 certifi==2025.11.12
cffi==2.0.0 cffi==2.0.0
charset-normalizer==3.4.4 charset-normalizer==3.4.4
coverage
cssselect==1.3.0 cssselect==1.3.0
dj-database-url
Django==6.0 Django==6.0
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
h11==0.16.0 h11==0.16.0
idna==3.11 idna==3.11

View File

@@ -1,8 +1,13 @@
celery
cssselect==1.3.0 cssselect==1.3.0
Django==6.0 Django==6.0
dj-database-url
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
lxml==6.0.2 lxml==6.0.2
psycopg2-binary
redis
requests==2.31.0 requests==2.31.0
whitenoise==6.11.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

0
src/apps/api/__init__.py Normal file
View File

View File

@@ -0,0 +1,17 @@
from rest_framework import serializers
from apps.dashboard.models import Item, List
class ItemSerializer(serializers.ModelSerializer):
class Meta:
model = Item
fields = ["id", "text"]
class ListSerializer(serializers.ModelSerializer):
name = serializers.ReadOnlyField()
url = serializers.CharField(source="get_absolute_url", read_only=True)
items = ItemSerializer(many=True, read_only=True, source="item_set")
class Meta:
model = List
fields = ["id", "name", "url", "items"]

View File

View File

@@ -0,0 +1,62 @@
from django.test import TestCase
from rest_framework.test import APIClient
from apps.dashboard.models import Item, List
from apps.lyric.models import User
class BaseAPITest(TestCase):
# Helper fns
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user("test@example.com")
self.client.force_authenticate(user=self.user)
class ListDetailAPITest(BaseAPITest):
def test_returns_list_with_items(self):
list_ = List.objects.create(owner=self.user)
Item.objects.create(text="item 1", list=list_)
Item.objects.create(text="item 2", list=list_)
response = self.client.get(f"/api/lists/{list_.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["id"], str(list_.id))
self.assertEqual(len(response.data["items"]), 2)
class ListItemsAPITest(BaseAPITest):
def test_can_add_item_to_list(self):
list_ = List.objects.create(owner=self.user)
response = self.client.post(
f"/api/lists/{list_.id}/items/",
{"text": "a new item"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Item.objects.count(), 1)
self.assertEqual(Item.objects.first().text, "a new item")
class ListsAPITest(BaseAPITest):
def test_get_returns_only_users_lists(self):
list1 = List.objects.create(owner=self.user)
Item.objects.create(text="item 1", list=list1)
other_user = User.objects.create_user("other@example.com")
List.objects.create(owner=other_user)
response = self.client.get("/api/lists/")
self.assertEqual(response.status_code, 200)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]["id"], str(list1.id))
def test_post_creates_list_with_item(self):
response = self.client.post(
"/api/lists/",
{"text": "first item"},
)
self.assertEqual(response.status_code, 201)
self.assertEqual(List.objects.count(), 1)
self.assertEqual(List.objects.first().owner, self.user)
self.assertEqual(Item.objects.first().text, "first item")

View File

View File

@@ -0,0 +1,20 @@
from django.test import SimpleTestCase
from apps.api.serializers import ItemSerializer, ListSerializer
class ItemSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ItemSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "text"},
)
class ListSerializerTest(SimpleTestCase):
def test_fields(self):
serializer = ListSerializer()
self.assertEqual(
set(serializer.fields.keys()),
{"id", "name", "url", "items"},
)

11
src/apps/api/urls.py Normal file
View File

@@ -0,0 +1,11 @@
from django.urls import path
from . import views
urlpatterns = [
path('', views.ListsAPI.as_view(), name='api_lists'),
path('<uuid:list_id>/', views.ListDetailAPI.as_view(), name='api_list_detail'),
path('<uuid:list_id>/items/', views.ListItemsAPI.as_view(), name='api_list_items'),
]

34
src/apps/api/views.py Normal file
View File

@@ -0,0 +1,34 @@
from django.shortcuts import get_object_or_404
from rest_framework.views import APIView
from rest_framework.response import Response
from apps.dashboard.models import Item, List
from apps.api.serializers import ItemSerializer, ListSerializer
class ListDetailAPI(APIView):
def get(self, request, list_id):
list_ = get_object_or_404(List, id=list_id)
serializer = ListSerializer(list_)
return Response(serializer.data)
class ListItemsAPI(APIView):
def post(self, request, list_id):
list_ = get_object_or_404(List, id=list_id)
serializer = ItemSerializer(data=request.data)
if serializer.is_valid():
serializer.save(list=list_)
return Response(serializer.data, status=201)
return Response(serializer.errors, status=400)
class ListsAPI(APIView):
def get(self, request):
lists = List.objects.filter(owner=request.user)
serializer = ListSerializer(lists, many=True)
return Response(serializer.data)
def post(self, request):
list_ = List.objects.create(owner=request.user)
item = Item.objects.create(text=request.data.get("text", ""), list=list_)
serializer = ListSerializer(list_)
return Response(serializer.data, status=201)

View File

@@ -1,6 +1,8 @@
# Generated by Django 6.0 on 2026-02-08 01:19 # Generated by Django 6.0 on 2026-02-23 04:30
import django.db.models.deletion import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models from django.db import migrations, models
@@ -9,13 +11,16 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='List', name='List',
fields=[ fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('owner', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL)),
('shared_with', models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL)),
], ],
), ),
migrations.CreateModel( migrations.CreateModel(

View File

@@ -1,21 +0,0 @@
# Generated by Django 6.0 on 2026-02-09 03:29
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='list',
name='owner',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -1,7 +1,11 @@
import uuid
from django.db import models from django.db import models
from django.urls import reverse from django.urls import reverse
class List(models.Model): class List(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
owner = models.ForeignKey( owner = models.ForeignKey(
"lyric.User", "lyric.User",
related_name="lists", related_name="lists",
@@ -10,6 +14,12 @@ class List(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
shared_with = models.ManyToManyField(
"lyric.User",
related_name="shared_lists",
blank=True,
)
@property @property
def name(self): def name(self):
return self.item_set.first().text return self.item_set.first().text

View File

@@ -1,18 +1,15 @@
from django.test import TestCase from django.test import TestCase
from ..forms import (
from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR, DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR, EMPTY_ITEM_ERROR,
ExistingListItemForm, ExistingListItemForm,
ItemForm, ItemForm,
) )
from ..models import Item, List from apps.dashboard.models import Item, List
class ItemFormTest(TestCase): 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): def test_form_save_handles_saving_to_a_list(self):
mylist = List.objects.create() mylist = List.objects.create()
form = ItemForm(data={"text": "do re mi"}) form = ItemForm(data={"text": "do re mi"})

View File

@@ -1,14 +1,12 @@
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from django.test import TestCase from django.test import TestCase
from ..models import Item, List
from apps.dashboard.models import Item, List
from apps.lyric.models import User from apps.lyric.models import User
class ItemModelTest(TestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
class ItemModelTest(TestCase):
def test_item_is_related_to_list(self): def test_item_is_related_to_list(self):
mylist = List.objects.create() mylist = List.objects.create()
item = Item() item = Item()
@@ -42,14 +40,10 @@ class ItemModelTest(TestCase):
item = Item(list=list2, text="nojk") item = Item(list=list2, text="nojk")
item.full_clean() # should not raise 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): class ListModelTest(TestCase):
def test_get_absolute_url(self): def test_get_absolute_url(self):
mylist = List.objects.create() mylist = List.objects.create()
self.assertEqual(mylist.get_absolute_url(), f"/apps/dashboard/{mylist.id}/") self.assertEqual(mylist.get_absolute_url(), f"/dashboard/list/{mylist.id}/")
def test_list_items_order(self): def test_list_items_order(self):
list1 = List.objects.create() list1 = List.objects.create()

View File

@@ -1,14 +1,17 @@
import lxml.html import lxml.html
from django.test import TestCase from django.test import TestCase
from django.urls import reverse
from django.utils import html from django.utils import html
from unittest import skip
from ..forms import ( from apps.dashboard.forms import (
DUPLICATE_ITEM_ERROR, DUPLICATE_ITEM_ERROR,
EMPTY_ITEM_ERROR, EMPTY_ITEM_ERROR,
) )
from ..models import Item, List from apps.dashboard.models import Item, List
from apps.lyric.models import User from apps.lyric.models import User
class HomePageTest(TestCase): class HomePageTest(TestCase):
def test_uses_home_template(self): def test_uses_home_template(self):
response = self.client.get('/') response = self.client.get('/')
@@ -18,26 +21,26 @@ class HomePageTest(TestCase):
response = self.client.get('/') response = self.client.get('/')
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect('form[method=POST]') forms = parsed.cssselect('form[method=POST]')
self.assertIn("/apps/dashboard/new_list", [form.get("action") for form in forms]) self.assertIn("/dashboard/new_list", [form.get("action") for form in forms])
[form] = [form for form in forms if form.get("action") == "/apps/dashboard/new_list"] [form] = [form for form in forms if form.get("action") == "/dashboard/new_list"]
inputs = form.cssselect("input") inputs = form.cssselect("input")
self.assertIn("text", [input.get("name") for input in inputs]) self.assertIn("text", [input.get("name") for input in inputs])
class NewListTest(TestCase): class NewListTest(TestCase):
def test_can_save_a_POST_request(self): def test_can_save_a_POST_request(self):
self. client.post("/apps/dashboard/new_list", data={"text": "A new list item"}) self. client.post("/dashboard/new_list", data={"text": "A new list item"})
self.assertEqual(Item.objects.count(), 1) self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get() new_item = Item.objects.get()
self.assertEqual(new_item.text, "A new list item") self.assertEqual(new_item.text, "A new list item")
def test_redirects_after_POST(self): def test_redirects_after_POST(self):
response = self.client.post("/apps/dashboard/new_list", data={"text": "A new list item"}) response = self.client.post("/dashboard/new_list", data={"text": "A new list item"})
new_list = List.objects.get() new_list = List.objects.get()
self.assertRedirects(response, f"/apps/dashboard/{new_list.id}/") self.assertRedirects(response, f"/dashboard/list/{new_list.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
return self.client.post("/apps/dashboard/new_list", data={"text": ""}) return self.client.post("/dashboard/new_list", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self): def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input() self.post_invalid_input()
@@ -55,12 +58,12 @@ class NewListTest(TestCase):
class ListViewTest(TestCase): class ListViewTest(TestCase):
def test_uses_list_template(self): def test_uses_list_template(self):
mylist = List.objects.create() mylist = List.objects.create()
response = self.client.get(f"/apps/dashboard/{mylist.id}/") response = self.client.get(f"/dashboard/list/{mylist.id}/")
self.assertTemplateUsed(response, "apps/dashboard/list.html") self.assertTemplateUsed(response, "apps/dashboard/list.html")
def test_renders_input_form(self): def test_renders_input_form(self):
mylist = List.objects.create() mylist = List.objects.create()
url = f"/apps/dashboard/{mylist.id}/" url = f"/dashboard/list/{mylist.id}/"
response = self.client.get(url) response = self.client.get(url)
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
forms = parsed.cssselect("form[method=POST]") forms = parsed.cssselect("form[method=POST]")
@@ -77,7 +80,7 @@ class ListViewTest(TestCase):
other_list = List.objects.create() other_list = List.objects.create()
Item.objects.create(text="other list item", list=other_list) Item.objects.create(text="other list item", list=other_list)
# When/Act # When/Act
response = self.client.get(f"/apps/dashboard/{correct_list.id}/") response = self.client.get(f"/dashboard/list/{correct_list.id}/")
# Then/Assert # Then/Assert
self.assertContains(response, "itemey 1") self.assertContains(response, "itemey 1")
self.assertContains(response, "itemey 2") self.assertContains(response, "itemey 2")
@@ -88,7 +91,7 @@ class ListViewTest(TestCase):
correct_list = List.objects.create() correct_list = List.objects.create()
self.client.post( self.client.post(
f"/apps/dashboard/{correct_list.id}/", f"/dashboard/list/{correct_list.id}/",
data={"text": "A new item for an existing list"}, data={"text": "A new item for an existing list"},
) )
@@ -102,16 +105,16 @@ class ListViewTest(TestCase):
correct_list = List.objects.create() correct_list = List.objects.create()
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{correct_list.id}/", f"/dashboard/list/{correct_list.id}/",
data={"text": "A new item for an existing list"}, data={"text": "A new item for an existing list"},
) )
self.assertRedirects(response, f"/apps/dashboard/{correct_list.id}/") self.assertRedirects(response, f"/dashboard/list/{correct_list.id}/")
# Post invalid input helper # Post invalid input helper
def post_invalid_input(self): def post_invalid_input(self):
mylist = List.objects.create() mylist = List.objects.create()
return self.client.post(f"/apps/dashboard/{mylist.id}/", data={"text": ""}) return self.client.post(f"/dashboard/list/{mylist.id}/", data={"text": ""})
def test_for_invalid_input_nothing_saved_to_db(self): def test_for_invalid_input_nothing_saved_to_db(self):
self.post_invalid_input() self.post_invalid_input()
@@ -137,7 +140,7 @@ class ListViewTest(TestCase):
Item.objects.create(list=list1, text="lorem ipsum") Item.objects.create(list=list1, text="lorem ipsum")
response = self.client.post( response = self.client.post(
f"/apps/dashboard/{list1.id}/", f"/dashboard/list/{list1.id}/",
data={"text": "lorem ipsum"}, data={"text": "lorem ipsum"},
) )
@@ -150,26 +153,26 @@ class MyListsTest(TestCase):
def test_my_lists_url_renders_my_lists_template(self): def test_my_lists_url_renders_my_lists_template(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.client.force_login(user) self.client.force_login(user)
response = self.client.get(f"/apps/dashboard/users/{user.id}/") response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertTemplateUsed(response, "apps/dashboard/my_lists.html") self.assertTemplateUsed(response, "apps/dashboard/my_lists.html")
def test_passes_correct_owner_to_template(self): def test_passes_correct_owner_to_template(self):
User.objects.create(email="wrongowner@example.com") User.objects.create(email="wrongowner@example.com")
correct_user = User.objects.create(email="a@b.cde") correct_user = User.objects.create(email="a@b.cde")
self.client.force_login(correct_user) self.client.force_login(correct_user)
response = self.client.get(f"/apps/dashboard/users/{correct_user.id}/") response = self.client.get(f"/dashboard/users/{correct_user.id}/")
self.assertEqual(response.context["owner"], correct_user) self.assertEqual(response.context["owner"], correct_user)
def test_list_owner_is_saved_if_user_is_authenticated(self): def test_list_owner_is_saved_if_user_is_authenticated(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
self.client.force_login(user) self.client.force_login(user)
self.client.post("/apps/dashboard/new_list", data={"text": "new item"}) self.client.post("/dashboard/new_list", data={"text": "new item"})
new_list = List.objects.get() new_list = List.objects.get()
self.assertEqual(new_list.owner, user) self.assertEqual(new_list.owner, user)
def test_my_lists_redirects_if_not_logged_in(self): def test_my_lists_redirects_if_not_logged_in(self):
user = User.objects.create(email="a@b.cde") user = User.objects.create(email="a@b.cde")
response = self.client.get(f"/apps/dashboard/users/{user.id}/") response = self.client.get(f"/dashboard/users/{user.id}/")
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
def test_my_lists_returns_403_for_wrong_user(self): def test_my_lists_returns_403_for_wrong_user(self):
@@ -177,6 +180,63 @@ class MyListsTest(TestCase):
user1 = User.objects.create(email="a@b.cde") user1 = User.objects.create(email="a@b.cde")
user2 = User.objects.create(email="wrongowner@example.com") user2 = User.objects.create(email="wrongowner@example.com")
self.client.force_login(user2) self.client.force_login(user2)
response = self.client.get(f"/apps/dashboard/users/{user1.id}/") response = self.client.get(f"/dashboard/users/{user1.id}/")
# assert 403 # assert 403
self.assertEqual(response.status_code, 403) self.assertEqual(response.status_code, 403)
class ShareListTest(TestCase):
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"/dashboard/list/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/dashboard/list/{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"/dashboard/list/{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"/dashboard/list/{our_list.id}/share_list",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(response, f"/dashboard/list/{our_list.id}/")
def test_share_list_does_not_add_owner_as_recipient(self):
owner = User.objects.create(email="owner@example.com")
our_list = List.objects.create(owner=owner)
self.client.force_login(owner)
self.client.post(reverse("share_list", args=[our_list.id]),
data={"recipient": "owner@example.com"})
self.assertNotIn(owner, our_list.shared_with.all())
class ViewAuthListTest(TestCase):
def setUp(self):
self.owner = User.objects.create(email="disco@example.com")
self.our_list = List.objects.create(owner=self.owner)
def test_anonymous_user_is_redirected(self):
response = self.client.get(reverse("view_list", args=[self.our_list.id]))
self.assertRedirects(response, "/")
def test_non_owner_non_shared_user_gets_403(self):
stranger = User.objects.create(email="stranger@example.com")
self.client.force_login(stranger)
response = self.client.get(reverse("view_list", args=[self.our_list.id]))
self.assertEqual(response.status_code, 403)
def test_shared_with_user_can_access_list(self):
guest = User.objects.create(email="guest@example.com")
self.our_list.shared_with.add(guest)
self.client.force_login(guest)
response = self.client.get(reverse("view_list", args=[self.our_list.id]))
self.assertEqual(response.status_code, 200)

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

@@ -3,6 +3,7 @@ from . import views
urlpatterns = [ urlpatterns = [
path('new_list', views.new_list, name='new_list'), path('new_list', views.new_list, name='new_list'),
path('<int:list_id>/', views.view_list, name='view_list'), path('list/<uuid:list_id>/', views.view_list, name='view_list'),
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'), path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
path('list/<uuid:list_id>/share_list', views.share_list, name="share_list"),
] ]

View File

@@ -1,9 +1,11 @@
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from .forms import ExistingListItemForm, ItemForm from .forms import ExistingListItemForm, ItemForm
from .models import Item, List from .models import Item, List
from apps.lyric.models import User from apps.lyric.models import User
def home_page(request): def home_page(request):
return render(request, "apps/dashboard/home.html", {"form": ItemForm()}) return render(request, "apps/dashboard/home.html", {"form": ItemForm()})
@@ -21,6 +23,13 @@ def new_list(request):
def view_list(request, list_id): def view_list(request, list_id):
our_list = List.objects.get(id=list_id) our_list = List.objects.get(id=list_id)
if our_list.owner:
if not request.user.is_authenticated:
return redirect("/")
if request.user != our_list.owner and request.user not in our_list.shared_with.all():
return HttpResponseForbidden()
form = ExistingListItemForm(for_list=our_list) form = ExistingListItemForm(for_list=our_list)
if request.method == "POST": if request.method == "POST":
@@ -37,3 +46,14 @@ def my_lists(request, user_id):
if request.user.id != owner.id: if request.user.id != owner.id:
return HttpResponseForbidden() return HttpResponseForbidden()
return render(request, "apps/dashboard/my_lists.html", {"owner": owner}) return render(request, "apps/dashboard/my_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"])
if recipient == request.user:
return redirect(our_list)
our_list.shared_with.add(recipient)
except User.DoesNotExist:
pass
return redirect(our_list)

View File

@@ -1,3 +1,11 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from .models import Token, User
class UserAdmin(admin.ModelAdmin):
list_display = ["email"]
search_fields = ["email"]
admin.site.register(User, UserAdmin)
admin.site.register(Token)

View File

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

View File

View File

@@ -0,0 +1,21 @@
import os
from django.core.management.base import BaseCommand
from apps.lyric.models import User
class Command(BaseCommand):
help = "Create a superuser if none exists"
def handle(self, *args, **options):
if User.objects.filter(is_superuser=True).exists():
self.stdout.write("Superuser already exists!")
return
email = os.environ.get('DJANGO_SUPERUSER_EMAIL')
password = os.environ.get('DJANGO_SUPERUSER_PASSWORD')
if not email or not password:
self.stdout.write("Superuser credentials not set!—skipping")
return
User.objects.create_superuser(email=email, password=password)
self.stdout.write("Superuser created!")

View File

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

View File

@@ -1,16 +1,38 @@
import uuid import uuid
from django.contrib.auth.base_user import AbstractBaseUser, BaseUserManager
from django.db import models 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): class Token(models.Model):
email = models.EmailField() email = models.EmailField()
uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
class User(models.Model): class User(AbstractBaseUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
email = models.EmailField(unique=True) email = models.EmailField(unique=True)
is_staff = models.BooleanField(default=False)
is_superuser = models.BooleanField(default=False)
objects = UserManager()
REQUIRED_FIELDS = [] REQUIRED_FIELDS = []
USERNAME_FIELD = "email" USERNAME_FIELD = "email"
is_authenticated = True def has_perm(self, perm, obj=None):
is_anonymous =False return self.is_superuser
def has_module_perms(self, app_label):
return self.is_superuser

24
src/apps/lyric/tasks.py Normal file
View File

@@ -0,0 +1,24 @@
import requests
from celery import shared_task
from django.conf import settings
@shared_task
def send_login_email_task(email, url):
message_body = f"Use this magic link to login to your Dashboard:\n\n{url}"
# Send mail via Mailgun HTTP API
response = requests.post(
f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages",
auth=("api", settings.MAILGUN_API_KEY),
data={
"from": "adman@howdy.earthmanrpg.com",
"to": email,
"subject": "A magic login link to your Dashboard",
"text": message_body,
}
)
# Log any errors
if response.status_code != 200:
print(f"Mailgun API error: {response.status_code}: {response.text}")

View File

@@ -0,0 +1,25 @@
from django.test import TestCase
from apps.lyric.models import User
class UserAdminTest(TestCase):
def setUp(self):
self.superuser = User.objects.create_superuser(
email="admin@example.com", password="secret"
)
self.client.force_login(self.superuser)
def test_user_changelist_loads(self):
response = self.client.get("/admin/lyric/user/")
self.assertEqual(response.status_code, 200)
def test_user_changelist_displays_email(self):
response = self.client.get("/admin/lyric/user/")
self.assertContains(response, "admin@example.com")
def test_user_changelist_search_by_email(self):
User.objects.create_superuser(email="other@example.com", password="x")
response = self.client.get("/admin/lyric/user/?q=admin")
self.assertContains(response, "admin@example.com")
self.assertNotContains(response, "other@example.com")

View File

@@ -2,17 +2,11 @@ import uuid
from django.http import HttpRequest from django.http import HttpRequest
from django.test import TestCase from django.test import TestCase
from ..authentication import PasswordlessAuthenticationBackend from apps.lyric.authentication import PasswordlessAuthenticationBackend
from ..models import Token, User from apps.lyric.models import Token, User
class AuthenticateTest(TestCase): 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): def test_returns_None_if_token_uuid_not_found(self):
uid = uuid.uuid4() uid = uuid.uuid4()
result = PasswordlessAuthenticationBackend().authenticate( result = PasswordlessAuthenticationBackend().authenticate(

View File

@@ -0,0 +1,34 @@
import os
from django.core.management import call_command
from django.test import TestCase
from unittest.mock import patch
# from apps.lyric.management.commands.ensure_superuser import EnsureSuperuserCommand
from apps.lyric.models import User
FAKE_ENV = {
'DJANGO_SUPERUSER_EMAIL': 'admin@example.com',
'DJANGO_SUPERUSER_PASSWORD': 'secret',
}
class EnsureSuperuserCommandTest(TestCase):
def test_creates_superuser_if_none_exists(self):
with patch.dict('os.environ', FAKE_ENV):
call_command('ensure_superuser')
self.assertEqual(User.objects.filter(is_superuser=True).count(), 1)
def test_does_not_create_duplicate_if_superuser_exists(self):
User.objects.create_superuser(email="admin@example.com", password="secret")
with patch.dict('os.environ', FAKE_ENV):
call_command('ensure_superuser')
self.assertEqual(User.objects.filter(is_superuser=True).count(), 1)
def test_skips_creation_if_credentials_not_set(self):
with patch.dict("os.environ", {}):
os.environ.pop("DJANGO_SUPERUSER_EMAIL", None)
os.environ.pop("DJANGO_SUPERUSER_PASSWORD", None)
call_command("ensure_superuser")
self.assertEqual(User.objects.filter(is_superuser=True).count(), 0)

View File

@@ -1,7 +1,9 @@
import uuid import uuid
from django.contrib import auth from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from ..models import Token, User
from apps.lyric.models import Token, User
class UserModelTest(TestCase): class UserModelTest(TestCase):
def test_model_is_configured_for_django_auth(self): 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): def test_user_is_valid_with_email_only(self):
user = User(email="a@b.cde") user = User(email="a@b.cde")
user.set_unusable_password()
user.full_clean() # should not raise user.full_clean() # should not raise
def test_id_is_primary_key(self): def test_id_is_primary_key(self):
@@ -21,3 +24,19 @@ class TokenModelTest(TestCase):
token2 = Token.objects.create(email="v@w.xyz") token2 = Token.objects.create(email="v@w.xyz")
self.assertNotEqual(token1.pk, token2.pk) self.assertNotEqual(token1.pk, token2.pk)
self.assertIsInstance(token1.pk, uuid.UUID) 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,30 +1,29 @@
from django.contrib import auth from django.contrib import auth
from django.test import TestCase from django.test import TestCase
from unittest import mock from unittest import mock
from ..models import Token
from apps.lyric.models import Token
@mock.patch("apps.lyric.views.send_login_email_task.delay")
class SendLoginEmailViewTest(TestCase): class SendLoginEmailViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self, mock_delay):
response = self.client.post( response = self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
@mock.patch("apps.lyric.views.requests.post") def test_sends_mail_to_address_from_post(self, mock_delay):
def test_sends_mail_to_address_from_post(self, mock_post):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
self.assertEqual(mock_post.called, True) self.assertEqual(mock_delay.called, True)
data = mock_post.call_args.kwargs["data"] self.assertEqual(mock_delay.call_args.args[0], "discoman@example.com")
self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.me")
self.assertEqual(data["to"], "discoman@example.com")
def test_adds_success_message(self): def test_adds_success_message(self, mock_delay):
response = self.client.post( response = self.client.post(
"/apps/lyric/send_login_email", "/lyric/send_login_email",
data={"email": "discoman@example.com"}, data={"email": "discoman@example.com"},
follow=True follow=True
) )
@@ -36,28 +35,25 @@ class SendLoginEmailViewTest(TestCase):
) )
self.assertEqual(message.tags, "success") self.assertEqual(message.tags, "success")
def test_creates_token_associated_with_email(self): def test_creates_token_associated_with_email(self, mock_delay):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() token = Token.objects.get()
self.assertEqual(token.email, "discoman@example.com") self.assertEqual(token.email, "discoman@example.com")
def test_sends_link_to_login_using_token_uid(self, mock_delay):
@mock.patch("apps.lyric.views.requests.post")
def test_sends_link_to_login_using_token_uid(self, mock_post):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() token = Token.objects.get()
expected_url = f"http://testserver/apps/lyric/login?token={token.uid}" expected_url = f"http://testserver/lyric/login?token={token.uid}"
data = mock_post.call_args.kwargs["data"] self.assertEqual(mock_delay.call_args.args[1], expected_url)
self.assertIn(expected_url, data["text"])
class LoginViewTest(TestCase): class LoginViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self):
response = self.client.get("/apps/lyric/login?token=abc123") response = self.client.get("/lyric/login?token=abc123")
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
def test_logs_in_if_given_valid_token(self): def test_logs_in_if_given_valid_token(self):
@@ -65,14 +61,14 @@ class LoginViewTest(TestCase):
self.assertEqual(anon_user.is_authenticated, False) self.assertEqual(anon_user.is_authenticated, False)
token = Token.objects.create(email="discoman@example.com") token = Token.objects.create(email="discoman@example.com")
self.client.get(f"/apps/lyric/login?token={token.uid}", follow=True) self.client.get(f"/lyric/login?token={token.uid}", follow=True)
user = auth.get_user(self.client) user = auth.get_user(self.client)
self.assertEqual(user.is_authenticated, True) self.assertEqual(user.is_authenticated, True)
self.assertEqual(user.email, "discoman@example.com") self.assertEqual(user.email, "discoman@example.com")
def test_shows_login_error_if_token_invalid(self): def test_shows_login_error_if_token_invalid(self):
response = self.client.get("/apps/lyric/login?token=invalid-token", follow=True) response = self.client.get("/lyric/login?token=invalid-token", follow=True)
user = auth.get_user(self.client) user = auth.get_user(self.client)
self.assertEqual(user.is_authenticated, False) self.assertEqual(user.is_authenticated, False)
message = list(response.context["messages"])[0] message = list(response.context["messages"])[0]
@@ -84,7 +80,7 @@ class LoginViewTest(TestCase):
@mock.patch("apps.lyric.views.auth") @mock.patch("apps.lyric.views.auth")
def test_calls_authenticate_with_uid_from_get_request(self, mock_auth): def test_calls_authenticate_with_uid_from_get_request(self, mock_auth):
self.client.get("/apps/lyric/login?token=abc123") self.client.get("/lyric/login?token=abc123")
self.assertEqual( self.assertEqual(
mock_auth.authenticate.call_args, mock_auth.authenticate.call_args,
mock.call(uid="abc123") mock.call(uid="abc123")

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

@@ -0,0 +1,16 @@
from django.test import SimpleTestCase
from unittest import mock
from apps.lyric.tasks import send_login_email_task
class SendLoginEmailTaskTest(SimpleTestCase):
@mock.patch("apps.lyric.tasks.requests.post")
def test_sends_mail_via_mailgun(self, mock_post):
send_login_email_task("discoman@example.com", "http://example.com/login?token=abc123")
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")
self.assertIn("http://example.com/login?token=abc123", data["text"])

View File

@@ -1,10 +1,10 @@
import requests
from django.contrib import auth, messages from django.contrib import auth, messages
from django.conf import settings
# from django.core.mail import send_mail
from django.shortcuts import redirect from django.shortcuts import redirect
from django.urls import reverse from django.urls import reverse
from .models import Token from .models import Token
from .tasks import send_login_email_task
def send_login_email(request): def send_login_email(request):
email = request.POST["email"] email = request.POST["email"]
@@ -12,26 +12,13 @@ def send_login_email(request):
url = request.build_absolute_uri( url = request.build_absolute_uri(
reverse("login") + "?token=" + str(token.uid), reverse("login") + "?token=" + str(token.uid),
) )
message_body = f"Use this magic link to login to your Dashboard:\n\n{url}"
# Send mail via Mailgun HTTP API
response = requests.post(
f"https://api.mailgun.net/v3/{settings.MAILGUN_DOMAIN}/messages",
auth=("api", settings.MAILGUN_API_KEY),
data={
"from": "adman@howdy.earthmanrpg.me",
"to": email,
"subject": "A magic login link to your Dashboard",
"text": message_body,
},
)
# Log any errors
if response.status_code != 200:
print(f"Mailgun API error: {response.status_code}: {response.text}")
send_login_email_task.delay(email, url)
messages.success( messages.success(
request, request,
"Check your email!—there you'll find a magic login link. But hurry… it's only temporary!", "Check your email!—there you'll find a magic login link. But hurry… it's only temporary!",
) )
return redirect("/") return redirect("/")
def login(request): def login(request):

View File

@@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ("celery_app",)

10
src/core/celery.py Normal file
View File

@@ -0,0 +1,10 @@
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
app = Celery('core')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()

View File

@@ -12,6 +12,7 @@ https://docs.djangoproject.com/en/6.0/ref/settings/
from pathlib import Path from pathlib import Path
import os import os
import dj_database_url
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@@ -26,20 +27,25 @@ if 'DJANGO_DEBUG_FALSE' in os.environ:
DEBUG = False DEBUG = False
SECRET_KEY = os.environ['DJANGO_SECRET_KEY'] SECRET_KEY = os.environ['DJANGO_SECRET_KEY']
ALLOWED_HOSTS = [host.strip() for host in os.environ['DJANGO_ALLOWED_HOST'].split(',')] 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: else:
DEBUG = True DEBUG = True
# SECURITY WARNING: keep the secret key used in production secret! # 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' SECRET_KEY = 'django-insecure-&9b_h=qpjy=sshhnsyg98&jp7(t6*v78__y%h2l$b#_@6z$-9r'
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
db_path = BASE_DIR / 'db.sqlite3'
# Application definition # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
# Django apps # Django apps
# 'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
'django.contrib.contenttypes', 'django.contrib.contenttypes',
'django.contrib.sessions', 'django.contrib.sessions',
@@ -48,8 +54,10 @@ INSTALLED_APPS = [
# Custom apps # Custom apps
'apps.dashboard', 'apps.dashboard',
'apps.lyric', 'apps.lyric',
'apps.api',
'functional_tests', 'functional_tests',
# Depend apps # Depend apps
'rest_framework',
] ]
# if 'DJANGO_DEBUG_FALSE' not in os.environ: # if 'DJANGO_DEBUG_FALSE' not in os.environ:
@@ -90,13 +98,28 @@ ASGI_APPLICATION = 'core.asgi.application'
# Database # Database
# https://docs.djangoproject.com/en/6.0/ref/settings/#databases # https://docs.djangoproject.com/en/6.0/ref/settings/#databases
DATABASES = { DATABASE_URL = os.environ.get('DATABASE_URL')
'default': { if DATABASE_URL:
'ENGINE': 'django.db.backends.sqlite3', DATABASES = {'default': dj_database_url.config(conn_max_age=600)}
'NAME': db_path, else:
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
} }
}
# Celery & Redis
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
REDIS_URL = os.environ.get('REDIS_URL')
if REDIS_URL:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': REDIS_URL,
}
}
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
@@ -119,6 +142,7 @@ AUTH_PASSWORD_VALIDATORS = [
AUTH_USER_MODEL = "lyric.User" AUTH_USER_MODEL = "lyric.User"
AUTHENTICATION_BACKENDS = [ AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"apps.lyric.authentication.PasswordlessAuthenticationBackend", "apps.lyric.authentication.PasswordlessAuthenticationBackend",
] ]
@@ -155,12 +179,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 settings (for HTTP API instead of SMTP)
MAILGUN_API_KEY = os.environ.get("MAILGUN_API_KEY") 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,13 +1,14 @@
# from django.contrib import admin from django.contrib import admin
from django.http import HttpResponse from django.http import HttpResponse
from django.urls import include, path from django.urls import include, path
from apps.dashboard import views as dash_views from apps.dashboard import views as dash_views
urlpatterns = [ urlpatterns = [
# path('admin/', admin.site.urls), path('admin/', admin.site.urls),
path('', dash_views.home_page, name='home'), path('', dash_views.home_page, name='home'),
path('apps/dashboard/', include('apps.dashboard.urls')), path('dashboard/', include('apps.dashboard.urls')),
path('apps/lyric/', include('apps.lyric.urls')), path('lyric/', include('apps.lyric.urls')),
path('api/lists/', include('apps.api.urls')),
] ]
# Please remove the following urlpattern # Please remove the following urlpattern

View File

@@ -2,6 +2,7 @@ import os
import time import time
from datetime import datetime from datetime import datetime
from django.conf import settings
from django.contrib.staticfiles.testing import StaticLiveServerTestCase from django.contrib.staticfiles.testing import StaticLiveServerTestCase
from pathlib import Path from pathlib import Path
from selenium import webdriver from selenium import webdriver
@@ -9,7 +10,9 @@ from selenium.common.exceptions import WebDriverException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys 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 = 10 MAX_WAIT = 10
@@ -70,24 +73,26 @@ class FunctionalTest(StaticLiveServerTestCase):
f"{self.__class__.__name__}.{self._testMethodName}-{timestamp}.{extension}" f"{self.__class__.__name__}.{self._testMethodName}-{timestamp}.{extension}"
) )
@wait @wait
def wait_for(self, fn): def wait_for(self, fn):
return fn() return fn()
def get_item_input_box(self): def create_pre_authenticated_session(self, email):
return self.browser.find_element(By.ID, "id_text") if self.test_server:
session_key = create_session_on_server(self.test_server, email)
@wait else:
def wait_for_row_in_list_table(self, row_text): session_key = create_pre_authenticated_session(email)
rows = self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr") ## to set a cookie we need to first visit the domain
self.assertIn(row_text, [row.text for row in rows]) ## 404 pages load the quickest!
self.browser.get(self.live_server_url + "/404_no_such_url/")
def add_list_item(self, item_text): self.browser.add_cookie(
num_rows = len(self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr")) dict(
self.get_item_input_box().send_keys(item_text) name=settings.SESSION_COOKIE_NAME,
self.get_item_input_box().send_keys(Keys.ENTER) value=session_key,
item_number = num_rows + 1 path="/",
self.wait_for_row_in_list_table(f"{item_number}. {item_text}") )
)
@wait @wait
def wait_to_be_logged_in(self, email): def wait_to_be_logged_in(self, email):

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -1,15 +1,22 @@
import re import re
from unittest.mock import patch from unittest.mock import patch
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest from .base import FunctionalTest
from apps.lyric.tasks import send_login_email_task
TEST_EMAIL = "discoman@example.com" TEST_EMAIL = "discoman@example.com"
SUBJECT = "A magic login link to your Dashboard" SUBJECT = "A magic login link to your Dashboard"
class LoginTest(FunctionalTest): class LoginTest(FunctionalTest):
@patch('apps.lyric.views.requests.post') @patch('apps.lyric.tasks.requests.post')
def test_login_using_magic_link(self, mock_post): @patch('apps.lyric.views.send_login_email_task.delay',
side_effect=send_login_email_task)
def test_login_using_magic_link(self, mock_delay, mock_post):
# Mock successful Mailgun API response # Mock successful Mailgun API response
mock_post.return_value.status_code = 200 mock_post.return_value.status_code = 200

View File

@@ -1,43 +1,22 @@
from django.conf import settings
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from .base import FunctionalTest from .base import FunctionalTest
from .container_commands import create_session_on_server from .list_page import ListPage
from .management.commands.create_session import create_pre_authenticated_session from .my_lists_page import MyListsPage
class MyListsTest(FunctionalTest): 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): def test_logged_in_users_lists_are_saved_as_my_lists(self):
self.create_pre_authenticated_session("discoman@example.com") self.create_pre_authenticated_session("discoman@example.com")
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Reticulate splines") list_page = ListPage(self)
self.add_list_item("Regurgitate spines") list_page.add_list_item("Reticulate splines")
list_page.add_list_item("Regurgitate spines")
first_list_url = self.browser.current_url first_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click() MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.assertIn(
"discoman@example.com",
self.browser.find_element(By.CSS_SELECTOR, "h2").text,
)
)
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines") lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
@@ -48,17 +27,14 @@ class MyListsTest(FunctionalTest):
) )
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Ribbon of death") list_page.add_list_item("Ribbon of death")
second_list_url = self.browser.current_url second_list_url = self.browser.current_url
self.browser.find_element(By.LINK_TEXT, "My lists").click() self.browser.find_element(By.LINK_TEXT, "My lists").click()
self.wait_for( self.wait_for(
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death") lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
) )
self.browser.find_element(By.LINK_TEXT, "Ribbon of death").click() MyListsPage(self).go_to_my_lists_page("discoman@example.com")
self.wait_for(
lambda: self.assertEqual(self.browser.current_url, second_list_url)
)
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click() self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
self.wait_for( self.wait_for(

View File

@@ -0,0 +1,73 @@
import os
from django.conf import settings
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)
class ListAccessTest(FunctionalTest):
def test_stranger_cannot_access_owned_list(self):
self.create_pre_authenticated_session("disco@example.com")
self.browser.get(self.live_server_url)
list_page = ListPage(self).add_list_item("private eye")
list_url = self.browser.current_url
self.browser.delete_cookie(settings.SESSION_COOKIE_NAME)
self.browser.get(list_url)
self.assertNotEqual(self.browser.current_url, list_url)

View File

@@ -2,46 +2,57 @@ from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from .base import FunctionalTest from .base import FunctionalTest
from .list_page import ListPage
class NewVisitorTest(FunctionalTest): class NewVisitorTest(FunctionalTest):
# Test methods # Test methods
def test_can_start_a_todo_list(self): def test_can_start_a_todo_list(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self)
self.assertIn('Earthman RPG', self.browser.title) self.assertIn('Earthman RPG', self.browser.title)
header_text = self.browser.find_element(By.TAG_NAME, 'h1').text header_text = self.browser.find_element(By.TAG_NAME, 'h1').text
self.assertIn('Welcome', header_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') self.assertEqual(inputbox.get_attribute('placeholder'), 'Enter a to-do item')
inputbox.send_keys('Buy peacock feathers') inputbox.send_keys('Buy peacock feathers')
inputbox.send_keys(Keys.ENTER) inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. Buy peacock feathers') list_page.wait_for_row_in_list_table("Buy peacock feathers", 1)
self.add_list_item("Use peacock feathers to make a fly") list_page.add_list_item("Use peacock feathers to make a fly")
self.wait_for_row_in_list_table('2. Use peacock feathers to make a fly') list_page.wait_for_row_in_list_table("Use peacock feathers to make a fly", 2)
self.wait_for_row_in_list_table('1. Buy peacock feathers') list_page.wait_for_row_in_list_table("Buy peacock feathers", 1)
def test_multiple_users_can_start_lists_at_different_urls(self): def test_multiple_users_can_start_lists_at_different_urls(self):
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
self.add_list_item("Buy peacock feathers") list_page = ListPage(self)
list_page.add_list_item("Buy peacock feathers")
edith_dash_url = self.browser.current_url edith_dash_url = self.browser.current_url
self.assertRegex(edith_dash_url, '/apps/dashboard/.+') self.assertRegex(
edith_dash_url,
r'/dashboard/list/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
)
self.browser.delete_all_cookies() self.browser.delete_all_cookies()
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
list_page = ListPage(self)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text page_text = self.browser.find_element(By.TAG_NAME, 'body').text
self.assertNotIn('Buy peacock feathers', page_text) self.assertNotIn('Buy peacock feathers', page_text)
self.add_list_item("Buy milk") list_page.add_list_item("Buy milk")
francis_dash_url = self.browser.current_url francis_dash_url = self.browser.current_url
self.assertRegex(francis_dash_url, '/apps/dashboard/.+') self.assertRegex(
francis_dash_url,
r'/dashboard/list/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/$',
)
self.assertNotEqual(francis_dash_url, edith_dash_url) self.assertNotEqual(francis_dash_url, edith_dash_url)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text page_text = self.browser.find_element(By.TAG_NAME, 'body').text

View File

@@ -11,6 +11,7 @@
{% block content %} {% block content %}
<div class="row justify-content-center"> <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"> <div class="col-lg-6">
<table id="id_list_table" class="table"> <table id="id_list_table" class="table">
{% for item in list.item_set.all %} {% for item in list.item_set.all %}
@@ -19,6 +20,35 @@
</table> </table>
</div> </div>
</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 %} {% endblock content %}
{% block scripts %} {% block scripts %}

View File

@@ -9,4 +9,10 @@
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li> <li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
{% endfor %} {% endfor %}
</ul> </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 %} {% endblock content %}