Coverage for app/backend/src/couchers/servicers/discussions.py: 85%

124 statements  

« prev     ^ index     » next       coverage.py v7.14.2, created at 2026-06-21 09:29 +0000

1import logging 

2 

3import grpc 

4from google.protobuf import empty_pb2 

5from sqlalchemy import select 

6from sqlalchemy.orm import Session 

7 

8from couchers.context import CouchersContext, make_notification_user_context 

9from couchers.db import can_moderate_node, session_scope 

10from couchers.event_log import log_event 

11from couchers.jobs.enqueue import queue_job 

12from couchers.models import Cluster, ClusterSubscription, Discussion, ModerationObjectType, Thread, User 

13from couchers.models.discussions import ContentChangeType, DiscussionVersion 

14from couchers.models.notifications import NotificationTopicAction 

15from couchers.moderation.utils import create_moderation 

16from couchers.notifications.notify import notify 

17from couchers.proto import discussions_pb2, discussions_pb2_grpc, notification_data_pb2 

18from couchers.proto.internal import jobs_pb2 

19from couchers.servicers.api import user_model_to_pb 

20from couchers.servicers.blocking import is_not_visible 

21from couchers.servicers.threads import thread_to_pb 

22from couchers.sql import where_moderated_content_visible 

23from couchers.utils import Timestamp_from_datetime, now 

24 

25logger = logging.getLogger(__name__) 

26 

27MAX_PAGE_SIZE = 25 

28 

29 

30def discussion_to_pb(session: Session, discussion: Discussion, context: CouchersContext) -> discussions_pb2.Discussion: 

31 owner_community_id = None 

32 owner_group_id = None 

33 if discussion.owner_cluster.is_official_cluster: 

34 owner_community_id = discussion.owner_cluster.parent_node_id 

35 else: 

36 owner_group_id = discussion.owner_cluster.id 

37 

38 if discussion.deleted is not None: 

39 return discussions_pb2.Discussion( 

40 discussion_id=discussion.id, 

41 slug=discussion.slug, 

42 deleted=True, 

43 owner_community_id=owner_community_id, 

44 owner_group_id=owner_group_id, 

45 owner_title=discussion.owner_cluster.name, 

46 thread=thread_to_pb(session, context, discussion.thread_id), 

47 ) 

48 

49 can_moderate = can_moderate_node(session, context.user_id, discussion.owner_cluster.parent_node_id) 

50 

51 return discussions_pb2.Discussion( 

52 discussion_id=discussion.id, 

53 slug=discussion.slug, 

54 created=Timestamp_from_datetime(discussion.created), 

55 creator_user_id=discussion.creator_user_id, 

56 owner_community_id=owner_community_id, 

57 owner_group_id=owner_group_id, 

58 owner_title=discussion.owner_cluster.name, 

59 title=discussion.title, 

60 content=discussion.content, 

61 thread=thread_to_pb(session, context, discussion.thread_id), 

62 can_moderate=can_moderate, 

63 can_edit=(context.user_id == discussion.creator_user_id), 

64 last_edited=Timestamp_from_datetime(discussion.last_edited) if discussion.last_edited else None, 

65 ) 

66 

67 

68def generate_create_discussion_notifications(payload: jobs_pb2.GenerateCreateDiscussionNotificationsPayload) -> None: 

69 with session_scope() as session: 

70 discussion = session.execute(select(Discussion).where(Discussion.id == payload.discussion_id)).scalar_one() 

71 

72 cluster = discussion.owner_cluster 

73 

74 if not cluster.is_official_cluster: 74 ↛ 75line 74 didn't jump to line 75 because the condition on line 74 was never true

75 raise NotImplementedError("Shouldn't have discussions under groups, only communities") 

76 

77 for user in list(cluster.members.where(User.is_visible)): 

78 if is_not_visible(session, user.id, discussion.creator_user_id): 78 ↛ 79line 78 didn't jump to line 79 because the condition on line 78 was never true

79 continue 

80 context = make_notification_user_context(user_id=user.id) 

81 notify( 

82 session, 

83 user_id=user.id, 

84 topic_action=NotificationTopicAction.discussion__create, 

85 key=str(payload.discussion_id), 

86 data=notification_data_pb2.DiscussionCreate( 

87 author=user_model_to_pb(discussion.creator_user, session, context), 

88 discussion=discussion_to_pb(session, discussion, context), 

89 ), 

90 moderation_state_id=discussion.moderation_state_id, 

91 ) 

