Coverage for app/backend/src/couchers/servicers/message_threads.py: 96%

65 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-08 23:56 +0000

1""" 

2Bulk operations over the viewer's message threads — group chats, DMs, host requests and public-trip 

3offers — selected by category (Conversations.MarkAllThreadsSeen). 

4""" 

5 

6import logging 

7from collections.abc import Sequence 

8from dataclasses import dataclass 

9 

10import grpc 

11from google.protobuf import empty_pb2 

12from sqlalchemy import ColumnElement, Select, select, update 

13from sqlalchemy.orm import Session 

14from sqlalchemy.sql import and_, func, or_ 

15 

16from couchers.context import CouchersContext 

17from couchers.helpers.group_chats import is_newest_subscription, is_unseen, was_subscribed_at 

18from couchers.helpers.host_requests import ( 

19 HOST_REQUEST_NOTIFICATION_TOPIC_ACTIONS, 

20 has_unseen_host_request_messages, 

21 is_hosting_party, 

22 is_public_trip_offer_recipient, 

23 is_surfing_party, 

24) 

25from couchers.models import ( 

26 GroupChat, 

27 GroupChatSubscription, 

28 HostRequest, 

29 Message, 

30) 

31from couchers.models.notifications import NotificationTopicAction 

32from couchers.notifications.notify import mark_notifications_seen 

33from couchers.proto import conversations_pb2 

34from couchers.sql import to_bool, where_moderated_content_visible, where_users_column_visible 

35 

36logger = logging.getLogger(__name__) 

37 

38 

39@dataclass(frozen=True) 

40class _ThreadFilters: 

41 categories: frozenset[int] 

42 only_archived: bool | None 

43 only_unread: bool 

44 

45 

46def _resolve_thread_filters( 

47 context: CouchersContext, request: conversations_pb2.MarkAllThreadsSeenReq 

48) -> _ThreadFilters: 

49 """ 

50 An empty category list means all categories. MY_PUBLIC_TRIPS is dropped when the public-trips 

51 flag is off; those offers still show up under SURFING. 

52 """ 

53 if conversations_pb2.MESSAGE_THREAD_CATEGORY_UNSPECIFIED in request.categories: 

54 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_thread_category") 

55 categories = frozenset( 

56 request.categories 

57 or { 

58 conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS, 

59 conversations_pb2.MESSAGE_THREAD_CATEGORY_HOSTING, 

60 conversations_pb2.MESSAGE_THREAD_CATEGORY_SURFING, 

61 conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS, 

62 } 

63 ) 

64 if not context.get_boolean_value("public_trips_enabled", False): 64 ↛ 65line 64 didn't jump to line 65 because the condition on line 64 was never true

65 categories -= {conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS} 

66 return _ThreadFilters( 

67 categories=categories, 

68 only_archived=request.only_archived if request.HasField("only_archived") else None, 

69 only_unread=request.only_unread, 

70 ) 

71 

72 

73def _build_group_chat_select_query( 

74 context: CouchersContext, only_archived: bool | None, unread: bool 

75) -> Select[tuple[int]]: 

76 """ 

77 The ids of the group chats (including DMs) the viewer should see, narrowed by the request's 

78 archived and unread filters. 

79 """ 

80 return where_moderated_content_visible( 

81 select(GroupChatSubscription.group_chat_id.label("conversation_id")) 

82 .join(Message, Message.conversation_id == GroupChatSubscription.group_chat_id) 

83 .join(GroupChat, GroupChat.conversation_id == GroupChatSubscription.group_chat_id) 

84 .where(GroupChatSubscription.user_id == context.user_id) 

85 .where(is_newest_subscription(context.user_id)) 

86 .where(was_subscribed_at(GroupChatSubscription, Message.time)) 

87 .where(or_(to_bool(only_archived is None), GroupChatSubscription.is_archived == only_archived)) 

88 .where(or_(to_bool(not unread), is_unseen(Message, GroupChatSubscription))) 

89 .group_by(GroupChatSubscription.group_chat_id), 

90 context, 

91 GroupChat, 

92 is_list_operation=True, 

93 ) 

94 

95 

96def _build_host_request_select_query( 

97 context: CouchersContext, role_filter: ColumnElement[bool], only_archived: bool | None, unread: bool 

98) -> Select[tuple[int]]: 

99 """ 

100 The ids of the host requests and public-trip offers the viewer should see, narrowed by the 

101 request's archived and unread filters. 

102 

103 role_filter picks which side of the request the viewer is on: hosting, surfing, or offers on 

104 their own public trips. 

105 """ 

106 query = ( 

107 select(HostRequest.conversation_id.label("conversation_id")) 

108 .where( 

109 or_( 

110 HostRequest.initiator_user_id == context.user_id, 

111 HostRequest.recipient_user_id == context.user_id, 

112 ) 

113 ) 

114 .where(role_filter) 

115 .where( 

116 or_( 

117 to_bool(only_archived is None), 

118 and_( 

119 HostRequest.initiator_user_id == context.user_id, 

120 HostRequest.is_initiator_archived == only_archived, 

121 ), 

122 and_( 

123 HostRequest.recipient_user_id == context.user_id, 

124 HostRequest.is_recipient_archived == only_archived, 

125 ), 

126 ) 

127 ) 

128 .where(or_(to_bool(not unread), has_unseen_host_request_messages(context.user_id))) 

129 ) 

130 query = where_users_column_visible(query, context, HostRequest.initiator_user_id) 

