bottleneckcrm/backend/common/models.py
Ashwin dd6ca2958d Add unit tests for organization admin checks and saved views provider
- Implement tests for `isOrgAdminProvider` to validate admin roles based on organization membership and role.
- Create tests for `saved_views_provider` to ensure correct API interactions for saving, renaming, and deleting saved views.
- Enhance ticket tree tests to accurately reflect inactive and merged ticket handling.
- Update CSV import tests to verify error handling and file saving functionality.
- Introduce tests for deal cards to ensure correct display of next steps and task statuses.
2026-09-26 20:38:32 +05:30

1257 lines
45 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.db import models
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.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 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. `RequireOrgContext` activates
# it per request so `timezone.localdate()` answers in it; Celery has no
# middleware and must call `common.tasks.set_org_timezone` itself.
#
# 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)
# 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'}.",
)
# 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],
)
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",
),
]
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}"
@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
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.
"""
TARGET_MODEL_CHOICES = [
("Account", "Account"),
("Case", "Case"),
("Contact", "Contact"),
("Estimate", "Estimate"),
("Invoice", "Invoice"),
("Lead", "Lead"),
("Opportunity", "Opportunity"),
("RecurringInvoice", "Recurring Invoice"),
("Task", "Task"),
]
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, choices=TARGET_MODEL_CHOICES)
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 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 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`` / existence after the context is set.
See ``docs/PORTAL_RLS.md``.
"""
INVOICE = "invoice"
ESTIMATE = "estimate"
CSAT = "csat"
RESOURCE_CHOICES = (
(INVOICE, "Invoice"),
(ESTIMATE, "Estimate"),
(CSAT, "CSAT survey"),
)
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}"