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

643 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 12:25 +0000

1import json 

2import logging 

3from collections.abc import Callable 

4from datetime import UTC, datetime, timedelta 

5from typing import Any 

6 

7import grpc 

8from google.protobuf import empty_pb2 

9from google.protobuf.wrappers_pb2 import Int64Value 

10from sqlalchemy import select, tuple_ 

11from sqlalchemy.orm import Session, aliased, selectinload, undefer 

12from sqlalchemy.sql import and_, func, or_ 

13from user_agents import parse as user_agents_parse 

14 

15from couchers import urls 

16from couchers.context import CouchersContext 

17from couchers.crypto import urlsafe_secure_token 

18from couchers.helpers.badges import user_add_badge, user_remove_badge 

19from couchers.helpers.geoip import geoip_approximate_location, geoip_asn 

20from couchers.helpers.strong_verification import get_strong_verification_fields 

21from couchers.helpers.upload_uses import UploadUseType, get_upload_uses_for_keys 

22from couchers.jobs.enqueue import queue_job 

23from couchers.models import ( 

24 AccountDeletionToken, 

25 AdminAction, 

26 AdminActionLevel, 

27 AdminTag, 

28 Comment, 

29 ContentReport, 

30 Discussion, 

31 Event, 

32 EventOccurrence, 

33 FriendRelationship, 

34 GroupChat, 

35 GroupChatSubscription, 

36 HostRequest, 

37 LanguageAbility, 

38 Message, 

39 ModerationUserList, 

40 ModerationVisibility, 

41 ModNote, 

42 NonvisibleUserAccess, 

43 NonvisibleUserAccessType, 

44 NonvisibleUserState, 

45 OTAPackage, 

46 OTAPlatform, 

47 Reference, 

48 Reply, 

49 User, 

50 UserActivity, 

51 UserAdminTag, 

52 UserBadge, 

53 UserBlock, 

54) 

55from couchers.models.discussions import ( 

56 CommentVersion, 

57 ContentChangeType, 

58 DiscussionVersion, 

59 ReplyVersion, 

60) 

61from couchers.models.notifications import NotificationTopicAction 

62from couchers.models.uploads import Upload, has_avatar_photo_expression 

63from couchers.notifications.notify import notify 

64from couchers.proto import admin_pb2, admin_pb2_grpc, api_pb2, notification_data_pb2 

65from couchers.proto.internal import jobs_pb2 

66from couchers.resources import get_badge_dict 

67from couchers.servicers.api import user_model_to_pb 

68from couchers.servicers.auth import create_session 

69from couchers.servicers.bugs import _fetch_signed_manifest, _native_ota_manifest_url 

70from couchers.servicers.events import generate_event_delete_notifications 

71from couchers.servicers.moderation import bulk_set_user_content_visibility 

72from couchers.servicers.threads import unpack_thread_id 

73from couchers.sql import to_bool, username_or_email_or_id 

74from couchers.utils import Timestamp_from_datetime, date_to_api, not_none, now, parse_date, to_aware_datetime 

75 

76logger = logging.getLogger(__name__) 

77 

78MAX_PAGINATION_LENGTH = 250 

79 

80 

81adminactionlevel2api = { 

82 AdminActionLevel.trace: admin_pb2.ADMIN_ACTION_LEVEL_TRACE, 

83 AdminActionLevel.debug: admin_pb2.ADMIN_ACTION_LEVEL_DEBUG, 

84 AdminActionLevel.normal: admin_pb2.ADMIN_ACTION_LEVEL_NORMAL, 

85 AdminActionLevel.high: admin_pb2.ADMIN_ACTION_LEVEL_HIGH, 

86} 

87 

88api2adminactionlevel = { 

89 admin_pb2.ADMIN_ACTION_LEVEL_TRACE: AdminActionLevel.trace, 

90 admin_pb2.ADMIN_ACTION_LEVEL_DEBUG: AdminActionLevel.debug, 

91 admin_pb2.ADMIN_ACTION_LEVEL_NORMAL: AdminActionLevel.normal, 

92 admin_pb2.ADMIN_ACTION_LEVEL_HIGH: AdminActionLevel.high, 

93} 

94 

95uploadusetype2api = { 

96 None: admin_pb2.UPLOAD_USE_TYPE_UNSPECIFIED, 

97 UploadUseType.profile_gallery_photo: admin_pb2.UPLOAD_USE_TYPE_PROFILE_GALLERY_PHOTO, 

98 UploadUseType.profile_gallery_photo_avatar: admin_pb2.UPLOAD_USE_TYPE_PROFILE_GALLERY_PHOTO_AVATAR, 

99 UploadUseType.event: admin_pb2.UPLOAD_USE_TYPE_EVENT, 

100 UploadUseType.page: admin_pb2.UPLOAD_USE_TYPE_PAGE, 

101} 

102 

103otaplatform2api = { 

104 None: admin_pb2.OTA_PLATFORM_UNSPECIFIED, 

105 OTAPlatform.ios: admin_pb2.OTA_PLATFORM_IOS, 

106 OTAPlatform.android: admin_pb2.OTA_PLATFORM_ANDROID, 

107} 

108 

109api2otaplatform = { 

110 admin_pb2.OTA_PLATFORM_UNSPECIFIED: None, 

111 admin_pb2.OTA_PLATFORM_IOS: OTAPlatform.ios, 

112 admin_pb2.OTA_PLATFORM_ANDROID: OTAPlatform.android, 

113} 

114 

115nonvisibleuseraccesstype2api = { 

116 None: admin_pb2.NONVISIBLE_USER_ACCESS_TYPE_UNSPECIFIED, 

117 NonvisibleUserAccessType.login_attempt: admin_pb2.NONVISIBLE_USER_ACCESS_TYPE_LOGIN_ATTEMPT, 

118 NonvisibleUserAccessType.profile_view: admin_pb2.NONVISIBLE_USER_ACCESS_TYPE_PROFILE_VIEW, 

119 NonvisibleUserAccessType.ghost_served: admin_pb2.NONVISIBLE_USER_ACCESS_TYPE_GHOST_SERVED, 

120} 

121 

122nonvisibleuserstate2api = { 

123 None: admin_pb2.NONVISIBLE_USER_STATE_UNSPECIFIED, 

124 NonvisibleUserState.banned: admin_pb2.NONVISIBLE_USER_STATE_BANNED, 

125 NonvisibleUserState.shadowed: admin_pb2.NONVISIBLE_USER_STATE_SHADOWED, 

126 NonvisibleUserState.deleted: admin_pb2.NONVISIBLE_USER_STATE_DELETED, 

127} 

128 

129 

130def log_admin_action( 

131 session: Session, 

132 context: CouchersContext, 

133 target_user: User, 

134 action_type: str, 

135 note: str | None = None, 

136 data: object | None = None, 

137 tag: str | None = None, 

138 level: AdminActionLevel = AdminActionLevel.normal, 

139) -> AdminAction: 

140 action = AdminAction( 

141 admin_user_id=context.user_id, 

142 target_user_id=target_user.id, 

143 action_type=action_type, 

144 level=level, 

145 note=note, 

146 data=data, 

147 tag=tag, 

148 ) 

149 session.add(action) 

150 session.flush() 

151 return action 

152 

153 

154def _live_ota_package_ids(session: Session) -> set[int]: 

155 # The live package per (platform, fingerprint) is the newest non-banned one by manifest_created_at, 

156 # matching what GetNativeUpdateManifest resolves. DISTINCT ON picks the row with the leading ORDER BY 

157 # value per (platform, fingerprint) group in a single index-friendly query. 

158 return set( 

159 session.scalars( 

160 select(OTAPackage.id) 

161 .where(OTAPackage.banned_at.is_(None)) 

162 .distinct(OTAPackage.platform, OTAPackage.fingerprint) 

163 .order_by( 

164 OTAPackage.platform, 

165 OTAPackage.fingerprint, 

166 OTAPackage.manifest_created_at.desc(), 

167 OTAPackage.id.desc(), 

168 ) 

169 ) 

170 ) 

171 

172 

173def _extract_ota_manifest(body: bytes) -> dict[str, Any] | None: 

174 # The manifest object is the JSON in the "manifest" part of the signed multipart/mixed body. 

175 marker = body.find(b'name="manifest"') 

176 if marker == -1: 

177 return None 

178 body_start = body.find(b"\r\n\r\n", marker) 

179 if body_start == -1: 179 ↛ 180line 179 didn't jump to line 180 because the condition on line 179 was never true

180 return None 

181 body_end = body.find(b"\r\n--", body_start + 4) 

182 if body_end == -1: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true

183 return None 

184 try: 

185 manifest = json.loads(body[body_start + 4 : body_end]) 

186 except json.JSONDecodeError: 

187 return None 

188 return manifest if isinstance(manifest, dict) else None 

189 

190 

