Coverage for app / backend / src / couchers / servicers / discussions.py: 86%
67 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-19 14:14 +0000
1import logging
3import grpc
4from sqlalchemy import select
5from sqlalchemy.orm import Session
7from couchers.context import CouchersContext, make_background_user_context
8from couchers.db import can_moderate_node, session_scope
9from couchers.event_log import log_event
10from couchers.jobs.enqueue import queue_job
11from couchers.models import Cluster, Discussion, Thread, User
12from couchers.models.notifications import NotificationTopicAction
13from couchers.notifications.notify import notify
14from couchers.proto import discussions_pb2, discussions_pb2_grpc, notification_data_pb2
15from couchers.proto.internal import jobs_pb2
16from couchers.servicers.api import user_model_to_pb
17from couchers.servicers.blocking import is_not_visible
18from couchers.servicers.threads import thread_to_pb
19from couchers.utils import Timestamp_from_datetime
21logger = logging.getLogger(__name__)
24def discussion_to_pb(session: Session, discussion: Discussion, context: CouchersContext) -> discussions_pb2.Discussion:
25 owner_community_id = None
26 owner_group_id = None
27 if discussion.owner_cluster.is_official_cluster:
28 owner_community_id = discussion.owner_cluster.parent_node_id
29 else:
30 owner_group_id = discussion.owner_cluster.id
32 can_moderate = can_moderate_node(session, context.user_id, discussion.owner_cluster.parent_node_id)
34 return discussions_pb2.Discussion(
35 discussion_id=discussion.id,
36 slug=discussion.slug,
37 created=Timestamp_from_datetime(discussion.created),
38 creator_user_id=discussion.creator_user_id,
39 owner_community_id=owner_community_id,
40 owner_group_id=owner_group_id,
41 owner_title=discussion.owner_cluster.name,
42 title=discussion.title,
43 content=discussion.content,
44 thread=thread_to_pb(session, discussion.thread_id),
45 can_moderate=can_moderate,
46 )
49def generate_create_discussion_notifications(payload: jobs_pb2.GenerateCreateDiscussionNotificationsPayload) -> None:
50 with session_scope() as session:
51 discussion = session.execute(select(Discussion).where(Discussion.id == payload.discussion_id)).scalar_one()
53 cluster = discussion.owner_cluster
55 if not cluster.is_official_cluster: 55 ↛ 56line 55 didn't jump to line 56 because the condition on line 55 was never true
56 raise NotImplementedError("Shouldn't have discussions under groups, only communities")
58 for user in list(cluster.members.where(User.is_visible)):
59 if is_not_visible(session, user.id, discussion.creator_user_id): 59 ↛ 60line 59 didn't jump to line 60 because the condition on line 59 was never true
60 continue
61 context = make_background_user_context(user_id=user.id)
62 notify(
63 session,
64 user_id=user.id,
65 topic_action=NotificationTopicAction.discussion__create,
66 key=str(payload.discussion_id),
67 data=notification_data_pb2.DiscussionCreate(
68 author=user_model_to_pb(discussion.creator_user, session, context),
69 discussion=discussion_to_pb(session, discussion, context),
70 ),
71 )
74class Discussions(discussions_pb2_grpc.DiscussionsServicer):
75 def CreateDiscussion(
76 self, request: discussions_pb2.CreateDiscussionReq, context: CouchersContext, session: Session
77 ) -> discussions_pb2.Discussion:
78 if not request.title:
79 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_title")
80 if not request.content:
81 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_content")
82 if not request.owner_community_id and not request.owner_group_id: 82 ↛ 83line 82 didn't jump to line 83 because the condition on line 82 was never true
83 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "group_or_community_not_found")
85 if request.WhichOneof("owner") == "owner_group_id":
86 cluster = session.execute(
87 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.owner_group_id)
88 ).scalar_one_or_none()
89 elif request.WhichOneof("owner") == "owner_community_id": 89 ↛ 96line 89 didn't jump to line 96 because the condition on line 89 was always true
90 cluster = session.execute(
91 select(Cluster)
92 .where(Cluster.parent_node_id == request.owner_community_id)
93 .where(Cluster.is_official_cluster)
94 ).scalar_one_or_none()
96 if not cluster: 96 ↛ 97line 96 didn't jump to line 97 because the condition on line 96 was never true
97 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found")
99 if not cluster.discussions_enabled: 99 ↛ 100line 99 didn't jump to line 100 because the condition on line 99 was never true
100 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cannot_create_discussion")
102 thread = Thread()
103 session.add(thread)
104 session.flush()
106 discussion = Discussion(
107 title=request.title,
108 content=request.content,
109 creator_user_id=context.user_id,
110 owner_cluster_id=cluster.id,
111 thread_id=thread.id,
112 )
113 session.add(discussion)
114 session.flush()
116 log_event(
117 context,
118 session,
119 "discussion.created",
120 {
121 "discussion_id": discussion.id,
122 "cluster_id": cluster.id,
123 "cluster_name": cluster.name,
124 "is_official_cluster": cluster.is_official_cluster,
125 },
126 )
128 queue_job(
129 session,
130 job=generate_create_discussion_notifications,
131 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload(
132 discussion_id=discussion.id,
133 ),
134 )
136 return discussion_to_pb(session, discussion, context)
138 def GetDiscussion(
139 self, request: discussions_pb2.GetDiscussionReq, context: CouchersContext, session: Session
140 ) -> discussions_pb2.Discussion:
141 discussion = session.execute(
142 select(Discussion).where(Discussion.id == request.discussion_id)
143 ).scalar_one_or_none()
144 if not discussion: 144 ↛ 145line 144 didn't jump to line 145 because the condition on line 144 was never true
145 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found")
147 return discussion_to_pb(session, discussion, context)