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

127 statements  

« prev     ^ index     » next       coverage.py v7.15.3, created at 2026-08-04 22:32 +0000

1import logging 

2 

3import grpc 

4from google.protobuf import empty_pb2 

5from sqlalchemy import select 

6from sqlalchemy.orm import Session, aliased 

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.helpers.completed_profile import has_completed_profile 

12from couchers.jobs.enqueue import queue_job 

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

14from couchers.models.discussions import ContentChangeType, DiscussionVersion 

15from couchers.models.notifications import NotificationTopicAction 

16from couchers.moderation.utils import create_moderation 

17from couchers.notifications.notify import notify 

18from couchers.proto import discussions_pb2, discussions_pb2_grpc, notification_data_pb2 

19from couchers.proto.internal import jobs_pb2 

20from couchers.servicers.api import user_model_to_pb 

21from couchers.servicers.threads import thread_to_pb 

22from couchers.sql import users_visible_to_each_other, 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 creator = aliased(User) 

78 members = ( 

79 session.execute( 

80 select(User) 

81 .join(ClusterSubscription, ClusterSubscription.user_id == User.id) 

82 .join_from(User, creator, creator.id == discussion.creator_user_id) 

83 .where(ClusterSubscription.cluster_id == cluster.id) 

84 .where(users_visible_to_each_other(self_user=User, other_user=creator)) 

85 ) 

86 .scalars() 

87 .all() 

88 ) 

89 for user in members: 

90 context = make_notification_user_context(user_id=user.id) 

91 notify( 

92 session, 

93 user_id=user.id, 

94 topic_action=NotificationTopicAction.discussion__create, 

95 key=str(payload.discussion_id), 

96 data=notification_data_pb2.DiscussionCreate( 

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

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

99 ), 

100 moderation_state_id=discussion.moderation_state_id, 

101 ) 

102 

103 

104class Discussions(discussions_pb2_grpc.DiscussionsServicer): 

105 def CreateDiscussion( 

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

107 ) -> discussions_pb2.Discussion: 

108 if not request.title: 

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

110 if not request.content: 

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

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

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

114 

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

116 cluster = session.execute( 

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

118 ).scalar_one_or_none() 

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

120 cluster = session.execute( 

121 select(Cluster) 

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

123 .where(Cluster.is_official_cluster) 

124 ).scalar_one_or_none() 

125 

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

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

128 

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

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

131 

132 user = session.execute(select(User).where(User.id == context.user_id)).scalar_one() 

133 if not has_completed_profile(session, user): 

134 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "incomplete_profile_create_discussion") 

135 

136 thread = Thread() 

137 session.add(thread) 

138 session.flush() 

139 

140 discussion: Discussion | None = None 

141 

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

143 nonlocal discussion 

144 discussion = Discussion( 

145 title=request.title, 

146 content=request.content, 

147 creator_user_id=context.user_id, 

148 owner_cluster_id=cluster.id, 

149 thread_id=thread.id, 

150 moderation_state_id=moderation_state_id, 

151 ) 

152 session.add(discussion) 

153 session.flush() 

154 return discussion.id 

155 

156 create_moderation( 

157 session=session, 

158 object_type=ModerationObjectType.discussion, 

159 object_id=create_object, 

160 creator_user_id=context.user_id, 

161 ) 

162 assert discussion is not None 

163 

164 log_event( 

165 context, 

166 session, 

167 "discussion.created", 

168 { 

169 "discussion_id": discussion.id, 

170 "cluster_id": cluster.id, 

171 "cluster_name": cluster.name, 

172 "is_official_cluster": cluster.is_official_cluster, 

173 }, 

174 ) 

175 

176 queue_job( 

177 session, 

178 job=generate_create_discussion_notifications, 

179 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload( 

180 discussion_id=discussion.id, 

181 ), 

182 ) 

183 

184 return discussion_to_pb(session, discussion, context) 

185 

186 def GetDiscussion( 

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

188 ) -> discussions_pb2.Discussion: 

189 discussion = session.execute( 

190 where_moderated_content_visible( 

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

192 context, 

193 Discussion, 

194 ) 

195 ).scalar_one_or_none() 

196 if not discussion: 

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

198 

199 return discussion_to_pb(session, discussion, context) 

200 

201 def UpdateDiscussion( 

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

203 ) -> discussions_pb2.Discussion: 

204 discussion = session.execute( 

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

206 ).scalar_one_or_none() 

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

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

209 if discussion.deleted is not None: 

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

211 if context.user_id != discussion.creator_user_id: 

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

213 

214 old_title = discussion.title 

215 old_content = discussion.content 

216 

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

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

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

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

221 discussion.title = new_title 

222 

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

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

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

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

227 discussion.content = new_content 

228 

229 title_changed = discussion.title != old_title 

230 content_changed = discussion.content != old_content 

231 

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

233 return discussion_to_pb(session, discussion, context) 

234 

235 session.add( 

236 DiscussionVersion( 

237 discussion_id=discussion.id, 

238 editor_user_id=context.user_id, 

239 change_type=ContentChangeType.edit, 

240 old_title=old_title if title_changed else None, 

241 new_title=discussion.title if title_changed else None, 

242 old_content=old_content if content_changed else None, 

243 new_content=discussion.content if content_changed else None, 

244 ) 

245 ) 

246 

247 discussion.last_edited = now() 

248 

249 log_event( 

250 context, 

251 session, 

252 "discussion.updated", 

253 { 

254 "discussion_id": discussion.id, 

255 }, 

256 ) 

257 

258 return discussion_to_pb(session, discussion, context) 

259 

260 def DeleteDiscussion( 

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

262 ) -> empty_pb2.Empty: 

263 discussion = session.execute( 

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

265 ).scalar_one_or_none() 

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

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

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

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

270 

271 if context.user_id != discussion.creator_user_id: 

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

273 

274 session.add( 

275 DiscussionVersion( 

276 discussion_id=discussion.id, 

277 editor_user_id=context.user_id, 

278 change_type=ContentChangeType.delete, 

279 old_title=discussion.title, 

280 new_title=None, 

281 old_content=discussion.content, 

282 new_content=None, 

283 ) 

284 ) 

285 

286 discussion.deleted = now() 

287 

288 log_event( 

289 context, 

290 session, 

291 "discussion.deleted", 

292 { 

293 "discussion_id": discussion.id, 

294 }, 

295 ) 

296 

297 return empty_pb2.Empty() 

298 

299 def ListMyCommunitiesDiscussions( 

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

301 ) -> discussions_pb2.ListMyCommunitiesDiscussionsRes: 

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

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

304 

305 discussions = ( 

306 session.execute( 

307 where_moderated_content_visible( 

308 select(Discussion) 

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

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

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

312 .where(Cluster.is_official_cluster) 

313 .where(Cluster.small_community_features_enabled) 

314 .where(Discussion.id <= next_page_id) 

315 .order_by(Discussion.id.desc()) 

316 .limit(page_size + 1), 

317 context, 

318 Discussion, 

319 is_list_operation=True, 

320 ) 

321 ) 

322 .scalars() 

323 .all() 

324 ) 

325 

326 return discussions_pb2.ListMyCommunitiesDiscussionsRes( 

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

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

329 )