Compare commits
3 Commits
5a3b76f74b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 905d87fd31 | |||
| bc62611cc4 | |||
| a05062dc11 |
@@ -27,6 +27,7 @@ from core.models import (
|
|||||||
SupportCase,
|
SupportCase,
|
||||||
SupportMessage,
|
SupportMessage,
|
||||||
FAQ,
|
FAQ,
|
||||||
|
OneTimePasscode,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
"email",
|
"email",
|
||||||
"first_name",
|
"first_name",
|
||||||
"last_name",
|
"last_name",
|
||||||
|
"uuid4",
|
||||||
"is_active",
|
"is_active",
|
||||||
"is_staff",
|
"is_staff",
|
||||||
"has_usable_password",
|
"has_usable_password",
|
||||||
@@ -44,7 +46,7 @@ class UserAdmin(admin.ModelAdmin):
|
|||||||
# "has_signed_tos",
|
# "has_signed_tos",
|
||||||
"last_login",
|
"last_login",
|
||||||
)
|
)
|
||||||
search_fields = ("fields", "email", "first_name", "last_name", "user_type")
|
search_fields = ("fields", "email", "first_name", "last_name", "user_type", "uuid4")
|
||||||
|
|
||||||
|
|
||||||
class PropertyOwnerAdmin(admin.ModelAdmin):
|
class PropertyOwnerAdmin(admin.ModelAdmin):
|
||||||
@@ -137,6 +139,7 @@ class PropertyAdmin(admin.ModelAdmin):
|
|||||||
model = Property
|
model = Property
|
||||||
list_display = (
|
list_display = (
|
||||||
"pk",
|
"pk",
|
||||||
|
"uuid4",
|
||||||
"owner",
|
"owner",
|
||||||
"address",
|
"address",
|
||||||
"city",
|
"city",
|
||||||
@@ -144,7 +147,7 @@ class PropertyAdmin(admin.ModelAdmin):
|
|||||||
"zip_code",
|
"zip_code",
|
||||||
"property_status",
|
"property_status",
|
||||||
)
|
)
|
||||||
search_fields = ("address", "city", "state", "zip_code", "owner")
|
search_fields = ("address", "city", "state", "zip_code", "owner", "uuid4")
|
||||||
inlines = [PropertyWalkScoreInfoStackedInline, SchoolInfoStackedInline]
|
inlines = [PropertyWalkScoreInfoStackedInline, SchoolInfoStackedInline]
|
||||||
|
|
||||||
|
|
||||||
@@ -287,7 +290,8 @@ class PropertySaveAdmin(admin.ModelAdmin):
|
|||||||
|
|
||||||
@admin.register(Document)
|
@admin.register(Document)
|
||||||
class DocumentAdmin(admin.ModelAdmin):
|
class DocumentAdmin(admin.ModelAdmin):
|
||||||
list_display = ("document_type", "property", "uploaded_by")
|
list_display = ("document_type", "property", "uploaded_by", "uuid4")
|
||||||
|
search_fields = ("document_type", "uuid4")
|
||||||
|
|
||||||
|
|
||||||
@admin.register(SellerDisclosure)
|
@admin.register(SellerDisclosure)
|
||||||
@@ -428,3 +432,11 @@ admin.site.register(PropertySaleInfo, PropertySaleInfoAdmin)
|
|||||||
admin.site.register(PropertyTaxInfo, PropertyTaxInfoAdmin)
|
admin.site.register(PropertyTaxInfo, PropertyTaxInfoAdmin)
|
||||||
admin.site.register(PropertyWalkScoreInfo, PropertyWalkScoreInfoAdmin)
|
admin.site.register(PropertyWalkScoreInfo, PropertyWalkScoreInfoAdmin)
|
||||||
admin.site.register(SchoolInfo, SchoolInfoAdmin)
|
admin.site.register(SchoolInfo, SchoolInfoAdmin)
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(OneTimePasscode)
|
||||||
|
class OneTimePasscodeAdmin(admin.ModelAdmin):
|
||||||
|
list_display = ("user", "code", "purpose", "created_at", "expires_at", "used")
|
||||||
|
list_filter = ("purpose", "used")
|
||||||
|
search_fields = ("user__email", "code")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-12-15 15:39
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0035_alter_document_document_type_alter_user_is_active_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='document',
|
||||||
|
name='uuid4',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='property',
|
||||||
|
name='uuid4',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, null=True),
|
||||||
|
),
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='uuid4',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
# Generated by Django 5.2.4 on 2025-12-15 15:41
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
def populate_uuids(apps, schema_editor):
|
||||||
|
User = apps.get_model('core', 'User')
|
||||||
|
Document = apps.get_model('core', 'Document')
|
||||||
|
Property = apps.get_model('core', 'Property')
|
||||||
|
|
||||||
|
for obj in User.objects.filter(uuid4__isnull=True):
|
||||||
|
obj.uuid4 = uuid.uuid4()
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
for obj in Document.objects.filter(uuid4__isnull=True):
|
||||||
|
obj.uuid4 = uuid.uuid4()
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
for obj in Property.objects.filter(uuid4__isnull=True):
|
||||||
|
obj.uuid4 = uuid.uuid4()
|
||||||
|
obj.save()
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0036_document_uuid4_property_uuid4_user_uuid4'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(populate_uuids, migrations.RunPython.noop),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='document',
|
||||||
|
name='uuid4',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='property',
|
||||||
|
name='uuid4',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='uuid4',
|
||||||
|
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
import uuid
|
||||||
from .property import Property
|
from .property import Property
|
||||||
from .user import User
|
from .user import User
|
||||||
|
|
||||||
@@ -24,6 +25,8 @@ class Document(models.Model):
|
|||||||
Property, on_delete=models.CASCADE, related_name="documents"
|
Property, on_delete=models.CASCADE, related_name="documents"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
uuid4 = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
|
|
||||||
# The document file itself
|
# The document file itself
|
||||||
file = models.FileField(upload_to="property_documents/", blank=True, null=True)
|
file = models.FileField(upload_to="property_documents/", blank=True, null=True)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from django.db import models
|
|||||||
from .property_owner import PropertyOwner
|
from .property_owner import PropertyOwner
|
||||||
from .user import User
|
from .user import User
|
||||||
import datetime
|
import datetime
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
class Property(models.Model):
|
class Property(models.Model):
|
||||||
@@ -15,6 +16,7 @@ class Property(models.Model):
|
|||||||
owner = models.ForeignKey(
|
owner = models.ForeignKey(
|
||||||
PropertyOwner, on_delete=models.CASCADE, related_name="properties"
|
PropertyOwner, on_delete=models.CASCADE, related_name="properties"
|
||||||
)
|
)
|
||||||
|
uuid4 = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
address = models.CharField(max_length=200)
|
address = models.CharField(max_length=200)
|
||||||
street = models.CharField(max_length=200, default="")
|
street = models.CharField(max_length=200, default="")
|
||||||
city = models.CharField(max_length=100)
|
city = models.CharField(max_length=100)
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ class User(AbstractBaseUser, PermissionsMixin):
|
|||||||
("vendor", "Vendor"),
|
("vendor", "Vendor"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
uuid4 = models.UUIDField(default=uuid.uuid4, editable=False, unique=True)
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
first_name = models.CharField(max_length=30)
|
first_name = models.CharField(max_length=30)
|
||||||
last_name = models.CharField(max_length=30)
|
last_name = models.CharField(max_length=30)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class DocumentSerializer(serializers.ModelSerializer):
|
|||||||
model = Document
|
model = Document
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
|
"uuid4",
|
||||||
"property",
|
"property",
|
||||||
"file",
|
"file",
|
||||||
"document_type",
|
"document_type",
|
||||||
@@ -112,4 +113,4 @@ class DocumentSerializer(serializers.ModelSerializer):
|
|||||||
"updated_at",
|
"updated_at",
|
||||||
"sub_document",
|
"sub_document",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "created_at", "updated_at"]
|
read_only_fields = ["id", "uuid4", "created_at", "updated_at"]
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ class PublicPropertyResponseSerializer(serializers.ModelSerializer):
|
|||||||
model = Property
|
model = Property
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
|
"uuid4",
|
||||||
"address",
|
"address",
|
||||||
"street",
|
"street",
|
||||||
"city",
|
"city",
|
||||||
@@ -82,7 +83,7 @@ class PublicPropertyResponseSerializer(serializers.ModelSerializer):
|
|||||||
"tax_info",
|
"tax_info",
|
||||||
"sale_info",
|
"sale_info",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "created_at", "updated_at", "documents"]
|
read_only_fields = ["id", "uuid4", "created_at", "updated_at", "documents"]
|
||||||
|
|
||||||
|
|
||||||
class PropertyResponseSerializer(serializers.ModelSerializer):
|
class PropertyResponseSerializer(serializers.ModelSerializer):
|
||||||
@@ -99,6 +100,7 @@ class PropertyResponseSerializer(serializers.ModelSerializer):
|
|||||||
model = Property
|
model = Property
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
|
"uuid4",
|
||||||
"owner",
|
"owner",
|
||||||
"address",
|
"address",
|
||||||
"street",
|
"street",
|
||||||
@@ -132,7 +134,7 @@ class PropertyResponseSerializer(serializers.ModelSerializer):
|
|||||||
"sale_info",
|
"sale_info",
|
||||||
"documents",
|
"documents",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "created_at", "updated_at", "documents"]
|
read_only_fields = ["id", "uuid4", "created_at", "updated_at", "documents"]
|
||||||
|
|
||||||
|
|
||||||
class PropertyRequestSerializer(serializers.ModelSerializer):
|
class PropertyRequestSerializer(serializers.ModelSerializer):
|
||||||
@@ -144,6 +146,7 @@ class PropertyRequestSerializer(serializers.ModelSerializer):
|
|||||||
model = Property
|
model = Property
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
|
"uuid4",
|
||||||
"owner",
|
"owner",
|
||||||
"address",
|
"address",
|
||||||
"street",
|
"street",
|
||||||
@@ -173,7 +176,7 @@ class PropertyRequestSerializer(serializers.ModelSerializer):
|
|||||||
"sale_info",
|
"sale_info",
|
||||||
"schools",
|
"schools",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "created_at", "updated_at", "views", "saves"]
|
read_only_fields = ["id", "uuid4", "created_at", "updated_at", "views", "saves"]
|
||||||
|
|
||||||
def create(self, validated_data):
|
def create(self, validated_data):
|
||||||
# tax_info_data = validated_data.pop("tax_info")
|
# tax_info_data = validated_data.pop("tax_info")
|
||||||
|
|||||||
@@ -18,10 +18,22 @@ class SupportMessageSerializer(serializers.ModelSerializer):
|
|||||||
"user_first_name",
|
"user_first_name",
|
||||||
"user_last_name",
|
"user_last_name",
|
||||||
]
|
]
|
||||||
read_only_fields = ["created_at", "updated_at", "user", "support_case"]
|
read_only_fields = ["created_at", "updated_at", "user"]
|
||||||
|
|
||||||
|
def validate_support_case(self, value):
|
||||||
|
user = self.context["request"].user
|
||||||
|
if user.user_type != "support_agent" and value.user != user:
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"You cannot add a message to a support case that does not belong to you."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SupportCaseListSerializer(serializers.ModelSerializer):
|
class SupportCaseListSerializer(serializers.ModelSerializer):
|
||||||
|
user_email = serializers.EmailField(source="user.email", read_only=True)
|
||||||
|
user_first_name = serializers.CharField(source="user.first_name", read_only=True)
|
||||||
|
user_last_name = serializers.CharField(source="user.last_name", read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SupportCase
|
model = SupportCase
|
||||||
fields = [
|
fields = [
|
||||||
@@ -30,11 +42,22 @@ class SupportCaseListSerializer(serializers.ModelSerializer):
|
|||||||
"description",
|
"description",
|
||||||
"category",
|
"category",
|
||||||
"status",
|
"status",
|
||||||
"user",
|
"user_email",
|
||||||
|
"user_first_name",
|
||||||
|
"user_last_name",
|
||||||
|
"created_at",
|
||||||
"updated_at",
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = ["created_at", "updated_at", "user"]
|
read_only_fields = ["created_at", "updated_at", "user"]
|
||||||
|
|
||||||
|
def validate_status(self, value):
|
||||||
|
user = self.context["request"].user
|
||||||
|
if value == "closed" and user.user_type != "support_agent":
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
"You cannot close a support case unless you are a support agent."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
class SupportCaseDetailSerializer(serializers.ModelSerializer):
|
class SupportCaseDetailSerializer(serializers.ModelSerializer):
|
||||||
messages = SupportMessageSerializer(many=True, read_only=True)
|
messages = SupportMessageSerializer(many=True, read_only=True)
|
||||||
@@ -49,6 +72,8 @@ class SupportCaseDetailSerializer(serializers.ModelSerializer):
|
|||||||
"status",
|
"status",
|
||||||
"user",
|
"user",
|
||||||
"messages",
|
"messages",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
]
|
]
|
||||||
read_only_fields = ["created_at", "updated_at", "user"]
|
read_only_fields = ["created_at", "updated_at", "user"]
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
model = User
|
model = User
|
||||||
fields = [
|
fields = [
|
||||||
"id",
|
"id",
|
||||||
|
"uuid4",
|
||||||
"email",
|
"email",
|
||||||
"first_name",
|
"first_name",
|
||||||
"last_name",
|
"last_name",
|
||||||
@@ -56,7 +57,7 @@ class UserSerializer(serializers.ModelSerializer):
|
|||||||
"profile_created",
|
"profile_created",
|
||||||
"tier",
|
"tier",
|
||||||
]
|
]
|
||||||
read_only_fields = ["id", "is_active", "date_joined"]
|
read_only_fields = ["id", "uuid4", "is_active", "date_joined"]
|
||||||
|
|
||||||
|
|
||||||
class UserRegisterSerializer(serializers.ModelSerializer):
|
class UserRegisterSerializer(serializers.ModelSerializer):
|
||||||
|
|||||||
52
dta_service/core/services/email/conversation.py
Normal file
52
dta_service/core/services/email/conversation.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from core.services.email.base import BaseEmailService
|
||||||
|
from core.models import Conversation, Message
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationEmailService(BaseEmailService):
|
||||||
|
def send_new_conversation_email(self, conversation: Conversation) -> None:
|
||||||
|
conversation_link = f"{settings.FRONTEND_URL}/messages/{conversation.id}"
|
||||||
|
# Vendor receives email when conversation is started (usually by property owner)
|
||||||
|
recipient = conversation.vendor.user
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "New Conversation Started",
|
||||||
|
"message_body": f"A new conversation has been started regarding property at {conversation.property.address if conversation.property else 'a property'}.",
|
||||||
|
"action_link": conversation_link,
|
||||||
|
"action_text": "View Conversation",
|
||||||
|
"user": recipient,
|
||||||
|
}
|
||||||
|
self.send_email(
|
||||||
|
"New Conversation Started",
|
||||||
|
"new_conversation_email",
|
||||||
|
context,
|
||||||
|
recipient.email,
|
||||||
|
)
|
||||||
|
|
||||||
|
def send_new_message_email(self, message: Message) -> None:
|
||||||
|
conversation = message.conversation
|
||||||
|
conversation_link = f"{settings.FRONTEND_URL}/messages/{conversation.id}"
|
||||||
|
|
||||||
|
# Determine recipient (the other party in the conversation)
|
||||||
|
sender = message.sender
|
||||||
|
if sender == conversation.property_owner.user:
|
||||||
|
recipient = conversation.vendor.user
|
||||||
|
elif sender == conversation.vendor.user:
|
||||||
|
recipient = conversation.property_owner.user
|
||||||
|
else:
|
||||||
|
# Should not happen based on existing logic, but good to handle or log
|
||||||
|
return
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"title": "New Message Received",
|
||||||
|
"message_body": f"You have received a new message from {sender.first_name} {sender.last_name}.",
|
||||||
|
"action_link": conversation_link,
|
||||||
|
"action_text": "View Message",
|
||||||
|
"user": recipient,
|
||||||
|
}
|
||||||
|
self.send_email(
|
||||||
|
"New Message Received",
|
||||||
|
"new_message_email",
|
||||||
|
context,
|
||||||
|
recipient.email,
|
||||||
|
)
|
||||||
@@ -1,21 +1,63 @@
|
|||||||
|
from django.conf import settings
|
||||||
from core.services.email.base import BaseEmailService
|
from core.services.email.base import BaseEmailService
|
||||||
from core.models import User, OfferDocument
|
from core.models import User, OfferDocument, Document
|
||||||
|
|
||||||
|
|
||||||
class DocumentEmailService(BaseEmailService):
|
class DocumentEmailService(BaseEmailService):
|
||||||
def send_document_shared_email(self, users: list[User]) -> None:
|
def send_document_shared_email(self, document: Document, users: list[User]) -> None:
|
||||||
# NOTE: Original code fetched all users, ignoring 'users' arg. Keeping logic.
|
document_link = f"{settings.FRONTEND_URL}/documents/{document.uuid4}"
|
||||||
emails = User.objects.values_list("email", flat=True)
|
for user in users:
|
||||||
context = {}
|
context = {
|
||||||
self.send_email(
|
"document_name": str(document),
|
||||||
"New document shared with you",
|
"sharer_name": f"{document.uploaded_by.first_name} {document.uploaded_by.last_name}"
|
||||||
"document_shared_email",
|
if document.uploaded_by
|
||||||
context,
|
else "Someone",
|
||||||
list(emails),
|
"document_link": document_link,
|
||||||
)
|
"user": user,
|
||||||
|
}
|
||||||
|
self.send_email(
|
||||||
|
"New document shared with you",
|
||||||
|
"document_shared_email",
|
||||||
|
context,
|
||||||
|
user.email,
|
||||||
|
)
|
||||||
|
|
||||||
def send_new_offer_email(self, offer: OfferDocument) -> None:
|
def send_new_offer_email(self, offer: OfferDocument) -> None:
|
||||||
pass
|
document_link = f"{settings.FRONTEND_URL}/documents/{offer.document.uuid4}"
|
||||||
|
# Send to property owner
|
||||||
|
owner = offer.document.property.owner.user
|
||||||
|
context = {
|
||||||
|
"document_name": "New Offer",
|
||||||
|
"sharer_name": "A potential buyer",
|
||||||
|
"document_link": document_link,
|
||||||
|
"user": owner,
|
||||||
|
}
|
||||||
|
self.send_email(
|
||||||
|
"New Offer Received",
|
||||||
|
"document_shared_email",
|
||||||
|
context,
|
||||||
|
owner.email,
|
||||||
|
)
|
||||||
|
|
||||||
def send_updated_offer_email(self, offer: OfferDocument) -> None:
|
def send_updated_offer_email(self, offer: OfferDocument) -> None:
|
||||||
pass
|
document_link = f"{settings.FRONTEND_URL}/documents/{offer.document.uuid4}"
|
||||||
|
# Determine recipient based on who updated it?
|
||||||
|
# For now, let's assume if status is 'accepted' or 'rejected', notify the buyer (uploaded_by)
|
||||||
|
# If 'countered', notify the buyer.
|
||||||
|
# Ideally we'd know who triggered this, but for now let's notify the document uploader (buyer)
|
||||||
|
recipient = offer.document.uploaded_by
|
||||||
|
if not recipient:
|
||||||
|
return
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"document_name": f"Offer Update: {offer.get_status_display()}",
|
||||||
|
"sharer_name": "The Property Owner",
|
||||||
|
"document_link": document_link,
|
||||||
|
"user": recipient,
|
||||||
|
}
|
||||||
|
self.send_email(
|
||||||
|
f"Offer Status Update: {offer.get_status_display()}",
|
||||||
|
"document_shared_email",
|
||||||
|
context,
|
||||||
|
recipient.email,
|
||||||
|
)
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ class UserEmailService(BaseEmailService):
|
|||||||
"display_name": user.first_name if user.first_name else user.email,
|
"display_name": user.first_name if user.first_name else user.email,
|
||||||
"code": code,
|
"code": code,
|
||||||
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
|
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
|
||||||
|
"verify_link": f"{settings.FRONTEND_URL}/authentication/verify-email",
|
||||||
}
|
}
|
||||||
self.send_email(
|
self.send_email(
|
||||||
"Account Created", "user_registration_email", context, user.email
|
"Account Created", "user_registration_email", context, user.email
|
||||||
@@ -33,6 +34,7 @@ class UserEmailService(BaseEmailService):
|
|||||||
"display_name": user.first_name if user.first_name else user.email,
|
"display_name": user.first_name if user.first_name else user.email,
|
||||||
"code": code,
|
"code": code,
|
||||||
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
|
"expiration_minutes": settings.OTC_EXPIRATION_MINUTES,
|
||||||
|
"reset_link": f"{settings.FRONTEND_URL}/authentication/reset-password",
|
||||||
}
|
}
|
||||||
self.send_email("Password Reset", "password_reset_email", context, user.email)
|
self.send_email("Password Reset", "password_reset_email", context, user.email)
|
||||||
|
|
||||||
|
|||||||
146
dta_service/core/templates/emails/base_email.html
Normal file
146
dta_service/core/templates/emails/base_email.html
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>{% block title %}Email{% endblock %}</title>
|
||||||
|
<!--
|
||||||
|
NOTE: For best compatibility, modern email clients prefer inline styles.
|
||||||
|
This template uses a combination of inline styles and classes.
|
||||||
|
For production, you may want to use a tool to pre-process the classes into inline styles.
|
||||||
|
The styles are designed to mimic Material-UI's design language:
|
||||||
|
- Clean, modern typography (sans-serif)
|
||||||
|
- Elevated card-like container with rounded corners and a subtle shadow
|
||||||
|
- Primary color for call-to-action buttons
|
||||||
|
-->
|
||||||
|
<style type="text/css">
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family:
|
||||||
|
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
|
||||||
|
"Segoe UI Emoji", "Segoe UI Symbol";
|
||||||
|
background-color: #2d4a4aff;
|
||||||
|
color: #050f24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #050f24;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content p {
|
||||||
|
margin: 0 0 16px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 12px 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff !important;
|
||||||
|
background-color: #2d4a4aff;
|
||||||
|
border-radius: 4px;
|
||||||
|
text-decoration: none;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer {
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6f757e;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #27d095;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body style="margin: 0; padding: 0; background-color: #2d4a4aff">
|
||||||
|
<div class="container" style="width: 100%; max-width: 600px; margin: 0 auto; padding: 20px">
|
||||||
|
<div class="card" style="
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 24px;
|
||||||
|
">
|
||||||
|
<div class="header" style="
|
||||||
|
padding-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
">
|
||||||
|
<h1 style="
|
||||||
|
margin: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #050f24;
|
||||||
|
">
|
||||||
|
{% block header_title %}Django App{% endblock %}
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content" style="
|
||||||
|
padding-top: 20px;
|
||||||
|
padding-bottom: 20px;
|
||||||
|
line-height: 1.6;
|
||||||
|
font-size: 16px;
|
||||||
|
">
|
||||||
|
{% block content %}
|
||||||
|
<!-- Content will be inserted here by child templates -->
|
||||||
|
{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer" style="
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #e0e0e0;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #6f757e;
|
||||||
|
line-height: 1.5;
|
||||||
|
">
|
||||||
|
<p>This email was sent by Ditch the Agent.</p>
|
||||||
|
<p>Please do not reply to this email.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html lang="en">
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>{% block title %}Email{% endblock %}</title>
|
|
||||||
<!--
|
|
||||||
NOTE: For best compatibility, modern email clients prefer inline styles.
|
|
||||||
This template uses a combination of inline styles and classes.
|
|
||||||
For production, you may want to use a tool to pre-process the classes into inline styles.
|
|
||||||
The styles are designed to mimic Material-UI's design language:
|
|
||||||
- Clean, modern typography (sans-serif)
|
|
||||||
- Elevated card-like container with rounded corners and a subtle shadow
|
|
||||||
- Primary color for call-to-action buttons
|
|
||||||
-->
|
|
||||||
<style type="text/css">
|
|
||||||
body,
|
|
||||||
html {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
font-family:
|
|
||||||
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
|
||||||
"Helvetica Neue", Arial, sans-serif, "Apple Color Emoji",
|
|
||||||
"Segoe UI Emoji", "Segoe UI Symbol";
|
|
||||||
background-color: #2d4a4aff;
|
|
||||||
color: #050f24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.container {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 600px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 24px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #050f24;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
padding-top: 20px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content p {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.button {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 12px 24px;
|
|
||||||
font-size: 16px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: #ffffff !important;
|
|
||||||
background-color: #2d4a4aff;
|
|
||||||
border-radius: 4px;
|
|
||||||
text-decoration: none;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.footer {
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6f757e;
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #27d095;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body style="margin: 0; padding: 0; background-color: #2d4a4aff">
|
|
||||||
<div
|
|
||||||
class="container"
|
|
||||||
style="width: 100%; max-width: 600px; margin: 0 auto; padding: 20px"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="card"
|
|
||||||
style="
|
|
||||||
background-color: #ffffff;
|
|
||||||
border-radius: 8px;
|
|
||||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
||||||
padding: 24px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
class="header"
|
|
||||||
style="
|
|
||||||
padding-bottom: 20px;
|
|
||||||
border-bottom: 1px solid #e0e0e0;
|
|
||||||
text-align: center;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<h1
|
|
||||||
style="
|
|
||||||
margin: 0;
|
|
||||||
font-size: 24px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: #050f24;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{% block header_title %}Django App{% endblock %}
|
|
||||||
</h1>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="content"
|
|
||||||
style="
|
|
||||||
padding-top: 20px;
|
|
||||||
padding-bottom: 20px;
|
|
||||||
line-height: 1.6;
|
|
||||||
font-size: 16px;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{% block content %}
|
|
||||||
<!-- Content will be inserted here by child templates -->
|
|
||||||
{% endblock %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
class="footer"
|
|
||||||
style="
|
|
||||||
padding-top: 20px;
|
|
||||||
border-top: 1px solid #e0e0e0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 12px;
|
|
||||||
color: #6f757e;
|
|
||||||
line-height: 1.5;
|
|
||||||
"
|
|
||||||
>
|
|
||||||
<p>This email was sent by Ditch the Agent.</p>
|
|
||||||
<p>Please do not reply to this email.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
{% extends 'emails/base_email.html' %} {% block header_title %}Document Shared
|
{% extends 'emails/base_email.html' %} {% block header_title %}Document Update{% endblock %} {% block content %}
|
||||||
With You{% endblock %} {% block content %}
|
|
||||||
<p>Hello {{ user.first_name|default:user.username }},</p>
|
<p>Hello {{ user.first_name|default:user.username }},</p>
|
||||||
<p>
|
<p>
|
||||||
|
{% if custom_message %}
|
||||||
|
{{ custom_message|safe }}
|
||||||
|
{% else %}
|
||||||
A document titled <strong>"{{ document_name }}"</strong> has been shared
|
A document titled <strong>"{{ document_name }}"</strong> has been shared
|
||||||
with you by {{ sharer_name }}.
|
with you by {{ sharer_name }}.
|
||||||
|
{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<p>You can view the document now by clicking the button below:</p>
|
<p>You can view the document now by clicking the button below:</p>
|
||||||
<div style="text-align: center; margin: 24px 0">
|
<div style="text-align: center; margin: 24px 0">
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>Hello {{ user.first_name }},</p>
|
||||||
|
|
||||||
|
<p>{{ message_body }}</p>
|
||||||
|
|
||||||
|
<p>Click the button below to view the conversation:</p>
|
||||||
|
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td> <a href="{{ action_link }}" target="_blank">{{ action_text }}</a> </td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>If you have any questions, please contact support.</p>
|
||||||
|
{% endblock %}
|
||||||
27
dta_service/core/templates/emails/new_message_email.html
Normal file
27
dta_service/core/templates/emails/new_message_email.html
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "emails/base_email.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<p>Hello {{ user.first_name }},</p>
|
||||||
|
|
||||||
|
<p>{{ message_body }}</p>
|
||||||
|
|
||||||
|
<p>Click the button below to reply:</p>
|
||||||
|
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="btn btn-primary">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td align="left">
|
||||||
|
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td> <a href="{{ action_link }}" target="_blank">{{ action_text }}</a> </td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<p>If you didn't expect this message, please ignore this email.</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -1,16 +1,16 @@
|
|||||||
{% extends 'emails/base_email.html' %} {% block header_title %}Password Reset
|
{% extends 'emails/base_email.html' %} {% block header_title %}Password Reset
|
||||||
Request{% endblock %} {% block content %}
|
Request{% endblock %} {% block content %}
|
||||||
<p>Hello {{ user.first_name|default:user.username }},</p>
|
<p>Hello {{ display_name }},</p>
|
||||||
<p>
|
<p>
|
||||||
We received a request to reset the password for your account. If you did not
|
We received a request to reset the password for your account. If you did not
|
||||||
make this request, you can safely ignore this email.
|
make this request, you can safely ignore this email.
|
||||||
</p>
|
</p>
|
||||||
<p>To reset your password, please click the link below:</p>
|
<p>To reset your password, please click the link below or enter the code:</p>
|
||||||
|
<p style="text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px;">
|
||||||
|
{{ code }}
|
||||||
|
</p>
|
||||||
<div style="text-align: center; margin: 24px 0">
|
<div style="text-align: center; margin: 24px 0">
|
||||||
<a
|
<a href="{{ reset_link }}" class="button" style="
|
||||||
href="{{ reset_link }}"
|
|
||||||
class="button"
|
|
||||||
style="
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -20,8 +20,7 @@ Request{% endblock %} {% block content %}
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
"
|
">
|
||||||
>
|
|
||||||
Reset Password
|
Reset Password
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -30,9 +29,7 @@ Request{% endblock %} {% block content %}
|
|||||||
into your web browser:
|
into your web browser:
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ reset_link }}" style="word-break: break-all"
|
<a href="{{ reset_link }}" style="word-break: break-all">{{ reset_link }}</a>
|
||||||
>{{ reset_link }}</a
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
<p>This link will expire in a few hours for security reasons.</p>
|
<p>This link will expire in a few hours for security reasons.</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -3,14 +3,13 @@ The Agent!{% endblock %} {% block content %}
|
|||||||
<p>Hello {{ display_name }},</p>
|
<p>Hello {{ display_name }},</p>
|
||||||
<p>Thank you for registering with us. We're excited to have you on board!</p>
|
<p>Thank you for registering with us. We're excited to have you on board!</p>
|
||||||
<p>
|
<p>
|
||||||
Please confirm your email address by clicking the button below to activate
|
Please confirm your email address by entering the code below at the link:
|
||||||
your account:
|
</p>
|
||||||
|
<p style="text-align: center; font-size: 24px; font-weight: bold; letter-spacing: 5px;">
|
||||||
|
{{ code }}
|
||||||
</p>
|
</p>
|
||||||
<div style="text-align: center; margin: 24px 0">
|
<div style="text-align: center; margin: 24px 0">
|
||||||
<a
|
<a href="{{ verify_link }}" class="button" style="
|
||||||
href="{{ activation_link }}"
|
|
||||||
class="button"
|
|
||||||
style="
|
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
@@ -20,9 +19,8 @@ The Agent!{% endblock %} {% block content %}
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
"
|
">
|
||||||
>
|
Verify Account
|
||||||
Confirm Account
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
@@ -30,8 +28,6 @@ The Agent!{% endblock %} {% block content %}
|
|||||||
into your web browser:
|
into your web browser:
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<a href="{{ activation_link }}" style="word-break: break-all"
|
<a href="{{ verify_link }}" style="word-break: break-all">{{ verify_link }}</a>
|
||||||
>{{ activation_link }}</a
|
|
||||||
>
|
|
||||||
</p>
|
</p>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
27
dta_service/core/tests/reproduce_issue.py
Normal file
27
dta_service/core/tests/reproduce_issue.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework import status
|
||||||
|
from core.models import SupportCase
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class SupportCaseReproductionTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="user@example.com", password="password", user_type="property_owner"
|
||||||
|
)
|
||||||
|
self.case = SupportCase.objects.create(
|
||||||
|
user=self.user, title="Case 1", description="Desc 1"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_user_can_close_own_case(self):
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
data = {"status": "closed"}
|
||||||
|
response = self.client.patch(f"/api/support/cases/{self.case.id}/", data)
|
||||||
|
|
||||||
|
# If this passes (400 Bad Request), it confirms the fix that regular users cannot close cases
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.case.refresh_from_db()
|
||||||
|
self.assertNotEqual(self.case.status, "closed")
|
||||||
54
dta_service/core/tests/reproduce_uuid_lookup.py
Normal file
54
dta_service/core/tests/reproduce_uuid_lookup.py
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework import status
|
||||||
|
from core.models import Document, Property, PropertyOwner, User
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
class DocumentUUIDLookupTest(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="test@example.com",
|
||||||
|
password="password",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User"
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
|
||||||
|
self.owner_user = User.objects.create(
|
||||||
|
email="owner@example.com",
|
||||||
|
first_name="Owner",
|
||||||
|
last_name="User",
|
||||||
|
user_type="property_owner",
|
||||||
|
)
|
||||||
|
self.owner = PropertyOwner.objects.create(user=self.owner_user)
|
||||||
|
|
||||||
|
self.property = Property.objects.create(
|
||||||
|
owner=self.owner,
|
||||||
|
address="123 Test St",
|
||||||
|
city="Test City",
|
||||||
|
state="CA",
|
||||||
|
zip_code="12345",
|
||||||
|
market_value=100000,
|
||||||
|
realestate_api_id=123,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.document = Document.objects.create(
|
||||||
|
property=self.property,
|
||||||
|
document_type="other",
|
||||||
|
description="Test Document",
|
||||||
|
uploaded_by=self.user
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_retrieve_document_by_id(self):
|
||||||
|
response = self.client.get(f'/api/documents/retrieve/?docId={self.document.id}')
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['id'], self.document.id)
|
||||||
|
|
||||||
|
def test_retrieve_document_by_uuid(self):
|
||||||
|
response = self.client.get(f'/api/documents/retrieve/?docId={self.document.uuid4}')
|
||||||
|
# Expecting failure currently, so we assert 404 or 400 depending on how it fails
|
||||||
|
# But for the purpose of "reproduction", we want to see it fail if we expect success.
|
||||||
|
# So I will assert 200, and it should fail.
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data['id'], self.document.id)
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from core.models import User, PropertyOwner, Vendor, Conversation, Message, Property
|
||||||
|
from core.services.email.conversation import ConversationEmailService
|
||||||
|
|
||||||
|
|
||||||
|
class ConversationEmailServiceTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.owner_user = User.objects.create(
|
||||||
|
email="owner@example.com",
|
||||||
|
first_name="Owner",
|
||||||
|
last_name="User",
|
||||||
|
user_type="property_owner",
|
||||||
|
)
|
||||||
|
self.owner = PropertyOwner.objects.create(user=self.owner_user)
|
||||||
|
|
||||||
|
self.vendor_user = User.objects.create(
|
||||||
|
email="vendor@example.com",
|
||||||
|
first_name="Vendor",
|
||||||
|
last_name="User",
|
||||||
|
user_type="vendor",
|
||||||
|
)
|
||||||
|
self.vendor = Vendor.objects.create(user=self.vendor_user)
|
||||||
|
|
||||||
|
self.property = Property.objects.create(
|
||||||
|
owner=self.owner,
|
||||||
|
address="123 Test St",
|
||||||
|
city="Test City",
|
||||||
|
state="CA",
|
||||||
|
zip_code="12345",
|
||||||
|
market_value=100000,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.conversation = Conversation.objects.create(
|
||||||
|
property_owner=self.owner,
|
||||||
|
vendor=self.vendor,
|
||||||
|
property=self.property
|
||||||
|
)
|
||||||
|
|
||||||
|
self.service = ConversationEmailService()
|
||||||
|
|
||||||
|
@patch("core.services.email.base.BaseEmailService.send_email")
|
||||||
|
def test_send_new_conversation_email(self, mock_send_email):
|
||||||
|
self.service.send_new_conversation_email(self.conversation)
|
||||||
|
|
||||||
|
mock_send_email.assert_called_once()
|
||||||
|
args, _ = mock_send_email.call_args
|
||||||
|
subject, template, context, recipient = args
|
||||||
|
|
||||||
|
self.assertEqual(subject, "New Conversation Started")
|
||||||
|
self.assertEqual(template, "new_conversation_email")
|
||||||
|
self.assertEqual(recipient, self.vendor_user.email)
|
||||||
|
self.assertIn("action_link", context)
|
||||||
|
self.assertIn("user", context)
|
||||||
|
self.assertEqual(context["user"], self.vendor_user)
|
||||||
|
|
||||||
|
@patch("core.services.email.base.BaseEmailService.send_email")
|
||||||
|
def test_send_new_message_email(self, mock_send_email):
|
||||||
|
message = Message.objects.create(
|
||||||
|
conversation=self.conversation,
|
||||||
|
sender=self.owner_user,
|
||||||
|
text="Hello there"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.service.send_new_message_email(message)
|
||||||
|
|
||||||
|
mock_send_email.assert_called_once()
|
||||||
|
args, _ = mock_send_email.call_args
|
||||||
|
subject, template, context, recipient = args
|
||||||
|
|
||||||
|
self.assertEqual(subject, "New Message Received")
|
||||||
|
self.assertEqual(template, "new_message_email")
|
||||||
|
self.assertEqual(recipient, self.vendor_user.email)
|
||||||
|
self.assertIn("action_link", context)
|
||||||
|
self.assertIn("user", context)
|
||||||
|
self.assertEqual(context["user"], self.vendor_user)
|
||||||
65
dta_service/core/tests/test_support_message_fix.py
Normal file
65
dta_service/core/tests/test_support_message_fix.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from rest_framework.test import APIClient
|
||||||
|
from rest_framework import status
|
||||||
|
from core.models import SupportCase, SupportMessage
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class SupportMessageFixTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.client = APIClient()
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="user@example.com", password="password", user_type="property_owner"
|
||||||
|
)
|
||||||
|
self.other_user = User.objects.create_user(
|
||||||
|
email="other@example.com", password="password", user_type="property_owner"
|
||||||
|
)
|
||||||
|
self.support_agent = User.objects.create_user(
|
||||||
|
email="agent@example.com", password="password", user_type="support_agent"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.case = SupportCase.objects.create(
|
||||||
|
user=self.user, title="My Case", description="Help"
|
||||||
|
)
|
||||||
|
self.other_case = SupportCase.objects.create(
|
||||||
|
user=self.other_user, title="Other Case", description="Help other"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_create_message_success(self):
|
||||||
|
"""Test that a user can create a message for their own case."""
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
data = {
|
||||||
|
"user": self.user.id,
|
||||||
|
"text": "My reply",
|
||||||
|
"support_case": self.case.id,
|
||||||
|
}
|
||||||
|
response = self.client.post("/api/support/messages/", data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(SupportMessage.objects.count(), 1)
|
||||||
|
self.assertEqual(SupportMessage.objects.first().support_case, self.case)
|
||||||
|
|
||||||
|
def test_create_message_fail_other_case(self):
|
||||||
|
"""Test that a user cannot create a message for another user's case."""
|
||||||
|
self.client.force_authenticate(user=self.user)
|
||||||
|
data = {
|
||||||
|
"user": self.user.id,
|
||||||
|
"text": "Intruder reply",
|
||||||
|
"support_case": self.other_case.id,
|
||||||
|
}
|
||||||
|
response = self.client.post("/api/support/messages/", data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("You cannot add a message to a support case that does not belong to you.", str(response.data))
|
||||||
|
|
||||||
|
def test_support_agent_can_reply_to_any_case(self):
|
||||||
|
"""Test that a support agent can create a message for any case."""
|
||||||
|
self.client.force_authenticate(user=self.support_agent)
|
||||||
|
data = {
|
||||||
|
"user": self.support_agent.id,
|
||||||
|
"text": "Agent reply",
|
||||||
|
"support_case": self.case.id,
|
||||||
|
}
|
||||||
|
response = self.client.post("/api/support/messages/", data)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(SupportMessage.objects.count(), 1)
|
||||||
39
dta_service/core/tests/verify_serializer_update.py
Normal file
39
dta_service/core/tests/verify_serializer_update.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from core.models import SupportCase
|
||||||
|
from core.serializers import SupportCaseListSerializer
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
class SupportCaseSerializerTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.user = User.objects.create_user(
|
||||||
|
email="testuser@example.com",
|
||||||
|
password="password",
|
||||||
|
user_type="property_owner",
|
||||||
|
first_name="Test",
|
||||||
|
last_name="User",
|
||||||
|
)
|
||||||
|
self.case = SupportCase.objects.create(
|
||||||
|
user=self.user, title="Test Case", description="Test Description"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_serializer_fields(self):
|
||||||
|
serializer = SupportCaseListSerializer(self.case)
|
||||||
|
data = serializer.data
|
||||||
|
|
||||||
|
# Verify user_email is present and correct
|
||||||
|
self.assertIn("user_email", data)
|
||||||
|
self.assertEqual(data["user_email"], "testuser@example.com")
|
||||||
|
|
||||||
|
# Verify created_at is present
|
||||||
|
self.assertIn("created_at", data)
|
||||||
|
|
||||||
|
# Verify user ID is NOT present
|
||||||
|
self.assertNotIn("user", data)
|
||||||
|
|
||||||
|
# Verify user name fields are present
|
||||||
|
self.assertIn("user_first_name", data)
|
||||||
|
self.assertEqual(data["user_first_name"], "Test")
|
||||||
|
self.assertIn("user_last_name", data)
|
||||||
|
self.assertEqual(data["user_last_name"], "User")
|
||||||
@@ -7,6 +7,7 @@ from .user import (
|
|||||||
PasswordResetRequestView,
|
PasswordResetRequestView,
|
||||||
PasswordResetConfirmView,
|
PasswordResetConfirmView,
|
||||||
CheckPasscodeView,
|
CheckPasscodeView,
|
||||||
|
ResendRegistrationEmailView
|
||||||
)
|
)
|
||||||
from .property_owner import PropertyOwnerViewSet
|
from .property_owner import PropertyOwnerViewSet
|
||||||
from .vendor import VendorViewSet
|
from .vendor import VendorViewSet
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from core.serializers import (
|
|||||||
MessageSerializer,
|
MessageSerializer,
|
||||||
)
|
)
|
||||||
from core.permissions import IsParticipant
|
from core.permissions import IsParticipant
|
||||||
|
from core.services.email.conversation import ConversationEmailService
|
||||||
|
|
||||||
|
|
||||||
class ConversationViewSet(viewsets.ModelViewSet):
|
class ConversationViewSet(viewsets.ModelViewSet):
|
||||||
permission_classes = [IsAuthenticated, IsParticipant]
|
permission_classes = [IsAuthenticated, IsParticipant]
|
||||||
search_fields = ["vendor", "property_owner"]
|
search_fields = ["vendor", "property_owner"]
|
||||||
|
email_service = ConversationEmailService()
|
||||||
|
|
||||||
def get_serializer_class(self):
|
def get_serializer_class(self):
|
||||||
"""
|
"""
|
||||||
@@ -44,14 +46,17 @@ class ConversationViewSet(viewsets.ModelViewSet):
|
|||||||
if self.request.user.user_type == "property_owner":
|
if self.request.user.user_type == "property_owner":
|
||||||
owner = PropertyOwner.objects.get(user=self.request.user)
|
owner = PropertyOwner.objects.get(user=self.request.user)
|
||||||
serializer.save(property_owner=owner)
|
serializer.save(property_owner=owner)
|
||||||
elif self.request.user.user_type == "vendor":
|
|
||||||
vendor = Vendor.objects.get(user=self.request.user)
|
vendor = Vendor.objects.get(user=self.request.user)
|
||||||
serializer.save(vendor=vendor)
|
serializer.save(vendor=vendor)
|
||||||
|
|
||||||
|
# Send email notification
|
||||||
|
self.email_service.send_new_conversation_email(serializer.instance)
|
||||||
|
|
||||||
|
|
||||||
class MessageViewSet(viewsets.ModelViewSet):
|
class MessageViewSet(viewsets.ModelViewSet):
|
||||||
serializer_class = MessageSerializer
|
serializer_class = MessageSerializer
|
||||||
permission_classes = [IsAuthenticated, IsParticipant]
|
permission_classes = [IsAuthenticated, IsParticipant]
|
||||||
|
email_service = ConversationEmailService()
|
||||||
|
|
||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
conversation_id = self.kwargs.get("conversation_id")
|
conversation_id = self.kwargs.get("conversation_id")
|
||||||
@@ -64,6 +69,9 @@ class MessageViewSet(viewsets.ModelViewSet):
|
|||||||
conversation = get_object_or_404(Conversation, id=conversation_id)
|
conversation = get_object_or_404(Conversation, id=conversation_id)
|
||||||
self.check_object_permissions(self.request, conversation)
|
self.check_object_permissions(self.request, conversation)
|
||||||
serializer.save(conversation=conversation, sender=self.request.user)
|
serializer.save(conversation=conversation, sender=self.request.user)
|
||||||
|
|
||||||
|
# Send email notification
|
||||||
|
self.email_service.send_new_message_email(serializer.instance)
|
||||||
|
|
||||||
def get_serializer_context(self):
|
def get_serializer_context(self):
|
||||||
context = super().get_serializer_context()
|
context = super().get_serializer_context()
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import uuid
|
||||||
from rest_framework import viewsets, generics, status
|
from rest_framework import viewsets, generics, status
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -119,9 +120,18 @@ class RetrieveDocumentView(generics.RetrieveAPIView, generics.UpdateAPIView):
|
|||||||
try:
|
try:
|
||||||
# We use select_related to eagerly load the related documents
|
# We use select_related to eagerly load the related documents
|
||||||
# to prevent extra database queries in the serializer.
|
# to prevent extra database queries in the serializer.
|
||||||
return Document.objects.select_related(
|
queryset = Document.objects.select_related(
|
||||||
"offer_data", "seller_disclosure_data", "home_improvement_receipt_data"
|
"offer_data", "seller_disclosure_data", "home_improvement_receipt_data"
|
||||||
).get(id=document_id)
|
)
|
||||||
|
|
||||||
|
# Check if document_id is a valid UUID
|
||||||
|
try:
|
||||||
|
uuid_obj = uuid.UUID(str(document_id))
|
||||||
|
return queryset.get(uuid4=uuid_obj)
|
||||||
|
except ValueError:
|
||||||
|
# If not a UUID, assume it's an ID
|
||||||
|
return queryset.get(id=document_id)
|
||||||
|
|
||||||
except Document.DoesNotExist:
|
except Document.DoesNotExist:
|
||||||
raise NotFound(detail="Document not found.")
|
raise NotFound(detail="Document not found.")
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,45 @@ class PropertyViewSet(viewsets.ModelViewSet):
|
|||||||
|
|
||||||
return Property.objects.all()
|
return Property.objects.all()
|
||||||
|
|
||||||
|
def get_object(self):
|
||||||
|
"""
|
||||||
|
Returns the object the view is displaying.
|
||||||
|
You may want to override this if you need to provide non-standard
|
||||||
|
queryset lookups. For example if you want to
|
||||||
|
restrict objects to a user.
|
||||||
|
"""
|
||||||
|
queryset = self.filter_queryset(self.get_queryset())
|
||||||
|
|
||||||
|
# Perform the lookup filtering.
|
||||||
|
lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field
|
||||||
|
|
||||||
|
assert lookup_url_kwarg in self.kwargs, (
|
||||||
|
'Expected view %s to be called with a URL keyword argument '
|
||||||
|
'named "%s". Fix your URL conf, or set the `.lookup_field` '
|
||||||
|
'attribute on the view correctly.' %
|
||||||
|
(self.__class__.__name__, lookup_url_kwarg)
|
||||||
|
)
|
||||||
|
|
||||||
|
filter_kwargs = {self.lookup_field: self.kwargs[lookup_url_kwarg]}
|
||||||
|
|
||||||
|
# Check if the lookup value is a UUID
|
||||||
|
lookup_value = self.kwargs[lookup_url_kwarg]
|
||||||
|
import uuid
|
||||||
|
try:
|
||||||
|
uuid.UUID(str(lookup_value))
|
||||||
|
# It's a UUID, so filter by uuid4
|
||||||
|
filter_kwargs = {'uuid4': lookup_value}
|
||||||
|
except ValueError:
|
||||||
|
# Not a UUID, assume it's an ID
|
||||||
|
pass
|
||||||
|
|
||||||
|
obj = generics.get_object_or_404(queryset, **filter_kwargs)
|
||||||
|
|
||||||
|
# May raise a permission denied
|
||||||
|
self.check_object_permissions(self.request, obj)
|
||||||
|
|
||||||
|
return obj
|
||||||
|
|
||||||
def perform_create(self, serializer):
|
def perform_create(self, serializer):
|
||||||
if self.request.user.user_type == "property_owner":
|
if self.request.user.user_type == "property_owner":
|
||||||
owner = PropertyOwner.objects.get(user=self.request.user)
|
owner = PropertyOwner.objects.get(user=self.request.user)
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ class UserRegisterView(generics.CreateAPIView):
|
|||||||
|
|
||||||
# Send registration email with OTC
|
# Send registration email with OTC
|
||||||
try:
|
try:
|
||||||
EmailService.send_registration_email(user)
|
email_service = EmailService()
|
||||||
|
email_service.send_registration_email(user)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(e)
|
print(e)
|
||||||
|
|
||||||
@@ -101,6 +102,7 @@ class LogoutView(APIView):
|
|||||||
|
|
||||||
class PasswordResetRequestView(APIView):
|
class PasswordResetRequestView(APIView):
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
authentication_classes = ()
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = PasswordResetRequestSerializer(data=request.data)
|
serializer = PasswordResetRequestSerializer(data=request.data)
|
||||||
@@ -115,6 +117,7 @@ class PasswordResetRequestView(APIView):
|
|||||||
|
|
||||||
class PasswordResetConfirmView(APIView):
|
class PasswordResetConfirmView(APIView):
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
authentication_classes = ()
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
serializer = PasswordResetConfirmSerializer(data=request.data)
|
serializer = PasswordResetConfirmSerializer(data=request.data)
|
||||||
@@ -129,6 +132,7 @@ class PasswordResetConfirmView(APIView):
|
|||||||
|
|
||||||
class CheckPasscodeView(APIView):
|
class CheckPasscodeView(APIView):
|
||||||
permission_classes = [permissions.AllowAny]
|
permission_classes = [permissions.AllowAny]
|
||||||
|
authentication_classes = ()
|
||||||
|
|
||||||
def post(self, request):
|
def post(self, request):
|
||||||
email = request.data.get("email")
|
email = request.data.get("email")
|
||||||
@@ -175,3 +179,28 @@ class CheckPasscodeView(APIView):
|
|||||||
return Response(
|
return Response(
|
||||||
{"valid": False, "detail": "Invalid code."}, status=status.HTTP_200_OK
|
{"valid": False, "detail": "Invalid code."}, status=status.HTTP_200_OK
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ResendRegistrationEmailView(APIView):
|
||||||
|
permission_classes = [permissions.AllowAny]
|
||||||
|
authentication_classes = ()
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
email = request.data.get("email")
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
return Response(
|
||||||
|
{"detail": "Email is required."}, status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(email=email)
|
||||||
|
email_service = EmailService()
|
||||||
|
email_service.send_registration_email(user)
|
||||||
|
return Response(
|
||||||
|
{"detail": "Registration email sent."}, status=status.HTTP_200_OK
|
||||||
|
)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from core.views import (
|
|||||||
PropertyCompsProxyView,
|
PropertyCompsProxyView,
|
||||||
MLSDetailProxyView,
|
MLSDetailProxyView,
|
||||||
CheckPasscodeView,
|
CheckPasscodeView,
|
||||||
|
ResendRegistrationEmailView,
|
||||||
)
|
)
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.conf.urls.static import static
|
from django.conf.urls.static import static
|
||||||
@@ -80,6 +81,11 @@ urlpatterns = [
|
|||||||
CheckPasscodeView.as_view(),
|
CheckPasscodeView.as_view(),
|
||||||
name="check_passcode",
|
name="check_passcode",
|
||||||
),
|
),
|
||||||
|
path(
|
||||||
|
"api/resend-registration-email/",
|
||||||
|
ResendRegistrationEmailView.as_view(),
|
||||||
|
name="resend_registration_email",
|
||||||
|
),
|
||||||
# API endpoints
|
# API endpoints
|
||||||
path("api/attorney/", include("core.urls.attorney")),
|
path("api/attorney/", include("core.urls.attorney")),
|
||||||
path("api/document/", include("core.urls.document")),
|
path("api/document/", include("core.urls.document")),
|
||||||
|
|||||||
Reference in New Issue
Block a user