This commit is contained in:
2025-12-12 09:43:47 -06:00
parent d2c127f881
commit c0e4fa26ef
34 changed files with 793 additions and 263 deletions

View File

@@ -33,7 +33,6 @@ class ChatConsumer(AsyncWebsocketConsumer):
# return # return
# #
# await self.channel_layer.group_add( # await self.channel_layer.group_add(
# self.account_group_name, self.account_id # self.account_group_name, self.account_id
# ) # )
@@ -44,7 +43,7 @@ class ChatConsumer(AsyncWebsocketConsumer):
await self.close() await self.close()
async def disconnect(self, close_code): async def disconnect(self, close_code):
if (self.channel_layer): if self.channel_layer:
await self.channel_layer.group_discard( await self.channel_layer.group_discard(
self.account_group_name, self.account_id self.account_group_name, self.account_id
) )
@@ -57,29 +56,26 @@ class ChatConsumer(AsyncWebsocketConsumer):
Then to the conversation Then to the conversation
""" """
messages = text_data_json.get('messages') messages = text_data_json.get("messages")
moderation_result = await moderation_classifier.classify_async(messages[-1]) moderation_result = await moderation_classifier.classify_async(messages[-1])
if moderation_result == ModerationLabel.NSFW: if moderation_result == ModerationLabel.NSFW:
await self.send('BEGINING_OF_THE_WORLD') await self.send("BEGINING_OF_THE_WORLD")
await self.send(str('Try again')) await self.send(str("Try again"))
await self.send('END_OF_THE_WORLD') await self.send("END_OF_THE_WORLD")
await self.send("BEGINING_OF_THE_WORLD")
await self.send('BEGINING_OF_THE_WORLD')
service = AsyncLLMService() service = AsyncLLMService()
response = '' response = ""
# get the account to add to the prompt # get the account to add to the prompt
print('generating') print("generating")
async for chunk in service.generate_response( async for chunk in service.generate_response(messages, self.user):
messages, self.user
):
response += chunk response += chunk
await self.send(chunk) await self.send(chunk)
print(response) print(response)
await self.send('END_OF_THE_WORLD') await self.send("END_OF_THE_WORLD")
# # Save message to database # # Save message to database
# conversation = await self.get_conversation(self.conversation_id) # conversation = await self.get_conversation(self.conversation_id)
@@ -94,8 +90,6 @@ class ChatConsumer(AsyncWebsocketConsumer):
# {"type": "chat_message", "message": serializer.data}, # {"type": "chat_message", "message": serializer.data},
# ) # )
async def chat_message(self, event): async def chat_message(self, event):
message = event["message"] message = event["message"]

View File

@@ -3,22 +3,42 @@ from .models import Property, Vendor
from django.db.models import QuerySet from django.db.models import QuerySet
from .utils import haversine_distance from .utils import haversine_distance
class PropertyFilterSet(django_filters.FilterSet): class PropertyFilterSet(django_filters.FilterSet):
address = django_filters.CharFilter(field_name='address', lookup_expr='icontains') address = django_filters.CharFilter(field_name="address", lookup_expr="icontains")
city = django_filters.CharFilter(field_name='city', lookup_expr='icontains') city = django_filters.CharFilter(field_name="city", lookup_expr="icontains")
state = django_filters.CharFilter(field_name='state', lookup_expr='icontains') state = django_filters.CharFilter(field_name="state", lookup_expr="icontains")
zip_code = django_filters.CharFilter(field_name='zip_code', lookup_expr='exact') zip_code = django_filters.CharFilter(field_name="zip_code", lookup_expr="exact")
min_num_bedrooms = django_filters.NumberFilter(field_name='num_bedrooms', lookup_expr='gte') min_num_bedrooms = django_filters.NumberFilter(
max_num_bedrooms = django_filters.NumberFilter(field_name='num_bedrooms', lookup_expr='lte') field_name="num_bedrooms", lookup_expr="gte"
min_num_bathrooms = django_filters.NumberFilter(field_name='num_bathrooms', lookup_expr='gte') )
max_num_bathrooms = django_filters.NumberFilter(field_name='num_bathrooms', lookup_expr='lte') max_num_bedrooms = django_filters.NumberFilter(
min_sq_ft = django_filters.NumberFilter(field_name='sq_ft', lookup_expr='gte') field_name="num_bedrooms", lookup_expr="lte"
max_sq_ft = django_filters.NumberFilter(field_name='sq_ft', lookup_expr='lte') )
min_num_bathrooms = django_filters.NumberFilter(
field_name="num_bathrooms", lookup_expr="gte"
)
max_num_bathrooms = django_filters.NumberFilter(
field_name="num_bathrooms", lookup_expr="lte"
)
min_sq_ft = django_filters.NumberFilter(field_name="sq_ft", lookup_expr="gte")
max_sq_ft = django_filters.NumberFilter(field_name="sq_ft", lookup_expr="lte")
class Meta: class Meta:
model = Property model = Property
fields = ['address', 'city', 'state', 'zip_code', 'min_num_bedrooms', 'max_num_bedrooms', fields = [
'min_num_bathrooms', 'max_num_bathrooms', 'min_sq_ft', 'max_sq_ft'] "address",
"city",
"state",
"zip_code",
"min_num_bedrooms",
"max_num_bedrooms",
"min_num_bathrooms",
"max_num_bathrooms",
"min_sq_ft",
"max_sq_ft",
]
class DistanceFilter(django_filters.Filter): class DistanceFilter(django_filters.Filter):
def filter(self, qs: QuerySet, value: str) -> QuerySet: def filter(self, qs: QuerySet, value: str) -> QuerySet:
@@ -26,7 +46,7 @@ class DistanceFilter(django_filters.Filter):
if not value: if not value:
return qs return qs
try: try:
property_id, distance_str = value.split(',') property_id, distance_str = value.split(",")
property_id = int(property_id) property_id = int(property_id)
distance_miles = float(distance_str) distance_miles = float(distance_str)
except (ValueError, IndexError): except (ValueError, IndexError):
@@ -35,9 +55,12 @@ class DistanceFilter(django_filters.Filter):
try: try:
# Import Property model here to avoid circular imports # Import Property model here to avoid circular imports
from .models import Property from .models import Property
# Ensure the requesting user owns the property # Ensure the requesting user owns the property
request = self.parent.request request = self.parent.request
property_obj = Property.objects.get(id=property_id, owner__user=request.user) property_obj = Property.objects.get(
id=property_id, owner__user=request.user
)
if property_obj.latitude is None or property_obj.longitude is None: if property_obj.latitude is None or property_obj.longitude is None:
return qs.none() return qs.none()
@@ -50,7 +73,12 @@ class DistanceFilter(django_filters.Filter):
vendor_pks = [] vendor_pks = []
for vendor in qs: for vendor in qs:
if vendor.latitude is not None and vendor.longitude is not None: if vendor.latitude is not None and vendor.longitude is not None:
dist = haversine_distance(prop_lat, prop_lon, float(vendor.latitude), float(vendor.longitude)) dist = haversine_distance(
prop_lat,
prop_lon,
float(vendor.latitude),
float(vendor.longitude),
)
if dist <= distance_miles: if dist <= distance_miles:
vendor_pks.append(vendor.pk) vendor_pks.append(vendor.pk)
@@ -59,6 +87,7 @@ class DistanceFilter(django_filters.Filter):
except Property.DoesNotExist: except Property.DoesNotExist:
return qs.none() return qs.none()
class VendorFilterSet(django_filters.FilterSet): class VendorFilterSet(django_filters.FilterSet):
# Your existing filters # Your existing filters
business_type = django_filters.ChoiceFilter(choices=Vendor.BUSINESS_TYPES) business_type = django_filters.ChoiceFilter(choices=Vendor.BUSINESS_TYPES)
@@ -68,4 +97,4 @@ class VendorFilterSet(django_filters.FilterSet):
class Meta: class Meta:
model = Vendor model = Vendor
fields = ['business_type', 'distance_from_property'] fields = ["business_type", "distance_from_property"]

