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
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
1import logging
3import grpc
4from google.protobuf import empty_pb2
5from sqlalchemy import select
6from sqlalchemy.orm import Session, aliased
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
25logger = logging.getLogger(__name__)
27MAX_PAGE_SIZE = 25
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
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 )
49 can_moderate = can_moderate_node(session, context.user_id, discussion.owner_cluster.parent_node_id)
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 )
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()
72 cluster = discussion.owner_cluster
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")
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 )
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")
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()
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")
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")
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")
136 thread = Thread()
137 session.add(thread)
138 session.flush()
140 discussion: Discussion | None = None
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
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
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 )
176 queue_job(
177 session,
178 job=generate_create_discussion_notifications,
179 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload(
180 discussion_id=discussion.id,
181 ),
182 )
184 return discussion_to_pb(session, discussion, context)
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")
199 return discussion_to_pb(session, discussion, context)
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")
214 old_title = discussion.title
215 old_content = discussion.content
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
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
229 title_changed = discussion.title != old_title
230 content_changed = discussion.content != old_content
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)
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 )
247 discussion.last_edited = now()
249 log_event(
250 context,
251 session,
252 "discussion.updated",
253 {
254 "discussion_id": discussion.id,
255 },
256 )
258 return discussion_to_pb(session, discussion, context)
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")
271 if context.user_id != discussion.creator_user_id:
272 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "discussion_delete_permission_denied")
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 )
286 discussion.deleted = now()
288 log_event(
289 context,
290 session,
291 "discussion.deleted",
292 {
293 "discussion_id": discussion.id,
294 },
295 )
297 return empty_pb2.Empty()
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
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 )
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 )