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

1import logging 

2 

3import grpc 

4from sqlalchemy import select 

5from sqlalchemy.orm import Session 

6 

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 

20 

21logger = logging.getLogger(__name__) 

22 

23 

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 

31 

32 can_moderate = can_moderate_node(session, context.user_id, discussion.owner_cluster.parent_node_id) 

33 

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 ) 

47 

48 

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() 

52 

53 cluster = discussion.owner_cluster 

54 

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") 

57 

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 ) 

72 

73 

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") 

84 

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() 

95 

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") 

98 

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") 

101 

102 thread = Thread() 

103 session.add(thread) 

104 session.flush() 

105 

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() 

115 

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 ) 

127 

128 queue_job( 

129 session, 

130 job=generate_create_discussion_notifications, 

131 payload=jobs_pb2.GenerateCreateDiscussionNotificationsPayload( 

132 discussion_id=discussion.id, 

133 ), 

134 ) 

135 

136 return discussion_to_pb(session, discussion, context) 

137 

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") 

146 

147 return discussion_to_pb(session, discussion, context)