manage.py changed to lf; FTs tweaked to accomodate WSL2 ansible deployment

This commit is contained in:
Disco DeDisco
2026-01-13 20:58:05 -05:00
parent d942839308
commit 4b137db317
21 changed files with 881 additions and 848 deletions

354
.gitignore vendored
View File

@@ -1,177 +1,177 @@
# Created by https://www.toptal.com/developers/gitignore/api/django # Created by https://www.toptal.com/developers/gitignore/api/django
# Edit at https://www.toptal.com/developers/gitignore?templates=django # Edit at https://www.toptal.com/developers/gitignore?templates=django
### Django ### ### Django ###
*.log *.log
*.pot *.pot
*.pyc *.pyc
__pycache__/ __pycache__/
local_settings.py local_settings.py
db.sqlite3 db.sqlite3
db.sqlite3-journal db.sqlite3-journal
container.db.sqlite3 container.db.sqlite3
media media
static static
# If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/ # If your build process includes running collectstatic, then you probably don't need or want to include staticfiles/
# in your Git repository. Update and uncomment the following line accordingly. # in your Git repository. Update and uncomment the following line accordingly.
# <django-project-name>/staticfiles/ # <django-project-name>/staticfiles/
### Django.Python Stack ### ### Django.Python Stack ###
# Byte-compiled / optimized / DLL files # Byte-compiled / optimized / DLL files
*.py[cod] *.py[cod]
*$py.class *$py.class
# C extensions # C extensions
*.so *.so
# Distribution / packaging # Distribution / packaging
.Python .Python
build/ build/
develop-eggs/ develop-eggs/
dist/ dist/
downloads/ downloads/
eggs/ eggs/
.eggs/ .eggs/
lib/ lib/
lib64/ lib64/
parts/ parts/
sdist/ sdist/
var/ var/
wheels/ wheels/
share/python-wheels/ share/python-wheels/
*.egg-info/ *.egg-info/
.installed.cfg .installed.cfg
*.egg *.egg
MANIFEST MANIFEST
# PyInstaller # PyInstaller
# Usually these files are written by a python script from a template # Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it. # before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest *.manifest
*.spec *.spec
# Installer logs # Installer logs
pip-log.txt pip-log.txt
pip-delete-this-directory.txt pip-delete-this-directory.txt
# Unit test / coverage reports # Unit test / coverage reports
htmlcov/ htmlcov/
.tox/ .tox/
.nox/ .nox/
.coverage .coverage
.coverage.* .coverage.*
.cache .cache
nosetests.xml nosetests.xml
coverage.xml coverage.xml
*.cover *.cover
*.py,cover *.py,cover
.hypothesis/ .hypothesis/
.pytest_cache/ .pytest_cache/
cover/ cover/
# Translations # Translations
*.mo *.mo
# Django stuff: # Django stuff:
# Flask stuff: # Flask stuff:
instance/ instance/
.webassets-cache .webassets-cache
# Scrapy stuff: # Scrapy stuff:
.scrapy .scrapy
# Sphinx documentation # Sphinx documentation
docs/_build/ docs/_build/
# PyBuilder # PyBuilder
.pybuilder/ .pybuilder/
target/ target/
# Jupyter Notebook # Jupyter Notebook
.ipynb_checkpoints .ipynb_checkpoints
# IPython # IPython
profile_default/ profile_default/
ipython_config.py ipython_config.py
# pyenv # pyenv
# For a library or package, you might want to ignore these files since the code is # For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in: # intended to run in multiple environments; otherwise, check them in:
# .python-version # .python-version
# pipenv # pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies # However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not # having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies. # install all needed dependencies.
#Pipfile.lock #Pipfile.lock
# poetry # poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more # This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries. # commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock #poetry.lock
# pdm # pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock #pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control. # in version control.
# https://pdm.fming.dev/#use-with-ide # https://pdm.fming.dev/#use-with-ide
.pdm.toml .pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/ __pypackages__/
# Celery stuff # Celery stuff
celerybeat-schedule celerybeat-schedule
celerybeat.pid celerybeat.pid
# SageMath parsed files # SageMath parsed files
*.sage.py *.sage.py
# Environments # Environments
.env .env
.venv .venv
.venv-wsl .venv-wsl
env/ env/
venv/ venv/
ENV/ ENV/
env.bak/ env.bak/
venv.bak/ venv.bak/
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
# Rope project settings # Rope project settings
.ropeproject .ropeproject
# mkdocs documentation # mkdocs documentation
/site /site
# mypy # mypy
.mypy_cache/ .mypy_cache/
.dmypy.json .dmypy.json
dmypy.json dmypy.json
# Pyre type checker # Pyre type checker
.pyre/ .pyre/
# pytype static type analyzer # pytype static type analyzer
.pytype/ .pytype/
# Cython debug symbols # Cython debug symbols
cython_debug/ cython_debug/
# PyCharm # PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can # JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
# End of https://www.toptal.com/developers/gitignore/api/django # End of https://www.toptal.com/developers/gitignore/api/django