191def _ota_package_to_pb(package: OTAPackage, live_ids: set[int]) -> admin_pb2.OTAPackage: 

192 return admin_pb2.OTAPackage( 

193 ota_package_id=package.id, 

194 created=Timestamp_from_datetime(package.created), 

195 creator_user_id=package.creator_user_id, 

196 platform=otaplatform2api[package.platform], 

197 fingerprint=package.fingerprint, 

198 version=package.version, 

199 manifest_created_at=Timestamp_from_datetime(package.manifest_created_at), 

200 manifest_id=package.manifest_id, 

201 banned=package.banned_at is not None, 

202 banned_at=Timestamp_from_datetime(package.banned_at) if package.banned_at else None, 

203 banned_by_user_id=package.banned_by_user_id or 0, 

204 banned_reason=package.banned_reason or "", 

205 live=package.id in live_ids, 

206 ) 

207 

208 

209def _user_to_details(session: Session, user: User) -> admin_pb2.UserDetails: 

210 # Query admin actions for this user 

211 actions = session.execute( 

212 select(AdminAction, User.username) 

213 .join(User, AdminAction.admin_user_id == User.id) 

214 .where(AdminAction.target_user_id == user.id) 

215 .order_by(AdminAction.created.asc()) 

216 ).all() 

217 

218 action_pbs = [] 

219 for action, admin_username in actions: 

220 action_pbs.append( 

221 admin_pb2.AdminActionLog( 

222 admin_action_id=action.id, 

223 created=Timestamp_from_datetime(action.created), 

224 admin_user_id=action.admin_user_id, 

225 admin_username=admin_username, 

226 action_type=action.action_type, 

227 level=adminactionlevel2api[action.level], 

228 note=action.note or "", 

229 data=json.dumps(action.data) if action.data is not None else "", 

230 tag=action.tag or "", 

231 target_user_id=action.target_user_id, 

232 target_username=user.username, 

233 ) 

234 ) 

235 

236 # Query admin tags 

237 admin_tags = ( 

238 session.execute( 

239 select(AdminTag.tag) 

240 .join(UserAdminTag, UserAdminTag.admin_tag_id == AdminTag.id) 

241 .where(UserAdminTag.user_id == user.id) 

242 .order_by(AdminTag.tag) 

243 ) 

244 .scalars() 

245 .all() 

246 ) 

247 

248 last_mod_note_acknowledged = session.execute( 

249 select(func.max(ModNote.acknowledged)).where(ModNote.user_id == user.id) 

250 ).scalar() 

251 

252 return admin_pb2.UserDetails( 

253 user_id=user.id, 

254 username=user.username, 

255 name=user.name, 

256 email=user.email, 

257 gender=user.gender, 

258 birthdate=date_to_api(user.birthdate), 

259 banned=user.banned_at is not None, 

260 deleted=user.deleted_at is not None, 

261 shadowed=user.shadowed_at is not None, 

262 do_not_email=user.do_not_email, 

263 badges=[badge.badge_id for badge in user.badges], 

264 **get_strong_verification_fields(session, user), 

265 has_passport_sex_gender_exception=user.has_passport_sex_gender_exception, 

266 pending_mod_notes_count=user.mod_notes.where(ModNote.is_pending).count(), 

267 acknowledged_mod_notes_count=user.mod_notes.where(~ModNote.is_pending).count(), 

268 last_mod_note_acknowledged=( 

269 Timestamp_from_datetime(last_mod_note_acknowledged) if last_mod_note_acknowledged else None 

270 ), 

271 admin_actions=action_pbs, 

272 admin_tags=list(admin_tags), 

273 mod_score=user.mod_score, 

274 ui_language_preference=user.ui_language_preference, 

275 ) 

276 

277 

278def _content_report_to_pb(content_report: ContentReport) -> admin_pb2.ContentReport: 

279 return admin_pb2.ContentReport( 

280 content_report_id=content_report.id, 

281 time=Timestamp_from_datetime(content_report.time), 

282 reporting_user_id=content_report.reporting_user_id, 

283 author_user_id=content_report.author_user_id, 

284 reason=content_report.reason, 

285 description=content_report.description, 

286 content_ref=content_report.content_ref, 

287 user_agent=content_report.user_agent, 

288 page=content_report.page, 

289 ) 

290 

291 

292def _upload_metadata_to_pb(upload: Upload) -> admin_pb2.UploadMetadata: 

293 return admin_pb2.UploadMetadata( 

294 parsed_json=json.dumps(upload.metadata_parsed, sort_keys=True) if upload.metadata_parsed else None, 

295 parse_error=upload.metadata_parse_error, 

296 original_filename=upload.original_filename, 

297 original_format=upload.original_format, 

298 original_size=upload.original_size, 

299 original_width=upload.original_width, 

300 original_height=upload.original_height, 

301 ) 

302 

303 

304def _make_chat_user_info_getter(session: Session) -> Callable[[int], admin_pb2.ChatUserInfo]: 

305 user_info_cache: dict[int, admin_pb2.ChatUserInfo] = {} 

306 

307 def get_chat_user_info(user_id: int) -> admin_pb2.ChatUserInfo: 

308 if user_id not in user_info_cache: 

309 u = session.execute(select(User).where(User.id == user_id)).scalar_one() 

310 user_info_cache[user_id] = admin_pb2.ChatUserInfo( 

311 user_id=u.id, 

312 username=u.username, 

313 name=u.name, 

314 birthdate=date_to_api(u.birthdate), 

315 gender=u.gender, 

316 ) 

317 return user_info_cache[user_id] 

318 

319 return get_chat_user_info 

320 

321 

322def _reference_to_pb( 

323 reference: Reference, get_chat_user_info: Callable[[int], admin_pb2.ChatUserInfo] 

324) -> admin_pb2.AdminReference: 

325 host_request = reference.host_request 

326 return admin_pb2.AdminReference( 

327 reference_id=reference.id, 

328 from_user_id=reference.from_user_id, 

329 to_user_id=reference.to_user_id, 

330 from_user=get_chat_user_info(reference.from_user_id), 

331 to_user=get_chat_user_info(reference.to_user_id), 

332 reference_type=reference.reference_type.name, 

333 text=reference.text, 

334 private_text=reference.private_text or "", 

335 time=Timestamp_from_datetime(reference.time), 

336 host_request_id=reference.host_request_id or 0, 

337 rating=reference.rating, 

338 was_appropriate=reference.was_appropriate, 

339 moderation_visibility=not_none(reference.moderation_state.visibility).name, 

340 hosting_city=host_request.hosting_city if host_request else "", 

341 from_date=date_to_api(host_request.from_date) if host_request else "", 

342 to_date=date_to_api(host_request.to_date) if host_request else "", 

343 status=host_request.status.name if host_request else "", 

344 ) 

345 

346 

347class Admin(admin_pb2_grpc.AdminServicer): 

348 def GetUserDetails( 

349 self, request: admin_pb2.GetUserDetailsReq, context: CouchersContext, session: Session 

350 ) -> admin_pb2.UserDetails: 

351 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

354 return _user_to_details(session, user) 

355 

356 def GetUser(self, request: admin_pb2.GetUserReq, context: CouchersContext, session: Session) -> api_pb2.User: 

357 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

360 return user_model_to_pb(user, session, context, is_admin_see_ghosts=True) 

361 

362 def SearchUsers( 

363 self, request: admin_pb2.SearchUsersReq, context: CouchersContext, session: Session 

364 ) -> admin_pb2.SearchUsersRes: 

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

366 next_user_id = int(request.page_token) if request.page_token else 0 

367 statement = select(User) 

368 if request.username: 368 ↛ 369line 368 didn't jump to line 369 because the condition on line 368 was never true

369 statement = statement.where(User.username.ilike(request.username)) 

370 if request.email: 370 ↛ 371line 370 didn't jump to line 371 because the condition on line 370 was never true

371 statement = statement.where(User.email.ilike(request.email)) 

372 if request.name: 372 ↛ 373line 372 didn't jump to line 373 because the condition on line 372 was never true

373 statement = statement.where(User.name.ilike(request.name)) 

374 if request.admin_action_log: 

375 statement = statement.where( 

376 User.id.in_(select(AdminAction.target_user_id).where(AdminAction.note.ilike(request.admin_action_log))) 

377 ) 

378 if request.city: 378 ↛ 379line 378 didn't jump to line 379 because the condition on line 378 was never true

379 statement = statement.where(User.city.ilike(request.city)) 

380 if request.min_user_id: 380 ↛ 381line 380 didn't jump to line 381 because the condition on line 380 was never true

381 statement = statement.where(User.id >= request.min_user_id) 

382 if request.max_user_id: 382 ↛ 383line 382 didn't jump to line 383 because the condition on line 382 was never true

383 statement = statement.where(User.id <= request.max_user_id) 

384 if request.min_birthdate: 384 ↛ 385line 384 didn't jump to line 385 because the condition on line 384 was never true

