From 04e28b96c8d25ddf33d435055ab1009c4b0edb5d Mon Sep 17 00:00:00 2001 From: Disco DeDisco Date: Sat, 21 Feb 2026 21:35:15 -0500 Subject: [PATCH] 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 --- .woodpecker.yaml | 5 ++++ infra/deploy-playbook.yaml | 28 +++++++++++++++++++ infra/deploy.sh.j2 | 10 +++++++ infra/gamearray.env.j2 | 1 + requirements.txt | 2 ++ src/apps/lyric/tasks.py | 24 ++++++++++++++++ src/apps/lyric/tests/integrated/test_views.py | 25 ++++++----------- src/apps/lyric/tests/unit/test_tasks.py | 16 +++++++++++ src/apps/lyric/views.py | 23 ++++----------- src/core/__init__.py | 3 ++ src/core/celery.py | 10 +++++++ src/core/settings.py | 2 ++ 12 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 src/apps/lyric/tasks.py create mode 100644 src/apps/lyric/tests/unit/test_tasks.py create mode 100644 src/core/celery.py diff --git a/.woodpecker.yaml b/.woodpecker.yaml index eca9ddf..dc18565 100644 --- a/.woodpecker.yaml +++ b/.woodpecker.yaml @@ -6,11 +6,15 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres + - name: redis + image: redis:7 + steps: - name: test-UTs-n-ITs image: python:3.13-slim environment: DATABASE_URL: postgresql://postgres:postgres@postgres/python_tdd_test + CELERY_BROKER_URL: redis://redis:6379/0 commands: - pip install -r requirements.txt - cd ./src @@ -22,6 +26,7 @@ steps: image: gitea.earthmanrpg.me/discoman/python-tdd-ci:latest environment: HEADLESS: 1 + CELERY_BROKER_URL: redis://redis:6379/0 commands: - pip install -r requirements.txt - cd ./src diff --git a/infra/deploy-playbook.yaml b/infra/deploy-playbook.yaml index 8657e1f..6a3d8c1 100644 --- a/infra/deploy-playbook.yaml +++ b/infra/deploy-playbook.yaml @@ -114,6 +114,15 @@ POSTGRES_USER: gamearray POSTGRES_PASSWORD: "{{ postgres_password }}" + - name: Start Redis container + community.docker.docker_container: + name: gamearray_redis + image: redis:7 + state: started + restart_policy: unless-stopped + networks: + - name: gamearray_net + - name: Run container community.docker.docker_container: name: gamearray @@ -126,11 +135,30 @@ 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" networks: - name: gamearray_net ports: 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" + networks: + - name: gamearray_net + command: "python -m celery -A core worker -l info" + + - name: Create static files directory ansible.builtin.file: path: /var/www/gamearray/static diff --git a/infra/deploy.sh.j2 b/infra/deploy.sh.j2 index 5f12f85..8345766 100644 --- a/infra/deploy.sh.j2 +++ b/infra/deploy.sh.j2 @@ -17,6 +17,16 @@ docker run -d --name gamearray \ -p 127.0.0.1:8888:8888 \ "$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..." docker exec gamearray python ./manage.py migrate diff --git a/infra/gamearray.env.j2 b/infra/gamearray.env.j2 index 2b59194..8d1391b 100644 --- a/infra/gamearray.env.j2 +++ b/infra/gamearray.env.j2 @@ -3,3 +3,4 @@ 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 diff --git a/requirements.txt b/requirements.txt index c66c561..745a33b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +celery cssselect==1.3.0 Django==6.0 dj-database-url @@ -7,5 +8,6 @@ djangorestframework gunicorn==23.0.0 lxml==6.0.2 psycopg2-binary +redis requests==2.31.0 whitenoise==6.11.0 diff --git a/src/apps/lyric/tasks.py b/src/apps/lyric/tasks.py new file mode 100644 index 0000000..e770ca6 --- /dev/null +++ b/src/apps/lyric/tasks.py @@ -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}") diff --git a/src/apps/lyric/tests/integrated/test_views.py b/src/apps/lyric/tests/integrated/test_views.py index 43b00d9..69cba14 100644 --- a/src/apps/lyric/tests/integrated/test_views.py +++ b/src/apps/lyric/tests/integrated/test_views.py @@ -5,27 +5,23 @@ from unittest import mock from apps.lyric.models import Token - +@mock.patch("apps.lyric.views.send_login_email_task.delay") class SendLoginEmailViewTest(TestCase): - def test_redirects_to_home_page(self): + def test_redirects_to_home_page(self, mock_delay): response = self.client.post( "/apps/lyric/send_login_email", data={"email": "discoman@example.com"} ) self.assertRedirects(response, "/") - @mock.patch("apps.lyric.views.requests.post") - def test_sends_mail_to_address_from_post(self, mock_post): + def test_sends_mail_to_address_from_post(self, mock_delay): self.client.post( "/apps/lyric/send_login_email", data={"email": "discoman@example.com"} ) - 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.assertEqual(mock_delay.called, True) + self.assertEqual(mock_delay.call_args.args[0], "discoman@example.com") - def test_adds_success_message(self): + def test_adds_success_message(self, mock_delay): response = self.client.post( "/apps/lyric/send_login_email", data={"email": "discoman@example.com"}, @@ -39,24 +35,21 @@ class SendLoginEmailViewTest(TestCase): ) 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( "/apps/lyric/send_login_email", data={"email": "discoman@example.com"} ) token = Token.objects.get() self.assertEqual(token.email, "discoman@example.com") - - @mock.patch("apps.lyric.views.requests.post") - def test_sends_link_to_login_using_token_uid(self, mock_post): + def test_sends_link_to_login_using_token_uid(self, mock_delay): self.client.post( "/apps/lyric/send_login_email", data={"email": "discoman@example.com"} ) token = Token.objects.get() expected_url = f"http://testserver/apps/lyric/login?token={token.uid}" - data = mock_post.call_args.kwargs["data"] - self.assertIn(expected_url, data["text"]) + self.assertEqual(mock_delay.call_args.args[1], expected_url) class LoginViewTest(TestCase): def test_redirects_to_home_page(self): diff --git a/src/apps/lyric/tests/unit/test_tasks.py b/src/apps/lyric/tests/unit/test_tasks.py new file mode 100644 index 0000000..5994064 --- /dev/null +++ b/src/apps/lyric/tests/unit/test_tasks.py @@ -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"]) diff --git a/src/apps/lyric/views.py b/src/apps/lyric/views.py index 5e2d612..467faf3 100644 --- a/src/apps/lyric/views.py +++ b/src/apps/lyric/views.py @@ -1,10 +1,10 @@ -import requests 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.urls import reverse + from .models import Token +from .tasks import send_login_email_task + def send_login_email(request): email = request.POST["email"] @@ -12,26 +12,13 @@ def send_login_email(request): url = request.build_absolute_uri( 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( request, "Check your email!—there you'll find a magic login link. But hurry… it's only temporary!", ) + return redirect("/") def login(request): diff --git a/src/core/__init__.py b/src/core/__init__.py index e69de29..e31568a 100644 --- a/src/core/__init__.py +++ b/src/core/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ("celery_app",) \ No newline at end of file diff --git a/src/core/celery.py b/src/core/celery.py new file mode 100644 index 0000000..d9b33d8 --- /dev/null +++ b/src/core/celery.py @@ -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() \ No newline at end of file diff --git a/src/core/settings.py b/src/core/settings.py index 5b3522f..9916bba 100644 --- a/src/core/settings.py +++ b/src/core/settings.py @@ -109,6 +109,8 @@ else: } } +# Celery +CELERY_BROKER_URL = os.environ.get('CELERY_BROKER_URL', 'redis://localhost:6379/0') # Password validation # https://docs.djangoproject.com/en/6.0/ref/settings/#auth-password-validators