Compare commits

...

7 Commits

Author SHA1 Message Date
Disco DeDisco
44c335b089 added superuser support in apps.lyric.admin & new manage.py cmd ensure_superuser; .tests.integrated.test_admin & .test_management_commands green
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-22 20:42:33 -05:00
Disco DeDisco
87ef197823 enabled redis alongside celery, but waiting on true caching functionality—flash messages will behave better w. cache_page after they rely on htmx library, not current full-page reload
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-21 23:13:23 -05:00
Disco DeDisco
a9e635f40e fix for functional_tests.test_login, which still relied on old mock logic, no longer in apps.lyric.views, but handled by celery in apps.lyric.tasks
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-21 22:03:03 -05:00
Disco DeDisco
04e28b96c8 offloaded some apps.lyric.views responsibilities to new Celery depend fn in .tasks; core.celery created for celery config; CELERY_BROKER_URL added to .settings & throughout project; some lyric view IT responsibility now accordingly covered by task UT domain
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-21 21:35:15 -05:00
Disco DeDisco
880fcb5bcf more consistent DRF installation in pipeline
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 16:58:55 -05:00
Disco DeDisco
9bdc358e59 commenced DRF efforts w. package installation, creation of apps.api, w. UTs & ITs to ensure core efficacy; core.settings & .urls changed to accomodate
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-02-20 16:37:48 -05:00
Disco DeDisco
ed21730a38 when clause fixes in .woodpecker.yaml
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-02-20 15:16:19 -05:00
32 changed files with 422 additions and 63 deletions

View File

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

View File

@@ -114,6 +114,15 @@
POSTGRES_USER: gamearray POSTGRES_USER: gamearray
POSTGRES_PASSWORD: "{{ postgres_password }}" POSTGRES_PASSWORD: "{{ postgres_password }}"
- name: Start Redis container
community.docker.docker_container:
name: gamearray_redis
image: redis:7
state: started
restart_policy: unless-stopped
networks:
- name: gamearray_net
- name: Run container - name: Run container
community.docker.docker_container: community.docker.docker_container:
name: gamearray name: gamearray
@@ -124,13 +133,36 @@
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_SUPERUSER_EMAIL: "{{ django_superuser_email }}"
DJANGO_SUPERUSER_PASSWORD: "{{ django_superuser_password }}"
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray" DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}" MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
networks: networks:
- name: gamearray_net - name: gamearray_net
ports: ports:
127.0.0.1:8888:8888 127.0.0.1:8888:8888
- name: Start Celery worker container
community.docker.docker_container:
name: gamearray_celery
image: gitea.earthmanrpg.me/discoman/gamearray:latest
state: started
recreate: true
env:
DJANGO_DEBUG_FALSE: "1"
DJANGO_SECRET_KEY: "{{ secret_key.content | b64decode }}"
DJANGO_ALLOWED_HOST: "{{ django_allowed_host }}"
DATABASE_URL: "postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray"
MAILGUN_API_KEY: "{{ mailgun_api_key }}"
CELERY_BROKER_URL: "redis://gamearray_redis:6379/0"
REDIS_URL: "redis://gamearray_redis:6379/1"
networks:
- name: gamearray_net
command: "python -m celery -A core worker -l info"
- name: Create static files directory - name: Create static files directory
ansible.builtin.file: ansible.builtin.file:
path: /var/www/gamearray/static path: /var/www/gamearray/static
@@ -149,6 +181,11 @@
container: gamearray container: gamearray
command: python manage.py migrate command: python manage.py migrate
- name: Ensure superuser exists
community.docker.docker_container_exec:
container: gamearray
command: python manage.py ensure_superuser
handlers: handlers:
- name: Restart nginx - name: Restart nginx
ansible.builtin.service: ansible.builtin.service:

View File

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

View File

@@ -1,5 +1,10 @@
DJANGO_DEBUG_FALSE=1 DJANGO_DEBUG_FALSE=1
DJANGO_SECRET_KEY={{ secret_key.content | b64decode }} DJANGO_SECRET_KEY={{ secret_key.content | b64decode }}
DJANGO_ALLOWED_HOST={{ django_allowed_host }} DJANGO_ALLOWED_HOST={{ django_allowed_host }}
DJANGO_SUPERUSER_EMAIL={{ django_superuser_email }}
DJANGO_SUPERUSER_PASSWORD={{ django_superuser_password }}
DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray DATABASE_URL=postgresql://gamearray:{{ postgres_password }}@gamearray_postgres/gamearray
MAILGUN_API_KEY={{ mailgun_api_key }} MAILGUN_API_KEY={{ mailgun_api_key }}
CELERY_BROKER_URL=redis://gamearray_redis:6379/0
REDIS_URL=redis://gamearray_redis:6379/1

