Coverage for api/channel_requests.py: 19.60%

266 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2026-04-15 17:36 +0000

1""" 

2Channel Access Request API endpoints for Missing Table 

3 

4Allows logged-in users to request access to their team's Telegram/Discord channels. 

5Admins review requests and mark them approved/denied after manually adding users. 

6""" 

7 

8import os 

9import sys 

10from datetime import datetime 

11from typing import Literal 

12 

13import structlog 

14from fastapi import APIRouter, Depends, HTTPException, Query 

15from pydantic import BaseModel 

16 

17from supabase import create_client 

18 

19logger = structlog.get_logger(__name__) 

20 

21sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 

22 

23from auth import get_current_user_required 

24from dao.match_dao import SupabaseConnection as DbConnectionHolder 

25 

26# Initialize database connection with service role for admin operations 

27supabase_url = os.getenv("SUPABASE_URL", "") 

28service_key = os.getenv("SUPABASE_SERVICE_KEY") 

29 

30if service_key: 30 ↛ 33line 30 didn't jump to line 33 because the condition on line 30 was always true

31 service_client = create_client(supabase_url, service_key) 

32else: 

33 db_conn_holder_obj = DbConnectionHolder() 

34 service_client = db_conn_holder_obj.client 

35 

36router = APIRouter(prefix="/api/channel-requests", tags=["channel-requests"]) 

37 

38 

39# ============================================================ 

40# Pydantic models 

41# ============================================================ 

42 

43class ChannelAccessRequestCreate(BaseModel): 

44 """Model for creating/updating a channel access request.""" 

45 telegram: bool = False 

46 discord: bool = False 

47 telegram_handle: str | None = None 

48 discord_handle: str | None = None 

49 

50 

51class ChannelAccessRequestResponse(BaseModel): 

52 """Model for channel access request response.""" 

53 id: str 

54 user_id: str 

55 team_id: int 

56 telegram_handle: str | None 

57 discord_handle: str | None 

58 telegram_status: str 

59 discord_status: str 

60 telegram_reviewed_by: str | None 

61 telegram_reviewed_at: datetime | None 

62 discord_reviewed_by: str | None 

63 discord_reviewed_at: datetime | None 

64 admin_notes: str | None 

65 created_at: datetime 

66 updated_at: datetime 

67 # Joined fields (not always present) 

68 user_display_name: str | None = None 

69 user_email: str | None = None 

70 team_name: str | None = None 

71 

72 

73class ChannelAccessStatusUpdate(BaseModel): 

74 """Model for admin updating per-platform status.""" 

75 platform: Literal["telegram", "discord"] 

76 status: Literal["approved", "denied"] 

77 admin_notes: str | None = None 

78 

79 

80class ChannelAccessStats(BaseModel): 

81 """Statistics about channel access requests.""" 

82 total: int 

83 pending_telegram: int 

84 pending_discord: int 

85 pending_total: int 

86 approved: int 

87 denied: int 

88 

89 

90# ============================================================ 

91# User endpoints 

92# ============================================================ 

93 

94@router.get("/me") 

95async def get_my_channel_request(current_user=Depends(get_current_user_required)): 

96 """Get the current user's channel access request (if any).""" 

97 user_id = current_user.get("user_id") or current_user.get("id") or current_user.get("sub") 

98 try: 

99 result = ( 

100 service_client.table("channel_access_requests") 

101 .select("*") 

102 .eq("user_id", user_id) 

103 .order("created_at", desc=True) 

104 .limit(1) 

105 .execute() 

106 ) 

107 if not result.data: 

108 raise HTTPException(status_code=404, detail="No channel access request found") 

109 return result.data[0] 

110 except HTTPException: 

111 raise 

112 except Exception: 

113 logger.exception("Error getting channel request for user") 

114 raise HTTPException(status_code=500, detail="Failed to get channel access request") from None 

115 

116 

117@router.post("", status_code=201) 

118async def create_channel_request( 

119 request: ChannelAccessRequestCreate, 

120 current_user=Depends(get_current_user_required), 

121): 

122 """ 

123 Submit or update a channel access request for the current user. 

124 

125 Sets chosen platform(s) to 'pending' (only if current status is 'none' or 'denied'). 

126 Writes handles to both the request row and user_profiles. 

127 Requires the user to have a team_id on their profile. 

128 """ 

129 user_id = current_user.get("user_id") or current_user.get("id") or current_user.get("sub") 

130 

131 if not request.telegram and not request.discord: 

132 raise HTTPException(status_code=400, detail="Must request at least one platform (telegram or discord)") 

133 

134 try: 

135 # Get user profile to find team_id / club_id 

136 profile_result = ( 

137 service_client.table("user_profiles") 

138 .select("team_id, club_id, telegram_handle, discord_handle") 

139 .eq("id", user_id) 

140 .execute() 

141 ) 

