255 lines
8.3 KiB
Python
255 lines
8.3 KiB
Python
from django.db import models
|
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
|
from django.contrib.contenttypes.models import ContentType
|
|
from django_choices_field import TextChoicesField
|
|
|
|
from core.models.base import BaseModel
|
|
from core.models.enums import ConversationTypeChoices
|
|
|
|
|
|
class Conversation(BaseModel):
|
|
"""
|
|
Conversation thread that groups messages together.
|
|
Can be linked to specific entities (Project, Service, Account, etc.) for context.
|
|
"""
|
|
subject = models.CharField(
|
|
max_length=500,
|
|
help_text="Conversation subject/title"
|
|
)
|
|
conversation_type = TextChoicesField(
|
|
choices_enum=ConversationTypeChoices,
|
|
help_text="Type of conversation (DIRECT, GROUP, SUPPORT)"
|
|
)
|
|
last_message_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Timestamp of most recent message"
|
|
)
|
|
is_archived = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether this conversation is archived (system-wide)"
|
|
)
|
|
|
|
# Generic foreign key for linking to any entity (Project, Service, Account, etc.)
|
|
entity_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
null=True,
|
|
blank=True,
|
|
related_name='conversations',
|
|
help_text="Content type of the related entity"
|
|
)
|
|
entity_object_id = models.UUIDField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="UUID of the related entity"
|
|
)
|
|
entity = GenericForeignKey('entity_content_type', 'entity_object_id')
|
|
|
|
# Generic foreign key for conversation creator
|
|
created_by_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='created_conversations',
|
|
help_text="Content type of the creator (TeamProfile or CustomerProfile)"
|
|
)
|
|
created_by_object_id = models.UUIDField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="UUID of the creator"
|
|
)
|
|
created_by = GenericForeignKey('created_by_content_type', 'created_by_object_id')
|
|
|
|
metadata = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Additional conversation metadata"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'conversations'
|
|
ordering = ['-last_message_at', '-created_at']
|
|
indexes = [
|
|
models.Index(fields=['-last_message_at']),
|
|
models.Index(fields=['entity_content_type', 'entity_object_id']),
|
|
models.Index(fields=['conversation_type', '-last_message_at']),
|
|
models.Index(fields=['created_by_content_type', 'created_by_object_id']),
|
|
]
|
|
|
|
def __str__(self):
|
|
entity_info = f" ({self.entity_content_type.model}:{self.entity_object_id})" if self.entity else ""
|
|
return f"{self.subject}{entity_info}"
|
|
|
|
|
|
class ConversationParticipant(BaseModel):
|
|
"""
|
|
Links users (TeamProfile or CustomerProfile) to conversations.
|
|
Tracks per-user read status and preferences.
|
|
"""
|
|
conversation = models.ForeignKey(
|
|
Conversation,
|
|
on_delete=models.CASCADE,
|
|
related_name='participants',
|
|
help_text="Conversation this participant belongs to"
|
|
)
|
|
|
|
# Generic foreign key to support both TeamProfile and CustomerProfile
|
|
participant_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
related_name='conversation_participants',
|
|
help_text="Content type of the participant (TeamProfile or CustomerProfile)"
|
|
)
|
|
participant_object_id = models.UUIDField(
|
|
help_text="UUID of the participant"
|
|
)
|
|
participant = GenericForeignKey('participant_content_type', 'participant_object_id')
|
|
|
|
last_read_at = models.DateTimeField(
|
|
null=True,
|
|
blank=True,
|
|
help_text="Timestamp when participant last read messages in this conversation"
|
|
)
|
|
unread_count = models.PositiveIntegerField(
|
|
default=0,
|
|
help_text="Number of unread messages for this participant"
|
|
)
|
|
is_muted = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether participant has muted notifications for this conversation"
|
|
)
|
|
is_archived = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether participant has archived this conversation (user-specific)"
|
|
)
|
|
joined_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="When participant joined the conversation"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'conversation_participants'
|
|
ordering = ['conversation', 'joined_at']
|
|
indexes = [
|
|
models.Index(fields=['participant_content_type', 'participant_object_id', 'is_archived']),
|
|
models.Index(fields=['conversation', 'participant_content_type', 'participant_object_id']),
|
|
models.Index(fields=['unread_count']),
|
|
]
|
|
unique_together = [['conversation', 'participant_content_type', 'participant_object_id']]
|
|
|
|
def __str__(self):
|
|
return f"{self.participant} in {self.conversation.subject}"
|
|
|
|
|
|
class Message(BaseModel):
|
|
"""
|
|
Individual message within a conversation.
|
|
"""
|
|
conversation = models.ForeignKey(
|
|
Conversation,
|
|
on_delete=models.CASCADE,
|
|
related_name='messages',
|
|
help_text="Conversation this message belongs to"
|
|
)
|
|
|
|
# Generic foreign key for sender (TeamProfile or CustomerProfile)
|
|
sender_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
related_name='sent_messages',
|
|
help_text="Content type of the sender"
|
|
)
|
|
sender_object_id = models.UUIDField(
|
|
help_text="UUID of the sender"
|
|
)
|
|
sender = GenericForeignKey('sender_content_type', 'sender_object_id')
|
|
|
|
body = models.TextField(
|
|
help_text="Message content"
|
|
)
|
|
|
|
# For message threading/replies
|
|
reply_to = models.ForeignKey(
|
|
'self',
|
|
on_delete=models.SET_NULL,
|
|
null=True,
|
|
blank=True,
|
|
related_name='replies',
|
|
help_text="Message this is replying to (for threading)"
|
|
)
|
|
|
|
# Attachments stored as array of file references
|
|
attachments = models.JSONField(
|
|
default=list,
|
|
blank=True,
|
|
help_text="Array of attachment metadata (file paths, names, sizes, types)"
|
|
)
|
|
|
|
is_system_message = models.BooleanField(
|
|
default=False,
|
|
help_text="Whether this is an automated system message"
|
|
)
|
|
|
|
metadata = models.JSONField(
|
|
default=dict,
|
|
blank=True,
|
|
help_text="Additional message metadata (formatting, mentions, etc.)"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'messages'
|
|
ordering = ['created_at']
|
|
indexes = [
|
|
models.Index(fields=['conversation', 'created_at']),
|
|
models.Index(fields=['sender_content_type', 'sender_object_id', 'created_at']),
|
|
models.Index(fields=['reply_to']),
|
|
]
|
|
|
|
def __str__(self):
|
|
preview = self.body[:50] + "..." if len(self.body) > 50 else self.body
|
|
return f"Message from {self.sender} in {self.conversation.subject}: {preview}"
|
|
|
|
|
|
class MessageReadReceipt(BaseModel):
|
|
"""
|
|
Tracks when individual messages are read by specific participants.
|
|
Allows for fine-grained read tracking beyond conversation-level.
|
|
"""
|
|
message = models.ForeignKey(
|
|
Message,
|
|
on_delete=models.CASCADE,
|
|
related_name='read_receipts',
|
|
help_text="Message that was read"
|
|
)
|
|
|
|
# Generic foreign key for reader (TeamProfile or CustomerProfile)
|
|
reader_content_type = models.ForeignKey(
|
|
ContentType,
|
|
on_delete=models.CASCADE,
|
|
related_name='message_reads',
|
|
help_text="Content type of the reader"
|
|
)
|
|
reader_object_id = models.UUIDField(
|
|
help_text="UUID of the reader"
|
|
)
|
|
reader = GenericForeignKey('reader_content_type', 'reader_object_id')
|
|
|
|
read_at = models.DateTimeField(
|
|
auto_now_add=True,
|
|
help_text="When the message was read"
|
|
)
|
|
|
|
class Meta:
|
|
db_table = 'message_read_receipts'
|
|
ordering = ['read_at']
|
|
indexes = [
|
|
models.Index(fields=['message', 'reader_content_type', 'reader_object_id']),
|
|
models.Index(fields=['reader_content_type', 'reader_object_id', 'read_at']),
|
|
]
|
|
unique_together = [['message', 'reader_content_type', 'reader_object_id']]
|
|
|
|
def __str__(self):
|
|
return f"{self.reader} read message {self.message_id} at {self.read_at}"
|