92 

93 

94class Discussions(discussions_pb2_grpc.DiscussionsServicer): 

95 def CreateDiscussion( 

96 self, request: discussions_pb2.CreateDiscussionReq, context: CouchersContext, session: Session 

97 ) -> discussions_pb2.Discussion: 

98 if not request.title: 

99 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_title") 

100 if not request.content: 

101 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_content") 

102 if not request.owner_community_id and not request.owner_group_id: 102 ↛ 103line 102 didn't jump to line 103 because the condition on line 102 was never true

103 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "group_or_community_not_found") 

104 

105 if request.WhichOneof("owner") == "owner_group_id": 

106 cluster = session.execute( 

107 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.owner_group_id) 

108 ).scalar_one_or_none() 

109 elif request.WhichOneof("owner") == "owner_community_id": 109 ↛ 116line 109 didn't jump to line 116 because the condition on line 109 was always true

110 cluster = session.execute( 

111 select(Cluster) 

112 .where(Cluster.parent_node_id == request.owner_community_id) 

113 .where(Cluster.is_official_cluster) 

114 ).scalar_one_or_none() 

115 

116 if not cluster: 116 ↛ 117line 116 didn't jump to line 117 because the condition on line 116 was never true

117 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found") 

118 

119 if not cluster.small_community_features_enabled: 119 ↛ 120line 119 didn't jump to line 120 because the condition on line 119 was never true

120 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cannot_create_discussion") 

121 

122 thread = Thread() 

123 session.add(thread) 

124 session.flush() 

125 

126 discussion: Discussion | None = None 

127 

128 def create_object(moderation_state_id: int) -> int: 

129 nonlocal discussion 

130 discussion = Discussion( 

131 title=request.title, 

132 content=request.content, 

133 creator_user_id=context.user_id, 

134 owner_cluster_id=cluster.id, 

135 thread_id=thread.id, 

136 moderation_state_id=moderation_state_id, 

137 ) 

138 session.add(discussion) 

139 session.flush() 

140 return discussion.id 

141 

142 create_moderation( 

143 session=session, 

144 object_type=ModerationObjectType.discussion, 

145 object_id=create_object, 

146 creator_user_id=context.user_id, 

147 ) 

148 assert discussion is not None 

149 

150 log_event( 

151 context, 

152 session, 

153 "discussion.created", 

154 { 

155 "discussion_id": discussion.id, 

156 "cluster_id": cluster.id, 

157 "cluster_name": cluster.name, 

158 "is_official_cluster": cluster.is_official_cluster, 

159 }, 

160 ) 

161 

162 queue_job( 

163 session, 

164 job=generate_create_discussion_notifications, 

165 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload( 

166 discussion_id=discussion.id, 

167 ), 

168 ) 

169 

170 return discussion_to_pb(session, discussion, context) 

171 

172 def GetDiscussion( 

173 self, request: discussions_pb2.GetDiscussionReq, context: CouchersContext, session: Session 

174 ) -> discussions_pb2.Discussion: 

175 discussion = session.execute( 

176 where_moderated_content_visible( 

177 select(Discussion).where(Discussion.id == request.discussion_id), 

178 context, 

179 Discussion, 

180 ) 

181 ).scalar_one_or_none() 

182 if not discussion: 

183 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found") 

184 

185 return discussion_to_pb(session, discussion, context) 

186 

187 def UpdateDiscussion( 

188 self, request: discussions_pb2.UpdateDiscussionReq, context: CouchersContext, session: Session 

189 ) -> discussions_pb2.Discussion: 

190 discussion = session.execute( 

191 select(Discussion).where(Discussion.id == request.discussion_id) 

192 ).scalar_one_or_none() 

193 if not discussion: 193 ↛ 194line 193 didn't jump to line 194 because the condition on line 193 was never true

194 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found") 

195 if discussion.deleted is not None: 

196 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "discussion_deleted") 

197 if context.user_id != discussion.creator_user_id: 

198 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "discussion_edit_permission_denied") 

199 

200 old_title = discussion.title 

201 old_content = discussion.content 

202 

203 if request.HasField("title"): 203 ↛ 209line 203 didn't jump to line 209 because the condition on line 203 was always true