View File

@@ -1,20 +1,20 @@
FROM python:3.13-slim FROM python:3.13-slim
RUN python -m venv /.venv RUN python -m venv /.venv
ENV PATH="/.venv/bin:$PATH" ENV PATH="/.venv/bin:$PATH"
COPY requirements.txt /tmp/requirements.txt COPY requirements.txt /tmp/requirements.txt
RUN pip install -r /tmp/requirements.txt RUN pip install -r /tmp/requirements.txt
COPY src /src COPY src /src
WORKDIR /src WORKDIR /src
RUN python manage.py collectstatic RUN python manage.py collectstatic
ENV DJANGO_DEBUG_FALSE=1 ENV DJANGO_DEBUG_FALSE=1
RUN adduser --uid 1234 nonroot RUN adduser --uid 1234 nonroot
USER nonroot
USER nonroot
CMD ["gunicorn", "--bind", ":8888", "core.wsgi:application"] CMD ["gunicorn", "--bind", ":8888", "core.wsgi:application"]

View File

@@ -1,57 +1,89 @@
- hosts: all - hosts: all
tasks: tasks:
- name: Install docker - name: Install docker
ansible.builtin.apt: ansible.builtin.apt:
name: docker.io name: docker.io
state: latest state: latest
update_cache: true update_cache: true
become: true become: true
- 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 }}'
groups: docker groups: docker
append: true # don't remove any existing groups append: true # don't remove any existing groups
become: true become: true
- 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 - name: Build container image locally
community.docker.docker_image: community.docker.docker_image:
name: gamearray name: gamearray
source: build source: build
state: present state: present
build: build:
path: .. path: /mnt/d/cosmovault/latticework/oreilly/percival/python-tdd
platform: linux/amd64 platform: linux/amd64
force_source: true force_source: true
delegate_to: 127.0.0.1 delegate_to: 127.0.0.1
- name: Export container image locally - name: Export container image locally
community.docker.docker_image: community.docker.docker_image:
name: gamearray name: gamearray
archive_path: /tmp/gamearray-img.tar archive_path: /tmp/gamearray-img.tar
source: local source: local
delegate_to: 127.0.0.1 delegate_to: 127.0.0.1
- name: Upload image to server - name: Upload image to server
ansible.builtin.copy: ansible.builtin.copy:
src: /tmp/gamearray-img.tar src: /tmp/gamearray-img.tar
dest: /tmp/gamearray-img.tar dest: /tmp/gamearray-img.tar
- name: Import container image on server - name: Import container image on server
community.docker.docker_image: community.docker.docker_image:
name: gamearray name: gamearray
load_path: /tmp/gamearray-img.tar load_path: /tmp/gamearray-img.tar
source: load source: load
force_source: true force_source: true
state: present state: present
- name: Run container - name: Ensure .secret-key files exists
community.docker.docker_container: # the intention is that this only happens once per server
name: gamearray ansible.builtin.copy:
image: gamearray dest: ~/.secret-key
state: started content: "{{ lookup('password', '/dev/null length=32 chars=ascii_letters') }}"
recreate: true mode: 0600
force: false # do not recreate file if it already exists
- name: Read secret key back from file
ansible.builtin.slurp:
src: ~/.secret-key
register: secret_key
- name: Ensure db.sqlite3 file exists outside container
ansible.builtin.file:
path: "{{ ansible_env.HOME }}/db.sqlite3"
state: touch
owner: 1234 # so nonroot user can access it in container
become: true # needed for ownership change
- name: Run container
community.docker.docker_container:
name: gamearray
image: gamearray
state: started
recreate: true
env:
DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "staging.earthmanrpg.me,104.131.184.0,localhost"
DJANGO_DB_PATH: "/home/nonroot/db.sqlite3"
ports:
80:8888 # container port 80 (standard http port) maps to server port 8888 (arbitrary internal port)
- name: Run migration inside container
community.docker.docker_container_exec:
container: gamearray
command: ./manage.py migrate

