From 6a985a2477069ee5c18eef126976e4d33264d6b5 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Mon, 27 Jul 2026 06:39:28 -0500 Subject: [PATCH] Brand emails as Hesychia and audit password reset/set events Restyle invite/reset/feedback mail to match the app glass UI, queue sends via Django 6 Tasks (ImmediateBackend for now), and show timestamped auth events on the user admin detail page. --- llm_be/chat_backend/admin.py | 51 ++++++-- llm_be/chat_backend/email_tasks.py | 120 ++++++++++++++++++ .../migrations/0024_user_auth_event.py | 30 +++++ llm_be/chat_backend/models.py | 37 ++++++ .../templates/emails/base_email.html | 45 +++++++ .../templates/emails/feedback_email.html | 117 ++--------------- .../templates/emails/feedback_email.txt | 9 +- .../templates/emails/invite_email.html | 118 ++++------------- .../templates/emails/invite_email.txt | 11 +- .../templates/emails/reset_email.html | 119 ++++------------- .../templates/emails/reset_email.txt | 12 +- llm_be/chat_backend/tests/test_views_users.py | 30 +++-- llm_be/chat_backend/views.py | 84 +++++------- llm_be/llm_be/settings.py | 8 ++ 14 files changed, 426 insertions(+), 365 deletions(-) create mode 100644 llm_be/chat_backend/email_tasks.py create mode 100644 llm_be/chat_backend/migrations/0024_user_auth_event.py create mode 100644 llm_be/chat_backend/templates/emails/base_email.html diff --git a/llm_be/chat_backend/admin.py b/llm_be/chat_backend/admin.py index 8c4a631..4d898a3 100644 --- a/llm_be/chat_backend/admin.py +++ b/llm_be/chat_backend/admin.py @@ -11,10 +11,9 @@ from .models import ( PromptMetric, DocumentWorkspace, Document, + UserAuthEvent, ) -# Register your models here. - class AnnouncmentAdmin(admin.ModelAdmin): model = Announcement @@ -24,6 +23,19 @@ class CompanyAdmin(admin.ModelAdmin): model = Company +class UserAuthEventInline(admin.TabularInline): + model = UserAuthEvent + extra = 0 + can_delete = False + fields = ("created", "event_type", "detail", "ip_address") + readonly_fields = ("created", "event_type", "detail", "ip_address") + ordering = ("-created",) + show_change_link = False + + def has_add_permission(self, request, obj=None): + return False + + class CustomUserAdmin(admin.ModelAdmin): model = CustomUser list_display = ( @@ -41,7 +53,24 @@ class CustomUserAdmin(admin.ModelAdmin): "slug", "get_set_password_url", ) - search_fields = ("fields", "username", "first_name", "last_name", "slug") + search_fields = ("email", "username", "first_name", "last_name", "slug") + readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug") + inlines = (UserAuthEventInline,) + + +class UserAuthEventAdmin(admin.ModelAdmin): + model = UserAuthEvent + list_display = ("created", "user", "event_type", "detail", "ip_address") + list_filter = ("event_type",) + search_fields = ("user__email", "user__username", "detail", "ip_address") + readonly_fields = ("user", "event_type", "created", "detail", "ip_address") + ordering = ("-created",) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False class FeedbackAdmin(admin.ModelAdmin): @@ -59,6 +88,7 @@ class LLMModelsAdmin(admin.ModelAdmin): class PromptInline(admin.TabularInline): model = Prompt + class ConversationAdmin(admin.ModelAdmin): model = Conversation list_display = ( @@ -70,12 +100,14 @@ class ConversationAdmin(admin.ModelAdmin): "tokens_total", ) search_fields = ("title",) - inlines = [PromptInline,] + inlines = [ + PromptInline, + ] def _token_sum(self, conversation, field): - total = PromptMetric.objects.filter( - conversation_id=conversation.id - ).aggregate(total=Sum(field))["total"] + total = PromptMetric.objects.filter(conversation_id=conversation.id).aggregate( + total=Sum(field) + )["total"] return total or 0 @admin.display(description="Tokens in") @@ -93,7 +125,7 @@ class ConversationAdmin(admin.ModelAdmin): class PromptAdmin(admin.ModelAdmin): model = Prompt - list_display = ("id","message", "user_created", "get_conversation_title","created") + list_display = ("id", "message", "user_created", "get_conversation_title", "created") search_fields = ("message",) @@ -110,7 +142,7 @@ class PromptMetricAdmin(admin.ModelAdmin): "has_file", "file_type", "get_duration", - "created" + "created", ) list_filter = ("event", "model_name", "has_file") @@ -136,6 +168,7 @@ class DocumentAdmin(admin.ModelAdmin): admin.site.register(Announcement, AnnouncmentAdmin) admin.site.register(Company, CompanyAdmin) admin.site.register(CustomUser, CustomUserAdmin) +admin.site.register(UserAuthEvent, UserAuthEventAdmin) admin.site.register(LLMModels, LLMModelsAdmin) admin.site.register(Conversation, ConversationAdmin) diff --git a/llm_be/chat_backend/email_tasks.py b/llm_be/chat_backend/email_tasks.py new file mode 100644 index 0000000..cdcf004 --- /dev/null +++ b/llm_be/chat_backend/email_tasks.py @@ -0,0 +1,120 @@ +"""Outbound email helpers. + +Uses Django 6 Tasks API so callers enqueue work the same way today and when a +real worker backend is configured later. ImmediateBackend (default) still runs +the send inside the current process. +""" + +from __future__ import annotations + +import logging +from functools import partial + +from django.conf import settings +from django.core.mail import EmailMultiAlternatives +from django.db import transaction +from django.tasks import task +from django.template.loader import get_template + +logger = logging.getLogger(__name__) + +DEFAULT_FROM_EMAIL = "ryan@aimloperations.com" +SUPPORT_EMAIL = "ryan@aimloperations.com" +SITE_NAME = "Hesychia" +COMPANY_NAME = "AI ML Operations, LLC" + + +def brand_email_context(**extra): + base = settings.FRONTEND_BASE_URL.rstrip("/") + return { + "site_name": SITE_NAME, + "company_name": COMPANY_NAME, + "site_url": base, + "support_email": SUPPORT_EMAIL, + **extra, + } + + +def set_password_url(slug: str) -> str: + base = settings.FRONTEND_BASE_URL.rstrip("/") + return f"{base}/set_password/?slug={slug}" + + +@task +def send_templated_email( + subject: str, + to_email: str, + html_template: str, + text_template: str, + context: dict, +) -> bool: + """Send a branded HTML+text email via configured SMTP (SMTP2GO in prod).""" + logger.info("Sending email subject=%r to=%s", subject, to_email) + html_content = get_template(html_template).render(context) + text_content = get_template(text_template).render(context) + msg = EmailMultiAlternatives( + subject, text_content, DEFAULT_FROM_EMAIL, [to_email] + ) + msg.attach_alternative(html_content, "text/html") + try: + msg.send(fail_silently=False) + return True + except Exception: + logger.exception("Failed to send email subject=%r to=%s", subject, to_email) + return False + + +def enqueue_email( + *, + subject: str, + to_email: str, + html_template: str, + text_template: str, + context: dict, +) -> None: + """Enqueue after DB commit so workers never race uncommitted rows.""" + transaction.on_commit( + partial( + send_templated_email.enqueue, + subject=subject, + to_email=to_email, + html_template=html_template, + text_template=text_template, + context=context, + ) + ) + + +def send_invite_email(slug: str, email_to: str) -> None: + url = set_password_url(slug) + logger.info("Queueing invite email url=%s", url) + enqueue_email( + subject=f"Welcome to {SITE_NAME}", + to_email=email_to, + html_template="emails/invite_email.html", + text_template="emails/invite_email.txt", + context=brand_email_context(url=url), + ) + + +def send_password_reset_email(slug: str, email_to: str) -> None: + url = set_password_url(slug) + logger.info("Queueing password reset email url=%s", url) + enqueue_email( + subject=f"Reset your {SITE_NAME} password", + to_email=email_to, + html_template="emails/reset_email.html", + text_template="emails/reset_email.txt", + context=brand_email_context(url=url), + ) + + +def send_feedback_email(title: str, feedback_text: str) -> None: + logger.info("Queueing feedback email") + enqueue_email( + subject=f"New {SITE_NAME} feedback", + to_email=SUPPORT_EMAIL, + html_template="emails/feedback_email.html", + text_template="emails/feedback_email.txt", + context=brand_email_context(title=title, feedback_text=feedback_text), + ) diff --git a/llm_be/chat_backend/migrations/0024_user_auth_event.py b/llm_be/chat_backend/migrations/0024_user_auth_event.py new file mode 100644 index 0000000..e03bd96 --- /dev/null +++ b/llm_be/chat_backend/migrations/0024_user_auth_event.py @@ -0,0 +1,30 @@ +# Generated by Django 6.0 on 2026-07-27 11:38 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat_backend', '0023_promptmetric_tokens_in_promptmetric_tokens_out'), + ] + + operations = [ + migrations.CreateModel( + name='UserAuthEvent', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('event_type', models.CharField(choices=[('password_reset_requested', 'Password reset requested'), ('password_set', 'Password set'), ('invite_sent', 'Invite sent')], max_length=64)), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('detail', models.CharField(blank=True, default='', max_length=512)), + ('ip_address', models.GenericIPAddressField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='auth_events', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-created'], + }, + ), + ] diff --git a/llm_be/chat_backend/models.py b/llm_be/chat_backend/models.py index f91a095..72ecae6 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -80,6 +80,43 @@ class CustomUser(AbstractUser): return f"{base}/set_password/?slug={self.slug}" +class UserAuthEvent(models.Model): + """Audit trail for password reset / set actions, shown on user admin.""" + + class EventType(models.TextChoices): + PASSWORD_RESET_REQUESTED = ( + "password_reset_requested", + "Password reset requested", + ) + PASSWORD_SET = ("password_set", "Password set") + INVITE_SENT = ("invite_sent", "Invite sent") + + user = models.ForeignKey( + CustomUser, + on_delete=models.CASCADE, + related_name="auth_events", + ) + event_type = models.CharField(max_length=64, choices=EventType.choices) + created = models.DateTimeField(default=timezone.now, db_index=True) + detail = models.CharField(max_length=512, blank=True, default="") + ip_address = models.GenericIPAddressField(null=True, blank=True) + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"{self.get_event_type_display()} @ {self.created.isoformat()}" + + @classmethod + def log(cls, user, event_type, *, detail="", ip_address=None): + return cls.objects.create( + user=user, + event_type=event_type, + detail=detail or "", + ip_address=ip_address, + ) + + FEEDBACK_CHOICE = ( ("SUBMITTED", "Submitted"), ("RESOLVED", "Resolved"), diff --git a/llm_be/chat_backend/templates/emails/base_email.html b/llm_be/chat_backend/templates/emails/base_email.html new file mode 100644 index 0000000..8a26376 --- /dev/null +++ b/llm_be/chat_backend/templates/emails/base_email.html @@ -0,0 +1,45 @@ + + + + + + + {% block title %}{{ site_name }}{% endblock %} + + + + + + +
+ + + + + + + + + + + + + +
+
+ {{ site_name }} +
+
+ by {{ company_name }} +
+
+
+
+ {% block content %}{% endblock %} +
+