View File

@@ -1,10 +1,11 @@
from django.db import migrations from django.db import migrations
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
def create_default_attorney(apps, schema_editor): def create_default_attorney(apps, schema_editor):
User = apps.get_model('core', 'User') User = apps.get_model("core", "User")
Attorney = apps.get_model('core', 'Attorney') Attorney = apps.get_model("core", "Attorney")
# Create User # Create User
user, created = User.objects.get_or_create( user, created = User.objects.get_or_create(
email="ryan@relawfirm", email="ryan@relawfirm",
@@ -13,10 +14,10 @@ def create_default_attorney(apps, schema_editor):
"first_name": "Ryan", "first_name": "Ryan",
"last_name": "Attorney", "last_name": "Attorney",
"user_type": "attorney", "user_type": "attorney",
"is_active": True "is_active": True,
} },
) )
if not created: if not created:
user.set_password("asdfuweoriasdgn") user.set_password("asdfuweoriasdgn")
user.save() user.save()
@@ -30,14 +31,15 @@ def create_default_attorney(apps, schema_editor):
"address": "505 W Main St Suite A", "address": "505 W Main St Suite A",
"city": "St. Charles", "city": "St. Charles",
"state": "IL", "state": "IL",
"zip_code": "60174" "zip_code": "60174",
} },
) )
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('core', '0033_alter_document_document_type_and_more'), ("core", "0033_alter_document_document_type_and_more"),
] ]
operations = [ operations = [

View File

@@ -0,0 +1,103 @@
# Generated by Django 5.2.4 on 2025-12-12 15:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("core", "0034_create_default_attorney"),
]
operations = [
migrations.AlterField(
model_name="document",
name="document_type",
field=models.CharField(
choices=[
("offer_letter", "Offer Letter"),
("seller_disclosure", "Seller Disclosure"),
("home_improvement_receipt", "Home Improvement Receipt"),
("attorney_contract", "Attorney Contract"),
("contractor_contract", "Contractor Contract"),
("title_report", "Title Report"),
("inspection_report", "Inspection Report"),
("deed", "Deed"),
("closing_disclosure", "Closing Disclosure"),
("attorney_engagement_letter", "Attorney Engagement Letter"),
("lendor_financing_agreement", "Lendor Financing Agreement"),
("other", "Other"),
],
max_length=50,
),
),
migrations.AlterField(
model_name="user",
name="is_active",
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name="LendorFinancingAgreement",
fields=[
(
"document",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
primary_key=True,
related_name="lendor_financing_agreement_data",
serialize=False,
to="core.document",
),
),
("is_signed", models.BooleanField(default=False)),
("date_signed", models.DateTimeField(blank=True, null=True)),
(
"offer",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="financing_agreements",
to="core.offerdocument",
),
),
],
),
migrations.CreateModel(
name="OneTimePasscode",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("code", models.CharField(max_length=6)),
("created_at", models.DateTimeField(auto_now_add=True)),
("expires_at", models.DateTimeField()),
("used", models.BooleanField(default=False)),
(
"purpose",
models.CharField(
choices=[
("registration", "Registration"),
("reset", "Password Reset"),
],
max_length=20,
),
),
(
"user",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@@ -1,13 +1,26 @@
from .user import UserManager, User, UserViewModel, PasswordResetToken from .user import UserManager, User, UserViewModel, PasswordResetToken, OneTimePasscode
from .property_owner import PropertyOwner from .property_owner import PropertyOwner
from .vendor import Vendor, VendorPictures from .vendor import Vendor, VendorPictures
from .attorney import Attorney from .attorney import Attorney
from .real_estate_agent import RealEstateAgent from .real_estate_agent import RealEstateAgent
from .support_agent import SupportAgent from .support_agent import SupportAgent
from .property import Property, PropertyPictures, PropertySave from .property import Property, PropertyPictures, PropertySave
from .property_info import SchoolInfo, PropertyTaxInfo, PropertySaleInfo, PropertyWalkScoreInfo, OpenHouse from .property_info import (
SchoolInfo,
PropertyTaxInfo,
PropertySaleInfo,
PropertyWalkScoreInfo,
OpenHouse,
)
from .video import VideoCategory, Video, UserVideoProgress from .video import VideoCategory, Video, UserVideoProgress
from .conversation import Conversation, Message, message_file_path from .conversation import Conversation, Message, message_file_path
from .bid import Bid, BidImage, BidResponse from .bid import Bid, BidImage, BidResponse
from .document import Document, OfferDocument, SellerDisclosure, HomeImprovementReceipt, AttorneyEngagementLetter, LendorFinancingAgreement from .document import (
Document,
OfferDocument,
SellerDisclosure,
HomeImprovementReceipt,
AttorneyEngagementLetter,
LendorFinancingAgreement,
)
from .support import FAQ, SupportCase, SupportMessage from .support import FAQ, SupportCase, SupportMessage

View File

@@ -136,7 +136,10 @@ class AttorneyEngagementLetter(models.Model):
primary_key=True, primary_key=True,
) )
attorney = models.ForeignKey( attorney = models.ForeignKey(
"core.Attorney", on_delete=models.SET_NULL, null=True, related_name="engagement_letters" "core.Attorney",
on_delete=models.SET_NULL,
null=True,
related_name="engagement_letters",
) )
is_accepted = models.BooleanField(default=False) is_accepted = models.BooleanField(default=False)
accepted_at = models.DateTimeField(null=True, blank=True) accepted_at = models.DateTimeField(null=True, blank=True)
@@ -157,7 +160,7 @@ class LendorFinancingAgreement(models.Model):
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name="financing_agreements", related_name="financing_agreements",
null=True, null=True,
blank=True blank=True,
) )
is_signed = models.BooleanField(default=False) is_signed = models.BooleanField(default=False)
date_signed = models.DateTimeField(null=True, blank=True) date_signed = models.DateTimeField(null=True, blank=True)

View File

@@ -48,7 +48,7 @@ class User(AbstractBaseUser, PermissionsMixin):
first_name = models.CharField(max_length=30) first_name = models.CharField(max_length=30)
last_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30)
user_type = models.CharField(max_length=20, choices=USER_TYPE_CHOICES) user_type = models.CharField(max_length=20, choices=USER_TYPE_CHOICES)
is_active = models.BooleanField(default=True) is_active = models.BooleanField(default=False)
is_staff = models.BooleanField(default=False) is_staff = models.BooleanField(default=False)
date_joined = models.DateTimeField(default=timezone.now) date_joined = models.DateTimeField(default=timezone.now)
tos_signed = models.BooleanField(default=False) tos_signed = models.BooleanField(default=False)
@@ -107,3 +107,23 @@ class PasswordResetToken(models.Model):
def __str__(self): def __str__(self):
return f"Password reset token for {self.user.email}" return f"Password reset token for {self.user.email}"
class OneTimePasscode(models.Model):
PURPOSE_CHOICES = (
("registration", "Registration"),
("reset", "Password Reset"),
)
user = models.ForeignKey(User, on_delete=models.CASCADE)
code = models.CharField(max_length=6)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
used = models.BooleanField(default=False)
purpose = models.CharField(max_length=20, choices=PURPOSE_CHOICES)
def is_valid(self):
return not self.used and self.expires_at > timezone.now()
def __str__(self):
return f"{self.purpose} code for {self.user.email}"

