Coverage for app/backend/src/couchers/servicers/communities.py: 81%
208 statements
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
« prev ^ index » next coverage.py v7.15.3, created at 2026-08-04 22:32 +0000
1import logging
2from collections.abc import Sequence
4import grpc
5from google.protobuf import empty_pb2
6from sqlalchemy import select
7from sqlalchemy.orm import Session, selectinload
8from sqlalchemy.sql import and_, delete, func, or_
10from couchers.constants import COMMUNITIES_SEARCH_FUZZY_SIMILARITY_THRESHOLD
11from couchers.context import CouchersContext
12from couchers.crypto import decrypt_page_token, encrypt_page_token
13from couchers.db import can_moderate_node, get_node_parents_recursively, is_user_in_node_geography
14from couchers.event_log import log_event
15from couchers.materialized_views import ClusterAdminCount, ClusterSubscriptionCount
16from couchers.models import (
17 Cluster,
18 ClusterRole,
19 ClusterSubscription,
20 Comment,
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 apply_occurrence_pagination, event_to_pb, occurrences_next_page_token
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, where_users_column_visible
36from couchers.utils import Timestamp_from_datetime
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 small_community_features_enabled=official_cluster.small_community_features_enabled,
125 node_type=nodetype2api[node.node_type],
126 )
127 for node, official_cluster, can_moderate in zip(nodes, official_clusters, can_moderates)
128 ]
131def community_to_pb(session: Session, node: Node, context: CouchersContext) -> communities_pb2.Community:
132 return communities_to_pb(session, [node], context)[0]
135class Communities(communities_pb2_grpc.CommunitiesServicer):
136 def GetCommunity(
137 self, request: communities_pb2.GetCommunityReq, context: CouchersContext, session: Session
138 ) -> communities_pb2.Community:
139 node = session.execute(
140 select(Node).where(Node.id == request.community_id).options(selectinload(Node.official_cluster))
141 ).scalar_one_or_none()
142 if not node: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
145 return community_to_pb(session, node, context)
147 def ListCommunities(
148 self, request: communities_pb2.ListCommunitiesReq, context: CouchersContext, session: Session
149 ) -> communities_pb2.ListCommunitiesRes:
150 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
151 offset = int(decrypt_page_token(request.page_token)) if request.page_token else 0
152 nodes = (
153 session.execute(
154 select(Node)
155 .join(Cluster, Cluster.parent_node_id == Node.id)
156 .where(or_(Node.parent_node_id == request.community_id, to_bool(request.community_id == 0)))
157 .where(Cluster.is_official_cluster)
158 .order_by(Cluster.name)
159 .limit(page_size + 1)
160 .offset(offset)
161 .options(selectinload(Node.official_cluster))
162 )
163 .scalars()
164 .all()
165 )
166 return communities_pb2.ListCommunitiesRes(
167 communities=communities_to_pb(session, nodes[:page_size], context),
168 next_page_token=encrypt_page_token(str(offset + page_size)) if len(nodes) > page_size else None,
169 )
171 def SearchCommunities(
172 self, request: communities_pb2.SearchCommunitiesReq, context: CouchersContext, session: Session
173 ) -> communities_pb2.SearchCommunitiesRes:
174 raw_query = request.query.strip()
175 if len(raw_query) < 3:
176 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "query_too_short")
178 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
180 word_similarity_score = func.word_similarity(func.unaccent(raw_query), func.immutable_unaccent(Cluster.name))
182 query = (
183 select(Node)
184 .join(Cluster, Cluster.parent_node_id == Node.id)
185 .where(Cluster.is_official_cluster)
186 .where(word_similarity_score > COMMUNITIES_SEARCH_FUZZY_SIMILARITY_THRESHOLD)
187 .order_by(word_similarity_score.desc(), Cluster.name.asc(), Node.id.asc())
188 .limit(page_size)
189 )
191 rows = session.execute(query.options(selectinload(Node.official_cluster))).scalars().all()
193 return communities_pb2.SearchCommunitiesRes(communities=communities_to_pb(session, rows, context))
195 def ListRecentCommunities(
196 self, request: communities_pb2.ListRecentCommunitiesReq, context: CouchersContext, session: Session
197 ) -> communities_pb2.ListRecentCommunitiesRes:
198 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
199 rows = session.execute(
200 select(Node, Cluster)
201 .join(Cluster, Cluster.parent_node_id == Node.id)
202 .where(Cluster.is_official_cluster)
203 .order_by(Node.created.desc(), Node.id.desc())
204 .limit(page_size)
205 ).all()
206 return communities_pb2.ListRecentCommunitiesRes(
207 communities=[
208 communities_pb2.CommunitySummary(
209 community_id=node.id,
210 name=cluster.name,
211 slug=cluster.slug,
212 created=Timestamp_from_datetime(node.created),
213 node_type=nodetype2api[node.node_type],
214 )
215 for node, cluster in rows
216 ],
217 )
219 def ListGroups(
220 self, request: communities_pb2.ListGroupsReq, context: CouchersContext, session: Session
221 ) -> communities_pb2.ListGroupsRes:
222 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
223 next_cluster_id = int(request.page_token) if request.page_token else 0
224 clusters = (
225 session.execute(
226 select(Cluster)
227 .where(~Cluster.is_official_cluster) # not an official group
228 .where(Cluster.parent_node_id == request.community_id)
229 .where(Cluster.id >= next_cluster_id)
230 .order_by(Cluster.id)
231 .limit(page_size + 1)
232 )
233 .scalars()
234 .all()
235 )
236 return communities_pb2.ListGroupsRes(
237 groups=[group_to_pb(session, cluster, context) for cluster in clusters[:page_size]],
238 next_page_token=str(clusters[-1].id) if len(clusters) > page_size else None,
239 )
241 def ListAdmins(
242 self, request: communities_pb2.ListAdminsReq, context: CouchersContext, session: Session
243 ) -> communities_pb2.ListAdminsRes:
244 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
245 next_admin_id = int(request.page_token) if request.page_token else 0
246 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
247 if not node: 247 ↛ 248line 247 didn't jump to line 248 because the condition on line 247 was never true
248 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
249 admins = (
250 session.execute(
251 select(User)
252 .join(ClusterSubscription, ClusterSubscription.user_id == User.id)
253 .where(users_visible(context))
254 .where(ClusterSubscription.cluster_id == node.official_cluster.id)
255 .where(ClusterSubscription.role == ClusterRole.admin)
256 .where(User.id >= next_admin_id)
257 .order_by(User.id)
258 .limit(page_size + 1)
259 )
260 .scalars()
261 .all()
262 )
263 return communities_pb2.ListAdminsRes(
264 admin_user_ids=[admin.id for admin in admins[:page_size]],
265 next_page_token=str(admins[-1].id) if len(admins) > page_size else None,
266 )
268 def AddAdmin(
269 self, request: communities_pb2.AddAdminReq, context: CouchersContext, session: Session
270 ) -> empty_pb2.Empty:
271 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
272 if not node: 272 ↛ 273line 272 didn't jump to line 273 because the condition on line 272 was never true
273 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
274 if not can_moderate_node(session, context.user_id, node.id):
275 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "node_moderate_permission_denied")
277 user = session.execute(
278 select(User).where(users_visible(context)).where(User.id == request.user_id)
279 ).scalar_one_or_none()
280 if not user: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
283 subscription = session.execute(
284 select(ClusterSubscription)
285 .where(ClusterSubscription.user_id == user.id)
286 .where(ClusterSubscription.cluster_id == node.official_cluster.id)
287 ).scalar_one_or_none()
288 if not subscription:
289 # Can't upgrade a member to admin if they're not already a member
290 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_member")
291 if subscription.role == ClusterRole.admin:
292 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_already_admin")
294 subscription.role = ClusterRole.admin
296 return empty_pb2.Empty()
298 def RemoveAdmin(
299 self, request: communities_pb2.RemoveAdminReq, context: CouchersContext, session: Session
300 ) -> empty_pb2.Empty:
301 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
302 if not node: 302 ↛ 303line 302 didn't jump to line 303 because the condition on line 302 was never true
303 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
304 if not can_moderate_node(session, context.user_id, node.id): 304 ↛ 305line 304 didn't jump to line 305 because the condition on line 304 was never true
305 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "node_moderate_permission_denied")
307 user = session.execute(
308 select(User).where(users_visible(context)).where(User.id == request.user_id)
309 ).scalar_one_or_none()
310 if not user: 310 ↛ 311line 310 didn't jump to line 311 because the condition on line 310 was never true
311 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "user_not_found")
313 subscription = session.execute(
314 select(ClusterSubscription)
315 .where(ClusterSubscription.user_id == user.id)
316 .where(ClusterSubscription.cluster_id == node.official_cluster.id)
317 ).scalar_one_or_none()
318 if not subscription:
319 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_member")
320 if subscription.role == ClusterRole.member:
321 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "user_not_admin")
323 subscription.role = ClusterRole.member
325 return empty_pb2.Empty()
327 def ListMembers(
328 self, request: communities_pb2.ListMembersReq, context: CouchersContext, session: Session
329 ) -> communities_pb2.ListMembersRes:
330 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
331 next_member_id = int(request.page_token) if request.page_token else None
333 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
334 if not node: 334 ↛ 335line 334 didn't jump to line 335 because the condition on line 334 was never true
335 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
337 query = (
338 select(User)
339 .join(ClusterSubscription, ClusterSubscription.user_id == User.id)
340 .where(users_visible(context))
341 .where(ClusterSubscription.cluster_id == node.official_cluster.id)
342 )
343 if next_member_id is not None: 343 ↛ 344line 343 didn't jump to line 344 because the condition on line 343 was never true
344 query = query.where(User.id <= next_member_id)
345 members = session.execute(query.order_by(User.id.desc()).limit(page_size + 1)).scalars().all()
347 return communities_pb2.ListMembersRes(
348 member_user_ids=[member.id for member in members[:page_size]],
349 next_page_token=str(members[-1].id) if len(members) > page_size else None,
350 )
352 def ListNearbyUsers(
353 self, request: communities_pb2.ListNearbyUsersReq, context: CouchersContext, session: Session
354 ) -> communities_pb2.ListNearbyUsersRes:
355 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
356 next_nearby_id = int(request.page_token) if request.page_token else 0
357 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
358 if not node: 358 ↛ 359line 358 didn't jump to line 359 because the condition on line 358 was never true
359 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
360 nearbys = (
361 session.execute(
362 select(User)
363 .where(users_visible(context))
364 .where(func.ST_Contains(node.geom, User.geom))
365 .where(User.id >= next_nearby_id)
366 .order_by(User.id)
367 .limit(page_size + 1)
368 )
369 .scalars()
370 .all()
371 )
372 return communities_pb2.ListNearbyUsersRes(
373 nearby_user_ids=[nearby.id for nearby in nearbys[:page_size]],
374 next_page_token=str(nearbys[-1].id) if len(nearbys) > page_size else None,
375 )
377 def ListPlaces(
378 self, request: communities_pb2.ListPlacesReq, context: CouchersContext, session: Session
379 ) -> communities_pb2.ListPlacesRes:
380 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
381 next_page_id = int(request.page_token) if request.page_token else 0
382 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
383 if not node:
384 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
385 places = (
386 node.official_cluster.owned_pages.where(Page.type == PageType.place)
387 .where(Page.id >= next_page_id)
388 .order_by(Page.id)
389 .limit(page_size + 1)
390 .all()
391 )
392 return communities_pb2.ListPlacesRes(
393 places=[page_to_pb(session, page, context) for page in places[:page_size]],
394 next_page_token=str(places[-1].id) if len(places) > page_size else None,
395 )
397 def ListGuides(
398 self, request: communities_pb2.ListGuidesReq, context: CouchersContext, session: Session
399 ) -> communities_pb2.ListGuidesRes:
400 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
401 next_page_id = int(request.page_token) if request.page_token else 0
402 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
403 if not node:
404 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
405 guides = (
406 node.official_cluster.owned_pages.where(Page.type == PageType.guide)
407 .where(Page.id >= next_page_id)
408 .order_by(Page.id)
409 .limit(page_size + 1)
410 .all()
411 )
412 return communities_pb2.ListGuidesRes(
413 guides=[page_to_pb(session, page, context) for page in guides[:page_size]],
414 next_page_token=str(guides[-1].id) if len(guides) > page_size else None,
415 )
417 def ListEvents(
418 self, request: communities_pb2.ListEventsReq, context: CouchersContext, session: Session
419 ) -> communities_pb2.ListEventsRes:
420 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
422 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
423 if not node: 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
425 if not node.official_cluster.small_community_features_enabled: 425 ↛ 426line 425 didn't jump to line 426 because the condition on line 425 was never true
426 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "events_not_enabled")
428 if not request.include_parents: 428 ↛ 432line 428 didn't jump to line 432 because the condition on line 428 was always true
429 nodes_clusters_to_search = [(node.id, node.official_cluster)]
430 else:
431 # the first value is the node_id, the last is the cluster (object)
432 nodes_clusters_to_search = [
433 (parent[0], parent[3]) for parent in get_node_parents_recursively(session, node.id)
434 ]
436 membership_clauses = []
437 for node_id, official_cluster_obj in nodes_clusters_to_search:
438 membership_clauses.append(Event.owner_cluster == official_cluster_obj)
439 membership_clauses.append(Event.parent_node_id == node_id)
441 # for communities, we list events owned by this community or for which this is a parent
442 query = (
443 select(EventOccurrence).join(Event, Event.id == EventOccurrence.event_id).where(or_(*membership_clauses))
444 )
445 query = where_moderated_content_visible(query, context, EventOccurrence, is_list_operation=True)
447 query = apply_occurrence_pagination(query, request.page_token, request.past)
449 query = query.limit(page_size + 1)
450 occurrences = session.execute(query).scalars().all()
452 return communities_pb2.ListEventsRes(
453 events=[event_to_pb(session, occurrence, context) for occurrence in occurrences[:page_size]],
454 next_page_token=occurrences_next_page_token(occurrences, page_size),
455 )
457 def ListDiscussions(
458 self, request: communities_pb2.ListDiscussionsReq, context: CouchersContext, session: Session
459 ) -> communities_pb2.ListDiscussionsRes:
460 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
461 next_page_id = int(request.page_token) if request.page_token else 2**63 - 1
462 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
463 if not node: 463 ↛ 464line 463 didn't jump to line 464 because the condition on line 463 was never true
464 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
465 if not node.official_cluster.small_community_features_enabled: 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.FAILED_PRECONDITION, "discussions_not_enabled")
467 has_visible_comments = (
468 where_moderated_content_visible(
469 where_users_column_visible(
470 select(func.count())
471 .select_from(Comment)
472 .where(Comment.thread_id == Discussion.thread_id)
473 .where(Comment.deleted == None),
474 context,
475 Comment.author_user_id,
476 ),
477 context,
478 Comment,
479 is_list_operation=True,
480 )
481 .correlate(Discussion)
482 .scalar_subquery()
483 )
484 discussions = (
485 session.execute(
486 where_moderated_content_visible(
487 select(Discussion)
488 .where(Discussion.owner_cluster_id == node.official_cluster.id)
489 .where((Discussion.deleted == None) | (has_visible_comments > 0))
490 .where(Discussion.id <= next_page_id)
491 .order_by(Discussion.id.desc())
492 .limit(page_size + 1),
493 context,
494 Discussion,
495 is_list_operation=True,
496 )
497 )
498 .scalars()
499 .all()
500 )
501 return communities_pb2.ListDiscussionsRes(
502 discussions=[discussion_to_pb(session, discussion, context) for discussion in discussions[:page_size]],
503 next_page_token=str(discussions[-1].id) if len(discussions) > page_size else None,
504 )
506 def JoinCommunity(
507 self, request: communities_pb2.JoinCommunityReq, context: CouchersContext, session: Session
508 ) -> empty_pb2.Empty:
509 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
510 if not node: 510 ↛ 511line 510 didn't jump to line 511 because the condition on line 510 was never true
511 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
513 current_membership = node.official_cluster.members.where(User.id == context.user_id).one_or_none()
514 if current_membership:
515 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "already_in_community")
517 node.official_cluster.cluster_subscriptions.append(
518 ClusterSubscription(
519 user_id=context.user_id,
520 cluster_id=node.official_cluster.id,
521 role=ClusterRole.member,
522 )
523 )
525 log_event(
526 context,
527 session,
528 "community.joined",
529 {"community_id": node.id, "community_name": node.official_cluster.name},
530 )
532 return empty_pb2.Empty()
534 def LeaveCommunity(
535 self, request: communities_pb2.LeaveCommunityReq, context: CouchersContext, session: Session
536 ) -> empty_pb2.Empty:
537 node = session.execute(select(Node).where(Node.id == request.community_id)).scalar_one_or_none()
538 if not node: 538 ↛ 539line 538 didn't jump to line 539 because the condition on line 538 was never true
539 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "community_not_found")
541 current_membership = node.official_cluster.members.where(User.id == context.user_id).one_or_none()
543 if not current_membership:
544 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "not_in_community")
546 if is_user_in_node_geography(session, context.user_id, node.id):
547 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "cannot_leave_containing_community")
549 session.execute(
550 delete(ClusterSubscription)
551 .where(ClusterSubscription.cluster_id == node.official_cluster.id)
552 .where(ClusterSubscription.user_id == context.user_id)
553 )
555 log_event(
556 context, session, "community.left", {"community_id": node.id, "community_name": node.official_cluster.name}
557 )
559 return empty_pb2.Empty()
561 def ListUserCommunities(
562 self, request: communities_pb2.ListUserCommunitiesReq, context: CouchersContext, session: Session
563 ) -> communities_pb2.ListUserCommunitiesRes:
564 page_size = min(MAX_PAGINATION_LENGTH, request.page_size or MAX_PAGINATION_LENGTH)
565 user_id = request.user_id or context.user_id
566 # most specific communities first: node_type desc (sublocality -> world), then id asc
567 statement = (
568 select(Node)
569 .join(Cluster, Cluster.parent_node_id == Node.id)
570 .join(ClusterSubscription, ClusterSubscription.cluster_id == Cluster.id)
571 .where(ClusterSubscription.user_id == user_id)
572 .where(Cluster.is_official_cluster)
573 .order_by(Node.node_type.desc(), Node.id)
574 .limit(page_size + 1)
575 .options(selectinload(Node.official_cluster))
576 )
577 if request.page_token:
578 node_type_ordinal, next_node_id = (int(v) for v in decrypt_page_token(request.page_token).split(","))
579 next_node_type = NodeType(node_type_ordinal)
580 statement = statement.where(
581 or_(
582 Node.node_type < next_node_type,
583 and_(Node.node_type == next_node_type, Node.id >= next_node_id),
584 )
585 )
586 nodes = session.execute(statement).scalars().all()
588 return communities_pb2.ListUserCommunitiesRes(
589 communities=communities_to_pb(session, nodes[:page_size], context),
590 next_page_token=encrypt_page_token(f"{nodes[-1].node_type.value},{nodes[-1].id}")
591 if len(nodes) > page_size
592 else None,
593 )
595 def ListAllCommunities(
596 self, request: communities_pb2.ListAllCommunitiesReq, context: CouchersContext, session: Session
597 ) -> communities_pb2.ListAllCommunitiesRes:
598 """List all communities ordered hierarchically by parent-child relationships"""
599 # Get all nodes with their clusters, member counts, and user membership in a single query
600 results = session.execute(
601 select(
602 Node,
603 Cluster,
604 ClusterSubscriptionCount.count,
605 ClusterSubscription.cluster_id.label("user_subscription"),
606 )
607 .join(Cluster, Cluster.parent_node_id == Node.id)
608 .outerjoin(
609 ClusterSubscriptionCount,
610 ClusterSubscriptionCount.cluster_id == Cluster.id,
611 )
612 .outerjoin(
613 ClusterSubscription,
614 (ClusterSubscription.cluster_id == Cluster.id) & (ClusterSubscription.user_id == context.user_id),
615 )
616 .where(Cluster.is_official_cluster)
617 .order_by(Node.id)
618 ).all()
620 return communities_pb2.ListAllCommunitiesRes(
621 communities=[
622 communities_pb2.CommunitySummary(
623 community_id=node.id,
624 name=cluster.name,
625 slug=cluster.slug,
626 member=user_subscription is not None,
627 member_count=member_count or 1,
628 parents=_parents_to_pb(session, node.id),
629 created=Timestamp_from_datetime(node.created),
630 node_type=nodetype2api[node.node_type],
631 )
632 for node, cluster, member_count, user_subscription in results
633 ],
634 )