Files
orion/models/database/message.py
Samir Boulahtit 29a3bf3989 fix: add values_callable to SQLAlchemy Enum columns
Add _enum_values helper and values_callable parameter to Enum columns
in message models to ensure proper enum value extraction for database
operations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 20:37:41 +01:00

273 lines
7.9 KiB
Python

# models/database/message.py
"""
Messaging system database models.
Supports three communication channels:
- Admin <-> Vendor
- Vendor <-> Customer
- Admin <-> Customer
Multi-tenant isolation is enforced via vendor_id for conversations
involving customers.
"""
import enum
from datetime import datetime
from sqlalchemy import (
Boolean,
Column,
DateTime,
Enum,
ForeignKey,
Index,
Integer,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import relationship
from app.core.database import Base
from models.database.base import TimestampMixin
class ConversationType(str, enum.Enum):
"""Defines the three supported conversation channels."""
ADMIN_VENDOR = "admin_vendor"
VENDOR_CUSTOMER = "vendor_customer"
ADMIN_CUSTOMER = "admin_customer"
class ParticipantType(str, enum.Enum):
"""Type of participant in a conversation."""
ADMIN = "admin" # User with role="admin"
VENDOR = "vendor" # User with role="vendor" (via VendorUser)
CUSTOMER = "customer" # Customer model
def _enum_values(enum_class):
"""Extract enum values for SQLAlchemy Enum column."""
return [e.value for e in enum_class]
class Conversation(Base, TimestampMixin):
"""
Represents a threaded conversation between participants.
Multi-tenancy: vendor_id is required for vendor_customer and admin_customer
conversations to ensure customer data isolation.
"""
__tablename__ = "conversations"
id = Column(Integer, primary_key=True, index=True)
# Conversation type determines participant structure
conversation_type = Column(
Enum(ConversationType, values_callable=_enum_values),
nullable=False,
index=True,
)
# Subject line for the conversation thread
subject = Column(String(500), nullable=False)
# For vendor_customer and admin_customer conversations
# Required for multi-tenant data isolation
vendor_id = Column(
Integer,
ForeignKey("vendors.id"),
nullable=True,
index=True,
)
# Status flags
is_closed = Column(Boolean, default=False, nullable=False)
closed_at = Column(DateTime, nullable=True)
closed_by_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=True)
closed_by_id = Column(Integer, nullable=True)
# Last activity tracking for sorting
last_message_at = Column(DateTime, nullable=True, index=True)
message_count = Column(Integer, default=0, nullable=False)
# Relationships
vendor = relationship("Vendor", foreign_keys=[vendor_id])
participants = relationship(
"ConversationParticipant",
back_populates="conversation",
cascade="all, delete-orphan",
)
messages = relationship(
"Message",
back_populates="conversation",
cascade="all, delete-orphan",
order_by="Message.created_at",
)
# Indexes for common queries
__table_args__ = (
Index("ix_conversations_type_vendor", "conversation_type", "vendor_id"),
)
def __repr__(self) -> str:
return (
f"<Conversation(id={self.id}, type='{self.conversation_type.value}', "
f"subject='{self.subject[:30]}...')>"
)
class ConversationParticipant(Base, TimestampMixin):
"""
Links participants (users or customers) to conversations.
Polymorphic relationship:
- participant_type="admin" or "vendor": references users.id
- participant_type="customer": references customers.id
"""
__tablename__ = "conversation_participants"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(
Integer,
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Polymorphic participant reference
participant_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False)
participant_id = Column(Integer, nullable=False, index=True)
# For vendor participants, track which vendor they represent
vendor_id = Column(
Integer,
ForeignKey("vendors.id"),
nullable=True,
)
# Unread tracking per participant
unread_count = Column(Integer, default=0, nullable=False)
last_read_at = Column(DateTime, nullable=True)
# Notification preferences for this conversation
email_notifications = Column(Boolean, default=True, nullable=False)
muted = Column(Boolean, default=False, nullable=False)
# Relationships
conversation = relationship("Conversation", back_populates="participants")
vendor = relationship("Vendor", foreign_keys=[vendor_id])
__table_args__ = (
UniqueConstraint(
"conversation_id",
"participant_type",
"participant_id",
name="uq_conversation_participant",
),
Index(
"ix_participant_lookup",
"participant_type",
"participant_id",
),
)
def __repr__(self) -> str:
return (
f"<ConversationParticipant(conversation_id={self.conversation_id}, "
f"type='{self.participant_type.value}', id={self.participant_id})>"
)
class Message(Base, TimestampMixin):
"""
Individual message within a conversation thread.
Sender polymorphism follows same pattern as participant.
"""
__tablename__ = "messages"
id = Column(Integer, primary_key=True, index=True)
conversation_id = Column(
Integer,
ForeignKey("conversations.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# Polymorphic sender reference
sender_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=False)
sender_id = Column(Integer, nullable=False, index=True)
# Message content
content = Column(Text, nullable=False)
# System messages (e.g., "conversation closed")
is_system_message = Column(Boolean, default=False, nullable=False)
# Soft delete for moderation
is_deleted = Column(Boolean, default=False, nullable=False)
deleted_at = Column(DateTime, nullable=True)
deleted_by_type = Column(Enum(ParticipantType, values_callable=_enum_values), nullable=True)
deleted_by_id = Column(Integer, nullable=True)
# Relationships
conversation = relationship("Conversation", back_populates="messages")
attachments = relationship(
"MessageAttachment",
back_populates="message",
cascade="all, delete-orphan",
)
__table_args__ = (
Index("ix_messages_conversation_created", "conversation_id", "created_at"),
)
def __repr__(self) -> str:
return (
f"<Message(id={self.id}, conversation_id={self.conversation_id}, "
f"sender={self.sender_type.value}:{self.sender_id})>"
)
class MessageAttachment(Base, TimestampMixin):
"""
File attachments for messages.
Files are stored in platform storage (local/S3) with references here.
"""
__tablename__ = "message_attachments"
id = Column(Integer, primary_key=True, index=True)
message_id = Column(
Integer,
ForeignKey("messages.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
# File metadata
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(1000), nullable=False) # Storage path
file_size = Column(Integer, nullable=False) # Size in bytes
mime_type = Column(String(100), nullable=False)
# For image attachments
is_image = Column(Boolean, default=False, nullable=False)
image_width = Column(Integer, nullable=True)
image_height = Column(Integer, nullable=True)
thumbnail_path = Column(String(1000), nullable=True)
# Relationships
message = relationship("Message", back_populates="attachments")
def __repr__(self) -> str:
return f"<MessageAttachment(id={self.id}, filename='{self.original_filename}')>"