From cf9773f74da59b101a69d5fa36d63099f7356b23 Mon Sep 17 00:00:00 2001 From: Ryan Westfall Date: Wed, 15 Oct 2025 15:07:24 -0500 Subject: [PATCH] checking in for production --- dta_service/core/admin.py | 238 +++++-- dta_service/core/filters.py | 54 +- dta_service/core/migrations/0024_document.py | 74 ++ ...provementreceipt_offerdocument_and_more.py | 108 +++ .../migrations/0026_alter_document_file.py | 20 + .../migrations/0027_offerdocument_status.py | 28 + .../0028_offerdocument_parent_offer.py | 25 + ..._end_time_openhouse_start_time_and_more.py | 29 + ...orpictures_alter_propertypictures_image.py | 36 + .../0031_alter_vendor_business_type.py | 57 ++ ...ter_user_user_type_supportcase_and_more.py | 140 ++++ dta_service/core/models.py | 341 ++++++++-- dta_service/core/permissions.py | 45 +- dta_service/core/public_views/__init__.py | 0 dta_service/core/public_views/public.py | 107 +++ dta_service/core/serializers.py | 585 +++++++++++++--- dta_service/core/services/email_service.py | 64 ++ .../property_description_generator.py | 36 +- .../core/templates/emails/base_template.html | 160 +++++ .../core/templates/emails/bid_response.html | 29 + .../emails/document_shared_email.html | 29 + .../core/templates/emails/new_bid_email.html | 29 + .../emails/password_change_email.html | 15 + .../emails/password_reset_email.html | 38 ++ .../emails/user_registration_email.html | 37 + .../core/templates/password_reset_email.html | 20 - dta_service/core/urls/document.py | 8 + dta_service/core/urls/property.py | 3 +- dta_service/core/urls/public.py | 12 + dta_service/core/urls/support.py | 12 + dta_service/core/urls/support_agent.py | 8 + dta_service/core/utils.py | 17 + dta_service/core/views.py | 630 +++++++++++++++--- dta_service/dta_service/settings.py | 13 +- dta_service/dta_service/urls.py | 84 ++- 35 files changed, 2779 insertions(+), 352 deletions(-) create mode 100644 dta_service/core/migrations/0024_document.py create mode 100644 dta_service/core/migrations/0025_homeimprovementreceipt_offerdocument_and_more.py create mode 100644 dta_service/core/migrations/0026_alter_document_file.py create mode 100644 dta_service/core/migrations/0027_offerdocument_status.py create mode 100644 dta_service/core/migrations/0028_offerdocument_parent_offer.py create mode 100644 dta_service/core/migrations/0029_openhouse_end_time_openhouse_start_time_and_more.py create mode 100644 dta_service/core/migrations/0030_vendorpictures_alter_propertypictures_image.py create mode 100644 dta_service/core/migrations/0031_alter_vendor_business_type.py create mode 100644 dta_service/core/migrations/0032_faq_supportagent_alter_user_user_type_supportcase_and_more.py create mode 100644 dta_service/core/public_views/__init__.py create mode 100644 dta_service/core/public_views/public.py create mode 100644 dta_service/core/services/email_service.py create mode 100644 dta_service/core/templates/emails/base_template.html create mode 100644 dta_service/core/templates/emails/bid_response.html create mode 100644 dta_service/core/templates/emails/document_shared_email.html create mode 100644 dta_service/core/templates/emails/new_bid_email.html create mode 100644 dta_service/core/templates/emails/password_change_email.html create mode 100644 dta_service/core/templates/emails/password_reset_email.html create mode 100644 dta_service/core/templates/emails/user_registration_email.html delete mode 100644 dta_service/core/templates/password_reset_email.html create mode 100644 dta_service/core/urls/document.py create mode 100644 dta_service/core/urls/public.py create mode 100644 dta_service/core/urls/support.py create mode 100644 dta_service/core/urls/support_agent.py create mode 100644 dta_service/core/utils.py diff --git a/dta_service/core/admin.py b/dta_service/core/admin.py index 2bb0213..3c24a21 100644 --- a/dta_service/core/admin.py +++ b/dta_service/core/admin.py @@ -9,14 +9,24 @@ from core.models import ( Video, UserVideoProgress, Conversation, - Offer, + OfferDocument, PropertyPictures, OpenHouse, PropertySaleInfo, PropertyTaxInfo, PropertyWalkScoreInfo, SchoolInfo, - Attorney, RealEstateAgent, UserViewModel, PropertySave + Attorney, + RealEstateAgent, + UserViewModel, + PropertySave, + Document, + SellerDisclosure, + VendorPictures, + SupportAgent, + SupportCase, + SupportMessage, + FAQ, ) @@ -58,10 +68,6 @@ class VendorAdmin(admin.ModelAdmin): ) - - - - class VideoCategoryAdmin(admin.ModelAdmin): model = VideoCategory list_display = ("name", "description") @@ -89,8 +95,8 @@ class ConversationAdmin(admin.ModelAdmin): class OfferAdmin(admin.ModelAdmin): - model = Offer - list_display = ("pk", "user", "property", "status") + model = OfferDocument + list_display = ("pk", "offer_price", "closing_days", "closing_date", "status") search_fields = ("user", "status") @@ -118,15 +124,26 @@ class PropertyWalkScoreInfoAdmin(admin.ModelAdmin): class SchoolInfoAdmin(admin.ModelAdmin): model = SchoolInfo + class PropertyWalkScoreInfoStackedInline(admin.StackedInline): model = PropertyWalkScoreInfo + class SchoolInfoStackedInline(admin.StackedInline): model = SchoolInfo + class PropertyAdmin(admin.ModelAdmin): model = Property - list_display = ("pk", "owner", "address", "city", "state", "zip_code") + list_display = ( + "pk", + "owner", + "address", + "city", + "state", + "zip_code", + "property_status", + ) search_fields = ("address", "city", "state", "zip_code", "owner") inlines = [PropertyWalkScoreInfoStackedInline, SchoolInfoStackedInline] @@ -134,63 +151,182 @@ class PropertyAdmin(admin.ModelAdmin): # Registering the new Attorney model @admin.register(Attorney) class AttorneyAdmin(admin.ModelAdmin): - list_display = ('user', 'firm_name', 'bar_number', 'phone_number', 'city', 'state', 'years_experience') - list_filter = ('state', 'years_experience', 'specialties') # You might need custom list_filter for JSONField - search_fields = ('user__email', 'user__first_name', 'user__last_name', 'firm_name', 'bar_number', 'city') + list_display = ( + "user", + "firm_name", + "bar_number", + "phone_number", + "city", + "state", + "years_experience", + ) + list_filter = ( + "state", + "years_experience", + "specialties", + ) # You might need custom list_filter for JSONField + search_fields = ( + "user__email", + "user__first_name", + "user__last_name", + "firm_name", + "bar_number", + "city", + ) # Use filter_horizontal for JSONField if you want a nice interface for many-to-many like selection # filter_horizontal = ('specialties', 'licensed_states') fieldsets = ( - (None, { - 'fields': ('user', 'firm_name', 'bar_number', 'phone_number', 'address', 'city', 'state', 'zip_code', 'bio', 'profile_picture', 'website') - }), - ('Professional Details', { - 'fields': ('specialties', 'years_experience', 'licensed_states') - }), - ('Location', { - 'fields': ('latitude', 'longitude'), - }), - ('Timestamps', { - 'fields': ('created_at', 'updated_at'), - 'classes': ('collapse',) - }), + ( + None, + { + "fields": ( + "user", + "firm_name", + "bar_number", + "phone_number", + "address", + "city", + "state", + "zip_code", + "bio", + "profile_picture", + "website", + ) + }, + ), + ( + "Professional Details", + {"fields": ("specialties", "years_experience", "licensed_states")}, + ), + ( + "Location", + { + "fields": ("latitude", "longitude"), + }, + ), + ( + "Timestamps", + {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}, + ), ) - readonly_fields = ('created_at', 'updated_at') + readonly_fields = ("created_at", "updated_at") # Registering the new RealEstateAgent model @admin.register(RealEstateAgent) class RealEstateAgentAdmin(admin.ModelAdmin): - list_display = ('user', 'brokerage_name', 'license_number', 'phone_number', 'city', 'state', 'agent_type', 'years_experience') - list_filter = ('agent_type', 'state', 'years_experience', 'specialties') - search_fields = ('user__email', 'user__first_name', 'user__last_name', 'brokerage_name', 'license_number', 'city') - #filter_horizontal = ('specialties', 'licensed_states') - fieldsets = ( - (None, { - 'fields': ('user', 'brokerage_name', 'license_number', 'phone_number', 'address', 'city', 'state', 'zip_code', 'bio', 'profile_picture', 'website', 'agent_type') - }), - ('Professional Details', { - 'fields': ('specialties', 'years_experience', 'licensed_states') - }), - ('Location', { - 'fields': ('latitude', 'longitude'), - }), - - ('Timestamps', { - 'fields': ('created_at', 'updated_at'), - 'classes': ('collapse',) - }), + list_display = ( + "user", + "brokerage_name", + "license_number", + "phone_number", + "city", + "state", + "agent_type", + "years_experience", ) - readonly_fields = ('created_at', 'updated_at') + list_filter = ("agent_type", "state", "years_experience", "specialties") + search_fields = ( + "user__email", + "user__first_name", + "user__last_name", + "brokerage_name", + "license_number", + "city", + ) + # filter_horizontal = ('specialties', 'licensed_states') + fieldsets = ( + ( + None, + { + "fields": ( + "user", + "brokerage_name", + "license_number", + "phone_number", + "address", + "city", + "state", + "zip_code", + "bio", + "profile_picture", + "website", + "agent_type", + ) + }, + ), + ( + "Professional Details", + {"fields": ("specialties", "years_experience", "licensed_states")}, + ), + ( + "Location", + { + "fields": ("latitude", "longitude"), + }, + ), + ( + "Timestamps", + {"fields": ("created_at", "updated_at"), "classes": ("collapse",)}, + ), + ) + readonly_fields = ("created_at", "updated_at") + @admin.register(UserViewModel) class UserViewModelAdmin(admin.ModelAdmin): - list_display = ('pk','user','created_at') - readonly_fields = ('created_at',) + list_display = ("pk", "user", "created_at") + readonly_fields = ("created_at",) + @admin.register(PropertySave) class PropertySaveAdmin(admin.ModelAdmin): - list_display = ('pk', 'user','property') - readonly_fields = ('created_at',) + list_display = ("pk", "user", "property") + readonly_fields = ("created_at",) + + +@admin.register(Document) +class DocumentAdmin(admin.ModelAdmin): + list_display = ("document_type", "property", "uploaded_by") + + +@admin.register(SellerDisclosure) +class SellerDisclosureAdmin(admin.ModelAdmin): + model = SellerDisclosure + + +@admin.register(VendorPictures) +class VendorPicturesAdmin(admin.ModelAdmin): + list_display = ("category_name", "image") + model = VendorPictures + + +@admin.register(SupportAgent) +class SupportAgentAdmin(admin.ModelAdmin): + model = SupportAgent + + +@admin.register(FAQ) +class FAQAdmin(admin.ModelAdmin): + model = FAQ + + +@admin.register(SupportMessage) +class SupportMessageAdmin(admin.ModelAdmin): + model = SupportMessage + + +class SupportMessageStackedInline(admin.StackedInline): + model = SupportMessage + + +@admin.register(SupportCase) +class SupportCaseAdmin(admin.ModelAdmin): + model = SupportCase + inlines = [ + SupportMessageStackedInline, + ] + admin.site.register(User, UserAdmin) admin.site.register(PropertyOwner, PropertyOwnerAdmin) @@ -200,7 +336,7 @@ admin.site.register(VideoCategory, VideoCategoryAdmin) admin.site.register(Video, VideoAdmin) admin.site.register(UserVideoProgress, UserVideoProgressAdmin) admin.site.register(Conversation, ConversationAdmin) -admin.site.register(Offer, OfferAdmin) +admin.site.register(OfferDocument, OfferAdmin) admin.site.register(PropertyPictures, PropertyPicturesAdmin) admin.site.register(OpenHouse, OpenHouseAdmin) diff --git a/dta_service/core/filters.py b/dta_service/core/filters.py index bdb2ab2..51fd275 100644 --- a/dta_service/core/filters.py +++ b/dta_service/core/filters.py @@ -1,5 +1,7 @@ import django_filters -from .models import Property +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') @@ -17,3 +19,53 @@ class PropertyFilterSet(django_filters.FilterSet): 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'] + +class DistanceFilter(django_filters.Filter): + def filter(self, qs: QuerySet, value: str) -> QuerySet: + # The 'value' is expected to be a comma-separated string: "property_id,distance_in_miles" + if not value: + return qs + try: + property_id, distance_str = value.split(',') + property_id = int(property_id) + distance_miles = float(distance_str) + except (ValueError, IndexError): + return qs.none() # Return an empty queryset on malformed input + + 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) + + if property_obj.latitude is None or property_obj.longitude is None: + return qs.none() + + prop_lat = float(property_obj.latitude) + prop_lon = float(property_obj.longitude) + + # This approach is database-agnostic but less performant + # than a database-native solution like PostGIS. + 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)) + if dist <= distance_miles: + vendor_pks.append(vendor.pk) + + return qs.filter(pk__in=vendor_pks) + + except Property.DoesNotExist: + return qs.none() + +class VendorFilterSet(django_filters.FilterSet): + # Your existing filters + business_type = django_filters.ChoiceFilter(choices=Vendor.BUSINESS_TYPES) + + # Custom distance filter + distance_from_property = DistanceFilter(label="property_id,distance_miles") + + class Meta: + model = Vendor + fields = ['business_type', 'distance_from_property'] diff --git a/dta_service/core/migrations/0024_document.py b/dta_service/core/migrations/0024_document.py new file mode 100644 index 0000000..bb06dbb --- /dev/null +++ b/dta_service/core/migrations/0024_document.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.4 on 2025-08-17 11:02 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0023_propertysave"), + ] + + operations = [ + migrations.CreateModel( + name="Document", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("file", models.FileField(upload_to="property_documents/")), + ( + "document_type", + models.CharField( + choices=[ + ("offer_letter", "Offer Letter"), + ("attorney_contract", "Attorney Contract"), + ("contractor_contract", "Contractor Contract"), + ("title_report", "Title Report"), + ("inspection_report", "Inspection Report"), + ("deed", "Deed"), + ("closing_disclosure", "Closing Disclosure"), + ("other", "Other"), + ], + max_length=50, + ), + ), + ("description", models.TextField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "property", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="documents", + to="core.property", + ), + ), + ( + "shared_with", + models.ManyToManyField( + blank=True, + related_name="shared_documents", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "uploaded_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="uploaded_documents", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/dta_service/core/migrations/0025_homeimprovementreceipt_offerdocument_and_more.py b/dta_service/core/migrations/0025_homeimprovementreceipt_offerdocument_and_more.py new file mode 100644 index 0000000..26ef085 --- /dev/null +++ b/dta_service/core/migrations/0025_homeimprovementreceipt_offerdocument_and_more.py @@ -0,0 +1,108 @@ +# Generated by Django 5.2.4 on 2025-08-22 01:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0024_document"), + ] + + operations = [ + migrations.CreateModel( + name="HomeImprovementReceipt", + fields=[ + ( + "document", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="home_improvement_receipt_data", + serialize=False, + to="core.document", + ), + ), + ("vendor_id", models.IntegerField(blank=True, null=True)), + ("date_of_work", models.DateField()), + ("description", models.TextField()), + ("cost", models.DecimalField(decimal_places=2, max_digits=10)), + ], + ), + migrations.CreateModel( + name="OfferDocument", + fields=[ + ( + "document", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="offer_data", + serialize=False, + to="core.document", + ), + ), + ( + "offer_price", + models.DecimalField(decimal_places=2, default=0, max_digits=10), + ), + ("closing_date", models.DateField(blank=True, null=True)), + ("closing_days", models.IntegerField(blank=True, null=True)), + ("contingencies", models.TextField(default="")), + ], + ), + migrations.CreateModel( + name="SellerDisclosure", + fields=[ + ( + "document", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + related_name="seller_disclosure_data", + serialize=False, + to="core.document", + ), + ), + ("general_defects", models.TextField()), + ("roof_condition", models.CharField(max_length=100)), + ("roof_age", models.CharField(max_length=100)), + ("known_roof_leaks", models.BooleanField()), + ("plumbing_issues", models.TextField()), + ("electrical_issues", models.TextField()), + ("hvac_condition", models.CharField(max_length=100)), + ("hvac_age", models.CharField(max_length=100)), + ("known_lead_paint", models.BooleanField()), + ("known_asbestos", models.BooleanField()), + ("known_radon", models.BooleanField()), + ("past_water_damage", models.TextField()), + ("structural_issues", models.TextField()), + ("neighborhood_nuisances", models.TextField()), + ("property_line_disputes", models.TextField()), + ("appliances_included", models.TextField()), + ], + ), + 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"), + ("other", "Other"), + ], + max_length=50, + ), + ), + migrations.DeleteModel( + name="Offer", + ), + ] diff --git a/dta_service/core/migrations/0026_alter_document_file.py b/dta_service/core/migrations/0026_alter_document_file.py new file mode 100644 index 0000000..737db9c --- /dev/null +++ b/dta_service/core/migrations/0026_alter_document_file.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.4 on 2025-08-22 01:53 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0025_homeimprovementreceipt_offerdocument_and_more"), + ] + + operations = [ + migrations.AlterField( + model_name="document", + name="file", + field=models.FileField( + blank=True, null=True, upload_to="property_documents/" + ), + ), + ] diff --git a/dta_service/core/migrations/0027_offerdocument_status.py b/dta_service/core/migrations/0027_offerdocument_status.py new file mode 100644 index 0000000..5fbe198 --- /dev/null +++ b/dta_service/core/migrations/0027_offerdocument_status.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.4 on 2025-09-09 02:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0026_alter_document_file"), + ] + + operations = [ + migrations.AddField( + model_name="offerdocument", + name="status", + field=models.CharField( + choices=[ + ("submitted", "Submitted"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ("countered", "countered"), + ("pending", "pending"), + ], + default="submitted", + max_length=50, + ), + ), + ] diff --git a/dta_service/core/migrations/0028_offerdocument_parent_offer.py b/dta_service/core/migrations/0028_offerdocument_parent_offer.py new file mode 100644 index 0000000..6bf8769 --- /dev/null +++ b/dta_service/core/migrations/0028_offerdocument_parent_offer.py @@ -0,0 +1,25 @@ +# Generated by Django 5.2.4 on 2025-09-10 17:33 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0027_offerdocument_status"), + ] + + operations = [ + migrations.AddField( + model_name="offerdocument", + name="parent_offer", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="counter_offers", + to="core.offerdocument", + ), + ), + ] diff --git a/dta_service/core/migrations/0029_openhouse_end_time_openhouse_start_time_and_more.py b/dta_service/core/migrations/0029_openhouse_end_time_openhouse_start_time_and_more.py new file mode 100644 index 0000000..5f786bf --- /dev/null +++ b/dta_service/core/migrations/0029_openhouse_end_time_openhouse_start_time_and_more.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.4 on 2025-09-16 17:28 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0028_offerdocument_parent_offer"), + ] + + operations = [ + migrations.AddField( + model_name="openhouse", + name="end_time", + field=models.TimeField(default=datetime.time(17, 0)), + ), + migrations.AddField( + model_name="openhouse", + name="start_time", + field=models.TimeField(default=datetime.time(9, 0)), + ), + migrations.AlterField( + model_name="openhouse", + name="updated_at", + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/dta_service/core/migrations/0030_vendorpictures_alter_propertypictures_image.py b/dta_service/core/migrations/0030_vendorpictures_alter_propertypictures_image.py new file mode 100644 index 0000000..6dd348e --- /dev/null +++ b/dta_service/core/migrations/0030_vendorpictures_alter_propertypictures_image.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.4 on 2025-10-09 12:25 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0029_openhouse_end_time_openhouse_start_time_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="VendorPictures", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("image", models.FileField(upload_to="vendor_pictures/")), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("category_name", models.CharField(max_length=100)), + ], + ), + migrations.AlterField( + model_name="propertypictures", + name="image", + field=models.FileField(upload_to="pictures/"), + ), + ] diff --git a/dta_service/core/migrations/0031_alter_vendor_business_type.py b/dta_service/core/migrations/0031_alter_vendor_business_type.py new file mode 100644 index 0000000..fadae66 --- /dev/null +++ b/dta_service/core/migrations/0031_alter_vendor_business_type.py @@ -0,0 +1,57 @@ +# Generated by Django 5.2.4 on 2025-10-09 15:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0030_vendorpictures_alter_propertypictures_image"), + ] + + operations = [ + migrations.AlterField( + model_name="vendor", + name="business_type", + field=models.CharField( + choices=[ + ("arborist", "Arborist"), + ( + "basement_waterproofing_and_injection", + "Basement Waterproofing And Injection", + ), + ("carpenter", "Carpenter"), + ("cleaning Company", "Cleaning Company"), + ("decking", "Decking"), + ("door_company", "Door Company"), + ("electrician", "Electrician"), + ("fencing", "Fencing"), + ("general_contractor", "General Contractor"), + ("handyman", "Handyman"), + ("home_inspector", "Home Inspector"), + ("house_staging", "House Staging"), + ("hvac", "HVAC"), + ( + "irrigation_and_sprinkler_system", + "Irrigation And Sprinkler System", + ), + ("junk_removal", "Junk Removal"), + ("landscaping", "Landscaping"), + ("masonry", "Masonry"), + ("mortgage_lendor", "Mortgage Lendor"), + ("moving_company", "Moving Company"), + ("painter", "Painter"), + ("paving_company", "Paving Company"), + ("pest_control", "Pest Control"), + ("photographer", "Photographer"), + ("plumber", "Plumber"), + ("pressure_washing", "Pressure Washing"), + ("roofer", "Roofer"), + ("storage_facility", "Storage Facility"), + ("window_company", "Window Company"), + ("window_washing", "Window Washing"), + ], + max_length=50, + ), + ), + ] diff --git a/dta_service/core/migrations/0032_faq_supportagent_alter_user_user_type_supportcase_and_more.py b/dta_service/core/migrations/0032_faq_supportagent_alter_user_user_type_supportcase_and_more.py new file mode 100644 index 0000000..84b54ac --- /dev/null +++ b/dta_service/core/migrations/0032_faq_supportagent_alter_user_user_type_supportcase_and_more.py @@ -0,0 +1,140 @@ +# Generated by Django 5.2.4 on 2025-10-13 15:27 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("core", "0031_alter_vendor_business_type"), + ] + + operations = [ + migrations.CreateModel( + name="FAQ", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("question", models.TextField()), + ("answer", models.TextField()), + ("order", models.IntegerField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + ), + migrations.CreateModel( + name="SupportAgent", + fields=[ + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + primary_key=True, + serialize=False, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.AlterField( + model_name="user", + name="user_type", + field=models.CharField( + choices=[ + ("property_owner", "Property Owner"), + ("vendor", "Vendor"), + ("attorney", "Attorney"), + ("real_estate_agent", "Real Estate Agent"), + ("support_agent", "Support Agent"), + ("admin", "Admin"), + ], + max_length=20, + ), + ), + migrations.CreateModel( + name="SupportCase", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.TextField()), + ("description", models.TextField()), + ( + "status", + models.CharField( + choices=[("opened", "Opened"), ("closed", "Closed")], + default="opened", + max_length=50, + ), + ), + ( + "category", + models.CharField( + choices=[ + ("question", "Question"), + ("bug", "Bug"), + ("other", "Other"), + ], + default="question", + max_length=50, + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + migrations.CreateModel( + name="SupportMessage", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("text", models.TextField()), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "support_case", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="messages", + to="core.supportcase", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ], + ), + ] diff --git a/dta_service/core/models.py b/dta_service/core/models.py index 7ff3879..61fbf17 100644 --- a/dta_service/core/models.py +++ b/dta_service/core/models.py @@ -11,6 +11,8 @@ from django.utils.html import strip_tags from django.conf import settings import uuid import os +import datetime + class UserManager(BaseUserManager): def create_user(self, email, password=None, **extra_fields): @@ -35,6 +37,7 @@ class User(AbstractBaseUser, PermissionsMixin): ("vendor", "Vendor"), ("attorney", "Attorney"), ("real_estate_agent", "Real Estate Agent"), + ("support_agent", "Support Agent"), ("admin", "Admin"), ) USER_TIER_CHOICES = ( @@ -83,17 +86,43 @@ class PropertyOwner(models.Model): class Vendor(models.Model): BUSINESS_TYPES = ( - ("electrician", "Electrician"), + ("arborist", "Arborist"), + ( + "basement_waterproofing_and_injection", + "Basement Waterproofing And Injection", + ), ("carpenter", "Carpenter"), + ("cleaning Company", "Cleaning Company"), + ("decking", "Decking"), + ("door_company", "Door Company"), + ("electrician", "Electrician"), + ("fencing", "Fencing"), + ("general_contractor", "General Contractor"), + ("handyman", "Handyman"), + ("home_inspector", "Home Inspector"), + ("house_staging", "House Staging"), + ("hvac", "HVAC"), + ("irrigation_and_sprinkler_system", "Irrigation And Sprinkler System"), + ("junk_removal", "Junk Removal"), + ("landscaping", "Landscaping"), + ("masonry", "Masonry"), + ("mortgage_lendor", "Mortgage Lendor"), + ("moving_company", "Moving Company"), + ("painter", "Painter"), + ("paving_company", "Paving Company"), + ("pest_control", "Pest Control"), + ("photographer", "Photographer"), ("plumber", "Plumber"), - ("inspector", "Inspector"), - ("lender", "Lender"), - ("other", "Other"), + ("pressure_washing", "Pressure Washing"), + ("roofer", "Roofer"), + ("storage_facility", "Storage Facility"), + ("window_company", "Window Company"), + ("window_washing", "Window Washing"), ) user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) business_name = models.CharField(max_length=100) - business_type = models.CharField(max_length=20, choices=BUSINESS_TYPES) + business_type = models.CharField(max_length=50, choices=BUSINESS_TYPES) phone_number = models.CharField(max_length=20, blank=True, null=True) address = models.CharField(max_length=200) city = models.CharField(max_length=100) @@ -123,25 +152,27 @@ class Vendor(models.Model): ) # For coordinates views = models.IntegerField(default=0) - def __str__(self): return self.business_name + class Attorney(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) firm_name = models.CharField(max_length=200) - bar_number = models.CharField(max_length=50, unique=True, blank=True, null=True) # Bar numbers are typically unique + bar_number = models.CharField( + max_length=50, unique=True, blank=True, null=True + ) # Bar numbers are typically unique phone_number = models.CharField(max_length=20, blank=True, null=True) address = models.CharField(max_length=200) city = models.CharField(max_length=100) state = models.CharField(max_length=2) zip_code = models.CharField(max_length=10) - specialties = models.JSONField(blank=True, default=list) # Store as JSON array + specialties = models.JSONField(blank=True, default=list) # Store as JSON array years_experience = models.IntegerField(default=0) website = models.URLField(blank=True, null=True) profile_picture = models.URLField(max_length=500, blank=True, null=True) - bio = models.TextField(blank=True, null=True) # Use TextField for longer text - licensed_states = models.JSONField(blank=True, default=list) # Store as JSON array + bio = models.TextField(blank=True, null=True) # Use TextField for longer text + licensed_states = models.JSONField(blank=True, default=list) # Store as JSON array created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -155,6 +186,7 @@ class Attorney(models.Model): def __str__(self): return f"{self.user.get_full_name()} ({self.firm_name})" + class RealEstateAgent(models.Model): AGENT_TYPE_CHOICES = ( ("buyer_agent", "Buyer's Agent"), @@ -165,19 +197,23 @@ class RealEstateAgent(models.Model): user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) brokerage_name = models.CharField(max_length=200) - license_number = models.CharField(max_length=50, unique=True) # License numbers are typically unique + license_number = models.CharField( + max_length=50, unique=True + ) # License numbers are typically unique phone_number = models.CharField(max_length=20, blank=True, null=True) address = models.CharField(max_length=200) city = models.CharField(max_length=100) state = models.CharField(max_length=2) zip_code = models.CharField(max_length=10) - specialties = models.JSONField(blank=True, default=list) # Store as JSON array + specialties = models.JSONField(blank=True, default=list) # Store as JSON array years_experience = models.IntegerField(default=0) website = models.URLField(blank=True, null=True) profile_picture = models.URLField(max_length=500, blank=True, null=True) bio = models.TextField(blank=True, null=True) - licensed_states = models.JSONField(blank=True, default=list) # Store as JSON array - agent_type = models.CharField(max_length=20, choices=AGENT_TYPE_CHOICES, default="other") + licensed_states = models.JSONField(blank=True, default=list) # Store as JSON array + agent_type = models.CharField( + max_length=20, choices=AGENT_TYPE_CHOICES, default="other" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -192,12 +228,15 @@ class RealEstateAgent(models.Model): return f"{self.user.get_full_name()} ({self.brokerage_name})" +class SupportAgent(models.Model): + user = models.OneToOneField(User, on_delete=models.CASCADE, primary_key=True) + + class UserViewModel(models.Model): created_at = models.DateTimeField(auto_now_add=True) user = models.ForeignKey(User, on_delete=models.CASCADE) - class Property(models.Model): PROPERTY_STATUS_TYPES = ( ("active", "Active"), @@ -250,16 +289,22 @@ class Property(models.Model): saves = models.IntegerField(default=0) listed_date = models.DateTimeField(blank=True, null=True) - def __str__(self): return f"{self.address}, {self.city}, {self.state} {self.zip_code}" + class SchoolInfo(models.Model): SCHOOL_TYPES = ( ("Public", "Public"), ("Other", "Other"), ) - property = models.ForeignKey(Property, on_delete=models.CASCADE, related_name="schools", blank=True, null=True) + property = models.ForeignKey( + Property, + on_delete=models.CASCADE, + related_name="schools", + blank=True, + null=True, + ) city = models.CharField(max_length=100) state = models.CharField(max_length=2) zip_code = models.CharField(max_length=10) @@ -280,8 +325,11 @@ class SchoolInfo(models.Model): max_length=15, choices=SCHOOL_TYPES, default="public" ) + class PropertyTaxInfo(models.Model): - property = models.OneToOneField(Property, on_delete=models.CASCADE, related_name="tax_info") + property = models.OneToOneField( + Property, on_delete=models.CASCADE, related_name="tax_info" + ) assessed_value = models.IntegerField() assessment_year = models.IntegerField() tax_amount = models.FloatField() @@ -291,7 +339,9 @@ class PropertyTaxInfo(models.Model): class PropertySaleInfo(models.Model): - property = models.ForeignKey(Property, on_delete=models.CASCADE, related_name="sale_info") + property = models.ForeignKey( + Property, on_delete=models.CASCADE, related_name="sale_info" + ) seq_no = models.IntegerField() sale_date = models.DateTimeField() sale_amount = models.FloatField() @@ -301,7 +351,11 @@ class PropertySaleInfo(models.Model): class PropertyWalkScoreInfo(models.Model): property = models.OneToOneField( - Property, on_delete=models.CASCADE, blank=True, null=True, related_name="walk_score" + Property, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="walk_score", ) walk_score = models.IntegerField() walk_description = models.CharField(max_length=256) @@ -317,21 +371,36 @@ class PropertyWalkScoreInfo(models.Model): class OpenHouse(models.Model): - property = models.ForeignKey(Property, on_delete=models.CASCADE, blank=True, null=True, related_name="open_houses") + property = models.ForeignKey( + Property, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="open_houses", + ) listed_date = models.DateTimeField() + start_time = models.TimeField(default=datetime.time(9, 0)) + end_time = models.TimeField(default=datetime.time(17, 0)) created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) + updated_at = models.DateTimeField(auto_now_add=True) class PropertyPictures(models.Model): Property = models.ForeignKey( Property, on_delete=models.CASCADE, related_name="pictures" ) - image = models.FileField(upload_to="pcitures/") + image = models.FileField(upload_to="pictures/") created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) +class VendorPictures(models.Model): + image = models.FileField(upload_to="vendor_pictures/") + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + category_name = models.CharField(max_length=100) + + class VideoCategory(models.Model): name = models.CharField(max_length=100) description = models.TextField(blank=True, null=True) @@ -472,29 +541,30 @@ class PasswordResetToken(models.Model): return f"Password reset token for {self.user.email}" -class Offer(models.Model): - OFFER_STATUS_TYPES = ( - ("submitted", "Submitted"), - ("draft", "Draft"), - ("accepted", "Accepted"), - ("rejected", "Rejected"), - ("counter", "Counter"), - ("withdrawn", "Withdrawn"), - ) - user = models.ForeignKey(User, on_delete=models.CASCADE) - property = models.ForeignKey(Property, on_delete=models.PROTECT) - status = models.CharField( - max_length=10, choices=OFFER_STATUS_TYPES, default="draft" - ) - previous_offer = models.ForeignKey( - "self", on_delete=models.CASCADE, null=True, blank=True - ) - is_active = models.BooleanField(default=True) - created_at = models.DateTimeField(auto_now_add=True) - updated_at = models.DateTimeField(auto_now=True) +# class Offer(models.Model): +# OFFER_STATUS_TYPES = ( +# ("submitted", "Submitted"), +# ("draft", "Draft"), +# ("accepted", "Accepted"), +# ("rejected", "Rejected"), +# ("counter", "Counter"), +# ("withdrawn", "Withdrawn"), +# ) +# user = models.ForeignKey(User, on_delete=models.CASCADE) +# property = models.ForeignKey(Property, on_delete=models.PROTECT) +# status = models.CharField( +# max_length=10, choices=OFFER_STATUS_TYPES, default="draft" +# ) +# previous_offer = models.ForeignKey( +# "self", on_delete=models.CASCADE, null=True, blank=True +# ) +# is_active = models.BooleanField(default=True) +# created_at = models.DateTimeField(auto_now_add=True) +# updated_at = models.DateTimeField(auto_now=True) + +# def __str__(self): +# return f"{self.user.email} {self.status} {self.property.address}" - def __str__(self): - return f"{self.user.email} {self.status} {self.property.address}" class Bid(models.Model): BID_TYPE_CHOICES = ( @@ -512,7 +582,9 @@ class Bid(models.Model): ("outside", "Outside"), ) - property = models.ForeignKey(Property, on_delete=models.CASCADE, related_name="bids") + property = models.ForeignKey( + Property, on_delete=models.CASCADE, related_name="bids" + ) description = models.TextField() bid_type = models.CharField(max_length=50, choices=BID_TYPE_CHOICES) location = models.CharField(max_length=50, choices=LOCATION_CHOICES) @@ -522,11 +594,13 @@ class Bid(models.Model): def __str__(self): return f"Bid for {self.bid_type} at {self.property.address}" + class BidImage(models.Model): bid = models.ForeignKey(Bid, on_delete=models.CASCADE, related_name="images") image = models.FileField(upload_to="bid_pictures/") uploaded_at = models.DateTimeField(auto_now_add=True) + class BidResponse(models.Model): RESPONSE_STATUS_CHOICES = ( ("draft", "Draft"), @@ -534,23 +608,190 @@ class BidResponse(models.Model): ("selected", "Selected"), ) bid = models.ForeignKey(Bid, on_delete=models.CASCADE, related_name="responses") - vendor = models.ForeignKey(Vendor, on_delete=models.CASCADE, related_name="bid_responses") + vendor = models.ForeignKey( + Vendor, on_delete=models.CASCADE, related_name="bid_responses" + ) description = models.TextField() price = models.DecimalField(max_digits=10, decimal_places=2) - status = models.CharField(max_length=20, choices=RESPONSE_STATUS_CHOICES, default="draft") + status = models.CharField( + max_length=20, choices=RESPONSE_STATUS_CHOICES, default="draft" + ) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) class Meta: - unique_together = ('bid', 'vendor') + unique_together = ("bid", "vendor") def __str__(self): return f"Response from {self.vendor.business_name} for Bid {self.bid.id}" + class PropertySave(models.Model): user = models.ForeignKey(User, on_delete=models.CASCADE) property = models.ForeignKey(Property, on_delete=models.CASCADE) created_at = models.DateTimeField(auto_now_add=True) class Meta: - unique_together = ('user', 'property') + unique_together = ("user", "property") + + +class Document(models.Model): + DOCUMENT_TYPES = ( + ("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"), + ("other", "Other"), + ) + + # Link to the property the document belongs to + property = models.ForeignKey( + Property, on_delete=models.CASCADE, related_name="documents" + ) + + # The document file itself + file = models.FileField(upload_to="property_documents/", blank=True, null=True) + + # The type of document + document_type = models.CharField(max_length=50, choices=DOCUMENT_TYPES) + + # Optional description of the document + description = models.TextField(blank=True, null=True) + + # The user who uploaded the document + uploaded_by = models.ForeignKey( + User, on_delete=models.SET_NULL, null=True, related_name="uploaded_documents" + ) + + # A list of users who have permission to view this document + shared_with = models.ManyToManyField( + User, related_name="shared_documents", blank=True + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.document_type} for {self.property.address}" + + +class OfferDocument(models.Model): + OFFER_STATUS = ( + ("submitted", "Submitted"), + ("accepted", "Accepted"), + ("rejected", "Rejected"), + ("countered", "countered"), + ("pending", "pending"), + ) + + # One-to-one link to the generic Document + document = models.OneToOneField( + Document, on_delete=models.CASCADE, related_name="offer_data", primary_key=True + ) + parent_offer = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="counter_offers", + ) + offer_price = models.DecimalField(max_digits=10, decimal_places=2, default=0) + closing_date = models.DateField(null=True, blank=True) + closing_days = models.IntegerField(null=True, blank=True) + contingencies = models.TextField(default="") + status = models.CharField(max_length=50, choices=OFFER_STATUS, default="submitted") + + def __str__(self): + return f"Offer for Document ID: {self.document.id}" + + +class SellerDisclosure(models.Model): + # One-to-one link to the generic Document + document = models.OneToOneField( + Document, + on_delete=models.CASCADE, + related_name="seller_disclosure_data", + primary_key=True, + ) + general_defects = models.TextField() + roof_condition = models.CharField(max_length=100) + roof_age = models.CharField(max_length=100) + known_roof_leaks = models.BooleanField() + plumbing_issues = models.TextField() + electrical_issues = models.TextField() + hvac_condition = models.CharField(max_length=100) + hvac_age = models.CharField(max_length=100) + known_lead_paint = models.BooleanField() + known_asbestos = models.BooleanField() + known_radon = models.BooleanField() + past_water_damage = models.TextField() + structural_issues = models.TextField() + neighborhood_nuisances = models.TextField() + property_line_disputes = models.TextField() + appliances_included = models.TextField() + + def __str__(self): + return f"Seller Disclosure for Document ID: {self.document.id}" + + +class HomeImprovementReceipt(models.Model): + # One-to-one link to the generic Document + document = models.OneToOneField( + Document, + on_delete=models.CASCADE, + related_name="home_improvement_receipt_data", + primary_key=True, + ) + # Assuming vendor_id is just an integer, not a ForeignKey to a Vendor model for now + vendor_id = models.IntegerField(null=True, blank=True) + date_of_work = models.DateField() + description = models.TextField() + cost = models.DecimalField(max_digits=10, decimal_places=2) + + def __str__(self): + return f"Home Improvement Receipt for Document ID: {self.document.id}" + + +class FAQ(models.Model): + question = models.TextField() + answer = models.TextField() + order = models.IntegerField() + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class SupportCase(models.Model): + SUPPORT_STATUS = ( + ("opened", "Opened"), + ("closed", "Closed"), + ) + SUPPORT_CATEGORIES = ( + ("question", "Question"), + ("bug", "Bug"), + ("other", "Other"), + ) + user = models.ForeignKey(User, on_delete=models.CASCADE) + title = models.TextField() + description = models.TextField() + status = models.CharField(max_length=50, choices=SUPPORT_STATUS, default="opened") + category = models.CharField( + max_length=50, choices=SUPPORT_CATEGORIES, default="question" + ) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + +class SupportMessage(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE) + support_case = models.ForeignKey( + SupportCase, on_delete=models.CASCADE, related_name="messages" + ) + text = models.TextField() + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) diff --git a/dta_service/core/permissions.py b/dta_service/core/permissions.py index 03a278f..680f941 100644 --- a/dta_service/core/permissions.py +++ b/dta_service/core/permissions.py @@ -3,7 +3,6 @@ from rest_framework import permissions class IsOwnerOrReadOnly(permissions.BasePermission): def has_object_permission(self, request, view, obj): - if request.method in permissions.SAFE_METHODS: return True @@ -27,6 +26,16 @@ class IsPropertyOwner(permissions.BasePermission): return False +class IsSupportAgent(permissions.BasePermission): + def has_permission(self, request, view): + return request.user.user_type == "support_agent" + + def has_object_permission(self, request, view, obj): + if hasattr(obj, "support_agent"): + return obj.support_agent.user == request.user + return False + + class IsVendor(permissions.BasePermission): def has_permission(self, request, view): return request.user.user_type == "vendor" @@ -58,6 +67,38 @@ class IsParticipantInOffer(permissions.BasePermission): There are two options, either you are the sender or the owner of the property """ if hasattr(obj, "user") and hasattr(obj, "property"): - return request.user == obj.user or request.user == obj.property.owner.user return False + + +class IsPropertyOwnerOrVendorOrAttorney(permissions.BasePermission): + """ + Custom permission to only allow property owners, vendors, or attorneys + who are part of the property transaction to view or edit it. + """ + + def has_object_permission(self, request, view, obj): + user = request.user + property_owner = obj.property.owner.user + + # Check if the user is the property owner + if user == property_owner: + return True + + # Check if the user is a vendor or attorney associated with a bid/bid response + if user.user_type in ["vendor", "attorney"]: + # Check for vendor-related bids for this property + is_vendor_for_property = obj.property.bids.filter( + responses__vendor__user=user + ).exists() + + # For attorneys, you would need a new model linking attorneys to properties. + # Assuming a simpler check for now. + is_attorney_for_property = ( + False # Implement this based on your attorney model relationships + ) + + if is_vendor_for_property or is_attorney_for_property: + return True + + return False diff --git a/dta_service/core/public_views/__init__.py b/dta_service/core/public_views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dta_service/core/public_views/public.py b/dta_service/core/public_views/public.py new file mode 100644 index 0000000..a5d894c --- /dev/null +++ b/dta_service/core/public_views/public.py @@ -0,0 +1,107 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from django.http import Http404 +from rest_framework.permissions import AllowAny +from core.models import Property +from core.serializers import PublicPropertyResponseSerializer as PropertySerializer + + +class PropertyList(APIView): + """ + List all active properties. + """ + + permission_classes = [ + AllowAny, + ] + + def get(self, request, format=None): + properties = Property.objects.filter(property_status="active") + + address = request.query_params.get("address") + if address: + properties = properties.filter(address__icontains=address) + + city = request.query_params.get("city") + if city: + properties = properties.filter(city__icontains=city) + + state = request.query_params.get("state") + if state: + properties = properties.filter(state__iexact=state) + + zip_code = request.query_params.get("zipCode") + if zip_code: + properties = properties.filter(zip_code=zip_code) + + min_sq_ft = request.query_params.get("minSqFt") + if min_sq_ft: + properties = properties.filter(sq_ft__gte=min_sq_ft) + + max_sq_ft = request.query_params.get("maxSqFt") + if max_sq_ft: + properties = properties.filter(sq_ft__lte=max_sq_ft) + + min_bedrooms = request.query_params.get("minBedrooms") + if min_bedrooms: + properties = properties.filter(num_bedrooms__gte=min_bedrooms) + + max_bedrooms = request.query_params.get("maxBedrooms") + if max_bedrooms: + properties = properties.filter(num_bedrooms__lte=max_bedrooms) + + min_bathrooms = request.query_params.get("minBathrooms") + if min_bathrooms: + properties = properties.filter(num_bathrooms__gte=min_bathrooms) + + max_bathrooms = request.query_params.get("maxBathrooms") + if max_bathrooms: + properties = properties.filter(num_bathrooms__lte=max_bathrooms) + + serializer = PropertySerializer(properties, many=True) + return Response(serializer.data) + + +class PropertyDetail(APIView): + """ + Retrieve a single property. + """ + + permission_classes = [ + AllowAny, + ] + + def get_object(self, pk): + print("asdlkfjhaslkdjhfalksjdhf") + try: + return Property.objects.get(id=pk, property_status="active") + except Property.DoesNotExist: + raise Http404 + + def get(self, request, pk, format=None): + try: + property = self.get_object(pk) + serializer = PropertySerializer(property) + return Response(serializer.data) + except Exception as e: + print(e) + return Response(status=400) + + +class PropertyIncrementCount(APIView): + permission_classes = [ + AllowAny, + ] + + def get_object(self, pk): + try: + return Property.objects.get(id=pk, property_status="active") + except Property.DoesNotExist: + raise Http404 + + def post(self, request, pk, format=None): + property = self.get_object(pk) + property.views += 1 + property.save() + serializer = PropertySerializer(property) + return Response(serializer.data) diff --git a/dta_service/core/serializers.py b/dta_service/core/serializers.py index 8d4d597..5704048 100644 --- a/dta_service/core/serializers.py +++ b/dta_service/core/serializers.py @@ -1,9 +1,11 @@ +from django.utils import timezone from rest_framework import serializers from django.contrib.auth import get_user_model from rest_framework_simplejwt.serializers import TokenObtainPairSerializer from rest_framework_simplejwt.tokens import RefreshToken from .models import ( PropertyOwner, + SupportAgent, Vendor, Property, VideoCategory, @@ -12,14 +14,26 @@ from .models import ( Conversation, Message, PasswordResetToken, - Offer, + OfferDocument, PropertyPictures, OpenHouse, PropertySaleInfo, PropertyTaxInfo, PropertyWalkScoreInfo, SchoolInfo, - Bid, BidImage, BidResponse, RealEstateAgent, Attorney, UserViewModel, PropertySave + Bid, + BidImage, + BidResponse, + RealEstateAgent, + Attorney, + UserViewModel, + PropertySave, + Document, + SellerDisclosure, + HomeImprovementReceipt, + SupportCase, + SupportMessage, + FAQ, ) from django.core.mail import send_mail from django.template.loader import render_to_string @@ -143,6 +157,7 @@ class PropertyOwnerSerializer(serializers.ModelSerializer): class VendorSerializer(serializers.ModelSerializer): user = UserSerializer() + class Meta: model = Vendor fields = [ @@ -163,7 +178,7 @@ class VendorSerializer(serializers.ModelSerializer): "latitude", "profile_picture", "user", - "views" + "views", ] read_only_fields = [ "id", @@ -172,10 +187,13 @@ class VendorSerializer(serializers.ModelSerializer): "average_rating", "num_reviews", ] + # This create method is fine for creating a new vendor and user def create(self, validated_data): user_data = validated_data.pop("user") - user = User.objects.create_user(**user_data) # Use create_user to hash the password if present + user = User.objects.create_user( + **user_data + ) # Use create_user to hash the password if present vendor = Vendor.objects.create(user=user, **validated_data) return vendor @@ -219,7 +237,15 @@ class PropertyPictureSerializer(serializers.ModelSerializer): class OpenHouseSerializer(serializers.ModelSerializer): class Meta: model = OpenHouse - fields = ["id", "created_at", "updated_at", "listed_date", "property"] + fields = [ + "id", + "created_at", + "updated_at", + "listed_date", + "property", + "start_time", + "end_time", + ] read_only_fields = ["id", "created_at", "updated_at"] def create(self, validated_data): @@ -234,6 +260,49 @@ class OpenHouseSerializer(serializers.ModelSerializer): return super().create(validated_data) +class SubDocumentField(serializers.RelatedField): + def to_representation(self, value): + from .serializers import ( + OfferSerializer, + SellerDisclosureSerializer, + HomeImprovementReceiptSerializer, + ) + + # Check for each related type and use the correct serializer + if hasattr(value, "offer_data"): + return OfferSerializer(value.offer_data).data + elif hasattr(value, "seller_disclosure_data"): + return SellerDisclosureSerializer(value.seller_disclosure_data).data + elif hasattr(value, "home_improvement_receipt_data"): + return HomeImprovementReceiptSerializer( + value.home_improvement_receipt_data + ).data + return None + + +class DocumentSerializer(serializers.ModelSerializer): + sub_document = SubDocumentField(source="*", read_only=True) + shared_with = serializers.PrimaryKeyRelatedField( + queryset=User.objects.all(), many=True, required=False + ) # Allow sharing + + class Meta: + model = Document + fields = [ + "id", + "property", + "file", + "document_type", + "description", + "uploaded_by", + "shared_with", + "created_at", + "updated_at", + "sub_document", + ] + read_only_fields = ["id", "created_at", "updated_at"] + + class SchoolInfoSerializer(serializers.ModelSerializer): class Meta: model = SchoolInfo @@ -306,13 +375,59 @@ class PropertySaleInfoSerializer(serializers.ModelSerializer): "sale_amount", "created_at", "updated_at", - ] read_only_fields = ["id", "created_at", "updated_at"] +class PublicPropertyResponseSerializer(serializers.ModelSerializer): + pictures = PropertyPictureSerializer(many=True) + open_houses = OpenHouseSerializer(many=True) + schools = SchoolInfoSerializer(many=True) + walk_score = PropertyWalkScoreInfoSerializer(many=False) + tax_info = PropertyTaxInfoSerializer(many=False) + sale_info = PropertySaleInfoSerializer(many=True) + + class Meta: + model = Property + fields = [ + "id", + "address", + "street", + "city", + "state", + "zip_code", + "market_value", + "loan_amount", + "loan_interest_rate", + "loan_term", + "loan_start_date", + "created_at", + "updated_at", + "description", + "sq_ft", + "features", + "num_bedrooms", + "num_bathrooms", + "latitude", + "longitude", + "realestate_api_id", + "property_status", + "views", + "saves", + "listed_date", + "pictures", + "open_houses", + "schools", + "walk_score", + "tax_info", + "sale_info", + ] + read_only_fields = ["id", "created_at", "updated_at", "documents"] + + class PropertyResponseSerializer(serializers.ModelSerializer): owner = PropertyOwnerSerializer() + documents = DocumentSerializer(many=True, read_only=True) pictures = PropertyPictureSerializer(many=True) open_houses = OpenHouseSerializer(many=True) schools = SchoolInfoSerializer(many=True) @@ -350,13 +465,14 @@ class PropertyResponseSerializer(serializers.ModelSerializer): "saves", "listed_date", "pictures", - 'open_houses', - 'schools', - 'walk_score', - 'tax_info', - 'sale_info', + "open_houses", + "schools", + "walk_score", + "tax_info", + "sale_info", + "documents", ] - read_only_fields = ["id", "created_at", "updated_at"] + read_only_fields = ["id", "created_at", "updated_at", "documents"] class PropertyRequestSerializer(serializers.ModelSerializer): @@ -395,10 +511,10 @@ class PropertyRequestSerializer(serializers.ModelSerializer): "listed_date", "tax_info", "sale_info", - "schools" - + "schools", ] read_only_fields = ["id", "created_at", "updated_at", "views", "saves"] + def create(self, validated_data): # tax_info_data = validated_data.pop("tax_info") # tax_info_data = validated_data.pop("tax_info") @@ -408,17 +524,18 @@ class PropertyRequestSerializer(serializers.ModelSerializer): sale_info = validated_data.pop("sale_info") schools = [] - - property_instance = Property.objects.create(**validated_data) sale_infos = [] for sale_in in sale_info: - sale_infos.append(PropertySaleInfo.objects.create(**sale_in, property=property_instance)) - + sale_infos.append( + PropertySaleInfo.objects.create(**sale_in, property=property_instance) + ) for school_data in schools_data: - schools.append(SchoolInfo.objects.create(**school_data, property=property_instance)) + schools.append( + SchoolInfo.objects.create(**school_data, property=property_instance) + ) PropertyTaxInfo.objects.create(**tax_info, property=property_instance) walk_score.property = property_instance @@ -426,6 +543,54 @@ class PropertyRequestSerializer(serializers.ModelSerializer): return property_instance + def update(self, instance, validated_data): + # New logic for updating listed_date based on property_status + new_status = validated_data.get("property_status", None) + + if new_status and new_status != instance.property_status: + # Status is changing + if new_status == "active": + # Set listed_date to current time + validated_data["listed_date"] = timezone.now() + elif new_status == "off_market": + # Set listed_date to null + validated_data["listed_date"] = None + + # Handle nested updates + schools_data = validated_data.pop("schools", None) + tax_info_data = validated_data.pop("tax_info", None) + sale_info_data = validated_data.pop("sale_info", None) + walk_score_data = validated_data.pop("walk_score", None) + + # Update the main Property instance + instance = super().update(instance, validated_data) + + # Handle nested updates (e.g., update or create new nested objects) + if tax_info_data: + tax_info_instance, created = PropertyTaxInfo.objects.update_or_create( + property=instance, defaults=tax_info_data + ) + + if walk_score_data: + walk_score_instance, created = WalkScore.objects.update_or_create( + property=instance, defaults=walk_score_data + ) + + # 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) + if schools_data is not None: + # Example: Clear existing schools and create new ones + instance.schools.all().delete() + for school_data in schools_data: + SchoolInfo.objects.create(property=instance, **school_data) + + if sale_info_data is not None: + instance.sale_info.all().delete() + for sale_data in sale_info_data: + PropertySaleInfo.objects.create(property=instance, **sale_data) + + return instance + class VideoCategorySerializer(serializers.ModelSerializer): class Meta: @@ -518,7 +683,6 @@ class UserVideoProgressSerializer(serializers.ModelSerializer): class MessageSerializer(serializers.ModelSerializer): - class Meta: model = Message fields = [ @@ -622,7 +786,6 @@ class PasswordResetConfirmSerializer(serializers.Serializer): new_password2 = serializers.CharField(write_only=True) def validate(self, attrs): - try: token = PasswordResetToken.objects.get(token=attrs["token"]) except PasswordResetToken.DoesNotExist: @@ -650,99 +813,138 @@ class PasswordResetConfirmSerializer(serializers.Serializer): return user -class OfferRequestSerializer(serializers.ModelSerializer): - previous_offer = serializers.PrimaryKeyRelatedField(read_only=True) +# class OfferRequestSerializer(serializers.ModelSerializer): +# previous_offer = serializers.PrimaryKeyRelatedField(read_only=True) - class Meta: - model = Offer - fields = ["id", "user", "property", "status", "previous_offer", "is_active"] - read_only_fields = ["id", "created_at", "updated_at"] +# class Meta: +# model = Offer +# fields = ["id", "user", "property", "status", "previous_offer", "is_active"] +# read_only_fields = ["id", "created_at", "updated_at"] - def get_previous_offer(self, model_field): - return OfferRequestSerializer() +# def get_previous_offer(self, model_field): +# return OfferRequestSerializer() -class OfferResponseSerializer(serializers.ModelSerializer): - previous_offer = serializers.PrimaryKeyRelatedField(read_only=True) - user = UserSerializer() - property = PropertyResponseSerializer() +# class OfferResponseSerializer(serializers.ModelSerializer): +# previous_offer = serializers.PrimaryKeyRelatedField(read_only=True) +# user = UserSerializer() +# property = PropertyResponseSerializer() - class Meta: - model = Offer - fields = [ - "id", - "user", - "property", - "status", - "previous_offer", - "is_active", - "created_at", - "updated_at", - ] - read_only_fields = ["id", "created_at", "updated_at"] +# class Meta: +# model = Offer +# fields = [ +# "id", +# "user", +# "property", +# "status", +# "previous_offer", +# "is_active", +# "created_at", +# "updated_at", +# ] +# read_only_fields = ["id", "created_at", "updated_at"] - def get_previous_offer(self, model_field): - return OfferResponseSerializer() +# def get_previous_offer(self, model_field): +# return OfferResponseSerializer() + +# def validate_status(self, value): +# return value - def validate_status(self, value): - return value class BidImageSerializer(serializers.ModelSerializer): class Meta: model = BidImage fields = ["id", "image"] + class BidResponseSerializer(serializers.ModelSerializer): vendor = VendorSerializer(read_only=True) class Meta: model = BidResponse - fields = ["id", "bid", "vendor", "description", "price", "status", "created_at", "updated_at"] + fields = [ + "id", + "bid", + "vendor", + "description", + "price", + "status", + "created_at", + "updated_at", + ] read_only_fields = ["id", "created_at", "updated_at", "vendor"] + class BidSerializer(serializers.ModelSerializer): images = BidImageSerializer(many=True, read_only=True) responses = BidResponseSerializer(many=True, read_only=True) class Meta: model = Bid - fields = ["id", "property", "description", "bid_type", "location", "created_at", "updated_at", "images", "responses"] + fields = [ + "id", + "property", + "description", + "bid_type", + "location", + "created_at", + "updated_at", + "images", + "responses", + ] read_only_fields = ["id", "created_at", "updated_at", "responses"] def create(self, validated_data): - images_data = self.context.get('request').FILES.getlist('images') + images_data = self.context.get("request").FILES.getlist("images") bid = Bid.objects.create(**validated_data) for image_data in images_data: # Assuming you have an image upload logic, like storing to S3 and getting a URL BidImage.objects.create(bid=bid, image=image_data) return bid + class AttorneySerializer(serializers.ModelSerializer): - user = UserSerializer(read_only=True) # Nested serializer for the related User object + user = UserSerializer( + read_only=True + ) # Nested serializer for the related User object class Meta: model = Attorney fields = [ - 'user', 'firm_name', 'phone_number', 'address', 'city', - 'state', 'zip_code', 'specialties', 'years_experience', 'website', - 'profile_picture', 'bio', 'licensed_states', 'created_at', 'updated_at', + "user", + "firm_name", + "phone_number", + "address", + "city", + "state", + "zip_code", + "specialties", + "years_experience", + "website", + "profile_picture", + "bio", + "licensed_states", + "created_at", + "updated_at", "longitude", "latitude", ] - read_only_fields = ['created_at', 'updated_at'] + read_only_fields = ["created_at", "updated_at"] def create(self, validated_data): # When creating an Attorney, the User object should already exist or be created separately. # This serializer assumes the user is already linked or passed in the context. # For simplicity, we'll assume the user is passed directly to the view. # In a real scenario, you'd handle user creation/association in the view or a custom manager. - user_instance = self.context.get('user') + user_instance = self.context.get("user") if not user_instance: - raise serializers.ValidationError("User instance must be provided to create an Attorney.") + raise serializers.ValidationError( + "User instance must be provided to create an Attorney." + ) # Ensure the user_type is correctly set for the new user - if user_instance.user_type != 'attorney': - user_instance.user_type = 'attorney' + if user_instance.user_type != "attorney": + user_instance.user_type = "attorney" user_instance.save() attorney = Attorney.objects.create(user=user_instance, **validated_data) @@ -750,47 +952,99 @@ class AttorneySerializer(serializers.ModelSerializer): def update(self, instance, validated_data): # Handle updates for Attorney fields - instance.firm_name = validated_data.get('firm_name', instance.firm_name) - instance.bar_number = validated_data.get('bar_number', instance.bar_number) - instance.phone_number = validated_data.get('phone_number', instance.phone_number) - instance.address = validated_data.get('address', instance.address) - instance.city = validated_data.get('city', instance.city) - instance.state = validated_data.get('state', instance.state) - instance.zip_code = validated_data.get('zip_code', instance.zip_code) - instance.specialties = validated_data.get('specialties', instance.specialties) - instance.years_experience = validated_data.get('years_experience', instance.years_experience) - instance.website = validated_data.get('website', instance.website) - instance.profile_picture = validated_data.get('profile_picture', instance.profile_picture) - instance.bio = validated_data.get('bio', instance.bio) - instance.licensed_states = validated_data.get('licensed_states', instance.licensed_states) - instance.longitude = validated_data.get('longitude', instance.longitude) - instance.latitude = validated_data.get('latitude', instance.latitude) + instance.firm_name = validated_data.get("firm_name", instance.firm_name) + instance.bar_number = validated_data.get("bar_number", instance.bar_number) + instance.phone_number = validated_data.get( + "phone_number", instance.phone_number + ) + instance.address = validated_data.get("address", instance.address) + instance.city = validated_data.get("city", instance.city) + instance.state = validated_data.get("state", instance.state) + instance.zip_code = validated_data.get("zip_code", instance.zip_code) + instance.specialties = validated_data.get("specialties", instance.specialties) + instance.years_experience = validated_data.get( + "years_experience", instance.years_experience + ) + instance.website = validated_data.get("website", instance.website) + instance.profile_picture = validated_data.get( + "profile_picture", instance.profile_picture + ) + instance.bio = validated_data.get("bio", instance.bio) + instance.licensed_states = validated_data.get( + "licensed_states", instance.licensed_states + ) + instance.longitude = validated_data.get("longitude", instance.longitude) + instance.latitude = validated_data.get("latitude", instance.latitude) instance.save() return instance +class SupportAgentSerializer(serializers.ModelSerializer): + user = UserSerializer(read_only=True) + + class Meta: + model = SupportAgent + fields = [ + "user", + ] + read_only_fields = ["created_at", "updated_at"] + + def create(self, validated_data): + user_instance = self.context.get("user") + if not user_instance: + raise serializers.ValidationError( + "User instance must be provided to create a SupportAgent." + ) + + # Ensure the user_type is correctly set for the new user + if user_instance.user_type != "support_agent": + user_instance.user_type = "support_agent" + user_instance.save() + + agent = SupportAgent.objects.create(user=user_instance, **validated_data) + return agent + + class RealEstateAgentSerializer(serializers.ModelSerializer): - user = UserSerializer(read_only=True) # Nested serializer for the related User object + user = UserSerializer( + read_only=True + ) # Nested serializer for the related User object class Meta: model = RealEstateAgent fields = [ - 'user', 'brokerage_name', 'license_number', 'phone_number', 'address', 'city', - 'state', 'zip_code', 'specialties', 'years_experience', 'website', - 'profile_picture', 'bio', 'licensed_states', 'agent_type', 'created_at', 'updated_at', + "user", + "brokerage_name", + "license_number", + "phone_number", + "address", + "city", + "state", + "zip_code", + "specialties", + "years_experience", + "website", + "profile_picture", + "bio", + "licensed_states", + "agent_type", + "created_at", + "updated_at", "longitude", "latitude", ] - read_only_fields = ['created_at', 'updated_at'] + read_only_fields = ["created_at", "updated_at"] def create(self, validated_data): - user_instance = self.context.get('user') + user_instance = self.context.get("user") if not user_instance: - raise serializers.ValidationError("User instance must be provided to create a RealEstateAgent.") + raise serializers.ValidationError( + "User instance must be provided to create a RealEstateAgent." + ) # Ensure the user_type is correctly set for the new user - if user_instance.user_type != 'real_estate_agent': - user_instance.user_type = 'real_estate_agent' + if user_instance.user_type != "real_estate_agent": + user_instance.user_type = "real_estate_agent" user_instance.save() agent = RealEstateAgent.objects.create(user=user_instance, **validated_data) @@ -798,45 +1052,164 @@ class RealEstateAgentSerializer(serializers.ModelSerializer): def update(self, instance, validated_data): # Handle updates for RealEstateAgent fields - instance.brokerage_name = validated_data.get('brokerage_name', instance.brokerage_name) - instance.license_number = validated_data.get('license_number', instance.license_number) - instance.phone_number = validated_data.get('phone_number', instance.phone_number) - instance.address = validated_data.get('address', instance.address) - instance.city = validated_data.get('city', instance.city) - instance.state = validated_data.get('state', instance.state) - instance.zip_code = validated_data.get('zip_code', instance.zip_code) - instance.specialties = validated_data.get('specialties', instance.specialties) - instance.years_experience = validated_data.get('years_experience', instance.years_experience) - instance.website = validated_data.get('website', instance.website) - instance.profile_picture = validated_data.get('profile_picture', instance.profile_picture) - instance.bio = validated_data.get('bio', instance.bio) - instance.licensed_states = validated_data.get('licensed_states', instance.licensed_states) - instance.agent_type = validated_data.get('agent_type', instance.agent_type) - instance.longitude = validated_data.get('longitude', instance.longitude) - instance.latitude = validated_data.get('latitude', instance.latitude) + instance.brokerage_name = validated_data.get( + "brokerage_name", instance.brokerage_name + ) + instance.license_number = validated_data.get( + "license_number", instance.license_number + ) + instance.phone_number = validated_data.get( + "phone_number", instance.phone_number + ) + instance.address = validated_data.get("address", instance.address) + instance.city = validated_data.get("city", instance.city) + instance.state = validated_data.get("state", instance.state) + instance.zip_code = validated_data.get("zip_code", instance.zip_code) + instance.specialties = validated_data.get("specialties", instance.specialties) + instance.years_experience = validated_data.get( + "years_experience", instance.years_experience + ) + instance.website = validated_data.get("website", instance.website) + instance.profile_picture = validated_data.get( + "profile_picture", instance.profile_picture + ) + instance.bio = validated_data.get("bio", instance.bio) + instance.licensed_states = validated_data.get( + "licensed_states", instance.licensed_states + ) + instance.agent_type = validated_data.get("agent_type", instance.agent_type) + instance.longitude = validated_data.get("longitude", instance.longitude) + instance.latitude = validated_data.get("latitude", instance.latitude) instance.save() return instance + class PropertySaveSerializer(serializers.ModelSerializer): """ Serializer for the PropertySave model. """ + property = serializers.PrimaryKeyRelatedField(queryset=Property.objects.all()) user = serializers.PrimaryKeyRelatedField(read_only=True) class Meta: model = PropertySave - fields = ['id', 'user', 'property', 'created_at'] - read_only_fields = ['created_at'] + fields = ["id", "user", "property", "created_at"] + read_only_fields = ["created_at"] def validate(self, data): """ Check for a unique user-property combination before creation. """ - user = self.context['request'].user - property_id = data.get('property').id + user = self.context["request"].user + property_id = data.get("property").id if PropertySave.objects.filter(user=user, property=property_id).exists(): raise serializers.ValidationError( "This property is already saved by the user." ) return data + + +class OfferSerializer(serializers.ModelSerializer): + # parent_offer = 'self' + parent_offer = serializers.SerializerMethodField() + + class Meta: + model = OfferDocument + + # 'document' field is excluded as it's set by the view after creating the generic Document + exclude = ["document"] + read_only_fields = ["parent_offer"] + + def get_parent_offer(self, obj): + if obj.parent_offer: + # Use the same serializer recursively for the parent offer + return OfferSerializer(obj.parent_offer).data + return None + + +class SellerDisclosureSerializer(serializers.ModelSerializer): + class Meta: + model = SellerDisclosure + fields = [ + "general_defects", + "roof_condition", + "roof_age", + "known_roof_leaks", + "plumbing_issues", + "electrical_issues", + "hvac_condition", + "hvac_age", + "known_lead_paint", + "known_asbestos", + "known_radon", + "past_water_damage", + "structural_issues", + "neighborhood_nuisances", + "property_line_disputes", + "appliances_included", + ] + # exclude = ['document'] + + +class HomeImprovementReceiptSerializer(serializers.ModelSerializer): + class Meta: + model = HomeImprovementReceipt + exclude = ["document"] + + +class SupportMessageSerializer(serializers.ModelSerializer): + user_first_name = serializers.CharField(source="user.first_name", read_only=True) + user_last_name = serializers.CharField(source="user.last_name", read_only=True) + + class Meta: + model = SupportMessage + fields = [ + "id", + "text", + "support_case", + "user", + "created_at", + "updated_at", + "user_first_name", + "user_last_name", + ] + read_only_fields = ["created_at", "updated_at", "user"] + + +class SupportCaseListSerializer(serializers.ModelSerializer): + class Meta: + model = SupportCase + fields = [ + "id", + "title", + "description", + "category", + "status", + "user", + "updated_at", + ] + read_only_fields = ["created_at", "updated_at", "user"] + + +class SupportCaseDetailSerializer(serializers.ModelSerializer): + messages = SupportMessageSerializer(many=True, read_only=True) + + class Meta: + model = SupportCase + fields = [ + "id", + "title", + "description", + "category", + "status", + "user", + "messages", + ] + read_only_fields = ["created_at", "updated_at", "user"] + + +class FAQSerializer(serializers.ModelSerializer): + class Meta: + model = FAQ + fields = ["order", "question", "answer"] diff --git a/dta_service/core/services/email_service.py b/dta_service/core/services/email_service.py new file mode 100644 index 0000000..12ffa75 --- /dev/null +++ b/dta_service/core/services/email_service.py @@ -0,0 +1,64 @@ +from core.models import User, Vendor, Bid, OfferDocument, Document +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.template.loader import get_template +from django.template import Context + +class EmailService(object): + def __init__(self): + self.from_email: str = "info@ditchtheagent.com" + + def send_email(self, subject: str, template_name: str, context: dict, to_email: str | list[str]) -> None: + # NOTE: to_email can be a singular address (str) or a list of emails (list) + # TODO: make a text version of each email + html_content = get_template(f"emails/{template_name}.html").render(context) + text_content = get_template(f"emails/{template_name}.txt").render(context) + to = [to_email] if isinstance(to_email, str) else to_email + msg = EmailMultiAlternatives(subject, text_content, self.from_email, to) + msg.attach_alternative(html_content, "text/html") + msg.send(fail_silently=True) + + def send_registration_email(self, user: User, activation_link: str) -> None: + print('Sending a registration email') + context = { + "display_name": user.first_name if user.first_name else user.email, + "activation_link": "This_is@not.com" + } + self.send_email("Account Created", "user_registration_email", context, user.email) + + def send_password_reset_email(self, user:User) -> None: + context = {} + self.send_email("Password Reset", "password_reset_email", context, user.email) + + def send_password_change_email(self, user:User) -> None: + context = {} + self.send_email("Password Updated", "password_change_email", context, user.email) + + def send_new_bid_email(self, bid:Bid, vendors:list[Vendor]) -> None: + context = {"bid_title": bid.bid_type} + emails = Vendor.objects.values_list('user__email', flat=True) + self.send_email("New bid available", "new_bid_email", context, list(emails)) + + def send_document_shared_email(self, users:list[User]) -> None: + emails = User.objects.values_list('email', flat=True) + context = {} + self.send_email("New document shared with you", "document_shared_email", context, list(emails)) + + def send_bid_response_email(self, bid:Bid) -> None: + context = {} + self.send_email("New bid response", "bid_response", context, bid.property.property_owner.user.email) + + def send_new_offer_email(self, offer: OfferDocument) -> None: + pass + + def send_updated_offer_email(self, offer: OfferDocument) -> None: + pass + + def send_account_upgrade_email(self, user: User) -> None: + pass + + def send_weekly_report_email(self, user:User) -> None: + pass + + # TODO: Open house information here diff --git a/dta_service/core/services/property_description_generator.py b/dta_service/core/services/property_description_generator.py index b16bb97..b415a67 100644 --- a/dta_service/core/services/property_description_generator.py +++ b/dta_service/core/services/property_description_generator.py @@ -19,6 +19,10 @@ class PropertyDescriptionGenerator(BaseService): def _setup_chain(self): template = """You are an expert real estate copywriter specializing in creating compelling residential property listings. Write a detailed, engaging description for the following property by incorporating its features, local context, and market appeal. + **User Information:** + - User: {owner_name} + - Tier: {owner_tier} + **Property Details:** - Address: {address} - City: {city} @@ -31,11 +35,20 @@ class PropertyDescriptionGenerator(BaseService): - Features: {features_list} - Coordinates: ({lat}, {lon}) + **Tax Information:** + - Tax Amount: {tax_amount} + - Tax Year: {tax_year} + + **Walk Score Information:** + - Walk Score: {walk_score} + - Walk Score Description: {walk_description} + - Transit Score: {transit_score} + - Bike Score: {bike_score} + **Instructions:** 1. First analyze the property's key selling points based on its features, size, and value proposition 2. Use the attached [property photos] to note any visible architectural styles, finishes, or unique elements 3. Make API calls (when available) to gather: - - Walkability score (0-100) from coordinates - Nearby school ratings (GreatSchools or similar) - Distance/time to major downtown areas - Notable nearby amenities (parks, transit, shopping) @@ -54,7 +67,6 @@ class PropertyDescriptionGenerator(BaseService): - End with a 'Schedule your showing today!' variation **API Tools Available (call if needed):** - - get_walkability_score(lat, lon) - get_school_ratings(zip_code) - get_nearby_amenities(lat, lon, radius=1mi) - get_downtown_distance(lat, lon) @@ -76,6 +88,14 @@ class PropertyDescriptionGenerator(BaseService): "features_list": lambda x: x["features_list"], "lat": lambda x: x["lat"], "lon": lambda x: x["lon"], + "owner_name": lambda x: x["owner_name"], + "owner_tier": lambda x: x["owner_tier"], + "tax_amount": lambda x: x["tax_amount"], + "tax_year": lambda x: x["tax_year"], + "walk_score": lambda x: x["walk_score"], + "walk_description": lambda x: x["walk_description"], + "transit_score": lambda x: x["transit_score"], + "bike_score": lambda x: x["bike_score"], } | self.prompt | self.llm @@ -85,6 +105,10 @@ class PropertyDescriptionGenerator(BaseService): def generate_response( self, property: Property, **kwargs ) -> Generator[str, None, None]: + owner = property.owner.user + tax_info = getattr(property, "tax_info", None) + walk_score_info = getattr(property, "walk_score", None) + chain_input = { "address": property.address, "city": property.city, @@ -97,6 +121,14 @@ class PropertyDescriptionGenerator(BaseService): "features_list": property.features, "lat": property.latitude, "lon": property.longitude, + "owner_name": owner.get_full_name(), + "owner_tier": owner.tier, + "tax_amount": getattr(tax_info, "tax_amount", "N/A"), + "tax_year": getattr(tax_info, "year", "N/A"), + "walk_score": getattr(walk_score_info, "walk_score", "N/A"), + "walk_description": getattr(walk_score_info, "walk_description", "N/A"), + "transit_score": getattr(walk_score_info, "transit_score", "N/A"), + "bike_score": getattr(walk_score_info, "bike_score", "N/A"), } return self.conversation_chain.invoke(chain_input) diff --git a/dta_service/core/templates/emails/base_template.html b/dta_service/core/templates/emails/base_template.html new file mode 100644 index 0000000..addc517 --- /dev/null +++ b/dta_service/core/templates/emails/base_template.html @@ -0,0 +1,160 @@ + + + + + + + {% block title %}Email{% endblock %} + + + + +
+
+
+