View File

@@ -1,3 +1,3 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. # Register your models here.

View File

@@ -1,5 +1,5 @@
from django.apps import AppConfig from django.apps import AppConfig
class DashboardConfig(AppConfig): class DashboardConfig(AppConfig):
name = 'apps.dashboard' name = 'apps.dashboard'

View File

@@ -1,20 +1,20 @@
# Generated by Django 6.0 on 2025-12-31 05:18 # Generated by Django 6.0 on 2025-12-31 05:18
from django.db import migrations, models from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
] ]
operations = [ operations = [
migrations.CreateModel( migrations.CreateModel(
name='Item', name='Item',
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')),
], ],
), ),
] ]

View File

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

View File

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

View File

@@ -1,9 +1,9 @@
from django.db import models from django.db import models
class List(models.Model): class List(models.Model):
pass pass
class Item(models.Model): class Item(models.Model):
text = models.TextField(default='') text = models.TextField(default='')
list = models.ForeignKey(List, default=None, on_delete=models.CASCADE) list = models.ForeignKey(List, default=None, on_delete=models.CASCADE)

View File

@@ -1,111 +1,111 @@
from django.test import TestCase from django.test import TestCase
from .models import Item, List from .models import Item, List
import lxml.html import lxml.html
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('/')
self.assertTemplateUsed(response, 'apps/dashboard/home.html') self.assertTemplateUsed(response, 'apps/dashboard/home.html')
def test_renders_input_form(self): def test_renders_input_form(self):
response = self.client.get('/') response = self.client.get('/')
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
[form] = parsed.cssselect('form[method=POST]') [form] = parsed.cssselect('form[method=POST]')
self.assertEqual(form.get('action'), '/apps/dashboard/newlist') self.assertEqual(form.get('action'), '/apps/dashboard/newlist')
inputs = form.cssselect('input') inputs = form.cssselect('input')
self.assertIn('item_text', [input.get('name') for input in inputs]) self.assertIn('item_text', [input.get('name') for input in inputs])
class ListAndItemModelsTest(TestCase): class ListAndItemModelsTest(TestCase):
def test_saving_and_retrieving_items(self): def test_saving_and_retrieving_items(self):
mylist = List() mylist = List()
mylist.save() mylist.save()
first_item = Item() first_item = Item()
first_item.text = "The first (ever) list item" first_item.text = "The first (ever) list item"
first_item.list = mylist first_item.list = mylist
first_item.save() first_item.save()
second_item = Item() second_item = Item()
second_item.text = "A sequel somehow better than the first" second_item.text = "A sequel somehow better than the first"
second_item.list = mylist second_item.list = mylist
second_item.save() second_item.save()
saved_list = List.objects.get() saved_list = List.objects.get()
self.assertEqual(saved_list, mylist) self.assertEqual(saved_list, mylist)
saved_items = Item.objects.all() saved_items = Item.objects.all()
self.assertEqual(saved_items.count(), 2) self.assertEqual(saved_items.count(), 2)
first_saved_item = saved_items[0] first_saved_item = saved_items[0]
second_saved_item = saved_items[1] second_saved_item = saved_items[1]
self.assertEqual(first_saved_item.text, "The first (ever) list item") self.assertEqual(first_saved_item.text, "The first (ever) list item")
self.assertEqual(first_saved_item.list, mylist) self.assertEqual(first_saved_item.list, mylist)
self.assertEqual(second_saved_item.text, "A sequel somehow better than the first") self.assertEqual(second_saved_item.text, "A sequel somehow better than the first")
self.assertEqual(second_saved_item.list, mylist) self.assertEqual(second_saved_item.list, mylist)
class DashViewTest(TestCase): class DashViewTest(TestCase):
def test_uses_list_template(self): def test_uses_list_template(self):
mylist = List.objects.create() mylist = List.objects.create()
response = self.client.get(f'/apps/dashboard/{mylist.id}/') response = self.client.get(f'/apps/dashboard/{mylist.id}/')
self.assertTemplateUsed(response, 'apps/dashboard/list.html') self.assertTemplateUsed(response, 'apps/dashboard/list.html')
def test_renders_input_form(self): def test_renders_input_form(self):
mylist = List.objects.create() mylist = List.objects.create()
response = self.client.get(f'/apps/dashboard/{mylist.id}/') response = self.client.get(f'/apps/dashboard/{mylist.id}/')
parsed = lxml.html.fromstring(response.content) parsed = lxml.html.fromstring(response.content)
[form] = parsed.cssselect('form[method=POST]') [form] = parsed.cssselect('form[method=POST]')
self.assertEqual(form.get('action'), f"/apps/dashboard/{mylist.id}/add-item") self.assertEqual(form.get('action'), f"/apps/dashboard/{mylist.id}/add-item")
inputs = form.cssselect('input') inputs = form.cssselect('input')
self.assertIn('item_text', [input.get('name') for input in inputs]) self.assertIn('item_text', [input.get('name') for input in inputs])
def test_displays_only_items_for_that_list(self): def test_displays_only_items_for_that_list(self):
# Given/Arrange # Given/Arrange
correct_list = List.objects.create() correct_list = List.objects.create()
Item.objects.create(text='itemey 1', list=correct_list) Item.objects.create(text='itemey 1', list=correct_list)
Item.objects.create(text='itemey 2', list=correct_list) Item.objects.create(text='itemey 2', list=correct_list)
other_list = List.objects.create() other_list = List.objects.create()
Item.objects.create(text='other list item', list=other_list) Item.objects.create(text='other list item', list=other_list)
# When/Act # When/Act
response = self.client.get(f'/apps/dashboard/{correct_list.id}/') response = self.client.get(f'/apps/dashboard/{correct_list.id}/')
# Then/Assert # Then/Assert
self.assertContains(response, 'itemey 1') self.assertContains(response, 'itemey 1')
self.assertContains(response, 'itemey 2') self.assertContains(response, 'itemey 2')
self.assertNotContains(response, 'other list item') self.assertNotContains(response, 'other list item')
class NewListTest(TestCase): class NewListTest(TestCase):
def test_can_save_a_POST_request(self): def test_can_save_a_POST_request(self):
self. client.post('/apps/dashboard/newlist', data={'item_text': 'A new list item'}) self. client.post('/apps/dashboard/newlist', data={'item_text': 'A new list item'})
self.assertEqual(Item.objects.count(), 1) self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get() new_item = Item.objects.get()
self.assertEqual(new_item.text, 'A new list item') self.assertEqual(new_item.text, 'A new list item')
def test_redirects_after_POST(self): def test_redirects_after_POST(self):
response = self.client.post('/apps/dashboard/newlist', data={'item_text': 'A new list item'}) response = self.client.post('/apps/dashboard/newlist', data={'item_text': 'A new list item'})
new_list = List.objects.get() new_list = List.objects.get()
self.assertRedirects(response, f'/apps/dashboard/{new_list.id}/') self.assertRedirects(response, f'/apps/dashboard/{new_list.id}/')
class NewItemTest(TestCase): class NewItemTest(TestCase):
def test_can_save_a_POST_request_to_an_existing_list(self): def test_can_save_a_POST_request_to_an_existing_list(self):
other_list = List.objects.create() other_list = List.objects.create()
correct_list = List.objects.create() correct_list = List.objects.create()
self.client.post( self.client.post(
f'/apps/dashboard/{correct_list.id}/add-item', f'/apps/dashboard/{correct_list.id}/add-item',
data={'item_text': 'A new item for an existing list'}, data={'item_text': 'A new item for an existing list'},
) )
self.assertEqual(Item.objects.count(), 1) self.assertEqual(Item.objects.count(), 1)
new_item = Item.objects.get() new_item = Item.objects.get()
self.assertEqual(new_item.text, 'A new item for an existing list') self.assertEqual(new_item.text, 'A new item for an existing list')
self.assertEqual(new_item.list, correct_list) self.assertEqual(new_item.list, correct_list)
def test_redirects_to_list_view(self): def test_redirects_to_list_view(self):
other_list = List.objects.create() other_list = List.objects.create()
correct_list = List.objects.create() correct_list = List.objects.create()
response = self.client.post( response = self.client.post(
f'/apps/dashboard/{correct_list.id}/add-item', f'/apps/dashboard/{correct_list.id}/add-item',
data={'item_text': 'A new item for an existing list'}, data={'item_text': 'A new item for an existing list'},
) )
self.assertRedirects(response, f'/apps/dashboard/{correct_list.id}/') self.assertRedirects(response, f'/apps/dashboard/{correct_list.id}/')

