bottleneckcrm/backend/common/models.py

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