Coverage for api/channel_requests.py: 19.60%
266 statements
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 11:37 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2026-04-13 11:37 +0000
1"""
2Channel Access Request API endpoints for Missing Table
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"""
8import os
9import sys
10from datetime import datetime
11from typing import Literal
13import structlog
14from fastapi import APIRouter, Depends, HTTPException, Query
15from pydantic import BaseModel
17from supabase import create_client
19logger = structlog.get_logger(__name__)
21sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
23from auth import get_current_user_required
24from dao.match_dao import SupabaseConnection as DbConnectionHolder
26# Initialize database connection with service role for admin operations
27supabase_url = os.getenv("SUPABASE_URL", "")
28service_key = os.getenv("SUPABASE_SERVICE_KEY")
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
36router = APIRouter(prefix="/api/channel-requests", tags=["channel-requests"])
39# ============================================================
40# Pydantic models
41# ============================================================
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
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
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
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
90# ============================================================
91# User endpoints
92# ============================================================
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
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.
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")
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)")
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 )
143 if not profile_result.data:
144 raise HTTPException(status_code=404, detail="User profile not found")
146 profile = profile_result.data[0]
147 team_id = profile.get("team_id")
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"]
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 )
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 )
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")
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")
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()
196 now = datetime.utcnow().isoformat()
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}
203 if tg_handle:
204 update_data["telegram_handle"] = tg_handle
205 if dc_handle:
206 update_data["discord_handle"] = dc_handle
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"
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"
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()
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")
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
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")
266 try:
267 existing_result = (
268 service_client.table("channel_access_requests")
269 .select("*")
270 .eq("user_id", user_id)
271 .execute()
272 )
274 if not existing_result.data:
275 raise HTTPException(status_code=404, detail="No channel access request found")
277 existing = existing_result.data[0]
278 status_field = f"{platform}_status"
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 )
286 service_client.table("channel_access_requests").update(
287 {status_field: "none", "updated_at": datetime.utcnow().isoformat()}
288 ).eq("id", existing["id"]).execute()
290 return {"success": True, "message": f"{platform.capitalize()} request withdrawn"}
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
299# ============================================================
300# Admin endpoints
301# ============================================================
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")
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)
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 )
334 if team_id:
335 query = query.eq("team_id", team_id)
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")
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 []
362 result = query.execute()
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)
374 return rows
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
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)
388 try:
389 all_requests = (
390 service_client.table("channel_access_requests")
391 .select("telegram_status, discord_status")
392 .execute()
393 )
395 stats = {
396 "total": 0,
397 "pending_telegram": 0,
398 "pending_discord": 0,
399 "pending_total": 0,
400 "approved": 0,
401 "denied": 0,
402 }
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)
422 return stats
424 except Exception:
425 logger.exception("Error getting channel request stats")
426 raise HTTPException(status_code=500, detail="Failed to retrieve statistics") from None
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)
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 )
446 if not result.data:
447 raise HTTPException(status_code=404, detail="Channel access request not found")
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
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
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).
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)
477 try:
478 existing = service_client.table("channel_access_requests").select("*").eq("id", request_id).execute()
480 if not existing.data:
481 raise HTTPException(status_code=404, detail="Channel access request not found")
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
487 update_data = {
488 f"{platform}_status": status_update.status,
489 f"{platform}_reviewed_by": reviewer_id,
490 f"{platform}_reviewed_at": now,
491 }
493 if status_update.admin_notes:
494 update_data["admin_notes"] = status_update.admin_notes
496 result = (
497 service_client.table("channel_access_requests")
498 .update(update_data)
499 .eq("id", request_id)
500 .execute()
501 )
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")
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
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")
525 try:
526 existing = service_client.table("channel_access_requests").select("id").eq("id", request_id).execute()
528 if not existing.data:
529 raise HTTPException(status_code=404, detail="Channel access request not found")
531 service_client.table("channel_access_requests").delete().eq("id", request_id).execute()
533 return {"success": True, "message": "Channel access request deleted"}
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