385 statement = statement.where(User.birthdate >= parse_date(request.min_birthdate)) 

386 if request.max_birthdate: 386 ↛ 387line 386 didn't jump to line 387 because the condition on line 386 was never true

387 statement = statement.where(User.birthdate <= parse_date(request.max_birthdate)) 

388 if request.genders: 388 ↛ 389line 388 didn't jump to line 389 because the condition on line 388 was never true

389 statement = statement.where(User.gender.in_(request.genders)) 

390 if request.min_joined_date: 390 ↛ 391line 390 didn't jump to line 391 because the condition on line 390 was never true

391 statement = statement.where(User.joined >= parse_date(request.min_joined_date)) 

392 if request.max_joined_date: 392 ↛ 393line 392 didn't jump to line 393 because the condition on line 392 was never true

393 statement = statement.where(User.joined <= parse_date(request.max_joined_date)) 

394 if request.min_last_active_date: 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 statement = statement.where(User.last_active >= parse_date(request.min_last_active_date)) 

396 if request.max_last_active_date: 396 ↛ 397line 396 didn't jump to line 397 because the condition on line 396 was never true

397 statement = statement.where(User.last_active <= parse_date(request.max_last_active_date)) 

398 if request.genders: 398 ↛ 399line 398 didn't jump to line 399 because the condition on line 398 was never true

399 statement = statement.where(User.gender.in_(request.genders)) 

400 if request.language_codes: 400 ↛ 401line 400 didn't jump to line 401 because the condition on line 400 was never true

401 statement = statement.join( 

402 LanguageAbility, 

403 and_(LanguageAbility.user_id == User.id, LanguageAbility.language_code.in_(request.language_codes)), 

404 ) 

405 if request.HasField("is_deleted"): 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true

406 statement = statement.where((User.deleted_at != None) == request.is_deleted.value) 

407 if request.HasField("is_banned"): 407 ↛ 408line 407 didn't jump to line 408 because the condition on line 407 was never true

408 statement = statement.where((User.banned_at != None) == request.is_banned.value) 

409 if request.HasField("is_shadowed"): 409 ↛ 410line 409 didn't jump to line 410 because the condition on line 409 was never true

410 statement = statement.where((User.shadowed_at != None) == request.is_shadowed.value) 

411 if request.HasField("has_avatar"): 411 ↛ 412line 411 didn't jump to line 412 because the condition on line 411 was never true

412 statement = statement.where(has_avatar_photo_expression(User) == request.has_avatar.value) 

413 if request.admin_tags: 

414 for tag_name in request.admin_tags: 

415 statement = statement.where( 

416 User.id.in_( 

417 select(UserAdminTag.user_id) 

418 .join(AdminTag, UserAdminTag.admin_tag_id == AdminTag.id) 

419 .where(AdminTag.tag == tag_name) 

420 ) 

421 ) 

422 users = ( 

423 session.execute( 

424 statement.where(User.id >= next_user_id) 

425 .order_by(User.id) 

426 .limit(page_size + 1) 

427 .options(selectinload(User.badges)) 

428 ) 

429 .scalars() 

430 .all() 

431 ) 

432 logger.info(users) 

433 return admin_pb2.SearchUsersRes( 

434 users=[_user_to_details(session, user) for user in users[:page_size]], 

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

436 ) 

437 

438 def ChangeUserGender( 

439 self, request: admin_pb2.ChangeUserGenderReq, context: CouchersContext, session: Session 

440 ) -> admin_pb2.UserDetails: 

441 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

444 old_gender = user.gender 

445 user.gender = request.gender 

446 log_admin_action( 

447 session, context, user, "change_gender", note=f"Changed from '{old_gender}' to '{request.gender}'" 

448 ) 

449 session.commit() 

450 

451 notify( 

452 session, 

453 user_id=user.id, 

454 topic_action=NotificationTopicAction.gender__change, 

455 key="", 

456 data=notification_data_pb2.GenderChange( 

457 gender=request.gender, 

458 ), 

459 ) 

460 

461 return _user_to_details(session, user) 

462 

463 def ChangeUserBirthdate( 

464 self, request: admin_pb2.ChangeUserBirthdateReq, context: CouchersContext, session: Session 

465 ) -> admin_pb2.UserDetails: 

466 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

469 if not (birthdate := parse_date(request.birthdate)): 469 ↛ 470line 469 didn't jump to line 470 because the condition on line 469 was never true

470 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "invalid_birthdate") 

471 

472 old_birthdate = user.birthdate 

473 user.birthdate = birthdate 

474 log_admin_action( 

475 session, context, user, "change_birthdate", note=f"Changed from {old_birthdate} to {request.birthdate}" 

476 ) 

477 session.commit() 

478 

479 notify( 

480 session, 

481 user_id=user.id, 

482 topic_action=NotificationTopicAction.birthdate__change, 

483 key="", 

484 data=notification_data_pb2.BirthdateChange( 

485 birthdate=request.birthdate, 

486 ), 

487 ) 

488 

489 return _user_to_details(session, user) 

490 

491 def AddBadge( 

492 self, request: admin_pb2.AddBadgeReq, context: CouchersContext, session: Session 

493 ) -> admin_pb2.UserDetails: 

494 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

497 

498 badge = get_badge_dict().get(request.badge_id) 

499 if not badge: 

500 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found") 

501 

502 if not badge.admin_editable: 

503 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:cannot_edit_badge") 

504 

505 if badge.id in [b.badge_id for b in user.badges]: 

506 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_already_has_badge") 

507 

508 user_add_badge(session, user.id, request.badge_id) 

509 log_admin_action(session, context, user, "add_badge", note=f"Added badge {request.badge_id}") 

510 

511 return _user_to_details(session, user) 

512 

513 def RemoveBadge( 

514 self, request: admin_pb2.RemoveBadgeReq, context: CouchersContext, session: Session 

515 ) -> admin_pb2.UserDetails: 

516 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

519 

520 badge = get_badge_dict().get(request.badge_id) 

521 if not badge: 521 ↛ 522line 521 didn't jump to line 522 because the condition on line 521 was never true

522 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "badge_not_found") 

523 

524 if not badge.admin_editable: 524 ↛ 525line 524 didn't jump to line 525 because the condition on line 524 was never true

525 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:cannot_edit_badge") 

526 

527 user_badge = session.execute( 

528 select(UserBadge).where(UserBadge.user_id == user.id, UserBadge.badge_id == badge.id) 

529 ).scalar_one_or_none() 

530 if not user_badge: 

531 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_does_not_have_badge") 

532 

533 user_remove_badge(session, user.id, request.badge_id) 

534 log_admin_action(session, context, user, "remove_badge", note=f"Removed badge {request.badge_id}") 

535 

536 return _user_to_details(session, user) 

537 

538 def SetPassportSexGenderException( 

539 self, request: admin_pb2.SetPassportSexGenderExceptionReq, context: CouchersContext, session: Session 

540 ) -> admin_pb2.UserDetails: 

541 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

544 old_exception = user.has_passport_sex_gender_exception 

545 user.has_passport_sex_gender_exception = request.passport_sex_gender_exception 

546 log_admin_action( 

547 session, 

548 context, 

549 user, 

550 "set_passport_sex_gender_exception", 

551 note=f"Changed from {old_exception} to {request.passport_sex_gender_exception}", 

552 ) 

553 return _user_to_details(session, user) 

554 

555 def BanUser( 

556 self, request: admin_pb2.BanUserReq, context: CouchersContext, session: Session 

557 ) -> admin_pb2.UserDetails: 

558 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

561 if not request.admin_note.strip(): 561 ↛ 562line 561 didn't jump to line 562 because the condition on line 561 was never true

562 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:note_cant_be_empty") 

563 log_admin_action(session, context, user, "ban", note=request.admin_note, level=AdminActionLevel.high) 

564 user.banned_at = now() 

565 return _user_to_details(session, user) 

566 

567 def UnbanUser( 

568 self, request: admin_pb2.UnbanUserReq, context: CouchersContext, session: Session 

569 ) -> admin_pb2.UserDetails: 

570 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

573 if not request.admin_note.strip(): 573 ↛ 574line 573 didn't jump to line 574 because the condition on line 573 was never true

574 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:note_cant_be_empty") 

575 log_admin_action(session, context, user, "unban", note=request.admin_note, level=AdminActionLevel.high) 

576 user.banned_at = None 

577 return _user_to_details(session, user) 

578 

579 def ShadowUser( 

580 self, request: admin_pb2.ShadowUserReq, context: CouchersContext, session: Session 

581 ) -> admin_pb2.UserDetails: 

582 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

585 if not request.admin_note.strip(): 

586 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:note_cant_be_empty") 

587 log_admin_action(session, context, user, "shadow", note=request.admin_note, level=AdminActionLevel.high) 

588 user.shadowed_at = now() 

