Compare commits

...

14 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
47 changed files with 573 additions and 207 deletions

View File

@@ -1,6 +1,16 @@
services:
- name: postgres
image: postgres:16
environment:
POSTGRES_DB: python_tdd_test
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
steps: 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
commands: commands:
- pip install -r requirements.txt - pip install -r requirements.txt
- cd ./src - cd ./src

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,27 +89,45 @@
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: 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" DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
EMAIL_HOST_USER: "{{ email_host_user }}"
EMAIL_HOST_PASSWORD: "{{ email_host_password }}"
MAILGUN_API_KEY: "{{ mailgun_api_key }}" MAILGUN_API_KEY: "{{ mailgun_api_key }}"
networks:
- name: gamearray_net
ports: ports:
127.0.0.1:8888:8888 127.0.0.1:8888:8888
@@ -160,7 +147,7 @@
- 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
handlers: handlers:
- name: Restart nginx - name: Restart nginx

View File

@@ -13,6 +13,7 @@ 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"

View File

@@ -1,7 +1,5 @@
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 DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
EMAIL_HOST_USER={{ email_host_user }} MAILGUN_API_KEY={{ mailgun_api_key }}
EMAIL_HOST_PASSWORD={{ email_host_password }}
MAILGUN_API_KEY={{ mailgun_api_key }}

View File

@@ -1,28 +1,23 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
36653566363731653435616430626663303038623766663561363231333163336165623863613964 33616230376431343735626631623932393166343538653732383533323436326335343463646664
6164383861643530366438623465613565373032396331350a666163636431636663353162383531 6565373531623465613661613533376231373837326438300a393665613839646231633737313938
34306534656430653533303530613764336438616536343534663236333665323837636337333334 64633035336663313163333634623732323537326363646132313136376131636666636538323066
3432643436636265610a313465396435616263386631353336326464333930613865313934313032 3037373930303537320a313062646166353862633836373466316261363939633433663039323866
38353362623937643234333466323063336535623666613366633263623034616638653566666463 62333739303662343836306538393734343830366336323265393138343438363533353166383031
66323032653034376663623933306162313832643038653764643864666433376236643163663637 32313461313137643039376237346633316466646136353038633861333031663164656233366634
63626334393963343934666665373764393066383866616461333063633664363436613031663036 38303363383130376264373861393863623330623733643135643461383132613339376633353031
61343939343633393138666637646137376537393335663032383839306365613764303833323338 32313863323039646534633733383661333361313832333830383066633130396239626661643264
33343936333730373362393466373238636666343762373134633962383237623335373634656330 65636335303339613432326533343337366261356632313639623634386633383836333733663536
37363039393261313034306166656563333461353034646234323462623631393338383461363961 39383361353530646166643531333535356636326535383534326237666638326137616162646261
33356564633637333630663464613265666264393435363238383530333861636365616362316130 65316466323335653932636338653565383038313531383638393839313736643739363037353230
38353464343064616463636535316339336430323866303161393065363830356431386430666534 35653632353531656435396663316537333133653632366437613339303033333536643937353166
61353961666333313536616661636631643630373337633262653662393863336264636431366634 64363037653733303332643931343362303261643432366531326262383465313965633064356338
32323533383963393435343935616135663262633634356631363632396233383839326365396333 31336333373665373035656533633864316139303934623030383934393434356334643962666163
64333232626465643438313132323661386235313063303036303631376537353666313532323766 33343739366336613263333764306365333566363536616662383733616237396563346132336633
63633834336631633364333334373461333836666630353363343365323033653234356536643939 38663239613339376335386233386330396634323033343332366130616162666339393861306336
30316538663230653636316532393931333936613733336366326239633362353666636436636136 35383566383831356530633130313732356331616164646132626665646235396635386237313538
64656134663733376630316536616138613234383838316138616433353531396363316462626133 38656631336261646530303761643334303937613036363766303637376262373466316431323731
62383431396465333634623066333565643332613935653532613536646632346533383362393330 38666462313639353131303134646434646135366136343361353932326165626666306361393431
38646562393762346434663666313431363037636463306435663263386336343461303839346365 62646238323265346263386363373462313766616333326366366461346436383064336535376339
63326432643662353830383736613636643866363765366132653563363036316265646531623433 31356566356336386262393831616631666233633930393263623563386265343237323133313832
30303131323165653564333331353233373731333539346163613564343331373931633365633631 3430363635363332303963316530663765613666306233376463
66396332653436376430626564316639623362383635633134343234626462333162336464656438
65393062333631373836303662326436333265373033353339356334633666363065636164343239
64623535663633653130643764656539643339633061646437643366376261383137613439323934
37343338336130313339356531333038613334393736353365366662313262653737623533616366
346430623266646464353639386266313339

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,7 +3,9 @@ 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