View File

@@ -1,23 +1,28 @@
$ANSIBLE_VAULT;1.1;AES256 $ANSIBLE_VAULT;1.1;AES256
33616230376431343735626631623932393166343538653732383533323436326335343463646664 65383061626464353936363564313761663834646361326362613934363565623234636337313363
6565373531623465613661613533376231373837326438300a393665613839646231633737313938 3933313962643261353830333463336166393030313936370a616234626135633432613366633363
64633035336663313163333634623732323537326363646132313136376131636666636538323066 61633265363937326231623365646336333737306634646335376135633031643564666164336230
3037373930303537320a313062646166353862633836373466316261363939633433663039323866 3435353764383936620a396165386538666433356166383661323037333861373632376432313332
62333739303662343836306538393734343830366336323265393138343438363533353166383031 66666236373462363236663335623734633364653539323331396361613738636166323134386466
32313461313137643039376237346633316466646136353038633861333031663164656233366634 66656431663261633036333537373336643866623236643139656662333831366435373837656262
38303363383130376264373861393863623330623733643135643461383132613339376633353031 36333734376363373462643239623437623735373935633732343639313666663436616630363933
32313863323039646534633733383661333361313832333830383066633130396239626661643264 61396530336461393064323161666537646135383462383532363932326132363331633438313138
65636335303339613432326533343337366261356632313639623634386633383836333733663536 61623431326537313637626239653038353263313731303262653537316134383264616661623962
39383361353530646166643531333535356636326535383534326237666638326137616162646261 32333564366362383431336432303964663835363365636434303332613161363930333065336637
65316466323335653932636338653565383038313531383638393839313736643739363037353230 33343466343062306434663765613837343635386630326439303739616166396134393939626434
35653632353531656435396663316537333133653632366437613339303033333536643937353166 62336634303963653230626630636363343730623734626336363039623231633532653330646366
64363037653733303332643931343362303261643432366531326262383465313965633064356338 66613432633834393133386666623466326131386633303264333766306135623337353433306632
31336333373665373035656533633864316139303934623030383934393434356334643962666163 66323733373232383862646661313966366465333463366361366337656537623562613964666631
33343739366336613263333764306365333566363536616662383733616237396563346132336633 65373566316432383134666434393338626138363632633766636561383263333636623530326664
38663239613339376335386233386330396634323033343332366130616162666339393861306336 63333265366132376437396431393535323931383637323833303839336635633735333565333530
35383566383831356530633130313732356331616164646132626665646235396635386237313538 65343263373630633063383931646163323237643436366566363932646566323539373136646433
38656631336261646530303761643334303937613036363766303637376262373466316431323731 37623638333834373537316164633166633738333363656431356163623332396631353864333333
38666462313639353131303134646434646135366136343361353932326165626666306361393431 33306666646532626636376239326438373737383432663539333736363866663938396136383035
62646238323265346263386363373462313766616333326366366461346436383064336535376339 32343534613862653538346430313338326435356230636535343464666262626663376635363835
31356566356336386262393831616631666233633930393263623563386265343237323133313832 65363862663461353464313533313333323863313539643533343431643130383663656161616131
3430363635363332303963316530663765613666306233376463 33323639333564383830346163386362386238323936393832623961646565613961356263356365
65376431666130356564666236383764316136326366666661326538653133343165326431393564
36303065366263316232663230343137333231346538633036613066643365616331336135376461
35613265623134663633303238366363336137383436663836353863623533396236666433303738
38356361653633323065303035376664326238633066623731623436333332373363636634323433
393631303539373234386465663630316335

View File

@@ -9,6 +9,7 @@ dj-database-url
Django==6.0 Django==6.0
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
h11==0.16.0 h11==0.16.0
idna==3.11 idna==3.11

View File

@@ -1,10 +1,13 @@
celery
cssselect==1.3.0 cssselect==1.3.0
Django==6.0 Django==6.0
dj-database-url dj-database-url
django-stubs==5.2.8 django-stubs==5.2.8
django-stubs-ext==5.2.8 django-stubs-ext==5.2.8
djangorestframework
gunicorn==23.0.0 gunicorn==23.0.0
lxml==6.0.2 lxml==6.0.2
psycopg2-binary psycopg2-binary
redis
requests==2.31.0 requests==2.31.0
whitenoise==6.11.0 whitenoise==6.11.0

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

View File

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

View File

View File

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

View File

View File

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

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

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

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

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

View File

@@ -1,9 +1,11 @@
from django.http import HttpResponseForbidden from django.http import HttpResponseForbidden
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from .forms import ExistingListItemForm, ItemForm from .forms import ExistingListItemForm, ItemForm
from .models import Item, List from .models import Item, List
from apps.lyric.models import User from apps.lyric.models import User
def home_page(request): def home_page(request):
return render(request, "apps/dashboard/home.html", {"form": ItemForm()}) return render(request, "apps/dashboard/home.html", {"form": ItemForm()})

