Coverage for src/couchers/models/conversations.py: 95%
84 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-12-25 10:58 +0000
1import enum
2from datetime import datetime
3from typing import TYPE_CHECKING, Any
5from sqlalchemy import BigInteger, Boolean, DateTime, Enum, ForeignKey, String, func
6from sqlalchemy.ext.hybrid import hybrid_property
7from sqlalchemy.orm import Mapped, backref, mapped_column, relationship
9from couchers.constants import DATETIME_INFINITY, DATETIME_MINUS_INFINITY
10from couchers.models.base import Base
11from couchers.models.host_requests import HostRequestStatus
12from couchers.utils import now
14if TYPE_CHECKING:
15 from couchers.models.moderation import ModerationState
18class Conversation(Base):
19 """
20 Conversation brings together the different types of message/conversation types
21 """
23 __tablename__ = "conversations"
25 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
26 # timezone should always be UTC
27 created: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
29 def __repr__(self) -> str:
30 return f"Conversation(id={self.id}, created={self.created})"
33class GroupChat(Base):
34 """
35 Group chat
36 """
38 __tablename__ = "group_chats"
39 __moderation_author_column__ = "creator_id"
41 conversation_id: Mapped[int] = mapped_column("id", ForeignKey("conversations.id"), primary_key=True)
43 title: Mapped[str | None] = mapped_column(String, nullable=True)
44 only_admins_invite: Mapped[bool] = mapped_column(Boolean, default=True)
45 creator_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
46 is_dm: Mapped[bool] = mapped_column(Boolean)
48 # Unified Moderation System
49 moderation_state_id: Mapped[int] = mapped_column(ForeignKey("moderation_states.id"), index=True)
51 conversation = relationship("Conversation", backref="group_chat")
52 creator = relationship("User", backref="created_group_chats")
53 moderation_state: Mapped["ModerationState"] = relationship("ModerationState")
55 def __repr__(self) -> str:
56 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})"
59class GroupChatRole(enum.Enum):
60 admin = enum.auto()
61 participant = enum.auto()
64class GroupChatSubscription(Base):
65 """
66 The recipient of a thread and information about when they joined/left/etc.
67 """
69 __tablename__ = "group_chat_subscriptions"
70 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
72 # TODO: DB constraint on only one user+group_chat combo at a given time
73 user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
74 group_chat_id: Mapped[int] = mapped_column(ForeignKey("group_chats.id"), index=True)
76 # timezones should always be UTC
77 joined: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
78 left: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
80 role: Mapped[GroupChatRole] = mapped_column(Enum(GroupChatRole))
82 last_seen_message_id: Mapped[int] = mapped_column(BigInteger, default=0)
84 # when this chat is muted until, DATETIME_INFINITY for "forever"
85 muted_until: Mapped[datetime] = mapped_column(
86 DateTime(timezone=True), server_default=DATETIME_MINUS_INFINITY.isoformat()
87 )
89 user = relationship("User", backref="group_chat_subscriptions")
90 group_chat = relationship("GroupChat", backref=backref("subscriptions", lazy="dynamic"))
92 def muted_display(self) -> tuple[bool, datetime | None]:
93 """
94 Returns (muted, muted_until) display values:
95 1. If not muted, returns (False, None)
96 2. If muted forever, returns (True, None)
97 3. If muted until a given datetime returns (True, dt)
98 """
99 if self.muted_until < now():
100 return (False, None)
101 elif self.muted_until == DATETIME_INFINITY:
102 return (True, None)
103 else:
104 return (True, self.muted_until)
106 @hybrid_property
107 def is_muted(self) -> Any:
108 return self.muted_until > func.now()
110 def __repr__(self) -> str:
111 return f"GroupChatSubscription(id={self.id}, user={self.user}, joined={self.joined}, left={self.left}, role={self.role}, group_chat={self.group_chat})"
114class MessageType(enum.Enum):
115 text = enum.auto()
116 # e.g.
117 # image =
118 # emoji =
119 # ...
120 chat_created = enum.auto()
121 chat_edited = enum.auto()
122 user_invited = enum.auto()
123 user_left = enum.auto()
124 user_made_admin = enum.auto()
125 user_removed_admin = enum.auto() # RemoveGroupChatAdmin: remove admin permission from a user in group chat
126 host_request_status_changed = enum.auto()
127 user_removed = enum.auto() # user is removed from group chat by amdin RemoveGroupChatUser
130class Message(Base):
131 """
132 A message.
134 If message_type = text, then the message is a normal text message, otherwise, it's a special control message.
135 """
137 __tablename__ = "messages"
139 id: Mapped[int] = mapped_column(BigInteger, primary_key=True)
141 # which conversation the message belongs in
142 conversation_id: Mapped[int] = mapped_column(ForeignKey("conversations.id"), index=True)
144 # the user that sent the message/command
145 author_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
147 # the message type, "text" is a text message, otherwise a "control message"
148 message_type: Mapped[MessageType] = mapped_column(Enum(MessageType))
150 # the target if a control message and requires target, e.g. if inviting a user, the user invited is the target
151 target_id: Mapped[int | None] = mapped_column(ForeignKey("users.id"), nullable=True, index=True)
153 # time sent, timezone should always be UTC
154 time: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
156 # the plain-text message text if not control
157 text: Mapped[str | None] = mapped_column(String, nullable=True)
159 # the new host request status if the message type is host_request_status_changed
160 host_request_status_target: Mapped[HostRequestStatus | None] = mapped_column(Enum(HostRequestStatus), nullable=True)
162 conversation = relationship("Conversation", backref="messages", order_by="Message.time.desc()")
163 author = relationship("User", foreign_keys="Message.author_id")
164 target = relationship("User", foreign_keys="Message.target_id")
166 @property
167 def is_normal_message(self) -> bool:
168 """
169 There's only one normal type atm, text
170 """
171 return self.message_type == MessageType.text
173 def __repr__(self) -> str:
174 return f"Message(id={self.id}, time={self.time}, text={self.text}, author={self.author}, conversation={self.conversation})"