Coverage for app / backend / src / couchers / servicers / communities.py: 79%

205 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-03-19 14:14 +0000

1import logging 

2from collections.abc import Sequence 

3from datetime import timedelta 

4 

5import grpc 

6from google.protobuf import empty_pb2 

7from sqlalchemy import select 

8from sqlalchemy.orm import Session 

9from sqlalchemy.sql import delete, func, or_ 

10 

11from couchers.constants import COMMUNITIES_SEARCH_FUZZY_SIMILARITY_THRESHOLD 

12from couchers.context import CouchersContext 

13from couchers.crypto import decrypt_page_token, encrypt_page_token 

14from couchers.db import can_moderate_node, get_node_parents_recursively, is_user_in_node_geography 

15from couchers.event_log import log_event 

16from couchers.materialized_views import ClusterAdminCount, ClusterSubscriptionCount 

17from couchers.models import ( 

18 Cluster, 

19 ClusterRole, 

20 ClusterSubscription, 

21 Discussion, 

22 Event, 

23 EventOccurrence, 

24 Node, 

25 NodeType, 

26 Page, 

27 PageType, 

28 User, 

29) 

30from couchers.proto import communities_pb2, communities_pb2_grpc, groups_pb2 

31from couchers.servicers.discussions import discussion_to_pb 

32from couchers.servicers.events import event_to_pb 

33from couchers.servicers.groups import group_to_pb 

34from couchers.servicers.pages import page_to_pb 

35from couchers.sql import to_bool, users_visible, where_moderated_content_visible 

36from couchers.utils import Timestamp_from_datetime, dt_from_millis, millis_from_dt, now 

37 

38logger = logging.getLogger(__name__) 

39 

40MAX_PAGINATION_LENGTH = 25 

41 

42nodetype2api = { 

43 NodeType.world: communities_pb2.NODE_TYPE_WORLD, 

44 NodeType.macroregion: communities_pb2.NODE_TYPE_MACROREGION, 

45 NodeType.region: communities_pb2.NODE_TYPE_REGION, 

46 NodeType.subregion: communities_pb2.NODE_TYPE_SUBREGION, 

47 NodeType.locality: communities_pb2.NODE_TYPE_LOCALITY, 

48 NodeType.sublocality: communities_pb2.NODE_TYPE_SUBLOCALITY, 

49} 

50 

51 

52def _parents_to_pb(session: Session, node_id: int) -> list[groups_pb2.Parent]: 

53 parents = get_node_parents_recursively(session, node_id) 

54 return [ 

55 groups_pb2.Parent( 

56 community=groups_pb2.CommunityParent( 

57 community_id=node_id, 

58 name=cluster.name, 

59 slug=cluster.slug, 

60 description=cluster.description, 

61 ) 

62 ) 

63 for node_id, parent_node_id, level, cluster in parents 

64 ] 

65 

66 

67def communities_to_pb( 

68 session: Session, nodes: Sequence[Node], context: CouchersContext 

69) -> list[communities_pb2.Community]: 

70 can_moderates = [can_moderate_node(session, context.user_id, node.id) for node in nodes] 

71 

72 official_clusters = [node.official_cluster for node in nodes] 

73 official_cluster_ids = [cluster.id for cluster in official_clusters] 

74 

75 member_counts: dict[int, int] = dict( 

76 session.execute( # type: ignore[arg-type] 

77 select(ClusterSubscriptionCount.cluster_id, ClusterSubscriptionCount.count).where( 

78 ClusterSubscriptionCount.cluster_id.in_(official_cluster_ids) 

79 ) 

80 ).all() 

81 ) 

82 cluster_memberships = set( 

83 session.execute( 

84 select(ClusterSubscription.cluster_id) 

85 .where(ClusterSubscription.user_id == context.user_id) 

86 .where(ClusterSubscription.cluster_id.in_(official_cluster_ids)) 

87 ) 

88 .scalars() 

89 .all() 

90 ) 

91 

92 admin_counts: dict[int, int] = dict( 

93 session.execute( # type: ignore[arg-type] 

94 select(ClusterAdminCount.cluster_id, ClusterAdminCount.count).where( 

95 ClusterAdminCount.cluster_id.in_(official_cluster_ids) 

96 ) 

97 ).all() 

98 ) 