View File

@@ -3,5 +3,5 @@ from . import consumers
websocket_urlpatterns = [ websocket_urlpatterns = [
re_path(r"ws/chat/(?P<account_id>\w+)/$", consumers.ChatConsumer.as_asgi()), re_path(r"ws/chat/(?P<account_id>\w+)/$", consumers.ChatConsumer.as_asgi()),
#re_path(r"ws/chat/$", consumers.ChatConsumer.as_asgi()), # re_path(r"ws/chat/$", consumers.ChatConsumer.as_asgi()),
] ]

View File

@@ -192,7 +192,7 @@ class PropertyRequestSerializer(serializers.ModelSerializer):
property_instance=property_instance, property_instance=property_instance,
file=None, file=None,
uploaded_by=user, uploaded_by=user,
description="Automatically created Lendor Financing Agreement" description="Automatically created Lendor Financing Agreement",
) )
sale_infos = [] sale_infos = []
@@ -246,8 +246,10 @@ class PropertyRequestSerializer(serializers.ModelSerializer):
) )
if walk_score_data: if walk_score_data:
walk_score_instance, created = PropertyWalkScoreInfo.objects.update_or_create( walk_score_instance, created = (
property=instance, defaults=walk_score_data PropertyWalkScoreInfo.objects.update_or_create(
property=instance, defaults=walk_score_data
)
) )
# For "many" relationships like schools and sale_info, you might need more complex logic # For "many" relationships like schools and sale_info, you might need more complex logic

View File

@@ -106,27 +106,37 @@ class PasswordResetRequestSerializer(serializers.Serializer):
return value return value
def save(self): def save(self):
from core.services.email.user import UserEmailService
user = User.objects.get(email=self.validated_data["email"]) user = User.objects.get(email=self.validated_data["email"])
expires_at = datetime.now() + timedelta(hours=24) UserEmailService().send_password_reset_email(user)
token = PasswordResetToken.objects.create(user=user, expires_at=expires_at) return user
token.send_reset_email()
return token
class PasswordResetConfirmSerializer(serializers.Serializer): class PasswordResetConfirmSerializer(serializers.Serializer):
token = serializers.UUIDField() email = serializers.EmailField()
code = serializers.CharField(max_length=6)
new_password = serializers.CharField(write_only=True) new_password = serializers.CharField(write_only=True)
new_password2 = serializers.CharField(write_only=True) new_password2 = serializers.CharField(write_only=True)
def validate(self, attrs): def validate(self, attrs):
try: try:
token = PasswordResetToken.objects.get(token=attrs["token"]) user = User.objects.get(email=attrs["email"])
except PasswordResetToken.DoesNotExist: except User.DoesNotExist:
raise serializers.ValidationError({"token": "Invalid token."}) raise serializers.ValidationError({"email": "Invalid email."})
if not token.is_valid(): from core.models import OneTimePasscode
try:
otc = OneTimePasscode.objects.filter(
user=user, code=attrs["code"], purpose="reset", used=False
).latest("created_at")
except OneTimePasscode.DoesNotExist:
raise serializers.ValidationError({"code": "Invalid code."})
if not otc.is_valid():
raise serializers.ValidationError( raise serializers.ValidationError(
{"token": "Token is invalid or has expired."} {"code": "Code is invalid or has expired."}
) )
if attrs["new_password"] != attrs["new_password2"]: if attrs["new_password"] != attrs["new_password2"]:
@@ -134,13 +144,17 @@ class PasswordResetConfirmSerializer(serializers.Serializer):
{"new_password": "Password fields didn't match."} {"new_password": "Password fields didn't match."}
) )
attrs["user"] = user
attrs["otc"] = otc
return attrs return attrs
def save(self): def save(self):
token = PasswordResetToken.objects.get(token=self.validated_data["token"]) user = self.validated_data["user"]
user = token.user otc = self.validated_data["otc"]
user.set_password(self.validated_data["new_password"]) user.set_password(self.validated_data["new_password"])
user.save() user.save()
token.used = True
token.save() otc.used = True
otc.save()
return user return user

View File