142 

143 if not profile_result.data: 

144 raise HTTPException(status_code=404, detail="User profile not found") 

145 

146 profile = profile_result.data[0] 

147 team_id = profile.get("team_id") 

148 

149 # Club managers may have club_id but no team_id — fall back to first team in their club 

150 if not team_id and profile.get("club_id"): 

151 teams_result = ( 

152 service_client.table("teams") 

153 .select("id") 

154 .eq("club_id", profile["club_id"]) 

155 .order("id") 

156 .limit(1) 

157 .execute() 

158 ) 

159 if teams_result.data: 

160 team_id = teams_result.data[0]["id"] 

161 

162 if not team_id: 

163 raise HTTPException( 

164 status_code=400, 

165 detail="You must be assigned to a team before requesting channel access" 

166 ) 

167 

168 # Check for existing request for this (user, team) 

169 existing_result = ( 

170 service_client.table("channel_access_requests") 

171 .select("*") 

172 .eq("user_id", user_id) 

173 .eq("team_id", team_id) 

174 .execute() 

175 ) 

176 

177 # Determine handles to use (prefer provided, fall back to profile values) 

178 tg_handle = request.telegram_handle or profile.get("telegram_handle") 

179 dc_handle = request.discord_handle or profile.get("discord_handle") 

180 

181 # Validate that handles are provided for requested platforms 

182 if request.telegram and not tg_handle: 

183 raise HTTPException(status_code=400, detail="Telegram handle is required to request Telegram access") 

184 if request.discord and not dc_handle: 

185 raise HTTPException(status_code=400, detail="Discord handle is required to request Discord access") 

186 

187 # Prepare update data for user_profiles (sync handles) 

188 profile_update = {} 

189 if tg_handle: 

190 profile_update["telegram_handle"] = tg_handle 

191 if dc_handle: 

192 profile_update["discord_handle"] = dc_handle 

193 if profile_update: 

194 service_client.table("user_profiles").update(profile_update).eq("id", user_id).execute() 

195 

196 now = datetime.utcnow().isoformat() 

197 

198 if existing_result.data: 

199 # Update existing row — only flip status if currently none/denied 

200 existing = existing_result.data[0] 

201 update_data = {"updated_at": now} 

202 

203 if tg_handle: 

204 update_data["telegram_handle"] = tg_handle 

205 if dc_handle: 

206 update_data["discord_handle"] = dc_handle 

207 

208 if request.telegram: 

209 if existing.get("telegram_status") in ("none", "denied"): 

210 update_data["telegram_status"] = "pending" 

211 elif existing.get("telegram_status") == "approved": 

212 pass # Already approved — don't reset 

213 else: 

214 update_data["telegram_status"] = "pending" 

215 

216 if request.discord: 

217 if existing.get("discord_status") in ("none", "denied"): 

218 update_data["discord_status"] = "pending" 

219 elif existing.get("discord_status") == "approved": 

220 pass 

221 else: 

222 update_data["discord_status"] = "pending" 

223 

224 result = ( 

225 service_client.table("channel_access_requests") 

226 .update(update_data) 

227 .eq("id", existing["id"]) 

228 .execute() 

229 ) 

230 else: 

231 # Insert new row 

232 insert_data = { 

233 "user_id": user_id, 

234 "team_id": team_id, 

235 "telegram_handle": tg_handle, 

236 "discord_handle": dc_handle, 

237 "telegram_status": "pending" if request.telegram else "none", 

238 "discord_status": "pending" if request.discord else "none", 

239 } 

240 result = service_client.table("channel_access_requests").insert(insert_data).execute() 

241 

242 if result.data: 

243 return { 

244 "success": True, 

245 "message": "Channel access request submitted", 

246 "request": result.data[0], 

247 } 

248 else: 

249 raise HTTPException(status_code=500, detail="Failed to submit channel access request") 

250 

251 except HTTPException: 

252 raise 

253 except Exception: 

254 logger.exception("Error creating channel access request") 

255 raise HTTPException(status_code=500, detail="Failed to submit channel access request") from None 

256 

257 

258@router.delete("/me/{platform}") 

259async def withdraw_channel_request( 

260 platform: Literal["telegram", "discord"], 

261 current_user=Depends(get_current_user_required), 

262): 

263 """Withdraw a pending channel request (set back to 'none').""" 

264 user_id = current_user.get("user_id") or current_user.get("id") or current_user.get("sub") 

265 

266 try: 

267 existing_result = ( 

268 service_client.table("channel_access_requests") 

269 .select("*") 

270 .eq("user_id", user_id) 

271 .execute() 

272 ) 

273 

274 if not existing_result.data: 

275 raise HTTPException(status_code=404, detail="No channel access request found") 

