Coverage for app / backend / src / couchers / servicers / discussions.py: 85%
65 statements
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +0000
« prev ^ index » next coverage.py v7.13.2, created at 2026-02-03 06:18 +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.jobs.enqueue import queue_job
10from couchers.models import Cluster, Discussion, Thread, User
11from couchers.models.notifications import NotificationTopicAction
12from couchers.notifications.notify import notify
13from couchers.proto import discussions_pb2, discussions_pb2_grpc, notification_data_pb2
14from couchers.proto.internal import jobs_pb2
15from couchers.servicers.api import user_model_to_pb
16from couchers.servicers.blocking import is_not_visible
17from couchers.servicers.threads import thread_to_pb
18from couchers.utils import Timestamp_from_datetime
20logger = logging.getLogger(__name__)
23def discussion_to_pb(session: Session, discussion: Discussion, context: CouchersContext) -> discussions_pb2.Discussion:
24 owner_community_id = None
25 owner_group_id = None
26 if discussion.owner_cluster.is_official_cluster:
27 owner_community_id = discussion.owner_cluster.parent_node_id
28 else:
29 owner_group_id = discussion.owner_cluster.id
31 can_moderate = can_moderate_node(session, context.user_id, discussion.owner_cluster.parent_node_id)
33 return discussions_pb2.Discussion(
34 discussion_id=discussion.id,
35 slug=discussion.slug,
36 created=Timestamp_from_datetime(discussion.created),
37 creator_user_id=discussion.creator_user_id,
38 owner_community_id=owner_community_id,
39 owner_group_id=owner_group_id,
40 owner_title=discussion.owner_cluster.name,
41 title=discussion.title,
42 content=discussion.content,
43 thread=thread_to_pb(session, discussion.thread_id),
44 can_moderate=can_moderate,
45 )
48def generate_create_discussion_notifications(payload: jobs_pb2.GenerateCreateDiscussionNotificationsPayload) -> None:
49 with session_scope() as session:
50 discussion = session.execute(select(Discussion).where(Discussion.id == payload.discussion_id)).scalar_one()
52 cluster = discussion.owner_cluster
54 if not cluster.is_official_cluster: 54 ↛ 55line 54 didn't jump to line 55 because the condition on line 54 was never true
55 raise NotImplementedError("Shouldn't have discussions under groups, only communities")
57 for user in list(cluster.members.where(User.is_visible)):
58 if is_not_visible(session, user.id, discussion.creator_user_id): 58 ↛ 59line 58 didn't jump to line 59 because the condition on line 58 was never true
59 continue
60 context = make_background_user_context(user_id=user.id)
61 notify(
62 session,
63 user_id=user.id,
64 topic_action=NotificationTopicAction.discussion__create,
65 key=str(payload.discussion_id),
66 data=notification_data_pb2.DiscussionCreate(
67 author=user_model_to_pb(discussion.creator_user, session, context),
68 discussion=discussion_to_pb(session, discussion, context),
69 ),
70 )
73class Discussions(discussions_pb2_grpc.DiscussionsServicer):
74 def CreateDiscussion(
75 self, request: discussions_pb2.CreateDiscussionReq, context: CouchersContext, session: Session
76 ) -> discussions_pb2.Discussion:
77 if not request.title:
78 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_title")
79 if not request.content:
80 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "missing_discussion_content")
81 if not request.owner_community_id and not request.owner_group_id: 81 ↛ 82line 81 didn't jump to line 82 because the condition on line 81 was never true
82 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "group_or_community_not_found")
84 if request.WhichOneof("owner") == "owner_group_id":
85 cluster = session.execute(
86 select(Cluster).where(~Cluster.is_official_cluster).where(Cluster.id == request.owner_group_id)
87 ).scalar_one_or_none()
88 elif request.WhichOneof("owner") == "owner_community_id": 88 ↛ 95line 88 didn't jump to line 95 because the condition on line 88 was always true
89 cluster = session.execute(
90 select(Cluster)
91 .where(Cluster.parent_node_id == request.owner_community_id)
92 .where(Cluster.is_official_cluster)
93 ).scalar_one_or_none()
95 if not cluster: 95 ↛ 96line 95 didn't jump to line 96 because the condition on line 95 was never true
96 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "group_or_community_not_found")
98 if not cluster.discussions_enabled: 98 ↛ 99line 98 didn't jump to line 99 because the condition on line 98 was never true
99 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cannot_create_discussion")
101 thread = Thread()
102 session.add(thread)
103 session.flush()
105 discussion = Discussion(
106 title=request.title,
107 content=request.content,
108 creator_user_id=context.user_id,
109 owner_cluster_id=cluster.id,
110 thread_id=thread.id,
111 )
112 session.add(discussion)
113 session.flush()
115 queue_job(
116 session,
117 job=generate_create_discussion_notifications,
118 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload(
119 discussion_id=discussion.id,
120 ),
121 )
123 return discussion_to_pb(session, discussion, context)
125 def GetDiscussion(
126 self, request: discussions_pb2.GetDiscussionReq, context: CouchersContext, session: Session
127 ) -> discussions_pb2.Discussion:
128 discussion = session.execute(
129 select(Discussion).where(Discussion.id == request.discussion_id)
130 ).scalar_one_or_none()
131 if not discussion: 131 ↛ 132line 131 didn't jump to line 132 because the condition on line 131 was never true
132 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found")
134 return discussion_to_pb(session, discussion, context)