2019-02-18 08:45:58 +00:00
|
|
|
import binascii
|
2019-08-01 10:40:39 +00:00
|
|
|
import datetime
|
2019-02-18 08:45:58 +00:00
|
|
|
import os
|
2019-02-14 07:04:29 +00:00
|
|
|
import time
|
2023-10-04 05:37:23 +00:00
|
|
|
import uuid
|
2025-11-29 04:37:50 +00:00
|
|
|
|
|
|
|
|
from django.contrib.auth.models import AbstractBaseUser, AbstractUser, PermissionsMixin
|
|
|
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
|
|
|
from django.contrib.contenttypes.models import ContentType
|
2017-10-03 10:18:43 +00:00
|
|
|
from django.db import models
|
2023-01-31 17:48:08 +00:00
|
|
|
from django.utils import timezone
|
2025-11-29 04:37:50 +00:00
|
|
|
from django.utils.text import slugify
|
2025-12-06 17:40:54 +00:00
|
|
|
from django.utils.timesince import timesince
|
2022-09-22 17:04:41 +00:00
|
|
|
from django.utils.translation import gettext_lazy as _
|
2025-11-29 04:37:50 +00:00
|
|
|
from common.base import BaseModel
|
2025-12-06 17:40:54 +00:00
|
|
|
from common.utils import (
|
|
|
|
|
COUNTRIES,
|
|
|
|
|
CURRENCY_CODES,
|
|
|
|
|
ROLES,
|
2023-10-04 05:37:23 +00:00
|
|
|
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,
|
|
|
|
|
)
|
2025-11-29 04:37:50 +00:00
|
|
|
|
|
|
|
|
from .manager import UserManager
|
added profile pic for user model, updated code to upload profile pic in user edit, few changes in list pages (#112)
* updated python version in travis yml file
* added 404 & 500 pages, updated text in account, cases, leads, contact, opportunity add edit pages
* updated debug to false, added local manage.py file
* updated the code to redirect to create page only after clicking on save & new in accounts page, updated text in forgot password
* Added the relevant place holders in all the forms, displaying error message in forgot password form
* updated django version for security reasons
* fixed issue of showing column names if there is no data in list pages, fixed design in comment update section, changed the url of cancel button in user update page
* added profile pic for user model, updated code to upload profile pric in user edit, few changes in list pages
2018-10-09 13:14:55 +00:00
|
|
|
|
|
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
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)
|
2025-11-29 04:37:50 +00:00
|
|
|
profile_pic = models.CharField(max_length=1000, null=True, blank=True)
|
2023-10-04 05:37:23 +00:00
|
|
|
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)
|
2025-11-29 04:37:50 +00:00
|
|
|
is_staff = models.BooleanField(_("staff status"), default=False)
|
2023-10-04 05:37:23 +00:00
|
|
|
|
|
|
|
|
USERNAME_FIELD = "email"
|
2024-02-16 03:07:53 +00:00
|
|
|
REQUIRED_FIELDS = []
|
2023-10-04 05:37:23 +00:00
|
|
|
|
|
|
|
|
objects = UserManager()
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "User"
|
|
|
|
|
verbose_name_plural = "Users"
|
|
|
|
|
db_table = "users"
|
|
|
|
|
ordering = ("-is_active",)
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return self.email
|
|
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Address(BaseModel):
|
2021-10-06 10:22:56 +00:00
|
|
|
address_line = models.CharField(
|
2022-02-02 15:17:29 +00:00
|
|
|
_("Address"), max_length=255, blank=True, default=""
|
2021-10-06 10:22:56 +00:00
|
|
|
)
|
2022-02-02 15:17:29 +00:00
|
|
|
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="")
|
2021-10-06 10:22:56 +00:00
|
|
|
postcode = models.CharField(
|
2022-02-02 15:17:29 +00:00
|
|
|
_("Post/Zip-code"), max_length=64, blank=True, default=""
|
2021-10-06 10:22:56 +00:00
|
|
|
)
|
2022-02-02 15:17:29 +00:00
|
|
|
country = models.CharField(max_length=3, choices=COUNTRIES, blank=True, default="")
|
2025-11-29 04:37:50 +00:00
|
|
|
org = models.ForeignKey(
|
|
|
|
|
"Org",
|
|
|
|
|
on_delete=models.CASCADE,
|
|
|
|
|
related_name="addresses",
|
|
|
|
|
)
|
2021-10-06 10:22:56 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "Address"
|
|
|
|
|
verbose_name_plural = "Addresses"
|
|
|
|
|
db_table = "address"
|
|
|
|
|
ordering = ("-created_at",)
|
|
|
|
|
|
2021-10-06 10:22:56 +00:00
|
|
|
def __str__(self):
|
|
|
|
|
return self.city if self.city else ""
|
|
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
|
2023-11-02 15:56:30 +00:00
|
|
|
def generate_unique_key():
|
|
|
|
|
return str(uuid.uuid4())
|
2021-10-06 10:22:56 +00:00
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Org(BaseModel):
|
2021-10-06 10:22:56 +00:00
|
|
|
name = models.CharField(max_length=100, blank=True, null=True)
|
2025-11-29 04:37:50 +00:00
|
|
|
api_key = models.TextField(default=generate_unique_key, unique=True, editable=False)
|
2023-11-02 15:56:30 +00:00
|
|
|
is_active = models.BooleanField(default=True)
|
2025-12-06 17:40:54 +00:00
|
|
|
|
2025-12-09 08:02:12 +00:00
|
|
|
# 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"
|
|
|
|
|
)
|
|
|
|
|
|
2025-12-06 17:40:54 +00:00
|
|
|
# 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
|
|
|
|
|
)
|
2020-05-18 09:12:15 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "Organization"
|
|
|
|
|
verbose_name_plural = "Organizations"
|
|
|
|
|
db_table = "organization"
|
|
|
|
|
ordering = ("-created_at",)
|
2020-05-18 09:12:15 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
def __str__(self):
|
|
|
|
|
return str(self.name)
|
added profile pic for user model, updated code to upload profile pic in user edit, few changes in list pages (#112)
* updated python version in travis yml file
* added 404 & 500 pages, updated text in account, cases, leads, contact, opportunity add edit pages
* updated debug to false, added local manage.py file
* updated the code to redirect to create page only after clicking on save & new in accounts page, updated text in forgot password
* Added the relevant place holders in all the forms, displaying error message in forgot password form
* updated django version for security reasons
* fixed issue of showing column names if there is no data in list pages, fixed design in comment update section, changed the url of cancel button in user update page
* added profile pic for user model, updated code to upload profile pric in user edit, few changes in list pages
2018-10-09 13:14:55 +00:00
|
|
|
|
2017-10-03 10:18:43 +00:00
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
class Tags(BaseModel):
|
2025-12-09 08:02:12 +00:00
|
|
|
"""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"),
|
|
|
|
|
)
|
2025-11-29 04:37:50 +00:00
|
|
|
|
2025-12-09 08:02:12 +00:00
|
|
|
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)
|
2025-11-29 04:37:50 +00:00
|
|
|
org = models.ForeignKey(
|
|
|
|
|
"Org",
|
|
|
|
|
on_delete=models.CASCADE,
|
|
|
|
|
related_name="tags",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "Tag"
|
|
|
|
|
verbose_name_plural = "Tags"
|
|
|
|
|
db_table = "tags"
|
2025-12-09 08:02:12 +00:00
|
|
|
ordering = ("name",)
|
2025-11-29 04:37:50 +00:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Profile(BaseModel):
|
2025-11-29 04:37:50 +00:00
|
|
|
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name="profiles")
|
|
|
|
|
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="profiles")
|
2025-12-06 17:40:54 +00:00
|
|
|
phone = models.CharField(max_length=20, null=True, blank=True)
|
|
|
|
|
alternate_phone = models.CharField(max_length=20, null=True, blank=True)
|
2021-10-06 10:22:56 +00:00
|
|
|
address = models.ForeignKey(
|
|
|
|
|
Address,
|
2025-11-29 04:37:50 +00:00
|
|
|
related_name="address_users",
|
2021-10-06 10:22:56 +00:00
|
|
|
on_delete=models.CASCADE,
|
|
|
|
|
blank=True,
|
|
|
|
|
null=True,
|
2021-04-18 05:51:43 +00:00
|
|
|
)
|
2021-10-06 10:22:56 +00:00
|
|
|
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)
|
2017-10-03 10:18:43 +00:00
|
|
|
|
2021-10-06 10:22:56 +00:00
|
|
|
class Meta:
|
2023-10-04 05:37:23 +00:00
|
|
|
verbose_name = "Profile"
|
|
|
|
|
verbose_name_plural = "Profiles"
|
|
|
|
|
db_table = "profile"
|
|
|
|
|
ordering = ("-created_at",)
|
2025-11-29 04:37:50 +00:00
|
|
|
unique_together = [["user", "org"], ["phone", "org"]]
|
2023-10-04 05:37:23 +00:00
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"{self.user.email} <{self.org.name}>"
|
2017-10-03 10:18:43 +00:00
|
|
|
|
2021-10-06 10:22:56 +00:00
|
|
|
@property
|
|
|
|
|
def is_admin(self):
|
|
|
|
|
return self.is_organization_admin
|
2018-10-12 09:34:44 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
@property
|
|
|
|
|
def user_details(self):
|
2025-11-29 04:37:50 +00:00
|
|
|
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,
|
2023-10-04 05:37:23 +00:00
|
|
|
}
|
|
|
|
|
|
2018-10-12 09:34:44 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Comment(BaseModel):
|
2025-11-29 04:37:50 +00:00
|
|
|
"""
|
|
|
|
|
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"
|
2020-05-18 09:12:15 +00:00
|
|
|
)
|
2025-11-29 04:37:50 +00:00
|
|
|
object_id = models.UUIDField()
|
|
|
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
|
|
|
|
|
2017-10-03 10:18:43 +00:00
|
|
|
comment = models.CharField(max_length=255)
|
|
|
|
|
commented_on = models.DateTimeField(auto_now_add=True)
|
2019-02-12 10:06:02 +00:00
|
|
|
commented_by = models.ForeignKey(
|
2021-10-06 10:22:56 +00:00
|
|
|
Profile, on_delete=models.CASCADE, blank=True, null=True
|
2020-05-18 09:12:15 +00:00
|
|
|
)
|
2025-11-29 04:37:50 +00:00
|
|
|
org = models.ForeignKey(
|
|
|
|
|
"Org",
|
2020-05-18 09:12:15 +00:00
|
|
|
on_delete=models.CASCADE,
|
2025-11-29 04:37:50 +00:00
|
|
|
related_name="comments",
|
2020-05-18 09:12:15 +00:00
|
|
|
)
|
added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module (#238)
* added models contacts, contactlists, campaigns for marketing module & completed crud operations for email templates model in marketing, added contact list crud operations, sample csv file added for reference, updated actions icons in all list pages, fixed issue of getting closed leads in accounts create/edit pages
* completed crud operations to use email templates model, displaying contact list contacts and faile/invalid contacts, completed changes for new campaign, removed firstname and lastname as required and added title as required in leads create page, completed changes for Campaign email send with attachment
* added test cases in marketing, common modules, updated documentation and changed jquery plugin in campaign
* added mentions in comments section for all modules, added email templates when assigining a case or account, changed validation error messages in documents create and edit pages, added thumbnails for attachments in all detail pages
* added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module
2019-06-18 12:18:03 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "Comment"
|
|
|
|
|
verbose_name_plural = "Comments"
|
|
|
|
|
db_table = "comment"
|
|
|
|
|
ordering = ("-created_at",)
|
2025-11-29 04:37:50 +00:00
|
|
|
indexes = [
|
|
|
|
|
models.Index(fields=["content_type", "object_id"]),
|
|
|
|
|
models.Index(fields=["org", "-created_at"]),
|
|
|
|
|
]
|
2023-10-04 05:37:23 +00:00
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"{self.comment}"
|
|
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
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)
|
|
|
|
|
|
2017-10-03 10:18:43 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class CommentFiles(BaseModel):
|
2025-12-06 17:40:54 +00:00
|
|
|
"""
|
|
|
|
|
File attachments for comments.
|
|
|
|
|
Security: org field added for RLS protection and org-level isolation.
|
|
|
|
|
"""
|
|
|
|
|
|
2017-10-03 10:18:43 +00:00
|
|
|
comment = models.ForeignKey(Comment, on_delete=models.CASCADE)
|
2021-10-06 10:22:56 +00:00
|
|
|
comment_file = models.FileField(
|
2023-10-04 05:37:23 +00:00
|
|
|
"File", upload_to="CommentFiles", null=True, blank=True
|
2022-02-02 15:17:29 +00:00
|
|
|
)
|
2025-12-06 17:40:54 +00:00
|
|
|
# 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,
|
|
|
|
|
)
|
2017-10-03 10:18:43 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "CommentFile"
|
|
|
|
|
verbose_name_plural = "CommentFiles"
|
|
|
|
|
db_table = "commentFiles"
|
|
|
|
|
ordering = ("-created_at",)
|
|
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"{self.comment.comment}"
|
|
|
|
|
|
2025-12-06 17:40:54 +00:00
|
|
|
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)
|
2018-12-26 15:50:45 +00:00
|
|
|
|
|
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Attachments(BaseModel):
|
2025-11-29 04:37:50 +00:00
|
|
|
"""
|
|
|
|
|
Generic attachment model using ContentType framework.
|
|
|
|
|
Can be attached to any model (Account, Lead, Contact, Opportunity, Case, Task, Invoice).
|
|
|
|
|
"""
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
# Generic relation to any model
|
|
|
|
|
content_type = models.ForeignKey(
|
|
|
|
|
ContentType, on_delete=models.CASCADE, related_name="attachments"
|
2020-05-18 09:12:15 +00:00
|
|
|
)
|
2025-11-29 04:37:50 +00:00
|
|
|
object_id = models.UUIDField()
|
|
|
|
|
content_object = GenericForeignKey("content_type", "object_id")
|
added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module (#238)
* added models contacts, contactlists, campaigns for marketing module & completed crud operations for email templates model in marketing, added contact list crud operations, sample csv file added for reference, updated actions icons in all list pages, fixed issue of getting closed leads in accounts create/edit pages
* completed crud operations to use email templates model, displaying contact list contacts and faile/invalid contacts, completed changes for new campaign, removed firstname and lastname as required and added title as required in leads create page, completed changes for Campaign email send with attachment
* added test cases in marketing, common modules, updated documentation and changed jquery plugin in campaign
* added mentions in comments section for all modules, added email templates when assigining a case or account, changed validation error messages in documents create and edit pages, added thumbnails for attachments in all detail pages
* added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module
2019-06-18 12:18:03 +00:00
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
file_name = models.CharField(max_length=60)
|
|
|
|
|
attachment = models.FileField(max_length=1001, upload_to="attachments/%Y/%m/")
|
|
|
|
|
org = models.ForeignKey(
|
|
|
|
|
"Org",
|
2020-05-18 09:12:15 +00:00
|
|
|
on_delete=models.CASCADE,
|
2025-11-29 04:37:50 +00:00
|
|
|
related_name="attachments",
|
2020-05-18 09:12:15 +00:00
|
|
|
)
|
added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module (#238)
* added models contacts, contactlists, campaigns for marketing module & completed crud operations for email templates model in marketing, added contact list crud operations, sample csv file added for reference, updated actions icons in all list pages, fixed issue of getting closed leads in accounts create/edit pages
* completed crud operations to use email templates model, displaying contact list contacts and faile/invalid contacts, completed changes for new campaign, removed firstname and lastname as required and added title as required in leads create page, completed changes for Campaign email send with attachment
* added test cases in marketing, common modules, updated documentation and changed jquery plugin in campaign
* added mentions in comments section for all modules, added email templates when assigining a case or account, changed validation error messages in documents create and edit pages, added thumbnails for attachments in all detail pages
* added tags field for campaign, changed styles for marketing module, added loading spinner and validation for file fields in campaign list create page, changes in marketing module
2019-06-18 12:18:03 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Meta:
|
|
|
|
|
verbose_name = "Attachment"
|
|
|
|
|
verbose_name_plural = "Attachments"
|
|
|
|
|
db_table = "attachments"
|
|
|
|
|
ordering = ("-created_at",)
|
2025-11-29 04:37:50 +00:00
|
|
|
indexes = [
|
|
|
|
|
models.Index(fields=["content_type", "object_id"]),
|
|
|
|
|
models.Index(fields=["org", "-created_at"]),
|
|
|
|
|
]
|
2023-10-04 05:37:23 +00:00
|
|
|
|
|
|
|
|
def __str__(self):
|
|
|
|
|
return f"{self.file_name}"
|
|
|
|
|
|
2019-02-05 16:00:30 +00:00
|
|
|
def file_type(self):
|
|
|
|
|
name_ext_list = self.attachment.url.split(".")
|
2020-05-18 09:12:15 +00:00
|
|
|
if len(name_ext_list) > 1:
|
2019-02-05 16:00:30 +00:00
|
|
|
ext = name_ext_list[int(len(name_ext_list) - 1)]
|
|
|
|
|
if is_document_file_audio(ext):
|
|
|
|
|
return ("audio", "fa fa-file-audio")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_video(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("video", "fa fa-file-video")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_image(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("image", "fa fa-file-image")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_pdf(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("pdf", "fa fa-file-pdf")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_code(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("code", "fa fa-file-code")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_text(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("text", "fa fa-file-alt")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_sheet(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("sheet", "fa fa-file-excel")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_zip(ext):
|
2019-02-05 16:00:30 +00:00
|
|
|
return ("zip", "fa fa-file-archive")
|
|
|
|
|
return ("file", "fa fa-file")
|
|
|
|
|
return ("file", "fa fa-file")
|
|
|
|
|
|
2025-11-29 04:37:50 +00:00
|
|
|
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)
|
|
|
|
|
|
2019-02-05 16:00:30 +00:00
|
|
|
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
def document_path(self, filename):
|
|
|
|
|
hash_ = int(time.time())
|
|
|
|
|
return "%s/%s/%s" % ("docs", hash_, filename)
|
|
|
|
|
|
2019-02-12 10:06:02 +00:00
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class Document(BaseModel):
|
2020-05-18 09:12:15 +00:00
|
|
|
DOCUMENT_STATUS_CHOICE = (("active", "active"), ("inactive", "inactive"))
|
2019-02-04 10:00:09 +00:00
|
|
|
|
2021-10-06 10:22:56 +00:00
|
|
|
title = models.TextField(blank=True, null=True)
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
document_file = models.FileField(upload_to=document_path, max_length=5000)
|
2019-02-18 08:45:58 +00:00
|
|
|
status = models.CharField(
|
2020-05-18 09:12:15 +00:00
|
|
|
choices=DOCUMENT_STATUS_CHOICE, max_length=64, default="active"
|
|
|
|
|
)
|
2022-02-02 15:17:29 +00:00
|
|
|
shared_to = models.ManyToManyField(Profile, related_name="document_shared_to")
|
2025-11-29 04:37:50 +00:00
|
|
|
teams = models.ManyToManyField("Teams", related_name="document_teams")
|
2021-10-06 10:22:56 +00:00
|
|
|
org = models.ForeignKey(
|
2022-02-02 15:17:29 +00:00
|
|
|
Org,
|
2025-11-29 04:37:50 +00:00
|
|
|
on_delete=models.CASCADE,
|
|
|
|
|
related_name="documents",
|
2021-10-06 10:22:56 +00:00
|
|
|
)
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
|
2019-06-26 13:34:17 +00:00
|
|
|
class Meta:
|
2023-10-04 05:37:23 +00:00
|
|
|
verbose_name = "Document"
|
|
|
|
|
verbose_name_plural = "Documents"
|
|
|
|
|
db_table = "document"
|
|
|
|
|
ordering = ("-created_at",)
|
2019-06-26 13:34:17 +00:00
|
|
|
|
2021-04-18 05:30:45 +00:00
|
|
|
def __str__(self):
|
2023-10-04 05:37:23 +00:00
|
|
|
return f"{self.title}"
|
2025-11-29 04:37:50 +00:00
|
|
|
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
def file_type(self):
|
|
|
|
|
name_ext_list = self.document_file.url.split(".")
|
2020-05-18 09:12:15 +00:00
|
|
|
if len(name_ext_list) > 1:
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
ext = name_ext_list[int(len(name_ext_list) - 1)]
|
2019-02-05 16:00:30 +00:00
|
|
|
if is_document_file_audio(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("audio", "fa fa-file-audio")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_video(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("video", "fa fa-file-video")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_image(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("image", "fa fa-file-image")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_pdf(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("pdf", "fa fa-file-pdf")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_code(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("code", "fa fa-file-code")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_text(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("text", "fa fa-file-alt")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_sheet(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("sheet", "fa fa-file-excel")
|
2019-02-06 16:34:29 +00:00
|
|
|
if is_document_file_zip(ext):
|
Changes added documents functionality, displaying preview icon for documents, downloading documents files (#152)
* removed migartion files, created single migration file, updating closed on, start & end date for emails, events, cases, opportunities,
* added documents functionality, displaying preview icon for documents, downloading documents
2019-01-28 15:25:58 +00:00
|
|
|
return ("zip", "fa fa-file-archive")
|
|
|
|
|
return ("file", "fa fa-file")
|
|
|
|
|
return ("file", "fa fa-file")
|
|
|
|
|
|
2019-02-14 07:04:29 +00:00
|
|
|
|
2019-02-18 08:45:58 +00:00
|
|
|
def generate_key():
|
2025-12-06 17:40:54 +00:00
|
|
|
# Security: Increased from 8 bytes (64 bits) to 32 bytes (256 bits)
|
|
|
|
|
return binascii.hexlify(os.urandom(32)).decode()
|
2019-02-18 08:45:58 +00:00
|
|
|
|
|
|
|
|
|
2023-10-04 05:37:23 +00:00
|
|
|
class APISettings(BaseModel):
|
2021-04-18 05:30:45 +00:00
|
|
|
title = models.TextField()
|
2025-12-06 17:40:54 +00:00
|
|
|
# Security: Increased max_length to accommodate 32-byte keys (64 hex chars)
|
|
|
|
|
apikey = models.CharField(max_length=64, blank=True)
|
2021-10-06 10:22:56 +00:00
|
|
|
website = models.URLField(max_length=255, null=True)
|
|
|
|
|
lead_assigned_to = models.ManyToManyField(
|
2022-02-02 15:17:29 +00:00
|
|
|
Profile, related_name="lead_assignee_users"
|
|
|
|
|
)
|
2025-11-29 04:37:50 +00:00
|
|
|
tags = models.ManyToManyField(Tags, blank=True)
|
2021-10-06 10:22:56 +00:00
|
|
|
org = models.ForeignKey(
|
2022-02-02 15:17:29 +00:00
|
|
|
Org,
|
2025-11-29 04:37:50 +00:00
|
|
|
on_delete=models.CASCADE,
|
|
|
|
|
related_name="api_settings",
|
2022-02-02 15:17:29 +00:00
|
|
|
)
|
2019-02-18 08:45:58 +00:00
|
|
|
|
2019-06-26 13:34:17 +00:00
|
|
|
class Meta:
|
2023-10-04 05:37:23 +00:00
|
|
|
verbose_name = "APISetting"
|
|
|
|
|
verbose_name_plural = "APISettings"
|
|
|
|
|
db_table = "apiSettings"
|
|
|
|
|
ordering = ("-created_at",)
|
2019-06-26 13:34:17 +00:00
|
|
|
|
2019-02-18 08:45:58 +00:00
|
|
|
def __str__(self):
|
2023-10-04 05:37:23 +00:00
|
|
|
return f"{self.title}"
|
2019-02-18 08:45:58 +00:00
|
|
|
|
|
|
|
|
def save(self, *args, **kwargs):
|
2019-02-18 13:40:47 +00:00
|
|
|
if not self.apikey or self.apikey is None or self.apikey == "":
|
|
|
|
|
self.apikey = generate_key()
|
2021-12-15 11:25:29 +00:00
|
|
|
super().save(*args, **kwargs)
|
2025-11-29 04:37:50 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# 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):
|
2025-12-06 17:40:54 +00:00
|
|
|
return timesince(self.created_at) + " ago"
|
2025-11-29 04:37:50 +00:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|