Track outbound emails in admin with send status
Persist OutboundEmail rows (queued/sending/sent/failed) for invite, reset, and feedback mail, show them on user detail + a dedicated admin list, and stamp X-Hesychia-Email-Id for a future SMTP2GO webhook (#28).
This commit is contained in:
@@ -12,6 +12,7 @@ from .models import (
|
|||||||
DocumentWorkspace,
|
DocumentWorkspace,
|
||||||
Document,
|
Document,
|
||||||
UserAuthEvent,
|
UserAuthEvent,
|
||||||
|
OutboundEmail,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -36,6 +37,19 @@ class UserAuthEventInline(admin.TabularInline):
|
|||||||
return False
|
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 = (
|
||||||
@@ -55,7 +69,7 @@ class CustomUserAdmin(admin.ModelAdmin):
|
|||||||
)
|
)
|
||||||
search_fields = ("email", "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")
|
readonly_fields = ("last_login", "date_joined", "get_set_password_url", "slug")
|
||||||
inlines = (UserAuthEventInline,)
|
inlines = (UserAuthEventInline, OutboundEmailInline)
|
||||||
|
|
||||||
|
|
||||||
class UserAuthEventAdmin(admin.ModelAdmin):
|
class UserAuthEventAdmin(admin.ModelAdmin):
|
||||||
@@ -73,6 +87,45 @@ class UserAuthEventAdmin(admin.ModelAdmin):
|
|||||||
return False
|
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):
|
||||||
model = Feedback
|
model = Feedback
|
||||||
search_fields = ("status", "text", "get_user_email")
|
search_fields = ("status", "text", "get_user_email")
|
||||||
@@ -169,6 +222,7 @@ 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(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)
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
Uses Django 6 Tasks API so callers enqueue work the same way today and when a
|
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
|
real worker backend is configured later. ImmediateBackend (default) still runs
|
||||||
the send inside the current process.
|
the send inside the current process.
|
||||||
|
|
||||||
|
Each send creates an OutboundEmail row for admin visibility (queued/sent/failed).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -15,6 +17,9 @@ from django.core.mail import EmailMultiAlternatives
|
|||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.tasks import task
|
from django.tasks import task
|
||||||
from django.template.loader import get_template
|
from django.template.loader import get_template
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import OutboundEmail
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -41,80 +46,119 @@ def set_password_url(slug: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
@task
|
@task
|
||||||
def send_templated_email(
|
def send_templated_email(outbound_email_id: str) -> bool:
|
||||||
subject: str,
|
"""Send a previously queued OutboundEmail via SMTP (SMTP2GO in prod)."""
|
||||||
to_email: str,
|
try:
|
||||||
html_template: str,
|
record = OutboundEmail.objects.get(pk=outbound_email_id)
|
||||||
text_template: str,
|
except OutboundEmail.DoesNotExist:
|
||||||
context: dict,
|
logger.error("OutboundEmail %s missing; cannot send", outbound_email_id)
|
||||||
) -> bool:
|
return False
|
||||||
"""Send a branded HTML+text email via configured SMTP (SMTP2GO in prod)."""
|
|
||||||
logger.info("Sending email subject=%r to=%s", subject, to_email)
|
record.status = OutboundEmail.Status.SENDING
|
||||||
html_content = get_template(html_template).render(context)
|
record.save(update_fields=["status", "updated"])
|
||||||
text_content = get_template(text_template).render(context)
|
|
||||||
|
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(
|
msg = EmailMultiAlternatives(
|
||||||
subject, text_content, DEFAULT_FROM_EMAIL, [to_email]
|
record.subject, text_content, record.from_email, [record.to_email]
|
||||||
)
|
)
|
||||||
msg.attach_alternative(html_content, "text/html")
|
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:
|
try:
|
||||||
msg.send(fail_silently=False)
|
msg.send(fail_silently=False)
|
||||||
return True
|
except Exception as exc:
|
||||||
except Exception:
|
logger.exception(
|
||||||
logger.exception("Failed to send email subject=%r to=%s", subject, to_email)
|
"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
|
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(
|
def enqueue_email(
|
||||||
*,
|
*,
|
||||||
|
kind: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
to_email: str,
|
to_email: str,
|
||||||
html_template: str,
|
html_template: str,
|
||||||
text_template: str,
|
text_template: str,
|
||||||
context: dict,
|
context: dict,
|
||||||
) -> None:
|
user=None,
|
||||||
"""Enqueue after DB commit so workers never race uncommitted rows."""
|
) -> OutboundEmail:
|
||||||
transaction.on_commit(
|
"""Create an OutboundEmail row and enqueue send after DB commit."""
|
||||||
partial(
|
record = OutboundEmail.objects.create(
|
||||||
send_templated_email.enqueue,
|
kind=kind,
|
||||||
subject=subject,
|
status=OutboundEmail.Status.QUEUED,
|
||||||
to_email=to_email,
|
to_email=to_email,
|
||||||
|
from_email=DEFAULT_FROM_EMAIL,
|
||||||
|
subject=subject,
|
||||||
html_template=html_template,
|
html_template=html_template,
|
||||||
text_template=text_template,
|
text_template=text_template,
|
||||||
context=context,
|
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) -> None:
|
def send_invite_email(slug: str, email_to: str, user=None) -> OutboundEmail:
|
||||||
url = set_password_url(slug)
|
url = set_password_url(slug)
|
||||||
logger.info("Queueing invite email url=%s", url)
|
logger.info("Queueing invite email url=%s", url)
|
||||||
enqueue_email(
|
return enqueue_email(
|
||||||
|
kind=OutboundEmail.Kind.INVITE,
|
||||||
subject=f"Welcome to {SITE_NAME}",
|
subject=f"Welcome to {SITE_NAME}",
|
||||||
to_email=email_to,
|
to_email=email_to,
|
||||||
html_template="emails/invite_email.html",
|
html_template="emails/invite_email.html",
|
||||||
text_template="emails/invite_email.txt",
|
text_template="emails/invite_email.txt",
|
||||||
context=brand_email_context(url=url),
|
context=brand_email_context(url=url),
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_password_reset_email(slug: str, email_to: str) -> None:
|
def send_password_reset_email(slug: str, email_to: str, user=None) -> OutboundEmail:
|
||||||
url = set_password_url(slug)
|
url = set_password_url(slug)
|
||||||
logger.info("Queueing password reset email url=%s", url)
|
logger.info("Queueing password reset email url=%s", url)
|
||||||
enqueue_email(
|
return enqueue_email(
|
||||||
|
kind=OutboundEmail.Kind.PASSWORD_RESET,
|
||||||
subject=f"Reset your {SITE_NAME} password",
|
subject=f"Reset your {SITE_NAME} password",
|
||||||
to_email=email_to,
|
to_email=email_to,
|
||||||
html_template="emails/reset_email.html",
|
html_template="emails/reset_email.html",
|
||||||
text_template="emails/reset_email.txt",
|
text_template="emails/reset_email.txt",
|
||||||
context=brand_email_context(url=url),
|
context=brand_email_context(url=url),
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def send_feedback_email(title: str, feedback_text: str) -> None:
|
def send_feedback_email(
|
||||||
|
title: str, feedback_text: str, user=None
|
||||||
|
) -> OutboundEmail:
|
||||||
logger.info("Queueing feedback email")
|
logger.info("Queueing feedback email")
|
||||||
enqueue_email(
|
return enqueue_email(
|
||||||
|
kind=OutboundEmail.Kind.FEEDBACK,
|
||||||
subject=f"New {SITE_NAME} feedback",
|
subject=f"New {SITE_NAME} feedback",
|
||||||
to_email=SUPPORT_EMAIL,
|
to_email=SUPPORT_EMAIL,
|
||||||
html_template="emails/feedback_email.html",
|
html_template="emails/feedback_email.html",
|
||||||
text_template="emails/feedback_email.txt",
|
text_template="emails/feedback_email.txt",
|
||||||
context=brand_email_context(title=title, feedback_text=feedback_text),
|
context=brand_email_context(title=title, feedback_text=feedback_text),
|
||||||
|
user=user,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -117,6 +118,50 @@ class UserAuthEvent(models.Model):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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 = (
|
FEEDBACK_CHOICE = (
|
||||||
("SUBMITTED", "Submitted"),
|
("SUBMITTED", "Submitted"),
|
||||||
("RESOLVED", "Resolved"),
|
("RESOLVED", "Resolved"),
|
||||||
|
|||||||
@@ -5,7 +5,13 @@ 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, UserAuthEvent
|
from chat_backend.models import (
|
||||||
|
Announcement,
|
||||||
|
CustomUser,
|
||||||
|
Feedback,
|
||||||
|
OutboundEmail,
|
||||||
|
UserAuthEvent,
|
||||||
|
)
|
||||||
|
|
||||||
from .factories import make_company, make_user
|
from .factories import make_company, make_user
|
||||||
|
|
||||||
@@ -243,6 +249,11 @@ class ResetPasswordTestCase(APITestCase):
|
|||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
event.event_type, UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED
|
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):
|
def test_reset_unknown_email_still_returns_ok(self):
|
||||||
with self._mock_captcha(success=True):
|
with self._mock_captcha(success=True):
|
||||||
@@ -315,6 +326,9 @@ class UserInviteTestCase(APITestCase):
|
|||||||
self.assertEqual(mail.outbox[0].to, ["newhire@example.com"])
|
self.assertEqual(mail.outbox[0].to, ["newhire@example.com"])
|
||||||
event = UserAuthEvent.objects.get(user=invited)
|
event = UserAuthEvent.objects.get(user=invited)
|
||||||
self.assertEqual(event.event_type, UserAuthEvent.EventType.INVITE_SENT)
|
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)
|
||||||
|
|||||||
@@ -175,7 +175,7 @@ class CustomUserInvite(APIView):
|
|||||||
company=request.user.company,
|
company=request.user.company,
|
||||||
)
|
)
|
||||||
|
|
||||||
send_invite_email(user.slug, email_to_invite)
|
send_invite_email(user.slug, email_to_invite, user=user)
|
||||||
UserAuthEvent.log(
|
UserAuthEvent.log(
|
||||||
user,
|
user,
|
||||||
UserAuthEvent.EventType.INVITE_SENT,
|
UserAuthEvent.EventType.INVITE_SENT,
|
||||||
@@ -228,7 +228,7 @@ class ResetUserPassword(APIView):
|
|||||||
if user:
|
if user:
|
||||||
user.set_unusable_password()
|
user.set_unusable_password()
|
||||||
user.save(update_fields=["password"])
|
user.save(update_fields=["password"])
|
||||||
send_password_reset_email(user.slug, email)
|
send_password_reset_email(user.slug, email, user=user)
|
||||||
UserAuthEvent.log(
|
UserAuthEvent.log(
|
||||||
user,
|
user,
|
||||||
UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED,
|
UserAuthEvent.EventType.PASSWORD_RESET_REQUESTED,
|
||||||
@@ -311,7 +311,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.title, feedback_obj.text)
|
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)
|
||||||
|
|||||||
Reference in New Issue
Block a user