589 # Bulk-shadow all UMS-governed content authored by this user so existing visible content is hidden too 

590 bulk_set_user_content_visibility( 

591 session=session, 

592 user=user, 

593 new_visibility=ModerationVisibility.shadowed, 

594 moderator_user_id=context.user_id, 

595 reason=f"User {user.id} shadowed: {request.admin_note}", 

596 ) 

597 return _user_to_details(session, user) 

598 

599 def UnshadowUser( 

600 self, request: admin_pb2.UnshadowUserReq, context: CouchersContext, session: Session 

601 ) -> admin_pb2.UserDetails: 

602 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

605 if not request.admin_note.strip(): 605 ↛ 606line 605 didn't jump to line 606 because the condition on line 605 was never true

606 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:note_cant_be_empty") 

607 log_admin_action(session, context, user, "unshadow", note=request.admin_note, level=AdminActionLevel.high) 

608 user.shadowed_at = None 

609 # Sweep content shadowed by the cascade back to visible; leave hidden/unlisted content where moderators put it 

610 bulk_set_user_content_visibility( 

611 session=session, 

612 user=user, 

613 new_visibility=ModerationVisibility.visible, 

614 moderator_user_id=context.user_id, 

615 from_visibilities={ModerationVisibility.shadowed}, 

616 reason=f"User {user.id} unshadowed: {request.admin_note}", 

617 ) 

618 return _user_to_details(session, user) 

619 

620 def AddAdminNote( 

621 self, request: admin_pb2.AddAdminNoteReq, context: CouchersContext, session: Session 

622 ) -> admin_pb2.UserDetails: 

623 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

626 has_note = bool(request.admin_note.strip()) 

627 has_data = bool(request.data.strip()) 

628 if has_note == has_data: 

629 context.abort_with_error_code( 

630 grpc.StatusCode.INVALID_ARGUMENT, "admin:note_requires_exactly_one_of_note_or_data" 

631 ) 

632 data = None 

633 if has_data: 

634 try: 

635 data = json.loads(request.data) 

636 except json.JSONDecodeError: 

637 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:note_data_must_be_valid_json") 

638 level = api2adminactionlevel.get(request.level, AdminActionLevel.normal) 

639 log_admin_action( 

640 session, 

641 context, 

642 user, 

643 "note", 

644 note=request.admin_note if has_note else None, 

645 data=data, 

646 level=level, 

647 ) 

648 return _user_to_details(session, user) 

649 

650 def GetContentReport( 

651 self, request: admin_pb2.GetContentReportReq, context: CouchersContext, session: Session 

652 ) -> admin_pb2.GetContentReportRes: 

653 content_report = session.execute( 

654 select(ContentReport).where(ContentReport.id == request.content_report_id) 

655 ).scalar_one_or_none() 

656 if not content_report: 

657 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:content_report_not_found") 

658 return admin_pb2.GetContentReportRes( 

659 content_report=_content_report_to_pb(content_report), 

660 ) 

661 

662 def GetContentReportsForAuthor( 

663 self, request: admin_pb2.GetContentReportsForAuthorReq, context: CouchersContext, session: Session 

664 ) -> admin_pb2.GetContentReportsForAuthorRes: 

665 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

668 content_reports = ( 

669 session.execute( 

670 select(ContentReport).where(ContentReport.author_user_id == user.id).order_by(ContentReport.id.desc()) 

671 ) 

672 .scalars() 

673 .all() 

674 ) 

675 return admin_pb2.GetContentReportsForAuthorRes( 

676 content_reports=[_content_report_to_pb(content_report) for content_report in content_reports], 

677 ) 

678 

679 def SendModNote( 

680 self, request: admin_pb2.SendModNoteReq, context: CouchersContext, session: Session 

681 ) -> admin_pb2.UserDetails: 

682 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

685 session.add( 

686 ModNote( 

687 user_id=user.id, 

688 internal_id=request.internal_id, 

689 creator_user_id=context.user_id, 

690 note_content=request.content, 

691 ) 

692 ) 

693 session.flush() 

694 notify_user = "No" if request.do_not_notify else "Yes" 

695 log_admin_action( 

696 session, 

697 context, 

698 user, 

699 "send_mod_note", 

700 note=f"Notify user: {notify_user}\n\n{request.content}", 

701 ) 

702 

703 if not request.do_not_notify: 

704 notify( 

705 session, 

706 user_id=user.id, 

707 topic_action=NotificationTopicAction.modnote__create, 

708 key="", 

709 ) 

710 

711 return _user_to_details(session, user) 

712 

713 def MarkUserNeedsLocationUpdate( 

714 self, request: admin_pb2.MarkUserNeedsLocationUpdateReq, context: CouchersContext, session: Session 

715 ) -> admin_pb2.UserDetails: 

716 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

719 user.needs_to_update_location = True 

720 log_admin_action( 

721 session, context, user, "mark_needs_location_update", note="Marked user as needing location update" 

722 ) 

723 return _user_to_details(session, user) 

724 

725 def DeleteUser( 

726 self, request: admin_pb2.DeleteUserReq, context: CouchersContext, session: Session 

727 ) -> admin_pb2.UserDetails: 

728 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

731 user.deleted_at = now() 

732 log_admin_action(session, context, user, "delete_user", level=AdminActionLevel.high) 

733 return _user_to_details(session, user) 

734 

735 def RecoverDeletedUser( 

736 self, request: admin_pb2.RecoverDeletedUserReq, context: CouchersContext, session: Session 

737 ) -> admin_pb2.UserDetails: 

738 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

741 user.deleted_at = None 

742 user.undelete_token = None 

743 user.undelete_until = None 

744 log_admin_action(session, context, user, "recover_user", level=AdminActionLevel.high) 

745 return _user_to_details(session, user) 

746 

747 def CreateApiKey( 

748 self, request: admin_pb2.CreateApiKeyReq, context: CouchersContext, session: Session 

749 ) -> admin_pb2.CreateApiKeyRes: 

750 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

753 token, expiry = create_session( 

754 context, session, user, long_lived=True, is_api_key=True, duration=timedelta(days=365), set_cookie=False 

755 ) 

756 log_admin_action(session, context, user, "create_api_key") 

757 

758 notify( 

759 session, 

760 user_id=user.id, 

761 topic_action=NotificationTopicAction.api_key__create, 

762 key="", 

763 data=notification_data_pb2.ApiKeyCreate( 

764 api_key=token, 

765 expiry=Timestamp_from_datetime(expiry), 

766 ), 

767 ) 

768 

769 return admin_pb2.CreateApiKeyRes() 

770 

771 def GetChats( 

772 self, request: admin_pb2.GetChatsReq, context: CouchersContext, session: Session 

773 ) -> admin_pb2.GetChatsRes: 

774 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

777 

778 get_chat_user_info = _make_chat_user_info_getter(session) 

779 

780 def message_to_pb(message: Message) -> admin_pb2.ChatMessage: 

781 return admin_pb2.ChatMessage( 

782 message_id=message.id, 

783 author=get_chat_user_info(message.author_id), 

784 time=Timestamp_from_datetime(message.time), 

785 message_type=message.message_type.name if message.message_type else "", 

786 text=message.text or "", 

787 host_request_status_target=( 

788 message.host_request_status_target.name if message.host_request_status_target else "" 

789 ), 

790 target=get_chat_user_info(message.target_id) if message.target_id else None, 

791 ) 

792 

793 def get_messages_for_conversation(conversation_id: int) -> list[admin_pb2.ChatMessage]: 

794 messages = ( 

795 session.execute( 

796 select(Message).where(Message.conversation_id == conversation_id).order_by(Message.id.asc()) 

797 ) 

798 .scalars() 

799 .all() 

800 ) 

801 return [message_to_pb(msg) for msg in messages] 

802 

803 def get_host_request_pb(host_request: HostRequest) -> admin_pb2.AdminHostRequest: 

804 return admin_pb2.AdminHostRequest( 

805 host_request_id=host_request.conversation_id, 

806 surfer=get_chat_user_info(host_request.surfer_user_id), 

807 host=get_chat_user_info(host_request.host_user_id), 

808 status=host_request.status.name if host_request.status else "", 

809 from_date=date_to_api(host_request.from_date), 

810 to_date=date_to_api(host_request.to_date), 

811 created=Timestamp_from_datetime(host_request.conversation.created), 

812 messages=get_messages_for_conversation(host_request.conversation_id), 

813 ) 

814 

815 def get_group_chat_pb(group_chat: GroupChat) -> admin_pb2.AdminGroupChat: 

816 subs = ( 

817 session.execute( 

818 select(GroupChatSubscription) 

819 .where(GroupChatSubscription.group_chat_id == group_chat.conversation_id) 

820 .order_by(GroupChatSubscription.joined.asc()) 

821 ) 

822 .scalars() 

823 .all() 

824 ) 

