Compare commits

...

2 Commits

Author SHA1 Message Date
bc62611cc4 closes #11 2025-12-15 10:38:18 -06:00
a05062dc11 ensure password reset and account verification works as well as support 2025-12-13 06:51:27 -06:00
24 changed files with 574 additions and 198 deletions

View File

@@ -27,6 +27,7 @@ from core.models import (
SupportCase,
SupportMessage,
FAQ,
OneTimePasscode,
)
@@ -37,6 +38,7 @@ class UserAdmin(admin.ModelAdmin):
"email",
"first_name",
"last_name",
"uuid4",
"is_active",
"is_staff",
"has_usable_password",
@@ -44,7 +46,7 @@ class UserAdmin(admin.ModelAdmin):
# "has_signed_tos",
"last_login",
)
search_fields = ("fields", "email", "first_name", "last_name", "user_type")
search_fields = ("fields", "email", "first_name", "last_name", "user_type", "uuid4")
class PropertyOwnerAdmin(admin.ModelAdmin):
@@ -137,6 +139,7 @@ class PropertyAdmin(admin.ModelAdmin):
model = Property
list_display = (
"pk",
"uuid4",
"owner",
"address",
"city",
@@ -144,7 +147,7 @@ class PropertyAdmin(admin.ModelAdmin):
"zip_code",
"property_status",
)
search_fields = ("address", "city", "state", "zip_code", "owner")
search_fields = ("address", "city", "state", "zip_code", "owner", "uuid4")
inlines = [PropertyWalkScoreInfoStackedInline, SchoolInfoStackedInline]
@@ -287,7 +290,8 @@ class PropertySaveAdmin(admin.ModelAdmin):
@admin.register(Document)
class DocumentAdmin(admin.ModelAdmin):
list_display = ("document_type", "property", "uploaded_by")
list_display = ("document_type", "property", "uploaded_by", "uuid4")
search_fields = ("document_type", "uuid4")
@admin.register(SellerDisclosure)
@@ -428,3 +432,11 @@ admin.site.register(PropertySaleInfo, PropertySaleInfoAdmin)
admin.site.register(PropertyTaxInfo, PropertyTaxInfoAdmin)
admin.site.register(PropertyWalkScoreInfo, PropertyWalkScoreInfoAdmin)
admin.site.register(SchoolInfo, SchoolInfoAdmin)
@admin.register(OneTimePasscode)
class OneTimePasscodeAdmin(admin.ModelAdmin):
list_display = ("user", "code", "purpose", "created_at", "expires_at", "used")
list_filter = ("purpose", "used")
search_fields = ("user__email", "code")

View File

@@ -0,0 +1,29 @@
# Generated by Django 5.2.4 on 2025-12-15 15:39
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('core', '0035_alter_document_document_type_alter_user_is_active_and_more'),
]
operations = [
migrations.AddField(
model_name='document',
name='uuid4',
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.AddField(
model_name='property',
name='uuid4',
field=models.UUIDField(default=uuid.uuid4, null=True),
),
migrations.AddField(
model_name='user',
name='uuid4',
field=models.UUIDField(default=uuid.uuid4, null=True),
),
]

View File

@@ -0,0 +1,48 @@
# Generated by Django 5.2.4 on 2025-12-15 15:41
import uuid
from django.db import migrations, models
def populate_uuids(apps, schema_editor):
User = apps.get_model('core', 'User')
Document = apps.get_model('core', 'Document')
Property = apps.get_model('core', 'Property')
for obj in User.objects.filter(uuid4__isnull=True):
obj.uuid4 = uuid.uuid4()
obj.save()
for obj in Document.objects.filter(uuid4__isnull=True):
obj.uuid4 = uuid.uuid4()
obj.save()
for obj in Property.objects.filter(uuid4__isnull=True):
obj.uuid4 = uuid.uuid4()
obj.save()
class Migration(migrations.Migration):
dependencies = [
('core', '0036_document_uuid4_property_uuid4_user_uuid4'),
]
operations = [
migrations.RunPython(populate_uuids, migrations.RunPython.noop),
migrations.AlterField(
model_name='document',
name='uuid4',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name='property',
name='uuid4',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
migrations.AlterField(
model_name='user',
name='uuid4',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]

View File

@@ -1,4 +1,5 @@
from django.db import models
import uuid
from .property import Property
from .user import User
@@ -24,6 +25,8 @@ class Document(models.Model):
Property, on_delete=models.CASCADE, related_name="documents"
)
uuid4 = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
# The document file itself
file = models.FileField(upload_to="property_documents/", blank=True, null=True)

View File

@@ -2,6 +2,7 @@ from django.db import models
from .property_owner import PropertyOwner
from .user import User
import datetime
import uuid
class Property(models.Model):
@@ -15,6 +16,7 @@ class Property(models.Model):
owner = models.ForeignKey(
PropertyOwner, on_delete=models.CASCADE, related_name="properties"
)
uuid4 = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
address = models.CharField(max_length=200)
street = models.CharField(max_length=200, default="")
city = models.CharField(max_length=100)

View File

@@ -44,6 +44,7 @@ class User(AbstractBaseUser, PermissionsMixin):
("vendor", "Vendor"),
)
uuid4 = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
email = models.EmailField(unique=True)
first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30)

