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

128 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-07-21 21:52 +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.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.blocking import is_not_visible 

22from couchers.servicers.threads import thread_to_pb 

23from couchers.sql import where_moderated_content_visible 

24from couchers.utils import Timestamp_from_datetime, now 

25 

26logger = logging.getLogger(__name__) 

27 

28MAX_PAGE_SIZE = 25 

29 

30 

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

32 owner_community_id = None 

33 owner_group_id = None 

34 if discussion.owner_cluster.is_official_cluster: 

35 owner_community_id = discussion.owner_cluster.parent_node_id 

36 else: 

37 owner_group_id = discussion.owner_cluster.id 

38 

39 if discussion.deleted is not None: 

40 return discussions_pb2.Discussion( 

41 discussion_id=discussion.id, 

42 slug=discussion.slug, 

43 deleted=True, 

44 owner_community_id=owner_community_id, 

45 owner_group_id=owner_group_id, 

46 owner_title=discussion.owner_cluster.name, 

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

48 ) 

49 

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

51 

52 return discussions_pb2.Discussion( 

53 discussion_id=discussion.id, 

54 slug=discussion.slug, 

55 created=Timestamp_from_datetime(discussion.created), 

56 creator_user_id=discussion.creator_user_id, 

57 owner_community_id=owner_community_id, 

58 owner_group_id=owner_group_id, 

59 owner_title=discussion.owner_cluster.name, 

60 title=discussion.title, 

61 content=discussion.content, 

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

63 can_moderate=can_moderate, 

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

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

66 ) 

67 

68 

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

70 with session_scope() as session: 

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

72 

73 cluster = discussion.owner_cluster 

74 

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

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

77 

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

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

80 continue 

81 context = make_notification_user_context(user_id=user.id) 

82 notify( 

83 session, 

84 user_id=user.id, 

85 topic_action=NotificationTopicAction.discussion__create, 

86 key=str(payload.discussion_id), 

87 data=notification_data_pb2.DiscussionCreate( 

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

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

90 ), 

91 moderation_state_id=discussion.moderation_state_id, 

92 ) 

93 

94 

95class Discussions(discussions_pb2_grpc.DiscussionsServicer): 

96 def CreateDiscussion( 

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

98 ) -> discussions_pb2.Discussion: 

99 if not request.title: 

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

101 if not request.content: 

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

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

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

105 

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

107 cluster = session.execute( 

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

109 ).scalar_one_or_none() 

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

111 cluster = session.execute( 

112 select(Cluster) 

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

114 .where(Cluster.is_official_cluster) 

115 ).scalar_one_or_none() 

116 

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

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

119 

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

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

122 

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

124 if not has_completed_profile(session, user): 

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

126 

127 thread = Thread() 

128 session.add(thread) 

129 session.flush() 

130 

131 discussion: Discussion | None = None 

132 

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

134 nonlocal discussion 

135 discussion = Discussion( 

136 title=request.title, 

137 content=request.content, 

138 creator_user_id=context.user_id, 

139 owner_cluster_id=cluster.id, 

140 thread_id=thread.id, 

141 moderation_state_id=moderation_state_id, 

142 ) 

143 session.add(discussion) 

144 session.flush() 

145 return discussion.id 

146 

147 create_moderation( 

148 session=session, 

149 object_type=ModerationObjectType.discussion, 

150 object_id=create_object, 

151 creator_user_id=context.user_id, 

152 ) 

153 assert discussion is not None 

154 

155 log_event( 

156 context, 

157 session, 

158 "discussion.created", 

159 { 

160 "discussion_id": discussion.id, 

161 "cluster_id": cluster.id, 

162 "cluster_name": cluster.name, 

163 "is_official_cluster": cluster.is_official_cluster, 

164 }, 

165 ) 

166 

167 queue_job( 

168 session, 

169 job=generate_create_discussion_notifications, 

170 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload( 

171 discussion_id=discussion.id, 

172 ), 

173 ) 

174 

175 return discussion_to_pb(session, discussion, context) 

176 

177 def GetDiscussion( 

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

179 ) -> discussions_pb2.Discussion: 

180 discussion = session.execute( 

181 where_moderated_content_visible( 

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

183 context, 

184 Discussion, 

185 ) 

186 ).scalar_one_or_none() 

187 if not discussion: 

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

189 

190 return discussion_to_pb(session, discussion, context) 

191 

192 def UpdateDiscussion( 

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

194 ) -> discussions_pb2.Discussion: 

195 discussion = session.execute( 

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

197 ).scalar_one_or_none() 

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

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

200 if discussion.deleted is not None: 

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

202 if context.user_id != discussion.creator_user_id: 

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

204 

205 old_title = discussion.title 

206 old_content = discussion.content 

207 

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

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

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

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

212 discussion.title = new_title 

213 

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

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

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

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

218 discussion.content = new_content 

219 

220 title_changed = discussion.title != old_title 

221 content_changed = discussion.content != old_content 

222 

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

224 return discussion_to_pb(session, discussion, context) 

225 

226 session.add( 

227 DiscussionVersion( 

228 discussion_id=discussion.id, 

229 editor_user_id=context.user_id, 

230 change_type=ContentChangeType.edit, 

231 old_title=old_title if title_changed else None, 

232 new_title=discussion.title if title_changed else None, 

233 old_content=old_content if content_changed else None, 

234 new_content=discussion.content if content_changed else None, 

235 ) 

236 ) 

237 

238 discussion.last_edited = now() 

239 

240 log_event( 

241 context, 

242 session, 

243 "discussion.updated", 

244 { 

245 "discussion_id": discussion.id, 

246 }, 

247 ) 

248 

249 return discussion_to_pb(session, discussion, context) 

250 

251 def DeleteDiscussion( 

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

253 ) -> empty_pb2.Empty: 

254 discussion = session.execute( 

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

256 ).scalar_one_or_none() 

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

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

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

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

261 

262 if context.user_id != discussion.creator_user_id: 

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

264 

265 session.add( 

266 DiscussionVersion( 

267 discussion_id=discussion.id, 

268 editor_user_id=context.user_id, 

269 change_type=ContentChangeType.delete, 

270 old_title=discussion.title, 

271 new_title=None, 

272 old_content=discussion.content, 

273 new_content=None, 

274 ) 

275 ) 

276 

277 discussion.deleted = now() 

278 

279 log_event( 

280 context, 

281 session, 

282 "discussion.deleted", 

283 { 

284 "discussion_id": discussion.id, 

285 }, 

286 ) 

287 

288 return empty_pb2.Empty() 

289 

290 def ListMyCommunitiesDiscussions( 

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

292 ) -> discussions_pb2.ListMyCommunitiesDiscussionsRes: 

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

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

295 

296 discussions = ( 

297 session.execute( 

298 where_moderated_content_visible( 

299 select(Discussion) 

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

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

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

303 .where(Cluster.is_official_cluster) 

304 .where(Cluster.small_community_features_enabled) 

305 .where(Discussion.id <= next_page_id) 

306 .order_by(Discussion.id.desc()) 

307 .limit(page_size + 1), 

308 context, 

309 Discussion, 

310 is_list_operation=True, 

311 ) 

312 ) 

313 .scalars() 

314 .all() 

315 ) 

316 

317 return discussions_pb2.ListMyCommunitiesDiscussionsRes( 

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

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

320 )