@@ -1,9 +1,23 @@
from core.models import Document, AttorneyEngagementLetter, Attorney, User, LendorFinancingAgreement from core.models import (
Document,
AttorneyEngagementLetter,
Attorney,
User,
LendorFinancingAgreement,
)
from django.utils import timezone from django.utils import timezone
class DocumentService: class DocumentService:
@staticmethod @staticmethod
def create_document(property_instance, document_type, file, uploaded_by, description=None, shared_with=None): def create_document(
property_instance,
document_type,
file,
uploaded_by,
description=None,
shared_with=None,
):
""" """
Creates a generic Document instance. Creates a generic Document instance.
""" """
@@ -12,16 +26,18 @@ class DocumentService:
document_type=document_type, document_type=document_type,
file=file, file=file,
uploaded_by=uploaded_by, uploaded_by=uploaded_by,
description=description description=description,
) )
if shared_with: if shared_with:
document.shared_with.set(shared_with) document.shared_with.set(shared_with)
return document return document
@staticmethod @staticmethod
def create_attorney_engagement_letter(property_instance, file, uploaded_by, attorney_id, description=None): def create_attorney_engagement_letter(
property_instance, file, uploaded_by, attorney_id, description=None
):
""" """
Creates an Attorney Engagement Letter document and its specific data. Creates an Attorney Engagement Letter document and its specific data.
""" """
@@ -30,9 +46,9 @@ class DocumentService:
document_type="attorney_engagement_letter", document_type="attorney_engagement_letter",
file=file, file=file,
uploaded_by=uploaded_by, uploaded_by=uploaded_by,
description=description description=description,
) )
try: try:
attorney = Attorney.objects.get(user__id=attorney_id) attorney = Attorney.objects.get(user__id=attorney_id)
except Attorney.DoesNotExist: except Attorney.DoesNotExist:
@@ -40,15 +56,14 @@ class DocumentService:
# For now, we'll assume valid ID or let it fail if critical # For now, we'll assume valid ID or let it fail if critical
raise ValueError(f"Attorney with ID {attorney_id} not found.") raise ValueError(f"Attorney with ID {attorney_id} not found.")
AttorneyEngagementLetter.objects.create( AttorneyEngagementLetter.objects.create(document=document, attorney=attorney)
document=document,
attorney=attorney
)
return document return document
@staticmethod @staticmethod
def create_lendor_financing_agreement(property_instance, file, uploaded_by, description=None): def create_lendor_financing_agreement(
property_instance, file, uploaded_by, description=None
):
""" """
Creates a Lendor Financing Agreement document. Creates a Lendor Financing Agreement document.
""" """
@@ -57,13 +72,11 @@ class DocumentService:
document_type="lendor_financing_agreement", document_type="lendor_financing_agreement",
file=file, file=file,
uploaded_by=uploaded_by, uploaded_by=uploaded_by,
description=description description=description,
) )
LendorFinancingAgreement.objects.create( LendorFinancingAgreement.objects.create(document=document)
document=document
)
return document return document
@staticmethod @staticmethod
@@ -75,26 +88,28 @@ class DocumentService:
# Find all engagement letters for this property # Find all engagement letters for this property
engagement_letters = AttorneyEngagementLetter.objects.filter( engagement_letters = AttorneyEngagementLetter.objects.filter(
document__property=property_instance, document__property=property_instance,
document__document_type="attorney_engagement_letter" document__document_type="attorney_engagement_letter",
) )
if not engagement_letters.exists(): if not engagement_letters.exists():
# Create one with default attorney # Create one with default attorney
try: try:
default_attorney_user = User.objects.get(email="ryan@relawfirm") default_attorney_user = User.objects.get(email="ryan@relawfirm")
# We need to pass a user for 'uploaded_by'. # We need to pass a user for 'uploaded_by'.
# Ideally this should be the property owner, but we only have property_instance. # Ideally this should be the property owner, but we only have property_instance.
# We can try to get the owner from property_instance.owner.user # We can try to get the owner from property_instance.owner.user
uploaded_by = property_instance.owner.user if property_instance.owner else None uploaded_by = (
property_instance.owner.user if property_instance.owner else None
)
DocumentService.create_attorney_engagement_letter( DocumentService.create_attorney_engagement_letter(
property_instance=property_instance, property_instance=property_instance,
file=None, # No file initially file=None, # No file initially
uploaded_by=uploaded_by, uploaded_by=uploaded_by,
attorney_id=default_attorney_user.id, attorney_id=default_attorney_user.id,
description="Automatically created Engagement Letter" description="Automatically created Engagement Letter",
) )
return False # Created but not accepted return False # Created but not accepted
except User.DoesNotExist: except User.DoesNotExist:
# If default attorney doesn't exist (e.g. migration didn't run or test env), # If default attorney doesn't exist (e.g. migration didn't run or test env),
# we can't create it. Just return False. # we can't create it. Just return False.
@@ -104,5 +119,5 @@ class DocumentService:
for letter in engagement_letters: for letter in engagement_letters:
if letter.is_accepted: if letter.is_accepted:
return True return True
return False return False

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,22 +1,46 @@
from core.services.email.base import BaseEmailService from core.services.email.base import BaseEmailService
from core.models import User from core.models import User, OneTimePasscode
from django.utils import timezone
from django.conf import settings
import random
from datetime import timedelta
class UserEmailService(BaseEmailService): class UserEmailService(BaseEmailService):
def send_registration_email(self, user: User, activation_link: str) -> None: def _generate_otc(self, user: User, purpose: str) -> str:
print('Sending a registration email') code = "".join([str(random.randint(0, 9)) for _ in range(6)])
expires_at = timezone.now() + timedelta(minutes=settings.OTC_EXPIRATION_MINUTES)
OneTimePasscode.objects.create(
user=user, code=code, expires_at=expires_at, purpose=purpose
)
return code
def send_registration_email(self, user: User, activation_link: str = None) -> None:
print("Sending a registration email")
code = self._generate_otc(user, "registration")
context = { context = {
"display_name": user.first_name if user.first_name else user.email, "display_name": user.first_name if user.first_name else user.email,
"activation_link": activation_link "code": code,
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
} }
self.send_email("Account Created", "user_registration_email", context, user.email) self.send_email(
"Account Created", "user_registration_email", context, user.email
)
def send_password_reset_email(self, user: User) -> None: def send_password_reset_email(self, user: User) -> None:
context = {} code = self._generate_otc(user, "reset")
context = {
"display_name": user.first_name if user.first_name else user.email,
"code": code,
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
}
self.send_email("Password Reset", "password_reset_email", context, user.email) self.send_email("Password Reset", "password_reset_email", context, user.email)
def send_password_change_email(self, user: User) -> None: def send_password_change_email(self, user: User) -> None:
context = {} context = {}
self.send_email("Password Updated", "password_change_email", context, user.email) self.send_email(
"Password Updated", "password_change_email", context, user.email
)
def send_account_upgrade_email(self, user: User) -> None: def send_account_upgrade_email(self, user: User) -> None:
pass pass

View File

@@ -3,9 +3,13 @@ from core.services.email.bid import BidEmailService
from core.services.email.document import DocumentEmailService from core.services.email.document import DocumentEmailService
from core.services.email.support import SupportEmailService from core.services.email.support import SupportEmailService
class EmailService(UserEmailService, BidEmailService, DocumentEmailService, SupportEmailService):
class EmailService(
UserEmailService, BidEmailService, DocumentEmailService, SupportEmailService
):
""" """
Legacy EmailService class that combines all specific email services. Legacy EmailService class that combines all specific email services.
This maintains backward compatibility with existing code. This maintains backward compatibility with existing code.
""" """
pass pass

View File