99 cluster_adminships = set( 

100 session.execute( 

101 select(ClusterSubscription.cluster_id) 

102 .where(ClusterSubscription.user_id == context.user_id) 

103 .where(ClusterSubscription.cluster_id.in_(official_cluster_ids)) 

104 .where(ClusterSubscription.role == ClusterRole.admin) 

105 ) 

106 .scalars() 

107 .all() 

108 ) 

109 

110 return [ 

111 communities_pb2.Community( 

112 community_id=node.id, 

113 name=official_cluster.name, 

114 slug=official_cluster.slug, 

115 description=official_cluster.description, 

116 created=Timestamp_from_datetime(node.created), 

117 parents=_parents_to_pb(session, node.id), 

118 member=official_cluster.id in cluster_memberships, 

119 admin=official_cluster.id in cluster_adminships, 

120 member_count=member_counts.get(official_cluster.id, 1), 

121 admin_count=admin_counts.get(official_cluster.id, 1), 

122 main_page=page_to_pb(session, official_cluster.main_page, context), 

123 can_moderate=can_moderate, 

124 discussions_enabled=official_cluster.discussions_enabled, 

125 events_enabled=official_cluster.events_enabled, 

126 node_type=nodetype2api[node.node_type], 

127 ) 

128 for node, official_cluster, can_moderate in zip(nodes, official_clusters, can_moderates) 

129 ] 

130 

131 

132def community_to_pb(session: Session, node: Node, context: CouchersContext) -> communities_pb2.Community: 

133 return communities_to_pb(session, [node], context)[0] 

134 

135 

136class Communities(communities_pb2_grpc.CommunitiesServicer): 

137 def GetCommunity( 

138 self, request: communities_pb2.GetCommunityReq, context: CouchersContext, session: Session 

139 ) -> communities_pb2.Community: 

140 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

141 if not node: 141 ↛ 142line 141 didn't jump to line 142 because the condition on line 141 was never true

142 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

143 

144 return community_to_pb(session, node, context) 

145 

146 def ListCommunities( 

147 self, request: communities_pb2.ListCommunitiesReq, context: CouchersContext, session: Session 

148 ) -> communities_pb2.ListCommunitiesRes: 

149 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

150 offset = int(decrypt_page_token(request.page_token)) if request.page_token else 0 

151 nodes = ( 

152 session.execute( 

153 select(Node) 

154 .join(Cluster, Cluster.parent_node_id == Node.id) 

155 .where(or_(Node.parent_node_id == request.community_id, to_bool(request.community_id == 0))) 

156 .where(Cluster.is_official_cluster) 

157 .order_by(Cluster.name) 

158 .limit(page_size + 1) 

159 .offset(offset) 

160 ) 

161 .scalars() 

162 .all() 

163 ) 

164 return communities_pb2.ListCommunitiesRes( 

165 communities=communities_to_pb(session, nodes[:page_size], context), 

166 next_page_token=encrypt_page_token(str(offset + page_size)) if len(nodes) > page_size else None, 

167 ) 

168 

169 def SearchCommunities( 

170 self, request: communities_pb2.SearchCommunitiesReq, context: CouchersContext, session: Session 

171 ) -> communities_pb2.SearchCommunitiesRes: 

172 raw_query = request.query.strip() 

173 if len(raw_query) < 3: 

174 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "query_too_short") 

175 

176 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

177 

178 word_similarity_score = func.word_similarity(func.unaccent(raw_query), func.immutable_unaccent(Cluster.name)) 

179 

180 query = ( 

181 select(Node) 

182 .join(Cluster, Cluster.parent_node_id == Node.id) 

183 .where(Cluster.is_official_cluster) 

184 .where(word_similarity_score > COMMUNITIES_SEARCH_FUZZY_SIMILARITY_THRESHOLD) 

185 .order_by(word_similarity_score.desc(), Cluster.name.asc(), Node.id.asc()) 

186 .limit(page_size) 

187 ) 

188 

