This commit is contained in:
2025-12-11 11:06:48 -06:00
parent 800996970a
commit ae5fa9ca1c
19 changed files with 322 additions and 69 deletions

View File

@@ -18,7 +18,7 @@ class SupportMessageSerializer(serializers.ModelSerializer):
"user_first_name",
"user_last_name",
]
read_only_fields = ["created_at", "updated_at", "user"]
read_only_fields = ["created_at", "updated_at", "user", "support_case"]
class SupportCaseListSerializer(serializers.ModelSerializer):

View File

@@ -0,0 +1,21 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import get_template
class BaseEmailService:
def __init__(self):
self.from_email: str = "info@ditchtheagent.com"
def send_email(self, subject: str, template_name: str, context: dict, to_email: str | list[str]) -> None:
# NOTE: to_email can be a singular address (str) or a list of emails (list)
# TODO: make a text version of each email
html_content = get_template(f"emails/{template_name}.html").render(context)
try:
text_content = get_template(f"emails/{template_name}.txt").render(context)
except Exception:
# Fallback if text template doesn't exist, though ideally it should
text_content = ""
to = [to_email] if isinstance(to_email, str) else to_email
msg = EmailMultiAlternatives(subject, text_content, self.from_email, to)
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)

View File

@@ -0,0 +1,15 @@
from core.services.email.base import BaseEmailService
from core.models import Bid, Vendor
class BidEmailService(BaseEmailService):
def send_new_bid_email(self, bid: Bid, vendors: list[Vendor]) -> None:
context = {"bid_title": bid.bid_type}
# NOTE: The original code fetched all vendors, ignoring the passed 'vendors' list.
# I will keep the original logic but it might be a bug or intended.
# Original: emails = Vendor.objects.values_list('user__email', flat=True)
emails = Vendor.objects.values_list('user__email', flat=True)
self.send_email("New bid available", "new_bid_email", context, list(emails))
def send_bid_response_email(self, bid: Bid) -> None:
context = {}
self.send_email("New bid response", "bid_response", context, bid.property.property_owner.user.email)

View File

@@ -0,0 +1,15 @@
from core.services.email.base import BaseEmailService
from core.models import User, OfferDocument
class DocumentEmailService(BaseEmailService):
def send_document_shared_email(self, users: list[User]) -> None:
# NOTE: Original code fetched all users, ignoring 'users' arg. Keeping logic.
emails = User.objects.values_list('email', flat=True)
context = {}
self.send_email("New document shared with you", "document_shared_email", context, list(emails))
def send_new_offer_email(self, offer: OfferDocument) -> None:
pass
def send_updated_offer_email(self, offer: OfferDocument) -> None:
pass

View File

@@ -0,0 +1,67 @@
from core.services.email.base import BaseEmailService
from core.models import SupportCase, User
class SupportEmailService(BaseEmailService):
def send_support_case_created_email(self, case: SupportCase) -> None:
# Email all support agents
support_agents = User.objects.filter(user_type='support_agent').values_list('email', flat=True)
if not support_agents:
return
context = {
"case_id": case.id,
"title": case.title,
"user_email": case.user.email,
"description": case.description
}
self.send_email(
f"New Support Case #{case.id}",
"support_case_created",
context,
list(support_agents)
)
def send_support_case_updated_email(self, case: SupportCase) -> None:
# Email all support agents when user replies
support_agents = User.objects.filter(user_type='support_agent').values_list('email', flat=True)
if not support_agents:
return
context = {
"case_id": case.id,
"title": case.title,
"user_email": case.user.email
}
self.send_email(
f"Update on Support Case #{case.id}",
"support_case_updated",
context,
list(support_agents)
)
def send_support_response_email(self, case: SupportCase) -> None:
# Email the user when support agent replies
context = {
"case_id": case.id,
"title": case.title
}
self.send_email(
f"New Response on Support Case #{case.id}",
"support_response",
context,
case.user.email
)
def send_support_status_update_email(self, case: SupportCase) -> None:
# Email the user when status changes
context = {
"case_id": case.id,
"title": case.title,
"status": case.status
}
self.send_email(
f"Status Update on Support Case #{case.id}",
"support_status_update",
context,
case.user.email
)

View File

