Compare commits

..
2 Commits
Author SHA1 Message Date
westfarn be6ff471ad Add Google/Microsoft SSO OAuth for register and sign-in (#24)
CI / test (pull_request) Failing after 7s
Unit Tests / test (pull_request) Failing after 6s
Introduce OAuthIdentity storage, start/callback endpoints, JWT handoff to
the SPA, and mocked IdP tests so Drive OAuth (#11) can reuse the same model.
2026-07-27 07:09:02 -05:00
westfarn 16442b336c Fix password reset flow (#1) (#27)
Unit Tests / test (push) Successful in 9s
## Summary
- Closes #1
- Replace broken `csrf_exempt` `reset_password` FBV (responses never returned; missing `requests` import) with working DRF `ResetUserPassword`
- Deduplicate reset email helper; build set-password links from `FRONTEND_BASE_URL`
- Harden `SetUserPassword`: require unusable password, min 8 chars, handle missing slug
- Accept reCAPTCHA v2 (success only) and v3 (score ≥ 0.5); avoid email enumeration (200 after valid captcha)
- Add unit tests for reset + set-password edge cases

## Test plan
- [ ] `manage.py test chat_backend.tests.test_views_users.ResetPasswordTestCase chat_backend.tests.test_views_users.SetPasswordTestCase`
- [ ] With SMTP configured: request reset for known email → receive link → set password → sign in
- [ ] Unknown email still returns 200 and sends no mail
- [ ] Failed captcha returns 400
- [ ] Pair with chat_web_app `feature/password-reset-1` PRReviewed-on: #27
2026-07-27 05:07:42 -07:00
17 changed files with 773 additions and 441 deletions
+96 -9
View File
@@ -11,11 +11,11 @@ from .models import (
PromptMetric, PromptMetric,
DocumentWorkspace, DocumentWorkspace,
Document, Document,
UserAuthEvent,
OutboundEmail,
OAuthIdentity, OAuthIdentity,
) )
# Register your models here.
class AnnouncmentAdmin(admin.ModelAdmin): class AnnouncmentAdmin(admin.ModelAdmin):
model = Announcement model = Announcement
@@ -25,6 +25,32 @@ class CompanyAdmin(admin.ModelAdmin):
model = Company 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): class CustomUserAdmin(admin.ModelAdmin):
model = CustomUser model = CustomUser
list_display = ( list_display = (
@@ -42,7 +68,63 @@ class CustomUserAdmin(admin.ModelAdmin):
"slug", "slug",
"get_set_password_url", "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): class FeedbackAdmin(admin.ModelAdmin):
@@ -60,6 +142,7 @@ class LLMModelsAdmin(admin.ModelAdmin):
class PromptInline(admin.TabularInline): class PromptInline(admin.TabularInline):
model = Prompt model = Prompt
class ConversationAdmin(admin.ModelAdmin): class ConversationAdmin(admin.ModelAdmin):
model = Conversation model = Conversation
list_display = ( list_display = (
@@ -71,12 +154,14 @@ class ConversationAdmin(admin.ModelAdmin):
"tokens_total", "tokens_total",
) )
search_fields = ("title",) search_fields = ("title",)
inlines = [PromptInline,] inlines = [
PromptInline,
]
def _token_sum(self, conversation, field): def _token_sum(self, conversation, field):
total = PromptMetric.objects.filter( total = PromptMetric.objects.filter(conversation_id=conversation.id).aggregate(
conversation_id=conversation.id total=Sum(field)
).aggregate(total=Sum(field))["total"] )["total"]
return total or 0 return total or 0
@admin.display(description="Tokens in") @admin.display(description="Tokens in")
@@ -94,7 +179,7 @@ class ConversationAdmin(admin.ModelAdmin):
class PromptAdmin(admin.ModelAdmin): class PromptAdmin(admin.ModelAdmin):
model = Prompt 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",) search_fields = ("message",)
@@ -111,7 +196,7 @@ class PromptMetricAdmin(admin.ModelAdmin):
"has_file", "has_file",
"file_type", "file_type",
"get_duration", "get_duration",
"created" "created",
) )
list_filter = ("event", "model_name", "has_file") list_filter = ("event", "model_name", "has_file")
@@ -137,6 +222,8 @@ class DocumentAdmin(admin.ModelAdmin):
admin.site.register(Announcement, AnnouncmentAdmin) admin.site.register(Announcement, AnnouncmentAdmin)
admin.site.register(Company, CompanyAdmin) admin.site.register(Company, CompanyAdmin)
admin.site.register(CustomUser, CustomUserAdmin) admin.site.register(CustomUser, CustomUserAdmin)
admin.site.register(UserAuthEvent, UserAuthEventAdmin)
admin.site.register(OutboundEmail, OutboundEmailAdmin)
admin.site.register(LLMModels, LLMModelsAdmin) admin.site.register(LLMModels, LLMModelsAdmin)
admin.site.register(Conversation, ConversationAdmin) admin.site.register(Conversation, ConversationAdmin)
+164
View File
@@ -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,
)
@@ -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'],
},
),
]
@@ -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'],
},
),
]
+86 -1
View File
@@ -3,6 +3,7 @@ from django.contrib.auth.models import AbstractUser
from django.utils import timezone from django.utils import timezone
from autoslug import AutoSlugField from autoslug import AutoSlugField
from chat_backend.storage import DatabaseStorage from chat_backend.storage import DatabaseStorage
import uuid
# Create your models here. # Create your models here.
@@ -74,7 +75,91 @@ class CustomUser(AbstractUser):
) )
def get_set_password_url(self): 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})"
class OAuthIdentity(TimeInfoBase): class OAuthIdentity(TimeInfoBase):
@@ -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;">&copy; {% now "Y" %} {{ company_name }}. All rights reserved.</p>
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>
@@ -1,105 +1,16 @@
<!DOCTYPE html> {% extends "emails/base_email.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>
<!-- Content --> {% block title %}New {{ site_name }} feedback{% endblock %}
<div class="content">
<p>Hello,</p>
<p>A new feedback item has been submitted. Here are the details:</p>
<!-- Feedback Title --> {% block content %}
<div class="feedback-title"> <h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">New feedback</h1>
Title: <strong>{{ title }}</strong> <p style="margin:0 0 16px 0;">A new feedback item was submitted in {{ site_name }}.</p>
</div> <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>
<!-- Feedback Text --> <div style="font-size:16px;font-weight:600;color:#ffffff;">{{ title }}</div>
<div class="feedback-text"> </div>
<strong>Feedback:</strong><br> <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);">
{{ feedback_text }} <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> <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> {% endblock %}
</div>
<!-- Footer -->
<div class="footer">
<p>This is an automated message. Please do not reply to this email.</p>
<p>&copy; 2025 AI ML Operations, LLC. All rights reserved.</p>
</div>
</div>
</td>
</tr>
</table>
</body>
</html>
@@ -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> {% extends "emails/base_email.html" %}
<html lang="en">
<head> {% block title %}Welcome to {{ site_name }}{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block content %}
<title>Invitation to Chat by AI ML Operations, LLC</title> <h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">You're invited</h1>
<style> <p style="margin:0 0 16px 0;">Hello,</p>
/* Basic reset for email clients */ <p style="margin:0 0 16px 0;">
body, table, td, a { You have been invited to {{ site_name }} by {{ company_name }}.
-webkit-text-size-adjust: 100%; Set a password to activate your account and start chatting.
-ms-text-size-adjust: 100%; </p>
} <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
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> <tr>
<td> <td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
<!-- Email Container --> <a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
<div class="email-container"> Set your password
<!-- Header --> </a>
<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>&copy; 2025 AI ML Operations, LLC. All rights reserved.</p>
</div>
</div>
</td> </td>
</tr> </tr>
</table> </table>
</body> <p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
</html> 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> {% extends "emails/base_email.html" %}
<html lang="en">
<head> {% block title %}Reset your {{ site_name }} password{% endblock %}
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> {% block content %}
<title>Reset Password for Chat by AI ML Operations, LLC</title> <h1 style="margin:0 0 16px 0;font-size:22px;font-weight:600;color:#ffffff;">Reset your password</h1>
<style> <p style="margin:0 0 16px 0;">Hello,</p>
/* Basic reset for email clients */ <p style="margin:0 0 16px 0;">
body, table, td, a { We received a request to reset the password for your {{ site_name }} account.
-webkit-text-size-adjust: 100%; If you did not make this request, you can ignore this email or contact
-ms-text-size-adjust: 100%; <a href="mailto:{{ support_email }}" style="color:#a78bfa;text-decoration:none;">{{ support_email }}</a>.
} </p>
table, td { <table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:24px 0;">
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> <tr>
<td> <td align="center" style="border-radius:8px;background:linear-gradient(135deg,#667eea 0%,#764ba2 100%);">
<!-- Email Container --> <a href="{{ url }}" style="display:inline-block;padding:14px 28px;font-size:15px;font-weight:600;color:#ffffff;text-decoration:none;">
<div class="email-container"> Set a new password
<!-- Header --> </a>
<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>&copy; 2023-2025 AI ML Operations, LLC. All rights reserved.</p>
</div>
</div>
</td> </td>
</tr> </tr>
</table> </table>
</body> <p style="margin:0 0 12px 0;font-size:13px;color:rgba(255,255,255,0.55);word-break:break-all;">
</html> 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 }}
+1 -1
View File
@@ -72,7 +72,7 @@ class CompanyAndUserTestCase(TestCase):
user = make_user(email="person@example.com") user = make_user(email="person@example.com")
self.assertEqual( self.assertEqual(
user.get_set_password_url(), 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): def test_user_defaults(self):
+120 -9
View File
@@ -1,10 +1,17 @@
from django.core import mail from django.core import mail
from django.urls import reverse from django.urls import reverse
from unittest import mock
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from rest_framework_simplejwt.tokens import RefreshToken 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 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) self.assertEqual(self.client.get(url).status_code, status.HTTP_200_OK)
def test_post_sets_password(self): def test_post_sets_password(self):
self.user.set_unusable_password()
self.user.save()
url = reverse("set_password", kwargs={"slug": self.user.slug}) url = reverse("set_password", kwargs={"slug": self.user.slug})
response = self.client.post(url, {"password": "brandnewpass"}, format="json") 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.assertEqual(response.status_code, status.HTTP_200_OK)
self.user.refresh_from_db() self.user.refresh_from_db()
self.assertTrue(self.user.check_password("brandnewpass")) 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): class AcknowledgeTermsOfServiceTestCase(APITestCase):
@@ -211,9 +311,10 @@ class UserInviteTestCase(APITestCase):
def test_manager_invites_new_user_and_email_is_sent(self): def test_manager_invites_new_user_and_email_is_sent(self):
self.client.force_authenticate(user=self.manager) self.client.force_authenticate(user=self.manager)
response = self.client.post( with self.captureOnCommitCallbacks(execute=True):
self.url, {"email": "newhire@example.com"}, format="json" response = self.client.post(
) self.url, {"email": "newhire@example.com"}, format="json"
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
invited = CustomUser.objects.get(email="newhire@example.com") invited = CustomUser.objects.get(email="newhire@example.com")
@@ -221,7 +322,13 @@ class UserInviteTestCase(APITestCase):
self.assertEqual(invited.username, "newhire@example.com") self.assertEqual(invited.username, "newhire@example.com")
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertIn(invited.slug, mail.outbox[0].body) self.assertIn(invited.slug, mail.outbox[0].body)
self.assertIn("Hesychia", mail.outbox[0].subject)
self.assertEqual(mail.outbox[0].to, ["newhire@example.com"]) 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): def test_non_manager_cannot_invite(self):
member = make_user(email="member@example.com", company=self.company) member = make_user(email="member@example.com", company=self.company)
@@ -268,17 +375,21 @@ class FeedbackViewTestCase(APITestCase):
self.url = reverse("feedbacks") self.url = reverse("feedbacks")
def test_post_creates_feedback_and_notifies(self): def test_post_creates_feedback_and_notifies(self):
response = self.client.post( with self.captureOnCommitCallbacks(execute=True):
self.url, response = self.client.post(
{"title": "Broken button", "text": "It does nothing"}, self.url,
format="json", {"title": "Broken button", "text": "It does nothing"},
) format="json",
)
self.assertEqual(response.status_code, status.HTTP_201_CREATED) self.assertEqual(response.status_code, status.HTTP_201_CREATED)
feedback = Feedback.objects.get() feedback = Feedback.objects.get()
self.assertEqual(feedback.user, self.user) self.assertEqual(feedback.user, self.user)
self.assertEqual(len(mail.outbox), 1) self.assertEqual(len(mail.outbox), 1)
self.assertIn("Broken button", mail.outbox[0].body) 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): def test_post_without_text_is_rejected(self):
response = self.client.post(self.url, {"title": "no body"}, format="json") response = self.client.post(self.url, {"title": "no body"}, format="json")
+3 -2
View File
@@ -20,7 +20,6 @@ from .views import (
UserConversationAnalytics, UserConversationAnalytics,
CompanyUsageAnalytics, CompanyUsageAnalytics,
AdminAnalytics, AdminAnalytics,
reset_password,
DocumentWorkspaceView, DocumentWorkspaceView,
DocumentUploadView, DocumentUploadView,
DocumentDetailView, DocumentDetailView,
@@ -45,7 +44,9 @@ urlpatterns = [
name="oauth_callback", name="oauth_callback",
), ),
path("user/invite/", CustomUserInvite.as_view(), name="invite_user"), 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( path(
"user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password" "user/set_password/<slug:slug>/", SetUserPassword.as_view(), name="set_password"
), ),
+90 -124
View File
@@ -28,6 +28,7 @@ from .models import (
PromptMetric, PromptMetric,
DocumentWorkspace, DocumentWorkspace,
Document, Document,
UserAuthEvent,
) )
from django.views.decorators.cache import never_cache from django.views.decorators.cache import never_cache
from django.http import JsonResponse from django.http import JsonResponse
@@ -47,12 +48,6 @@ import pandas as pd
import io import io
from chat_backend.services.assistant_identity import ASSISTANT_SYSTEM_PROMPT 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.utils import timezone
from django.core.files import File from django.core.files import File
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
@@ -62,9 +57,14 @@ import pytz
from langchain_ollama import OllamaEmbeddings from langchain_ollama import OllamaEmbeddings
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
from django.views.decorators.csrf import csrf_exempt import requests
from .utils import last_day_of_month 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.llm_service import AsyncLLMService
from .services.rag_services import AsyncRAGService from .services.rag_services import AsyncRAGService
from .services.title_generator import title_generator from .services.title_generator import title_generator
@@ -87,6 +87,13 @@ CHANNEL_NAME: str = "llm_messages"
MODEL_NAME: str = ollama_model() 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. # Create your views here.
class CustomObtainTokenView(TokenObtainPairView): class CustomObtainTokenView(TokenObtainPairView):
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
@@ -141,66 +148,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_invite):
logger.info("Sending reset email")
logger.info(f"url : https://www.chat.aimloperations.com/set_password?slug={slug}")
url = f"https://www.chat.aimloperations.com/set_password?slug={slug}"
subject = "Password reset for AI ML Operations, LLC Chat Services"
from_email = "ryan@aimloperations.com"
to = email_to_invite
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, [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)
def send_password_reset_email(slug, email_to_invite):
logger.info("Sending Password reset email")
url = f"https://www.chat.aimloperations.com/set_password?slug={slug}"
subject = "Password reset for Chat by AI ML Operations, LLC"
from_email = "ryan@aimloperations.com"
to = email_to_invite
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, [to])
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
class CustomUserInvite(APIView): class CustomUserInvite(APIView):
http_method_names = ["post"] http_method_names = ["post"]
@@ -231,72 +178,68 @@ class CustomUserInvite(APIView):
company=request.user.company, company=request.user.company,
) )
# send an email send_invite_email(user.slug, email_to_invite, user=user)
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) return Response(status=status.HTTP_201_CREATED)
@csrf_exempt
def reset_password(request):
if request.method == "POST":
data = json.loads(request.body)
token = data.get("recaptchaToken")
payload = {
"secret": settings.CAPTCHA_SECRET_KEY,
"response": token,
}
response = requests.post(
"https://www.google.com/recaptcha/api/siteverify", data=payload
)
result = response.json()
if result.get("success") and result.get("score") >= 0.5:
email = data.get("email")
user = CustomUser.objects.filter(email=email).first()
if user:
user.set_unusable_password()
user.save()
# send the email
send_password_reset_email(user.slug, email)
JsonResponse(status=200)
JsonResponse(status=400)
class ResetUserPassword(APIView): class ResetUserPassword(APIView):
http_method_names = [ """Request a password-reset email. Invalidates the current password when sent."""
"post",
] http_method_names = ["post"]
permission_classes = (permissions.AllowAny,) permission_classes = (permissions.AllowAny,)
authentication_classes = () authentication_classes = ()
def post(self, request, format="json"): def post(self, request, format="json"):
""" logger.info("Password reset requested")
Send an email with a set password link to the set password page email = request.data.get("email")
Also disable the account
"""
logger.info(f"Password reset for requests. {request.data}")
token = request.data.get("recaptchaToken") token = request.data.get("recaptchaToken")
if not email:
return Response(status=status.HTTP_400_BAD_REQUEST)
payload = { payload = {
"secret": settings.CAPTCHA_SECRET_KEY, "secret": settings.CAPTCHA_SECRET_KEY,
"response": recaptchaToken, "response": token,
} }
response = requests.post( try:
"https://www.google.com/recaptcha/api/siteverify", data=payload captcha_response = requests.post(
) "https://www.google.com/recaptcha/api/siteverify",
result = response.json() data=payload,
if result.get("success") and result.get("score") >= 0.5: timeout=10,
user = CustomUser.objects.filter(email=email).first() )
if user: result = captcha_response.json()
user.set_unusable_password() except requests.RequestException as exc:
user.save() logger.error("Captcha verification request failed: %s", exc)
return Response(status=status.HTTP_400_BAD_REQUEST)
# send the email # v2 invisible returns success only; v3 also returns a score.
send_password_reset_email(user.slug, email) if not result.get("success"):
else: logger.error("Captcha verification failed: %s", result)
logger.error("Captcha secret failed") return Response(status=status.HTTP_400_BAD_REQUEST)
score = result.get("score")
if score is not None and score < 0.5:
logger.error("Captcha score too low: %s", score)
return Response(status=status.HTTP_400_BAD_REQUEST)
user = CustomUser.objects.filter(email=email).first()
if user:
user.set_unusable_password()
user.save(update_fields=["password"])
send_password_reset_email(user.slug, email, user=user)
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) return Response(status=status.HTTP_200_OK)
@@ -306,16 +249,37 @@ class SetUserPassword(APIView):
authentication_classes = () authentication_classes = ()
def get(self, request, slug): def get(self, request, slug):
user = CustomUser.objects.get(slug=slug) try:
user = CustomUser.objects.get(slug=slug)
except CustomUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if user.has_usable_password(): if user.has_usable_password():
return Response(status=status.HTTP_401_UNAUTHORIZED) return Response(status=status.HTTP_401_UNAUTHORIZED)
else: return Response(status=status.HTTP_200_OK)
return Response(status=status.HTTP_200_OK)
def post(self, request, slug, format="json"): def post(self, request, slug, format="json"):
user = CustomUser.objects.get(slug=slug) try:
user.set_password(request.data["password"]) user = CustomUser.objects.get(slug=slug)
except CustomUser.DoesNotExist:
return Response(status=status.HTTP_404_NOT_FOUND)
if user.has_usable_password():
return Response(status=status.HTTP_401_UNAUTHORIZED)
password = request.data.get("password")
if not password or len(password) < 8:
return Response(
{"password": "Password must be at least 8 characters."},
status=status.HTTP_400_BAD_REQUEST,
)
user.set_password(password)
user.save() 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) return Response(status=status.HTTP_200_OK)
@@ -350,7 +314,9 @@ class FeedbackView(APIView):
feedback_obj.user = request.user feedback_obj.user = request.user
feedback_obj.save() feedback_obj.save()
send_feedback_email(feedback_obj) send_feedback_email(
feedback_obj.title, feedback_obj.text, user=request.user
)
return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.data, status=status.HTTP_201_CREATED)
else: else:
logger.error(serializer.errors) logger.error(serializer.errors)
+8
View File
@@ -285,6 +285,14 @@ EMAIL_HOST_PASSWORD = env("EMAIL_HOST_PASSWORD", "") or ""
EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525") EMAIL_PORT = int(env("EMAIL_PORT", "2525") or "2525")
EMAIL_USE_TLS = env_bool("EMAIL_USE_TLS", True) 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 "" CAPTCHA_SECRET_KEY = env("CAPTCHA_SECRET_KEY", "") or ""
USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"}) USE_TLS_PROXY = env_bool("USE_TLS_PROXY", DJANGO_ENV in {"prod", "beta"})