@@ -8,6 +8,7 @@ from langchain_core.prompts import ChatPromptTemplate
from core.models import User from core.models import User
class LLMService(ABC): class LLMService(ABC):
"""sd;ofisdf""" """sd;ofisdf"""
@@ -21,6 +22,7 @@ class LLMService(ABC):
num_ctx=4096, num_ctx=4096,
) )
self.output_parser = StrOutputParser() self.output_parser = StrOutputParser()
@abstractmethod @abstractmethod
def generate_response(self, query: str, **kwargs): def generate_response(self, query: str, **kwargs):
"""Generate a response to a query within a conversation context.""" """Generate a response to a query within a conversation context."""
@@ -35,6 +37,7 @@ class LLMService(ABC):
# f"{'User' if prompt.is_user else 'AI'}: {prompt.text}" for prompt in prompts # f"{'User' if prompt.is_user else 'AI'}: {prompt.text}" for prompt in prompts
# ) # )
class AsyncLLMService(LLMService): class AsyncLLMService(LLMService):
"""Asynchronous LLM conversation service.""" """Asynchronous LLM conversation service."""
@@ -109,7 +112,12 @@ class AsyncLLMService(LLMService):
# return "\n".join( # return "\n".join(
# f"{'User' if prompt.is_user else 'AI'}: {prompt.text}" for prompt in prompts # f"{'User' if prompt.is_user else 'AI'}: {prompt.text}" for prompt in prompts
# ) # )
return "\n".join([f"{"User" if prompt.get('sender')=="user" else "AI"}: {prompt.get('text')}" for prompt in conversation]) return "\n".join(
[
f"{"User" if prompt.get('sender')=="user" else "AI"}: {prompt.get('text')}"
for prompt in conversation
]
)
# async def _get_recent_messages(self, conversation: list) -> str: # async def _get_recent_messages(self, conversation: list) -> str:
# """Async version of format conversation history.""" # """Async version of format conversation history."""
@@ -125,7 +133,7 @@ class AsyncLLMService(LLMService):
# return "\n".join([f"{"User" if prompt.type=="human" else "AI"}: {prompt.text()}" for prompt in conversation]) # return "\n".join([f"{"User" if prompt.type=="human" else "AI"}: {prompt.text()}" for prompt in conversation])
async def generate_response( async def generate_response(
self, conversation: list[dict[str,str]], user: User self, conversation: list[dict[str, str]], user: User
) -> AsyncGenerator[str, None]: ) -> AsyncGenerator[str, None]:
"""Generate response with async streaming support.""" """Generate response with async streaming support."""
chain_input = { chain_input = {

View File

@@ -1,19 +1,58 @@
from django.test import TestCase from django.test import TestCase
from core.models import Property, PropertyOwner, User, Attorney, Document, AttorneyEngagementLetter from core.models import (
Property,
PropertyOwner,
User,
Attorney,
Document,
AttorneyEngagementLetter,
)
from core.services.document_service import DocumentService from core.services.document_service import DocumentService
from django.utils import timezone from django.utils import timezone
class DocumentServiceTests(TestCase): class DocumentServiceTests(TestCase):
def setUp(self): def setUp(self):
self.user = User.objects.create(email="test@example.com", first_name="Test", last_name="User") self.user = User.objects.create(
self.owner_user = User.objects.create(email="owner@example.com", first_name="Owner", last_name="User", user_type="property_owner") email="test@example.com", first_name="Test", last_name="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.owner = PropertyOwner.objects.create(user=self.owner_user)
self.attorney_user = User.objects.create(email="attorney@example.com", first_name="Attorney", last_name="User", user_type="attorney") self.attorney_user = User.objects.create(
self.attorney = Attorney.objects.create(user=self.attorney_user, firm_name="Test Firm", address="123 Law St", city="Lawville", state="CA", zip_code="90210") email="attorney@example.com",
first_name="Attorney",
last_name="User",
user_type="attorney",
)
self.attorney = Attorney.objects.create(
user=self.attorney_user,
firm_name="Test Firm",
address="123 Law St",
city="Lawville",
state="CA",
zip_code="90210",
)
# Create default attorney for auto-creation test # Create default attorney for auto-creation test
self.default_attorney_user = User.objects.create(email="ryan@relawfirm", first_name="Ryan", last_name="Attorney", user_type="attorney") self.default_attorney_user = User.objects.create(
self.default_attorney = Attorney.objects.create(user=self.default_attorney_user, firm_name="The Real Estate Law Firm, LLC", address="505 W Main St Suite A", city="St. Charles", state="IL", zip_code="60174") email="ryan@relawfirm",
first_name="Ryan",
last_name="Attorney",
user_type="attorney",
)
self.default_attorney = Attorney.objects.create(
user=self.default_attorney_user,
firm_name="The Real Estate Law Firm, LLC",
address="505 W Main St Suite A",
city="St. Charles",
state="IL",
zip_code="60174",
)
self.property = Property.objects.create( self.property = Property.objects.create(
owner=self.owner, owner=self.owner,
@@ -22,7 +61,7 @@ class DocumentServiceTests(TestCase):
state="CA", state="CA",
zip_code="12345", zip_code="12345",
market_value=100000, market_value=100000,
realestate_api_id=123 realestate_api_id=123,
) )
def test_create_attorney_engagement_letter(self): def test_create_attorney_engagement_letter(self):
@@ -31,35 +70,41 @@ class DocumentServiceTests(TestCase):
file=None, file=None,
uploaded_by=self.owner_user, uploaded_by=self.owner_user,
attorney_id=self.attorney_user.id, attorney_id=self.attorney_user.id,
description="Engagement Letter" description="Engagement Letter",
) )
self.assertEqual(document.document_type, "attorney_engagement_letter") self.assertEqual(document.document_type, "attorney_engagement_letter")
self.assertTrue(hasattr(document, "attorney_engagement_letter_data")) self.assertTrue(hasattr(document, "attorney_engagement_letter_data"))
self.assertEqual(document.attorney_engagement_letter_data.attorney, self.attorney) self.assertEqual(
document.attorney_engagement_letter_data.attorney, self.attorney
)
self.assertFalse(document.attorney_engagement_letter_data.is_accepted) self.assertFalse(document.attorney_engagement_letter_data.is_accepted)
def test_check_engagement_letter_accepted(self): def test_check_engagement_letter_accepted(self):
# No letter yet -> Should auto-create one linked to default attorney # No letter yet -> Should auto-create one linked to default attorney
self.assertFalse(DocumentService.check_engagement_letter_accepted(self.property)) self.assertFalse(
DocumentService.check_engagement_letter_accepted(self.property)
)
# Verify it was created # Verify it was created
engagement_letters = AttorneyEngagementLetter.objects.filter( engagement_letters = AttorneyEngagementLetter.objects.filter(
document__property=self.property, document__property=self.property,
document__document_type="attorney_engagement_letter" document__document_type="attorney_engagement_letter",
) )
self.assertTrue(engagement_letters.exists()) self.assertTrue(engagement_letters.exists())
self.assertEqual(engagement_letters.count(), 1) self.assertEqual(engagement_letters.count(), 1)
self.assertEqual(engagement_letters.first().attorney, self.default_attorney) self.assertEqual(engagement_letters.first().attorney, self.default_attorney)
# Still not accepted # Still not accepted
self.assertFalse(DocumentService.check_engagement_letter_accepted(self.property)) self.assertFalse(
DocumentService.check_engagement_letter_accepted(self.property)
)
# Accept letter # Accept letter
letter = engagement_letters.first() letter = engagement_letters.first()
letter.is_accepted = True letter.is_accepted = True
letter.accepted_at = timezone.now() letter.accepted_at = timezone.now()
letter.save() letter.save()
# Should be accepted now # Should be accepted now
self.assertTrue(DocumentService.check_engagement_letter_accepted(self.property)) self.assertTrue(DocumentService.check_engagement_letter_accepted(self.property))

View File

@@ -1,20 +1,45 @@
from django.test import TestCase from django.test import TestCase
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework import status from rest_framework import status
from core.models import Property, PropertyOwner, User, Attorney, Document, AttorneyEngagementLetter from core.models import (
Property,
PropertyOwner,
User,
Attorney,
Document,
AttorneyEngagementLetter,
)
from core.services.document_service import DocumentService from core.services.document_service import DocumentService
from django.utils import timezone from django.utils import timezone
class DocumentSigningTests(TestCase): class DocumentSigningTests(TestCase):
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
self.user = User.objects.create(email="test@example.com", first_name="Test", last_name="User", user_type="property_owner") self.user = User.objects.create(
email="test@example.com",
first_name="Test",
last_name="User",
user_type="property_owner",
)
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
self.owner = PropertyOwner.objects.create(user=self.user) self.owner = PropertyOwner.objects.create(user=self.user)
self.attorney_user = User.objects.create(email="attorney@example.com", first_name="Attorney", last_name="User", user_type="attorney") self.attorney_user = User.objects.create(
self.attorney = Attorney.objects.create(user=self.attorney_user, firm_name="Test Firm", address="123 Law St", city="Lawville", state="CA", zip_code="90210") email="attorney@example.com",
first_name="Attorney",
last_name="User",
user_type="attorney",
)
self.attorney = Attorney.objects.create(
user=self.attorney_user,
firm_name="Test Firm",
address="123 Law St",
city="Lawville",
state="CA",
zip_code="90210",
)
self.property = Property.objects.create( self.property = Property.objects.create(
owner=self.owner, owner=self.owner,
address="123 Test St", address="123 Test St",
@@ -22,23 +47,25 @@ class DocumentSigningTests(TestCase):
state="CA", state="CA",
zip_code="12345", zip_code="12345",
market_value=100000, market_value=100000,
realestate_api_id=123 realestate_api_id=123,
) )
self.document = DocumentService.create_attorney_engagement_letter( self.document = DocumentService.create_attorney_engagement_letter(
property_instance=self.property, property_instance=self.property,
file=None, file=None,
uploaded_by=self.user, uploaded_by=self.user,
attorney_id=self.attorney_user.id attorney_id=self.attorney_user.id,
) )
def test_sign_engagement_letter(self): def test_sign_engagement_letter(self):
url = f"/api/documents/{self.document.id}/sign/" url = f"/api/documents/{self.document.id}/sign/"
response = self.client.post(url) response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["detail"], "Attorney Engagement Letter accepted successfully.") self.assertEqual(
response.data["detail"], "Attorney Engagement Letter accepted successfully."
)
self.document.refresh_from_db() self.document.refresh_from_db()
self.assertTrue(self.document.attorney_engagement_letter_data.is_accepted) self.assertTrue(self.document.attorney_engagement_letter_data.is_accepted)
self.assertIsNotNone(self.document.attorney_engagement_letter_data.accepted_at) self.assertIsNotNone(self.document.attorney_engagement_letter_data.accepted_at)
@@ -49,23 +76,26 @@ class DocumentSigningTests(TestCase):
letter.is_accepted = True letter.is_accepted = True
letter.accepted_at = timezone.now() letter.accepted_at = timezone.now()
letter.save() letter.save()
url = f"/api/documents/{self.document.id}/sign/" url = f"/api/documents/{self.document.id}/sign/"
response = self.client.post(url) response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["detail"], "This letter has already been accepted.") self.assertEqual(
response.data["detail"], "This letter has already been accepted."
)
def test_sign_wrong_document_type(self): def test_sign_wrong_document_type(self):
# Create a generic document # Create a generic document
other_doc = Document.objects.create( other_doc = Document.objects.create(
property=self.property, property=self.property, document_type="other", uploaded_by=self.user
document_type="other",
uploaded_by=self.user
) )
url = f"/api/documents/{other_doc.id}/sign/" url = f"/api/documents/{other_doc.id}/sign/"
response = self.client.post(url) response = self.client.post(url)
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertEqual(response.data["detail"], "This document is not an Attorney Engagement Letter.") self.assertEqual(
response.data["detail"],
"This document is not an Attorney Engagement Letter.",
)