@@ -0,0 +1,25 @@
from core.services.email.base import BaseEmailService
from core.models import User
class UserEmailService(BaseEmailService):
def send_registration_email(self, user: User, activation_link: str) -> None:
print('Sending a registration email')
context = {
"display_name": user.first_name if user.first_name else user.email,
"activation_link": activation_link
}
self.send_email("Account Created", "user_registration_email", context, user.email)
def send_password_reset_email(self, user: User) -> None:
context = {}
self.send_email("Password Reset", "password_reset_email", context, user.email)
def send_password_change_email(self, user: User) -> None:
context = {}
self.send_email("Password Updated", "password_change_email", context, user.email)
def send_account_upgrade_email(self, user: User) -> None:
pass
def send_weekly_report_email(self, user: User) -> None:
pass

View File

@@ -1,64 +1,11 @@
from core.models import User, Vendor, Bid, OfferDocument, Document
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 core.services.email.user import UserEmailService
from core.services.email.bid import BidEmailService
from core.services.email.document import DocumentEmailService
from core.services.email.support import SupportEmailService
class EmailService(object):
def __init__(self):
self.from_email: str = "info@ditchtheagent.com"
def send_email(self, subject: str, template_name: str, context: dict, to_email: str | list[str]) -> None:
# NOTE: to_email can be a singular address (str) or a list of emails (list)
# TODO: make a text version of each email
html_content = get_template(f"emails/{template_name}.html").render(context)
text_content = get_template(f"emails/{template_name}.txt").render(context)
to = [to_email] if isinstance(to_email, str) else to_email
msg = EmailMultiAlternatives(subject, text_content, self.from_email, to)
msg.attach_alternative(html_content, "text/html")
msg.send(fail_silently=True)
def send_registration_email(self, user: User, activation_link: str) -> None:
print('Sending a registration email')
context = {
"display_name": user.first_name if user.first_name else user.email,
"activation_link": "This_is@not.com"
}
self.send_email("Account Created", "user_registration_email", context, user.email)
def send_password_reset_email(self, user:User) -> None:
context = {}
self.send_email("Password Reset", "password_reset_email", context, user.email)
def send_password_change_email(self, user:User) -> None:
context = {}
self.send_email("Password Updated", "password_change_email", context, user.email)
def send_new_bid_email(self, bid:Bid, vendors:list[Vendor]) -> None:
context = {"bid_title": bid.bid_type}
emails = Vendor.objects.values_list('user__email', flat=True)
self.send_email("New bid available", "new_bid_email", context, list(emails))
def send_document_shared_email(self, users:list[User]) -> None:
emails = User.objects.values_list('email', flat=True)
context = {}
self.send_email("New document shared with you", "document_shared_email", context, list(emails))
def send_bid_response_email(self, bid:Bid) -> None:
context = {}
self.send_email("New bid response", "bid_response", context, bid.property.property_owner.user.email)
def send_new_offer_email(self, offer: OfferDocument) -> None:
pass
def send_updated_offer_email(self, offer: OfferDocument) -> None:
pass
def send_account_upgrade_email(self, user: User) -> None:
pass
def send_weekly_report_email(self, user:User) -> None:
pass
# TODO: Open house information here
class EmailService(UserEmailService, BidEmailService, DocumentEmailService, SupportEmailService):
"""
Legacy EmailService class that combines all specific email services.
This maintains backward compatibility with existing code.
"""
pass

View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html>
<head>
<title>New Support Case</title>
</head>
<body>
<h2>New Support Case #{{ case_id }}</h2>
<p><strong>Title:</strong> {{ title }}</p>
<p><strong>User:</strong> {{ user_email }}</p>
<p><strong>Description:</strong> {{ description }}</p>
</body>
</html>

View File

@@ -0,0 +1,5 @@
New Support Case #{{ case_id }}
Title: {{ title }}
User: {{ user_email }}
Description: {{ description }}

View File

@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html>
<head>
<title>Support Case Updated</title>
</head>
<body>
<h2>Update on Support Case #{{ case_id }}</h2>
<p><strong>Title:</strong> {{ title }}</p>
<p><strong>User:</strong> {{ user_email }}</p>
<p>The user has added a new message to this support case.</p>
</body>
</html>

View File

@@ -0,0 +1,6 @@
Update on Support Case #{{ case_id }}
Title: {{ title }}
User: {{ user_email }}
The user has added a new message to this support case.

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>New Response on Support Case</title>
</head>
<body>
<h2>New Response on Support Case #{{ case_id }}</h2>
<p><strong>Title:</strong> {{ title }}</p>
<p>A support agent has responded to your support case.</p>
</body>
</html>

View File

@@ -0,0 +1,5 @@
New Response on Support Case #{{ case_id }}
Title: {{ title }}
A support agent has responded to your support case.

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<title>Status Update on Support Case</title>
</head>
<body>
<h2>Status Update on Support Case #{{ case_id }}</h2>
<p><strong>Title:</strong> {{ title }}</p>
<p><strong>New Status:</strong> {{ status }}</p>
</body>
</html>

