closes #9
This commit is contained in:
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
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(
|
||||||
@@ -13,8 +14,8 @@ 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:
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
|||||||
@@ -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()),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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,9 +246,11 @@ 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 = (
|
||||||
|
PropertyWalkScoreInfo.objects.update_or_create(
|
||||||
property=instance, defaults=walk_score_data
|
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
|
||||||
# (e.g., clearing old objects and creating new ones, or matching by ID)
|
# (e.g., clearing old objects and creating new ones, or matching by ID)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,7 +26,7 @@ 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:
|
||||||
@@ -21,7 +35,9 @@ class DocumentService:
|
|||||||
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,7 +46,7 @@ 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:
|
||||||
@@ -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,12 +72,10 @@ 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
|
||||||
|
|
||||||
@@ -75,7 +88,7 @@ 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():
|
||||||
@@ -85,14 +98,16 @@ class DocumentService:
|
|||||||
# 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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
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
|
||||||
|
|
||||||
@@ -12,56 +15,51 @@ class SupportEmailService(BaseEmailService):
|
|||||||
"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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,29 +70,35 @@ 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()
|
||||||
|
|||||||
@@ -1,19 +1,44 @@
|
|||||||
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,
|
||||||
@@ -22,14 +47,14 @@ 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):
|
||||||
@@ -37,7 +62,9 @@ class DocumentSigningTests(TestCase):
|
|||||||
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)
|
||||||
@@ -54,18 +81,21 @@ class DocumentSigningTests(TestCase):
|
|||||||
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.",
|
||||||
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -5,12 +5,30 @@ 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,
|
||||||
@@ -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,14 +61,16 @@ 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()
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
89
dta_service/core/tests/test_user_security_otc.py
Normal file
89
dta_service/core/tests/test_user_security_otc.py
Normal 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())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -272,10 +272,13 @@ class CreateDocumentView(APIView):
|
|||||||
|
|
||||||
# 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)
|
||||||
@@ -296,7 +299,10 @@ 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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")),
|
||||||
|
|||||||
Reference in New Issue
Block a user