View File

@@ -102,6 +102,7 @@ class DocumentSerializer(serializers.ModelSerializer):
model = Document
fields = [
"id",
"uuid4",
"property",
"file",
"document_type",
@@ -112,4 +113,4 @@ class DocumentSerializer(serializers.ModelSerializer):
"updated_at",
"sub_document",
]
read_only_fields = ["id", "created_at", "updated_at"]
read_only_fields = ["id", "uuid4", "created_at", "updated_at"]

View File

@@ -51,6 +51,7 @@ class PublicPropertyResponseSerializer(serializers.ModelSerializer):
model = Property
fields = [
"id",
"uuid4",
"address",
"street",
"city",
@@ -82,7 +83,7 @@ class PublicPropertyResponseSerializer(serializers.ModelSerializer):
"tax_info",
"sale_info",
]
read_only_fields = ["id", "created_at", "updated_at", "documents"]
read_only_fields = ["id", "uuid4", "created_at", "updated_at", "documents"]
class PropertyResponseSerializer(serializers.ModelSerializer):
@@ -99,6 +100,7 @@ class PropertyResponseSerializer(serializers.ModelSerializer):
model = Property
fields = [
"id",
"uuid4",
"owner",
"address",
"street",
@@ -132,7 +134,7 @@ class PropertyResponseSerializer(serializers.ModelSerializer):
"sale_info",
"documents",
]
read_only_fields = ["id", "created_at", "updated_at", "documents"]
read_only_fields = ["id", "uuid4", "created_at", "updated_at", "documents"]
class PropertyRequestSerializer(serializers.ModelSerializer):
@@ -144,6 +146,7 @@ class PropertyRequestSerializer(serializers.ModelSerializer):
model = Property
fields = [
"id",
"uuid4",
"owner",
"address",
"street",
@@ -173,7 +176,7 @@ class PropertyRequestSerializer(serializers.ModelSerializer):
"sale_info",
"schools",
]
read_only_fields = ["id", "created_at", "updated_at", "views", "saves"]
read_only_fields = ["id", "uuid4", "created_at", "updated_at", "views", "saves"]
def create(self, validated_data):
# tax_info_data = validated_data.pop("tax_info")

View File

@@ -18,10 +18,22 @@ class SupportMessageSerializer(serializers.ModelSerializer):
"user_first_name",
"user_last_name",
]
read_only_fields = ["created_at", "updated_at", "user", "support_case"]
read_only_fields = ["created_at", "updated_at", "user"]
def validate_support_case(self, value):
user = self.context["request"].user
if user.user_type != "support_agent" and value.user != user:
raise serializers.ValidationError(
"You cannot add a message to a support case that does not belong to you."
)
return value
class SupportCaseListSerializer(serializers.ModelSerializer):
user_email = serializers.EmailField(source="user.email", read_only=True)
user_first_name = serializers.CharField(source="user.first_name", read_only=True)
user_last_name = serializers.CharField(source="user.last_name", read_only=True)
class Meta:
model = SupportCase
fields = [
@@ -30,11 +42,22 @@ class SupportCaseListSerializer(serializers.ModelSerializer):
"description",
"category",
"status",
"user",
"user_email",
"user_first_name",
"user_last_name",
"created_at",
"updated_at",
]
read_only_fields = ["created_at", "updated_at", "user"]
def validate_status(self, value):
user = self.context["request"].user
if value == "closed" and user.user_type != "support_agent":
raise serializers.ValidationError(
"You cannot close a support case unless you are a support agent."
)
return value
class SupportCaseDetailSerializer(serializers.ModelSerializer):
messages = SupportMessageSerializer(many=True, read_only=True)
@@ -49,6 +72,8 @@ class SupportCaseDetailSerializer(serializers.ModelSerializer):
"status",
"user",
"messages",
"created_at",
"updated_at",
]
read_only_fields = ["created_at", "updated_at", "user"]

