Coverage for app/backend/src/couchers/servicers/editor.py: 87%

216 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 19:52 +0000

1import json 

2import logging 

3 

4import grpc 

5from geoalchemy2.shape import from_shape 

6from google.protobuf import empty_pb2 

7from shapely.geometry import shape 

8from shapely.geometry.base import BaseGeometry 

9from sqlalchemy import select, tuple_ 

10from sqlalchemy.orm import Session 

11from sqlalchemy.sql import exists, update 

12 

13from couchers import urls 

14from couchers.context import CouchersContext 

15from couchers.db import get_parent_node_at_location, session_scope 

16from couchers.helpers.clusters import CHILD_NODE_TYPE, create_cluster, create_node 

17from couchers.jobs.enqueue import queue_job 

18from couchers.materialized_views import LiteUser 

19from couchers.models import EventCommunityInviteRequest, EventOccurrence, Node, User, UserActivity, Volunteer 

20from couchers.models.notifications import NotificationTopicAction 

21from couchers.models.postal_verification import PostalVerificationAttempt 

22from couchers.notifications.notify import notify 

23from couchers.postal.my_postcard import download_pdf 

24from couchers.proto import communities_pb2, editor_pb2, editor_pb2_grpc, notification_data_pb2, postal_verification_pb2 

25from couchers.proto.internal import jobs_pb2 

26from couchers.resources import get_static_badge_dict 

27from couchers.servicers.communities import community_to_pb 

28from couchers.servicers.events import ( 

29 generate_event_create_notifications, 

30 get_users_to_notify_for_new_event, 

31 is_community_too_large_for_event_notifications, 

32) 

33from couchers.servicers.postal_verification import postalverificationstatus2pb 

34from couchers.servicers.public import format_volunteer_link 

35from couchers.utils import ( 

36 Timestamp_from_datetime, 

37 date_to_api, 

38 dt_id_from_page_token, 

39 dt_id_to_page_token, 

40 not_none, 

41 now, 

42 parse_date, 

43) 

44 

45logger = logging.getLogger(__name__) 

46 

47MAX_PAGINATION_LENGTH = 250 

48 

49 

50def load_community_geom(geojson: str, context: CouchersContext) -> BaseGeometry: 

51 geom = shape(json.loads(geojson)) 

52 

53 if geom.geom_type != "MultiPolygon": 

54 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:no_multipolygon") 

55 

56 return geom 

57 

58 

59def _get_event_occurrence(session: Session, occurrence_id: int, context: CouchersContext) -> EventOccurrence: 

60 # unfiltered by moderation state and deletion: editors need to fix up any event 

61 occurrence = session.execute( 

62 select(EventOccurrence).where(EventOccurrence.id == occurrence_id) 

63 ).scalar_one_or_none() 

64 if not occurrence: 

65 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "event_not_found") 

66 return occurrence 

67 

68 

69def _event_community_candidate_to_pb( 

70 session: Session, occurrence: EventOccurrence, node: Node, context: CouchersContext 

71) -> editor_pb2.EventCommunityCandidate: 

72 users_to_notify, node_id = get_users_to_notify_for_new_event(session, occurrence, node) 

73 return editor_pb2.EventCommunityCandidate( 

74 community=community_to_pb(session, node, context), 

75 approx_users_to_notify=len(users_to_notify), 

76 too_large_for_notifications=is_community_too_large_for_event_notifications(node), 

77 notifies_by_proximity=node_id is None, 

78 ) 

79 

80 

81def event_community_info_to_pb( 

82 session: Session, occurrence: EventOccurrence, context: CouchersContext 

83) -> editor_pb2.EventCommunityInfo: 

84 event = occurrence.event 

85 smallest_local_node = get_parent_node_at_location(session, occurrence.geom) 

86 

