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
« 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
5import grpc
6from google.protobuf import empty_pb2
7from sqlalchemy import select
8from sqlalchemy.orm import Session
9from sqlalchemy.sql import delete, func, or_
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
38logger = logging.getLogger(__name__)
40MAX_PAGINATION_LENGTH = 25
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}
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 ]
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]
72 official_clusters = [node.official_cluster for node in nodes]
73 official_cluster_ids = [cluster.id for cluster in official_clusters]
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 )
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 )
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 ]
132def community_to_pb(session: Session, node: Node, context: CouchersContext) -> communities_pb2.Community:
133 return communities_to_pb(session, [node], context)[0]
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")
144 return community_to_pb(session, node, context)
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 )
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")
176 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
178 word_similarity_score = func.word_similarity(func.unaccent(raw_query), func.immutable_unaccent(Cluster.name))
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 )
189 rows = session.execute(query).scalars().all()
191 return communities_pb2.SearchCommunitiesRes(communities=communities_to_pb(session, rows, context))
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 )
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 )
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")
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")
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")
268 subscription.role = ClusterRole.admin
270 return empty_pb2.Empty()
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")
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")
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")
297 subscription.role = ClusterRole.member
299 return empty_pb2.Empty()
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
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")
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()
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 )
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 )
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 )
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 )
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()
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")
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 ]
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)
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)
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())
430 query = query.limit(page_size + 1)
431 occurrences = session.execute(query).scalars().all()
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 )
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 )
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")
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")
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 )
480 log_event(
481 context,
482 session,
483 "community.joined",
484 {"community_id": node.id, "community_name": node.official_cluster.name},
485 )
487 return empty_pb2.Empty()
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")
496 current_membership = node.official_cluster.members.where(User.id == context.user_id).one_or_none()
498 if not current_membership:
499 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_in_community")
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")
504 session.execute(
505 delete(ClusterSubscription)
506 .where(ClusterSubscription.cluster_id == node.official_cluster.id)
507 .where(ClusterSubscription.user_id == context.user_id)
508 )
510 log_event(
511 context, session, "community.left", {"community_id": node.id, "community_name": node.official_cluster.name}
512 )
514 return empty_pb2.Empty()
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 )
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 )
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()
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 )