204 new_title = request.title.value.strip() 

205 if not new_title: 205 ↛ 206line 205 didn't jump to line 206 because the condition on line 205 was never true

206 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_title") 

207 discussion.title = new_title 

208 

209 if request.HasField("content"): 209 ↛ 215line 209 didn't jump to line 215 because the condition on line 209 was always true

210 new_content = request.content.value.strip() 

211 if not new_content: 211 ↛ 212line 211 didn't jump to line 212 because the condition on line 211 was never true

212 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_content") 

213 discussion.content = new_content 

214 

215 title_changed = discussion.title != old_title 

216 content_changed = discussion.content != old_content 

217 

218 if not title_changed and not content_changed: 218 ↛ 219line 218 didn't jump to line 219 because the condition on line 218 was never true

219 return discussion_to_pb(session, discussion, context) 

220 

221 session.add( 

222 DiscussionVersion( 

223 discussion_id=discussion.id, 

224 editor_user_id=context.user_id, 

225 change_type=ContentChangeType.edit, 

226 old_title=old_title if title_changed else None, 

227 new_title=discussion.title if title_changed else None, 

228 old_content=old_content if content_changed else None, 

229 new_content=discussion.content if content_changed else None, 

230 ) 

231 ) 

232 

233 discussion.last_edited = now() 

234 

235 log_event( 

236 context, 

237 session, 

238 "discussion.updated", 

239 { 

240 "discussion_id": discussion.id, 

241 }, 

242 ) 

243 

244 return discussion_to_pb(session, discussion, context) 

245 

246 def DeleteDiscussion( 

247 self, request: discussions_pb2.DeleteDiscussionReq, context: CouchersContext, session: Session 

248 ) -> empty_pb2.Empty: 

249 discussion = session.execute( 

250 select(Discussion).where(Discussion.id == request.discussion_id) 

251 ).scalar_one_or_none() 

252 if not discussion: 252 ↛ 253line 252 didn't jump to line 253 because the condition on line 252 was never true

253 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found") 

254 if discussion.deleted is not None: 254 ↛ 255line 254 didn't jump to line 255 because the condition on line 254 was never true

255 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "discussion_deleted") 

256 

257 if context.user_id != discussion.creator_user_id: 

258 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "discussion_delete_permission_denied") 

259 

260 session.add( 

261 DiscussionVersion( 

262 discussion_id=discussion.id, 

263 editor_user_id=context.user_id, 

264 change_type=ContentChangeType.delete, 

265 old_title=discussion.title, 

266 new_title=None, 

267 old_content=discussion.content, 

268 new_content=None, 

269 ) 

270 ) 

271 

272 discussion.deleted = now() 

273 

274 log_event( 

275 context, 

276 session, 

277 "discussion.deleted", 

278 { 

279 "discussion_id": discussion.id, 

280 }, 

281 ) 

282 

283 return empty_pb2.Empty() 

284 

285 def ListMyCommunitiesDiscussions( 

286 self, request: discussions_pb2.ListMyCommunitiesDiscussionsReq, context: CouchersContext, session: Session 

287 ) -> discussions_pb2.ListMyCommunitiesDiscussionsRes: 

288 page_size = min(MAX_PAGE_SIZE, request.page_size or MAX_PAGE_SIZE) 

289 next_page_id = int(request.page_token) if request.page_token else 2**63 - 1 

290 

291 discussions = ( 

292 session.execute( 

293 where_moderated_content_visible( 

294 select(Discussion) 

295 .join(Cluster, Cluster.id == Discussion.owner_cluster_id) 

296 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id) 

297 .where(ClusterSubscription.user_id == context.user_id) 

298 .where(Cluster.is_official_cluster) 

299 .where(Cluster.small_community_features_enabled) 

300 .where(Discussion.id <= next_page_id) 

301 .order_by(Discussion.id.desc()) 

302 .limit(page_size + 1), 

303 context, 

304 Discussion, 

305 is_list_operation=True, 

306 ) 

307 ) 

308 .scalars() 

309 .all() 

310 ) 

311 

312 return discussions_pb2.ListMyCommunitiesDiscussionsRes( 

313 discussions=[discussion_to_pb(session, d, context) for d in discussions[:page_size]], 

314 next_page_token=str(discussions[-1].id) if len(discussions) > page_size else None, 

315 )