189 rows = session.execute(query).scalars().all() 

190 

191 return communities_pb2.SearchCommunitiesRes(communities=communities_to_pb(session, rows, context)) 

192 

193 def ListGroups( 

194 self, request: communities_pb2.ListGroupsReq, context: CouchersContext, session: Session 

195 ) -> communities_pb2.ListGroupsRes: 

196 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

197 next_cluster_id = int(request.page_token) if request.page_token else 0 

198 clusters = ( 

199 session.execute( 

200 select(Cluster) 

201 .where(~Cluster.is_official_cluster) # not an official group 

202 .where(Cluster.parent_node_id == request.community_id) 

203 .where(Cluster.id >= next_cluster_id) 

204 .order_by(Cluster.id) 

205 .limit(page_size + 1) 

206 ) 

207 .scalars() 

208 .all() 

209 ) 

210 return communities_pb2.ListGroupsRes( 

211 groups=[group_to_pb(session, cluster, context) for cluster in clusters[:page_size]], 

212 next_page_token=str(clusters[-1].id) if len(clusters) > page_size else None, 

213 ) 

214 

215 def ListAdmins( 

216 self, request: communities_pb2.ListAdminsReq, context: CouchersContext, session: Session 

217 ) -> communities_pb2.ListAdminsRes: 

218 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

219 next_admin_id = int(request.page_token) if request.page_token else 0 

220 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

221 if not node: 221 ↛ 222line 221 didn't jump to line 222 because the condition on line 221 was never true

222 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

223 admins = ( 

224 session.execute( 

225 select(User) 

226 .join(ClusterSubscription, ClusterSubscription.user_id == User.id) 

227 .where(users_visible(context)) 

228 .where(ClusterSubscription.cluster_id == node.official_cluster.id) 

229 .where(ClusterSubscription.role == ClusterRole.admin) 

230 .where(User.id >= next_admin_id) 

231 .order_by(User.id) 

232 .limit(page_size + 1) 

233 ) 

234 .scalars() 

235 .all() 

236 ) 

237 return communities_pb2.ListAdminsRes( 

238 admin_user_ids=[admin.id for admin in admins[:page_size]], 

239 next_page_token=str(admins[-1].id) if len(admins) > page_size else None, 

240 ) 

241 

242 def AddAdmin( 

243 self, request: communities_pb2.AddAdminReq, context: CouchersContext, session: Session 

244 ) -> empty_pb2.Empty: 

245 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

246 if not node: 246 ↛ 247line 246 didn't jump to line 247 because the condition on line 246 was never true

247 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

248 if not can_moderate_node(session, context.user_id, node.id): 

249 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "node_moderate_permission_denied") 

250 

251 user = session.execute( 

252 select(User).where(users_visible(context)).where(User.id == request.user_id) 

253 ).scalar_one_or_none() 

254 if not user: 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.NOT_FOUND, "user_not_found") 

256 

257 subscription = session.execute( 

258 select(ClusterSubscription) 

259 .where(ClusterSubscription.user_id == user.id) 

260 .where(ClusterSubscription.cluster_id == node.official_cluster.id) 

261 ).scalar_one_or_none() 

262 if not subscription: 

263 # Can't upgrade a member to admin if they're not already a member 

264 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_member") 

265 if subscription.role == ClusterRole.admin: 

266 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_already_admin") 

267 

268 subscription.role = ClusterRole.admin 

269 

270 return empty_pb2.Empty() 

271 

272 def RemoveAdmin( 

273 self, request: communities_pb2.RemoveAdminReq, context: CouchersContext, session: Session 

274 ) -> empty_pb2.Empty: 

275 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

276 if not node: 276 ↛ 277line 276 didn't jump to line 277 because the condition on line 276 was never true

277 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

278 if not can_moderate_node(session, context.user_id, node.id): 278 ↛ 279line 278 didn't jump to line 279 because the condition on line 278 was never true

279 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "node_moderate_permission_denied") 

280 

281 user = session.execute( 

282 select(User).where(users_visible(context)).where(User.id == request.user_id) 

283 ).scalar_one_or_none() 