View File

@@ -1,8 +1,10 @@
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
gunicorn==23.0.0 gunicorn==23.0.0
lxml==6.0.2 lxml==6.0.2
psycopg2-binary
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

View File

@@ -0,0 +1,20 @@
# Generated by Django 6.0 on 2026-02-18 18:13
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dashboard', '0002_list_owner'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.AddField(
model_name='list',
name='shared_with',
field=models.ManyToManyField(blank=True, related_name='shared_lists', to=settings.AUTH_USER_MODEL),
),
]

View File

@@ -10,6 +10,12 @@ class List(models.Model):
on_delete=models.CASCADE, 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): class ItemModelTest(TestCase):
def test_default_text(self):
item = Item()
self.assertEqual(item.text, "")
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,10 +40,6 @@ 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()

View File

@@ -1,14 +1,17 @@
import lxml.html import lxml.html
from unittest import skip
from django.test import TestCase from django.test import TestCase
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('/')
@@ -180,3 +183,30 @@ class MyListsTest(TestCase):
response = self.client.get(f"/apps/dashboard/users/{user1.id}/") response = self.client.get(f"/apps/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"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")
def test_post_with_email_adds_user_to_shared_with(self):
our_list = List.objects.create()
alice = User.objects.create(email="alice@example.com")
self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "alice@example.com"},
)
self.assertIn(alice, our_list.shared_with.all())
def test_post_with_nonexistent_email_redirects_to_list(self):
our_list = List.objects.create()
response = self.client.post(
f"/apps/dashboard/{our_list.id}/share_list",
data={"recipient": "nobody@example.com"},
)
self.assertRedirects(response, f"/apps/dashboard/{our_list.id}/")

View File

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

View File

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

View File

@@ -5,4 +5,5 @@ urlpatterns = [
path('new_list', views.new_list, name='new_list'), path('new_list', views.new_list, name='new_list'),
path('<int:list_id>/', views.view_list, name='view_list'), path('<int: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('<int:list_id>/share_list', views.share_list, name="share_list"),
] ]

View File

@@ -37,3 +37,12 @@ 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"])
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 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: 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

@@ -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"
def has_perm(self, perm, obj=None):
return self.is_superuser
is_authenticated = True def has_module_perms(self, app_label):
is_anonymous =False return self.is_superuser

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

@@ -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,7 +1,10 @@
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
class SendLoginEmailViewTest(TestCase): class SendLoginEmailViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self):
@@ -19,7 +22,7 @@ class SendLoginEmailViewTest(TestCase):
self.assertEqual(mock_post.called, True) self.assertEqual(mock_post.called, True)
data = mock_post.call_args.kwargs["data"] data = mock_post.call_args.kwargs["data"]
self.assertEqual(data["subject"], "A magic login link to your Dashboard") self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.me") self.assertEqual(data["from"], "adman@howdy.earthmanrpg.com")
self.assertEqual(data["to"], "discoman@example.com") self.assertEqual(data["to"], "discoman@example.com")
def test_adds_success_message(self): def test_adds_success_message(self):

View File

View File

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

View File

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

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',
@@ -90,12 +96,16 @@ 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',
}
} }
}
# Password validation # Password validation
@@ -119,6 +129,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 +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 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,10 +1,10 @@
# 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('apps/dashboard/', include('apps.dashboard.urls')),
path('apps/lyric/', include('apps.lyric.urls')), path('apps/lyric/', include('apps.lyric.urls')),

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

@@ -2,11 +2,14 @@ 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
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.views.requests.post')
def test_login_using_magic_link(self, mock_post): def test_login_using_magic_link(self, mock_post):

View File

@@ -1,43 +1,22 @@
from django.conf import settings
from selenium.webdriver.common.by import By from 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,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,32 +2,36 @@ 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, '/apps/dashboard/.+')
@@ -35,10 +39,11 @@ class NewVisitorTest(FunctionalTest):
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, '/apps/dashboard/.+')

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 %}