View File

@@ -46,6 +46,7 @@ class UserSerializer(serializers.ModelSerializer):
model = User
fields = [
"id",
"uuid4",
"email",
"first_name",
"last_name",
@@ -56,7 +57,7 @@ class UserSerializer(serializers.ModelSerializer):
"profile_created",
"tier",
]
read_only_fields = ["id", "is_active", "date_joined"]
read_only_fields = ["id", "uuid4", "is_active", "date_joined"]
class UserRegisterSerializer(serializers.ModelSerializer):

View File

@@ -22,6 +22,7 @@ class UserEmailService(BaseEmailService):
"display_name": user.first_name if user.first_name else user.email,
"code": code,
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
"verify_link": f"{settings.FRONTEND_URL}/authentication/verify-email",
}
self.send_email(
"Account Created", "user_registration_email", context, user.email
@@ -33,6 +34,7 @@ class UserEmailService(BaseEmailService):
"display_name": user.first_name if user.first_name else user.email,
"code": code,
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
"reset_link": f"{settings.FRONTEND_URL}/authentication/reset-password",
}
self.send_email("Password Reset", "password_reset_email", context, user.email)

View File

@@ -0,0 +1,146 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Email{% endblock %}</title>
<!--
NOTE: For best compatibility, modern email clients prefer inline styles.
This template uses a combination of inline styles and classes.
For production, you may want to use a tool to pre-process the classes into inline styles.
The styles are designed to mimic Material-UI's design language:
- Clean, modern typography (sans-serif)
- Elevated card-like container with rounded corners and a subtle shadow
- Primary color for call-to-action buttons
-->
<style type="text/css">
body,
html {
margin: 0;
padding: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
background-color: #2d4a4aff;
color: #050f24;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.card {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.header {
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
color: #050f24;
}
.content {
padding-top: 20px;
padding-bottom: 20px;
line-height: 1.6;
font-size: 16px;
}
.content p {
margin: 0 0 16px 0;
}
.button {
display: inline-block;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
color: #ffffff !important;
background-color: #2d4a4aff;
border-radius: 4px;
text-decoration: none;
text-align: center;
}
.footer {
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
font-size: 12px;
color: #6f757e;
line-height: 1.5;
}
a {
color: #27d095;
text-decoration: none;
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #2d4a4aff">
<div class="container" style="width: 100%; max-width: 600px; margin: 0 auto; padding: 20px">
<div class="card" style="
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 24px;
">
<div class="header" style="
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
text-align: center;
">
<h1 style="
margin: 0;
font-size: 24px;
font-weight: 500;
color: #050f24;
">
{% block header_title %}Django App{% endblock %}
</h1>
</div>
<div class="content" style="
padding-top: 20px;
padding-bottom: 20px;
line-height: 1.6;
font-size: 16px;
">
{% block content %}
<!-- Content will be inserted here by child templates -->
{% endblock %}
</div>
<div class="footer" style="
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
font-size: 12px;
color: #6f757e;
line-height: 1.5;
">
<p>This email was sent by Ditch the Agent.</p>
<p>Please do not reply to this email.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,160 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{% block title %}Email{% endblock %}</title>
<!--
NOTE: For best compatibility, modern email clients prefer inline styles.
This template uses a combination of inline styles and classes.
For production, you may want to use a tool to pre-process the classes into inline styles.
The styles are designed to mimic Material-UI's design language:
- Clean, modern typography (sans-serif)
- Elevated card-like container with rounded corners and a subtle shadow
- Primary color for call-to-action buttons
-->
<style type="text/css">
body,
html {
margin: 0;
padding: 0;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
"Segoe UI Emoji", "Segoe UI Symbol";
background-color: #2d4a4aff;
color: #050f24;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
padding: 20px;
}
.card {
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 24px;
}
.header {
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 24px;
font-weight: 500;
color: #050f24;
}
.content {
padding-top: 20px;
padding-bottom: 20px;
line-height: 1.6;
font-size: 16px;
}
.content p {
margin: 0 0 16px 0;
}
.button {
display: inline-block;
padding: 12px 24px;
font-size: 16px;
font-weight: 600;
color: #ffffff !important;
background-color: #2d4a4aff;
border-radius: 4px;
text-decoration: none;
text-align: center;
}
.footer {
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
font-size: 12px;
color: #6f757e;
line-height: 1.5;
}
a {
color: #27d095;
text-decoration: none;
}
</style>
</head>
<body style="margin: 0; padding: 0; background-color: #2d4a4aff">
<div
class="container"
style="width: 100%; max-width: 600px; margin: 0 auto; padding: 20px"
>
<div
class="card"
style="
background-color: #ffffff;
border-radius: 8px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 24px;
"
>
<div
class="header"
style="
padding-bottom: 20px;
border-bottom: 1px solid #e0e0e0;
text-align: center;
"
>
<h1
style="
margin: 0;
font-size: 24px;
font-weight: 500;
color: #050f24;
"
>
{% block header_title %}Django App{% endblock %}
</h1>
</div>
<div
class="content"
style="
padding-top: 20px;
padding-bottom: 20px;
line-height: 1.6;
font-size: 16px;
"
>
{% block content %}
<!-- Content will be inserted here by child templates -->
{% endblock %}
</div>
<div
class="footer"
style="
padding-top: 20px;
border-top: 1px solid #e0e0e0;
text-align: center;
font-size: 12px;
color: #6f757e;
line-height: 1.5;
"
>
<p>This email was sent by Ditch the Agent.</p>
<p>Please do not reply to this email.</p>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,16 +1,16 @@
{% extends 'emails/base_email.html' %} {% block header_title %}Password Reset
Request{% endblock %} {% block content %}
<p>Hello {{ user.first_name|default:user.username }},</p>
<p>Hello {{ display_name }},</p>
<p>
We received a request to reset the password for your account. If you did not
make this request, you can safely ignore this email.
</p>
<p>To reset your password, please click the link below:</p>
<p>To reset your password, please click the link below or enter the code:</p>
<p style="text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px;">
{{ code }}
</p>
<div style="text-align: center; margin: 24px 0">
<a
href="{{ reset_link }}"
class="button"
style="
<a href="{{ reset_link }}" class="button" style="
display: inline-block;
padding: 12px 24px;
font-size: 16px;
@@ -20,8 +20,7 @@ Request{% endblock %} {% block content %}
border-radius: 4px;
text-decoration: none;
text-align: center;
"
>
">
Reset Password
</a>
</div>
@@ -30,9 +29,7 @@ Request{% endblock %} {% block content %}
into your web browser:
</p>
<p>
<a href="{{ reset_link }}" style="word-break: break-all"
>{{ reset_link }}</a
>
<a href="{{ reset_link }}" style="word-break: break-all">{{ reset_link }}</a>
</p>
<p>This link will expire in a few hours for security reasons.</p>
{% endblock %}
{% endblock %}

View File

@@ -3,14 +3,13 @@ The Agent!{% endblock %} {% block content %}
<p>Hello {{ display_name }},</p>
<p>Thank you for registering with us. We're excited to have you on board!</p>
<p>
Please confirm your email address by clicking the button below to activate
your account:
Please confirm your email address by entering the code below at the link:
</p>
<p style="text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px;">
{{ code }}
</p>
<div style="text-align: center; margin: 24px 0">
<a
href="{{ activation_link }}"
class="button"
style="
<a href="{{ verify_link }}" class="button" style="
display: inline-block;
padding: 12px 24px;
font-size: 16px;
@@ -20,9 +19,8 @@ The Agent!{% endblock %} {% block content %}
border-radius: 4px;
text-decoration: none;
text-align: center;
"
>
Confirm Account
">
Verify Account
</a>
</div>
<p>
@@ -30,8 +28,6 @@ The Agent!{% endblock %} {% block content %}
into your web browser:
</p>
<p>
<a href="{{ activation_link }}" style="word-break: break-all"
>{{ activation_link }}</a
>
<a href="{{ verify_link }}" style="word-break: break-all">{{ verify_link }}</a>
</p>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,27 @@
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
User = get_user_model()
class SupportCaseReproductionTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
email="user@example.com", password="password", user_type="property_owner"
)
self.case = SupportCase.objects.create(
user=self.user, title="Case 1", description="Desc 1"
)
def test_user_can_close_own_case(self):
self.client.force_authenticate(user=self.user)
data = {"status": "closed"}
response = self.client.patch(f"/api/support/cases/{self.case.id}/", data)
# If this passes (400 Bad Request), it confirms the fix that regular users cannot close cases
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.case.refresh_from_db()
self.assertNotEqual(self.case.status, "closed")