825 members = [ 

826 admin_pb2.GroupChatMember( 

827 user=get_chat_user_info(sub.user_id), 

828 joined=Timestamp_from_datetime(sub.joined), 

829 left=Timestamp_from_datetime(sub.left) if sub.left else None, 

830 role=sub.role.name if sub.role else "", 

831 ) 

832 for sub in subs 

833 ] 

834 return admin_pb2.AdminGroupChat( 

835 group_chat_id=group_chat.conversation_id, 

836 title=group_chat.title or "", 

837 is_dm=group_chat.is_dm, 

838 creator=get_chat_user_info(group_chat.creator_id), 

839 members=members, 

840 messages=get_messages_for_conversation(group_chat.conversation_id), 

841 ) 

842 

843 # Get all host requests for the user 

844 host_requests = ( 

845 session.execute( 

846 select(HostRequest) 

847 .where(or_(HostRequest.recipient_user_id == user.id, HostRequest.initiator_user_id == user.id)) 

848 .order_by(HostRequest.conversation_id.desc()) 

849 ) 

850 .scalars() 

851 .all() 

852 ) 

853 

854 # Get all group chats for the user 

855 group_chat_ids = ( 

856 session.execute( 

857 select(GroupChatSubscription.group_chat_id) 

858 .where(GroupChatSubscription.user_id == user.id) 

859 .order_by(GroupChatSubscription.joined.desc()) 

860 ) 

861 .scalars() 

862 .all() 

863 ) 

864 group_chats = ( 

865 session.execute(select(GroupChat).where(GroupChat.conversation_id.in_(group_chat_ids))).scalars().all() 

866 ) 

867 

868 # Build protobuf objects, then sort by latest message time (most recent first) 

869 host_request_pbs = [get_host_request_pb(hr) for hr in host_requests] 

870 host_request_pbs.sort(key=lambda hr: hr.messages[-1].time.seconds if hr.messages else 0, reverse=True) 

871 

872 group_chat_pbs = [get_group_chat_pb(gc) for gc in group_chats] 

873 group_chat_pbs.sort(key=lambda gc: gc.messages[-1].time.seconds if gc.messages else 0, reverse=True) 

874 

875 return admin_pb2.GetChatsRes( 

876 user=get_chat_user_info(user.id), 

877 host_requests=host_request_pbs, 

878 group_chats=group_chat_pbs, 

879 ) 

880 

881 def DeleteEvent( 

882 self, request: admin_pb2.DeleteEventReq, context: CouchersContext, session: Session 

883 ) -> empty_pb2.Empty: 

884 res = session.execute( 

885 select(Event, EventOccurrence) 

886 .where(EventOccurrence.id == request.event_id) 

887 .where(EventOccurrence.event_id == Event.id) 

888 .where(~EventOccurrence.is_deleted) 

889 ).one_or_none() 

890 

891 if not res: 891 ↛ 892line 891 didn't jump to line 892 because the condition on line 891 was never true

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

893 

894 event, occurrence = res 

895 

896 occurrence.is_deleted = True 

897 

898 queue_job( 

899 session, 

900 job=generate_event_delete_notifications, 

901 payload=jobs_pb2.GenerateEventDeleteNotificationsPayload( 

902 occurrence_id=occurrence.id, 

903 ), 

904 ) 

905 

906 return empty_pb2.Empty() 

907 

908 def ListUserIds( 

909 self, request: admin_pb2.ListUserIdsReq, context: CouchersContext, session: Session 

910 ) -> admin_pb2.ListUserIdsRes: 

911 start_date = to_aware_datetime(request.start_time) 

912 end_date = to_aware_datetime(request.end_time) 

913 

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

915 next_user_id = int(request.page_token) if request.page_token else 0 

916 

917 user_ids = ( 

918 session.execute( 

919 select(User.id) 

920 .where(or_(User.id <= next_user_id, to_bool(next_user_id == 0))) 

921 .where(User.joined >= start_date) 

922 .where(User.joined <= end_date) 

923 .order_by(User.id.desc()) 

924 .limit(page_size + 1) 

925 ) 

926 .scalars() 

927 .all() 

928 ) 

929 

930 return admin_pb2.ListUserIdsRes( 

931 user_ids=user_ids[:page_size], 

932 next_page_token=str(user_ids[-1]) if len(user_ids) > page_size else None, 

933 ) 

934 

935 def EditReferenceText( 

936 self, request: admin_pb2.EditReferenceTextReq, context: CouchersContext, session: Session 

937 ) -> empty_pb2.Empty: 

938 reference = session.execute(select(Reference).where(Reference.id == request.reference_id)).scalar_one_or_none() 

939 

940 if reference is None: 940 ↛ 941line 940 didn't jump to line 941 because the condition on line 940 was never true

941 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:reference_not_found") 

942 

943 if not request.new_text.strip(): 943 ↛ 944line 943 didn't jump to line 944 because the condition on line 943 was never true

944 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "reference_no_text") 

945 

946 reference.text = request.new_text.strip() 

947 # Log action against the reference author 

948 author = session.execute(select(User).where(User.id == reference.from_user_id)).scalar_one() 

949 log_admin_action(session, context, author, "edit_reference", note=f"Edited reference {reference.id}") 

950 return empty_pb2.Empty() 

951 

952 def DeleteReference( 

953 self, request: admin_pb2.DeleteReferenceReq, context: CouchersContext, session: Session 

954 ) -> empty_pb2.Empty: 

955 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:deletereference_deprecated_use_ums") 

956 

957 def GetUserReferences( 

958 self, request: admin_pb2.GetUserReferencesReq, context: CouchersContext, session: Session 

959 ) -> admin_pb2.GetUserReferencesRes: 

960 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

961 if not user: 

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

963 

964 get_chat_user_info = _make_chat_user_info_getter(session) 

965 

966 references_from = ( 

967 session.execute( 

968 select(Reference) 

969 .where(Reference.from_user_id == user.id) 

970 .options(selectinload(Reference.host_request), selectinload(Reference.moderation_state)) 

971 .order_by(Reference.id.desc()) 

972 ) 

973 .scalars() 

974 .all() 

975 ) 

976 

977 references_to = ( 

978 session.execute( 

979 select(Reference) 

980 .where(Reference.to_user_id == user.id) 

981 .options(selectinload(Reference.host_request), selectinload(Reference.moderation_state)) 

982 .order_by(Reference.id.desc()) 

983 ) 

984 .scalars() 

985 .all() 

986 ) 

987 

988 return admin_pb2.GetUserReferencesRes( 

989 references_from=[_reference_to_pb(ref, get_chat_user_info) for ref in references_from], 

990 references_to=[_reference_to_pb(ref, get_chat_user_info) for ref in references_to], 

991 ) 

992 

993 def GetFriendRequests( 

994 self, request: admin_pb2.GetFriendRequestsReq, context: CouchersContext, session: Session 

995 ) -> admin_pb2.GetFriendRequestsRes: 

996 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

997 if not user: 

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

999 

1000 get_chat_user_info = _make_chat_user_info_getter(session) 

1001 

1002 def friend_request_to_pb(rel: FriendRelationship) -> admin_pb2.AdminFriendRequest: 

1003 return admin_pb2.AdminFriendRequest( 

1004 friend_request_id=rel.id, 

1005 from_user=get_chat_user_info(rel.from_user_id), 

1006 to_user=get_chat_user_info(rel.to_user_id), 

1007 status=rel.status.name if rel.status else "", 

1008 time_sent=Timestamp_from_datetime(rel.time_sent), 

1009 time_responded=Timestamp_from_datetime(rel.time_responded) if rel.time_responded else None, 

1010 moderation_visibility=not_none(rel.moderation_state.visibility).name, 

1011 ) 

1012 

1013 sent = ( 

1014 session.execute( 

1015 select(FriendRelationship) 

1016 .where(FriendRelationship.from_user_id == user.id) 

1017 .order_by(FriendRelationship.id.desc()) 

1018 ) 

1019 .scalars() 

1020 .all() 

1021 ) 

1022 

1023 received = ( 

1024 session.execute( 

1025 select(FriendRelationship) 

1026 .where(FriendRelationship.to_user_id == user.id) 

1027 .order_by(FriendRelationship.id.desc()) 

1028 ) 

1029 .scalars() 

1030 .all() 

1031 ) 

1032 

1033 return admin_pb2.GetFriendRequestsRes( 

1034 sent=[friend_request_to_pb(rel) for rel in sent], 

1035 received=[friend_request_to_pb(rel) for rel in received], 

1036 ) 

1037 

1038 def GetUserBlocks( 

1039 self, request: admin_pb2.GetUserBlocksReq, context: CouchersContext, session: Session 

1040 ) -> admin_pb2.GetUserBlocksRes: 

1041 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

1042 if not user: 

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

1044 

1045 def user_block_to_pb(block: UserBlock, other_user: User) -> admin_pb2.AdminUserBlock: 

