bottleneckcrm/backend/common/models.py

1389 lines
52 KiB
Python

import binascii
import hashlib
import os
import re
import secrets
import time
import unicodedata
import uuid
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models, transaction
from django.db.models.functions import Lower
from django.utils import timezone
from django.utils.timesince import timesince
from django.utils.translation import gettext_lazy as _
from common.base import BaseModel, BaseOrgModel
from common.org_time import canonical_timezone
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 common.validators import (
number_prefix_validator,
validate_help_center_slug,
validate_iana_timezone,
)
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)
name = models.CharField(_("name"), max_length=255, blank=True, default="")
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 save(self, *args, **kwargs):
# On first save only, fall back to the email local-part when no name
# was supplied. Keeps `name` non-empty without overwriting later
# edits (PATCHing name to "" leaves it empty by user intent).
if self._state.adding and not self.name and self.email:
self.name = self.email.split("@", 1)[0][:255]
super().save(*args, **kwargs)
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
)
# The org's calendar day. Every "today", "overdue" and "this month" in the
# app is resolved against this, because a day boundary is a fact about the
# people using the org, not about the server. `GetProfileAndOrg` activates
# it per request so `timezone.localdate()` answers in it; Celery tasks and
# management commands have no middleware and must call
# `common.org_time.activate_org_timezone` themselves.
#
# UTC by default so a client that never sends one, an older mobile build in
# particular, still creates a usable org. Stored as an IANA name rather than
# an offset, since only a name knows when the clocks move.
timezone = models.CharField(
max_length=64,
default="UTC",
validators=[validate_iana_timezone],
help_text="IANA timezone (e.g. America/New_York). Sets the org's day.",
)
# CSAT (Tier 2 csat). Org-level kill switch, when False, the
# post-close signal short-circuits before any survey email is sent.
csat_enabled = models.BooleanField(default=True)
# Tier 3 parent/child: when True, closing a parent case offers to
# cascade-close any open descendants. The endpoint still requires an
# explicit confirmation; this flag only controls the default state.
auto_close_children_on_parent_close = models.BooleanField(default=False)
# Where the org came from, written once at creation by
# `common.signup_source.clean_signup_source`. Growth analytics only:
# NEVER used in a permission check or a queryset filter.
signup_source = models.JSONField(default=dict, blank=True)
# Vertical packs. Descriptive only: records which pack was applied at
# signup. NEVER used in a permission check or a queryset filter.
vertical = models.CharField(
max_length=64,
blank=True,
default="",
help_text="Pack id applied to this org, e.g. 'real-estate'. Descriptive only.",
)
terminology = models.JSONField(
default=dict,
blank=True,
help_text="Label overrides, e.g. {'lead.plural': 'Enquiries'}.",
)
# Modules: written only by `common.module_settings.change_modules`, and no
# serializer exposes them.
modules_off = models.JSONField(default=list, blank=True)
module_order = models.JSONField(default=list, blank=True)
modules_on_at = models.JSONField(default=dict, blank=True)
# Public help center at `/help-center/<slug>` on the app frontend. Off by default:
# it publishes the org's approved, published articles to anyone, search
# engines included, so an admin opts in. The slug is NULL until chosen
# (never "", which would collide across orgs under the unique constraint)
# and is stored lowercase, so the `Lower()` constraint below and an exact
# match in the public lookup agree. Written only by `HelpCenterSettingsView`.
help_center_enabled = models.BooleanField(default=False)
help_center_slug = models.CharField(
max_length=50,
blank=True,
null=True,
validators=[validate_help_center_slug],
)
# Invoice and estimate numbering (#802). A number is the prefix, then the
# date and a hyphen if `number_date_part` dates it, then a counter padded to
# `number_padding` digits. The defaults give `INV-20261015-0001`, the format
# every org had before these existed. The counter restarts whenever the
# part before it changes, so a dated series starts again each period. Only
# new documents take a change: a number already issued never moves. The
# generator is `invoices.models._next_number`.
NUMBER_DATE_PARTS = (
("NONE", "No date"),
("YEAR", "Year"),
("MONTH", "Year and month"),
("DAY", "Full date"),
)
invoice_number_prefix = models.CharField(
max_length=20, blank=True, default="INV-", validators=[number_prefix_validator]
)
estimate_number_prefix = models.CharField(
max_length=20, blank=True, default="EST-", validators=[number_prefix_validator]
)
number_date_part = models.CharField(
max_length=5, choices=NUMBER_DATE_PARTS, default="DAY"
)
number_padding = models.PositiveSmallIntegerField(
default=4, validators=[MinValueValidator(1), MaxValueValidator(10)]
)
# The lowest counter the next invoice may take, for an org carrying on a
# sequence from another system. Undated numbering only (the constraint
# below): a dated counter starts again each period, and a floor would
# start it at the floor every time.
invoice_number_start = models.PositiveIntegerField(
default=1, validators=[MinValueValidator(1), MaxValueValidator(999_999_999)]
)
class Meta:
verbose_name = "Organization"
verbose_name_plural = "Organizations"
db_table = "organization"
ordering = ("-created_at",)
constraints = [
models.UniqueConstraint(
Lower("help_center_slug"), name="org_help_center_slug_ci_unique"
),
# A help center that is on must have an address. The serializer
# says so in words; this stops any other write path storing a
# state the public lookup could never reach.
models.CheckConstraint(
condition=models.Q(help_center_enabled=False)
| models.Q(help_center_slug__isnull=False),
name="org_help_center_enabled_needs_slug",
),
models.CheckConstraint(
condition=models.Q(number_date_part="NONE")
| models.Q(invoice_number_start=1),
name="org_invoice_number_start_needs_undated",
),
]
def __str__(self):
return str(self.name)
def save(self, *args, **kwargs):
# Store the zone a tz alias points at, never the alias: PostgreSQL may
# not know the alias (see `common.org_time.canonical_timezone`).
self.timezone = canonical_timezone(self.timezone)
super().save(*args, **kwargs)
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}"
@staticmethod
def slug_for(name):
"""The slug a tag called `name` is stored and looked up under.
(slug, org) is the unique key, so this is what decides whether two names
are the same tag. Every caller that stores or finds a tag by name goes
through here; a second copy of the rule is how lookups and inserts
disagreed before.
It is Django's `slugify(name, allow_unicode=True)` with one difference:
a combining mark is kept when it is attached to a letter or digit (it
follows one, directly or after other marks). `slugify` drops every
mark, which folds "किला", "कल" and "काल" into one slug because
Devanagari (and Thai, Bengali, Tamil...) write vowels as marks. A mark
attached to anything else, such as the variation selector in an emoji,
is dropped, so an emoji-only name still has no slug. The older
ASCII-only `slugify(name)` mapped every Japanese, Cyrillic or Arabic
name to "", so an org could hold one of them at most.
"""
kept = []
after_base = False
for ch in unicodedata.normalize("NFKC", name).lower():
if unicodedata.category(ch).startswith("M"):
if after_base:
kept.append(ch)
continue
after_base = ch.isalnum()
if after_base or ch in "_-" or ch.isspace():
kept.append(ch)
return re.sub(r"[-\s]+", "-", "".join(kept)).strip("-_")
@classmethod
def name_error(cls, name):
"""Why `name` cannot be stored as a tag, or None when it can.
The tags API and the three CSV importers all ask this before creating a
tag. A slug is never truncated to fit: two long names that share a
prefix would then become one tag.
"""
name_max = cls._meta.get_field("name").max_length
slug_max = cls._meta.get_field("slug").max_length
if len(name) > name_max:
return f"A tag name can be at most {name_max} characters."
slug = cls.slug_for(name)
if not slug:
return "A tag name needs at least one letter or digit."
if len(slug) > slug_max:
# NFKC expands some characters ("ff" is two letters), so a name
# within its own limit can still produce a slug past the column's.
return (
f"A tag name is too long once normalised: it becomes "
f"{len(slug)} characters and the limit is {slug_max}."
)
return None
@classmethod
def archived_errors(cls, org, names):
"""Row-error text for each of `names` that is an archived tag in `org`.
Keyed by slug, so "vip" finds "VIP". The three CSV importers refuse
such a name rather than attach a tag that is hidden everywhere else,
and they do not restore it: restoring is admin-only in the tags API,
while importing is open to members with sales access.
"""
slugs = {cls.slug_for(name) for name in names}
if not slugs:
return {}
return {
slug: f"Tag '{name}' is archived; an admin can restore it in Settings > Tags"
for slug, name in cls.objects.filter(
org=org, is_active=False, slug__in=slugs
).values_list("slug", "name")
}
def save(self, *args, **kwargs):
self.slug = self.slug_for(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}>"
def save(self, *args, **kwargs):
# `role` and `is_organization_admin` are two columns for one binary
# fact (`ROLES` is ADMIN/USER and nothing else), and they used to be
# settable independently, which let an admin mint a colleague the UI
# showed as a plain user and could never demote. `role` is what every
# surface displays and what `common.permissions.is_org_admin` reads;
# the column is kept because it is in API responses, so it is derived
# here rather than left to drift.
self.is_organization_admin = self.role == "ADMIN"
super().save(*args, **kwargs)
@property
def is_admin(self):
"""`common.permissions.is_org_admin` for this profile.
Delegates rather than restating the rule, so it counts a superuser's
profile as an admin too, and reads `role` rather than the column, so it
answers correctly on an unsaved instance.
"""
from common.permissions import is_org_admin
return is_org_admin(self)
@property
def user_details(self):
return {
"email": self.user.email,
"id": self.user.id,
"name": self.user.name,
"is_active": self.user.is_active,
"profile_pic": self.user.profile_pic,
"last_login": self.user.last_login,
}
# The body was a CharField(max_length=255) until the customer portal needed
# customers to write real support replies, which 255 characters cannot hold.
# Widening it to TextField removed the only bound on the input, so this puts an
# intentional one back at the API layer.
#
# Read this before trusting it: Django does NOT enforce `max_length` on a
# TextField at the model or database level, only in a form widget. `full_clean`
# will not raise, so a direct ORM write of any size still succeeds. What makes
# this a real bound is DRF, which maps a model TextField's `max_length` into a
# serializer validator, so every request-driven write path is checked: the agent
# UI, the mobile client and the portal alike. Server-side code that writes
# comments directly is trusted and deliberately unbounded.
#
# The column stays an unbounded TEXT, so raising this later needs no data
# migration. Enforcement is pinned by
# `contacts/tests/test_contact_access_and_links.py::test_a_comment_too_long_to_store_is_reported`.
COMMENT_MAX_LENGTH = 10_000
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.TextField(max_length=COMMENT_MAX_LENGTH)
commented_on = models.DateTimeField(auto_now_add=True)
commented_by = models.ForeignKey(
Profile, on_delete=models.CASCADE, blank=True, null=True
)
# A reply written through the customer portal has no Profile behind it, so
# `commented_by` stays null and this carries the author instead. The check
# constraint below is what stops a row claiming to be written by both a
# colleague and a customer.
commented_by_contact = models.ForeignKey(
"contacts.Contact",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="portal_comments",
)
is_internal = models.BooleanField(default=False, db_index=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"]),
models.Index(fields=["content_type", "object_id", "is_internal"]),
]
constraints = [
# An author is either a colleague or a customer, never both. `save`
# calls `full_clean`, which validates constraints, so this is
# enforced on ordinary writes and not only at the database.
models.CheckConstraint(
condition=models.Q(commented_by__isnull=True)
| models.Q(commented_by_contact__isnull=True),
name="comment_single_author",
),
]
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 f"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)
class MagicLinkToken(models.Model):
"""One-time magic link tokens for passwordless authentication.
Supports two delivery modes (`delivery`):
- "link": classic email-link flow, the user clicks a URL containing `token`.
- "code": short 6-digit OTP flow for mobile clients. `code_hash` stores the
hashed code; `attempts` tracks failed verify attempts so we can lock the
row after too many guesses (codes are short, brute-force matters).
"""
DELIVERY_LINK = "link"
DELIVERY_CODE = "code"
DELIVERY_CHOICES = (
(DELIVERY_LINK, "Link"),
(DELIVERY_CODE, "Code"),
)
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
email = models.EmailField(db_index=True)
token = models.CharField(max_length=64, unique=True, db_index=True)
delivery = models.CharField(
max_length=8, choices=DELIVERY_CHOICES, default=DELIVERY_LINK
)
code_hash = models.CharField(max_length=256, blank=True, default="")
attempts = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
used_at = models.DateTimeField(null=True, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
class Meta:
db_table = "magic_link_token"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["email", "created_at"]),
]
def __str__(self):
return f"MagicLink({self.email}, delivery={self.delivery}, used={self.is_used})"
class PortalLoginToken(models.Model):
"""One-time sign-in codes for the customer portal.
Mirrors `MagicLinkToken`, deliberately as a separate table rather than a
nullable `org` column on that one. Sharing the table would mean one verify
endpoint accepting the other realm's token, and that is a customer logging
into the internal application. Roughly forty duplicated lines buys a realm
boundary that a single mistake cannot cross.
Six emailed digits, stored hashed, and never a clickable link. The portal
briefly carried both deliveries; nothing ever asked for the link one, so it
is gone. What remains is the flow the sign-in page actually drives, where
the customer stays in the tab they started in. The code is the entire
credential for ten minutes, which is why `attempts` exists: see
`PortalLoginVerifyView._redeem_code`.
Bound to one contact in one org when it is minted, so it cannot be replayed
against another tenant.
Deliberately absent from `ORG_SCOPED_TABLES`: the lookup runs before any org
context exists, so an RLS policy here would hide the row from the only
request that needs to read it. Same reasoning as `MagicLinkToken` and
`PortalAccessToken`.
"""
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
org = models.ForeignKey("Org", on_delete=models.CASCADE)
contact = models.ForeignKey("contacts.Contact", on_delete=models.CASCADE)
code_hash = models.CharField(max_length=256)
attempts = models.PositiveSmallIntegerField(default=0)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
is_used = models.BooleanField(default=False)
used_at = models.DateTimeField(null=True, blank=True)
ip_address = models.GenericIPAddressField(null=True, blank=True)
class Meta:
db_table = "portal_login_token"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["contact", "created_at"]),
]
def __str__(self):
return f"PortalLogin({self.contact_id}, used={self.is_used})"
# 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"),
# Cases roadmap verb registry: see docs/cases/COORDINATION_DECISIONS.md D1.
("STATUS_CHANGED", "Status Changed"),
("PRIORITY_CHANGED", "Priority Changed"),
("ROUTED", "Routed"),
("ESCALATED", "Escalated"),
("REOPENED", "Reopened"),
("MERGED", "Merged"),
("MERGE_TARGET", "Merge Target"),
("UNMERGED", "Unmerged"),
("UNMERGE_TARGET", "Unmerge Target"),
("LINKED_SOLUTION", "Linked Solution"),
("UNLINKED_SOLUTION", "Unlinked Solution"),
("WATCHED", "Watched"),
("UNWATCHED", "Unwatched"),
("MENTIONED", "Mentioned"),
("APPROVAL_REQUESTED", "Approval Requested"),
("APPROVED", "Approved"),
("REJECTED", "Rejected"),
("APPROVAL_CANCELLED", "Approval Cancelled"),
("LINKED_ASSET", "Linked Asset"),
("UNLINKED_ASSET", "Unlinked Asset"),
("LINKED_JIRA", "Linked Jira"),
("LINKED_PARENT", "Linked Parent"),
("UNLINKED_PARENT", "Unlinked Parent"),
("PARENT_CLOSED_CASCADE", "Parent Closed Cascade"),
("TIME_LOGGED", "Time Logged"),
)
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=32, 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="")
metadata = models.JSONField(default=dict, blank=True)
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"]),
# Analytics window scans: "all Case CREATE/UPDATE rows in window".
models.Index(
fields=["org", "entity_type", "action", "created_at"],
name="activity_analytics_idx",
),
]
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 Notification(BaseModel):
"""In-app notification delivered to a single recipient.
Per `docs/cases/COORDINATION_DECISIONS.md` D2 we inherit BaseModel and
declare our own `org` FK rather than using BaseOrgModel. RLS still
applies via the migration in 0018.
"""
recipient = models.ForeignKey(
Profile,
on_delete=models.CASCADE,
related_name="notifications",
)
verb = models.CharField(max_length=64)
actor = models.ForeignKey(
Profile,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="dispatched_notifications",
)
entity_type = models.CharField(max_length=50, blank=True, default="")
entity_id = models.UUIDField(null=True, blank=True)
entity_name = models.CharField(max_length=255, blank=True, default="")
data = models.JSONField(default=dict, blank=True)
link = models.CharField(max_length=500, blank=True, default="")
read_at = models.DateTimeField(null=True, blank=True)
org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="notifications")
class Meta:
verbose_name = "Notification"
verbose_name_plural = "Notifications"
db_table = "notification"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["org", "recipient", "-created_at"]),
models.Index(fields=["recipient", "read_at"]),
]
def __str__(self):
return f"{self.verb} -> {self.recipient_id}"
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})"
class CustomFieldDefinition(BaseModel):
"""Per-org schema extension for any supported entity (Case, Lead, ...).
Each row defines a single field on a single target model. Values are stored
on the entity itself in a `custom_fields` JSONField; this row is the
metadata that the validator uses to coerce types, enforce required fields,
and validate dropdown membership.
See docs/cases/tier1/custom-fields.md.
"""
FIELD_TYPE_CHOICES = [
("text", "Text"),
("textarea", "Textarea"),
("number", "Number"),
("dropdown", "Dropdown"),
("date", "Date"),
("checkbox", "Checkbox"),
]
org = models.ForeignKey(
Org, on_delete=models.CASCADE, related_name="custom_field_definitions"
)
target_model = models.CharField(max_length=32)
key = models.SlugField(max_length=64)
label = models.CharField(max_length=128)
field_type = models.CharField(max_length=16, choices=FIELD_TYPE_CHOICES)
options = models.JSONField(
blank=True,
null=True,
help_text="List of {value, label} pairs for dropdown fields.",
)
is_required = models.BooleanField(default=False)
is_filterable = models.BooleanField(default=False)
display_order = models.PositiveIntegerField(default=0)
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "Custom Field Definition"
verbose_name_plural = "Custom Field Definitions"
db_table = "custom_field_definition"
ordering = ("target_model", "display_order", "label")
constraints = [
models.UniqueConstraint(
fields=["org", "target_model", "key"],
name="uniq_custom_field_per_org_target",
)
]
indexes = [
models.Index(fields=["org", "target_model", "is_active"]),
]
def __str__(self):
return f"{self.target_model}.{self.key} ({self.label})"
def generate_pat_raw():
"""Return a new raw personal access token string."""
return f"bcrm_pat_{secrets.token_urlsafe(32)}"
class PersonalAccessToken(BaseOrgModel):
"""
Per-user token for programmatic REST API access (scripts, agents).
The agent authenticates AS `profile` and inherits that user's role,
org and RLS scope. The raw token is shown ONCE at creation and only
its SHA-256 hash is stored.
"""
profile = models.ForeignKey(
"common.Profile",
on_delete=models.CASCADE,
related_name="access_tokens",
)
name = models.CharField(max_length=255)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
token_prefix = models.CharField(max_length=20)
# `<resource>:<action>` strings, validated at creation and enforced on every
# request by `common.scopes` from the `GetProfileAndOrg` middleware. An empty
# list means unrestricted, which is what every token issued before
# enforcement carries and what this field used to mean unconditionally.
# See common/scopes.py for the grammar and for the credential deny-list that
# applies regardless of what is stored here.
scopes = models.JSONField(default=list, blank=True)
expires_at = models.DateTimeField(null=True, blank=True)
last_used_at = models.DateTimeField(null=True, blank=True)
revoked_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "personal_access_token"
indexes = [models.Index(fields=["org", "-created_at"])]
@staticmethod
def hash_token(raw):
return hashlib.sha256(raw.encode()).hexdigest()
@classmethod
def generate(cls, profile, name, scopes=None, expires_at=None):
raw = generate_pat_raw()
pat = cls.objects.create(
org=profile.org,
profile=profile,
name=name,
token_hash=cls.hash_token(raw),
token_prefix=raw[:13],
scopes=scopes or [],
expires_at=expires_at,
created_by=profile.user,
)
return raw, pat
def is_valid(self):
if self.revoked_at is not None:
return False
if self.expires_at is not None and self.expires_at <= timezone.now():
return False
return True
class CalendarFeedToken(BaseOrgModel):
"""The secret in one member's calendar subscription URL (G14).
A calendar app (Google Calendar, Outlook, Apple Calendar) polls
``/api/public/calendar/<token>.ics`` with no credential of its own, so the
URL is the credential. One per profile, which is one per org membership.
Only the SHA-256 of the raw token is stored; the raw value is returned once,
when it is issued, and cannot be read back.
Like ``personal_access_token``, this table is deliberately NOT under RLS: the
feed looks it up by ``token_hash`` before any tenant context exists, which is
how it learns the org to set. Management is filtered on
``profile=request.profile`` in ``common/views/calendar_feed_views.py``.
The token grants nothing by itself. Every fetch re-checks that the profile,
its user and its org are still active and reads tasks through
``tasks.access.visible_tasks_qs``, so a demotion or a removal takes effect
on the next poll.
"""
profile = models.OneToOneField(
"common.Profile",
on_delete=models.CASCADE,
related_name="calendar_feed_token",
)
token_hash = models.CharField(max_length=64, unique=True)
# Moved forward at most once an hour, so a calendar app polling every few
# minutes does not turn every fetch into a write.
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
db_table = "calendar_feed_token"
indexes = [models.Index(fields=["org", "-created_at"])]
@staticmethod
def hash_token(raw):
return hashlib.sha256(raw.encode()).hexdigest()
@classmethod
def issue(cls, profile):
"""Give ``profile`` a new token, destroying any previous one.
Returns ``(raw, row)``. The old row is deleted in the same transaction,
so the old URL stops working the moment this commits. The profile row
is locked first so that two concurrent requests (a double-click on
Regenerate) queue up instead of both inserting and one hitting the
one-per-profile constraint.
"""
raw = f"bcrm_cal_{secrets.token_urlsafe(32)}"
with transaction.atomic():
Profile.objects.select_for_update().filter(pk=profile.pk).first()
cls.objects.filter(profile=profile).delete()
row = cls.objects.create(
org=profile.org, profile=profile, token_hash=cls.hash_token(raw)
)
return raw, row
class PortalAccessToken(models.Model):
"""Unscoped ``token_hash → org`` lookup for anonymous portal requests.
The public portal endpoints: the invoice and estimate links emailed to
customers, and the CSAT survey link, arrive with no auth and no org
context. RLS is keyed on ``app.current_org``, so with an empty context the
isolation policy hides the very row (invoice / estimate / csat_survey) that
would tell us which org the token belongs to. That is a chicken-and-egg:
you cannot set the context until you know the org, and you cannot read the
org until the context is set.
This table breaks it. It is deliberately **not** org-scoped. It is absent
from ``common.rls.ORG_SCOPED_TABLES`` and carries no RLS policy, so it is
readable with an empty context. A public view hashes the URL token, looks
up the org here, calls ``set_rls_context(org_id)``, and only then reads the
resource under full RLS. That is the same sanctioned pattern Celery tasks
and the CSAT view already use; it adds a surface rather than weakening one.
The key is ``sha256(url_token)``. For invoices and estimates the URL token
is the raw ``public_token``; for CSAT it is the signed token whose SHA-256
is already the stored ``csat_survey.token_hash``, so a single hash resolves
all three. The inbound email webhook uses it the same way: SNS delivers to
``/api/cases/inbound/<mailbox_id>/`` anonymously, and the key there is the
SHA-256 of the mailbox id in its canonical UUID form. The row leaks only
the *existence* of a token to somebody who already holds it; the resource
contents stay behind RLS, and a disabled or deleted resource still 404s
because the view's own scoped query re-checks ``public_link_enabled`` /
``is_active`` / existence after the context is set.
See ``docs/PORTAL_RLS.md``.
"""
INVOICE = "invoice"
ESTIMATE = "estimate"
CSAT = "csat"
INBOUND_MAILBOX = "inbound_mailbox"
RESOURCE_CHOICES = (
(INVOICE, "Invoice"),
(ESTIMATE, "Estimate"),
(CSAT, "CSAT survey"),
(INBOUND_MAILBOX, "Inbound mailbox"),
)
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
token_hash = models.CharField(max_length=64, unique=True, db_index=True)
org = models.ForeignKey(
"common.Org", on_delete=models.CASCADE, related_name="portal_access_tokens"
)
resource_type = models.CharField(max_length=16, choices=RESOURCE_CHOICES)
resource_id = models.UUIDField()
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
db_table = "portal_access_token"
indexes = [
models.Index(fields=["resource_type", "resource_id"]),
]
def __str__(self):
return f"PortalAccessToken({self.resource_type}:{self.token_hash[:8]}…)"
# Import SecurityAuditLog so Django discovers it for migrations. The name is
# unused here on purpose, and the suppression has to sit on the line ruff
# attributes the diagnostic to, which is the imported name rather than the
# closing paren. Split across three lines it silently suppressed nothing, and
# `ruff check --fix` would have deleted the import and taken the model's
# migrations with it.
from common.audit_log import SecurityAuditLog # noqa: F401,E402
class PackApplication(BaseOrgModel):
"""Audit record of one vertical-pack application to one org."""
pack_id = models.CharField(max_length=64)
pack_version = models.PositiveIntegerField()
applied_by = models.ForeignKey(
"common.Profile",
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="pack_applications",
)
result = models.JSONField(
default=dict,
help_text="{'created': [...], 'skipped': [...], 'failed': [...]}",
)
class Meta:
verbose_name = "Pack Application"
verbose_name_plural = "Pack Applications"
db_table = "pack_application"
ordering = ("-created_at",)
indexes = [
models.Index(fields=["org", "-created_at"]),
]
def __str__(self):
return f"{self.pack_id} v{self.pack_version} → {self.org.name}"
class SavedView(BaseOrgModel):
"""A profile's named filters for one list, to put back with one tap (G29).
Private to ``profile``: every read and write is filtered on it as well as on
the org, and another profile's id answers 404. ``filters`` holds the list's
own query parameters as ``{param: [value, ...]}``; which parameters a list
takes, and how they are checked, lives in ``common/saved_views.py``.
"""
MODULE_CHOICES = (
("leads", "Leads"),
("contacts", "Contacts"),
("accounts", "Accounts"),
("opportunities", "Deals"),
("cases", "Tickets"),
("invoices", "Invoices"),
)
profile = models.ForeignKey(
"common.Profile", on_delete=models.CASCADE, related_name="saved_views"
)
module = models.CharField(max_length=20, choices=MODULE_CHOICES)
name = models.CharField(max_length=100)
filters = models.JSONField(default=dict, blank=True)
class Meta:
db_table = "saved_view"
ordering = ("name",)
indexes = [
models.Index(fields=["org", "-created_at"]),
]
constraints = [
# Case-insensitive, so "Hot leads" and "hot leads" cannot both
# appear in one menu. Leading with the profile makes this the
# index the per-profile lists read through as well.
models.UniqueConstraint(
"profile", "module", Lower("name"), name="saved_view_name_ci_unique"
),
]
def __str__(self):
return f"{self.module}: {self.name}"