View File

@@ -4,10 +4,16 @@ from rest_framework import status
from core.models import Property, PropertyOwner, User from core.models import Property, PropertyOwner, User
from unittest.mock import patch from unittest.mock import patch
class PropertyLocationRestrictionTests(TestCase): class PropertyLocationRestrictionTests(TestCase):
def setUp(self): def setUp(self):
self.client = APIClient() self.client = APIClient()
self.user = User.objects.create(email="owner@example.com", first_name="Owner", last_name="User", user_type="property_owner") self.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.user) self.owner = PropertyOwner.objects.create(user=self.user)
self.client.force_authenticate(user=self.user) self.client.force_authenticate(user=self.user)
@@ -19,7 +25,7 @@ class PropertyLocationRestrictionTests(TestCase):
zip_code="60601", zip_code="60601",
market_value=100000, market_value=100000,
realestate_api_id=1, realestate_api_id=1,
property_status="off_market" property_status="off_market",
) )
self.ny_property = Property.objects.create( self.ny_property = Property.objects.create(
@@ -30,30 +36,38 @@ class PropertyLocationRestrictionTests(TestCase):
zip_code="10001", zip_code="10001",
market_value=200000, market_value=200000,
realestate_api_id=2, realestate_api_id=2,
property_status="off_market" property_status="off_market",
) )
def test_activate_il_property_success(self): def test_activate_il_property_success(self):
# Mock the attorney letter check to pass # Mock the attorney letter check to pass
with patch('core.services.document_service.DocumentService.check_engagement_letter_accepted', return_value=True): with patch(
"core.services.document_service.DocumentService.check_engagement_letter_accepted",
return_value=True,
):
response = self.client.patch( response = self.client.patch(
f'/api/properties/{self.il_property.id}/', f"/api/properties/{self.il_property.id}/",
{'property_status': 'active'}, {"property_status": "active"},
format='json' format="json",
) )
self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.status_code, status.HTTP_200_OK)
self.il_property.refresh_from_db() self.il_property.refresh_from_db()
self.assertEqual(self.il_property.property_status, 'active') self.assertEqual(self.il_property.property_status, "active")
def test_activate_ny_property_failure(self): def test_activate_ny_property_failure(self):
# Mock the attorney letter check to pass (though it shouldn't be reached) # Mock the attorney letter check to pass (though it shouldn't be reached)
with patch('core.services.document_service.DocumentService.check_engagement_letter_accepted', return_value=True): with patch(
"core.services.document_service.DocumentService.check_engagement_letter_accepted",
return_value=True,
):
response = self.client.patch( response = self.client.patch(
f'/api/properties/{self.ny_property.id}/', f"/api/properties/{self.ny_property.id}/",
{'property_status': 'active'}, {"property_status": "active"},
format='json' format="json",
) )
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
self.assertIn("Only properties in Illinois can be set to active.", str(response.data)) self.assertIn(
"Only properties in Illinois can be set to active.", str(response.data)
)
self.ny_property.refresh_from_db() self.ny_property.refresh_from_db()
self.assertEqual(self.ny_property.property_status, 'off_market') self.assertEqual(self.ny_property.property_status, "off_market")

View File

@@ -5,13 +5,31 @@ from core.serializers.property import PropertyRequestSerializer
from core.services.document_service import DocumentService from core.services.document_service import DocumentService
from django.utils import timezone from django.utils import timezone
class PropertySerializerValidationTests(TestCase): class PropertySerializerValidationTests(TestCase):
def setUp(self): def setUp(self):
self.owner_user = User.objects.create(email="owner@example.com", first_name="Owner", last_name="User", user_type="property_owner") 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.owner = PropertyOwner.objects.create(user=self.owner_user)
self.attorney_user = User.objects.create(email="attorney@example.com", first_name="Attorney", last_name="User", user_type="attorney") self.attorney_user = User.objects.create(
self.attorney = Attorney.objects.create(user=self.attorney_user, firm_name="Test Firm", address="123 Law St", city="Lawville", state="CA", zip_code="90210") email="attorney@example.com",
first_name="Attorney",
last_name="User",
user_type="attorney",
)
self.attorney = Attorney.objects.create(
user=self.attorney_user,
firm_name="Test Firm",
address="123 Law St",
city="Lawville",
state="CA",
zip_code="90210",
)
self.property = Property.objects.create( self.property = Property.objects.create(
owner=self.owner, owner=self.owner,
address="123 Test St", address="123 Test St",
@@ -20,17 +38,22 @@ class PropertySerializerValidationTests(TestCase):
zip_code="12345", zip_code="12345",
market_value=100000, market_value=100000,
realestate_api_id=123, realestate_api_id=123,
property_status="off_market" property_status="off_market",
) )
def test_update_status_active_without_letter_fails(self): def test_update_status_active_without_letter_fails(self):
serializer = PropertyRequestSerializer(instance=self.property, data={"property_status": "active"}, partial=True) serializer = PropertyRequestSerializer(
instance=self.property, data={"property_status": "active"}, partial=True
)
self.assertTrue(serializer.is_valid()) self.assertTrue(serializer.is_valid())
with self.assertRaises(ValidationError) as cm: with self.assertRaises(ValidationError) as cm:
serializer.save() serializer.save()
self.assertIn("Cannot list property as active without an accepted Attorney Engagement Letter.", str(cm.exception)) self.assertIn(
"Cannot list property as active without an accepted Attorney Engagement Letter.",
str(cm.exception),
)
def test_update_status_active_with_accepted_letter_succeeds(self): def test_update_status_active_with_accepted_letter_succeeds(self):
# Create and accept letter # Create and accept letter
@@ -38,16 +61,18 @@ class PropertySerializerValidationTests(TestCase):
property_instance=self.property, property_instance=self.property,
file=None, file=None,
uploaded_by=self.owner_user, uploaded_by=self.owner_user,
attorney_id=self.attorney_user.id attorney_id=self.attorney_user.id,
) )
letter = document.attorney_engagement_letter_data letter = document.attorney_engagement_letter_data
letter.is_accepted = True letter.is_accepted = True
letter.accepted_at = timezone.now() letter.accepted_at = timezone.now()
letter.save() letter.save()
serializer = PropertyRequestSerializer(instance=self.property, data={"property_status": "active"}, partial=True) serializer = PropertyRequestSerializer(
instance=self.property, data={"property_status": "active"}, partial=True
)
self.assertTrue(serializer.is_valid()) self.assertTrue(serializer.is_valid())
updated_property = serializer.save() updated_property = serializer.save()
self.assertEqual(updated_property.property_status, "active") self.assertEqual(updated_property.property_status, "active")
self.assertIsNotNone(updated_property.listed_date) self.assertIsNotNone(updated_property.listed_date)