+ {% block header_title %}Django App{% endblock %} +

+
+ +
+ {% block content %} + + {% endblock %} +
+ + +
+
+ + diff --git a/dta_service/core/templates/emails/bid_response.html b/dta_service/core/templates/emails/bid_response.html new file mode 100644 index 0000000..b481f74 --- /dev/null +++ b/dta_service/core/templates/emails/bid_response.html @@ -0,0 +1,29 @@ +{% extends 'emails/base_email.html' %} {% block header_title %}Response to Your +Bid{% endblock %} {% block content %} +

Hello {{ user.first_name|default:user.username }},

+

+ There has been a new response to your bid titled + "{{ bid_title }}". +

+

You can view the response by clicking the button below:

+
+ + View Response + +
+

Thank you for using our platform!

+{% endblock %} diff --git a/dta_service/core/templates/emails/document_shared_email.html b/dta_service/core/templates/emails/document_shared_email.html new file mode 100644 index 0000000..92cc1f7 --- /dev/null +++ b/dta_service/core/templates/emails/document_shared_email.html @@ -0,0 +1,29 @@ +{% extends 'emails/base_email.html' %} {% block header_title %}Document Shared +With You{% endblock %} {% block content %} +

Hello {{ user.first_name|default:user.username }},

+

+ A document titled "{{ document_name }}" has been shared + with you by {{ sharer_name }}. +