This is an automated message. Please do not reply.

+

© {% now "Y" %} {{ company_name }}. All rights reserved.

+
+
+ + diff --git a/llm_be/chat_backend/templates/emails/feedback_email.html b/llm_be/chat_backend/templates/emails/feedback_email.html index 3dd5f4a..1ae8232 100644 --- a/llm_be/chat_backend/templates/emails/feedback_email.html +++ b/llm_be/chat_backend/templates/emails/feedback_email.html @@ -1,105 +1,16 @@ - - - - - - New Feedback Submission - - - - - - - -
- - -
- - \ No newline at end of file +{% block content %} +

New feedback

+

A new feedback item was submitted in {{ site_name }}.

+
+
Title
+
{{ title }}
+
+
+
Feedback
+
{{ feedback_text }}
+
+{% endblock %} diff --git a/llm_be/chat_backend/templates/emails/feedback_email.txt b/llm_be/chat_backend/templates/emails/feedback_email.txt index 1c5c6bb..d9fd3bd 100644 --- a/llm_be/chat_backend/templates/emails/feedback_email.txt +++ b/llm_be/chat_backend/templates/emails/feedback_email.txt @@ -1,3 +1,8 @@ -New feedback for Chat by AI ML Operations, LLC +New {{ site_name }} feedback -"New Feedback. {{ title }}. {{ feedback_text }}" \ No newline at end of file +Title: {{ title }} + +Feedback: +{{ feedback_text }} + +— {{ site_name }} by {{ company_name }} diff --git a/llm_be/chat_backend/templates/emails/invite_email.html b/llm_be/chat_backend/templates/emails/invite_email.html index b358c97..c279857 100644 --- a/llm_be/chat_backend/templates/emails/invite_email.html +++ b/llm_be/chat_backend/templates/emails/invite_email.html @@ -1,97 +1,29 @@ - - - - - - Invitation to Chat by AI ML Operations, LLC - - - - +{% extends "emails/base_email.html" %} + +{% block title %}Welcome to {{ site_name }}{% endblock %} + +{% block content %} +