View File

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

View File

@@ -0,0 +1,89 @@
from django.test import TestCase
from django.contrib.auth import get_user_model
from core.models import OneTimePasscode
from core.services.email.user import UserEmailService
from django.utils import timezone
from datetime import timedelta
from django.conf import settings
from rest_framework.test import APIClient
from rest_framework import status
User = get_user_model()
class UserSecurityOTCTest(TestCase):
def setUp(self):
self.user = User.objects.create_user(
email="test@example.com",
password="password123",
first_name="Test",
last_name="User",
user_type="property_owner",
is_active=False,
)
self.client = APIClient()
def test_otc_generation(self):
service = UserEmailService()
code = service._generate_otc(self.user, "registration")
self.assertEqual(len(code), 6)
self.assertTrue(code.isdigit())
otc = OneTimePasscode.objects.get(user=self.user, code=code)
self.assertEqual(otc.purpose, "registration")
self.assertFalse(otc.used)
# Check expiration (approximate)
expected_expiry = timezone.now() + timedelta(
minutes=settings.OTC_EXPIRATION_MINUTES
)
self.assertTrue(abs((otc.expires_at - expected_expiry).total_seconds()) < 10)
def test_check_passcode_view(self):
service = UserEmailService()
code = service._generate_otc(self.user, "registration")
url = "/api/check-passcode/"
data = {"email": self.user.email, "code": code}
response = self.client.post(url, data)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertTrue(response.data["valid"])
self.user.refresh_from_db()
self.assertTrue(self.user.is_active)
otc = OneTimePasscode.objects.get(code=code)
self.assertTrue(otc.used)
def test_password_reset_flow(self):
# 1. Request reset
service = UserEmailService()
code = service._generate_otc(self.user, "reset")
# 2. Confirm reset
data = {
"email": self.user.email,
"code": code,
"new_password": "newpassword123",
"new_password2": "newpassword123",
}
# I need to use the serializer or view.
from core.serializers import PasswordResetConfirmSerializer
serializer = PasswordResetConfirmSerializer(data=data)
self.assertTrue(serializer.is_valid(), serializer.errors)
serializer.save()
self.user.refresh_from_db()
self.assertTrue(self.user.check_password("newpassword123"))
otc = OneTimePasscode.objects.get(code=code)
self.assertTrue(otc.used)
def test_expired_otc(self):
otc = OneTimePasscode.objects.create(
user=self.user,
code="123456",
expires_at=timezone.now() - timedelta(minutes=1),
purpose="reset",
)
self.assertFalse(otc.is_valid())

View File

@@ -1,8 +1,9 @@
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from core.views import BidViewSet, BidResponseViewSet from core.views import BidViewSet, BidResponseViewSet
# Create a router and register our viewsets with it. # Create a router and register our viewsets with it.
router = DefaultRouter() router = DefaultRouter()
router.register(r'bids', BidViewSet, basename='bids') router.register(r"bids", BidViewSet, basename="bids")
router.register(r'bid-responses', BidResponseViewSet, basename='bid-responses') router.register(r"bid-responses", BidResponseViewSet, basename="bid-responses")
urlpatterns = router.urls urlpatterns = router.urls

View File

@@ -1,10 +1,10 @@
from django.urls import path from django.urls import path
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from core.views import PropertyViewSet, PropertyPictureViewSet,OpenHouseViewSet from core.views import PropertyViewSet, PropertyPictureViewSet, OpenHouseViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r"pictures", PropertyPictureViewSet, basename="property-pictures") router.register(r"pictures", PropertyPictureViewSet, basename="property-pictures")
router.register(r'open-houses', OpenHouseViewSet) router.register(r"open-houses", OpenHouseViewSet)
router.register(r"", PropertyViewSet, basename="property") router.register(r"", PropertyViewSet, basename="property")

View File

@@ -4,8 +4,8 @@ from rest_framework.routers import DefaultRouter
from core.views import PropertySaveViewSet from core.views import PropertySaveViewSet
router = DefaultRouter() router = DefaultRouter()
router.register(r'', PropertySaveViewSet, basename='propertysave') router.register(r"", PropertySaveViewSet, basename="propertysave")
urlpatterns = [ urlpatterns = [
path('', include(router.urls)), path("", include(router.urls)),
] ]

View File

@@ -6,6 +6,7 @@ from .user import (
LogoutView, LogoutView,
PasswordResetRequestView, PasswordResetRequestView,
PasswordResetConfirmView, PasswordResetConfirmView,
CheckPasscodeView,
) )
from .property_owner import PropertyOwnerViewSet from .property_owner import PropertyOwnerViewSet
from .vendor import VendorViewSet from .vendor import VendorViewSet
@@ -17,8 +18,10 @@ from .property import (
PropertyPictureViewSet, PropertyPictureViewSet,
PropertyDescriptionView, PropertyDescriptionView,
PropertySaveViewSet, PropertySaveViewSet,
PropertDetailProxyView, PropertyDetailProxyView,
AutoCompleteProxyView, AutoCompleteProxyView,
PropertyCompsProxyView,
MLSDetailProxyView,
) )
from .property_info import OpenHouseViewSet from .property_info import OpenHouseViewSet
from .video import ( from .video import (

View File

@@ -98,7 +98,7 @@ class DocumentViewSet(viewsets.ModelViewSet):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
except AttorneyEngagementLetter.DoesNotExist: except AttorneyEngagementLetter.DoesNotExist:
return Response( return Response(
{"detail": "Attorney Engagement Letter data not found."}, {"detail": "Attorney Engagement Letter data not found."},
status=status.HTTP_404_NOT_FOUND, status=status.HTTP_404_NOT_FOUND,
) )
@@ -261,27 +261,30 @@ class CreateDocumentView(APIView):
) )
elif document_type == "lendor_financing_agreement": elif document_type == "lendor_financing_agreement":
mutable_data["description"] = f"Financing Agreement for {property.address}" mutable_data["description"] = f"Financing Agreement for {property.address}"
# Add property owner # Add property owner
existing_shared_with = mutable_data.get("shared_with", []) existing_shared_with = mutable_data.get("shared_with", [])
if not isinstance(existing_shared_with, list): if not isinstance(existing_shared_with, list):
existing_shared_with = [existing_shared_with] existing_shared_with = [existing_shared_with]
if property.owner and property.owner.user: if property.owner and property.owner.user:
existing_shared_with.append(property.owner.user.id) existing_shared_with.append(property.owner.user.id)
# Add attorney if exists # Add attorney if exists
try: try:
engagement_letter = AttorneyEngagementLetter.objects.filter( engagement_letter = (
document__property=property, AttorneyEngagementLetter.objects.filter(
is_accepted=True document__property=property, is_accepted=True
).select_related('attorney__user').first() )
.select_related("attorney__user")
.first()
)
if engagement_letter and engagement_letter.attorney: if engagement_letter and engagement_letter.attorney:
existing_shared_with.append(engagement_letter.attorney.user.id) existing_shared_with.append(engagement_letter.attorney.user.id)
except Exception: except Exception:
pass pass
mutable_data["shared_with"] = existing_shared_with mutable_data["shared_with"] = existing_shared_with
elif document_type == "seller_disclosure": elif document_type == "seller_disclosure":
@@ -296,10 +299,13 @@ class CreateDocumentView(APIView):
existing_shared_with.append(default_attorney_user.id) existing_shared_with.append(default_attorney_user.id)
else: else:
# If it's a single value or something else, make it a list # If it's a single value or something else, make it a list
existing_shared_with = [existing_shared_with, default_attorney_user.id] existing_shared_with = [
existing_shared_with,
default_attorney_user.id,
]
mutable_data["shared_with"] = existing_shared_with mutable_data["shared_with"] = existing_shared_with
except User.DoesNotExist: except User.DoesNotExist:
pass # Default attorney not found, skip linking pass # Default attorney not found, skip linking
document_serializer = DocumentSerializer(data=mutable_data) document_serializer = DocumentSerializer(data=mutable_data)