1046 return admin_pb2.AdminUserBlock( 

1047 user=admin_pb2.ChatUserInfo( 

1048 user_id=other_user.id, 

1049 username=other_user.username, 

1050 name=other_user.name, 

1051 birthdate=date_to_api(other_user.birthdate), 

1052 gender=other_user.gender, 

1053 ), 

1054 time_blocked=Timestamp_from_datetime(block.time_blocked), 

1055 ) 

1056 

1057 blocked_user = aliased(User) 

1058 blocked_users = session.execute( 

1059 select(UserBlock, blocked_user) 

1060 .join(blocked_user, UserBlock.blocked_user_id == blocked_user.id) 

1061 .where(UserBlock.blocking_user_id == user.id) 

1062 .order_by(UserBlock.time_blocked.desc(), UserBlock.id.desc()) 

1063 ).all() 

1064 

1065 blocking_user = aliased(User) 

1066 blocking_users = session.execute( 

1067 select(UserBlock, blocking_user) 

1068 .join(blocking_user, UserBlock.blocking_user_id == blocking_user.id) 

1069 .where(UserBlock.blocked_user_id == user.id) 

1070 .order_by(UserBlock.time_blocked.desc(), UserBlock.id.desc()) 

1071 ).all() 

1072 

1073 return admin_pb2.GetUserBlocksRes( 

1074 blocked_users=[user_block_to_pb(block, other_user) for block, other_user in blocked_users], 

1075 blocking_users=[user_block_to_pb(block, other_user) for block, other_user in blocking_users], 

1076 ) 

1077 

1078 def GetNonvisibleUserAccessLog( 

1079 self, request: admin_pb2.GetNonvisibleUserAccessLogReq, context: CouchersContext, session: Session 

1080 ) -> admin_pb2.GetNonvisibleUserAccessLogRes: 

1081 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

1084 

1085 actor = aliased(User) 

1086 rows = session.execute( 

1087 select(NonvisibleUserAccess, actor.username) 

1088 .outerjoin(actor, NonvisibleUserAccess.actor_user_id == actor.id) 

1089 .where(NonvisibleUserAccess.target_user_id == user.id) 

1090 .order_by(NonvisibleUserAccess.time.desc()) 

1091 .limit(MAX_PAGINATION_LENGTH) 

1092 ).all() 

1093 

1094 return admin_pb2.GetNonvisibleUserAccessLogRes( 

1095 entries=[ 

1096 admin_pb2.NonvisibleUserAccessLogEntry( 

1097 time=Timestamp_from_datetime(access.time), 

1098 access_type=nonvisibleuseraccesstype2api[access.access_type], 

1099 target_state=nonvisibleuserstate2api[access.target_state], 

1100 target_user_id=access.target_user_id, 

1101 actor_user_id=Int64Value(value=access.actor_user_id) if access.actor_user_id is not None else None, 

1102 actor_username=actor_username or "", 

1103 ip_address=access.ip_address or "", 

1104 user_agent=access.user_agent or "", 

1105 sofa=access.sofa or "", 

1106 ) 

1107 for access, actor_username in rows 

1108 ] 

1109 ) 

1110 

1111 def EditDiscussion( 

1112 self, request: admin_pb2.EditDiscussionReq, context: CouchersContext, session: Session 

1113 ) -> empty_pb2.Empty: 

1114 discussion = session.execute( 

1115 select(Discussion).where(Discussion.id == request.discussion_id) 

1116 ).scalar_one_or_none() 

1117 if not discussion: 

1118 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found") 

1119 if request.new_title: 

1120 discussion.title = request.new_title.strip() 

1121 if request.new_content: 

1122 discussion.content = request.new_content.strip() 

1123 return empty_pb2.Empty() 

1124 

1125 def DeleteDiscussion( 

1126 self, request: admin_pb2.AdminDeleteDiscussionReq, context: CouchersContext, session: Session 

1127 ) -> empty_pb2.Empty: 

1128 discussion = session.execute( 

1129 select(Discussion).where(Discussion.id == request.discussion_id) 

1130 ).scalar_one_or_none() 

1131 if not discussion: 1131 ↛ 1132line 1131 didn't jump to line 1132 because the condition on line 1131 was never true

1132 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "discussion_not_found") 

1133 if discussion.deleted is not None: 1133 ↛ 1134line 1133 didn't jump to line 1134 because the condition on line 1133 was never true

1134 return empty_pb2.Empty() 

1135 session.add( 

1136 DiscussionVersion( 

1137 discussion_id=discussion.id, 

1138 editor_user_id=context.user_id, 

1139 change_type=ContentChangeType.delete, 

1140 old_title=discussion.title, 

1141 new_title=None, 

1142 old_content=discussion.content, 

1143 new_content=None, 

1144 ) 

1145 ) 

1146 discussion.deleted = now() 

1147 return empty_pb2.Empty() 

1148 

1149 def EditReply(self, request: admin_pb2.EditReplyReq, context: CouchersContext, session: Session) -> empty_pb2.Empty: 

1150 database_id, depth = unpack_thread_id(request.reply_id) 

1151 if depth == 1: 

1152 obj: Comment | Reply | None = session.execute( 

1153 select(Comment).where(Comment.id == database_id) 

1154 ).scalar_one_or_none() 

1155 elif depth == 2: 

1156 obj = session.execute(select(Reply).where(Reply.id == database_id)).scalar_one_or_none() 

1157 else: 

1158 obj = None 

1159 

1160 if not obj: 

1161 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:object_not_found") 

1162 old_content = obj.content 

1163 new_content = request.new_content.strip() 

1164 if depth == 1: 

1165 session.add( 

1166 CommentVersion( 

1167 comment_id=database_id, 

1168 editor_user_id=context.user_id, 

1169 change_type=ContentChangeType.edit, 

1170 old_content=old_content, 

1171 new_content=new_content, 

1172 ) 

1173 ) 

1174 else: 

1175 session.add( 

1176 ReplyVersion( 

1177 reply_id=database_id, 

1178 editor_user_id=context.user_id, 

1179 change_type=ContentChangeType.edit, 

1180 old_content=old_content, 

1181 new_content=new_content, 

1182 ) 

1183 ) 

1184 obj.content = new_content 

1185 return empty_pb2.Empty() 

1186 

1187 def AddUsersToModerationUserList( 

1188 self, request: admin_pb2.AddUsersToModerationUserListReq, context: CouchersContext, session: Session 

1189 ) -> admin_pb2.AddUsersToModerationUserListRes: 

1190 """Add multiple users to a moderation user list. If no moderation list is provided, a new one is created. 

1191 Id of the moderation list is returned.""" 

1192 req_users = request.users 

1193 users = [] 

1194 

1195 for req_user in req_users: 

1196 user = session.execute(select(User).where(username_or_email_or_id(req_user))).scalar_one_or_none() 

1197 if not user: 

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

1199 users.append(user) 

1200 

1201 if request.moderation_list_id: 

1202 moderation_user_list = session.get(ModerationUserList, request.moderation_list_id) 

1203 if not moderation_user_list: 

1204 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:moderation_user_list_not_found") 

1205 # Create a new moderation user list if no one is provided 

1206 else: 

1207 moderation_user_list = ModerationUserList() 

1208 session.add(moderation_user_list) 

1209 session.flush() 

1210 

1211 # Add users to the moderation list only if not already in it 

1212 for user in users: 

1213 if user not in moderation_user_list.users: 1213 ↛ 1215line 1213 didn't jump to line 1215 because the condition on line 1213 was always true

1214 moderation_user_list.users.append(user) 

1215 log_admin_action(session, context, user, "add_to_moderation_list") 

1216 

1217 return admin_pb2.AddUsersToModerationUserListRes(moderation_list_id=moderation_user_list.id) 

1218 

1219 def ListModerationUserLists( 

1220 self, request: admin_pb2.ListModerationUserListsReq, context: CouchersContext, session: Session 

1221 ) -> admin_pb2.ListModerationUserListsRes: 

1222 """Lists all moderation user lists for a user.""" 

1223 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

1226 

1227 moderation_lists = [ 

1228 admin_pb2.ModerationList( 

1229 moderation_list_id=ml.id, 

1230 members=[_user_to_details(session, u) for u in ml.users], 

1231 ) 

1232 for ml in user.moderation_user_lists 

1233 ] 

1234 return admin_pb2.ListModerationUserListsRes(moderation_lists=moderation_lists) 

1235 

1236 def RemoveUserFromModerationUserList( 

1237 self, request: admin_pb2.RemoveUserFromModerationUserListReq, context: CouchersContext, session: Session 

1238 ) -> empty_pb2.Empty: 

1239 """Removes a user from a provided moderation user list.""" 

1240 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

1241 if not user: 

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

1243 if not request.moderation_list_id: 

1244 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:missing_moderation_user_list_id") 

1245 

1246 moderation_user_list = session.get(ModerationUserList, request.moderation_list_id) 

