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

1import enum 

2 

3from sqlalchemy import BigInteger, Boolean, Column, DateTime, Enum, ForeignKey, String, func 

4from sqlalchemy.ext.hybrid import hybrid_property 

5from sqlalchemy.orm import backref, relationship 

6 

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 

11 

12 

13class Conversation(Base): 

14 """ 

15 Conversation brings together the different types of message/conversation types 

16 """ 

17 

18 __tablename__ = "conversations" 

19 

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()) 

23 

24 def __repr__(self): 

25 return f"Conversation(id={self.id}, created={self.created})" 

26 

27 

28class GroupChat(Base): 

29 """ 

30 Group chat 

31 """ 

32 

33 __tablename__ = "group_chats" 

34 

35 conversation_id = Column("id", ForeignKey("conversations.id"), nullable=False, primary_key=True) 

36 

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) 

41 

42 conversation = relationship("Conversation", backref="group_chat") 

43 creator = relationship("User", backref="created_group_chats") 

44 

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})" 

47 

48 

49class GroupChatRole(enum.Enum): 

50 admin = enum.auto() 

51 participant = enum.auto() 

52 

53 

54class GroupChatSubscription(Base): 

55 """ 

56 The recipient of a thread and information about when they joined/left/etc. 

57 """ 

58 

59 __tablename__ = "group_chat_subscriptions" 

60 id = Column(BigInteger, primary_key=True) 

61 

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) 

65 

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) 

69 

70 role = Column(Enum(GroupChatRole), nullable=False) 

71 

72 last_seen_message_id = Column(BigInteger, nullable=False, default=0) 

73 

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()) 

76 

77 user = relationship("User", backref="group_chat_subscriptions") 

78 group_chat = relationship("GroupChat", backref=backref("subscriptions", lazy="dynamic")) 

79 

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) 

93 

94 @hybrid_property 

95 def is_muted(self): 

96 return self.muted_until > func.now() 

97 

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})" 

100 

101 

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 

116 

117 

118class Message(Base): 

119 """ 

120 A message. 

121 

122 If message_type = text, then the message is a normal text message, otherwise, it's a special control message. 

123 """ 

124 

125 __tablename__ = "messages" 

126 

127 id = Column(BigInteger, primary_key=True) 

128 

129 # which conversation the message belongs in 

130 conversation_id = Column(ForeignKey("conversations.id"), nullable=False, index=True) 

131 

132 # the user that sent the message/command 

133 author_id = Column(ForeignKey("users.id"), nullable=False, index=True) 

134 

135 # the message type, "text" is a text message, otherwise a "control message" 

136 message_type = Column(Enum(MessageType), nullable=False) 

137 

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) 

140 

141 # time sent, timezone should always be UTC 

142 time = Column(DateTime(timezone=True), nullable=False, server_default=func.now()) 

143 

144 # the plain-text message text if not control 

145 text = Column(String, nullable=True) 

146 

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) 

149 

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") 

153 

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 

160 

161 def __repr__(self): 

162 return f"Message(id={self.id}, time={self.time}, text={self.text}, author={self.author}, conversation={self.conversation})"