You're invited

+

Hello,

+

+ You have been invited to {{ site_name }} by {{ company_name }}. + Set a password to activate your account and start chatting. +

+
-
- - + + + Set your password +
- - \ No newline at end of file +

+ Or copy this link:
+ {{ url }} +

+

+ Once ready, + open {{ site_name }}. +

+{% endblock %} diff --git a/llm_be/chat_backend/templates/emails/invite_email.txt b/llm_be/chat_backend/templates/emails/invite_email.txt index 0ba347b..2dc2ca3 100644 --- a/llm_be/chat_backend/templates/emails/invite_email.txt +++ b/llm_be/chat_backend/templates/emails/invite_email.txt @@ -1,3 +1,10 @@ -Welcome to AI ML Operations, LLC Chat Services +You're invited to {{ site_name }} -"Welcome to chat.aimloperations.com. Please use {{ url }} to set your password" \ No newline at end of file +You have been invited to {{ site_name }} by {{ company_name }}. +Set a password to activate your account: + +{{ url }} + +Then open {{ site_url }} + +— {{ site_name }} by {{ company_name }} diff --git a/llm_be/chat_backend/templates/emails/reset_email.html b/llm_be/chat_backend/templates/emails/reset_email.html index 1a127d7..2565b74 100644 --- a/llm_be/chat_backend/templates/emails/reset_email.html +++ b/llm_be/chat_backend/templates/emails/reset_email.html @@ -1,97 +1,30 @@ - - - - - - Reset Password for Chat by AI ML Operations, LLC - - - - +{% extends "emails/base_email.html" %} + +{% block title %}Reset your {{ site_name }} password{% endblock %} + +{% block content %} +

