Compare commits
10 Commits
10ba5b84e4
...
4e1feddb45
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e1feddb45 | ||
|
|
94a161fe09 | ||
|
|
6c0e9bb6ec | ||
|
|
c176fe6cb3 | ||
|
|
fad8657c97 | ||
|
|
306b4c8e5e | ||
|
|
8190317c21 | ||
|
|
07a76cb32d | ||
|
|
0c413a9cc2 | ||
|
|
58b526f434 |
Binary file not shown.
@@ -1,4 +1,4 @@
|
|||||||
- hosts: staging
|
- hosts: all
|
||||||
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: Debug django_allowed_host
|
- name: Debug django_allowed_host
|
||||||
@@ -12,6 +12,34 @@
|
|||||||
update_cache: true
|
update_cache: true
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
|
- name: Install nginx
|
||||||
|
ansible.builtin.apt:
|
||||||
|
name: nginx
|
||||||
|
state: latest
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Deploy nginx config
|
||||||
|
ansible.builtin.template:
|
||||||
|
src: nginx.conf.j2
|
||||||
|
dest: /etc/nginx/sites-available/gamearray
|
||||||
|
become: true
|
||||||
|
notify: Restart nginx
|
||||||
|
|
||||||
|
- name: Enable nginx site
|
||||||
|
ansible.builtin.file:
|
||||||
|
src: /etc/nginx/sites-available/gamearray
|
||||||
|
dest: /etc/nginx/sites-enabled/gamearray
|
||||||
|
state: link
|
||||||
|
become: true
|
||||||
|
notify: Restart nginx
|
||||||
|
|
||||||
|
- name: Remove default nginx site
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /etc/nginx/sites-enabled/default
|
||||||
|
state: absent
|
||||||
|
become: true
|
||||||
|
notify: Restart nginx
|
||||||
|
|
||||||
- name: Add our user to the docker group, so we don't need sudo/become
|
- name: Add our user to the docker group, so we don't need sudo/become
|
||||||
ansible.builtin.user:
|
ansible.builtin.user:
|
||||||
name: '{{ ansible_user }}'
|
name: '{{ ansible_user }}'
|
||||||
@@ -88,9 +116,29 @@
|
|||||||
EMAIL_HOST_PASSWORD: "{{ email_host_password }}"
|
EMAIL_HOST_PASSWORD: "{{ email_host_password }}"
|
||||||
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
|
||||||
ports:
|
ports:
|
||||||
80:8888 # container port 80 (standard http port) maps to server port 8888 (arbitrary internal port)
|
127.0.0.1:8888:8888
|
||||||
|
|
||||||
|
- name: Create static files directory
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: /var/www/gamearray/static
|
||||||
|
state: directory
|
||||||
|
owner: www-data
|
||||||
|
group: www-data
|
||||||
|
become: true
|
||||||
|
|
||||||
|
- name: Copy static files from container to host
|
||||||
|
ansible.builtin.command:
|
||||||
|
cmd: docker cp gamearray:/src/static/. /var/www/gamearray/static/
|
||||||
|
become: true
|
||||||
|
|
||||||
- name: Run migration inside container
|
- name: Run migration inside container
|
||||||
community.docker.docker_container_exec:
|
community.docker.docker_container_exec:
|
||||||
container: gamearray
|
container: gamearray
|
||||||
command: ./manage.py migrate
|
command: ./manage.py migrate
|
||||||
|
|
||||||
|
handlers:
|
||||||
|
- name: Restart nginx
|
||||||
|
ansible.builtin.service:
|
||||||
|
name: nginx
|
||||||
|
state: restarted
|
||||||
|
become: true
|
||||||
16
infra/nginx.conf.j2
Normal file
16
infra/nginx.conf.j2
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name {{ django_allowed_host }};
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /var/www/gamearray/static/;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8888;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,4 +27,5 @@ typing_extensions==4.15.0
|
|||||||
tzdata==2025.3
|
tzdata==2025.3
|
||||||
urllib3==2.6.2
|
urllib3==2.6.2
|
||||||
websocket-client==1.9.0
|
websocket-client==1.9.0
|
||||||
|
whitenoise==6.11.0
|
||||||
wsproto==1.3.2
|
wsproto==1.3.2
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ 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
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
|
whitenoise==6.11.0
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Generated by Django 6.0 on 2025-12-31 05:18
|
# Generated by Django 6.0 on 2026-02-08 01:19
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -12,9 +13,21 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Item',
|
name='List',
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Item',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('text', models.TextField(default='')),
|
||||||
|
('list', models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='dashboard.list')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ('id',),
|
||||||
|
'unique_together': {('list', 'text')},
|
||||||
|
},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2025-12-31 05:24
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='item',
|
|
||||||
name='text',
|
|
||||||
field=models.TextField(default=''),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
21
src/apps/dashboard/migrations/0002_list_owner.py
Normal file
21
src/apps/dashboard/migrations/0002_list_owner.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 6.0 on 2026-02-09 03:29
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('dashboard', '0001_initial'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='list',
|
||||||
|
name='owner',
|
||||||
|
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='lists', to=settings.AUTH_USER_MODEL),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-01-03 03:05
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0002_item_text'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='List',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.AddField(
|
|
||||||
model_name='item',
|
|
||||||
name='list',
|
|
||||||
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='dashboard.list'),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-01-24 02:45
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0003_list_item_list'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterUniqueTogether(
|
|
||||||
name='item',
|
|
||||||
unique_together={('list', 'text')},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-01-24 17:54
|
|
||||||
|
|
||||||
from django.db import migrations
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('dashboard', '0004_alter_item_unique_together'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.AlterModelOptions(
|
|
||||||
name='item',
|
|
||||||
options={'ordering': ('id',)},
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -2,6 +2,18 @@ from django.db import models
|
|||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
|
||||||
class List(models.Model):
|
class List(models.Model):
|
||||||
|
owner = models.ForeignKey(
|
||||||
|
"lyric.User",
|
||||||
|
related_name="lists",
|
||||||
|
blank=True,
|
||||||
|
null=True,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return self.item_set.first().text
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("view_list", args=[self.id])
|
return reverse("view_list", args=[self.id])
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
console.log("apps/scripts/dashboard.js loading");
|
// console.log("apps/scripts/dashboard.js loading");
|
||||||
const initialize = (inputSelector) => {
|
const initialize = (inputSelector) => {
|
||||||
console.log("initialize called!");
|
// console.log("initialize called!");
|
||||||
const textInput = document.querySelector(inputSelector);
|
const textInput = document.querySelector(inputSelector);
|
||||||
textInput.oninput = () => {
|
textInput.oninput = () => {
|
||||||
console.log("oninput triggered");
|
// console.log("oninput triggered");
|
||||||
textInput.classList.remove("is-invalid");
|
textInput.classList.remove("is-invalid");
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -2,6 +2,7 @@ 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 ..models import Item, List
|
||||||
|
from apps.lyric.models import User
|
||||||
|
|
||||||
class ItemModelTest(TestCase):
|
class ItemModelTest(TestCase):
|
||||||
def test_default_text(self):
|
def test_default_text(self):
|
||||||
@@ -59,3 +60,17 @@ class ListModelTest(TestCase):
|
|||||||
list(list1.item_set.all()),
|
list(list1.item_set.all()),
|
||||||
[item1, item2, item3],
|
[item1, item2, item3],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_lists_can_have_owners(self):
|
||||||
|
user = User.objects.create(email="a@b.cde")
|
||||||
|
mylist = List.objects.create(owner=user)
|
||||||
|
self.assertIn(mylist, user.lists.all())
|
||||||
|
|
||||||
|
def test_list_owner_is_optional(self):
|
||||||
|
List.objects.create()
|
||||||
|
|
||||||
|
def test_list_name_is_first_item_text(self):
|
||||||
|
list_ = List.objects.create()
|
||||||
|
Item.objects.create(list=list_, text="first item")
|
||||||
|
Item.objects.create(list=list_, text="second item")
|
||||||
|
self.assertEqual(list_.name, "first item")
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import lxml.html
|
||||||
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 unittest import skip
|
||||||
@@ -6,7 +7,7 @@ from ..forms import (
|
|||||||
EMPTY_ITEM_ERROR,
|
EMPTY_ITEM_ERROR,
|
||||||
)
|
)
|
||||||
from ..models import Item, List
|
from ..models import Item, List
|
||||||
import lxml.html
|
from apps.lyric.models import User
|
||||||
|
|
||||||
class HomePageTest(TestCase):
|
class HomePageTest(TestCase):
|
||||||
def test_uses_home_template(self):
|
def test_uses_home_template(self):
|
||||||
@@ -144,3 +145,22 @@ class ListViewTest(TestCase):
|
|||||||
self.assertContains(response, expected_error)
|
self.assertContains(response, expected_error)
|
||||||
self.assertTemplateUsed(response, "apps/dashboard/list.html")
|
self.assertTemplateUsed(response, "apps/dashboard/list.html")
|
||||||
self.assertEqual(Item.objects.all().count(), 1)
|
self.assertEqual(Item.objects.all().count(), 1)
|
||||||
|
|
||||||
|
class MyListsTest(TestCase):
|
||||||
|
def test_my_lists_url_renders_my_lists_template(self):
|
||||||
|
user = User.objects.create(email="a@b.cde")
|
||||||
|
response = self.client.get(f"/apps/dashboard/users/{user.id}/")
|
||||||
|
self.assertTemplateUsed(response, "apps/dashboard/my_lists.html")
|
||||||
|
|
||||||
|
def test_passes_correct_owner_to_template(self):
|
||||||
|
User.objects.create(email="wrong@owner.com")
|
||||||
|
correct_user = User.objects.create(email="a@b.cde")
|
||||||
|
response = self.client.get(f"/apps/dashboard/users/{correct_user.id}/")
|
||||||
|
self.assertEqual(response.context["owner"], correct_user)
|
||||||
|
|
||||||
|
def test_list_owner_is_saved_if_user_is_authenticated(self):
|
||||||
|
user = User.objects.create(email="a@b.cde")
|
||||||
|
self.client.force_login(user)
|
||||||
|
self.client.post("/apps/dashboard/new_list", data={"text": "new item"})
|
||||||
|
new_list = List.objects.get()
|
||||||
|
self.assertEqual(new_list.owner, user)
|
||||||
|
|||||||
@@ -4,4 +4,5 @@ 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('<int:list_id>/', views.view_list, name='view_list'),
|
||||||
|
path('users/<uuid:user_id>/', views.my_lists, name='my_lists'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
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
|
||||||
|
|
||||||
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()})
|
||||||
@@ -9,6 +10,9 @@ def new_list(request):
|
|||||||
form = ItemForm(data=request.POST)
|
form = ItemForm(data=request.POST)
|
||||||
if form.is_valid():
|
if form.is_valid():
|
||||||
nulist = List.objects.create()
|
nulist = List.objects.create()
|
||||||
|
if request.user.is_authenticated:
|
||||||
|
nulist.owner = request.user
|
||||||
|
nulist.save()
|
||||||
form.save(for_list=nulist)
|
form.save(for_list=nulist)
|
||||||
return redirect(nulist)
|
return redirect(nulist)
|
||||||
else:
|
else:
|
||||||
@@ -25,3 +29,6 @@ def view_list(request, list_id):
|
|||||||
return redirect(our_list)
|
return redirect(our_list)
|
||||||
return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form})
|
return render(request, "apps/dashboard/list.html", {"list": our_list, "form": form})
|
||||||
|
|
||||||
|
def my_lists(request, user_id):
|
||||||
|
owner = User.objects.get(id=user_id)
|
||||||
|
return render(request, "apps/dashboard/my_lists.html", {"owner": owner})
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Generated by Django 6.0 on 2026-01-30 20:02
|
# Generated by Django 6.0 on 2026-02-08 01:19
|
||||||
|
|
||||||
|
import uuid
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@@ -14,8 +15,15 @@ class Migration(migrations.Migration):
|
|||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='User',
|
name='User',
|
||||||
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)),
|
||||||
('email', models.EmailField(max_length=254, unique=True)),
|
('email', models.EmailField(max_length=254, unique=True)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Token',
|
||||||
|
fields=[
|
||||||
|
('email', models.EmailField(max_length=254)),
|
||||||
|
('uid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
|
||||||
|
],
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-01-30 20:10
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('lyric', '0001_initial'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.CreateModel(
|
|
||||||
name='Token',
|
|
||||||
fields=[
|
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
|
||||||
('email', models.EmailField(max_length=254)),
|
|
||||||
('uid', models.UUIDField()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
# Generated by Django 6.0 on 2026-01-31 01:03
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from django.db import migrations, models
|
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
|
||||||
|
|
||||||
dependencies = [
|
|
||||||
('lyric', '0002_token'),
|
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name='token',
|
|
||||||
name='id',
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='token',
|
|
||||||
name='uid',
|
|
||||||
field=models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name='user',
|
|
||||||
name='id',
|
|
||||||
field=models.BigAutoField(primary_key=True, serialize=False),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
@@ -6,7 +6,7 @@ class Token(models.Model):
|
|||||||
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(models.Model):
|
||||||
id = models.BigAutoField(primary_key=True)
|
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
|
|
||||||
REQUIRED_FIELDS = []
|
REQUIRED_FIELDS = []
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ INSTALLED_APPS = [
|
|||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
'django.middleware.common.CommonMiddleware',
|
'django.middleware.common.CommonMiddleware',
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.contrib.staticfiles.testing import StaticLiveServerTestCase
|
|||||||
from selenium import webdriver
|
from selenium import webdriver
|
||||||
from selenium.common.exceptions import WebDriverException
|
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 .container_commands import reset_database
|
from .container_commands import reset_database
|
||||||
|
|
||||||
@@ -49,6 +50,13 @@ class FunctionalTest(StaticLiveServerTestCase):
|
|||||||
rows = self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr")
|
rows = self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr")
|
||||||
self.assertIn(row_text, [row.text for row in rows])
|
self.assertIn(row_text, [row.text for row in rows])
|
||||||
|
|
||||||
|
def add_list_item(self, item_text):
|
||||||
|
num_rows = len(self.browser.find_elements(By.CSS_SELECTOR, "#id_list_table tr"))
|
||||||
|
self.get_item_input_box().send_keys(item_text)
|
||||||
|
self.get_item_input_box().send_keys(Keys.ENTER)
|
||||||
|
item_number = num_rows + 1
|
||||||
|
self.wait_for_row_in_list_table(f"{item_number}. {item_text}")
|
||||||
|
|
||||||
@wait
|
@wait
|
||||||
def wait_to_be_logged_in(self, email):
|
def wait_to_be_logged_in(self, email):
|
||||||
self.browser.find_element(By.CSS_SELECTOR, "#id_logout"),
|
self.browser.find_element(By.CSS_SELECTOR, "#id_logout"),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ def _exec_in_container(host, commands):
|
|||||||
return _exec_in_container_on_server(host, commands)
|
return _exec_in_container_on_server(host, commands)
|
||||||
|
|
||||||
def _exec_in_container_locally(commands):
|
def _exec_in_container_locally(commands):
|
||||||
print(f"Running {commands} on inside local docker container")
|
# print(f"Running {commands} on inside local docker container")
|
||||||
return _run_commands(["docker", "exec", _get_container_id()] + commands)
|
return _run_commands(["docker", "exec", _get_container_id()] + commands)
|
||||||
|
|
||||||
def _exec_in_container_on_server(host, commands):
|
def _exec_in_container_on_server(host, commands):
|
||||||
@@ -52,5 +52,5 @@ def _run_commands(commands):
|
|||||||
result = process.stdout.decode()
|
result = process.stdout.decode()
|
||||||
if process.returncode != 0:
|
if process.returncode != 0:
|
||||||
raise Exception(result)
|
raise Exception(result)
|
||||||
print(f"Result: {result!r}")
|
# print(f"Result: {result!r}")
|
||||||
return result.strip()
|
return result.strip()
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ class Command(BaseCommand):
|
|||||||
def create_pre_authenticated_session(email):
|
def create_pre_authenticated_session(email):
|
||||||
user = User.objects.create(email=email)
|
user = User.objects.create(email=email)
|
||||||
session = SessionStore()
|
session = SessionStore()
|
||||||
session[SESSION_KEY] = user.pk
|
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] = settings.AUTHENTICATION_BACKENDS[0]
|
||||||
session.save()
|
session.save()
|
||||||
return session.session_key
|
return session.session_key
|
||||||
|
|||||||
@@ -17,9 +17,7 @@ class LayoutAndStylingTest(FunctionalTest):
|
|||||||
delta=10,
|
delta=10,
|
||||||
)
|
)
|
||||||
|
|
||||||
inputbox.send_keys('testing')
|
self.add_list_item("testing")
|
||||||
inputbox.send_keys(Keys.ENTER)
|
|
||||||
self.wait_for_row_in_list_table('1. testing')
|
|
||||||
inputbox = self.get_item_input_box()
|
inputbox = self.get_item_input_box()
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
inputbox.location['x'] + inputbox.size['width'] / 2,
|
inputbox.location['x'] + inputbox.size['width'] / 2,
|
||||||
|
|||||||
@@ -44,9 +44,7 @@ class ItemValidationTest(FunctionalTest):
|
|||||||
|
|
||||||
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.get_item_input_box().send_keys("Witness divinity")
|
self.add_list_item("Witness divinity")
|
||||||
self.get_item_input_box().send_keys(Keys.ENTER)
|
|
||||||
self.wait_for_row_in_list_table("1. Witness divinity")
|
|
||||||
|
|
||||||
self.get_item_input_box().send_keys("Witness divinity")
|
self.get_item_input_box().send_keys("Witness divinity")
|
||||||
self.get_item_input_box().send_keys(Keys.ENTER)
|
self.get_item_input_box().send_keys(Keys.ENTER)
|
||||||
@@ -60,10 +58,8 @@ 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.get_item_input_box().send_keys("Banter too thicc")
|
self.add_list_item("Gobbledygook")
|
||||||
self.get_item_input_box().send_keys(Keys.ENTER)
|
self.get_item_input_box().send_keys("Gobbledygook")
|
||||||
self.wait_for_row_in_list_table("1. Banter too thicc")
|
|
||||||
self.get_item_input_box().send_keys("Banter too thicc")
|
|
||||||
self.get_item_input_box().send_keys(Keys.ENTER)
|
self.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())
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import re
|
import re
|
||||||
from django.core import mail
|
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
|
||||||
@@ -8,7 +8,11 @@ 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):
|
||||||
def test_login_using_magic_link(self):
|
@patch('apps.lyric.views.requests.post')
|
||||||
|
def test_login_using_magic_link(self, mock_post):
|
||||||
|
# Mock successful Mailgun API response
|
||||||
|
mock_post.return_value.status_code = 200
|
||||||
|
|
||||||
self.browser.get(self.live_server_url)
|
self.browser.get(self.live_server_url)
|
||||||
self.browser.find_element(By.CSS_SELECTOR, "input[name=email]").send_keys(
|
self.browser.find_element(By.CSS_SELECTOR, "input[name=email]").send_keys(
|
||||||
TEST_EMAIL, Keys.ENTER
|
TEST_EMAIL, Keys.ENTER
|
||||||
@@ -24,14 +28,20 @@ class LoginTest(FunctionalTest):
|
|||||||
if self.test_server:
|
if self.test_server:
|
||||||
return
|
return
|
||||||
|
|
||||||
email = mail.outbox.pop()
|
# Verify Mailgun API was called
|
||||||
self.assertIn(TEST_EMAIL, email.to)
|
self.assertEqual(mock_post.call_count, 1)
|
||||||
self.assertEqual(email.subject, SUBJECT)
|
call_kwargs = mock_post.call_args.kwargs
|
||||||
|
|
||||||
self.assertIn("Use this magic link to login to your Dashboard", email.body)
|
# Check email data
|
||||||
url_search = re.search(r"http://.+/.+$", email.body)
|
self.assertEqual(call_kwargs['data']['to'], TEST_EMAIL)
|
||||||
|
self.assertEqual(call_kwargs['data']['subject'], SUBJECT)
|
||||||
|
|
||||||
|
# Extract magic link URL from email body
|
||||||
|
email_body = call_kwargs['data']['text']
|
||||||
|
self.assertIn("Use this magic link to login to your Dashboard", email_body)
|
||||||
|
url_search = re.search(r"http://.+/.+$", email_body)
|
||||||
if not url_search:
|
if not url_search:
|
||||||
self.fail(f"Could not find url in email body:\n{email.body}")
|
self.fail(f"Could not find url in email body:\n{email_body}")
|
||||||
url = url_search.group(0)
|
url = url_search.group(0)
|
||||||
self.assertIn(self.live_server_url, url)
|
self.assertIn(self.live_server_url, url)
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,9 @@
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.auth import BACKEND_SESSION_KEY, SESSION_KEY, get_user_model
|
from selenium.webdriver.common.by import By
|
||||||
from django.contrib.sessions.backends.db import SessionStore
|
|
||||||
from .base import FunctionalTest
|
from .base import FunctionalTest
|
||||||
from .container_commands import create_session_on_server
|
from .container_commands import create_session_on_server
|
||||||
from .management.commands.create_session import create_pre_authenticated_session
|
from .management.commands.create_session import create_pre_authenticated_session
|
||||||
|
|
||||||
User = get_user_model()
|
|
||||||
|
|
||||||
|
|
||||||
class MyListsTest(FunctionalTest):
|
class MyListsTest(FunctionalTest):
|
||||||
def create_pre_authenticated_session(self, email):
|
def create_pre_authenticated_session(self, email):
|
||||||
@@ -25,11 +22,48 @@ class MyListsTest(FunctionalTest):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_logged_in_users_are_saved_as_my_lists(self):
|
def test_logged_in_users_lists_are_saved_as_my_lists(self):
|
||||||
email = "discoman@example.com"
|
self.create_pre_authenticated_session("discoman@example.com")
|
||||||
self.browser.get(self.live_server_url)
|
|
||||||
self.wait_to_be_logged_out(email)
|
|
||||||
|
|
||||||
self.create_pre_authenticated_session(email)
|
|
||||||
self.browser.get(self.live_server_url)
|
self.browser.get(self.live_server_url)
|
||||||
self.wait_to_be_logged_in(email)
|
self.add_list_item("Reticulate splines")
|
||||||
|
self.add_list_item("Regurgitate spines")
|
||||||
|
first_list_url = self.browser.current_url
|
||||||
|
|
||||||
|
self.browser.find_element(By.LINK_TEXT, "My lists").click()
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertIn(
|
||||||
|
"discoman@example.com",
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "h2").text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.LINK_TEXT, "Reticulate splines")
|
||||||
|
)
|
||||||
|
self.browser.find_element(By.LINK_TEXT, "Reticulate splines").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(self.browser.current_url, first_list_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.browser.get(self.live_server_url)
|
||||||
|
self.add_list_item("Ribbon of death")
|
||||||
|
second_list_url = self.browser.current_url
|
||||||
|
|
||||||
|
self.browser.find_element(By.LINK_TEXT, "My lists").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.browser.find_element(By.LINK_TEXT, "Ribbon of death")
|
||||||
|
)
|
||||||
|
self.browser.find_element(By.LINK_TEXT, "Ribbon of death").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(self.browser.current_url, second_list_url)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.browser.find_element(By.CSS_SELECTOR, "#id_logout").click()
|
||||||
|
self.wait_for(
|
||||||
|
lambda: self.assertEqual(
|
||||||
|
self.browser.find_elements(By.LINK_TEXT, "My lists"),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,19 +22,14 @@ class NewVisitorTest(FunctionalTest):
|
|||||||
inputbox.send_keys(Keys.ENTER)
|
inputbox.send_keys(Keys.ENTER)
|
||||||
self.wait_for_row_in_list_table('1. Buy peacock feathers')
|
self.wait_for_row_in_list_table('1. Buy peacock feathers')
|
||||||
|
|
||||||
inputbox = self.get_item_input_box()
|
self.add_list_item("Use peacock feathers to make a fly")
|
||||||
inputbox.send_keys('Use peacock feathers to make a fly')
|
|
||||||
inputbox.send_keys(Keys.ENTER)
|
|
||||||
|
|
||||||
self.wait_for_row_in_list_table('2. Use peacock feathers to make a fly')
|
self.wait_for_row_in_list_table('2. Use peacock feathers to make a fly')
|
||||||
self.wait_for_row_in_list_table('1. Buy peacock feathers')
|
self.wait_for_row_in_list_table('1. Buy peacock feathers')
|
||||||
|
|
||||||
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)
|
||||||
inputbox = self.get_item_input_box()
|
self.add_list_item("Buy peacock feathers")
|
||||||
inputbox.send_keys('Buy peacock feathers')
|
|
||||||
inputbox.send_keys(Keys.ENTER)
|
|
||||||
self.wait_for_row_in_list_table('1. 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/.+')
|
||||||
@@ -45,10 +40,7 @@ class NewVisitorTest(FunctionalTest):
|
|||||||
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)
|
||||||
|
|
||||||
inputbox = self.get_item_input_box()
|
self.add_list_item("Buy milk")
|
||||||
inputbox.send_keys('Buy milk')
|
|
||||||
inputbox.send_keys(Keys.ENTER)
|
|
||||||
self.wait_for_row_in_list_table('1. 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/.+')
|
||||||
|
|||||||
17
src/templates/apps/dashboard/_partials/_form.html
Normal file
17
src/templates/apps/dashboard/_partials/_form.html
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<form method="POST" action="{{ form_action }}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<input
|
||||||
|
id="id_text"
|
||||||
|
name="text"
|
||||||
|
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}"
|
||||||
|
placeholder="Enter a to-do item"
|
||||||
|
value="{{ form.text.value | default:'' }}"
|
||||||
|
aria-describedby="id_text_feedback"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{% if form.errors %}
|
||||||
|
<div id="id_text_feedback" class="invalid-feedback">
|
||||||
|
{{ form.errors.text.0 }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</form>
|
||||||
6
src/templates/apps/dashboard/_partials/_scripts.html
Normal file
6
src/templates/apps/dashboard/_partials/_scripts.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<script src="/static/apps/scripts/dashboard.js"></script>
|
||||||
|
<script>
|
||||||
|
window.onload = () => {
|
||||||
|
initialize("#id_text");
|
||||||
|
};
|
||||||
|
</script>
|
||||||
@@ -3,4 +3,11 @@
|
|||||||
{% block title_text %}Start a new to-do list{% endblock title_text %}
|
{% block title_text %}Start a new to-do list{% endblock title_text %}
|
||||||
{% block header_text %}Start a new to-do list{% endblock header_text %}
|
{% block header_text %}Start a new to-do list{% endblock header_text %}
|
||||||
|
|
||||||
{% block form_action %}{% url "new_list" %}{% endblock form_action %}
|
{% block extra_header %}
|
||||||
|
{% url "new_list" as form_action %}
|
||||||
|
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
|
||||||
|
{% endblock extra_header %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% include "apps/dashboard/_partials/_scripts.html" %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
|||||||
@@ -3,13 +3,24 @@
|
|||||||
{% block title_text %}Your to-do list{% endblock title_text %}
|
{% block title_text %}Your to-do list{% endblock title_text %}
|
||||||
{% block header_text %}Your to-do list{% endblock header_text %}
|
{% block header_text %}Your to-do list{% endblock header_text %}
|
||||||
|
|
||||||
{% block form_action %}{% url "view_list" list.id %}{% endblock form_action %}
|
|
||||||
|
|
||||||
{% block table %}
|
{% block extra_header %}
|
||||||
<table id="id_list_table" class="table">
|
{% url "view_list" list.id as form_action %}
|
||||||
{% for item in list.item_set.all %}
|
{% include "apps/dashboard/_partials/_form.html" with form=form form_action=form_action %}
|
||||||
<tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr>
|
{% endblock extra_header %}
|
||||||
{% endfor %}
|
|
||||||
</table>
|
|
||||||
{% endblock table %}
|
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<table id="id_list_table" class="table">
|
||||||
|
{% for item in list.item_set.all %}
|
||||||
|
<tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
|
|
||||||
|
{% block scripts %}
|
||||||
|
{% include "apps/dashboard/_partials/_scripts.html" %}
|
||||||
|
{% endblock scripts %}
|
||||||
|
|||||||
12
src/templates/apps/dashboard/my_lists.html
Normal file
12
src/templates/apps/dashboard/my_lists.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends "core/base.html" %}
|
||||||
|
|
||||||
|
{% block header_text %}{{ user.email }}'s lists{% endblock header_text %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h3>{{ owner.email }}'s lists</h3>
|
||||||
|
<ul>
|
||||||
|
{% for list in owner.lists.all %}
|
||||||
|
<li><a href="{{ list.get_absolute_url }}">{{ list.name }}</a></li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% endblock content %}
|
||||||
@@ -18,6 +18,7 @@
|
|||||||
<h1>Welcome, Earthman</h1>
|
<h1>Welcome, Earthman</h1>
|
||||||
</a>
|
</a>
|
||||||
{% if user.email %}
|
{% if user.email %}
|
||||||
|
<a class="navbar-link" href="{% url 'my_lists' user.id %}">My lists</a>
|
||||||
<span class="navbar-text">Logged in as {{ user.email }}</span>
|
<span class="navbar-text">Logged in as {{ user.email }}</span>
|
||||||
<form method="POST" action="{% url "logout" %}">
|
<form method="POST" action="{% url "logout" %}">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
@@ -61,33 +62,13 @@
|
|||||||
<div class="row justify-content-center p-5 bg-body-tertiary rounded-3">
|
<div class="row justify-content-center p-5 bg-body-tertiary rounded-3">
|
||||||
<div class="col-lg-6 text-center">
|
<div class="col-lg-6 text-center">
|
||||||
<h2 class="display-1 mb-4">{% block header_text %}{% endblock header_text %}</h2>
|
<h2 class="display-1 mb-4">{% block header_text %}{% endblock header_text %}</h2>
|
||||||
|
{% block extra_header %}
|
||||||
<form method="POST" action="{% block form_action %}{% endblock form_action %}">
|
{% endblock extra_header %}
|
||||||
{% csrf_token %}
|
|
||||||
<input
|
|
||||||
id="id_text"
|
|
||||||
name="text"
|
|
||||||
class="form-control form-control-lg{% if form.errors %} is-invalid{% endif %}"
|
|
||||||
placeholder="Enter a to-do item"
|
|
||||||
value="{{ form.text.value | default:'' }}"
|
|
||||||
aria-describedby="id_text_feedback"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{% if form.errors %}
|
|
||||||
<div id="id_text_feedback" class="invalid-feedback">
|
|
||||||
{{ form.errors.text.0 }}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row justify-content-center">
|
{% block content %}
|
||||||
<div class="col-lg-6">
|
{% endblock content %}
|
||||||
{% block table %}
|
|
||||||
{% endblock table %}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
@@ -95,12 +76,7 @@
|
|||||||
<script
|
<script
|
||||||
src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js"
|
src="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/js/bootstrap.min.js"
|
||||||
></script>
|
></script>
|
||||||
|
{% block scripts %}
|
||||||
<script src="/static/apps/scripts/dashboard.js"></script>
|
{% endblock scripts %}
|
||||||
<script>
|
|
||||||
window.onload = () => {
|
|
||||||
initialize("#id_text");
|
|
||||||
};
|
|
||||||
</script>
|
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
Reference in New Issue
Block a user