131 query = where_users_column_visible(query, context, HostRequest.recipient_user_id) 

132 query = where_moderated_content_visible(query, context, HostRequest, is_list_operation=True) 

133 return query 

134 

135 

136def _build_host_request_role_filter(user_id: int, categories: frozenset[int]) -> ColumnElement[bool] | None: 

137 """ 

138 The stay-roles the selected categories cover, or None if none of them is a host request category. 

139 MY_PUBLIC_TRIPS is a subset of SURFING, so asking for both is redundant but harmless. 

140 """ 

141 clauses = [] 

142 if conversations_pb2.MESSAGE_THREAD_CATEGORY_HOSTING in categories: 

143 clauses.append(is_hosting_party(user_id)) 

144 if conversations_pb2.MESSAGE_THREAD_CATEGORY_SURFING in categories: 

145 clauses.append(is_surfing_party(user_id)) 

146 if conversations_pb2.MESSAGE_THREAD_CATEGORY_MY_PUBLIC_TRIPS in categories: 

147 clauses.append(is_public_trip_offer_recipient(user_id)) 

148 return or_(*clauses) if clauses else None 

149 

150 

151def mark_all_threads_seen( 

152 request: conversations_pb2.MarkAllThreadsSeenReq, context: CouchersContext, session: Session 

153) -> empty_pb2.Empty: 

154 filters = _resolve_thread_filters(context, request) 

155 

156 # (topic actions, keys) groups for the notifications owned by the threads we mark seen 

157 notification_groups: list[tuple[Sequence[NotificationTopicAction], Sequence[str]]] = [] 

158 

159 if conversations_pb2.MESSAGE_THREAD_CATEGORY_CHATS in filters.categories: 159 ↛ 195line 159 didn't jump to line 195 because the condition on line 159 was always true

160 chat_query = _build_group_chat_select_query(context, filters.only_archived, filters.only_unread) 

161 # correlated, so it resolves per row of the update: every subscription advances to its own 

162 # chat's newest message without listing them out. windowed, so a subscription the viewer 

163 # has left advances only to the last message they could read, matching its unseen count 

164 latest_message_id = ( 

165 select(func.max(Message.id)) 

166 .where(Message.conversation_id == GroupChatSubscription.group_chat_id) 

167 .where(was_subscribed_at(GroupChatSubscription, Message.time)) 

168 .scalar_subquery() 

169 ) 

170 marked_group_chat_ids = ( 

171 session.execute( 

172 update(GroupChatSubscription) 

173 .where(GroupChatSubscription.user_id == context.user_id) 

174 .where(is_newest_subscription(context.user_id)) 

175 .where(GroupChatSubscription.group_chat_id.in_(select(chat_query.subquery().c.conversation_id))) 

176 .where(GroupChatSubscription.last_seen_message_id < latest_message_id) 

177 .values(last_seen_message_id=latest_message_id) 

178 .returning(GroupChatSubscription.group_chat_id) 

179 .execution_options(synchronize_session=False) 

180 ) 

181 .scalars() 

182 .all() 

183 ) 

184 if marked_group_chat_ids: 

185 notification_groups.append( 

186 ( 

187 [NotificationTopicAction.chat__message], 

188 [str(group_chat_id) for group_chat_id in marked_group_chat_ids], 

189 ) 

190 ) 

191 # chat__missed_messages is a summary across all chats, so it's keyed with an empty 

192 # string rather than a chat id (same as MarkLastSeenGroupChat) 

193 notification_groups.append(([NotificationTopicAction.chat__missed_messages], [""])) 

194 

195 role_filter = _build_host_request_role_filter(context.user_id, filters.categories) 

196 if role_filter is not None: 

197 host_request_query = _build_host_request_select_query( 

198 context, role_filter, filters.only_archived, filters.only_unread 

199 ) 

200 # the viewer's last-seen column depends on their role, so one update per role 

201 # (a user is never both initiator and recipient of the same request, so these are disjoint) 

202 matching_ids = select(host_request_query.subquery().c.conversation_id) 

203 latest_message_id = ( 

204 select(func.max(Message.id)).where(Message.conversation_id == HostRequest.conversation_id).scalar_subquery() 

205 ) 

206 marked_conversation_ids: list[int] = [] 

207 for user_id_column, last_seen_column in ( 

208 (HostRequest.initiator_user_id, HostRequest.initiator_last_seen_message_id), 

209 (HostRequest.recipient_user_id, HostRequest.recipient_last_seen_message_id), 

210 ): 

211 marked_conversation_ids += ( 

212 session.execute( 

213 update(HostRequest) 

214 .where(user_id_column == context.user_id) 

215 .where(HostRequest.conversation_id.in_(matching_ids)) 

216 .where(last_seen_column < latest_message_id) 

217 .values({last_seen_column: latest_message_id}) 

218 .returning(HostRequest.conversation_id) 

219 .execution_options(synchronize_session=False) 

220 ) 

221 .scalars() 

222 .all() 

223 ) 

224 if marked_conversation_ids: 

225 notification_groups.append( 

226 ( 

227 HOST_REQUEST_NOTIFICATION_TOPIC_ACTIONS, 

228 [str(conversation_id) for conversation_id in marked_conversation_ids], 

229 ) 

230 ) 

231 

232 mark_notifications_seen(session, user_id=context.user_id, topic_actions_and_keys=notification_groups) 

233 

234 return empty_pb2.Empty()