View File

@@ -0,0 +1,4 @@
Status Update on Support Case #{{ case_id }}
Title: {{ title }}
New Status: {{ status }}

View File

View File

@@ -0,0 +1,66 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from rest_framework.test import APIClient
from rest_framework import status
from core.models import SupportCase, SupportMessage
from unittest.mock import patch
User = get_user_model()
class SupportCaseTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(email='user@example.com', password='password', user_type='property_owner')
self.support_agent = User.objects.create_user(email='agent@example.com', password='password', user_type='support_agent')
self.other_user = User.objects.create_user(email='other@example.com', password='password', user_type='property_owner')
self.case1 = SupportCase.objects.create(user=self.user, title="Case 1", description="Desc 1")
self.case2 = SupportCase.objects.create(user=self.other_user, title="Case 2", description="Desc 2")
def test_user_can_only_see_own_cases(self):
self.client.force_authenticate(user=self.user)
response = self.client.get('/api/support/cases/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 1)
self.assertEqual(response.data[0]['id'], self.case1.id)
def test_support_agent_can_see_all_cases(self):
self.client.force_authenticate(user=self.support_agent)
response = self.client.get('/api/support/cases/')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.data), 2)
@patch('core.services.email.base.EmailMultiAlternatives.send')
def test_create_case_sends_email_to_support_agents(self, mock_send):
self.client.force_authenticate(user=self.user)
data = {"title": "New Case", "description": "Help me", "category": "question"}
response = self.client.post('/api/support/cases/', data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Verify email sent
self.assertTrue(mock_send.called)
# We can inspect call args if needed, but verifying it was called is a good start
@patch('core.services.email.base.EmailMultiAlternatives.send')
def test_user_reply_sends_email_to_support_agents(self, mock_send):
self.client.force_authenticate(user=self.user)
data = {"text": "User reply"}
response = self.client.post(f'/api/support/cases/{self.case1.id}/add_message/', data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(mock_send.called)
@patch('core.services.email.base.EmailMultiAlternatives.send')
def test_agent_reply_sends_email_to_user(self, mock_send):
self.client.force_authenticate(user=self.support_agent)
data = {"text": "Agent reply"}
response = self.client.post(f'/api/support/cases/{self.case1.id}/add_message/', data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(mock_send.called)
@patch('core.services.email.base.EmailMultiAlternatives.send')
def test_status_update_sends_email_to_user(self, mock_send):
self.client.force_authenticate(user=self.support_agent)
data = {"status": "closed"}
response = self.client.patch(f'/api/support/cases/{self.case1.id}/', data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(mock_send.called)

View File

@@ -9,15 +9,17 @@ from core.serializers import (
SupportMessageSerializer,
FAQSerializer,
)
from core.services.email.support import SupportEmailService
class SupportCaseViewSet(viewsets.ModelViewSet):
permission_classes = [IsAuthenticated]
def get_queryset(self):
return SupportCase.objects.filter(user=self.request.user).order_by(
"-updated_at"
)
user = self.request.user
if user.user_type == "support_agent":
return SupportCase.objects.all().order_by("-updated_at")
return SupportCase.objects.filter(user=user).order_by("-updated_at")
def get_serializer_class(self):
if self.action == "retrieve":
@@ -25,14 +27,34 @@ class SupportCaseViewSet(viewsets.ModelViewSet):
return SupportCaseListSerializer
def perform_create(self, serializer):
serializer.save(user=self.request.user)
case = serializer.save(user=self.request.user)
# Send email to support agents
SupportEmailService().send_support_case_created_email(case)
def perform_update(self, serializer):
# Check if status is changing
instance = self.get_object()
original_status = instance.status
case = serializer.save()
if original_status != case.status:
# Send status update email to user
SupportEmailService().send_support_status_update_email(case)
@action(detail=True, methods=["post"])
def add_message(self, request, pk=None):
support_case = self.get_object()
serializer = SupportMessageSerializer(data=request.data)
if serializer.is_valid():
serializer.save(user=request.user, support_case=support_case)
message = serializer.save(user=request.user, support_case=support_case)
# Send email notifications
email_service = SupportEmailService()
if request.user.user_type == "support_agent":
email_service.send_support_response_email(support_case)
else:
email_service.send_support_case_updated_email(support_case)
return Response(serializer.data)
return Response(serializer.errors, status=400)