87 return editor_pb2.EventCommunityInfo( 

88 event_id=occurrence.id, 

89 title=event.title, 

90 event_url=urls.event_link(occurrence_id=occurrence.id, slug=event.slug), 

91 creator_user_id=occurrence.creator_user_id, 

92 address=occurrence.address, 

93 occurrence_count=event.occurrences.count(), 

94 current=_event_community_candidate_to_pb(session, occurrence, event.parent_node, context), 

95 smallest_local=( 

96 _event_community_candidate_to_pb(session, occurrence, smallest_local_node, context) 

97 if smallest_local_node 

98 else None 

99 ), 

100 is_in_smallest_local_community=smallest_local_node is not None 

101 and smallest_local_node.id == event.parent_node_id, 

102 ) 

103 

104 

105def volunteer_to_pb(session: Session, volunteer: Volunteer) -> editor_pb2.Volunteer: 

106 """Convert a Volunteer model to the editor protobuf message.""" 

107 lite_user = session.execute(select(LiteUser).where(LiteUser.id == volunteer.user_id)).scalar_one() 

108 board_members = set(get_static_badge_dict()["board_member"]) 

109 

110 return editor_pb2.Volunteer( 

111 user_id=volunteer.user_id, 

112 name=volunteer.display_name or lite_user.name, 

113 username=lite_user.username, 

114 is_board_member=lite_user.id in board_members, 

115 role=volunteer.role, 

116 location=volunteer.display_location or lite_user.city, 

117 img=urls.media_url(filename=lite_user.avatar_filename, size="thumbnail") if lite_user.avatar_filename else None, 

118 sort_key=volunteer.sort_key, 

119 started_volunteering=date_to_api(volunteer.started_volunteering), 

120 stopped_volunteering=date_to_api(volunteer.stopped_volunteering) if volunteer.stopped_volunteering else None, 

121 show_on_team_page=volunteer.show_on_team_page, 

122 **format_volunteer_link(volunteer, lite_user.username), 

123 ) 

124 

125 

126def generate_new_blog_post_notifications(payload: jobs_pb2.GenerateNewBlogPostNotificationsPayload) -> None: 

127 with session_scope() as session: 

128 all_users_ids = session.execute(select(User.id).where(User.is_visible)).scalars().all() 

129 for user_id in all_users_ids: 

130 notify( 

131 session, 

132 user_id=user_id, 

133 topic_action=NotificationTopicAction.general__new_blog_post, 

134 key=payload.url, 

135 data=notification_data_pb2.GeneralNewBlogPost( 

136 url=payload.url, 

137 title=payload.title, 

138 blurb=payload.blurb, 

139 ), 

140 ) 

141 

142 

143class Editor(editor_pb2_grpc.EditorServicer): 

144 def CreateCommunity( 

145 self, request: editor_pb2.CreateCommunityReq, context: CouchersContext, session: Session 

146 ) -> communities_pb2.Community: 

147 geom = load_community_geom(request.geojson, context) 

148 

149 parent_node_id = request.parent_node_id if request.parent_node_id != 0 else None 

150 if parent_node_id is not None: 150 ↛ 151line 150 didn't jump to line 151 because the condition on line 150 was never true

151 parent_node = session.execute(select(Node).where(Node.id == parent_node_id)).scalar_one_or_none() 

152 if not parent_node: 

153 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:parent_node_not_found") 

154 parent_type = parent_node.node_type 

155 else: 

156 parent_type = None 

157 node_type = CHILD_NODE_TYPE[parent_type] 

158 node = create_node(session, geom, parent_node_id, node_type) 

159 create_cluster(session, node.id, request.name, request.description, context.user_id, request.admin_ids, True) 

160 

161 return community_to_pb(session, node, context) 

162 

163 def UpdateCommunity( 

164 self, request: editor_pb2.UpdateCommunityReq, context: CouchersContext, session: Session 

165 ) -> communities_pb2.Community: 

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

167 if not node: 

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

169 cluster = node.official_cluster 

170 

171 if request.name: 171 ↛ 174line 171 didn't jump to line 174 because the condition on line 171 was always true

172 cluster.name = request.name 

173 

174 if request.description: 174 ↛ 177line 174 didn't jump to line 177 because the condition on line 174 was always true

175 cluster.description = request.description 

176 