+

You can view the document now by clicking the button below:

+
+ + View Document + +
+

If you have any questions, please contact {{ sharer_name }}.

+{% endblock %} diff --git a/dta_service/core/templates/emails/new_bid_email.html b/dta_service/core/templates/emails/new_bid_email.html new file mode 100644 index 0000000..fac231d --- /dev/null +++ b/dta_service/core/templates/emails/new_bid_email.html @@ -0,0 +1,29 @@ +{% extends 'emails/base_email.html' %} {% block header_title %}New Bid +Available{% endblock %} {% block content %} +

Hello {{ user.first_name|default:user.username }},

+

+ A new bid titled "{{ bid_title }}" is now available for you + to review and respond to. +

+

To view the bid details, please click the button below:

+
+ + View Bid + +
+

We look forward to your response!

+{% endblock %} diff --git a/dta_service/core/templates/emails/password_change_email.html b/dta_service/core/templates/emails/password_change_email.html new file mode 100644 index 0000000..3c5cad3 --- /dev/null +++ b/dta_service/core/templates/emails/password_change_email.html @@ -0,0 +1,15 @@ +{% extends 'emails/base_email.html' %} {% block header_title %}Password +Changed{% endblock %} {% block content %} +

Hello {{ user.first_name|default:user.username }},

