679 lines
22 KiB
Python
679 lines
22 KiB
Python
import binascii
|
|
import datetime
|
|
import os
|
|
import time
|
|
import uuid
|
|
|
|
from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from django.utils.text import slugify
|
|
from django.utils.timesince import timesince
|
|
from django.utils.translation import gettext_lazy as _
|
|
from common.base import BaseModel
|
|
from common.utils import (
|
|
COUNTRIES,
|
|
CURRENCY_CODES,
|
|
ROLES,
|
|
is_document_file_audio,
|
|
is_document_file_code,
|
|
is_document_file_image,
|
|
is_document_file_pdf,
|
|
is_document_file_sheet,
|
|
is_document_file_text,
|
|
is_document_file_video,
|
|
is_document_file_zip,
|
|
)
|
|
|
|
from .manager import UserManager
|
|
|
|
|
|
class User(AbstractBaseUser, PermissionsMixin):
|
|
id = models.UUIDField(
|
|
default=uuid.uuid4, unique=True, editable=False, db_index=True, primary_key=True
|
|
)
|
|
email = models.EmailField(_("email address"), blank=True, unique=True)
|
|
profile_pic = models.CharField(max_length=1000, null=True, blank=True)
|
|
activation_key = models.CharField(max_length=150, null=True, blank=True)
|
|
key_expires = models.DateTimeField(null=True, blank=True)
|
|
is_active = models.BooleanField(default=True)
|
|
is_staff = models.BooleanField(_("staff status"), default=False)
|
|
|
|
USERNAME_FIELD = "email"
|
|
REQUIRED_FIELDS = []
|
|
|
|
objects = UserManager()
|
|
|
|
class Meta:
|
|
verbose_name = "User"
|
|
verbose_name_plural = "Users"
|
|
db_table = "users"
|
|
ordering = ("-is_active",)
|
|
|
|
def __str__(self):
|
|
return self.email
|
|
|
|
|
|
class Address(BaseModel):
|
|
address_line = models.CharField(
|
|
_("Address"), max_length=255, blank=True, default=""
|
|
)
|
|
street = models.CharField(_("Street"), max_length=55, blank=True, default="")
|
|
city = models.CharField(_("City"), max_length=255, blank=True, default="")
|
|
state = models.CharField(_("State"), max_length=255, blank=True, default="")
|
|
postcode = models.CharField(
|
|
_("Post/Zip-code"), max_length=64, blank=True, default=""
|
|
)
|
|
country = models.CharField(max_length=3, choices=COUNTRIES, blank=True, default="")
|
|
org = models.ForeignKey(
|
|
"Org",
|
|
on_delete=models.CASCADE,
|
|
related_name="addresses",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Address"
|
|
verbose_name_plural = "Addresses"
|
|
db_table = "address"
|
|
ordering = ("-created_at",)
|
|
|
|
def __str__(self):
|
|
return self.city if self.city else ""
|
|
|
|
|
|
def generate_unique_key():
|
|
return str(uuid.uuid4())
|
|
|
|
|
|
class Org(BaseModel):
|
|
name = models.CharField(max_length=100, blank=True, null=True)
|
|
api_key = models.TextField(default=generate_unique_key, unique=True, editable=False)
|
|
is_active = models.BooleanField(default=True)
|
|
|
|
# Company Profile (for invoices, documents, etc.)
|
|
company_name = models.CharField(
|
|
max_length=255, blank=True, help_text="Legal company name for invoices"
|
|
)
|
|
logo = models.ImageField(
|
|
upload_to="org_logos/", blank=True, null=True, help_text="Company logo"
|
|
)
|
|
address_line = models.CharField(max_length=255, blank=True)
|
|
city = models.CharField(max_length=100, blank=True)
|
|
state = models.CharField(max_length=100, blank=True)
|
|
postcode = models.CharField(max_length=20, blank=True)
|
|
country = models.CharField(max_length=3, choices=COUNTRIES, blank=True)
|
|
phone = models.CharField(max_length=25, blank=True)
|
|
email = models.EmailField(blank=True)
|
|
website = models.URLField(blank=True)
|
|
tax_id = models.CharField(
|
|
max_length=50, blank=True, help_text="Tax ID / VAT / Registration number"
|
|
)
|
|
|
|
# Locale settings
|
|
default_currency = models.CharField(
|
|
max_length=3, choices=CURRENCY_CODES, default="USD"
|
|
)
|
|
default_country = models.CharField(
|
|
max_length=2, choices=COUNTRIES, blank=True, null=True
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Organization"
|
|
verbose_name_plural = "Organizations"
|
|
db_table = "organization"
|
|
ordering = ("-created_at",)
|
|
|
|
def __str__(self):
|
|
return str(self.name)
|
|
|
|
|
|
class Tags(BaseModel):
|
|
"""Tags for categorizing CRM entities (Accounts, Leads, Opportunities, etc.)"""
|
|
|
|
COLOR_CHOICES = (
|
|
("gray", "Gray"),
|
|
("red", "Red"),
|
|
("orange", "Orange"),
|
|
("amber", "Amber"),
|
|
("yellow", "Yellow"),
|
|
("lime", "Lime"),
|
|
("green", "Green"),
|
|
("emerald", "Emerald"),
|
|
("teal", "Teal"),
|
|
("cyan", "Cyan"),
|
|
("sky", "Sky"),
|
|
("blue", "Blue"),
|
|
("indigo", "Indigo"),
|
|
("violet", "Violet"),
|
|
("purple", "Purple"),
|
|
("fuchsia", "Fuchsia"),
|
|
("pink", "Pink"),
|
|
("rose", "Rose"),
|
|
)
|
|
|
|
name = models.CharField(max_length=50)
|
|
slug = models.CharField(max_length=50, blank=True)
|
|
color = models.CharField(max_length=20, choices=COLOR_CHOICES, default="blue")
|
|
description = models.TextField(blank=True, default="")
|
|
is_active = models.BooleanField(default=True)
|
|
org = models.ForeignKey(
|
|
"Org",
|
|
on_delete=models.CASCADE,
|
|
related_name="tags",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Tag"
|
|
verbose_name_plural = "Tags"
|
|
db_table = "tags"
|
|
ordering = ("name",)
|
|
unique_together = ["slug", "org"]
|
|
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.slug = slugify(self.name)
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class Profile(BaseModel):
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="profiles")
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="profiles")
|
|
phone = models.CharField(max_length=20, null=True, blank=True)
|
|
alternate_phone = models.CharField(max_length=20, null=True, blank=True)
|
|
address = models.ForeignKey(
|
|
Address,
|
|
related_name="address_users",
|
|
on_delete=models.CASCADE,
|
|
blank=True,
|
|
null=True,
|
|
)
|
|
role = models.CharField(max_length=50, choices=ROLES, default="USER")
|
|
has_sales_access = models.BooleanField(default=False)
|
|
has_marketing_access = models.BooleanField(default=False)
|
|
is_active = models.BooleanField(default=True)
|
|
is_organization_admin = models.BooleanField(default=False)
|
|
date_of_joining = models.DateField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Profile"
|
|
verbose_name_plural = "Profiles"
|
|
db_table = "profile"
|
|
ordering = ("-created_at",)
|
|
unique_together = [["user", "org"], ["phone", "org"]]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} <{self.org.name}>"
|
|
|
|
@property
|
|
def is_admin(self):
|
|
return self.is_organization_admin
|
|
|
|
@property
|
|
def user_details(self):
|
|
return {
|
|
"email": self.user.email,
|
|
"id": self.user.id,
|
|
"is_active": self.user.is_active,
|
|
"profile_pic": self.user.profile_pic,
|
|
"last_login": self.user.last_login,
|
|
}
|
|
|
|
|
|
class Comment(BaseModel):
|
|
"""
|
|
Generic comment model using ContentType framework.
|
|
Can be attached to any model (Account, Lead, Contact, Opportunity, Case, Task, Invoice, Profile).
|
|
"""
|
|
|
|
# Generic relation to any model
|
|
content_type = models.ForeignKey(
|
|
ContentType, on_delete=models.CASCADE, related_name="comments"
|
|
)
|
|
object_id = models.UUIDField()
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
comment = models.CharField(max_length=255)
|
|
commented_on = models.DateTimeField(auto_now_add=True)
|
|
commented_by = models.ForeignKey(
|
|
Profile, on_delete=models.CASCADE, blank=True, null=True
|
|
)
|
|
org = models.ForeignKey(
|
|
"Org",
|
|
on_delete=models.CASCADE,
|
|
related_name="comments",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Comment"
|
|
verbose_name_plural = "Comments"
|
|
db_table = "comment"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["content_type", "object_id"]),
|
|
models.Index(fields=["org", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.comment}"
|
|
|
|
def clean(self):
|
|
"""
|
|
Validate that the comment's org matches the content object's org.
|
|
|
|
SECURITY: This prevents cross-org data references where a comment
|
|
in org_a could reference an object in org_b.
|
|
"""
|
|
from django.core.exceptions import ValidationError
|
|
|
|
if self.content_object and hasattr(self.content_object, "org"):
|
|
if self.content_object.org_id != self.org_id:
|
|
raise ValidationError(
|
|
{
|
|
"org": "Comment organization must match the referenced object's organization."
|
|
}
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class CommentFiles(BaseModel):
|
|
"""
|
|
File attachments for comments.
|
|
Security: org field added for RLS protection and org-level isolation.
|
|
"""
|
|
|
|
comment = models.ForeignKey(Comment, on_delete=models.CASCADE)
|
|
comment_file = models.FileField(
|
|
"File", upload_to="CommentFiles", null=True, blank=True
|
|
)
|
|
# Security fix: Add org field for RLS protection
|
|
org = models.ForeignKey(
|
|
"Org",
|
|
on_delete=models.CASCADE,
|
|
related_name="comment_files",
|
|
null=True, # Temporarily nullable for migration
|
|
blank=True,
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "CommentFile"
|
|
verbose_name_plural = "CommentFiles"
|
|
db_table = "commentFiles"
|
|
ordering = ("-created_at",)
|
|
|
|
def __str__(self):
|
|
return f"{self.comment.comment}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
# Auto-populate org from parent comment if not set
|
|
if not self.org_id and self.comment_id:
|
|
self.org_id = self.comment.org_id
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
class Attachments(BaseModel):
|
|
"""
|
|
Generic attachment model using ContentType framework.
|
|
Can be attached to any model (Account, Lead, Contact, Opportunity, Case, Task, Invoice).
|
|
"""
|
|
|
|
# Generic relation to any model
|
|
content_type = models.ForeignKey(
|
|
ContentType, on_delete=models.CASCADE, related_name="attachments"
|
|
)
|
|
object_id = models.UUIDField()
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
file_name = models.CharField(max_length=60)
|
|
attachment = models.FileField(max_length=1001, upload_to="attachments/%Y/%m/")
|
|
org = models.ForeignKey(
|
|
"Org",
|
|
on_delete=models.CASCADE,
|
|
related_name="attachments",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Attachment"
|
|
verbose_name_plural = "Attachments"
|
|
db_table = "attachments"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["content_type", "object_id"]),
|
|
models.Index(fields=["org", "-created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.file_name}"
|
|
|
|
def file_type(self):
|
|
name_ext_list = self.attachment.url.split(".")
|
|
if len(name_ext_list) > 1:
|
|
ext = name_ext_list[int(len(name_ext_list) - 1)]
|
|
if is_document_file_audio(ext):
|
|
return ("audio", "fa fa-file-audio")
|
|
if is_document_file_video(ext):
|
|
return ("video", "fa fa-file-video")
|
|
if is_document_file_image(ext):
|
|
return ("image", "fa fa-file-image")
|
|
if is_document_file_pdf(ext):
|
|
return ("pdf", "fa fa-file-pdf")
|
|
if is_document_file_code(ext):
|
|
return ("code", "fa fa-file-code")
|
|
if is_document_file_text(ext):
|
|
return ("text", "fa fa-file-alt")
|
|
if is_document_file_sheet(ext):
|
|
return ("sheet", "fa fa-file-excel")
|
|
if is_document_file_zip(ext):
|
|
return ("zip", "fa fa-file-archive")
|
|
return ("file", "fa fa-file")
|
|
return ("file", "fa fa-file")
|
|
|
|
def clean(self):
|
|
"""
|
|
Validate that the attachment's org matches the content object's org.
|
|
|
|
SECURITY: This prevents cross-org data references where an attachment
|
|
in org_a could reference an object in org_b.
|
|
"""
|
|
from django.core.exceptions import ValidationError
|
|
|
|
if self.content_object and hasattr(self.content_object, "org"):
|
|
if self.content_object.org_id != self.org_id:
|
|
raise ValidationError(
|
|
{
|
|
"org": "Attachment organization must match the referenced object's organization."
|
|
}
|
|
)
|
|
|
|
def save(self, *args, **kwargs):
|
|
self.full_clean()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
def document_path(self, filename):
|
|
hash_ = int(time.time())
|
|
return "%s/%s/%s" % ("docs", hash_, filename)
|
|
|
|
|
|
class Document(BaseModel):
|
|
DOCUMENT_STATUS_CHOICE = (("active", "active"), ("inactive", "inactive"))
|
|
|
|
title = models.TextField(blank=True, null=True)
|
|
document_file = models.FileField(upload_to=document_path, max_length=5000)
|
|
status = models.CharField(
|
|
choices=DOCUMENT_STATUS_CHOICE, max_length=64, default="active"
|
|
)
|
|
shared_to = models.ManyToManyField(Profile, related_name="document_shared_to")
|
|
teams = models.ManyToManyField("Teams", related_name="document_teams")
|
|
org = models.ForeignKey(
|
|
Org,
|
|
on_delete=models.CASCADE,
|
|
related_name="documents",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "Document"
|
|
verbose_name_plural = "Documents"
|
|
db_table = "document"
|
|
ordering = ("-created_at",)
|
|
|
|
def __str__(self):
|
|
return f"{self.title}"
|
|
|
|
def file_type(self):
|
|
name_ext_list = self.document_file.url.split(".")
|
|
if len(name_ext_list) > 1:
|
|
ext = name_ext_list[int(len(name_ext_list) - 1)]
|
|
if is_document_file_audio(ext):
|
|
return ("audio", "fa fa-file-audio")
|
|
if is_document_file_video(ext):
|
|
return ("video", "fa fa-file-video")
|
|
if is_document_file_image(ext):
|
|
return ("image", "fa fa-file-image")
|
|
if is_document_file_pdf(ext):
|
|
return ("pdf", "fa fa-file-pdf")
|
|
if is_document_file_code(ext):
|
|
return ("code", "fa fa-file-code")
|
|
if is_document_file_text(ext):
|
|
return ("text", "fa fa-file-alt")
|
|
if is_document_file_sheet(ext):
|
|
return ("sheet", "fa fa-file-excel")
|
|
if is_document_file_zip(ext):
|
|
return ("zip", "fa fa-file-archive")
|
|
return ("file", "fa fa-file")
|
|
return ("file", "fa fa-file")
|
|
|
|
|
|
def generate_key():
|
|
# Security: Increased from 8 bytes (64 bits) to 32 bytes (256 bits)
|
|
return binascii.hexlify(os.urandom(32)).decode()
|
|
|
|
|
|
class APISettings(BaseModel):
|
|
title = models.TextField()
|
|
# Security: Increased max_length to accommodate 32-byte keys (64 hex chars)
|
|
apikey = models.CharField(max_length=64, blank=True)
|
|
website = models.URLField(max_length=255, null=True)
|
|
lead_assigned_to = models.ManyToManyField(
|
|
Profile, related_name="lead_assignee_users"
|
|
)
|
|
tags = models.ManyToManyField(Tags, blank=True)
|
|
org = models.ForeignKey(
|
|
Org,
|
|
on_delete=models.CASCADE,
|
|
related_name="api_settings",
|
|
)
|
|
|
|
class Meta:
|
|
verbose_name = "APISetting"
|
|
verbose_name_plural = "APISettings"
|
|
db_table = "apiSettings"
|
|
ordering = ("-created_at",)
|
|
|
|
def __str__(self):
|
|
return f"{self.title}"
|
|
|
|
def save(self, *args, **kwargs):
|
|
if not self.apikey or self.apikey is None or self.apikey == "":
|
|
self.apikey = generate_key()
|
|
super().save(*args, **kwargs)
|
|
|
|
|
|
# Phase 3: JWT Token Tracking
|
|
|
|
|
|
class SessionToken(BaseModel):
|
|
"""Track active JWT sessions for security"""
|
|
|
|
user = models.ForeignKey(
|
|
User, on_delete=models.CASCADE, related_name="session_tokens"
|
|
)
|
|
token_jti = models.CharField(max_length=255, unique=True, db_index=True) # JWT ID
|
|
refresh_token_jti = models.CharField(
|
|
max_length=255, unique=True, db_index=True, null=True, blank=True
|
|
)
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(blank=True, null=True)
|
|
expires_at = models.DateTimeField()
|
|
is_active = models.BooleanField(default=True)
|
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
|
last_used_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Session Token"
|
|
verbose_name_plural = "Session Tokens"
|
|
db_table = "session_token"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["user", "is_active"]),
|
|
models.Index(fields=["token_jti"]),
|
|
models.Index(fields=["expires_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user.email} - {self.token_jti[:8]}..."
|
|
|
|
def revoke(self):
|
|
"""Revoke this session token"""
|
|
from django.utils import timezone
|
|
|
|
self.is_active = False
|
|
self.revoked_at = timezone.now()
|
|
self.save()
|
|
|
|
@classmethod
|
|
def cleanup_expired(cls):
|
|
"""Remove expired tokens (call via cron/celery)"""
|
|
from django.utils import timezone
|
|
|
|
return cls.objects.filter(expires_at__lt=timezone.now()).delete()
|
|
|
|
|
|
# Activity Tracking for Recent Activities Dashboard
|
|
|
|
|
|
class Activity(BaseModel):
|
|
"""Track user activities across all CRM entities"""
|
|
|
|
ACTION_CHOICES = (
|
|
("CREATE", "Created"),
|
|
("UPDATE", "Updated"),
|
|
("DELETE", "Deleted"),
|
|
("VIEW", "Viewed"),
|
|
("COMMENT", "Commented"),
|
|
("ASSIGN", "Assigned"),
|
|
)
|
|
|
|
ENTITY_TYPE_CHOICES = (
|
|
("Account", "Account"),
|
|
("Lead", "Lead"),
|
|
("Contact", "Contact"),
|
|
("Opportunity", "Opportunity"),
|
|
("Case", "Case"),
|
|
("Task", "Task"),
|
|
("Invoice", "Invoice"),
|
|
("Event", "Event"),
|
|
("Document", "Document"),
|
|
("Team", "Team"),
|
|
)
|
|
|
|
user = models.ForeignKey(
|
|
Profile,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="activities",
|
|
)
|
|
action = models.CharField(max_length=20, choices=ACTION_CHOICES)
|
|
entity_type = models.CharField(max_length=50, choices=ENTITY_TYPE_CHOICES)
|
|
entity_id = models.UUIDField()
|
|
entity_name = models.CharField(max_length=255, blank=True, default="")
|
|
description = models.TextField(blank=True, default="")
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="activities")
|
|
|
|
class Meta:
|
|
verbose_name = "Activity"
|
|
verbose_name_plural = "Activities"
|
|
db_table = "activity"
|
|
ordering = ("-created_at",)
|
|
indexes = [
|
|
models.Index(fields=["org", "-created_at"]),
|
|
models.Index(fields=["entity_type", "entity_id"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.user} {self.get_action_display()} {self.entity_type}: {self.entity_name}"
|
|
|
|
@property
|
|
def created_on_arrow(self):
|
|
return timesince(self.created_at) + " ago"
|
|
|
|
|
|
class Teams(BaseModel):
|
|
name = models.CharField(max_length=100)
|
|
description = models.TextField()
|
|
users = models.ManyToManyField(Profile, related_name="user_teams")
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="teams")
|
|
|
|
class Meta:
|
|
verbose_name = "Team"
|
|
verbose_name_plural = "Teams"
|
|
db_table = "teams"
|
|
ordering = ("-created_at",)
|
|
|
|
def __str__(self):
|
|
return f"{self.name}"
|
|
|
|
def get_users(self):
|
|
return ",".join(
|
|
[str(_id) for _id in list(self.users.values_list("id", flat=True))]
|
|
)
|
|
|
|
|
|
class ContactFormSubmission(BaseModel):
|
|
"""
|
|
Contact form submission from public website.
|
|
Stores inquiries from potential customers via contact forms.
|
|
Not org-scoped as these are platform-level submissions.
|
|
"""
|
|
|
|
name = models.CharField(max_length=255)
|
|
email = models.EmailField()
|
|
message = models.TextField()
|
|
reason = models.CharField(
|
|
max_length=100,
|
|
choices=[
|
|
("general", "General Inquiry"),
|
|
("sales", "Sales Question"),
|
|
("support", "Technical Support"),
|
|
("partnership", "Partnership Opportunity"),
|
|
("other", "Other"),
|
|
],
|
|
default="general",
|
|
)
|
|
|
|
# Tracking fields
|
|
ip_address = models.GenericIPAddressField(null=True, blank=True)
|
|
user_agent = models.TextField(null=True, blank=True)
|
|
referrer = models.URLField(max_length=500, null=True, blank=True)
|
|
|
|
# Status tracking
|
|
STATUS_CHOICES = [
|
|
("new", "New"),
|
|
("read", "Read"),
|
|
("replied", "Replied"),
|
|
("closed", "Closed"),
|
|
]
|
|
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="new")
|
|
replied_by = models.ForeignKey(
|
|
User,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name="contact_replies",
|
|
)
|
|
replied_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
class Meta:
|
|
verbose_name = "Contact Form Submission"
|
|
verbose_name_plural = "Contact Form Submissions"
|
|
db_table = "contact_form_submission"
|
|
ordering = ["-created_at"]
|
|
indexes = [
|
|
models.Index(fields=["email"]),
|
|
models.Index(fields=["status"]),
|
|
models.Index(fields=["created_at"]),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"{self.name} - {self.email} ({self.reason})"
|
|
|
|
|
|
# Import SecurityAuditLog so Django discovers it for migrations
|
|
from common.audit_log import SecurityAuditLog
|