284 if not user: 284 ↛ 285line 284 didn't jump to line 285 because the condition on line 284 was never true

285 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found") 

286 

287 subscription = session.execute( 

288 select(ClusterSubscription) 

289 .where(ClusterSubscription.user_id == user.id) 

290 .where(ClusterSubscription.cluster_id == node.official_cluster.id) 

291 ).scalar_one_or_none() 

292 if not subscription: 

293 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_member") 

294 if subscription.role == ClusterRole.member: 

295 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_admin") 

296 

297 subscription.role = ClusterRole.member 

298 

299 return empty_pb2.Empty() 

300 

301 def ListMembers( 

302 self, request: communities_pb2.ListMembersReq, context: CouchersContext, session: Session 

303 ) -> communities_pb2.ListMembersRes: 

304 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

305 next_member_id = int(request.page_token) if request.page_token else None 

306 

307 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

308 if not node: 308 ↛ 309line 308 didn't jump to line 309 because the condition on line 308 was never true

309 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

310 

311 query = ( 

312 select(User) 

313 .join(ClusterSubscription, ClusterSubscription.user_id == User.id) 

314 .where(users_visible(context)) 

315 .where(ClusterSubscription.cluster_id == node.official_cluster.id) 

316 ) 

317 if next_member_id is not None: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 query = query.where(User.id <= next_member_id) 

319 members = session.execute(query.order_by(User.id.desc()).limit(page_size + 1)).scalars().all() 

320 

321 return communities_pb2.ListMembersRes( 

322 member_user_ids=[member.id for member in members[:page_size]], 

323 next_page_token=str(members[-1].id) if len(members) > page_size else None, 

324 ) 

325 

326 def ListNearbyUsers( 

327 self, request: communities_pb2.ListNearbyUsersReq, context: CouchersContext, session: Session 

328 ) -> communities_pb2.ListNearbyUsersRes: 

329 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

330 next_nearby_id = int(request.page_token) if request.page_token else 0 

331 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

332 if not node: 332 ↛ 333line 332 didn't jump to line 333 because the condition on line 332 was never true

333 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

334 nearbys = ( 

335 session.execute( 

336 select(User) 

337 .where(users_visible(context)) 

338 .where(func.ST_Contains(node.geom, User.geom)) 

339 .where(User.id >= next_nearby_id) 

340 .order_by(User.id) 

341 .limit(page_size + 1) 

342 ) 

343 .scalars() 

344 .all() 

345 ) 

346 return communities_pb2.ListNearbyUsersRes( 

347 nearby_user_ids=[nearby.id for nearby in nearbys[:page_size]], 

348 next_page_token=str(nearbys[-1].id) if len(nearbys) > page_size else None, 

349 ) 

350 

351 def ListPlaces( 

352 self, request: communities_pb2.ListPlacesReq, context: CouchersContext, session: Session 

353 ) -> communities_pb2.ListPlacesRes: 

354 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

355 next_page_id = int(request.page_token) if request.page_token else 0 

356 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

357 if not node: 

358 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

359 places = ( 

360 node.official_cluster.owned_pages.where(Page.type == PageType.place) 

361 .where(Page.id >= next_page_id) 

362 .order_by(Page.id) 

363 .limit(page_size + 1) 

364 .all() 

365 ) 

366 return communities_pb2.ListPlacesRes( 

367 places=[page_to_pb(session, page, context) for page in places[:page_size]], 

368 next_page_token=str(places[-1].id) if len(places) > page_size else None, 

369 ) 

370 

371 def ListGuides( 

372 self, request: communities_pb2.ListGuidesReq, context: CouchersContext, session: Session 

373 ) -> communities_pb2.ListGuidesRes: 

374 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

375 next_page_id = int(request.page_token) if request.page_token else 0 

376 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

377 if not node: 

378 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

379 guides = ( 

380 node.official_cluster.owned_pages.where(Page.type == PageType.guide) 

381 .where(Page.id >= next_page_id) 

382 .order_by(Page.id) 

383 .limit(page_size + 1) 

384 .all() 

385 ) 