+

+ This is an automated confirmation to let you know that the password for your + account was recently changed. +

+

If you made this change, you can safely ignore this email.

+

+ If you did not change your password, please contact support immediately + to secure your account. +

+{% endblock %} diff --git a/dta_service/core/templates/emails/password_reset_email.html b/dta_service/core/templates/emails/password_reset_email.html new file mode 100644 index 0000000..1c5eda7 --- /dev/null +++ b/dta_service/core/templates/emails/password_reset_email.html @@ -0,0 +1,38 @@ +{% extends 'emails/base_email.html' %} {% block header_title %}Password Reset +Request{% endblock %} {% block content %} +

Hello {{ user.first_name|default:user.username }},

+

+ We received a request to reset the password for your account. If you did not + make this request, you can safely ignore this email. +

+

To reset your password, please click the link below:

+
+ + Reset Password + +
+

+ If the button above does not work, please copy and paste the following link + into your web browser: +

+

+ {{ reset_link }} +

+

This link will expire in a few hours for security reasons.

+{% endblock %} diff --git a/dta_service/core/templates/emails/user_registration_email.html b/dta_service/core/templates/emails/user_registration_email.html new file mode 100644 index 0000000..5340fa0 --- /dev/null +++ b/dta_service/core/templates/emails/user_registration_email.html @@ -0,0 +1,37 @@ +{% extends 'emails/base_email.html' %} {% block header_title %}Welcome to Ditch +The Agent!{% endblock %} {% block content %} +