177 if request.geojson: 177 ↛ 182line 177 didn't jump to line 182 because the condition on line 177 was always true

178 geom = load_community_geom(request.geojson, context) 

179 

180 node.geom = from_shape(geom) 

181 

182 if request.parent_node_id != 0: 

183 node.parent_node_id = request.parent_node_id 

184 

185 session.flush() 

186 

187 return community_to_pb(session, cluster.parent_node, context) 

188 

189 def ListEventCommunityInviteRequests( 

190 self, request: editor_pb2.ListEventCommunityInviteRequestsReq, context: CouchersContext, session: Session 

191 ) -> editor_pb2.ListEventCommunityInviteRequestsRes: 

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

193 next_request_id = int(request.page_token) if request.page_token else 0 

194 requests = ( 

195 session.execute( 

196 select(EventCommunityInviteRequest) 

197 .where(EventCommunityInviteRequest.approved.is_(None)) 

198 .where(EventCommunityInviteRequest.id >= next_request_id) 

199 .order_by(EventCommunityInviteRequest.id) 

200 .limit(page_size + 1) 

201 ) 

202 .scalars() 

203 .all() 

204 ) 

205 

206 def _request_to_pb(request: EventCommunityInviteRequest) -> editor_pb2.EventCommunityInviteRequest: 

207 users_to_notify, node_id = get_users_to_notify_for_new_event(session, request.occurrence) 

208 return editor_pb2.EventCommunityInviteRequest( 

209 event_community_invite_request_id=request.id, 

210 user_id=request.user_id, 

211 event_url=urls.event_link(occurrence_id=request.occurrence.id, slug=request.occurrence.event.slug), 

212 approx_users_to_notify=len(users_to_notify), 

213 community_id=node_id, 

214 ) 

215 

216 return editor_pb2.ListEventCommunityInviteRequestsRes( 

217 requests=[_request_to_pb(request) for request in requests[:page_size]], 

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

219 ) 

220 

221 def ListDecidedEventCommunityInviteRequests( 

222 self, request: editor_pb2.ListDecidedEventCommunityInviteRequestsReq, context: CouchersContext, session: Session 

223 ) -> editor_pb2.ListDecidedEventCommunityInviteRequestsRes: 

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

225 

226 query = ( 

227 select(EventCommunityInviteRequest) 

228 .where(EventCommunityInviteRequest.decided.is_not(None)) 

229 .order_by(EventCommunityInviteRequest.decided.desc(), EventCommunityInviteRequest.id.desc()) 

230 .limit(page_size + 1) 

231 ) 

232 

233 if request.HasField("approved"): 

234 query = query.where(EventCommunityInviteRequest.approved == request.approved.value) 

235 

236 if request.page_token: 

237 decided, request_id = dt_id_from_page_token(request.page_token) 

238 query = query.where( 

239 tuple_(EventCommunityInviteRequest.decided, EventCommunityInviteRequest.id) <= (decided, request_id) 

240 ) 

241 

242 requests = session.execute(query).scalars().all() 

243 

244 return editor_pb2.ListDecidedEventCommunityInviteRequestsRes( 

245 requests=[ 

246 editor_pb2.DecidedEventCommunityInviteRequest( 

247 event_community_invite_request_id=req.id, 

248 user_id=req.user_id, 

249 event_url=urls.event_link(occurrence_id=req.occurrence.id, slug=req.occurrence.event.slug), 

250 community_id=req.occurrence.event.parent_node_id, 

251 created=Timestamp_from_datetime(req.created), 

252 decided=Timestamp_from_datetime(not_none(req.decided)), 

253 decided_by_user_id=not_none(req.decided_by_user_id), 

254 approved=not_none(req.approved), 

255 ) 

256 for req in requests[:page_size] 

257 ], 

258 next_page_token=dt_id_to_page_token(not_none(requests[page_size].decided), requests[page_size].id) 

259 if len(requests) > page_size 

260 else None, 

261 ) 

262 