Reset your password

+

Hello,

+

+ We received a request to reset the password for your {{ site_name }} account. + If you did not make this request, you can ignore this email or contact + {{ support_email }}. +

+
-
- - + + + Set a new password +
- - \ No newline at end of file +

+ Or copy this link:
+ {{ url }} +

+

+ After you set your password, + sign in to {{ site_name }}. +

+{% endblock %} diff --git a/llm_be/chat_backend/templates/emails/reset_email.txt b/llm_be/chat_backend/templates/emails/reset_email.txt index f805092..7d7e1a3 100644 --- a/llm_be/chat_backend/templates/emails/reset_email.txt +++ b/llm_be/chat_backend/templates/emails/reset_email.txt @@ -1,3 +1,11 @@ -Password Reset for AI ML Operations, LLC Chat Services +Reset your {{ site_name }} password -"Password reset for chat.aimloperations.com. Please use {{ url }} to set your password" \ No newline at end of file +We received a request to reset the password for your {{ site_name }} account. +If you did not make this request, ignore this email or contact {{ support_email }}. + +Set a new password: +{{ url }} + +Then sign in at {{ site_url }} + +— {{ site_name }} by {{ company_name }} diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index d0e5a1f..8ace586 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -5,7 +5,7 @@ from rest_framework import status from rest_framework.test import APITestCase from rest_framework_simplejwt.tokens import RefreshToken -from chat_backend.models import Announcement, CustomUser, Feedback +from chat_backend.models import Announcement, CustomUser, Feedback, UserAuthEvent from .factories import make_company, make_user @@ -189,6 +189,8 @@ class SetPasswordTestCase(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.user.refresh_from_db() self.assertTrue(self.user.check_password("brandnewpass")) + event = UserAuthEvent.objects.get(user=self.user) + self.assertEqual(event.event_type, UserAuthEvent.EventType.PASSWORD_SET) def test_post_rejects_user_that_already_has_a_password(self): url = reverse("set_password", kwargs={"slug": self.user.slug}) @@ -223,18 +225,24 @@ class ResetPasswordTestCase(APITestCase): def test_reset_invalidates_password_and_sends_email(self): with self._mock_captcha(success=True): - response = self.client.post( - self.url, - {"email": self.user.email, "recaptchaToken": "token"}, - format="json", - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + self.url, + {"email": self.user.email, "recaptchaToken": "token"}, + format="json", + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.user.refresh_from_db() self.assertFalse(self.user.has_usable_password()) self.assertEqual(len(mail.outbox), 1) self.assertIn(self.user.slug, mail.outbox[0].body) + self.assertIn("Hesychia", mail.outbox[0].subject) self.assertEqual(mail.outbox[0].to, [self.user.email]) + event = UserAuthEvent.objects.get(user=self.user) + self.assertEqual( + event.event_type, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED + ) def test_reset_unknown_email_still_returns_ok(self): with self._mock_captcha(success=True): @@ -292,9 +300,10 @@ class UserInviteTestCase(APITestCase): def test_manager_invites_new_user_and_email_is_sent(self): self.client.force_authenticate(user=self.manager) - response = self.client.post( - self.url, {"email": "newhire@example.com"}, format="json" - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + self.url, {"email": "newhire@example.com"}, format="json" + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) invited = CustomUser.objects.get(email="newhire@example.com") @@ -302,7 +311,10 @@ class UserInviteTestCase(APITestCase): self.assertEqual(invited.username, "newhire@example.com") self.assertEqual(len(mail.outbox), 1) self.assertIn(invited.slug, mail.outbox[0].body) + self.assertIn("Hesychia", mail.outbox[0].subject) self.assertEqual(mail.outbox[0].to, ["newhire@example.com"]) + event = UserAuthEvent.objects.get(user=invited) + self.assertEqual(event.event_type, UserAuthEvent.EventType.INVITE_SENT) def test_non_manager_cannot_invite(self): member = make_user(email="member@example.com", company=self.company) diff --git a/llm_be/chat_backend/views.py b/llm_be/chat_backend/views.py index fadd191..fe2e1d0 100644 --- a/llm_be/chat_backend/views.py +++ b/llm_be/chat_backend/views.py @@ -28,6 +28,7 @@ from .models import ( PromptMetric, DocumentWorkspace, Document, + UserAuthEvent, ) from django.views.decorators.cache import never_cache from django.http import JsonResponse @@ -47,12 +48,6 @@ import pandas as pd import io from chat_backend.services.assistant_identity import ASSISTANT_SYSTEM_PROMPT -# For email support -from django.core.mail import EmailMultiAlternatives -from django.template.loader import render_to_string -from django.utils.html import strip_tags -from django.template.loader import get_template -from django.template import Context from django.utils import timezone from django.core.files import File from django.core.files.base import ContentFile @@ -65,6 +60,11 @@ from dateutil.relativedelta import relativedelta import requests from .utils import last_day_of_month +from .email_tasks import ( + send_feedback_email, + send_invite_email, + send_password_reset_email, +) from .services.llm_service import AsyncLLMService from .services.rag_services import AsyncRAGService from .services.title_generator import title_generator @@ -87,6 +87,13 @@ CHANNEL_NAME: str = "llm_messages" MODEL_NAME: str = ollama_model() +def _client_ip(request): + forwarded = request.META.get("HTTP_X_FORWARDED_FOR") + if forwarded: + return forwarded.split(",")[0].strip() + return request.META.get("REMOTE_ADDR") + + # Create your views here. class CustomObtainTokenView(TokenObtainPairView): permission_classes = (permissions.AllowAny,) @@ -138,50 +145,6 @@ class CustomUserCreate(APIView): ) -def send_invite_email(slug, email_to_invite): - logger.info("Sending invite email") - logger.info(f"url : https://chat.aimloperations.com/set_password?slug={slug}") - url = f"https://chat.aimloperations.com/set_password?slug={slug}" - subject = "Welcome to AI ML Operations, LLC Chat Services" - from_email = "ryan@aimloperations.com" - to = email_to_invite - d = {"url": url} - html_content = get_template(r"emails/invite_email.html").render(d) - text_content = get_template(r"emails/invite_email.txt").render(d) - - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - msg.send(fail_silently=True) - - -def send_password_reset_email(slug, email_to): - logger.info("Sending Password reset email") - base = settings.FRONTEND_BASE_URL.rstrip("/") - url = f"{base}/set_password/?slug={slug}" - subject = "Password reset for Chat by AI ML Operations, LLC" - from_email = "ryan@aimloperations.com" - d = {"url": url} - html_content = get_template(r"emails/reset_email.html").render(d) - text_content = get_template(r"emails/reset_email.txt").render(d) - msg = EmailMultiAlternatives(subject, text_content, from_email, [email_to]) - msg.attach_alternative(html_content, "text/html") - msg.send(fail_silently=True) - - -def send_feedback_email(feedback_obj): - logger.info("Sending feedback email") - subject = "New Feedback for Chat by AI ML Operations, LLC" - from_email = "ryan@aimloperations.com" - to = "ryan@aimloperations.com" - d = {"title": feedback_obj.title, "feedback_text": feedback_obj.text} - html_content = get_template(r"emails/feedback_email.html").render(d) - text_content = get_template(r"emails/feedback_email.txt").render(d) - - msg = EmailMultiAlternatives(subject, text_content, from_email, [to]) - msg.attach_alternative(html_content, "text/html") - msg.send(fail_silently=True) - - class CustomUserInvite(APIView): http_method_names = ["post"] @@ -212,8 +175,13 @@ class CustomUserInvite(APIView): company=request.user.company, ) - # send an email send_invite_email(user.slug, email_to_invite) + UserAuthEvent.log( + user, + UserAuthEvent.EventType.INVITE_SENT, + detail=f"Invited by {request.user.email}", + ip_address=_client_ip(request), + ) return Response(status=status.HTTP_201_CREATED) @@ -261,6 +229,12 @@ class ResetUserPassword(APIView): user.set_unusable_password() user.save(update_fields=["password"]) send_password_reset_email(user.slug, email) + UserAuthEvent.log( + user, + UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED, + detail="Password reset email queued", + ip_address=_client_ip(request), + ) # Always 200 after valid captcha to avoid email enumeration. return Response(status=status.HTTP_200_OK) @@ -297,6 +271,12 @@ class SetUserPassword(APIView): user.set_password(password) user.save() + UserAuthEvent.log( + user, + UserAuthEvent.EventType.PASSWORD_SET, + detail="Password set via email link", + ip_address=_client_ip(request), + ) return Response(status=status.HTTP_200_OK) @@ -331,7 +311,7 @@ class FeedbackView(APIView): feedback_obj.user = request.user feedback_obj.save() - send_feedback_email(feedback_obj) + send_feedback_email(feedback_obj.title, feedback_obj.text) return Response(serializer.data, status=status.HTTP_201_CREATED) else: logger.error(serializer.errors) diff --git a/llm_be/llm_be/settings.py b/llm_be/llm_be/settings.py index 7bda6ac..0538678 100644 --- a/llm_be/llm_be/settings.py +++ b/llm_be/llm_be/settings.py @@ -285,6 +285,14 @@ EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") or "" EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525") EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True) +# Django 6 Tasks: ImmediateBackend runs in-process (no worker yet). Swap BACKEND +# to a durable queue + worker when SMTP should leave the request thread. +TASKS = { + "default": { + "BACKEND": "django.tasks.backends.immediate.ImmediateBackend", + } +} + CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or "" USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})