View File

@@ -1,8 +1,8 @@
from django.urls import path from django.urls import path
from . import views from . import views
urlpatterns = [ urlpatterns = [
path('newlist', views.new_list, name='new_list'), path('newlist', 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('<int:list_id>/add-item', views.add_item, name='add_item'), path('<int:list_id>/add-item', views.add_item, name='add_item'),
] ]

View File

@@ -1,20 +1,20 @@
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from .models import Item, List from .models import Item, List
def home_page(request): def home_page(request):
return render(request, 'apps/dashboard/home.html') return render(request, 'apps/dashboard/home.html')
def view_list(request, list_id): def view_list(request, list_id):
our_list = List.objects.get(id=list_id) our_list = List.objects.get(id=list_id)
return render(request, 'apps/dashboard/list.html', {'list': our_list}) return render(request, 'apps/dashboard/list.html', {'list': our_list})
def new_list(request): def new_list(request):
nulist = List.objects.create() nulist = List.objects.create()
Item.objects.create(text=request.POST['item_text'], list=nulist) Item.objects.create(text=request.POST['item_text'], list=nulist)
return redirect(f'/apps/dashboard/{nulist.id}/') return redirect(f'/apps/dashboard/{nulist.id}/')
def add_item(request, list_id): def add_item(request, list_id):
our_list = List.objects.get(id=list_id) our_list = List.objects.get(id=list_id)
Item.objects.create(text=request.POST['item_text'], list=our_list) Item.objects.create(text=request.POST['item_text'], list=our_list)
return redirect(f'/apps/dashboard/{our_list.id}/') return redirect(f'/apps/dashboard/{our_list.id}/')

View File

@@ -1,16 +1,16 @@
""" """
ASGI config for core project. ASGI config for core project.
It exposes the ASGI callable as a module-level variable named ``application``. It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/ https://docs.djangoproject.com/en/6.0/howto/deployment/asgi/
""" """
import os import os
from django.core.asgi import get_asgi_application from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_asgi_application() application = get_asgi_application()

View File

@@ -1,141 +1,141 @@
""" """
Django settings for core project. Django settings for core project.
Generated by 'django-admin startproject' using Django 6.0. Generated by 'django-admin startproject' using Django 6.0.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/6.0/topics/settings/ https://docs.djangoproject.com/en/6.0/topics/settings/
For the full list of settings and their values, see For the full list of settings and their values, see
https://docs.djangoproject.com/en/6.0/ref/settings/ https://docs.djangoproject.com/en/6.0/ref/settings/
""" """
from pathlib import Path from pathlib import Path
import os import os
# 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
# Quick-start development settings - unsuitable for production # Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/ # See https://docs.djangoproject.com/en/6.0/howto/deployment/checklist/
# SECURITY WARNING: don't run with debug turned on in production! # SECURITY WARNING: don't run with debug turned on in production!
if 'DJANGO_DEBUG_FALSE' in os.environ: 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 = [os.environ['DJANGO_ALLOWED_HOST']] ALLOWED_HOSTS = [host.strip() for host in os.environ['DJANGO_ALLOWED_HOST'].split(',')]
db_path = os.environ['DJANGO_DB_PATH'] db_path = os.environ['DJANGO_DB_PATH']
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' 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',
'django.contrib.messages', 'django.contrib.messages',
'django.contrib.staticfiles', 'django.contrib.staticfiles',
# Custom apps # Custom apps
'apps.dashboard', 'apps.dashboard',
# Depend apps # Depend apps
] ]
MIDDLEWARE = [ MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware', 'django.middleware.security.SecurityMiddleware',
'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',
'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware', 'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware',
] ]
ROOT_URLCONF = 'core.urls' ROOT_URLCONF = 'core.urls'
TEMPLATES = [ TEMPLATES = [
{ {
'BACKEND': 'django.template.backends.django.DjangoTemplates', 'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [BASE_DIR / 'templates'], 'DIRS': [BASE_DIR / 'templates'],
'APP_DIRS': True, 'APP_DIRS': True,
'OPTIONS': { 'OPTIONS': {
'context_processors': [ 'context_processors': [
'django.template.context_processors.request', 'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth', 'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages', 'django.contrib.messages.context_processors.messages',
], ],
}, },
}, },
] ]
WSGI_APPLICATION = 'core.wsgi.application' WSGI_APPLICATION = 'core.wsgi.application'
ASGI_APPLICATION = 'core.asgi.application' 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 = { DATABASES = {
'default': { 'default': {
'ENGINE': 'django.db.backends.sqlite3', 'ENGINE': 'django.db.backends.sqlite3',
'NAME': db_path, 'NAME': db_path,
} }
} }
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [ AUTH_PASSWORD_VALIDATORS = [
{ {
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
}, },
{ {
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
}, },
] ]
# Internationalization # Internationalization
# https://docs.djangoproject.com/en/6.0/topics/i18n/ # https://docs.djangoproject.com/en/6.0/topics/i18n/
LANGUAGE_CODE = 'en-us' LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC' TIME_ZONE = 'UTC'
USE_I18N = True USE_I18N = True
USE_TZ = True USE_TZ = True
# Static files (CSS, JavaScript, Images) # Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/6.0/howto/static-files/ # https://docs.djangoproject.com/en/6.0/howto/static-files/
STATIC_URL = 'static/' STATIC_URL = 'static/'
STATIC_ROOT = BASE_DIR / 'static' STATIC_ROOT = BASE_DIR / 'static'
LOGGING = { LOGGING = {
"version": 1, "version": 1,
"disable_existing_loggers": False, "disable_existing_loggers": False,
"handlers": { "handlers": {
"console": {"class": "logging.StreamHandler"}, "console": {"class": "logging.StreamHandler"},
}, },
"loggers": { "loggers": {
"root": {"handlers": ["console"], "level": "INFO"}, "root": {"handlers": ["console"], "level": "INFO"},
}, },
} }