263 def DecideEventCommunityInviteRequest( 

264 self, request: editor_pb2.DecideEventCommunityInviteRequestReq, context: CouchersContext, session: Session 

265 ) -> editor_pb2.DecideEventCommunityInviteRequestRes: 

266 req = session.execute( 

267 select(EventCommunityInviteRequest).where( 

268 EventCommunityInviteRequest.id == request.event_community_invite_request_id 

269 ) 

270 ).scalar_one_or_none() 

271 

272 if not req: 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, "admin:event_community_invite_not_found") 

274 

275 if req.decided: 275 ↛ 276line 275 didn't jump to line 276 because the condition on line 275 was never true

276 context.abort_with_error_code( 

277 grpc.StatusCode.FAILED_PRECONDITION, "admin:event_community_invite_already_decided" 

278 ) 

279 

280 decided = now() 

281 req.decided = decided 

282 req.decided_by_user_id = context.user_id 

283 req.approved = request.approve 

284 

285 # deny other reqs for the same event 

286 if request.approve: 

287 session.execute( 

288 update(EventCommunityInviteRequest) 

289 .where(EventCommunityInviteRequest.occurrence_id == req.occurrence_id) 

290 .where(EventCommunityInviteRequest.decided.is_(None)) 

291 .values(decided=decided, decided_by_user_id=context.user_id, approved=False) 

292 ) 

293 

294 session.flush() 

295 

296 if request.approve: 

297 queue_job( 

298 session, 

299 job=generate_event_create_notifications, 

300 payload=jobs_pb2.GenerateEventCreateNotificationsPayload( 

301 inviting_user_id=req.user_id, 

302 occurrence_id=req.occurrence_id, 

303 approved=True, 

304 ), 

305 ) 

306 

307 return editor_pb2.DecideEventCommunityInviteRequestRes() 

308 

309 def GetEventCommunityInfo( 

310 self, request: editor_pb2.GetEventCommunityInfoReq, context: CouchersContext, session: Session 

311 ) -> editor_pb2.EventCommunityInfo: 

312 occurrence = _get_event_occurrence(session, request.event_id, context) 

313 return event_community_info_to_pb(session, occurrence, context) 

314 

315 def MoveEventToCommunity( 

316 self, request: editor_pb2.MoveEventToCommunityReq, context: CouchersContext, session: Session 

317 ) -> editor_pb2.EventCommunityInfo: 

318 occurrence = _get_event_occurrence(session, request.event_id, context) 

319 

320 if request.WhichOneof("new_parent_community") == "community_id": 

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

322 elif request.WhichOneof("new_parent_community") == "smallest_local_community": 

323 node = get_parent_node_at_location(session, occurrence.geom) 

324 else: 

325 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:event_move_target_must_be_specified") 

326 

327 if not node: 

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

329 

330 # assign the relationship, not the id, or we read a stale parent_node below 

331 occurrence.event.parent_node = node 

332 session.flush() 

333 

334 return event_community_info_to_pb(session, occurrence, context) 

335 

336 def SendBlogPostNotification( 

337 self, request: editor_pb2.SendBlogPostNotificationReq, context: CouchersContext, session: Session 

338 ) -> empty_pb2.Empty: 

339 if len(request.title) > 50: 339 ↛ 340line 339 didn't jump to line 340 because the condition on line 339 was never true

340 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:blog_title_too_long") 

341 if len(request.blurb) > 100: 341 ↛ 342line 341 didn't jump to line 342 because the condition on line 341 was never true

342 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:blog_blurb_too_long") 

343 queue_job( 

344 session, 

345 job=generate_new_blog_post_notifications, 

346 payload=jobs_pb2.GenerateNewBlogPostNotificationsPayload( 

347 url=request.url, 

348 title=request.title, 

349 blurb=request.blurb, 

350 ), 

351 ) 

352 return empty_pb2.Empty() 

353 

354 def MakeUserVolunteer( 

355 self, request: editor_pb2.MakeUserVolunteerReq, context: CouchersContext, session: Session 

356 ) -> editor_pb2.Volunteer: 

357 # Check if user exists 

358 if not session.execute(select(exists().where(User.id == request.user_id))).scalar(): 

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