386 return communities_pb2.ListGuidesRes( 

387 guides=[page_to_pb(session, page, context) for page in guides[:page_size]], 

388 next_page_token=str(guides[-1].id) if len(guides) > page_size else None, 

389 ) 

390 

391 def ListEvents( 

392 self, request: communities_pb2.ListEventsReq, context: CouchersContext, session: Session 

393 ) -> communities_pb2.ListEventsRes: 

394 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

395 # the page token is a unix timestamp of where we left off 

396 page_token = dt_from_millis(int(request.page_token)) if request.page_token else now() 

397 

398 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

399 if not node: 399 ↛ 400line 399 didn't jump to line 400 because the condition on line 399 was never true

400 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

401 if not node.official_cluster.events_enabled: 401 ↛ 402line 401 didn't jump to line 402 because the condition on line 401 was never true

402 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "events_not_enabled") 

403 

404 if not request.include_parents: 404 ↛ 408line 404 didn't jump to line 408 because the condition on line 404 was always true

405 nodes_clusters_to_search = [(node.id, node.official_cluster)] 

406 else: 

407 # the first value is the node_id, the last is the cluster (object) 

408 nodes_clusters_to_search = [ 

409 (parent[0], parent[3]) for parent in get_node_parents_recursively(session, node.id) 

410 ] 

411 

412 membership_clauses = [] 

413 for node_id, official_cluster_obj in nodes_clusters_to_search: 

414 membership_clauses.append(Event.owner_cluster == official_cluster_obj) 

415 membership_clauses.append(Event.parent_node_id == node_id) 

416 

417 # for communities, we list events owned by this community or for which this is a parent 

418 query = ( 

419 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(or_(*membership_clauses)) 

420 ) 

421 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True) 

422 

423 if request.past: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true

424 cutoff = page_token + timedelta(seconds=1) 

425 query = query.where(EventOccurrence.end_time < cutoff).order_by(EventOccurrence.start_time.desc()) 

426 else: 

427 cutoff = page_token - timedelta(seconds=1) 

428 query = query.where(EventOccurrence.end_time > cutoff).order_by(EventOccurrence.start_time.asc()) 

429 

430 query = query.limit(page_size + 1) 

431 occurrences = session.execute(query).scalars().all() 

432 

433 return communities_pb2.ListEventsRes( 

434 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]], 

435 next_page_token=str(millis_from_dt(occurrences[-1].end_time)) if len(occurrences) > page_size else None, 

436 ) 

437 

438 def ListDiscussions( 

439 self, request: communities_pb2.ListDiscussionsReq, context: CouchersContext, session: Session 

440 ) -> communities_pb2.ListDiscussionsRes: 

441 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

442 next_page_id = int(request.page_token) if request.page_token else 0 

443 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

444 if not node: 444 ↛ 445line 444 didn't jump to line 445 because the condition on line 444 was never true

445 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

446 if not node.official_cluster.discussions_enabled: 446 ↛ 447line 446 didn't jump to line 447 because the condition on line 446 was never true

447 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "discussions_not_enabled") 

448 discussions = ( 

449 node.official_cluster.owned_discussions.where( 

450 or_(Discussion.id <= next_page_id, to_bool(next_page_id == 0)) 

451 ) 

452 .order_by(Discussion.id.desc()) 

453 .limit(page_size + 1) 

454 .all() 

455 ) 

456 return communities_pb2.ListDiscussionsRes( 

457 discussions=[discussion_to_pb(session, discussion, context) for discussion in discussions[:page_size]], 

458 next_page_token=str(discussions[-1].id) if len(discussions) > page_size else None, 

459 ) 

460 

461 def JoinCommunity( 

462 self, request: communities_pb2.JoinCommunityReq, context: CouchersContext, session: Session 

463 ) -> empty_pb2.Empty: 

464 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

465 if not node: 465 ↛ 466line 465 didn't jump to line 466 because the condition on line 465 was never true

466 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

467 

468 current_membership = node.official_cluster.members.where(User.id == context.user_id).one_or_none() 

469 if current_membership: 

470 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "already_in_community") 

471 

