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
« prev ^ index » next coverage.py v7.14.2, created at 2026-06-21 09:29 +0000
1import logging
3import grpc
4from google.protobuf import empty_pb2
5from sqlalchemy import select
6from sqlalchemy.orm import Session
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
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 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 )
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")
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()
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")
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")
122 thread = Thread()
123 session.add(thread)
124 session.flush()
126 discussion: Discussion | None = None
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
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
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 )
162 queue_job(
163 session,
164 job=generate_create_discussion_notifications,
165 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload(
166 discussion_id=discussion.id,
167 ),
168 )
170 return discussion_to_pb(session, discussion, context)
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")
185 return discussion_to_pb(session, discussion, context)
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")
200 old_title = discussion.title
201 old_content = discussion.content
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
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
215 title_changed = discussion.title != old_title
216 content_changed = discussion.content != old_content
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)
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 )
233 discussion.last_edited = now()
235 log_event(
236 context,
237 session,
238 "discussion.updated",
239 {
240 "discussion_id": discussion.id,
241 },
242 )
244 return discussion_to_pb(session, discussion, context)
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")
257 if context.user_id != discussion.creator_user_id:
258 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "discussion_delete_permission_denied")
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 )
272 discussion.deleted = now()
274 log_event(
275 context,
276 session,
277 "discussion.deleted",
278 {
279 "discussion_id": discussion.id,
280 },
281 )
283 return empty_pb2.Empty()
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
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 )
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 )