360 

361 # Check if user is already a volunteer 

362 if session.execute(select(exists().where(Volunteer.user_id == request.user_id))).scalar(): 

363 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_already_volunteer") 

364 

365 # Parse started_volunteering date 

366 started_volunteering = None 

367 if request.started_volunteering: 

368 started_volunteering = parse_date(request.started_volunteering) 

369 if not started_volunteering: 

370 context.abort_with_error_code( 

371 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_started_volunteering_date" 

372 ) 

373 

374 # Create a volunteer record 

375 volunteer = Volunteer( 

376 user_id=request.user_id, 

377 role=request.role, 

378 show_on_team_page=not request.hide_on_team_page, 

379 ) 

380 if started_volunteering: 

381 volunteer.started_volunteering = started_volunteering 

382 session.add(volunteer) 

383 session.flush() 

384 

385 return volunteer_to_pb(session, volunteer) 

386 

387 def UpdateVolunteer( 

388 self, request: editor_pb2.UpdateVolunteerReq, context: CouchersContext, session: Session 

389 ) -> editor_pb2.Volunteer: 

390 # Check if volunteer exists 

391 volunteer = session.execute(select(Volunteer).where(Volunteer.user_id == request.user_id)).scalar_one_or_none() 

392 if not volunteer: 

393 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:volunteer_not_found") 

394 

395 # Update role if provided 

396 if request.HasField("role"): 

397 volunteer.role = request.role.value 

398 

399 # Update sort_key if provided 

400 if request.HasField("sort_key"): 

401 volunteer.sort_key = request.sort_key.value 

402 

403 # Update started_volunteering if provided 

404 if request.HasField("started_volunteering"): 

405 started_volunteering = parse_date(request.started_volunteering.value) 

406 if not started_volunteering: 

407 context.abort_with_error_code( 

408 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_started_volunteering_date" 

409 ) 

410 volunteer.started_volunteering = started_volunteering 

411 

412 # Reinstate (clear stopped_volunteering) or update stopped_volunteering 

413 if request.reinstate_volunteer and request.HasField("stopped_volunteering"): 

414 context.abort_with_error_code( 

415 grpc.StatusCode.INVALID_ARGUMENT, "admin:cannot_reinstate_and_set_stopped_date" 

416 ) 

417 if request.reinstate_volunteer: 

418 volunteer.stopped_volunteering = None 

419 elif request.HasField("stopped_volunteering"): 

420 stopped_volunteering = parse_date(request.stopped_volunteering.value) 

421 if not stopped_volunteering: 

422 context.abort_with_error_code( 

423 grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_stopped_volunteering_date" 

424 ) 

425 volunteer.stopped_volunteering = stopped_volunteering 

426 

427 # Update show_on_team_page if provided 

428 if request.HasField("show_on_team_page"): 

429 volunteer.show_on_team_page = request.show_on_team_page.value 

430 

431 session.flush() 

432 

433 return volunteer_to_pb(session, volunteer) 

434 

435 def ListVolunteers( 

436 self, request: editor_pb2.ListVolunteersReq, context: CouchersContext, session: Session 

437 ) -> editor_pb2.ListVolunteersRes: 

438 # Query volunteers 

439 query = select(Volunteer).join(LiteUser, LiteUser.id == Volunteer.user_id).where(LiteUser.is_visible) 

440 

441 # Filter based on include_past flag 

442 if not request.include_past: 

443 query = query.where(Volunteer.stopped_volunteering.is_(None)) 

444 

445 # Order by same criteria as public API 

446 query = query.order_by( 

447 Volunteer.sort_key.asc().nulls_last(), 

448 Volunteer.stopped_volunteering.desc().nulls_first(), 

449 Volunteer.started_volunteering.asc(), 

450 ) 

451 

452 volunteers = session.execute(query).scalars().all() 

453 

454 return editor_pb2.ListVolunteersRes( 

455 volunteers=[volunteer_to_pb(session, volunteer) for volunteer in volunteers] 

456 ) 

457 