View File

@@ -1,9 +1,9 @@
# from django.contrib import admin # from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from apps.dashboard import views as list_views from apps.dashboard import views as list_views
urlpatterns = [ urlpatterns = [
# path('admin/', admin.site.urls), # path('admin/', admin.site.urls),
path('', list_views.home_page, name='home'), path('', list_views.home_page, name='home'),
path('apps/dashboard/', include('apps.dashboard.urls')), path('apps/dashboard/', include('apps.dashboard.urls')),
] ]

View File

@@ -1,16 +1,16 @@
""" """
WSGI config for core project. WSGI config for core project.
It exposes the WSGI callable as a module-level variable named ``application``. It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see For more information on this file, see
https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/ https://docs.djangoproject.com/en/6.0/howto/deployment/wsgi/
""" """
import os import os
from django.core.wsgi import get_wsgi_application from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
application = get_wsgi_application() application = get_wsgi_application()

View File

@@ -1,107 +1,108 @@
from django.contrib.staticfiles.testing import StaticLiveServerTestCase 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 selenium.webdriver.common.keys import Keys
import os import os
import time import time
import unittest import unittest
MAX_WAIT = 5 MAX_WAIT = 5
class NewVisitorTest(StaticLiveServerTestCase): class NewVisitorTest(StaticLiveServerTestCase):
# Helper methods # Helper methods
def setUp(self): def setUp(self):
self.browser = webdriver.Firefox() self.browser = webdriver.Firefox()
if test_server := os.environ.get('TEST_SERVER'): if test_server := os.environ.get('TEST_SERVER'):
self.live_server_url = 'http://' + test_server self.live_server_url = 'http://' + test_server
def tearDown(self): def tearDown(self):
self.browser.quit() self.browser.quit()
def wait_for_row_in_list_table(self, row_text): def wait_for_row_in_list_table(self, row_text):
start_time = time.time() start_time = time.time()
while True: while True:
try: try:
table = self.browser.find_element(By.ID, 'id-list-table') table = self.browser.find_element(By.ID, 'id-list-table')
rows = table.find_elements(By.TAG_NAME, 'tr') rows = table.find_elements(By.TAG_NAME, 'tr')
self.assertIn(row_text, [row.text for row in rows]) self.assertIn(row_text, [row.text for row in rows])
return return
except (AssertionError, WebDriverException): except (AssertionError, WebDriverException):
if time.time() - start_time > MAX_WAIT: if time.time() - start_time > MAX_WAIT:
raise raise
time.sleep(0.5) time.sleep(0.5)
# 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)
self.assertIn('Dashboard | ', self.browser.title) self.assertIn('Dashboard | ', 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('Dashboard | ', header_text) self.assertIn('Dashboard | ', header_text)
inputbox = self.browser.find_element(By.ID, 'id-new-item') inputbox = self.browser.find_element(By.ID, 'id-new-item')
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') self.wait_for_row_in_list_table('1. Buy peacock feathers')
inputbox = self.browser.find_element(By.ID, 'id-new-item') inputbox = self.browser.find_element(By.ID, 'id-new-item')
inputbox.send_keys('Use peacock feathers to make a fly') inputbox.send_keys('Use peacock feathers to make a fly')
inputbox.send_keys(Keys.ENTER) 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.browser.find_element(By.ID, 'id-new-item') inputbox = self.browser.find_element(By.ID, 'id-new-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') 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/.+')
self.browser.delete_all_cookies() self.browser.delete_all_cookies()
self.browser.get(self.live_server_url) self.browser.get(self.live_server_url)
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.browser.find_element(By.ID, 'id-new-item') inputbox = self.browser.find_element(By.ID, 'id-new-item')
inputbox.send_keys('Buy milk') inputbox.send_keys('Buy milk')
inputbox.send_keys(Keys.ENTER) inputbox.send_keys(Keys.ENTER)
self.wait_for_row_in_list_table('1. Buy milk') 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/.+')
self.assertNotEqual(francis_dash_url, edith_dash_url) self.assertNotEqual(francis_dash_url, edith_dash_url)
page_text = self.browser.find_element(By.TAG_NAME, 'body').text page_text = self.browser.find_element(By.TAG_NAME, 'body').text
self.assertNotIn('Buy peacock feathers', page_text) self.assertNotIn('Buy peacock feathers', page_text)
self.assertIn('Buy milk', page_text) self.assertIn('Buy milk', page_text)
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)
self.browser.set_window_size(1024, 768) self.browser.set_window_size(1200, 768) # may have to switch back to 1024
print("Viewport width:", self.browser.execute_script("return window.innerWidth"))
inputbox = self.browser.find_element(By.ID, 'id-new-item')
self.assertAlmostEqual( inputbox = self.browser.find_element(By.ID, 'id-new-item')
inputbox.location['x'] + inputbox.size['width'] / 2, self.assertAlmostEqual(
512, inputbox.location['x'] + inputbox.size['width'] / 2,
delta=10, 512,
) delta=10,
)
inputbox.send_keys('testing')
inputbox.send_keys(Keys.ENTER) inputbox.send_keys('testing')
self.wait_for_row_in_list_table('1. testing') inputbox.send_keys(Keys.ENTER)
inputbox = self.browser.find_element(By.ID, 'id-new-item') self.wait_for_row_in_list_table('1. testing')
self.assertAlmostEqual( inputbox = self.browser.find_element(By.ID, 'id-new-item')
inputbox.location['x'] + inputbox.size['width'] / 2, self.assertAlmostEqual(
512, inputbox.location['x'] + inputbox.size['width'] / 2,
delta=10, 512,
) delta=10,
)