View File

@@ -0,0 +1,54 @@
from django.test import TestCase
from rest_framework.test import APIClient
from rest_framework import status
from core.models import Document, Property, PropertyOwner, User
import uuid
class DocumentUUIDLookupTest(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
email="test@example.com",
password="password",
first_name="Test",
last_name="User"
)
self.client.force_authenticate(user=self.user)
self.owner_user = User.objects.create(
email="owner@example.com",
first_name="Owner",
last_name="User",
user_type="property_owner",
)
self.owner = PropertyOwner.objects.create(user=self.owner_user)
self.property = Property.objects.create(
owner=self.owner,
address="123 Test St",
city="Test City",
state="CA",
zip_code="12345",
market_value=100000,
realestate_api_id=123,
)
self.document = Document.objects.create(
property=self.property,
document_type="other",
description="Test Document",
uploaded_by=self.user
)
def test_retrieve_document_by_id(self):
response = self.client.get(f'/api/documents/retrieve/?docId={self.document.id}')
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['id'], self.document.id)
def test_retrieve_document_by_uuid(self):
response = self.client.get(f'/api/documents/retrieve/?docId={self.document.uuid4}')
# Expecting failure currently, so we assert 404 or 400 depending on how it fails
# But for the purpose of "reproduction", we want to see it fail if we expect success.
# So I will assert 200, and it should fail.
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data['id'], self.document.id)

