Hello,
-A new feedback item has been submitted. Here are the details:
+{% block title %}New {{ site_name }} feedback{% endblock %} - -- {{ feedback_text }} -
Thank you for your attention.
-diff --git a/llm_be/chat_backend/admin.py b/llm_be/chat_backend/admin.py index 8c4a631..285539a 100644 --- a/llm_be/chat_backend/admin.py +++ b/llm_be/chat_backend/admin.py @@ -11,10 +11,10 @@ from .models import ( PromptMetric, DocumentWorkspace, Document, + UserAuthEvent, + OutboundEmail, ) -# Register your models here. - class AnnouncmentAdmin(admin.ModelAdmin): model = Announcement @@ -24,6 +24,32 @@ 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 OutboundEmailInline(admin.TabularInline): + model = OutboundEmail + extra = 0 + can_delete = False + fields = ("created", "kind", "status", "subject", "to_email", "sent_at") + readonly_fields = ("created", "kind", "status", "subject", "to_email", "sent_at") + ordering = ("-created",) + show_change_link = True + + def has_add_permission(self, request, obj=None): + return False + + class CustomUserAdmin(admin.ModelAdmin): model = CustomUser list_display = ( @@ -41,7 +67,63 @@ 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, OutboundEmailInline) + + +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 OutboundEmailAdmin(admin.ModelAdmin): + model = OutboundEmail + list_display = ( + "created", + "kind", + "status", + "subject", + "to_email", + "user", + "sent_at", + ) + list_filter = ("kind", "status") + search_fields = ("to_email", "subject", "user__email", "id") + readonly_fields = ( + "id", + "kind", + "status", + "to_email", + "from_email", + "subject", + "html_template", + "text_template", + "context", + "error_message", + "user", + "created", + "updated", + "sent_at", + ) + ordering = ("-created",) + date_hierarchy = "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 +141,7 @@ class LLMModelsAdmin(admin.ModelAdmin): class PromptInline(admin.TabularInline): model = Prompt + class ConversationAdmin(admin.ModelAdmin): model = Conversation list_display = ( @@ -70,12 +153,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 +178,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 +195,7 @@ class PromptMetricAdmin(admin.ModelAdmin): "has_file", "file_type", "get_duration", - "created" + "created", ) list_filter = ("event", "model_name", "has_file") @@ -136,6 +221,8 @@ 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(OutboundEmail, OutboundEmailAdmin) 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..6fbe911 --- /dev/null +++ b/llm_be/chat_backend/email_tasks.py @@ -0,0 +1,164 @@ +"""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. + +Each send creates an OutboundEmail row for admin visibility (queued/sent/failed). +""" + +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 +from django.utils import timezone + +from .models import OutboundEmail + +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(outbound_email_id: str) -> bool: + """Send a previously queued OutboundEmail via SMTP (SMTP2GO in prod).""" + try: + record = OutboundEmail.objects.get(pk=outbound_email_id) + except OutboundEmail.DoesNotExist: + logger.error("OutboundEmail %s missing; cannot send", outbound_email_id) + return False + + record.status = OutboundEmail.Status.SENDING + record.save(update_fields=["status", "updated"]) + + logger.info( + "Sending email id=%s subject=%r to=%s", + record.id, + record.subject, + record.to_email, + ) + html_content = get_template(record.html_template).render(record.context) + text_content = get_template(record.text_template).render(record.context) + msg = EmailMultiAlternatives( + record.subject, text_content, record.from_email, [record.to_email] + ) + msg.attach_alternative(html_content, "text/html") + # Correlate with SMTP2GO webhooks later (custom header passthrough). + msg.extra_headers["X-Hesychia-Email-Id"] = str(record.id) + + try: + msg.send(fail_silently=False) + except Exception as exc: + logger.exception( + "Failed to send email id=%s subject=%r to=%s", + record.id, + record.subject, + record.to_email, + ) + record.status = OutboundEmail.Status.FAILED + record.error_message = str(exc)[:2000] + record.save(update_fields=["status", "error_message", "updated"]) + return False + + record.status = OutboundEmail.Status.SENT + record.sent_at = timezone.now() + record.error_message = "" + record.save(update_fields=["status", "sent_at", "error_message", "updated"]) + return True + + +def enqueue_email( + *, + kind: str, + subject: str, + to_email: str, + html_template: str, + text_template: str, + context: dict, + user=None, +) -> OutboundEmail: + """Create an OutboundEmail row and enqueue send after DB commit.""" + record = OutboundEmail.objects.create( + kind=kind, + status=OutboundEmail.Status.QUEUED, + to_email=to_email, + from_email=DEFAULT_FROM_EMAIL, + subject=subject, + html_template=html_template, + text_template=text_template, + context=context, + user=user, + ) + transaction.on_commit( + partial(send_templated_email.enqueue, outbound_email_id=str(record.id)) + ) + return record + + +def send_invite_email(slug: str, email_to: str, user=None) -> OutboundEmail: + url = set_password_url(slug) + logger.info("Queueing invite email url=%s", url) + return enqueue_email( + kind=OutboundEmail.Kind.INVITE, + 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), + user=user, + ) + + +def send_password_reset_email(slug: str, email_to: str, user=None) -> OutboundEmail: + url = set_password_url(slug) + logger.info("Queueing password reset email url=%s", url) + return enqueue_email( + kind=OutboundEmail.Kind.PASSWORD_RESET, + 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), + user=user, + ) + + +def send_feedback_email( + title: str, feedback_text: str, user=None +) -> OutboundEmail: + logger.info("Queueing feedback email") + return enqueue_email( + kind=OutboundEmail.Kind.FEEDBACK, + 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), + user=user, + ) 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/migrations/0025_outbound_email.py b/llm_be/chat_backend/migrations/0025_outbound_email.py new file mode 100644 index 0000000..ea38799 --- /dev/null +++ b/llm_be/chat_backend/migrations/0025_outbound_email.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0 on 2026-07-27 11:41 + +import django.db.models.deletion +import django.utils.timezone +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat_backend', '0024_user_auth_event'), + ] + + operations = [ + migrations.CreateModel( + name='OutboundEmail', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('kind', models.CharField(choices=[('password_reset', 'Password reset'), ('invite', 'Invite'), ('feedback', 'Feedback')], max_length=32)), + ('status', models.CharField(choices=[('queued', 'Queued'), ('sending', 'Sending'), ('sent', 'Sent to SMTP'), ('failed', 'Failed')], db_index=True, default='queued', max_length=16)), + ('to_email', models.EmailField(max_length=254)), + ('from_email', models.EmailField(max_length=254)), + ('subject', models.CharField(max_length=255)), + ('html_template', models.CharField(max_length=255)), + ('text_template', models.CharField(max_length=255)), + ('context', models.JSONField(blank=True, default=dict)), + ('error_message', models.TextField(blank=True, default='')), + ('created', models.DateTimeField(db_index=True, default=django.utils.timezone.now)), + ('updated', models.DateTimeField(auto_now=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='outbound_emails', 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 6f7b854..ecb934b 100644 --- a/llm_be/chat_backend/models.py +++ b/llm_be/chat_backend/models.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import AbstractUser from django.utils import timezone from autoslug import AutoSlugField from chat_backend.storage import DatabaseStorage +import uuid # Create your models here. @@ -74,7 +75,91 @@ class CustomUser(AbstractUser): ) def get_set_password_url(self): - return f"https://chat.aimloperations.com/set_password?slug={self.slug}" + from django.conf import settings + + base = settings.FRONTEND_BASE_URL.rstrip("/") + 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, + ) + + +class OutboundEmail(models.Model): + """Record of emails queued/sent by the app (visible in admin).""" + + class Kind(models.TextChoices): + PASSWORD_RESET = "password_reset", "Password reset" + INVITE = "invite", "Invite" + FEEDBACK = "feedback", "Feedback" + + class Status(models.TextChoices): + QUEUED = "queued", "Queued" + SENDING = "sending", "Sending" + SENT = "sent", "Sent to SMTP" + FAILED = "failed", "Failed" + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + kind = models.CharField(max_length=32, choices=Kind.choices) + status = models.CharField( + max_length=16, choices=Status.choices, default=Status.QUEUED, db_index=True + ) + to_email = models.EmailField() + from_email = models.EmailField() + subject = models.CharField(max_length=255) + html_template = models.CharField(max_length=255) + text_template = models.CharField(max_length=255) + context = models.JSONField(default=dict, blank=True) + error_message = models.TextField(blank=True, default="") + user = models.ForeignKey( + CustomUser, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="outbound_emails", + ) + created = models.DateTimeField(default=timezone.now, db_index=True) + updated = models.DateTimeField(auto_now=True) + sent_at = models.DateTimeField(null=True, blank=True) + + class Meta: + ordering = ["-created"] + + def __str__(self): + return f"{self.subject} → {self.to_email} ({self.status})" FEEDBACK_CHOICE = ( 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 @@ + + +
+ + + +| + + | +
|
-
-
-
-
-
-
+{% extends "emails/base_email.html" %}
-
- New Feedback Submission-
-
-
-
-
- Hello, -A new feedback item has been submitted. Here are the details: +{% block title %}New {{ site_name }} feedback{% endblock %} - -
- Title: {{ title }}
-
-
-
-
- Feedback:
-
- - {{ feedback_text }} - Thank you for your attention. - |
-
A new feedback item was submitted in {{ site_name }}.
+| - - + | + + Set your password + |
+ 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 @@ - - - - - -|
-
-
-
-
+
-
-
-
-
-
-
-
- Password Reset for AI ML Operations, LLC Chat Services- | + + Set a new password + |
+ 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_models.py b/llm_be/chat_backend/tests/test_models.py index 198ffb0..50fffee 100644 --- a/llm_be/chat_backend/tests/test_models.py +++ b/llm_be/chat_backend/tests/test_models.py @@ -72,7 +72,7 @@ class CompanyAndUserTestCase(TestCase): user = make_user(email="person@example.com") self.assertEqual( user.get_set_password_url(), - f"https://chat.aimloperations.com/set_password?slug={user.slug}", + f"http://localhost:3000/set_password/?slug={user.slug}", ) def test_user_defaults(self): diff --git a/llm_be/chat_backend/tests/test_views_users.py b/llm_be/chat_backend/tests/test_views_users.py index 5d1108e..fc41425 100644 --- a/llm_be/chat_backend/tests/test_views_users.py +++ b/llm_be/chat_backend/tests/test_views_users.py @@ -1,10 +1,17 @@ from django.core import mail from django.urls import reverse +from unittest import mock 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, + OutboundEmail, + UserAuthEvent, +) from .factories import make_company, make_user @@ -179,6 +186,8 @@ class SetPasswordTestCase(APITestCase): self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK) def test_post_sets_password(self): + self.user.set_unusable_password() + self.user.save() url = reverse("set_password", kwargs={"slug": self.user.slug}) response = self.client.post(url, {"password": "brandnewpass"}, format="json") @@ -186,6 +195,97 @@ 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}) + + response = self.client.post(url, {"password": "brandnewpass"}, format="json") + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_post_rejects_short_password(self): + self.user.set_unusable_password() + self.user.save() + url = reverse("set_password", kwargs={"slug": self.user.slug}) + + response = self.client.post(url, {"password": "short"}, format="json") + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + +class ResetPasswordTestCase(APITestCase): + def setUp(self): + self.user = make_user(email="person@example.com", password="testpass123") + self.url = reverse("reset_password") + + def _mock_captcha(self, success=True, score=None): + result = {"success": success} + if score is not None: + result["score"] = score + return mock.patch( + "chat_backend.views.requests.post", + return_value=mock.Mock(json=mock.Mock(return_value=result)), + ) + + def test_reset_invalidates_password_and_sends_email(self): + with self._mock_captcha(success=True): + 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 + ) + outbound = OutboundEmail.objects.get(user=self.user) + self.assertEqual(outbound.kind, OutboundEmail.Kind.PASSWORD_RESET) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) + self.assertEqual(outbound.to_email, self.user.email) + self.assertIsNotNone(outbound.sent_at) + + def test_reset_unknown_email_still_returns_ok(self): + with self._mock_captcha(success=True): + response = self.client.post( + self.url, + {"email": "missing@example.com", "recaptchaToken": "token"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(mail.outbox), 0) + self.user.refresh_from_db() + self.assertTrue(self.user.has_usable_password()) + + def test_reset_rejects_failed_captcha(self): + with self._mock_captcha(success=False): + response = self.client.post( + self.url, + {"email": self.user.email, "recaptchaToken": "bad"}, + format="json", + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(len(mail.outbox), 0) + + def test_reset_rejects_missing_email(self): + with self._mock_captcha(success=True): + response = self.client.post( + self.url, {"recaptchaToken": "token"}, format="json" + ) + + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) class AcknowledgeTermsOfServiceTestCase(APITestCase): @@ -211,9 +311,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") @@ -221,7 +322,13 @@ 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) + outbound = OutboundEmail.objects.get(user=invited) + self.assertEqual(outbound.kind, OutboundEmail.Kind.INVITE) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) def test_non_manager_cannot_invite(self): member = make_user(email="member@example.com", company=self.company) @@ -268,17 +375,21 @@ class FeedbackViewTestCase(APITestCase): self.url = reverse("feedbacks") def test_post_creates_feedback_and_notifies(self): - response = self.client.post( - self.url, - {"title": "Broken button", "text": "It does nothing"}, - format="json", - ) + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + self.url, + {"title": "Broken button", "text": "It does nothing"}, + format="json", + ) self.assertEqual(response.status_code, status.HTTP_201_CREATED) feedback = Feedback.objects.get() self.assertEqual(feedback.user, self.user) self.assertEqual(len(mail.outbox), 1) self.assertIn("Broken button", mail.outbox[0].body) + outbound = OutboundEmail.objects.get(user=self.user) + self.assertEqual(outbound.kind, OutboundEmail.Kind.FEEDBACK) + self.assertEqual(outbound.status, OutboundEmail.Status.SENT) def test_post_without_text_is_rejected(self): response = self.client.post(self.url, {"title": "no body"}, format="json") diff --git a/llm_be/chat_backend/urls.py b/llm_be/chat_backend/urls.py index 510b75a..41418f3 100644 --- a/llm_be/chat_backend/urls.py +++ b/llm_be/chat_backend/urls.py @@ -21,7 +21,6 @@ from .views import ( UserConversationAnalytics, CompanyUsageAnalytics, AdminAnalytics, - reset_password, DocumentWorkspaceView, DocumentUploadView, DocumentDetailView, @@ -35,7 +34,9 @@ urlpatterns = [ path("user/create/", CustomUserCreate.as_view(), name="create_user"), path("public/settings/", PublicSettingsView.as_view(), name="public_settings"), path("user/invite/", CustomUserInvite.as_view(), name="invite_user"), - path("user/reset_password/", reset_password, name="reset_password"), + path( + "user/reset_password/", ResetUserPassword.as_view(), name="reset_password" + ), path( "user/set_password/