mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
Two defects that only surface once the app registry loads, which is why neither was visible before django.setup() started passing. Lazy FK targets: eight models point at "ee.Role" / "ee.AccessControl" as strings. Django resolves those at check time, not import time, so they survived the import repoint and left the graph dangling. Repointed at the in-tree models, which declare app_label "insights". The six migrations carrying the same string are left alone; migration reconciliation is gated separately. Index names: upstream's "posthog_" prefix put nine names at exactly Django's 30-character ceiling, so rewriting it to "insights_" pushed every one of them to 31 and made models.E034 fire. main had already solved this by dropping the prefix on exactly the names that overflow, and the live database agrees -- fsvl_recent_user_views, con_team_id_ticket_idx and pro_project_3583d2_idx all exist there unprefixed. Restore those names in the models and in the six migrations that declare them, so the tree, the migration state and the database say the same thing and no index rename is implied. Model checks now report 0 messages across 447 models.
205 lines
10 KiB
Python
205 lines
10 KiB
Python
from typing import TYPE_CHECKING
|
|
|
|
from django.db import models, transaction
|
|
|
|
from insights.models.utils import UUIDTModel
|
|
|
|
from .constants import Channel, ChannelDetail, Priority, Status
|
|
|
|
if TYPE_CHECKING:
|
|
from insights.models import Person
|
|
|
|
|
|
class TicketManager(models.Manager):
|
|
def create_with_number(self, **kwargs):
|
|
"""
|
|
Create a ticket with an auto-incrementing ticket_number.
|
|
Uses SELECT FOR UPDATE on Team row to serialize ticket creation per team.
|
|
"""
|
|
from insights.models import Team
|
|
|
|
team = kwargs.get("team")
|
|
if not team:
|
|
raise ValueError("team is required")
|
|
|
|
with transaction.atomic():
|
|
# Lock team row to serialize ticket creation for this team
|
|
Team.objects.select_for_update().get(id=team.id)
|
|
|
|
max_num = self.filter(team=team).aggregate(models.Max("ticket_number"))["ticket_number__max"] or 0
|
|
kwargs["ticket_number"] = max_num + 1
|
|
return self.create(**kwargs)
|
|
|
|
|
|
class Ticket(UUIDTModel):
|
|
objects = TicketManager()
|
|
|
|
# Dynamic attribute set by TicketViewSet._attach_persons_to_tickets for serialization
|
|
person: "Person | None"
|
|
|
|
team = models.ForeignKey("insights.Team", on_delete=models.CASCADE)
|
|
ticket_number = models.PositiveIntegerField()
|
|
channel_source = models.CharField(max_length=20, choices=Channel, default=Channel.WIDGET)
|
|
channel_detail = models.CharField(max_length=30, choices=ChannelDetail, null=True, blank=True)
|
|
widget_session_id = models.CharField(max_length=64, db_index=True) # Random UUID for access control
|
|
distinct_id = models.CharField(max_length=400) # Insights distinct_id for Person linking only
|
|
status = models.CharField(max_length=20, choices=Status, default=Status.NEW)
|
|
priority = models.CharField(max_length=20, choices=Priority, null=True, blank=True)
|
|
anonymous_traits = models.JSONField(default=dict, blank=True)
|
|
# Trust signal (tri-state):
|
|
# True — the claimed identity was attested by the server (widget HMAC,
|
|
# SPF-authenticated email, or a signature-validated platform webhook).
|
|
# False — assessed but not attested (anonymous claim we couldn't verify).
|
|
# None — unknown; we never assessed it (e.g. predates this signal and the
|
|
# channel doesn't structurally guarantee verification).
|
|
identity_verified = models.BooleanField(null=True)
|
|
ai_resolved = models.BooleanField(default=False)
|
|
escalation_reason = models.TextField(null=True, blank=True)
|
|
ai_triage = models.JSONField(default=dict, blank=True)
|
|
|
|
# Unread message counters
|
|
unread_customer_count = models.IntegerField(default=0) # Messages customer hasn't seen (from team/AI)
|
|
unread_team_count = models.IntegerField(default=0) # Messages team hasn't seen (from customer)
|
|
|
|
# Denormalized message stats (updated via signal on Comment save)
|
|
message_count = models.IntegerField(default=0)
|
|
last_message_at = models.DateTimeField(null=True, blank=True)
|
|
last_message_text = models.CharField(max_length=500, null=True, blank=True) # Truncated preview
|
|
|
|
# Slack channel fields (only set for channel_source="slack")
|
|
slack_channel_id = models.CharField(max_length=64, null=True, blank=True) # Slack channel ID
|
|
slack_thread_ts = models.CharField(max_length=64, null=True, blank=True) # Slack thread timestamp (thread ID)
|
|
slack_team_id = models.CharField(max_length=64, null=True, blank=True) # Slack workspace/team ID
|
|
|
|
# Teams channel fields (only set for channel_source="teams")
|
|
teams_channel_id = models.CharField(max_length=128, null=True, blank=True)
|
|
teams_conversation_id = models.CharField(max_length=256, null=True, blank=True) # Reply chain / thread ID
|
|
teams_service_url = models.URLField(max_length=512, null=True, blank=True) # Bot Connector endpoint for replies
|
|
teams_tenant_id = models.CharField(max_length=64, null=True, blank=True)
|
|
# Shared-channel thread reply poller watermark (Graph createdDateTime of last ingested reply).
|
|
teams_thread_replies_synced_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# GitHub channel fields (only set for channel_source="github")
|
|
github_repo = models.CharField(max_length=256, null=True, blank=True) # owner/repo
|
|
github_issue_number = models.PositiveIntegerField(null=True, blank=True)
|
|
|
|
# Email channel fields (only set for channel_source="email")
|
|
email_config = models.ForeignKey(
|
|
"conversations.EmailChannel",
|
|
null=True,
|
|
blank=True,
|
|
on_delete=models.SET_NULL,
|
|
related_name="tickets",
|
|
)
|
|
email_subject = models.CharField(max_length=500, null=True, blank=True)
|
|
email_from = models.EmailField(null=True, blank=True)
|
|
cc_participants = models.JSONField(default=list, blank=True)
|
|
|
|
# Session context (captured when ticket is created)
|
|
session_id = models.CharField(max_length=64, null=True, blank=True) # Insights session ID
|
|
session_context = models.JSONField(default=dict, blank=True) # session_replay_url, current_url, etc.
|
|
|
|
# SLA deadline — set via workflows, null means no SLA
|
|
sla_due_at = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Snooze — when set, ticket is "on hold" until this time, then auto-reopened by wake task
|
|
snoozed_until = models.DateTimeField(null=True, blank=True)
|
|
|
|
# Customer's Insights org group key, resolved once at creation or on a later message
|
|
# (local org pk, cross-region analytics key, or the person's organization_id property).
|
|
organization_id = models.CharField(max_length=400, null=True, blank=True)
|
|
# How organization_id was resolved: "person" (the requester's identity) or
|
|
# "slack_channel_account" (inferred from the customer analytics account linked to the Slack channel).
|
|
organization_id_source = models.CharField(max_length=32, null=True, blank=True)
|
|
|
|
# Zendesk import dedup — set when a ticket is imported from Zendesk Support.
|
|
# No standalone index: the partial unique constraint below covers the dedup lookup
|
|
# (team + zendesk_ticket_id), mirroring the GitHub issue-number pattern.
|
|
zendesk_ticket_id = models.BigIntegerField(null=True, blank=True)
|
|
|
|
created_at = models.DateTimeField(auto_now_add=True)
|
|
updated_at = models.DateTimeField(auto_now=True)
|
|
|
|
class Meta:
|
|
db_table = "insights_conversations_ticket"
|
|
indexes = [
|
|
models.Index(fields=["team", "widget_session_id"]), # Access control queries
|
|
models.Index(fields=["team", "distinct_id"]), # Person linking queries
|
|
models.Index(fields=["team", "status"]),
|
|
models.Index(fields=["team", "-ticket_number"], name="con_team_id_ticket_idx"), # MAX() lookups
|
|
models.Index(fields=["team", "session_id"]), # Session context queries
|
|
# Slack thread lookup: find ticket by (team, slack_channel_id, slack_thread_ts)
|
|
models.Index(
|
|
fields=["team", "slack_channel_id", "slack_thread_ts"],
|
|
name="insights_con_slack_thread_idx",
|
|
),
|
|
# Teams thread lookup: find ticket by (team, teams_channel_id, teams_conversation_id)
|
|
models.Index(
|
|
fields=["team", "teams_channel_id", "teams_conversation_id"],
|
|
name="insights_con_teams_thread_idx",
|
|
),
|
|
# Dashboard ordering optimization
|
|
models.Index(fields=["team", "-updated_at"], name="insights_con_team_updated_idx"),
|
|
# Dashboard filtered + ordered queries
|
|
models.Index(fields=["team", "status", "-updated_at"], name="insights_con_status_upd_idx"),
|
|
# SLA sort + filter. The dashboard sorts by "sla_due_at <dir> NULLS LAST, ticket_number
|
|
# DESC"; one expression index per direction makes each page a top-N index scan instead
|
|
# of a full sort of the mostly-NULL table (a plain ascending index can't serve DESC
|
|
# NULLS LAST, and lacks the ticket_number tiebreaker). The leading (team_id, sla_due_at)
|
|
# prefix also serves the SLA state filter, so these supersede a plain (team, sla_due_at).
|
|
models.Index(
|
|
models.F("team_id"),
|
|
models.F("sla_due_at").asc(nulls_last=True),
|
|
models.F("ticket_number").desc(),
|
|
name="insights_con_sla_asc_idx",
|
|
),
|
|
models.Index(
|
|
models.F("team_id"),
|
|
models.F("sla_due_at").desc(nulls_last=True),
|
|
models.F("ticket_number").desc(),
|
|
name="insights_con_sla_desc_idx",
|
|
),
|
|
# Snooze sort + filter: same asc/desc expression-index pair. The leading prefix serves
|
|
# the snoozed isnull filter, so these supersede a plain (team, snoozed_until) index.
|
|
models.Index(
|
|
models.F("team_id"),
|
|
models.F("snoozed_until").asc(nulls_last=True),
|
|
models.F("ticket_number").desc(),
|
|
name="insights_con_snooze_asc_idx",
|
|
),
|
|
models.Index(
|
|
models.F("team_id"),
|
|
models.F("snoozed_until").desc(nulls_last=True),
|
|
models.F("ticket_number").desc(),
|
|
name="insights_con_snooze_desc_idx",
|
|
),
|
|
# Snooze: wake task (cross-team, only non-null rows)
|
|
models.Index(
|
|
fields=["snoozed_until"],
|
|
name="insights_con_snooze_wake_idx",
|
|
condition=models.Q(snoozed_until__isnull=False),
|
|
),
|
|
models.Index(fields=["organization_id"], name="insights_org_id_idx"),
|
|
models.Index(
|
|
fields=["organization_id", "slack_channel_id"],
|
|
name="insights_org_slack_ch_idx",
|
|
condition=models.Q(channel_source="slack"),
|
|
),
|
|
]
|
|
constraints = [
|
|
models.UniqueConstraint(fields=["team", "ticket_number"], name="unique_ticket_number_per_team"),
|
|
models.UniqueConstraint(
|
|
fields=["team", "github_repo", "github_issue_number"],
|
|
condition=models.Q(github_repo__isnull=False, github_issue_number__isnull=False),
|
|
name="insights_con_github_issue_uniq",
|
|
),
|
|
models.UniqueConstraint(
|
|
fields=["team", "zendesk_ticket_id"],
|
|
condition=models.Q(zendesk_ticket_id__isnull=False),
|
|
name="insights_con_zendesk_ticket_uniq",
|
|
),
|
|
]
|
|
|
|
def __str__(self):
|
|
return f"Ticket {self.id} - {self.widget_session_id[:8]}..."
|