1247 if not moderation_user_list: 1247 ↛ 1248line 1247 didn't jump to line 1248 because the condition on line 1247 was never true

1248 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:moderation_user_list_not_found") 

1249 if user not in moderation_user_list.users: 

1250 context.abort_with_error_code( 

1251 grpc.StatusCode.FAILED_PRECONDITION, "admin:user_not_in_the_moderation_user_list" 

1252 ) 

1253 

1254 moderation_user_list.users.remove(user) 

1255 log_admin_action(session, context, user, "remove_from_moderation_list") 

1256 

1257 if len(moderation_user_list.users) == 0: 

1258 session.delete(moderation_user_list) 

1259 

1260 return empty_pb2.Empty() 

1261 

1262 def CreateAccountDeletionLink( 

1263 self, request: admin_pb2.CreateAccountDeletionLinkReq, context: CouchersContext, session: Session 

1264 ) -> admin_pb2.CreateAccountDeletionLinkRes: 

1265 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

1268 token = AccountDeletionToken(token=urlsafe_secure_token(), user_id=user.id, expiry=now() + timedelta(hours=2)) 

1269 session.add(token) 

1270 log_admin_action(session, context, user, "create_account_deletion_link", level=AdminActionLevel.high) 

1271 return admin_pb2.CreateAccountDeletionLinkRes( 

1272 account_deletion_confirm_url=urls.delete_account_link(account_deletion_token=token.token) 

1273 ) 

1274 

1275 def AccessStats( 

1276 self, request: admin_pb2.AccessStatsReq, context: CouchersContext, session: Session 

1277 ) -> admin_pb2.AccessStatsRes: 

1278 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

1281 

1282 start_time = ( 

1283 to_aware_datetime(request.start_time) if request.HasField("start_time") else now() - timedelta(days=90) 

1284 ) 

1285 end_time = to_aware_datetime(request.end_time) if request.HasField("end_time") else now() 

1286 

1287 user_activity = session.execute( 

1288 select( 

1289 UserActivity.ip_address, 

1290 UserActivity.user_agent, 

1291 func.sum(UserActivity.api_calls), 

1292 func.count(UserActivity.period), 

1293 func.min(UserActivity.period), 

1294 func.max(UserActivity.period), 

1295 ) 

1296 .where(UserActivity.user_id == user.id) 

1297 .where(UserActivity.period >= start_time) 

1298 .where(UserActivity.period <= end_time) 

1299 .order_by(func.max(UserActivity.period).desc()) 

1300 .group_by(UserActivity.ip_address, UserActivity.user_agent) 

1301 ).all() 

1302 

1303 out = admin_pb2.AccessStatsRes() 

1304 

1305 for ip_address, user_agent, api_call_count, periods_count, first_seen, last_seen in user_activity: 

1306 ip_address_str = str(ip_address) if ip_address is not None else None 

1307 user_agent_data = user_agents_parse(user_agent or "") 

1308 asn = geoip_asn(ip_address_str) 

1309 out.stats.append( 

1310 admin_pb2.AccessStat( 

1311 ip_address=ip_address_str, 

1312 asn=str(asn[0]) if asn else None, 

1313 asorg=str(asn[1]) if asn else None, 

1314 asnetwork=str(asn[2]) if asn else None, 

1315 user_agent=user_agent, 

1316 operating_system=user_agent_data.os.family, 

1317 browser=user_agent_data.browser.family, 

1318 device=user_agent_data.device.family, 

1319 approximate_location=geoip_approximate_location(ip_address_str) or "Unknown", 

1320 api_call_count=api_call_count, 

1321 periods_count=periods_count, 

1322 first_seen=Timestamp_from_datetime(first_seen), 

1323 last_seen=Timestamp_from_datetime(last_seen), 

1324 ) 

1325 ) 

1326 

1327 return out 

1328 

1329 def SetLastDonated( 

1330 self, request: admin_pb2.SetLastDonatedReq, context: CouchersContext, session: Session 

1331 ) -> admin_pb2.UserDetails: 

1332 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

1333 if not user: 

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

1335 

1336 if request.HasField("last_donated"): 

1337 user.last_donated = to_aware_datetime(request.last_donated) 

1338 else: 

1339 user.last_donated = None 

1340 

1341 log_admin_action(session, context, user, "set_last_donated") 

1342 return _user_to_details(session, user) 

1343 

1344 def CreateAdminTag( 

1345 self, request: admin_pb2.CreateAdminTagReq, context: CouchersContext, session: Session 

1346 ) -> admin_pb2.AdminTagInfo: 

1347 if not request.tag.strip(): 

1348 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:tag_cant_be_empty") 

1349 existing = session.execute(select(AdminTag).where(AdminTag.tag == request.tag.strip())).scalar_one_or_none() 

1350 if existing: 

1351 context.abort_with_error_code(grpc.StatusCode.ALREADY_EXISTS, "admin:tag_already_exists") 

1352 admin_tag = AdminTag(tag=request.tag.strip()) 

1353 session.add(admin_tag) 

1354 session.flush() 

1355 return admin_pb2.AdminTagInfo(admin_tag_id=admin_tag.id, tag=admin_tag.tag) 

1356 

1357 def ListAdminTags( 

1358 self, request: admin_pb2.ListAdminTagsReq, context: CouchersContext, session: Session 

1359 ) -> admin_pb2.ListAdminTagsRes: 

1360 tags = session.execute(select(AdminTag).order_by(AdminTag.tag)).scalars().all() 

1361 return admin_pb2.ListAdminTagsRes( 

1362 tags=[admin_pb2.AdminTagInfo(admin_tag_id=tag.id, tag=tag.tag) for tag in tags] 

1363 ) 

1364 

1365 def AddAdminTagToUser( 

1366 self, request: admin_pb2.AddAdminTagToUserReq, context: CouchersContext, session: Session 

1367 ) -> admin_pb2.UserDetails: 

1368 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

1371 admin_tag = session.execute(select(AdminTag).where(AdminTag.tag == request.tag)).scalar_one_or_none() 

1372 if not admin_tag: 

1373 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:tag_not_found") 

1374 existing = session.execute( 

1375 select(UserAdminTag).where(UserAdminTag.user_id == user.id, UserAdminTag.admin_tag_id == admin_tag.id) 

1376 ).scalar_one_or_none() 

1377 if existing: 

1378 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_already_has_admin_tag") 

1379 session.add(UserAdminTag(user_id=user.id, admin_tag_id=admin_tag.id)) 

1380 session.flush() 

1381 log_admin_action(session, context, user, "add_tag", tag=request.tag) 

1382 return _user_to_details(session, user) 

1383 

1384 def RemoveAdminTagFromUser( 

1385 self, request: admin_pb2.RemoveAdminTagFromUserReq, context: CouchersContext, session: Session 

1386 ) -> admin_pb2.UserDetails: 

1387 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

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

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

1390 admin_tag = session.execute(select(AdminTag).where(AdminTag.tag == request.tag)).scalar_one_or_none() 

1391 if not admin_tag: 1391 ↛ 1392line 1391 didn't jump to line 1392 because the condition on line 1391 was never true

1392 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:tag_not_found") 

1393 user_admin_tag = session.execute( 

1394 select(UserAdminTag).where(UserAdminTag.user_id == user.id, UserAdminTag.admin_tag_id == admin_tag.id) 

1395 ).scalar_one_or_none() 

1396 if not user_admin_tag: 

1397 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:user_does_not_have_admin_tag") 

1398 session.delete(user_admin_tag) 

1399 session.flush() 

1400 log_admin_action(session, context, user, "remove_tag", tag=request.tag) 

1401 return _user_to_details(session, user) 

1402 

1403 def SetModScore( 

1404 self, request: admin_pb2.SetModScoreReq, context: CouchersContext, session: Session 

1405 ) -> admin_pb2.UserDetails: 

1406 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

1407 if not user: 

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

1409 user.mod_score = request.mod_score 

1410 log_admin_action(session, context, user, "set_mod_score", note=f"mod_score={request.mod_score}") 

1411 return _user_to_details(session, user) 

1412 

1413 def ListAdminActions( 

1414 self, request: admin_pb2.ListAdminActionsReq, context: CouchersContext, session: Session 

1415 ) -> admin_pb2.ListAdminActionsRes: 

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

1417 

1418 admin_user = aliased(User) 

1419 target_user = aliased(User) 

1420 

1421 statement = ( 

1422 select(AdminAction, admin_user.username, target_user.username) 

1423 .join(admin_user, AdminAction.admin_user_id == admin_user.id) 

1424 .join(target_user, AdminAction.target_user_id == target_user.id) 

1425 ) 

1426 

1427 if request.admin_user_id: 

1428 statement = statement.where(AdminAction.admin_user_id == request.admin_user_id) 

1429 if request.target_user_id: 

1430 statement = statement.where(AdminAction.target_user_id == request.target_user_id) 