276 

277 existing = existing_result.data[0] 

278 status_field = f"{platform}_status" 

279 

280 if existing.get(status_field) != "pending": 

281 raise HTTPException( 

282 status_code=400, 

283 detail=f"{platform.capitalize()} request is not in pending state" 

284 ) 

285 

286 service_client.table("channel_access_requests").update( 

287 {status_field: "none", "updated_at": datetime.utcnow().isoformat()} 

288 ).eq("id", existing["id"]).execute() 

289 

290 return {"success": True, "message": f"{platform.capitalize()} request withdrawn"} 

291 

292 except HTTPException: 

293 raise 

294 except Exception: 

295 logger.exception("Error withdrawing channel request") 

296 raise HTTPException(status_code=500, detail="Failed to withdraw channel request") from None 

297 

298 

299# ============================================================ 

300# Admin endpoints 

301# ============================================================ 

302 

303def _require_admin_or_club_manager(current_user): 

304 """Raise 403 if user is not admin or club_manager.""" 

305 if current_user.get("role") not in ["admin", "club_manager"]: 

306 raise HTTPException(status_code=403, detail="Only admins and club managers can manage channel requests") 

307 

308 

309@router.get("", response_model=list[ChannelAccessRequestResponse]) 

310async def list_channel_requests( 

311 current_user=Depends(get_current_user_required), 

312 status: str | None = Query(None, pattern="^(pending|approved|denied)$"), 

313 platform: str | None = Query(None, pattern="^(telegram|discord)$"), 

314 team_id: int | None = Query(None), 

315 limit: int = Query(50, ge=1, le=100), 

316 offset: int = Query(0, ge=0), 

317): 

318 """List channel access requests (admin/club_manager only).""" 

319 _require_admin_or_club_manager(current_user) 

320 

321 try: 

322 # Join user_profiles and teams for display fields 

323 query = ( 

324 service_client.table("channel_access_requests") 

325 .select( 

326 "*, " 

327 "user_profiles!channel_access_requests_user_id_fkey(display_name, email), " 

328 "teams!channel_access_requests_team_id_fkey(name)" 

329 ) 

330 .order("created_at", desc=True) 

331 .range(offset, offset + limit - 1) 

332 ) 

333 

334 if team_id: 

335 query = query.eq("team_id", team_id) 

336 

337 if status and platform: 

338 query = query.eq(f"{platform}_status", status) 

339 elif status: 

340 # Filter where either platform has the status 

341 query = query.or_(f"telegram_status.eq.{status},discord_status.eq.{status}") 

342 elif platform: 

343 # Filter where platform is not 'none' 

344 query = query.neq(f"{platform}_status", "none") 

345 

346 # Club managers scoped to their club's teams 

347 if current_user.get("role") == "club_manager": 

348 club_id = current_user.get("club_id") 

349 if club_id: 

350 teams_result = ( 

351 service_client.table("teams") 

352 .select("id") 

353 .eq("club_id", club_id) 

354 .execute() 

355 ) 

356 club_team_ids = [t["id"] for t in (teams_result.data or [])] 

357 if club_team_ids: 

358 query = query.in_("team_id", club_team_ids) 

359 else: 

360 return [] 

361 

362 result = query.execute() 

363 

364 # Flatten joined fields 

365 rows = [] 

366 for row in (result.data or []): 

367 profile = row.pop("user_profiles", None) or {} 

368 team = row.pop("teams", None) or {} 

369 row["user_display_name"] = profile.get("display_name") 

370 row["user_email"] = profile.get("email") 

371 row["team_name"] = team.get("name") 

372 rows.append(row) 

373 

374 return rows 

375 

376 except HTTPException: 

377 raise 

378 except Exception: 

379 logger.exception("Error listing channel requests") 

380 raise HTTPException(status_code=500, detail="Failed to retrieve channel requests") from None 

381 

382 

383@router.get("/stats", response_model=ChannelAccessStats) 

384async def get_channel_request_stats(current_user=Depends(get_current_user_required)): 

385 """Get statistics about channel access requests (admin/club_manager only).""" 

386 _require_admin_or_club_manager(current_user) 

387 

388 try: 

389 all_requests = ( 

390 service_client.table("channel_access_requests") 

391 .select("telegram_status, discord_status") 

392 .execute() 

393 ) 

394 

395 stats = { 

396 "total": 0, 

397 "pending_telegram": 0, 

398 "pending_discord": 0, 

399 "pending_total": 0, 

400 "approved": 0, 

401 "denied": 0, 

402 } 

403 

404 if all_requests.data: 

405 stats["total"] = len(all_requests.data) 

406 pending_ids: set = set() 

407 for i, row in enumerate(all_requests.data): 

408 tg = row.get("telegram_status", "none") 