458 def ListPostcards( 

459 self, request: editor_pb2.ListPostcardsReq, context: CouchersContext, session: Session 

460 ) -> editor_pb2.ListPostcardsRes: 

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

462 next_id = int(request.page_token) if request.page_token else None 

463 

464 query = ( 

465 select(PostalVerificationAttempt, User) 

466 .join(User, User.id == PostalVerificationAttempt.user_id) 

467 .order_by(PostalVerificationAttempt.id.desc()) 

468 .limit(page_size + 1) 

469 ) 

470 if next_id is not None: 

471 query = query.where(PostalVerificationAttempt.id <= next_id) 

472 

473 results = session.execute(query).all() 

474 

475 def _attempt_to_pb(attempt: PostalVerificationAttempt, user: User) -> editor_pb2.PostcardInfo: 

476 return editor_pb2.PostcardInfo( 

477 postal_verification_attempt_id=attempt.id, 

478 user_id=attempt.user_id, 

479 username=user.username, 

480 name=user.name, 

481 status=postalverificationstatus2pb.get( 

482 attempt.status, postal_verification_pb2.POSTAL_VERIFICATION_STATUS_UNKNOWN 

483 ), 

484 address=postal_verification_pb2.PostalAddress( 

485 address_line_1=attempt.address_line_1, 

486 address_line_2=attempt.address_line_2, 

487 city=attempt.city, 

488 state=attempt.state, 

489 postal_code=attempt.postal_code, 

490 country_code=attempt.country_code, 

491 ), 

492 created=Timestamp_from_datetime(attempt.created), 

493 postcard_sent_at=Timestamp_from_datetime(attempt.postcard_sent_at) 

494 if attempt.postcard_sent_at 

495 else None, 

496 verified_at=Timestamp_from_datetime(attempt.verified_at) if attempt.verified_at else None, 

497 ) 

498 

499 return editor_pb2.ListPostcardsRes( 

500 postcards=[_attempt_to_pb(attempt, user) for attempt, user in results[:page_size]], 

501 next_page_token=str(results[-1][0].id) if len(results) > page_size else None, 

502 ) 

503 

504 def DownloadPostcardPdf( 

505 self, request: editor_pb2.DownloadPostcardPdfReq, context: CouchersContext, session: Session 

506 ) -> editor_pb2.DownloadPostcardPdfRes: 

507 attempt = session.execute( 

508 select(PostalVerificationAttempt).where( 

509 PostalVerificationAttempt.id == request.postal_verification_attempt_id 

510 ) 

511 ).scalar_one_or_none() 

512 

513 if not attempt: 

514 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "postal_verification_attempt_not_found") 

515 

516 if not attempt.mypostcard_job_id: 

517 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:postcard_not_sent") 

518 

519 pdf_data = download_pdf(attempt.mypostcard_job_id) 

520 return editor_pb2.DownloadPostcardPdfRes(pdf=pdf_data) 

521 

522 def LookupUsersForDebug( 

523 self, request: editor_pb2.LookupUsersForDebugReq, context: CouchersContext, session: Session 

524 ) -> editor_pb2.LookupUsersForDebugRes: 

525 sofa_prefix = request.sofa_prefix.strip() 

526 if len(sofa_prefix) != 12: 

527 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_sofa_prefix") 

528 

529 users = session.execute( 

530 select(User.id, User.banned_at, User.deleted_at, User.shadowed_at) 

531 .where( 

532 User.id.in_( 

533 select(UserActivity.user_id).where(UserActivity.sofa.startswith(sofa_prefix, autoescape=True)) 

534 ) 

535 ) 

536 .order_by(User.id) 

537 ).all() 

538 

539 return editor_pb2.LookupUsersForDebugRes( 

540 users=[ 

541 editor_pb2.LookupUser( 

542 user_id=user_id, 

543 banned=banned_at is not None, 

544 deleted=deleted_at is not None, 

545 shadowed=shadowed_at is not None, 

546 ) 

547 for user_id, banned_at, deleted_at, shadowed_at in users 

548 ] 

549 )