472 node.official_cluster.cluster_subscriptions.append( 

473 ClusterSubscription( 

474 user_id=context.user_id, 

475 cluster_id=node.official_cluster.id, 

476 role=ClusterRole.member, 

477 ) 

478 ) 

479 

480 log_event( 

481 context, 

482 session, 

483 "community.joined", 

484 {"community_id": node.id, "community_name": node.official_cluster.name}, 

485 ) 

486 

487 return empty_pb2.Empty() 

488 

489 def LeaveCommunity( 

490 self, request: communities_pb2.LeaveCommunityReq, context: CouchersContext, session: Session 

491 ) -> empty_pb2.Empty: 

492 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none() 

493 if not node: 493 ↛ 494line 493 didn't jump to line 494 because the condition on line 493 was never true

494 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found") 

495 

496 current_membership = node.official_cluster.members.where(User.id == context.user_id).one_or_none() 

497 

498 if not current_membership: 

499 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_in_community") 

500 

501 if is_user_in_node_geography(session, context.user_id, node.id): 

502 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cannot_leave_containing_community") 

503 

504 session.execute( 

505 delete(ClusterSubscription) 

506 .where(ClusterSubscription.cluster_id == node.official_cluster.id) 

507 .where(ClusterSubscription.user_id == context.user_id) 

508 ) 

509 

510 log_event( 

511 context, session, "community.left", {"community_id": node.id, "community_name": node.official_cluster.name} 

512 ) 

513 

514 return empty_pb2.Empty() 

515 

516 def ListUserCommunities( 

517 self, request: communities_pb2.ListUserCommunitiesReq, context: CouchersContext, session: Session 

518 ) -> communities_pb2.ListUserCommunitiesRes: 

519 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH) 

520 next_node_id = int(request.page_token) if request.page_token else 0 

521 user_id = request.user_id or context.user_id 

522 nodes = ( 

523 session.execute( 

524 select(Node) 

525 .join(Cluster, Cluster.parent_node_id == Node.id) 

526 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id) 

527 .where(ClusterSubscription.user_id == user_id) 

528 .where(Cluster.is_official_cluster) 

529 .where(Node.id >= next_node_id) 

530 .order_by(Node.id) 

531 .limit(page_size + 1) 

532 ) 

533 .scalars() 

534 .all() 

535 ) 

536 

537 return communities_pb2.ListUserCommunitiesRes( 

538 communities=communities_to_pb(session, nodes[:page_size], context), 

539 next_page_token=str(nodes[-1].id) if len(nodes) > page_size else None, 

540 ) 

541 

542 def ListAllCommunities( 

543 self, request: communities_pb2.ListAllCommunitiesReq, context: CouchersContext, session: Session 

544 ) -> communities_pb2.ListAllCommunitiesRes: 

545 """List all communities ordered hierarchically by parent-child relationships""" 

546 # Get all nodes with their clusters, member counts, and user membership in a single query 

547 results = session.execute( 

548 select( 

549 Node, 

550 Cluster, 

551 ClusterSubscriptionCount.count, 

552 ClusterSubscription.cluster_id.label("user_subscription"), 

553 ) 

554 .join(Cluster, Cluster.parent_node_id == Node.id) 

555 .outerjoin( 

556 ClusterSubscriptionCount, 

557 ClusterSubscriptionCount.cluster_id == Cluster.id, 

558 ) 

559 .outerjoin( 

560 ClusterSubscription, 

561 (ClusterSubscription.cluster_id == Cluster.id) & (ClusterSubscription.user_id == context.user_id), 

562 ) 

563 .where(Cluster.is_official_cluster) 

564 .order_by(Node.id) 

565 ).all() 

566 

567 return communities_pb2.ListAllCommunitiesRes( 

568 communities=[ 

569 communities_pb2.CommunitySummary( 

570 community_id=node.id, 

571 name=cluster.name, 

572 slug=cluster.slug, 

573 member=user_subscription is not None, 

574 member_count=member_count or 1, 

575 parents=_parents_to_pb(session, node.id), 

576 created=Timestamp_from_datetime(node.created), 

577 node_type=nodetype2api[node.node_type], 

578 ) 

579 for node, cluster, member_count, user_subscription in results 

580 ], 

581 )