Coverage for src/couchers/models/conversations.py: 95%
79 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-08 00:20 +0000
1import enum
3from sqlalchemy import BigInteger, Boolean, Column, DateTime, Enum, ForeignKey, String, func
4from sqlalchemy.ext.hybrid import hybrid_property
5from sqlalchemy.orm import backref, relationship
7from couchers.constants import DATETIME_INFINITY, DATETIME_MINUS_INFINITY
8from couchers.models.base import Base
9from couchers.models.host_requests import HostRequestStatus
10from couchers.utils import now
13class Conversation(Base):
14 """
15 Conversation brings together the different types of message/conversation types
16 """
18 __tablename__ = "conversations"
20 id = Column(BigInteger, primary_key=True)
21 # timezone should always be UTC
22 created = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
24 def __repr__(self):
25 return f"Conversation(id={self.id}, created={self.created})"
28class GroupChat(Base):
29 """
30 Group chat
31 """
33 __tablename__ = "group_chats"
35 conversation_id = Column("id", ForeignKey("conversations.id"), nullable=False, primary_key=True)
37 title = Column(String, nullable=True)
38 only_admins_invite = Column(Boolean, nullable=False, default=True)
39 creator_id = Column(ForeignKey("users.id"), nullable=False, index=True)
40 is_dm = Column(Boolean, nullable=False)
42 conversation = relationship("Conversation", backref="group_chat")
43 creator = relationship("User", backref="created_group_chats")
45 def __repr__(self):
46 return f"GroupChat(conversation={self.conversation}, title={self.title or 'None'}, only_admins_invite={self.only_admins_invite}, creator={self.creator}, is_dm={self.is_dm})"
49class GroupChatRole(enum.Enum):
50 admin = enum.auto()
51 participant = enum.auto()
54class GroupChatSubscription(Base):
55 """
56 The recipient of a thread and information about when they joined/left/etc.
57 """
59 __tablename__ = "group_chat_subscriptions"
60 id = Column(BigInteger, primary_key=True)
62 # TODO: DB constraint on only one user+group_chat combo at a given time
63 user_id = Column(ForeignKey("users.id"), nullable=False, index=True)
64 group_chat_id = Column(ForeignKey("group_chats.id"), nullable=False, index=True)
66 # timezones should always be UTC
67 joined = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
68 left = Column(DateTime(timezone=True), nullable=True)
70 role = Column(Enum(GroupChatRole), nullable=False)
72 last_seen_message_id = Column(BigInteger, nullable=False, default=0)
74 # when this chat is muted until, DATETIME_INFINITY for "forever"
75 muted_until = Column(DateTime(timezone=True), nullable=False, server_default=DATETIME_MINUS_INFINITY.isoformat())
77 user = relationship("User", backref="group_chat_subscriptions")
78 group_chat = relationship("GroupChat", backref=backref("subscriptions", lazy="dynamic"))
80 def muted_display(self):
81 """
82 Returns (muted, muted_until) display values:
83 1. If not muted, returns (False, None)
84 2. If muted forever, returns (True, None)
85 3. If muted until a given datetime returns (True, dt)
86 """
87 if self.muted_until < now():
88 return (False, None)
89 elif self.muted_until == DATETIME_INFINITY:
90 return (True, None)
91 else:
92 return (True, self.muted_until)
94 @hybrid_property
95 def is_muted(self):
96 return self.muted_until > func.now()
98 def __repr__(self):
99 return f"GroupChatSubscription(id={self.id}, user={self.user}, joined={self.joined}, left={self.left}, role={self.role}, group_chat={self.group_chat})"
102class MessageType(enum.Enum):
103 text = enum.auto()
104 # e.g.
105 # image =
106 # emoji =
107 # ...
108 chat_created = enum.auto()
109 chat_edited = enum.auto()
110 user_invited = enum.auto()
111 user_left = enum.auto()
112 user_made_admin = enum.auto()
113 user_removed_admin = enum.auto() # RemoveGroupChatAdmin: remove admin permission from a user in group chat
114 host_request_status_changed = enum.auto()
115 user_removed = enum.auto() # user is removed from group chat by amdin RemoveGroupChatUser
118class Message(Base):
119 """
120 A message.
122 If message_type = text, then the message is a normal text message, otherwise, it's a special control message.
123 """
125 __tablename__ = "messages"
127 id = Column(BigInteger, primary_key=True)
129 # which conversation the message belongs in
130 conversation_id = Column(ForeignKey("conversations.id"), nullable=False, index=True)
132 # the user that sent the message/command
133 author_id = Column(ForeignKey("users.id"), nullable=False, index=True)
135 # the message type, "text" is a text message, otherwise a "control message"
136 message_type = Column(Enum(MessageType), nullable=False)
138 # the target if a control message and requires target, e.g. if inviting a user, the user invited is the target
139 target_id = Column(ForeignKey("users.id"), nullable=True, index=True)
141 # time sent, timezone should always be UTC
142 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
144 # the plain-text message text if not control
145 text = Column(String, nullable=True)
147 # the new host request status if the message type is host_request_status_changed
148 host_request_status_target = Column(Enum(HostRequestStatus), nullable=True)
150 conversation = relationship("Conversation", backref="messages", order_by="Message.time.desc()")
151 author = relationship("User", foreign_keys="Message.author_id")
152 target = relationship("User", foreign_keys="Message.target_id")
154 @property
155 def is_normal_message(self):
156 """
157 There's only one normal type atm, text
158 """
159 return self.message_type == MessageType.text
161 def __repr__(self):
162 return f"Message(id={self.id}, time={self.time}, text={self.text}, author={self.author}, conversation={self.conversation})"