409 dc = row.get("discord_status", "none") 

410 if tg == "pending": 

411 stats["pending_telegram"] += 1 

412 pending_ids.add(i) 

413 if dc == "pending": 

414 stats["pending_discord"] += 1 

415 pending_ids.add(i) 

416 if tg == "approved" or dc == "approved": 

417 stats["approved"] += 1 

418 if tg == "denied" or dc == "denied": 

419 stats["denied"] += 1 

420 stats["pending_total"] = len(pending_ids) 

421 

422 return stats 

423 

424 except Exception: 

425 logger.exception("Error getting channel request stats") 

426 raise HTTPException(status_code=500, detail="Failed to retrieve statistics") from None 

427 

428 

429@router.get("/{request_id}", response_model=ChannelAccessRequestResponse) 

430async def get_channel_request(request_id: str, current_user=Depends(get_current_user_required)): 

431 """Get a specific channel access request by ID (admin/club_manager only).""" 

432 _require_admin_or_club_manager(current_user) 

433 

434 try: 

435 result = ( 

436 service_client.table("channel_access_requests") 

437 .select( 

438 "*, " 

439 "user_profiles!channel_access_requests_user_id_fkey(display_name, email), " 

440 "teams!channel_access_requests_team_id_fkey(name)" 

441 ) 

442 .eq("id", request_id) 

443 .execute() 

444 ) 

445 

446 if not result.data: 

447 raise HTTPException(status_code=404, detail="Channel access request not found") 

448 

449 row = result.data[0] 

450 profile = row.pop("user_profiles", None) or {} 

451 team = row.pop("teams", None) or {} 

452 row["user_display_name"] = profile.get("display_name") 

453 row["user_email"] = profile.get("email") 

454 row["team_name"] = team.get("name") 

455 return row 

456 

457 except HTTPException: 

458 raise 

459 except Exception: 

460 logger.exception("Error getting channel request") 

461 raise HTTPException(status_code=500, detail="Failed to retrieve channel request") from None 

462 

463 

464@router.put("/{request_id}/status") 

465async def update_channel_request_status( 

466 request_id: str, 

467 status_update: ChannelAccessStatusUpdate, 

468 current_user=Depends(get_current_user_required), 

469): 

470 """ 

471 Update the status for a specific platform on a channel access request (admin/club_manager only). 

472 

473 Approves or denies one platform at a time. Does not affect the other platform's status. 

474 """ 

475 _require_admin_or_club_manager(current_user) 

476 

477 try: 

478 existing = service_client.table("channel_access_requests").select("*").eq("id", request_id).execute() 

479 

480 if not existing.data: 

481 raise HTTPException(status_code=404, detail="Channel access request not found") 

482 

483 reviewer_id = current_user.get("id") or current_user.get("user_id") or current_user.get("sub") 

484 now = datetime.utcnow().isoformat() 

485 platform = status_update.platform 

486 

487 update_data = { 

488 f"{platform}_status": status_update.status, 

489 f"{platform}_reviewed_by": reviewer_id, 

490 f"{platform}_reviewed_at": now, 

491 } 

492 

493 if status_update.admin_notes: 

494 update_data["admin_notes"] = status_update.admin_notes 

495 

496 result = ( 

497 service_client.table("channel_access_requests") 

498 .update(update_data) 

499 .eq("id", request_id) 

500 .execute() 

501 ) 

502 

503 if result.data: 

504 return { 

505 "success": True, 

506 "message": f"{platform.capitalize()} request {status_update.status}", 

507 "request": result.data[0], 

508 } 

509 else: 

510 raise HTTPException(status_code=500, detail="Failed to update channel request status") 

511 

512 except HTTPException: 

513 raise 

514 except Exception: 

515 logger.exception("Error updating channel request status") 

516 raise HTTPException(status_code=500, detail="Failed to update channel request status") from None 

517 

518 

519@router.delete("/{request_id}") 

520async def delete_channel_request(request_id: str, current_user=Depends(get_current_user_required)): 

521 """Delete a channel access request (admin only).""" 

522 if current_user.get("role") != "admin": 

523 raise HTTPException(status_code=403, detail="Only admins can delete channel requests") 

524 

525 try: 

526 existing = service_client.table("channel_access_requests").select("id").eq("id", request_id).execute() 

527 

528 if not existing.data: 

529 raise HTTPException(status_code=404, detail="Channel access request not found") 

530 

531 service_client.table("channel_access_requests").delete().eq("id", request_id).execute() 

532 

533 return {"success": True, "message": "Channel access request deleted"} 

534 

535 except HTTPException: 

536 raise 

537 except Exception: 

538 logger.exception("Error deleting channel request") 

539 raise HTTPException(status_code=500, detail="Failed to delete channel request") from None