View File

@@ -75,19 +75,19 @@ class PropertyViewSet(viewsets.ModelViewSet):
walk_description=data.get("description"), walk_description=data.get("description"),
ws_link=data.get("ws_link"), ws_link=data.get("ws_link"),
logo_url=data.get("logo_url"), logo_url=data.get("logo_url"),
transit_score=data.get("transit").get("score") transit_score=(
if has_transit data.get("transit").get("score") if has_transit else None
else None, ),
transit_description=data.get("transit").get("description") transit_description=(
if has_transit data.get("transit").get("description") if has_transit else None
else None, ),
transit_summary=data.get("transit").get("summary") transit_summary=(
if has_transit data.get("transit").get("summary") if has_transit else None
else None, ),
bike_score=data.get("bike").get("score") if has_bike else None, bike_score=data.get("bike").get("score") if has_bike else None,
bike_description=data.get("bike").get("description") bike_description=(
if has_bike data.get("bike").get("description") if has_bike else None
else None, ),
) )
serializer.save(owner=owner, walk_score=walk_score) serializer.save(owner=owner, walk_score=walk_score)

View File

@@ -36,7 +36,7 @@ class SupportCaseViewSet(viewsets.ModelViewSet):
instance = self.get_object() instance = self.get_object()
original_status = instance.status original_status = instance.status
case = serializer.save() case = serializer.save()
if original_status != case.status: if original_status != case.status:
# Send status update email to user # Send status update email to user
SupportEmailService().send_support_status_update_email(case) SupportEmailService().send_support_status_update_email(case)
@@ -47,14 +47,14 @@ class SupportCaseViewSet(viewsets.ModelViewSet):
serializer = SupportMessageSerializer(data=request.data) serializer = SupportMessageSerializer(data=request.data)
if serializer.is_valid(): if serializer.is_valid():
message = serializer.save(user=request.user, support_case=support_case) message = serializer.save(user=request.user, support_case=support_case)
# Send email notifications # Send email notifications
email_service = SupportEmailService() email_service = SupportEmailService()
if request.user.user_type == "support_agent": if request.user.user_type == "support_agent":
email_service.send_support_response_email(support_case) email_service.send_support_response_email(support_case)
else: else:
email_service.send_support_case_updated_email(support_case) email_service.send_support_case_updated_email(support_case)
return Response(serializer.data) return Response(serializer.data)
return Response(serializer.errors, status=400) return Response(serializer.errors, status=400)

View File

@@ -34,8 +34,9 @@ class UserRegisterView(generics.CreateAPIView):
# If the user is a vendor, create the associated Vendor model # If the user is a vendor, create the associated Vendor model
from core.models.vendor import Vendor from core.models.vendor import Vendor
vendor_type = self.request.data.get('vendor_type')
if user.user_type == 'vendor' and vendor_type: vendor_type = self.request.data.get("vendor_type")
if user.user_type == "vendor" and vendor_type:
# Create Vendor with basic info; business_name defaults to user's name # Create Vendor with basic info; business_name defaults to user's name
Vendor.objects.create( Vendor.objects.create(
user=user, user=user,
@@ -43,10 +44,9 @@ class UserRegisterView(generics.CreateAPIView):
business_type=vendor_type, business_type=vendor_type,
) )
# Generate activation link (placeholder) # Send registration email with OTC
activation_link = "http://your-frontend-url.com/activate/"
try: try:
EmailService.send_registration_email(user, activation_link) EmailService.send_registration_email(user)
except Exception as e: except Exception as e:
print(e) print(e)
@@ -125,3 +125,53 @@ class PasswordResetConfirmView(APIView):
status=status.HTTP_200_OK, status=status.HTTP_200_OK,
) )
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
class CheckPasscodeView(APIView):
permission_classes = [permissions.AllowAny]
def post(self, request):
email = request.data.get("email")
code = request.data.get("code")
if not email or not code:
return Response(
{"valid": False, "detail": "Email and code are required."},
status=status.HTTP_400_BAD_REQUEST,
)
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
return Response(
{"valid": False, "detail": "User not found."},
status=status.HTTP_404_NOT_FOUND,
)
from core.models import OneTimePasscode
try:
# Check for any valid code for this user (registration or reset)
# Or should we enforce purpose? The request didn't specify, but safer to check valid code.
# Let's check if there is a valid code.
otc = OneTimePasscode.objects.filter(
user=user, code=code, used=False
).latest("created_at")
if otc.is_valid():
if otc.purpose == "registration":
user.is_active = True
user.save()
otc.used = True
otc.save()
return Response({"valid": True}, status=status.HTTP_200_OK)
else:
return Response(
{"valid": False, "detail": "Code expired or invalid."},
status=status.HTTP_200_OK,
)
except OneTimePasscode.DoesNotExist:
return Response(
{"valid": False, "detail": "Invalid code."}, status=status.HTTP_200_OK
)

View File

@@ -233,3 +233,5 @@ EMAIL_HOST_USER = "info.aimloperations.com"
EMAIL_HOST_PASSWORD = "ZDErIII2sipNNVMz" EMAIL_HOST_PASSWORD = "ZDErIII2sipNNVMz"
EMAIL_PORT = 2525 EMAIL_PORT = 2525
EMAIL_USE_TLS = True EMAIL_USE_TLS = True
OTC_EXPIRATION_MINUTES = 30

View File

@@ -35,6 +35,7 @@ from core.views import (
PropertyDetailProxyView, PropertyDetailProxyView,
PropertyCompsProxyView, PropertyCompsProxyView,
MLSDetailProxyView, MLSDetailProxyView,
CheckPasscodeView,
) )
from django.conf import settings from django.conf import settings
from django.conf.urls.static import static from django.conf.urls.static import static
@@ -74,6 +75,11 @@ urlpatterns = [
PasswordResetConfirmView.as_view(), PasswordResetConfirmView.as_view(),
name="password_reset_confirm", name="password_reset_confirm",
), ),
path(
"api/check-passcode/",
CheckPasscodeView.as_view(),
name="check_passcode",
),
# API endpoints # API endpoints
path("api/attorney/", include("core.urls.attorney")), path("api/attorney/", include("core.urls.attorney")),
path("api/document/", include("core.urls.document")), path("api/document/", include("core.urls.document")),