From c0e4fa26ef3a17b5a51b3b753c0ccf3ed03a518a Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Fri, 12 Dec 2025 09:43:47 -0600 Subject: [PATCH] closes #9 --- dta_service/core/consumers.py | 26 ++--- dta_service/core/filters.py | 61 ++++++++--- .../0034_create_default_attorney.py | 20 ++-- ...ment_type_alter_user_is_active_and_more.py | 103 ++++++++++++++++++ dta_service/core/models/__init__.py | 19 +++- dta_service/core/models/document.py | 7 +- dta_service/core/models/user.py | 22 +++- dta_service/core/routing.py | 2 +- dta_service/core/serializers/property.py | 8 +- dta_service/core/serializers/user.py | 42 ++++--- dta_service/core/services/document_service.py | 71 +++++++----- dta_service/core/services/email/base.py | 7 +- dta_service/core/services/email/bid.py | 12 +- dta_service/core/services/email/document.py | 10 +- dta_service/core/services/email/support.py | 58 +++++----- dta_service/core/services/email/user.py | 38 +++++-- dta_service/core/services/email_service.py | 6 +- dta_service/core/services/llm_service.py | 12 +- .../core/tests/test_document_service.py | 83 ++++++++++---- .../core/tests/test_document_signing.py | 72 ++++++++---- .../test_property_location_restriction.py | 42 ++++--- .../core/tests/test_property_serializer.py | 51 ++++++--- dta_service/core/tests/test_support_case.py | 49 ++++++--- .../core/tests/test_user_security_otc.py | 89 +++++++++++++++ dta_service/core/urls/bid.py | 5 +- dta_service/core/urls/property.py | 4 +- dta_service/core/urls/property_save.py | 4 +- dta_service/core/views/__init__.py | 5 +- dta_service/core/views/document.py | 30 +++-- dta_service/core/views/property.py | 24 ++-- dta_service/core/views/support.py | 6 +- dta_service/core/views/user.py | 60 +++++++++- dta_service/dta_service/settings.py | 2 + dta_service/dta_service/urls.py | 6 + 34 files changed, 793 insertions(+), 263 deletions(-) create mode 100644 dta_service/core/migrations/0035_alter_document_document_type_alter_user_is_active_and_more.py create mode 100644 dta_service/core/tests/test_user_security_otc.py diff --git a/dta_service/core/consumers.py b/dta_service/core/consumers.py index 271c292..d18712e 100644 --- a/dta_service/core/consumers.py +++ b/dta_service/core/consumers.py @@ -33,7 +33,6 @@ class ChatConsumer(AsyncWebsocketConsumer): # return # - # await self.channel_layer.group_add( # self.account_group_name, self.account_id # ) @@ -44,7 +43,7 @@ class ChatConsumer(AsyncWebsocketConsumer): await self.close() async def disconnect(self, close_code): - if (self.channel_layer): + if self.channel_layer: await self.channel_layer.group_discard( self.account_group_name, self.account_id ) @@ -57,29 +56,26 @@ class ChatConsumer(AsyncWebsocketConsumer): 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]) if moderation_result == ModerationLabel.NSFW: - await self.send('BEGINING_OF_THE_WORLD') - await self.send(str('Try again')) - await self.send('END_OF_THE_WORLD') + await self.send("BEGINING_OF_THE_WORLD") + await self.send(str("Try again")) + await self.send("END_OF_THE_WORLD") - - await self.send('BEGINING_OF_THE_WORLD') + await self.send("BEGINING_OF_THE_WORLD") service = AsyncLLMService() - response = '' + response = "" # get the account to add to the prompt - print('generating') - async for chunk in service.generate_response( - messages, self.user - ): + print("generating") + async for chunk in service.generate_response(messages, self.user): response += chunk await self.send(chunk) print(response) - await self.send('END_OF_THE_WORLD') + await self.send("END_OF_THE_WORLD") # # Save message to database # conversation = await self.get_conversation(self.conversation_id) @@ -94,8 +90,6 @@ class ChatConsumer(AsyncWebsocketConsumer): # {"type": "chat_message", "message": serializer.data}, # ) - - async def chat_message(self, event): message = event["message"] diff --git a/dta_service/core/filters.py b/dta_service/core/filters.py index 51fd275..786773d 100644 --- a/dta_service/core/filters.py +++ b/dta_service/core/filters.py @@ -3,22 +3,42 @@ from .models import Property, Vendor from django.db.models import QuerySet from .utils import haversine_distance + class PropertyFilterSet(django_filters.FilterSet): - address = django_filters.CharFilter(field_name='address', lookup_expr='icontains') - city = django_filters.CharFilter(field_name='city', 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') - min_num_bedrooms = django_filters.NumberFilter(field_name='num_bedrooms', lookup_expr='gte') - max_num_bedrooms = django_filters.NumberFilter(field_name='num_bedrooms', 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') + address = django_filters.CharFilter(field_name="address", lookup_expr="icontains") + city = django_filters.CharFilter(field_name="city", 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") + min_num_bedrooms = django_filters.NumberFilter( + field_name="num_bedrooms", lookup_expr="gte" + ) + max_num_bedrooms = django_filters.NumberFilter( + field_name="num_bedrooms", 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: model = Property - fields = ['address', 'city', 'state', 'zip_code', 'min_num_bedrooms', 'max_num_bedrooms', - 'min_num_bathrooms', 'max_num_bathrooms', 'min_sq_ft', 'max_sq_ft'] + fields = [ + "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): def filter(self, qs: QuerySet, value: str) -> QuerySet: @@ -26,7 +46,7 @@ class DistanceFilter(django_filters.Filter): if not value: return qs try: - property_id, distance_str = value.split(',') + property_id, distance_str = value.split(",") property_id = int(property_id) distance_miles = float(distance_str) except (ValueError, IndexError): @@ -35,9 +55,12 @@ class DistanceFilter(django_filters.Filter): try: # Import Property model here to avoid circular imports from .models import Property + # Ensure the requesting user owns the property 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: return qs.none() @@ -50,7 +73,12 @@ class DistanceFilter(django_filters.Filter): vendor_pks = [] for vendor in qs: 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: vendor_pks.append(vendor.pk) @@ -59,6 +87,7 @@ class DistanceFilter(django_filters.Filter): except Property.DoesNotExist: return qs.none() + class VendorFilterSet(django_filters.FilterSet): # Your existing filters business_type = django_filters.ChoiceFilter(choices=Vendor.BUSINESS_TYPES) @@ -68,4 +97,4 @@ class VendorFilterSet(django_filters.FilterSet): class Meta: model = Vendor - fields = ['business_type', 'distance_from_property'] + fields = ["business_type", "distance_from_property"] diff --git a/dta_service/core/migrations/0034_create_default_attorney.py b/dta_service/core/migrations/0034_create_default_attorney.py index 933dd91..b43d8ec 100644 --- a/dta_service/core/migrations/0034_create_default_attorney.py +++ b/dta_service/core/migrations/0034_create_default_attorney.py @@ -1,10 +1,11 @@ from django.db import migrations from django.contrib.auth.hashers import make_password + def create_default_attorney(apps, schema_editor): - User = apps.get_model('core', 'User') - Attorney = apps.get_model('core', 'Attorney') - + User = apps.get_model("core", "User") + Attorney = apps.get_model("core", "Attorney") + # Create User user, created = User.objects.get_or_create( email="ryan@relawfirm", @@ -13,10 +14,10 @@ def create_default_attorney(apps, schema_editor): "first_name": "Ryan", "last_name": "Attorney", "user_type": "attorney", - "is_active": True - } + "is_active": True, + }, ) - + if not created: user.set_password("asdfuweoriasdgn") user.save() @@ -30,14 +31,15 @@ def create_default_attorney(apps, schema_editor): "address": "505 W Main St Suite A", "city": "St. Charles", "state": "IL", - "zip_code": "60174" - } + "zip_code": "60174", + }, ) + class Migration(migrations.Migration): dependencies = [ - ('core', '0033_alter_document_document_type_and_more'), + ("core", "0033_alter_document_document_type_and_more"), ] operations = [ diff --git a/dta_service/core/migrations/0035_alter_document_document_type_alter_user_is_active_and_more.py b/dta_service/core/migrations/0035_alter_document_document_type_alter_user_is_active_and_more.py new file mode 100644 index 0000000..88624a7 --- /dev/null +++ b/dta_service/core/migrations/0035_alter_document_document_type_alter_user_is_active_and_more.py @@ -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, + ), + ), + ], + ), + ] diff --git a/dta_service/core/models/__init__.py b/dta_service/core/models/__init__.py index 8c84a70..6ae1380 100644 --- a/dta_service/core/models/__init__.py +++ b/dta_service/core/models/__init__.py @@ -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 .vendor import Vendor, VendorPictures from .attorney import Attorney from .real_estate_agent import RealEstateAgent from .support_agent import SupportAgent 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 .conversation import Conversation, Message, message_file_path 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 diff --git a/dta_service/core/models/document.py b/dta_service/core/models/document.py index 1641518..94341b4 100644 --- a/dta_service/core/models/document.py +++ b/dta_service/core/models/document.py @@ -136,7 +136,10 @@ class AttorneyEngagementLetter(models.Model): primary_key=True, ) 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) accepted_at = models.DateTimeField(null=True, blank=True) @@ -157,7 +160,7 @@ class LendorFinancingAgreement(models.Model): on_delete=models.CASCADE, related_name="financing_agreements", null=True, - blank=True + blank=True, ) is_signed = models.BooleanField(default=False) date_signed = models.DateTimeField(null=True, blank=True) diff --git a/dta_service/core/models/user.py b/dta_service/core/models/user.py index 21493eb..d8bb4aa 100644 --- a/dta_service/core/models/user.py +++ b/dta_service/core/models/user.py @@ -48,7 +48,7 @@ class User(AbstractBaseUser, PermissionsMixin): first_name = models.CharField(max_length=30) last_name = models.CharField(max_length=30) 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) date_joined = models.DateTimeField(default=timezone.now) tos_signed = models.BooleanField(default=False) @@ -107,3 +107,23 @@ class PasswordResetToken(models.Model): def __str__(self): 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}" diff --git a/dta_service/core/routing.py b/dta_service/core/routing.py index 948c79e..0a07049 100644 --- a/dta_service/core/routing.py +++ b/dta_service/core/routing.py @@ -3,5 +3,5 @@ from . import consumers websocket_urlpatterns = [ re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), - #re_path(r"ws/chat/$", consumers.ChatConsumer.as_asgi()), + # re_path(r"ws/chat/$", consumers.ChatConsumer.as_asgi()), ] diff --git a/dta_service/core/serializers/property.py b/dta_service/core/serializers/property.py index 07b82c2..64a96cf 100644 --- a/dta_service/core/serializers/property.py +++ b/dta_service/core/serializers/property.py @@ -192,7 +192,7 @@ class PropertyRequestSerializer(serializers.ModelSerializer): property_instance=property_instance, file=None, uploaded_by=user, - description="Automatically created Lendor Financing Agreement" + description="Automatically created Lendor Financing Agreement", ) sale_infos = [] @@ -246,8 +246,10 @@ class PropertyRequestSerializer(serializers.ModelSerializer): ) if walk_score_data: - walk_score_instance, created = PropertyWalkScoreInfo.objects.update_or_create( - property=instance, defaults=walk_score_data + walk_score_instance, created = ( + 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 diff --git a/dta_service/core/serializers/user.py b/dta_service/core/serializers/user.py index 52ee259..9359799 100644 --- a/dta_service/core/serializers/user.py +++ b/dta_service/core/serializers/user.py @@ -106,27 +106,37 @@ class PasswordResetRequestSerializer(serializers.Serializer): return value def save(self): + from core.services.email.user import UserEmailService + user = User.objects.get(email=self.validated_data["email"]) - expires_at = datetime.now() + timedelta(hours=24) - token = PasswordResetToken.objects.create(user=user, expires_at=expires_at) - token.send_reset_email() - return token + UserEmailService().send_password_reset_email(user) + return user class PasswordResetConfirmSerializer(serializers.Serializer): - token = serializers.UUIDField() + email = serializers.EmailField() + code = serializers.CharField(max_length=6) new_password = serializers.CharField(write_only=True) new_password2 = serializers.CharField(write_only=True) def validate(self, attrs): try: - token = PasswordResetToken.objects.get(token=attrs["token"]) - except PasswordResetToken.DoesNotExist: - raise serializers.ValidationError({"token": "Invalid token."}) + user = User.objects.get(email=attrs["email"]) + except User.DoesNotExist: + 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( - {"token": "Token is invalid or has expired."} + {"code": "Code is invalid or has expired."} ) if attrs["new_password"] != attrs["new_password2"]: @@ -134,13 +144,17 @@ class PasswordResetConfirmSerializer(serializers.Serializer): {"new_password": "Password fields didn't match."} ) + attrs["user"] = user + attrs["otc"] = otc return attrs def save(self): - token = PasswordResetToken.objects.get(token=self.validated_data["token"]) - user = token.user + user = self.validated_data["user"] + otc = self.validated_data["otc"] + user.set_password(self.validated_data["new_password"]) user.save() - token.used = True - token.save() + + otc.used = True + otc.save() return user diff --git a/dta_service/core/services/document_service.py b/dta_service/core/services/document_service.py index 076cfcf..ce3094b 100644 --- a/dta_service/core/services/document_service.py +++ b/dta_service/core/services/document_service.py @@ -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 + class DocumentService: @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. """ @@ -12,16 +26,18 @@ class DocumentService: document_type=document_type, file=file, uploaded_by=uploaded_by, - description=description + description=description, ) - + if shared_with: document.shared_with.set(shared_with) - + return document @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. """ @@ -30,9 +46,9 @@ class DocumentService: document_type="attorney_engagement_letter", file=file, uploaded_by=uploaded_by, - description=description + description=description, ) - + try: attorney = Attorney.objects.get(user__id=attorney_id) except Attorney.DoesNotExist: @@ -40,15 +56,14 @@ class DocumentService: # For now, we'll assume valid ID or let it fail if critical raise ValueError(f"Attorney with ID {attorney_id} not found.") - AttorneyEngagementLetter.objects.create( - document=document, - attorney=attorney - ) - + AttorneyEngagementLetter.objects.create(document=document, attorney=attorney) + return document @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. """ @@ -57,13 +72,11 @@ class DocumentService: document_type="lendor_financing_agreement", file=file, uploaded_by=uploaded_by, - description=description + description=description, ) - LendorFinancingAgreement.objects.create( - document=document - ) - + LendorFinancingAgreement.objects.create(document=document) + return document @staticmethod @@ -75,26 +88,28 @@ class DocumentService: # Find all engagement letters for this property engagement_letters = AttorneyEngagementLetter.objects.filter( document__property=property_instance, - document__document_type="attorney_engagement_letter" + document__document_type="attorney_engagement_letter", ) - + if not engagement_letters.exists(): # Create one with default attorney try: 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. # 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( property_instance=property_instance, - file=None, # No file initially + file=None, # No file initially uploaded_by=uploaded_by, 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: # If default attorney doesn't exist (e.g. migration didn't run or test env), # we can't create it. Just return False. @@ -104,5 +119,5 @@ class DocumentService: for letter in engagement_letters: if letter.is_accepted: return True - + return False diff --git a/dta_service/core/services/email/base.py b/dta_service/core/services/email/base.py index 96e89c7..febe942 100644 --- a/dta_service/core/services/email/base.py +++ b/dta_service/core/services/email/base.py @@ -1,11 +1,14 @@ from django.core.mail import EmailMultiAlternatives from django.template.loader import get_template + class BaseEmailService: def __init__(self): self.from_email: str = "info@ditchtheagent.com" - def send_email(self, subject: str, template_name: str, context: dict, to_email: str | list[str]) -> None: + def send_email( + self, subject: str, template_name: str, context: dict, to_email: str | list[str] + ) -> None: # NOTE: to_email can be a singular address (str) or a list of emails (list) # TODO: make a text version of each email html_content = get_template(f"emails/{template_name}.html").render(context) @@ -14,7 +17,7 @@ class BaseEmailService: except Exception: # Fallback if text template doesn't exist, though ideally it should text_content = "" - + to = [to_email] if isinstance(to_email, str) else to_email msg = EmailMultiAlternatives(subject, text_content, self.from_email, to) msg.attach_alternative(html_content, "text/html") diff --git a/dta_service/core/services/email/bid.py b/dta_service/core/services/email/bid.py index 40c3781..0ae3ad3 100644 --- a/dta_service/core/services/email/bid.py +++ b/dta_service/core/services/email/bid.py @@ -1,15 +1,21 @@ from core.services.email.base import BaseEmailService from core.models import Bid, Vendor + class BidEmailService(BaseEmailService): def send_new_bid_email(self, bid: Bid, vendors: list[Vendor]) -> None: context = {"bid_title": bid.bid_type} - # NOTE: The original code fetched all vendors, ignoring the passed 'vendors' list. + # NOTE: The original code fetched all vendors, ignoring the passed 'vendors' list. # I will keep the original logic but it might be a bug or intended. # Original: emails = Vendor.objects.values_list('user__email', flat=True) - emails = Vendor.objects.values_list('user__email', flat=True) + emails = Vendor.objects.values_list("user__email", flat=True) self.send_email("New bid available", "new_bid_email", context, list(emails)) def send_bid_response_email(self, bid: Bid) -> None: context = {} - self.send_email("New bid response", "bid_response", context, bid.property.property_owner.user.email) + self.send_email( + "New bid response", + "bid_response", + context, + bid.property.property_owner.user.email, + ) diff --git a/dta_service/core/services/email/document.py b/dta_service/core/services/email/document.py index a896cd4..df00d47 100644 --- a/dta_service/core/services/email/document.py +++ b/dta_service/core/services/email/document.py @@ -1,12 +1,18 @@ from core.services.email.base import BaseEmailService from core.models import User, OfferDocument + class DocumentEmailService(BaseEmailService): def send_document_shared_email(self, users: list[User]) -> None: # NOTE: Original code fetched all users, ignoring 'users' arg. Keeping logic. - emails = User.objects.values_list('email', flat=True) + emails = User.objects.values_list("email", flat=True) 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: pass diff --git a/dta_service/core/services/email/support.py b/dta_service/core/services/email/support.py index 5672e91..2babf41 100644 --- a/dta_service/core/services/email/support.py +++ b/dta_service/core/services/email/support.py @@ -1,67 +1,65 @@ from core.services.email.base import BaseEmailService from core.models import SupportCase, User + class SupportEmailService(BaseEmailService): def send_support_case_created_email(self, case: SupportCase) -> None: # Email all support agents - support_agents = User.objects.filter(user_type='support_agent').values_list('email', flat=True) + support_agents = User.objects.filter(user_type="support_agent").values_list( + "email", flat=True + ) if not support_agents: return - + context = { "case_id": case.id, "title": case.title, "user_email": case.user.email, - "description": case.description + "description": case.description, } self.send_email( - f"New Support Case #{case.id}", - "support_case_created", - context, - list(support_agents) + f"New Support Case #{case.id}", + "support_case_created", + context, + list(support_agents), ) def send_support_case_updated_email(self, case: SupportCase) -> None: # Email all support agents when user replies - support_agents = User.objects.filter(user_type='support_agent').values_list('email', flat=True) + support_agents = User.objects.filter(user_type="support_agent").values_list( + "email", flat=True + ) if not support_agents: return context = { "case_id": case.id, "title": case.title, - "user_email": case.user.email + "user_email": case.user.email, } self.send_email( - f"Update on Support Case #{case.id}", - "support_case_updated", - context, - list(support_agents) + f"Update on Support Case #{case.id}", + "support_case_updated", + context, + list(support_agents), ) def send_support_response_email(self, case: SupportCase) -> None: # Email the user when support agent replies - context = { - "case_id": case.id, - "title": case.title - } + context = {"case_id": case.id, "title": case.title} self.send_email( - f"New Response on Support Case #{case.id}", - "support_response", - context, - case.user.email + f"New Response on Support Case #{case.id}", + "support_response", + context, + case.user.email, ) def send_support_status_update_email(self, case: SupportCase) -> None: # Email the user when status changes - context = { - "case_id": case.id, - "title": case.title, - "status": case.status - } + context = {"case_id": case.id, "title": case.title, "status": case.status} self.send_email( - f"Status Update on Support Case #{case.id}", - "support_status_update", - context, - case.user.email + f"Status Update on Support Case #{case.id}", + "support_status_update", + context, + case.user.email, ) diff --git a/dta_service/core/services/email/user.py b/dta_service/core/services/email/user.py index 73791b8..f39b041 100644 --- a/dta_service/core/services/email/user.py +++ b/dta_service/core/services/email/user.py @@ -1,22 +1,46 @@ 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): - def send_registration_email(self, user: User, activation_link: str) -> None: - print('Sending a registration email') + def _generate_otc(self, user: User, purpose: str) -> str: + 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 = { "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: - 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) def send_password_change_email(self, user: User) -> None: 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: pass diff --git a/dta_service/core/services/email_service.py b/dta_service/core/services/email_service.py index 2742dc2..24a2c1c 100644 --- a/dta_service/core/services/email_service.py +++ b/dta_service/core/services/email_service.py @@ -3,9 +3,13 @@ from core.services.email.bid import BidEmailService from core.services.email.document import DocumentEmailService 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. This maintains backward compatibility with existing code. """ + pass diff --git a/dta_service/core/services/llm_service.py b/dta_service/core/services/llm_service.py index 61e77de..3b0cc33 100644 --- a/dta_service/core/services/llm_service.py +++ b/dta_service/core/services/llm_service.py @@ -8,6 +8,7 @@ from langchain_core.prompts import ChatPromptTemplate from core.models import User + class LLMService(ABC): """sd;ofisdf""" @@ -21,6 +22,7 @@ class LLMService(ABC): num_ctx=4096, ) self.output_parser = StrOutputParser() + @abstractmethod def generate_response(self, query: str, **kwargs): """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 # ) + class AsyncLLMService(LLMService): """Asynchronous LLM conversation service.""" @@ -109,7 +112,12 @@ class AsyncLLMService(LLMService): # return "\n".join( # 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 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]) async def generate_response( - self, conversation: list[dict[str,str]], user: User + self, conversation: list[dict[str, str]], user: User ) -> AsyncGenerator[str, None]: """Generate response with async streaming support.""" chain_input = { diff --git a/dta_service/core/tests/test_document_service.py b/dta_service/core/tests/test_document_service.py index 12adac7..5b2695b 100644 --- a/dta_service/core/tests/test_document_service.py +++ b/dta_service/core/tests/test_document_service.py @@ -1,19 +1,58 @@ 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 django.utils import timezone + class DocumentServiceTests(TestCase): def setUp(self): - self.user = User.objects.create(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.user = User.objects.create( + 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.attorney_user = User.objects.create(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.attorney_user = User.objects.create( + 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 - self.default_attorney_user = User.objects.create(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.default_attorney_user = User.objects.create( + 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( owner=self.owner, @@ -22,7 +61,7 @@ class DocumentServiceTests(TestCase): state="CA", zip_code="12345", market_value=100000, - realestate_api_id=123 + realestate_api_id=123, ) def test_create_attorney_engagement_letter(self): @@ -31,35 +70,41 @@ class DocumentServiceTests(TestCase): file=None, uploaded_by=self.owner_user, attorney_id=self.attorney_user.id, - description="Engagement Letter" + description="Engagement Letter", ) - + self.assertEqual(document.document_type, "attorney_engagement_letter") 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) def test_check_engagement_letter_accepted(self): # 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 engagement_letters = AttorneyEngagementLetter.objects.filter( document__property=self.property, - document__document_type="attorney_engagement_letter" + document__document_type="attorney_engagement_letter", ) self.assertTrue(engagement_letters.exists()) self.assertEqual(engagement_letters.count(), 1) self.assertEqual(engagement_letters.first().attorney, self.default_attorney) - + # Still not accepted - self.assertFalse(DocumentService.check_engagement_letter_accepted(self.property)) - + self.assertFalse( + DocumentService.check_engagement_letter_accepted(self.property) + ) + # Accept letter letter = engagement_letters.first() letter.is_accepted = True letter.accepted_at = timezone.now() letter.save() - + # Should be accepted now self.assertTrue(DocumentService.check_engagement_letter_accepted(self.property)) diff --git a/dta_service/core/tests/test_document_signing.py b/dta_service/core/tests/test_document_signing.py index b5836a5..9277386 100644 --- a/dta_service/core/tests/test_document_signing.py +++ b/dta_service/core/tests/test_document_signing.py @@ -1,20 +1,45 @@ from django.test import TestCase from rest_framework.test import APIClient 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 django.utils import timezone + class DocumentSigningTests(TestCase): def setUp(self): 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.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 = Attorney.objects.create(user=self.attorney_user, firm_name="Test Firm", address="123 Law St", city="Lawville", state="CA", zip_code="90210") - + self.attorney_user = User.objects.create( + 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( owner=self.owner, address="123 Test St", @@ -22,23 +47,25 @@ class DocumentSigningTests(TestCase): state="CA", zip_code="12345", market_value=100000, - realestate_api_id=123 + realestate_api_id=123, ) - + self.document = DocumentService.create_attorney_engagement_letter( property_instance=self.property, file=None, uploaded_by=self.user, - attorney_id=self.attorney_user.id + attorney_id=self.attorney_user.id, ) def test_sign_engagement_letter(self): url = f"/api/documents/{self.document.id}/sign/" response = self.client.post(url) - + 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.assertTrue(self.document.attorney_engagement_letter_data.is_accepted) self.assertIsNotNone(self.document.attorney_engagement_letter_data.accepted_at) @@ -49,23 +76,26 @@ class DocumentSigningTests(TestCase): letter.is_accepted = True letter.accepted_at = timezone.now() letter.save() - + url = f"/api/documents/{self.document.id}/sign/" response = self.client.post(url) - + 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): # Create a generic document other_doc = Document.objects.create( - property=self.property, - document_type="other", - uploaded_by=self.user + property=self.property, document_type="other", uploaded_by=self.user ) - + url = f"/api/documents/{other_doc.id}/sign/" response = self.client.post(url) - + 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.", + ) diff --git a/dta_service/core/tests/test_property_location_restriction.py b/dta_service/core/tests/test_property_location_restriction.py index 3b48495..7d9ab0b 100644 --- a/dta_service/core/tests/test_property_location_restriction.py +++ b/dta_service/core/tests/test_property_location_restriction.py @@ -4,10 +4,16 @@ from rest_framework import status from core.models import Property, PropertyOwner, User from unittest.mock import patch + class PropertyLocationRestrictionTests(TestCase): def setUp(self): 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.client.force_authenticate(user=self.user) @@ -19,7 +25,7 @@ class PropertyLocationRestrictionTests(TestCase): zip_code="60601", market_value=100000, realestate_api_id=1, - property_status="off_market" + property_status="off_market", ) self.ny_property = Property.objects.create( @@ -30,30 +36,38 @@ class PropertyLocationRestrictionTests(TestCase): zip_code="10001", market_value=200000, realestate_api_id=2, - property_status="off_market" + property_status="off_market", ) def test_activate_il_property_success(self): # 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( - f'/api/properties/{self.il_property.id}/', - {'property_status': 'active'}, - format='json' + f"/api/properties/{self.il_property.id}/", + {"property_status": "active"}, + format="json", ) self.assertEqual(response.status_code, status.HTTP_200_OK) 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): # 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( - f'/api/properties/{self.ny_property.id}/', - {'property_status': 'active'}, - format='json' + f"/api/properties/{self.ny_property.id}/", + {"property_status": "active"}, + format="json", ) 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.assertEqual(self.ny_property.property_status, 'off_market') + self.assertEqual(self.ny_property.property_status, "off_market") diff --git a/dta_service/core/tests/test_property_serializer.py b/dta_service/core/tests/test_property_serializer.py index 4adaef3..d679715 100644 --- a/dta_service/core/tests/test_property_serializer.py +++ b/dta_service/core/tests/test_property_serializer.py @@ -5,13 +5,31 @@ from core.serializers.property import PropertyRequestSerializer from core.services.document_service import DocumentService from django.utils import timezone + class PropertySerializerValidationTests(TestCase): 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.attorney_user = User.objects.create(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.attorney_user = User.objects.create( + 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( owner=self.owner, address="123 Test St", @@ -20,17 +38,22 @@ class PropertySerializerValidationTests(TestCase): zip_code="12345", market_value=100000, realestate_api_id=123, - property_status="off_market" + property_status="off_market", ) 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()) - + with self.assertRaises(ValidationError) as cm: 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): # Create and accept letter @@ -38,16 +61,18 @@ class PropertySerializerValidationTests(TestCase): property_instance=self.property, file=None, uploaded_by=self.owner_user, - attorney_id=self.attorney_user.id + attorney_id=self.attorney_user.id, ) letter = document.attorney_engagement_letter_data letter.is_accepted = True letter.accepted_at = timezone.now() 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()) updated_property = serializer.save() - + self.assertEqual(updated_property.property_status, "active") self.assertIsNotNone(updated_property.listed_date) diff --git a/dta_service/core/tests/test_support_case.py b/dta_service/core/tests/test_support_case.py index 8bd53f8..b8896ec 100644 --- a/dta_service/core/tests/test_support_case.py +++ b/dta_service/core/tests/test_support_case.py @@ -7,60 +7,75 @@ from unittest.mock import patch User = get_user_model() + class SupportCaseTests(TestCase): def setUp(self): self.client = APIClient() - self.user = User.objects.create_user(email='user@example.com', password='password', user_type='property_owner') - self.support_agent = User.objects.create_user(email='agent@example.com', password='password', user_type='support_agent') - self.other_user = User.objects.create_user(email='other@example.com', password='password', user_type='property_owner') + self.user = User.objects.create_user( + email="user@example.com", password="password", user_type="property_owner" + ) + self.support_agent = User.objects.create_user( + email="agent@example.com", password="password", user_type="support_agent" + ) + self.other_user = User.objects.create_user( + email="other@example.com", password="password", user_type="property_owner" + ) - self.case1 = SupportCase.objects.create(user=self.user, title="Case 1", description="Desc 1") - self.case2 = SupportCase.objects.create(user=self.other_user, title="Case 2", description="Desc 2") + self.case1 = SupportCase.objects.create( + user=self.user, title="Case 1", description="Desc 1" + ) + self.case2 = SupportCase.objects.create( + user=self.other_user, title="Case 2", description="Desc 2" + ) def test_user_can_only_see_own_cases(self): self.client.force_authenticate(user=self.user) - response = self.client.get('/api/support/cases/') + response = self.client.get("/api/support/cases/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 1) - self.assertEqual(response.data[0]['id'], self.case1.id) + self.assertEqual(response.data[0]["id"], self.case1.id) def test_support_agent_can_see_all_cases(self): self.client.force_authenticate(user=self.support_agent) - response = self.client.get('/api/support/cases/') + response = self.client.get("/api/support/cases/") self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data), 2) - @patch('core.services.email.base.EmailMultiAlternatives.send') + @patch("core.services.email.base.EmailMultiAlternatives.send") def test_create_case_sends_email_to_support_agents(self, mock_send): self.client.force_authenticate(user=self.user) data = {"title": "New Case", "description": "Help me", "category": "question"} - response = self.client.post('/api/support/cases/', data) + response = self.client.post("/api/support/cases/", data) self.assertEqual(response.status_code, status.HTTP_201_CREATED) - + # Verify email sent self.assertTrue(mock_send.called) # We can inspect call args if needed, but verifying it was called is a good start - @patch('core.services.email.base.EmailMultiAlternatives.send') + @patch("core.services.email.base.EmailMultiAlternatives.send") def test_user_reply_sends_email_to_support_agents(self, mock_send): self.client.force_authenticate(user=self.user) data = {"text": "User reply"} - response = self.client.post(f'/api/support/cases/{self.case1.id}/add_message/', data) + response = self.client.post( + f"/api/support/cases/{self.case1.id}/add_message/", data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(mock_send.called) - @patch('core.services.email.base.EmailMultiAlternatives.send') + @patch("core.services.email.base.EmailMultiAlternatives.send") def test_agent_reply_sends_email_to_user(self, mock_send): self.client.force_authenticate(user=self.support_agent) data = {"text": "Agent reply"} - response = self.client.post(f'/api/support/cases/{self.case1.id}/add_message/', data) + response = self.client.post( + f"/api/support/cases/{self.case1.id}/add_message/", data + ) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(mock_send.called) - @patch('core.services.email.base.EmailMultiAlternatives.send') + @patch("core.services.email.base.EmailMultiAlternatives.send") def test_status_update_sends_email_to_user(self, mock_send): self.client.force_authenticate(user=self.support_agent) data = {"status": "closed"} - response = self.client.patch(f'/api/support/cases/{self.case1.id}/', data) + response = self.client.patch(f"/api/support/cases/{self.case1.id}/", data) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(mock_send.called) diff --git a/dta_service/core/tests/test_user_security_otc.py b/dta_service/core/tests/test_user_security_otc.py new file mode 100644 index 0000000..6b4f384 --- /dev/null +++ b/dta_service/core/tests/test_user_security_otc.py @@ -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()) diff --git a/dta_service/core/urls/bid.py b/dta_service/core/urls/bid.py index 8aa65f8..664ec0b 100644 --- a/dta_service/core/urls/bid.py +++ b/dta_service/core/urls/bid.py @@ -1,8 +1,9 @@ from rest_framework.routers import DefaultRouter from core.views import BidViewSet, BidResponseViewSet + # Create a router and register our viewsets with it. router = DefaultRouter() -router.register(r'bids', BidViewSet, basename='bids') -router.register(r'bid-responses', BidResponseViewSet, basename='bid-responses') +router.register(r"bids", BidViewSet, basename="bids") +router.register(r"bid-responses", BidResponseViewSet, basename="bid-responses") urlpatterns = router.urls diff --git a/dta_service/core/urls/property.py b/dta_service/core/urls/property.py index 7402c41..7af6dac 100644 --- a/dta_service/core/urls/property.py +++ b/dta_service/core/urls/property.py @@ -1,10 +1,10 @@ from django.urls import path from rest_framework.routers import DefaultRouter -from core.views import PropertyViewSet, PropertyPictureViewSet,OpenHouseViewSet +from core.views import PropertyViewSet, PropertyPictureViewSet, OpenHouseViewSet router = DefaultRouter() 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") diff --git a/dta_service/core/urls/property_save.py b/dta_service/core/urls/property_save.py index a2dacba..cd8e58a 100644 --- a/dta_service/core/urls/property_save.py +++ b/dta_service/core/urls/property_save.py @@ -4,8 +4,8 @@ from rest_framework.routers import DefaultRouter from core.views import PropertySaveViewSet router = DefaultRouter() -router.register(r'', PropertySaveViewSet, basename='propertysave') +router.register(r"", PropertySaveViewSet, basename="propertysave") urlpatterns = [ - path('', include(router.urls)), + path("", include(router.urls)), ] diff --git a/dta_service/core/views/__init__.py b/dta_service/core/views/__init__.py index 85c7134..3a23bda 100644 --- a/dta_service/core/views/__init__.py +++ b/dta_service/core/views/__init__.py @@ -6,6 +6,7 @@ from .user import ( LogoutView, PasswordResetRequestView, PasswordResetConfirmView, + CheckPasscodeView, ) from .property_owner import PropertyOwnerViewSet from .vendor import VendorViewSet @@ -17,8 +18,10 @@ from .property import ( PropertyPictureViewSet, PropertyDescriptionView, PropertySaveViewSet, - PropertDetailProxyView, + PropertyDetailProxyView, AutoCompleteProxyView, + PropertyCompsProxyView, + MLSDetailProxyView, ) from .property_info import OpenHouseViewSet from .video import ( diff --git a/dta_service/core/views/document.py b/dta_service/core/views/document.py index d7d3814..8935f96 100644 --- a/dta_service/core/views/document.py +++ b/dta_service/core/views/document.py @@ -98,7 +98,7 @@ class DocumentViewSet(viewsets.ModelViewSet): status=status.HTTP_200_OK, ) except AttorneyEngagementLetter.DoesNotExist: - return Response( + return Response( {"detail": "Attorney Engagement Letter data not found."}, status=status.HTTP_404_NOT_FOUND, ) @@ -261,27 +261,30 @@ class CreateDocumentView(APIView): ) elif document_type == "lendor_financing_agreement": mutable_data["description"] = f"Financing Agreement for {property.address}" - + # Add property owner existing_shared_with = mutable_data.get("shared_with", []) if not isinstance(existing_shared_with, list): existing_shared_with = [existing_shared_with] - + if property.owner and property.owner.user: existing_shared_with.append(property.owner.user.id) - + # Add attorney if exists try: - engagement_letter = AttorneyEngagementLetter.objects.filter( - document__property=property, - is_accepted=True - ).select_related('attorney__user').first() - + engagement_letter = ( + AttorneyEngagementLetter.objects.filter( + document__property=property, is_accepted=True + ) + .select_related("attorney__user") + .first() + ) + if engagement_letter and engagement_letter.attorney: existing_shared_with.append(engagement_letter.attorney.user.id) except Exception: pass - + mutable_data["shared_with"] = existing_shared_with elif document_type == "seller_disclosure": @@ -296,10 +299,13 @@ class CreateDocumentView(APIView): existing_shared_with.append(default_attorney_user.id) else: # 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 except User.DoesNotExist: - pass # Default attorney not found, skip linking + pass # Default attorney not found, skip linking document_serializer = DocumentSerializer(data=mutable_data) diff --git a/dta_service/core/views/property.py b/dta_service/core/views/property.py index 6b47b4b..7ac57df 100644 --- a/dta_service/core/views/property.py +++ b/dta_service/core/views/property.py @@ -75,19 +75,19 @@ class PropertyViewSet(viewsets.ModelViewSet): walk_description=data.get("description"), ws_link=data.get("ws_link"), logo_url=data.get("logo_url"), - transit_score=data.get("transit").get("score") - if has_transit - else None, - transit_description=data.get("transit").get("description") - if has_transit - else None, - transit_summary=data.get("transit").get("summary") - if has_transit - else None, + transit_score=( + data.get("transit").get("score") if has_transit else None + ), + transit_description=( + data.get("transit").get("description") if has_transit else None + ), + transit_summary=( + data.get("transit").get("summary") if has_transit else None + ), bike_score=data.get("bike").get("score") if has_bike else None, - bike_description=data.get("bike").get("description") - if has_bike - else None, + bike_description=( + data.get("bike").get("description") if has_bike else None + ), ) serializer.save(owner=owner, walk_score=walk_score) diff --git a/dta_service/core/views/support.py b/dta_service/core/views/support.py index db23106..5b89bbd 100644 --- a/dta_service/core/views/support.py +++ b/dta_service/core/views/support.py @@ -36,7 +36,7 @@ class SupportCaseViewSet(viewsets.ModelViewSet): instance = self.get_object() original_status = instance.status case = serializer.save() - + if original_status != case.status: # Send status update email to user SupportEmailService().send_support_status_update_email(case) @@ -47,14 +47,14 @@ class SupportCaseViewSet(viewsets.ModelViewSet): serializer = SupportMessageSerializer(data=request.data) if serializer.is_valid(): message = serializer.save(user=request.user, support_case=support_case) - + # Send email notifications email_service = SupportEmailService() if request.user.user_type == "support_agent": email_service.send_support_response_email(support_case) else: email_service.send_support_case_updated_email(support_case) - + return Response(serializer.data) return Response(serializer.errors, status=400) diff --git a/dta_service/core/views/user.py b/dta_service/core/views/user.py index 828e7cd..5aab6bd 100644 --- a/dta_service/core/views/user.py +++ b/dta_service/core/views/user.py @@ -34,8 +34,9 @@ class UserRegisterView(generics.CreateAPIView): # If the user is a vendor, create the associated Vendor model 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 Vendor.objects.create( user=user, @@ -43,10 +44,9 @@ class UserRegisterView(generics.CreateAPIView): business_type=vendor_type, ) - # Generate activation link (placeholder) - activation_link = "http://your-frontend-url.com/activate/" + # Send registration email with OTC try: - EmailService.send_registration_email(user, activation_link) + EmailService.send_registration_email(user) except Exception as e: print(e) @@ -125,3 +125,53 @@ class PasswordResetConfirmView(APIView): status=status.HTTP_200_OK, ) 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 + ) diff --git a/dta_service/dta_service/settings.py b/dta_service/dta_service/settings.py index be0aa7e..6d7fb9d 100644 --- a/dta_service/dta_service/settings.py +++ b/dta_service/dta_service/settings.py @@ -233,3 +233,5 @@ EMAIL_HOST_USER = "info.aimloperations.com" EMAIL_HOST_PASSWORD = "ZDErIII2sipNNVMz" EMAIL_PORT = 2525 EMAIL_USE_TLS = True + +OTC_EXPIRATION_MINUTES = 30 diff --git a/dta_service/dta_service/urls.py b/dta_service/dta_service/urls.py index f97a5c4..1e58df9 100644 --- a/dta_service/dta_service/urls.py +++ b/dta_service/dta_service/urls.py @@ -35,6 +35,7 @@ from core.views import ( PropertyDetailProxyView, PropertyCompsProxyView, MLSDetailProxyView, + CheckPasscodeView, ) from django.conf import settings from django.conf.urls.static import static @@ -74,6 +75,11 @@ urlpatterns = [ PasswordResetConfirmView.as_view(), name="password_reset_confirm", ), + path( + "api/check-passcode/", + CheckPasscodeView.as_view(), + name="check_passcode", + ), # API endpoints path("api/attorney/", include("core.urls.attorney")), path("api/document/", include("core.urls.document")),