View File

@@ -1,22 +1,22 @@
#!/usr/bin/env python #!/usr/bin/env python
"""Django's command-line utility for administrative tasks.""" """Django's command-line utility for administrative tasks."""
import os import os
import sys import sys
def main(): def main():
"""Run administrative tasks.""" """Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings')
try: try:
from django.core.management import execute_from_command_line from django.core.management import execute_from_command_line
except ImportError as exc: except ImportError as exc:
raise ImportError( raise ImportError(
"Couldn't import Django. Are you sure it's installed and " "Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you " "available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?" "forget to activate a virtual environment?"
) from exc ) from exc
execute_from_command_line(sys.argv) execute_from_command_line(sys.argv)
if __name__ == '__main__': if __name__ == '__main__':
main() main()

View File

@@ -1,6 +1,6 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% 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 %}/apps/dashboard/newlist{% endblock form_action %} {% block form_action %}/apps/dashboard/newlist{% endblock form_action %}

View File

@@ -1,15 +1,15 @@
{% extends "core/base.html" %} {% extends "core/base.html" %}
{% 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 %}/apps/dashboard/{{ list.id }}/add-item{% endblock form_action %} {% block form_action %}/apps/dashboard/{{ list.id }}/add-item{% endblock form_action %}
{% block table %} {% block table %}
<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 %}
<tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr> <tr><td>{{ forloop.counter }}. {{ item.text }}</td></tr>
{% endfor %} {% endfor %}
</table> </table>
{% endblock table %} {% endblock table %}

View File

@@ -1,45 +1,45 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en" data-bs-theme="dark"> <html lang="en" data-bs-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="author" content="Disco DeDisco"> <meta name="author" content="Disco DeDisco">
<meta name="robots" content="noindex, nofollow"> <meta name="robots" content="noindex, nofollow">
<title>Dashboard | {% block title_text %}{% endblock title_text %}</title> <title>Dashboard | {% block title_text %}{% endblock title_text %}</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"/> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.8/css/bootstrap.min.css"/>
</head> </head>
<body> <body>
<div class="container"> <div class="container">
<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">
<h1 class="display-1 mb-4">Dashboard | {% block header_text %}{% endblock header_text %}</h1> <h1 class="display-1 mb-4">Dashboard | {% block header_text %}{% endblock header_text %}</h1>
<form method="POST" action="{% block form_action %}{% endblock form_action %}"> <form method="POST" action="{% block form_action %}{% endblock form_action %}">
<input <input
name="item_text" name="item_text"
id="id-new-item" id="id-new-item"
class="form-control form-control-lg" class="form-control form-control-lg"
placeholder="Enter a to-do item" placeholder="Enter a to-do item"
/> />
{% csrf_token %} {% csrf_token %}
</form> </form>
</div> </div>
</div> </div>
<div class="row justify-content-center"> <div class="row justify-content-center">
<div class="col-lg-6"> <div class="col-lg-6">
{% block table %} {% block table %}
{% endblock table %} {% endblock table %}
</div> </div>
</div> </div>
</div> </div>
</body> </body>
<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>
</html> </html>