View File

@@ -0,0 +1,65 @@
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
User = get_user_model()
class SupportMessageFixTests(TestCase):
def setUp(self):
self.client = APIClient()
self.user = User.objects.create_user(
email="user@example.com", password="password", user_type="property_owner"
)
self.other_user = User.objects.create_user(
email="other@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.case = SupportCase.objects.create(
user=self.user, title="My Case", description="Help"
)
self.other_case = SupportCase.objects.create(
user=self.other_user, title="Other Case", description="Help other"
)
def test_create_message_success(self):
"""Test that a user can create a message for their own case."""
self.client.force_authenticate(user=self.user)
data = {
"user": self.user.id,
"text": "My reply",
"support_case": self.case.id,
}
response = self.client.post("/api/support/messages/", data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(SupportMessage.objects.count(), 1)
self.assertEqual(SupportMessage.objects.first().support_case, self.case)
def test_create_message_fail_other_case(self):
"""Test that a user cannot create a message for another user's case."""
self.client.force_authenticate(user=self.user)
data = {
"user": self.user.id,
"text": "Intruder reply",
"support_case": self.other_case.id,
}
response = self.client.post("/api/support/messages/", data)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("You cannot add a message to a support case that does not belong to you.", str(response.data))
def test_support_agent_can_reply_to_any_case(self):
"""Test that a support agent can create a message for any case."""
self.client.force_authenticate(user=self.support_agent)
data = {
"user": self.support_agent.id,
"text": "Agent reply",
"support_case": self.case.id,
}
response = self.client.post("/api/support/messages/", data)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(SupportMessage.objects.count(), 1)

View File

@@ -0,0 +1,39 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from core.models import SupportCase
from core.serializers import SupportCaseListSerializer
User = get_user_model()
class SupportCaseSerializerTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(
email="testuser@example.com",
password="password",
user_type="property_owner",
first_name="Test",
last_name="User",
)
self.case = SupportCase.objects.create(
user=self.user, title="Test Case", description="Test Description"
)
def test_serializer_fields(self):
serializer = SupportCaseListSerializer(self.case)
data = serializer.data
# Verify user_email is present and correct
self.assertIn("user_email", data)
self.assertEqual(data["user_email"], "testuser@example.com")
# Verify created_at is present
self.assertIn("created_at", data)
# Verify user ID is NOT present
self.assertNotIn("user", data)
# Verify user name fields are present
self.assertIn("user_first_name", data)
self.assertEqual(data["user_first_name"], "Test")
self.assertIn("user_last_name", data)
self.assertEqual(data["user_last_name"], "User")

View File

@@ -7,6 +7,7 @@ from .user import (
PasswordResetRequestView,
PasswordResetConfirmView,
CheckPasscodeView,
ResendRegistrationEmailView
)
from .property_owner import PropertyOwnerViewSet
from .vendor import VendorViewSet

View File

@@ -1,3 +1,4 @@
import uuid
from rest_framework import viewsets, generics, status
from rest_framework.decorators import action
from django.utils import timezone
@@ -119,9 +120,18 @@ class RetrieveDocumentView(generics.RetrieveAPIView, generics.UpdateAPIView):
try:
# We use select_related to eagerly load the related documents
# to prevent extra database queries in the serializer.
return Document.objects.select_related(
queryset = Document.objects.select_related(
"offer_data", "seller_disclosure_data", "home_improvement_receipt_data"
).get(id=document_id)
)
# Check if document_id is a valid UUID
try:
uuid_obj = uuid.UUID(str(document_id))
return queryset.get(uuid4=uuid_obj)
except ValueError:
# If not a UUID, assume it's an ID
return queryset.get(id=document_id)
except Document.DoesNotExist:
raise NotFound(detail="Document not found.")

View File

@@ -59,6 +59,45 @@ class PropertyViewSet(viewsets.ModelViewSet):
return Property.objects.all()
def get_object(self):
"""
Returns the object the view is displaying.
You may want to override this if you need to provide non-standard
queryset lookups. For example if you want to
restrict objects to a user.
"""
queryset = self.filter_queryset(self.get_queryset())
# Perform the lookup filtering.
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
assert lookup_url_kwarg in self.kwargs, (
'Expected view %s to be called with a URL keyword argument '
'named "%s". Fix your URL conf, or set the `.lookup_field` '
'attribute on the view correctly.' %
(self.__class__.__name__, lookup_url_kwarg)
)
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
# Check if the lookup value is a UUID
lookup_value = self.kwargs[lookup_url_kwarg]
import uuid
try:
uuid.UUID(str(lookup_value))
# It's a UUID, so filter by uuid4
filter_kwargs = {'uuid4': lookup_value}
except ValueError:
# Not a UUID, assume it's an ID
pass
obj = generics.get_object_or_404(queryset, **filter_kwargs)
# May raise a permission denied
self.check_object_permissions(self.request, obj)
return obj
def perform_create(self, serializer):
if self.request.user.user_type == "property_owner":
owner = PropertyOwner.objects.get(user=self.request.user)

View File

@@ -46,7 +46,8 @@ class UserRegisterView(generics.CreateAPIView):
# Send registration email with OTC
try:
EmailService.send_registration_email(user)
email_service = EmailService()
email_service.send_registration_email(user)
except Exception as e:
print(e)
@@ -101,6 +102,7 @@ class LogoutView(APIView):
class PasswordResetRequestView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = ()
def post(self, request):
serializer = PasswordResetRequestSerializer(data=request.data)
@@ -115,6 +117,7 @@ class PasswordResetRequestView(APIView):
class PasswordResetConfirmView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = ()
def post(self, request):
serializer = PasswordResetConfirmSerializer(data=request.data)
@@ -129,6 +132,7 @@ class PasswordResetConfirmView(APIView):
class CheckPasscodeView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = ()
def post(self, request):
email = request.data.get("email")
@@ -175,3 +179,28 @@ class CheckPasscodeView(APIView):
return Response(
{"valid": False, "detail": "Invalid code."}, status=status.HTTP_200_OK
)
class ResendRegistrationEmailView(APIView):
permission_classes = [permissions.AllowAny]
authentication_classes = ()
def post(self, request):
email = request.data.get("email")
if not email:
return Response(
{"detail": "Email is required."}, status=status.HTTP_400_BAD_REQUEST
)
try:
user = User.objects.get(email=email)
email_service = EmailService()
email_service.send_registration_email(user)
return Response(
{"detail": "Registration email sent."}, status=status.HTTP_200_OK
)
except User.DoesNotExist:
return Response(
{"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND
)

View File

@@ -36,6 +36,7 @@ from core.views import (
PropertyCompsProxyView,
MLSDetailProxyView,
CheckPasscodeView,
ResendRegistrationEmailView,
)
from django.conf import settings
from django.conf.urls.static import static
@@ -80,6 +81,11 @@ urlpatterns = [
CheckPasscodeView.as_view(),
name="check_passcode",
),
path(
"api/resend-registration-email/",
ResendRegistrationEmailView.as_view(),
name="resend_registration_email",
),
# API endpoints
path("api/attorney/", include("core.urls.attorney")),
path("api/document/", include("core.urls.document")),