Hello {{ display_name }},

+

Thank you for registering with us. We're excited to have you on board!

+

+ Please confirm your email address by clicking the button below to activate + your account: +

+
+ + Confirm Account + +
+

+ If the button above does not work, please copy and paste the following link + into your web browser: +

+

+ {{ activation_link }} +

+{% endblock %} diff --git a/dta_service/core/templates/password_reset_email.html b/dta_service/core/templates/password_reset_email.html deleted file mode 100644 index a084d3a..0000000 --- a/dta_service/core/templates/password_reset_email.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - Password Reset - - -

Hello {{ user.first_name }},

- -

You're receiving this email because you requested a password reset for your account.

- -

Please click the following link to reset your password:

- -

{{ reset_url }}

- -

If you didn't request this, please ignore this email.

- -

Thanks,
- The Real Estate App Team

- - \ No newline at end of file diff --git a/dta_service/core/urls/document.py b/dta_service/core/urls/document.py new file mode 100644 index 0000000..f6d365f --- /dev/null +++ b/dta_service/core/urls/document.py @@ -0,0 +1,8 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from core.views import DocumentViewSet + +router = DefaultRouter() +router.register(r"", DocumentViewSet, basename="document") + +urlpatterns = router.urls diff --git a/dta_service/core/urls/property.py b/dta_service/core/urls/property.py index 4ccb6b5..7402c41 100644 --- a/dta_service/core/urls/property.py +++ b/dta_service/core/urls/property.py @@ -1,9 +1,10 @@ from django.urls import path from rest_framework.routers import DefaultRouter -from core.views import PropertyViewSet, PropertyPictureViewSet +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"", PropertyViewSet, basename="property") diff --git a/dta_service/core/urls/public.py b/dta_service/core/urls/public.py new file mode 100644 index 0000000..f38dc51 --- /dev/null +++ b/dta_service/core/urls/public.py @@ -0,0 +1,12 @@ +from django.urls import path +from core.public_views import public + +urlpatterns = [ + path("", public.PropertyList.as_view(), name="property-list"), + path("/", public.PropertyDetail.as_view(), name="property-detail"), + path( + "/increment_view_count/", + public.PropertyIncrementCount.as_view(), + name="property-detail-increment-count", + ), +] diff --git a/dta_service/core/urls/support.py b/dta_service/core/urls/support.py new file mode 100644 index 0000000..755bdcf --- /dev/null +++ b/dta_service/core/urls/support.py @@ -0,0 +1,12 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from core.views import SupportCaseViewSet, SupportMessageViewSet, FAQViewSet + +router = DefaultRouter() +router.register(r"cases", SupportCaseViewSet) +router.register(r"messages", SupportMessageViewSet) +router.register(r"faq", FAQViewSet) + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/dta_service/core/urls/support_agent.py b/dta_service/core/urls/support_agent.py new file mode 100644 index 0000000..d3e7e9e --- /dev/null +++ b/dta_service/core/urls/support_agent.py @@ -0,0 +1,8 @@ +from django.urls import path +from rest_framework.routers import DefaultRouter +from core.views import SupportAgentViewSet + +router = DefaultRouter() +router.register(r"", SupportAgentViewSet, basename="support_agent") + +urlpatterns = router.urls diff --git a/dta_service/core/utils.py b/dta_service/core/utils.py new file mode 100644 index 0000000..72a3a45 --- /dev/null +++ b/dta_service/core/utils.py @@ -0,0 +1,17 @@ +import math + + +def haversine_distance(lat1, lon1, lat2, lon2): + R = 3958.8 # Earth radius in miles + lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2]) + + dlat = lat2 - lat1 + dlon = lon2 - lon1 + + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(lat1) * math.cos(lat2) * math.sin(dlon / 2) ** 2 + ) + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + return R * c diff --git a/dta_service/core/views.py b/dta_service/core/views.py index b4ea220..6e0294a 100644 --- a/dta_service/core/views.py +++ b/dta_service/core/views.py @@ -1,6 +1,7 @@ from rest_framework import generics, permissions, status, viewsets from rest_framework.response import Response from rest_framework.views import APIView +from django.db import transaction from rest_framework_simplejwt.views import TokenObtainPairView from rest_framework_simplejwt.tokens import RefreshToken import requests @@ -10,19 +11,35 @@ from django.shortcuts import get_object_or_404 from .models import ( PropertyOwner, Vendor, + SupportAgent, Property, VideoCategory, Video, UserVideoProgress, Conversation, Message, - Offer, + OfferDocument, PropertyWalkScoreInfo, PropertyTaxInfo, - SchoolInfo,Bid, BidResponse, Attorney, RealEstateAgent, UserViewModel, PropertySave + SchoolInfo, + Bid, + BidResponse, + Attorney, + RealEstateAgent, + UserViewModel, + PropertySave, + Document, + SellerDisclosure, + HomeImprovementReceipt, + OfferDocument, + OpenHouse, + SupportCase, + SupportMessage, + FAQ, ) from .serializers import ( CustomTokenObtainPairSerializer, + SupportAgentSerializer, UserSerializer, UserRegisterSerializer, PropertyOwnerSerializer, @@ -37,9 +54,23 @@ from .serializers import ( MessageSerializer, PasswordResetRequestSerializer, PasswordResetConfirmSerializer, - OfferRequestSerializer, - OfferResponseSerializer, - PropertyPictureSerializer, BidSerializer, BidResponseSerializer, AttorneySerializer, RealEstateAgentSerializer, PropertySaveSerializer + # OfferRequestSerializer, + # OfferResponseSerializer, + PropertyPictureSerializer, + BidSerializer, + BidResponseSerializer, + AttorneySerializer, + RealEstateAgentSerializer, + PropertySaveSerializer, + DocumentSerializer, + OfferSerializer, + SellerDisclosureSerializer, + HomeImprovementReceiptSerializer, + OpenHouseSerializer, + SupportMessageSerializer, + SupportCaseDetailSerializer, + SupportCaseListSerializer, + FAQSerializer, ) from rest_framework.permissions import IsAuthenticated from .permissions import ( @@ -48,12 +79,16 @@ from .permissions import ( IsVendor, IsParticipant, IsParticipantInOffer, + IsSupportAgent, + IsPropertyOwnerOrVendorOrAttorney, ) from django_filters.rest_framework import DjangoFilterBackend from rest_framework import filters -from django.db.models import Q +from django.db.models import Q, Prefetch from .services.property_description_generator import PropertyDescriptionGenerator -from .filters import PropertyFilterSet +from .filters import PropertyFilterSet, VendorFilterSet + +from .services.email_service import EmailService User = get_user_model() @@ -67,6 +102,25 @@ class UserRegisterView(generics.CreateAPIView): serializer_class = UserRegisterSerializer permission_classes = [permissions.AllowAny] + def perform_create(self, serializer): + # This will save the user instance and return the object + user = serializer.save() + + # Here you would typically generate an activation link. + # This is a placeholder as the exact link generation depends on your + # front-end URL and activation logic (e.g., using a token). + # For a full implementation, you would need to generate a unique token + # and include it in the URL. + # activation_link = "http://your-frontend-url.com/activate/" + + # Call the email-sending function with the newly created user object + # and the activation link. + EmailService.send_registration_email(user, activation_link) + + # You can optionally modify the response data here if needed. + # For example, to not return all user data. + return Response(serializer.data, status=status.HTTP_201_CREATED) + class UserRetrieveView(generics.RetrieveAPIView, generics.UpdateAPIView): permission_classes = [IsAuthenticated] @@ -114,6 +168,47 @@ class LogoutView(APIView): return Response(status=status.HTTP_400_BAD_REQUEST) +class OpenHouseViewSet(viewsets.ModelViewSet): + """ + A ViewSet for viewing and editing OpenHouse instances. + """ + + queryset = OpenHouse.objects.all() + serializer_class = OpenHouseSerializer + permission_classes = [IsAuthenticated, IsPropertyOwner] + + def get_queryset(self): + """ + This view should return a list of all the open houses + for the currently authenticated property owner. + """ + user = self.request.user + if user.is_authenticated and hasattr(user, "propertyowner"): + return OpenHouse.objects.filter( + property__owner=user.propertyowner + ).order_by("start_time") + return OpenHouse.objects.none() + + def perform_create(self, serializer): + """ + Ensures that the property being assigned to the open house belongs + to the authenticated property owner. + """ + property_id = self.request.data.get("property") + try: + property_instance = Property.objects.get( + id=property_id, owner__user=self.request.user + ) + serializer.save(property=property_instance) + except Property.DoesNotExist: + return Response( + { + "detail": "You do not have permission to schedule an open house for this property." + }, + status=status.HTTP_403_FORBIDDEN, + ) + + class PasswordResetRequestView(APIView): permission_classes = [permissions.AllowAny] @@ -151,7 +246,7 @@ class PropertyOwnerViewSet(viewsets.ModelViewSet): def get_queryset(self): user = self.request.user if user.user_type == "property_owner": - if(PropertyOwner.objects.filter(user=user).count() == 0): + if PropertyOwner.objects.filter(user=user).count() == 0: return PropertyOwner.objects.create( user=user, ) @@ -164,6 +259,7 @@ class VendorViewSet(viewsets.ModelViewSet): serializer_class = VendorSerializer permission_classes = [IsAuthenticated] filter_backends = [DjangoFilterBackend, filters.SearchFilter] + filterset_class = VendorFilterSet search_fields = [ "business_name", "user__first_name", @@ -174,7 +270,7 @@ class VendorViewSet(viewsets.ModelViewSet): lookup_field = "user__id" # or 'user__id' if you want to be explicit def get_permissions(self): - if self.action in ['increment_view_count', 'increment_save_count']: + if self.action in ["increment_view_count", "increment_save_count"]: permission_classes = [IsAuthenticated] else: permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] @@ -201,24 +297,21 @@ class VendorViewSet(viewsets.ModelViewSet): def update(self, request, *args, **kwargs): # The update method will now handle the vendor profile correctly # and ignore any user data in the payload. - partial = kwargs.pop('partial', False) + partial = kwargs.pop("partial", False) instance = self.get_object() serializer = self.get_serializer(instance, data=request.data, partial=partial) serializer.is_valid(raise_exception=True) self.perform_update(serializer) return Response(serializer.data) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def increment_view_count(self, request, user__id=None): vendor_obj = Vendor.objects.get(user__id=user__id) vendor_obj.views += 1 vendor_obj.save() - UserViewModel.objects.create( - user_id=user__id - ) - - return Response({'views': vendor_obj.views}, status=status.HTTP_200_OK) + UserViewModel.objects.create(user_id=user__id) + return Response({"views": vendor_obj.views}, status=status.HTTP_200_OK) # Attorney ViewSet @@ -243,6 +336,7 @@ class AttorneyViewSet(viewsets.ModelViewSet): else: return Attorney.objects.all() + # Real Estate Agent ViewSet class RealEstateAgentViewSet(viewsets.ModelViewSet): serializer_class = RealEstateAgentSerializer @@ -262,13 +356,28 @@ class RealEstateAgentViewSet(viewsets.ModelViewSet): return RealEstateAgent.objects.all() +class SupportAgentViewSet(viewsets.ModelViewSet): + serializer_class = SupportAgentSerializer + permission_classes = [IsSupportAgent] + + def perform_create(self, serializer): + # Link to the currently authenticated user + serializer.save(user=self.request.user) + + def get_queryset(self): + user = self.request.user + if not SupportAgent.objects.filter(user=user).exists(): + return SupportAgent.objects.create(user=user) + return SupportAgent.objects.filter(user=user) + + class PropertyViewSet(viewsets.ModelViewSet): filter_backends = [DjangoFilterBackend, filters.SearchFilter] filterset_class = PropertyFilterSet search_fields = ["address", "city", "state", "zip_code"] def get_permissions(self): - if self.action in ['increment_view_count', 'increment_save_count']: + if self.action in ["increment_view_count", "increment_save_count"]: permission_classes = [IsAuthenticated] else: permission_classes = [IsAuthenticated, IsOwnerOrReadOnly] @@ -299,35 +408,43 @@ class PropertyViewSet(viewsets.ModelViewSet): return Property.objects.all() def perform_create(self, serializer): - if self.request.user.user_type == "property_owner": owner = PropertyOwner.objects.get(user=self.request.user) ## attempt to get the walkscore - res = requests.get(f'https://api.walkscore.com/score?format=json&address={self.request.data['address']}&lat={self.request.data['latitude']}&lon={self.request.data['longitude']}&transit=1&bike=1&wsapikey={'4430c9adf62a4d93cd1efbdcd018b4d4'}') + res = requests.get( + f"https://api.walkscore.com/score?format=json&address={self.request.data['address']}&lat={self.request.data['latitude']}&lon={self.request.data['longitude']}&transit=1&bike=1&wsapikey={'4430c9adf62a4d93cd1efbdcd018b4d4'}" + ) if res.ok: data = res.json() - has_transit = data.get('transit') - has_bike = data.get('bike') + has_transit = data.get("transit") + has_bike = data.get("bike") walk_score = PropertyWalkScoreInfo.objects.create( - walk_score = data.get('walkscore'), - 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, - bike_score = data.get('bike').get('score') if has_bike else None, - bike_description = data.get('bike').get('description') if has_bike else None, + walk_score=data.get("walkscore"), + 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, + bike_score=data.get("bike").get("score") 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) else: serializer.save(owner=owner) else: serializer.save() - @action(detail=True, methods=['post']) + + @action(detail=True, methods=["post"]) def increment_view_count(self, request, pk=None): property_obj = self.get_object() property_obj.views += 1 @@ -336,14 +453,14 @@ class PropertyViewSet(viewsets.ModelViewSet): # UserViewModel.objects.create( # user__id=pk # ) - return Response({'views': property_obj.views}, status=status.HTTP_200_OK) + return Response({"views": property_obj.views}, status=status.HTTP_200_OK) - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def increment_save_count(self, request, pk=None): property_obj = self.get_object() property_obj.saves += 1 property_obj.save() - return Response({'saves': property_obj.saves}, status=status.HTTP_200_OK) + return Response({"saves": property_obj.saves}, status=status.HTTP_200_OK) class PropertyPictureViewSet(viewsets.ModelViewSet): @@ -395,7 +512,6 @@ class VideoViewSet(viewsets.ModelViewSet): class UserVideoProgressViewSet(viewsets.ModelViewSet): - serializer_class = UserVideoProgressSerializer permission_classes = [IsAuthenticated] @@ -414,7 +530,6 @@ class UserVideoProgressViewSet(viewsets.ModelViewSet): class ConversationViewSet(viewsets.ModelViewSet): - permission_classes = [IsAuthenticated, IsParticipant] search_fields = ["vendor", "property_owner"] @@ -453,8 +568,6 @@ class ConversationViewSet(viewsets.ModelViewSet): serializer.save(vendor=vendor) - - class MessageViewSet(viewsets.ModelViewSet): serializer_class = MessageSerializer permission_classes = [IsAuthenticated, IsParticipant] @@ -477,61 +590,46 @@ class MessageViewSet(viewsets.ModelViewSet): return context -class OfferViewSet(viewsets.ModelViewSet): - - permission_classes = [IsAuthenticated, IsParticipantInOffer] - - def get_serializer_class(self): - """ - Returns the serializer class to use depending on the action. - - For 'list' and 'retrieve' (read operations), use PropertyResponseSerializer. - - For 'create', 'update', 'partial_update' (write operations), use PropertyRequestSerializer. - """ - if self.action in ["list", "retrieve"]: - return OfferResponseSerializer - return OfferRequestSerializer - - def get_queryset(self): - user_lookup = Q(user=self.request.user) - property_lookup = Q(property__owner__user=self.request.user) - null_previous_offer = Q(previous_offer=None) - return Offer.objects.filter( - (property_lookup & null_previous_offer) - | (user_lookup & null_previous_offer) - ) - class BidViewSet(viewsets.ModelViewSet): permission_classes = [IsAuthenticated] def get_queryset(self): user = self.request.user - if user.user_type == 'property_owner': - return Bid.objects.filter(property__owner__user=user).order_by('-created_at') - elif user.user_type == 'vendor': + if user.user_type == "property_owner": + return Bid.objects.filter(property__owner__user=user).order_by( + "-created_at" + ) + elif user.user_type == "vendor": # Vendors should see all bids, but only their own responses - return Bid.objects.all().order_by('-created_at') + return Bid.objects.all().order_by("-created_at") return Bid.objects.none() def get_serializer_class(self): return BidSerializer - @action(detail=True, methods=['post']) + @action(detail=True, methods=["post"]) def select_response(self, request, pk=None): bid = self.get_object() - response_id = request.data.get('response_id') + response_id = request.data.get("response_id") try: response = BidResponse.objects.get(id=response_id, bid=bid) # Ensure the current user is the property owner of the bid if request.user == bid.property.owner.user: # Unselect any previously selected response for this bid - BidResponse.objects.filter(bid=bid, status='selected').update(status='submitted') + BidResponse.objects.filter(bid=bid, status="selected").update( + status="submitted" + ) # Select the new response - response.status = 'selected' + response.status = "selected" response.save() - return Response({'status': 'response selected'}) - return Response({'error': 'You do not have permission to perform this action.'}, status=403) + return Response({"status": "response selected"}) + return Response( + {"error": "You do not have permission to perform this action."}, + status=403, + ) except BidResponse.DoesNotExist: - return Response({'error': 'Response not found.'}, status=404) + return Response({"error": "Response not found."}, status=404) + class BidResponseViewSet(viewsets.ModelViewSet): serializer_class = BidResponseSerializer @@ -539,31 +637,34 @@ class BidResponseViewSet(viewsets.ModelViewSet): def get_queryset(self): user = self.request.user - if user.user_type == 'property_owner': - return BidResponse.objects.filter(bid__property__owner__user=user).order_by('-created_at') - elif user.user_type == 'vendor': - return BidResponse.objects.filter(vendor__user=user).order_by('-created_at') + if user.user_type == "property_owner": + return BidResponse.objects.filter(bid__property__owner__user=user).order_by( + "-created_at" + ) + elif user.user_type == "vendor": + return BidResponse.objects.filter(vendor__user=user).order_by("-created_at") return BidResponse.objects.none() def perform_create(self, serializer): # A vendor can only create one response per bid - bid = serializer.validated_data['bid'] + bid = serializer.validated_data["bid"] vendor = self.request.user.vendor if BidResponse.objects.filter(bid=bid, vendor=vendor).exists(): raise serializers.ValidationError("You have already responded to this bid.") - serializer.save(vendor=vendor, status='submitted') + serializer.save(vendor=vendor, status="submitted") class PropertySaveViewSet( viewsets.mixins.CreateModelMixin, viewsets.mixins.ListModelMixin, viewsets.mixins.DestroyModelMixin, - viewsets.GenericViewSet + viewsets.GenericViewSet, ): """ A viewset that provides 'create', 'list', and 'destroy' actions for saved properties. """ + queryset = PropertySave.objects.all() serializer_class = PropertySaveSerializer permission_classes = [IsAuthenticated] @@ -574,13 +675,17 @@ class PropertySaveViewSet( for the currently authenticated user. """ user = self.request.user - return PropertySave.objects.filter(user=user).order_by('-created_at') + return PropertySave.objects.filter(user=user).order_by("-created_at") def perform_create(self, serializer): """ Saves the new PropertySave instance, associating it with the current authenticated user. """ + # update the save count for the property + property = serializer.validated_data.get("property") + property.saves += 1 + property.save() serializer.save(user=self.request.user) def destroy(self, request, *args, **kwargs): @@ -589,13 +694,370 @@ class PropertySaveViewSet( """ try: instance = self.get_object() + property = instance.property + property.saves -= 1 + property.save() self.perform_destroy(instance) return Response( {"detail": "Property successfully unsaved."}, - status=status.HTTP_204_NO_CONTENT + status=status.HTTP_204_NO_CONTENT, ) except PropertySave.DoesNotExist: return Response( {"detail": "PropertySave instance not found."}, - status=status.HTTP_404_NOT_FOUND + status=status.HTTP_404_NOT_FOUND, ) + + +class DocumentViewSet(viewsets.ModelViewSet): + serializer_class = DocumentSerializer + permission_classes = [IsAuthenticated, IsPropertyOwnerOrVendorOrAttorney] + search_fields = [ + "document_type", + ] + + def get_queryset(self): + # Only allow users to see documents for properties they are associated with. + user = self.request.user + if user.user_type == "property_owner": + # Filter for documents the user can see + # documents = Document.objects.filter( + # Q(property__owner__user=user) | Q(uploaded_by=user) | Q(shared_with=user) + # )#.exclude(offer_data__parent_offer__isnull=False) + + # # Prefetch related OfferDocument data + # offers = OfferDocument.objects.filter(parent_offer__isnull=True) + # offers_prefetch = Prefetch('offer_data', queryset=offers) + # documents = documents.prefetch_related(offers_prefetch) + + return Document.objects.filter( + ( + Q(property__owner__user=user) + | Q(uploaded_by=user) + | Q(shared_with=user) + ) + & ( + Q(offer_data__isnull=True) + | Q(offer_data__counter_offers__isnull=True) + ) + ).distinct() + # Add more logic here for vendors and attorneys to see relevant documents + # For example, for an attorney associated with a property: + # return Document.objects.filter(property__attorney__user=user) + # For now, let's keep it simple. + return Document.objects.none() + + def perform_create(self, serializer): + # Automatically link the uploader + serializer.save(uploaded_by=self.request.user) + + +class RetrieveDocumentView(generics.RetrieveAPIView, generics.UpdateAPIView): + queryset = Document.objects.all() + serializer_class = DocumentSerializer + permission_classes = [IsAuthenticated] + lookup_field = "id" + + def get_object(self): + # Override get_object to use a query parameter. + document_id = self.request.query_params.get("docId") + if not document_id: + raise NotFound(detail="docId is required.") + + try: + # We use select_related to eagerly load the related documents + # to prevent extra database queries in the serializer. + return Document.objects.select_related( + "offer_data", "seller_disclosure_data", "home_improvement_receipt_data" + ).get(id=document_id) + except Document.DoesNotExist: + raise NotFound(detail="Document not found.") + + def retrieve(self, request, *args, **kwargs): + instance = self.get_object() + serializer = self.get_serializer(instance) + # Return the data directly from the serializer. + return Response(serializer.data) + + # def get(self, request, *args, **kwargs): + # print(request, args, kwargs) + # documentId = request.query_params.get('docId', None) + # if not documentId: + # return Response( + # {"detail": "docId is required."}, + # status=status.HTTP_400_BAD_REQUEST + # ) + # try: + # document = Document.objects.get(id=documentId) + # document_serializer = DocumentSerializer(document) + # documentType = document.document_type + # if documentType == 'seller_disclosure': + + # serializer = SellerDisclosureSerializer(SellerDisclosure.objects.get(document_id=documentId)) + # elif documentType == 'offer_letter': + # serializer = OfferSerializer(OfferDocument.objects.get(document_id=documentId)) + # elif documentType == 'home_improvement_receipt': + # serializer = HomeImprovementReceiptSerializer(HomeImprovementReceipt.objects.get(document_id=documentId)) + # else: + # return Response({'error': 'couldnt find the document'}, status=status.HTTP_400_BAD_REQUEST) + + # data = { + # "document": document_serializer.data + # } + # data['document']['sub_document'] = serializer.data + + # return Response(data=data, status=status.HTTP_200_OK) + # except Exception as e: + # return Response({'error': str(e)}, status=status.HTTP_400_BAD_REQUEST) + + def patch(self, request, *args, **kwargs): + print(request, args, kwargs) + document_id = request.data.get("document_id") + action = request.data.get("action") + if not document_id or not action: + return Response( + {"error": "Need to supply both document_id and action"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + try: + with transaction.atomic(): + document = Document.objects.get(id=document_id) + if document.document_type == "offer_letter": + offer_document = OfferDocument.objects.get(document__id=document_id) + if action == "accept": + # find all of the other documents that pertain to this property and reject them + other_docs = Document.objects.filter( + property=document.property, document_type="offer_letter" + ).exclude(id=document_id) + for other_doc in other_docs: + offer_doc = OfferDocument.objects.get( + document__id=other_doc.id + ) + offer_doc.status = "rejected" + offer_doc.save() + + offer_document.status = "accepted" + offer_document.save() + + elif action == "reject": + offer_document.status = "rejected" + offer_document.save() + + elif action == "counter": + offer_document.status = "countered" + offer_document.save() + + # Create a new generic Document for the counter-offer + + new_doc = Document.objects.create( + property=document.property, + document_type="offer_letter", + description=f"Counter-offer to document ID {document.id}", + uploaded_by=request.user, + shared_with=[document.uploaded_by], + ) + # Create the new OfferDocument, linking it to the new generic document + # and the parent offer. + new_offer_doc = OfferDocument.objects.create( + document=new_doc, + parent_offer=offer_document, + offer_price=Decimal(str(new_price)), + closing_date=new_closing_date, + closing_days=new_closing_days, + contingencies=new_contingencies or "", + status="pending", # Set the status of the new offer + ) + + # Return the newly created offer data + serializer = OfferSerializer(new_offer_doc) + return Response(serializer.data, status=status.HTTP_201_CREATED) + + else: + return Response( + {"error": f"Dont understand the action: {action}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + else: + return Response( + {"error": "functionality is not implemented"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + return Response(data={}, status=status.HTTP_200_OK) + except Exception as e: + return Response({"error": str(e)}, status=status.HTTP_400_BAD_REQUEST) + + +class CreateDocumentView(APIView): + # Apply authentication if needed. For example, IsAuthenticated will ensure only logged-in users can upload. + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + # Validate property_id and document_type first + property_id = request.data.get("property") + document_type = request.data.get("document_type") + + # Ensure property_id and document_type are provided + if not property_id: + return Response( + {"detail": "Property ID is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + if not document_type: + return Response( + {"detail": "Document type is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + + property = Property.objects.get(id=property_id) + + # Check if the document_type is one of our specific types that require extra data + specific_document_types = { + "offer": OfferSerializer, + "seller_disclosure": SellerDisclosureSerializer, + "home_improvement_receipt": HomeImprovementReceiptSerializer, + } + + # Validate the generic Document data + # 'uploaded_by' can be automatically set to the current user + request.data["uploaded_by"] = ( + request.user.id if request.user.is_authenticated else None + ) + + # Initialize DocumentSerializer with all data, including file + # Make a mutable copy of request.data if it's an immutable QueryDict + mutable_data = request.data.copy() + print(mutable_data) + if mutable_data["document_type"] == "offer": + mutable_data["document_type"] = "offer_letter" + # smartly create the description + if document_type == "offer": + mutable_data["description"] = ( + f"${mutable_data['offer_price']} Offer for {property.address}" + ) + elif document_type == "seller_disclosure": + mutable_data["description"] = property.address + document_serializer = DocumentSerializer(data=mutable_data) + + with transaction.atomic(): + if document_serializer.is_valid(): + try: + # Save the generic Document instance + document = document_serializer.save() + + # If it's a specific type, validate and save its data + if document_type in specific_document_types: + specific_serializer_class = specific_document_types[ + document_type + ] + specific_data = ( + request.data.copy() + ) # Use a copy for specific serializer + + # Remove generic document fields that are not part of specific serializers + # This ensures the specific serializer only sees its relevant fields + for field in document_serializer.fields: + if field in specific_data: + del specific_data[field] + + specific_serializer = specific_serializer_class( + data=specific_data + ) + + if specific_serializer.is_valid(): + specific_instance = specific_serializer.save( + document=document + ) # Link to the document + # Prepare response data, including both generic and specific data + response_data = document_serializer.data + response_data[f"{document_type}_data"] = ( + specific_serializer.data + ) + return Response( + response_data, status=status.HTTP_201_CREATED + ) + else: + # If specific data is invalid, roll back the generic document creation + try: + # attempt to remove the document if it is already there + document.delete() + except: + pass + + return Response( + { + "detail": f"Invalid data for {document_type}.", + "errors": specific_serializer.errors, + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # For generic document types (e.g., 'other', 'attorney_contract') + return Response( + document_serializer.data, status=status.HTTP_201_CREATED + ) + + except Exception as e: + # Catch any unexpected errors during transaction + return Response( + {"detail": f"An unexpected error occurred: {str(e)}"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + else: + # If generic document data is invalid + return Response( + document_serializer.errors, status=status.HTTP_400_BAD_REQUEST + ) + + +class SupportCaseViewSet(viewsets.ModelViewSet): + queryset = SupportCase.objects.all() + permission_classes = [permissions.IsAuthenticated] + + def get_serializer_class(self): + if self.action == "retrieve": + return SupportCaseDetailSerializer + return SupportCaseListSerializer + + def get_queryset(self): + user = self.request.user + if user.user_type == "support_agent": + queryset = SupportCase.objects.all() + else: + queryset = SupportCase.objects.filter(user=user) + + status = self.request.query_params.get("status") + if status: + queryset = queryset.filter(status=status) + + category = self.request.query_params.get("category") + if category: + queryset = queryset.filter(category=category) + + return queryset + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class SupportMessageViewSet(viewsets.ModelViewSet): + queryset = SupportMessage.objects.all() + serializer_class = SupportMessageSerializer + permission_classes = [permissions.IsAuthenticated] + + def get_queryset(self): + user = self.request.user + if user.user_type == "support_agent": + return SupportMessage.objects.all() + return SupportMessage.objects.filter(support_case__user=user) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + +class FAQViewSet(viewsets.ModelViewSet): + queryset = FAQ.objects.all() + serializer_class = FAQSerializer + permission_classes = [permissions.IsAuthenticated] diff --git a/dta_service/dta_service/settings.py b/dta_service/dta_service/settings.py index 0721739..b30a9c8 100644 --- a/dta_service/dta_service/settings.py +++ b/dta_service/dta_service/settings.py @@ -200,14 +200,11 @@ SIMPLE_JWT = { 'SLIDING_TOKEN_REFRESH_LIFETIME': timedelta(days=1), } -# Email settings -# EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' -# EMAIL_HOST = config('SMTP2GO_HOST') -# EMAIL_PORT = config('SMTP2GO_PORT', cast=int) -# EMAIL_USE_TLS = True -# EMAIL_HOST_USER = config('SMTP2GO_USERNAME') -# EMAIL_HOST_PASSWORD = config('SMTP2GO_PASSWORD') -# DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL') +EMAIL_HOST = "mail.smtp2go.com" +EMAIL_HOST_USER = "info@ditchtheagent.com" +EMAIL_HOST_PASSWORD = "AkvsvMblPTCLJQGW" +EMAIL_PORT = 2525 +EMAIL_USE_TLS = True # CHANNEL_LAYERS = { diff --git a/dta_service/dta_service/urls.py b/dta_service/dta_service/urls.py index 705c54e..93e1d2d 100644 --- a/dta_service/dta_service/urls.py +++ b/dta_service/dta_service/urls.py @@ -14,54 +14,84 @@ Including another URLconf 1. Import the include() function: from django.urls import include, path 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ + from django.contrib import admin from django.urls import path, include from rest_framework_simplejwt.views import ( TokenRefreshView, ) from core.views import ( - CustomTokenObtainPairView, LogoutView, - PasswordResetRequestView, PasswordResetConfirmView, - UserRegisterView, UserRetrieveView, UserSignTosView, PropertyDescriptionView + CustomTokenObtainPairView, + LogoutView, + PasswordResetRequestView, + PasswordResetConfirmView, + UserRegisterView, + UserRetrieveView, + UserSignTosView, + PropertyDescriptionView, + CreateDocumentView, + RetrieveDocumentView, ) from django.conf import settings from django.conf.urls.static import static urlpatterns = [ - path('admin/', admin.site.urls), - + path("admin/", admin.site.urls), # Authentication - path('api/token/', CustomTokenObtainPairView.as_view(), name='token_obtain_pair'), - path('api/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), - path('api/logout/', LogoutView.as_view(), name='logout'), - path('api/register/', UserRegisterView.as_view(), name='register'), - path('api/password-reset/', PasswordResetRequestView.as_view(), name='password_reset'), - path('api/password-reset/confirm/', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), - + path("api/token/", CustomTokenObtainPairView.as_view(), name="token_obtain_pair"), + path("api/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("api/logout/", LogoutView.as_view(), name="logout"), + path("api/register/", UserRegisterView.as_view(), name="register"), + path( + "api/password-reset/", PasswordResetRequestView.as_view(), name="password_reset" + ), + path( + "api/password-reset/confirm/", + PasswordResetConfirmView.as_view(), + name="password_reset_confirm", + ), # API endpoints - path('api/user/', UserRetrieveView.as_view(), name='get_user'), - path('api/user/acknowledge_tos/', UserSignTosView.as_view(), name='sign_tos'), - path('api/property-description-generator//', PropertyDescriptionView.as_view(), name='property-description-generator'), - path('api/property-owners/', include('core.urls.property_owner')), - path('api/vendors/', include('core.urls.vendor')), - path('api/properties/', include('core.urls.property')), - path('api/videos/', include('core.urls.video')), - path('api/conversations/', include('core.urls.conversation')), - path('api/offers/', include('core.urls.offer')), - path('api/attorney/', include('core.urls.attorney')), - path('api/real_estate_agent/', include('core.urls.real_estate_agent')), - path('api/saved-properties/', include('core.urls.property_save')), - path('api/', include('core.urls.bid')), + path("api/attorney/", include("core.urls.attorney")), + path("api/document/", include("core.urls.document")), + path("api/conversations/", include("core.urls.conversation")), + # path('api/offers/', include('core.urls.offer')), + path("api/properties/", include("core.urls.property")), + path( + "api/property-description-generator//", + PropertyDescriptionView.as_view(), + name="property-description-generator", + ), + path("api/property-owners/", include("core.urls.property_owner")), + path("api/real_estate_agent/", include("core.urls.real_estate_agent")), + path("api/support_agent/", include("core.urls.support_agent")), + path("api/saved-properties/", include("core.urls.property_save")), + path("api/user/", UserRetrieveView.as_view(), name="get_user"), + path("api/user/acknowledge_tos/", UserSignTosView.as_view(), name="sign_tos"), + path("api/vendors/", include("core.urls.vendor")), + path("api/support/", include("core.urls.support")), + path("api/videos/", include("core.urls.video")), + path("api/", include("core.urls.bid")), + path("api/public/", include("core.urls.public")), + path("api/documents/upload/", CreateDocumentView.as_view(), name="document-upload"), + path( + "api/documents/retrieve/", + RetrieveDocumentView.as_view(), + name="document-retrieve", + ), ] urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + def print_patterns(patterns, base_path=""): for pattern in patterns: - if hasattr(pattern, 'url_patterns'): # It's a URLResolver + if hasattr(pattern, "url_patterns"): # It's a URLResolver print_patterns(pattern.url_patterns, base_path + str(pattern.pattern)) else: # It's a URLPattern full_path = base_path + str(pattern.pattern) view_name = pattern.callback.__module__ + "." + pattern.callback.__name__ - print(f"URL: /{full_path.replace('^', '').replace('$', '')} -> View: {view_name}") + print( + f"URL: /{full_path.replace('^', '').replace('$', '')} -> View: {view_name}" + ) + print_patterns(urlpatterns)