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.
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
@@ -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'],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="dark">
|
||||
<title>{% block title %}{{ site_name }}{% endblock %}</title>
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#0b0b14;font-family:Inter,Segoe UI,Roboto,Helvetica,Arial,sans-serif;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color:#0b0b14;background-image:linear-gradient(160deg,#0b0b14 0%,#1a1a2e 55%,#12122a 100%);">
|
||||
<tr>
|
||||
<td align="center" style="padding:40px 16px;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:560px;background-color:rgba(0,0,0,0.45);border:1px solid rgba(255,255,255,0.12);border-radius:16px;overflow:hidden;">
|
||||
<tr>
|
||||
<td align="center" style="padding:28px 28px 12px 28px;">
|
||||
<div style="font-size:28px;font-weight:700;letter-spacing:0.02em;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);-webkit-background-clip:text;background-clip:text;color:#a78bfa;">
|
||||
{{ site_name }}
|
||||
</div>
|
||||
<div style="margin-top:6px;font-size:12px;color:rgba(255,255,255,0.45);letter-spacing:0.08em;text-transform:uppercase;">
|
||||
by {{ company_name }}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:8px 28px 0 28px;">
|
||||
<div style="height:1px;background:linear-gradient(90deg,transparent,#667eea,#764ba2,transparent);"></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:28px;color:rgba(255,255,255,0.88);font-size:15px;line-height:1.6;">
|
||||
{% block content %}{% endblock %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 28px 28px 28px;color:rgba(255,255,255,0.45);font-size:12px;line-height:1.5;text-align:center;">
|
||||
<p style="margin:0 0 8px 0;">This is an automated message. Please do not reply.</p>
|
||||
<p style="margin:0;">© {% now "Y" %} {{ company_name }}. All rights reserved.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,105 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>New Feedback Submission</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>New Feedback Submission</h1>
|
||||
</div>
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>A new feedback item has been submitted. Here are the details:</p>
|
||||
{% block title %}New {{ site_name }} feedback{% endblock %}
|
||||
|
||||
<!-- Feedback Title -->
|
||||
<div class="feedback-title">
|
||||
Title: <strong>{{ title }}</strong>
|
||||
{% block content %}
|
||||
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">New feedback</h1>
|
||||
<p style="margin:0 0 16px 0;">A new feedback item was submitted in {{ site_name }}.</p>
|
||||
<div style="margin:0 0 12px 0;padding:16px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);">
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px;">Title</div>
|
||||
<div style="font-size:16px;font-weight:600;color:#ffffff;">{{ title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Feedback Text -->
|
||||
<div class="feedback-text">
|
||||
<strong>Feedback:</strong><br>
|
||||
{{ feedback_text }}
|
||||
<div style="margin:0 0 16px 0;padding:16px;border-radius:10px;background:rgba(255,255,255,0.05);border:1px solid rgba(255,255,255,0.1);">
|
||||
<div style="font-size:12px;color:rgba(255,255,255,0.45);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:6px;">Feedback</div>
|
||||
<div style="font-size:14px;color:rgba(255,255,255,0.85);white-space:pre-wrap;">{{ feedback_text }}</div>
|
||||
</div>
|
||||
|
||||
<p>Thank you for your attention.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
New feedback for Chat by AI ML Operations, LLC
|
||||
New {{ site_name }} feedback
|
||||
|
||||
"New Feedback. {{ title }}. {{ feedback_text }}"
|
||||
Title: {{ title }}
|
||||
|
||||
Feedback:
|
||||
{{ feedback_text }}
|
||||
|
||||
— {{ site_name }} by {{ company_name }}
|
||||
|
||||
@@ -1,97 +1,29 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invitation to Chat by AI ML Operations, LLC</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
{% block title %}Welcome to {{ site_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">You're invited</h1>
|
||||
<p style="margin:0 0 16px 0;">Hello,</p>
|
||||
<p style="margin:0 0 16px 0;">
|
||||
You have been invited to {{ site_name }} by {{ company_name }}.
|
||||
Set a password to activate your account and start chatting.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Welcome to AI ML Operations, LLC Chat Services</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>You have been invited to use Chat by AI ML Operations, LLC.</p>
|
||||
|
||||
<p>Please click <a href="{{ url }}">link</a> to set your password.</p>
|
||||
<p>Once you have set your password go <a href="https://chat.aimloperations.com">here</a> to get started.</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||||
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
|
||||
Set your password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
|
||||
Or copy this link:<br>
|
||||
<a href="{{ url }}" style="color:#a78bfa;text-decoration:none;">{{ url }}</a>
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
Once ready,
|
||||
<a href="{{ site_url }}" style="color:#a78bfa;text-decoration:none;">open {{ site_name }}</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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"
|
||||
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 }}
|
||||
|
||||
@@ -1,97 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Reset Password for Chat by AI ML Operations, LLC</title>
|
||||
<style>
|
||||
/* Basic reset for email clients */
|
||||
body, table, td, a {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
table, td {
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #dddddd;
|
||||
}
|
||||
.header {
|
||||
background-color: #007BFF;
|
||||
color: #ffffff;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
.content {
|
||||
padding: 20px;
|
||||
color: #333333;
|
||||
}
|
||||
.footer {
|
||||
background-color: #f4f4f4;
|
||||
color: #777777;
|
||||
text-align: center;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.feedback-title {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.feedback-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" align="center">
|
||||
{% extends "emails/base_email.html" %}
|
||||
|
||||
{% block title %}Reset your {{ site_name }} password{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">Reset your password</h1>
|
||||
<p style="margin:0 0 16px 0;">Hello,</p>
|
||||
<p style="margin:0 0 16px 0;">
|
||||
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
|
||||
<a href="mailto:{{ support_email }}" style="color:#a78bfa;text-decoration:none;">{{ support_email }}</a>.
|
||||
</p>
|
||||
<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
|
||||
<tr>
|
||||
<td>
|
||||
<!-- Email Container -->
|
||||
<div class="email-container">
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<h1>Password Reset for AI ML Operations, LLC Chat Services</h1>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
<p>There has been a request for a password reset. If you didn't requets this, please email ryan@aimloperations.com</p>
|
||||
|
||||
<p>Please click <a href="{{ url }}">link</a> to set your password.</p>
|
||||
<p>Once you have set your password go <a href="https://chat.aimloperations.com">here</a> to get started.</p>
|
||||
|
||||
<p>Thank you.</p>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="footer">
|
||||
<p>This is an automated message. Please do not reply to this email.</p>
|
||||
<p>© 2023-2025 AI ML Operations, LLC. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
<td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
|
||||
<a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
|
||||
Set a new password
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
||||
<p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
|
||||
Or copy this link:<br>
|
||||
<a href="{{ url }}" style="color:#a78bfa;text-decoration:none;">{{ url }}</a>
|
||||
</p>
|
||||
<p style="margin:0;">
|
||||
After you set your password,
|
||||
<a href="{{ site_url }}" style="color:#a78bfa;text-decoration:none;">sign in to {{ site_name }}</a>.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
||||
@@ -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"
|
||||
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 }}
|
||||
|
||||
@@ -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,6 +225,7 @@ class ResetPasswordTestCase(APITestCase):
|
||||
|
||||
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"},
|
||||
@@ -234,7 +237,12 @@ class ResetPasswordTestCase(APITestCase):
|
||||
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,6 +300,7 @@ class UserInviteTestCase(APITestCase):
|
||||
def test_manager_invites_new_user_and_email_is_sent(self):
|
||||
self.client.force_authenticate(user=self.manager)
|
||||
|
||||
with self.captureOnCommitCallbacks(execute=True):
|
||||
response = self.client.post(
|
||||
self.url, {"email": "newhire@example.com"}, format="json"
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"})
|
||||
|
||||
Reference in New Issue
Block a user