Coverage for app/backend/src/couchers/servicers/discussions.py: 86%
128 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-19 00:32 +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.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
26logger = logging.getLogger(__name__)
28MAX_PAGE_SIZE = 25
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
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 )
50 can_moderate = can_moderate_node(session, context.user_id, discussion.owner_cluster.parent_node_id)
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 )
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()
73 cluster = discussion.owner_cluster
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")
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 )
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")
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()
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")
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")
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")
127 thread = Thread()
128 session.add(thread)
129 session.flush()
131 discussion: Discussion | None = None
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
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
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 )
167 queue_job(
168 session,
169 job=generate_create_discussion_notifications,
170 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload(
171 discussion_id=discussion.id,
172 ),
173 )
175 return discussion_to_pb(session, discussion, context)
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")
190 return discussion_to_pb(session, discussion, context)
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")
205 old_title = discussion.title
206 old_content = discussion.content
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
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
220 title_changed = discussion.title != old_title
221 content_changed = discussion.content != old_content
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)
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 )
238 discussion.last_edited = now()
240 log_event(
241 context,
242 session,
243 "discussion.updated",
244 {
245 "discussion_id": discussion.id,
246 },
247 )
249 return discussion_to_pb(session, discussion, context)
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")
262 if context.user_id != discussion.creator_user_id:
263 context.abort_with_error_code(grpc.StatusCode.PERMISSION_DENIED, "discussion_delete_permission_denied")
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 )
277 discussion.deleted = now()
279 log_event(
280 context,
281 session,
282 "discussion.deleted",
283 {
284 "discussion_id": discussion.id,
285 },
286 )
288 return empty_pb2.Empty()
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
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 )
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 )