View File

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

View File

View File

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

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

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

View File

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

View File

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

View File

@@ -5,27 +5,23 @@ from unittest import mock
from apps.lyric.models import Token from apps.lyric.models import Token
@mock.patch("apps.lyric.views.send_login_email_task.delay")
class SendLoginEmailViewTest(TestCase): class SendLoginEmailViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self, mock_delay):
response = self.client.post( response = self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/apps/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
self.assertRedirects(response, "/") self.assertRedirects(response, "/")
@mock.patch("apps.lyric.views.requests.post") def test_sends_mail_to_address_from_post(self, mock_delay):
def test_sends_mail_to_address_from_post(self, mock_post):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/apps/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
self.assertEqual(mock_post.called, True) self.assertEqual(mock_delay.called, True)
data = mock_post.call_args.kwargs["data"] self.assertEqual(mock_delay.call_args.args[0], "discoman@example.com")
self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.com")
self.assertEqual(data["to"], "discoman@example.com")
def test_adds_success_message(self): def test_adds_success_message(self, mock_delay):
response = self.client.post( response = self.client.post(
"/apps/lyric/send_login_email", "/apps/lyric/send_login_email",
data={"email": "discoman@example.com"}, data={"email": "discoman@example.com"},
@@ -39,24 +35,21 @@ class SendLoginEmailViewTest(TestCase):
) )
self.assertEqual(message.tags, "success") self.assertEqual(message.tags, "success")
def test_creates_token_associated_with_email(self): def test_creates_token_associated_with_email(self, mock_delay):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/apps/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() token = Token.objects.get()
self.assertEqual(token.email, "discoman@example.com") self.assertEqual(token.email, "discoman@example.com")
def test_sends_link_to_login_using_token_uid(self, mock_delay):
@mock.patch("apps.lyric.views.requests.post")
def test_sends_link_to_login_using_token_uid(self, mock_post):
self.client.post( self.client.post(
"/apps/lyric/send_login_email", data={"email": "discoman@example.com"} "/apps/lyric/send_login_email", data={"email": "discoman@example.com"}
) )
token = Token.objects.get() token = Token.objects.get()
expected_url = f"http://testserver/apps/lyric/login?token={token.uid}" expected_url = f"http://testserver/apps/lyric/login?token={token.uid}"
data = mock_post.call_args.kwargs["data"] self.assertEqual(mock_delay.call_args.args[1], expected_url)
self.assertIn(expected_url, data["text"])
class LoginViewTest(TestCase): class LoginViewTest(TestCase):
def test_redirects_to_home_page(self): def test_redirects_to_home_page(self):

View File

@@ -0,0 +1,16 @@
from django.test import SimpleTestCase
from unittest import mock
from apps.lyric.tasks import send_login_email_task
class SendLoginEmailTaskTest(SimpleTestCase):
@mock.patch("apps.lyric.tasks.requests.post")
def test_sends_mail_via_mailgun(self, mock_post):
send_login_email_task("discoman@example.com", "http://example.com/login?token=abc123")
self.assertEqual(mock_post.called, True)
data = mock_post.call_args.kwargs["data"]
self.assertEqual(data["subject"], "A magic login link to your Dashboard")
self.assertEqual(data["from"], "adman@howdy.earthmanrpg.com")
self.assertEqual(data["to"], "discoman@example.com")
self.assertIn("http://example.com/login?token=abc123", data["text"])

View File

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

View File

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

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

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

View File

@@ -54,8 +54,10 @@ INSTALLED_APPS = [
# Custom apps # Custom apps
'apps.dashboard', 'apps.dashboard',
'apps.lyric', 'apps.lyric',
'apps.api',
'functional_tests', 'functional_tests',
# Depend apps # Depend apps
'rest_framework',
] ]
# if 'DJANGO_DEBUG_FALSE' not in os.environ: # if 'DJANGO_DEBUG_FALSE' not in os.environ:
@@ -107,6 +109,17 @@ else:
} }
} }
# Celery & Redis
CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0')
REDIS_URL = os.environ.get('REDIS_URL')
if REDIS_URL:
CACHES = {
'default': {
'BACKEND': 'django.core.cache.backends.redis.RedisCache',
'LOCATION': REDIS_URL,
}
}
SESSION_ENGINE = 'django.contrib.sessions.backends.cached_db'
# Password validation # Password validation
# https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators

View File

@@ -8,6 +8,7 @@ urlpatterns = [
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')),
path('api/lists/', include('apps.api.urls')),
] ]
# Please remove the following urlpattern # Please remove the following urlpattern

View File

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