1431 if request.page_token: 

1432 statement = statement.where(AdminAction.id < int(request.page_token)) 

1433 

1434 statement = statement.order_by(AdminAction.id.desc()).limit(page_size + 1) 

1435 

1436 rows = session.execute(statement).all() 

1437 

1438 action_pbs = [ 

1439 admin_pb2.AdminActionLog( 

1440 admin_action_id=action.id, 

1441 created=Timestamp_from_datetime(action.created), 

1442 admin_user_id=action.admin_user_id, 

1443 admin_username=admin_username, 

1444 action_type=action.action_type, 

1445 level=adminactionlevel2api[action.level], 

1446 note=action.note or "", 

1447 data=json.dumps(action.data) if action.data is not None else "", 

1448 tag=action.tag or "", 

1449 target_user_id=action.target_user_id, 

1450 target_username=target_username, 

1451 ) 

1452 for action, admin_username, target_username in rows[:page_size] 

1453 ] 

1454 

1455 return admin_pb2.ListAdminActionsRes( 

1456 admin_actions=action_pbs, 

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

1458 ) 

1459 

1460 def ListUserUploads( 

1461 self, request: admin_pb2.ListUserUploadsReq, context: CouchersContext, session: Session 

1462 ) -> admin_pb2.ListUserUploadsRes: 

1463 user = session.execute(select(User).where(username_or_email_or_id(request.user))).scalar_one_or_none() 

1464 if not user: 

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

1466 

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

1468 

1469 statement = ( 

1470 select(Upload) 

1471 .where(Upload.creator_user_id == user.id) 

1472 .options( 

1473 undefer(Upload.metadata_parsed), 

1474 undefer(Upload.metadata_parse_error), 

1475 undefer(Upload.original_filename), 

1476 undefer(Upload.original_format), 

1477 undefer(Upload.original_size), 

1478 undefer(Upload.original_width), 

1479 undefer(Upload.original_height), 

1480 ) 

1481 ) 

1482 if request.page_token: 

1483 cursor_created = session.execute( 

1484 select(Upload.created).where(Upload.key == request.page_token) 

1485 ).scalar_one() 

1486 statement = statement.where(tuple_(Upload.created, Upload.key) < (cursor_created, request.page_token)) 

1487 

1488 uploads = ( 

1489 session.execute(statement.order_by(Upload.created.desc(), Upload.key.desc()).limit(page_size + 1)) 

1490 .scalars() 

1491 .all() 

1492 ) 

1493 

1494 page = uploads[:page_size] 

1495 uses_by_key = get_upload_uses_for_keys(session, [upload.key for upload in page]) 

1496 

1497 return admin_pb2.ListUserUploadsRes( 

1498 uploads=[ 

1499 admin_pb2.UserUpload( 

1500 key=upload.key, 

1501 filename=upload.filename, 

1502 full_url=upload.full_url, 

1503 thumbnail_url=upload.thumbnail_url, 

1504 credit=upload.credit or "", 

1505 created=Timestamp_from_datetime(upload.created), 

1506 metadata=_upload_metadata_to_pb(upload), 

1507 uses=[ 

1508 admin_pb2.UploadUse( 

1509 type=uploadusetype2api[use.use_type], 

1510 is_current=use.is_current, 

1511 user_id=use.user_id, 

1512 event_id=use.event_id, 

1513 page_id=use.page_id, 

1514 url=use.url, 

1515 ) 

1516 for use in uses_by_key.get(upload.key, []) 

1517 ], 

1518 ) 

1519 for upload in page 

1520 ], 

1521 next_page_token=uploads[page_size - 1].key if len(uploads) > page_size else None, 

1522 ) 

1523 

1524 def CreateOTAPackage( 

1525 self, request: admin_pb2.CreateOTAPackageReq, context: CouchersContext, session: Session 

1526 ) -> admin_pb2.OTAPackage: 

1527 platform = api2otaplatform.get(request.platform) 

1528 if platform is None: 1528 ↛ 1529line 1528 didn't jump to line 1529 because the condition on line 1528 was never true

1529 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_ota_platform") 

1530 

1531 if not request.version: 

1532 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_ota_version") 

1533 

1534 existing = session.execute( 

1535 select(OTAPackage.id).where(OTAPackage.platform == platform).where(OTAPackage.version == request.version) 

1536 ).scalar_one_or_none() 

1537 if existing is not None: 

1538 context.abort_with_error_code(grpc.StatusCode.FAILED_PRECONDITION, "admin:ota_package_already_exists") 

1539 

1540 # Read the keying/ordering fields out of the manifest we're about to serve, so the row can't 

1541 # disagree with the bytes on the CDN. 

1542 cdn_root = context.get_string_value("native_ota_cdn_root", "https://cdn.couchers.org/native/ota") 

1543 _content_type, body = _fetch_signed_manifest( 

1544 _native_ota_manifest_url(cdn_root=cdn_root, version=request.version, platform=platform.name) 

1545 ) 

1546 manifest = _extract_ota_manifest(body) 

1547 fingerprint = manifest.get("runtimeVersion") if manifest else None 

1548 manifest_id = manifest.get("id") if manifest else None 

1549 created_at_raw = manifest.get("createdAt") if manifest else None 

1550 if ( 

1551 manifest is None 

1552 or not isinstance(fingerprint, str) 

1553 or not fingerprint 

1554 or not isinstance(manifest_id, str) 

1555 or not manifest_id 

1556 or not isinstance(created_at_raw, str) 

1557 or not created_at_raw 

1558 ): 

1559 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_ota_manifest") 

1560 try: 

1561 manifest_created_at = datetime.fromisoformat(created_at_raw) 

1562 except ValueError: 

1563 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_ota_manifest") 

1564 if manifest_created_at.tzinfo is None: 1564 ↛ 1565line 1564 didn't jump to line 1565 because the condition on line 1564 was never true

1565 manifest_created_at = manifest_created_at.replace(tzinfo=UTC) 

1566 

1567 package = OTAPackage( 

1568 creator_user_id=context.user_id, 

1569 platform=platform, 

1570 fingerprint=fingerprint, 

1571 version=request.version, 

1572 manifest_created_at=manifest_created_at, 

1573 manifest_id=manifest_id, 

1574 ) 

1575 session.add(package) 

1576 session.flush() 

1577 

1578 return _ota_package_to_pb(package, _live_ota_package_ids(session)) 

1579 

1580 def ListOTAPackages( 

1581 self, request: admin_pb2.ListOTAPackagesReq, context: CouchersContext, session: Session 

1582 ) -> admin_pb2.ListOTAPackagesRes: 

1583 statement = select(OTAPackage).order_by(OTAPackage.manifest_created_at.desc(), OTAPackage.id.desc()) 

1584 if request.platform != admin_pb2.OTA_PLATFORM_UNSPECIFIED: 

1585 platform = api2otaplatform.get(request.platform) 

1586 if platform is None: 1586 ↛ 1587line 1586 didn't jump to line 1587 because the condition on line 1586 was never true

1587 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:invalid_ota_platform") 

1588 statement = statement.where(OTAPackage.platform == platform) 

1589 if request.fingerprint: 1589 ↛ 1590line 1589 didn't jump to line 1590 because the condition on line 1589 was never true

1590 statement = statement.where(OTAPackage.fingerprint == request.fingerprint) 

1591 if not request.include_banned: 

1592 statement = statement.where(OTAPackage.banned_at.is_(None)) 

1593 

1594 packages = session.execute(statement).scalars().all() 

1595 live_ids = _live_ota_package_ids(session) 

1596 return admin_pb2.ListOTAPackagesRes(packages=[_ota_package_to_pb(package, live_ids) for package in packages]) 

1597 

1598 def BanOTAPackage( 

1599 self, request: admin_pb2.BanOTAPackageReq, context: CouchersContext, session: Session 

1600 ) -> admin_pb2.OTAPackage: 

1601 # Bans are irreversible — to roll back an accidental ban, republish the bundle as a new 

1602 # package — so a reason is required for the audit trail. 

1603 if not request.reason.strip(): 

1604 context.abort_with_error_code(grpc.StatusCode.INVALID_ARGUMENT, "admin:ota_ban_reason_required") 

1605 

1606 package = session.execute( 

1607 select(OTAPackage).where(OTAPackage.id == request.ota_package_id) 

1608 ).scalar_one_or_none() 

1609 if package is None: 

1610 context.abort_with_error_code(grpc.StatusCode.NOT_FOUND, "admin:ota_package_not_found") 

1611 

1612 if package.banned_at is None: 1612 ↛ 1616line 1612 didn't jump to line 1616 because the condition on line 1612 was always true

1613 package.banned_at = now() 

1614 package.banned_by_user_id = context.user_id 

1615 package.banned_reason = request.reason 

1616 session.flush() 

1617 

1618 return _ota_package_to_pb(package, _live_ota_package_ids(session))