Coverage for api/invite_requests.py: 31.07%

141 statements  

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

1""" 

2Invite Request API endpoints for Missing Table 

3 

4Handles public invite request submissions and admin management. 

5""" 

6 

7import os 

8import sys 

9from datetime import datetime 

10 

11import structlog 

12from fastapi import APIRouter, Depends, HTTPException, Query 

13from pydantic import BaseModel, EmailStr, Field 

14 

15from supabase import create_client 

16 

17logger = structlog.get_logger(__name__) 

18 

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

20 

21from auth import get_current_user_required 

22from dao.match_dao import SupabaseConnection as DbConnectionHolder 

23 

24# Initialize database connection with service role for admin operations 

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

26service_key = os.getenv("SUPABASE_SERVICE_KEY") 

27 

28# Create service role client for operations that bypass RLS 

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

30 service_client = create_client(supabase_url, service_key) 

31else: 

32 # Fallback to regular connection if service key not available 

33 db_conn_holder_obj = DbConnectionHolder() 

34 service_client = db_conn_holder_obj.client 

35 

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

37 

38 

39# Pydantic models 

40class InviteRequestCreate(BaseModel): 

41 """Model for creating a new invite request""" 

42 

43 email: EmailStr = Field(..., description="Email address of the requester") 

44 name: str = Field(..., min_length=1, max_length=255, description="Name of the requester") 

45 team: str | None = Field(None, max_length=255, description="Team or club affiliation") 

46 reason: str | None = Field(None, description="Reason for wanting to join") 

47 website: str | None = Field(None, description="Honeypot field - should be empty") 

48 

49 

50class InviteRequestResponse(BaseModel): 

51 """Model for invite request response""" 

52 

53 id: str 

54 email: str 

55 name: str 

56 team: str | None 

57 reason: str | None 

58 status: str 

59 created_at: datetime 

60 updated_at: datetime 

61 reviewed_by: str | None 

62 reviewed_at: datetime | None 

63 admin_notes: str | None 

64 

65 

66class InviteRequestStatusUpdate(BaseModel): 

67 """Model for updating invite request status""" 

68 

69 status: str = Field(..., pattern="^(pending|approved|rejected)$") 

70 admin_notes: str | None = Field(None, description="Admin notes about the decision") 

71 

72 

73class InviteRequestStats(BaseModel): 

74 """Statistics about invite requests""" 

75 

76 total: int 

77 pending: int 

78 approved: int 

79 rejected: int 

80 

81 

82# Public endpoint - no auth required 

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

84async def create_invite_request(request: InviteRequestCreate): 

85 """ 

86 Submit a new invite request (public endpoint). 

87 

88 Anyone can submit an invite request without authentication. 

89 Duplicate email submissions are allowed (users can re-submit). 

90 """ 

91 try: 

92 # Honeypot check - if filled, it's a bot 

93 if request.website: 

94 # Return fake success to not alert the bot 

95 return { 

96 "success": True, 

97 "message": "Thank you for your interest! We'll review your request and reach out soon.", 

98 } 

99 

100 # Check for existing pending request with same email 

101 existing = ( 

102 service_client.table("invite_requests") 

103 .select("id, status") 

104 .eq("email", request.email) 

105 .eq("status", "pending") 

106 .execute() 

107 ) 

108 

109 if existing.data: 

110 # Return success anyway - we don't want to reveal if email exists 

111 return { 

112 "success": True, 

113 "message": "Thank you for your interest! We'll review your request and reach out soon.", 

114 } 

115 

116 # Insert new invite request 

117 result = ( 

118 service_client.table("invite_requests") 

119 .insert( 

120 { 

121 "email": request.email, 

122 "name": request.name, 

123 "team": request.team, 

124 "reason": request.reason, 

125 "status": "pending", 

126 } 

127 ) 

128 .execute() 

129 ) 

130 

131 if result.data: 

132 return { 

133 "success": True, 

134 "message": "Thank you for your interest! We'll review your request and reach out soon.", 

135 } 

136 else: 

137 raise HTTPException(status_code=500, detail="Failed to submit invite request") 

138 

139 except HTTPException: 

140 raise 

141 except Exception: 

142 logger.exception("Error creating invite request") 

143 raise HTTPException(status_code=500, detail="An error occurred while submitting your request") from None 

144 

145 

146# Admin endpoints 

147@router.get("", response_model=list[InviteRequestResponse]) 

148async def list_invite_requests( 

149 current_user=Depends(get_current_user_required), 

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

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

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

153): 

154 """ 

155 List all invite requests (admin only). 

156 

157 Supports filtering by status and pagination. 

158 """ 

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

160 raise HTTPException(status_code=403, detail="Only admins can view invite requests") 

161 

162 try: 

163 query = ( 

164 service_client.table("invite_requests") 

165 .select("*") 

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

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

168 ) 

169 

170 if status: 

171 query = query.eq("status", status) 

172 

173 result = query.execute() 

174 

175 return result.data or [] 

176 

177 except Exception: 

178 logger.exception("Error listing invite requests") 

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

180 

181 

182@router.get("/stats", response_model=InviteRequestStats) 

183async def get_invite_request_stats(current_user=Depends(get_current_user_required)): 

184 """ 

185 Get statistics about invite requests (admin only). 

186 """ 

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

188 raise HTTPException(status_code=403, detail="Only admins can view invite request stats") 

189 

190 try: 

191 # Get counts by status 

192 all_requests = service_client.table("invite_requests").select("status").execute() 

193 

194 stats = {"total": 0, "pending": 0, "approved": 0, "rejected": 0} 

195 

196 if all_requests.data: 

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

198 for req in all_requests.data: 

199 status = req.get("status", "pending") 

200 if status in stats: 

201 stats[status] += 1 

202 

203 return stats 

204 

205 except Exception: 

206 logger.exception("Error getting invite request stats") 

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

208 

209 

210@router.get("/{request_id}", response_model=InviteRequestResponse) 

211async def get_invite_request(request_id: str, current_user=Depends(get_current_user_required)): 

212 """ 

213 Get a specific invite request by ID (admin only). 

214 """ 

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

216 raise HTTPException(status_code=403, detail="Only admins can view invite requests") 

217 

218 try: 

219 result = service_client.table("invite_requests").select("*").eq("id", request_id).execute() 

220 

221 if not result.data: 

222 raise HTTPException(status_code=404, detail="Invite request not found") 

223 

224 return result.data[0] 

225 

226 except HTTPException: 

227 raise 

228 except Exception: 

229 logger.exception("Error getting invite request") 

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

231 

232 

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

234async def update_invite_request_status( 

235 request_id: str, 

236 status_update: InviteRequestStatusUpdate, 

237 current_user=Depends(get_current_user_required), 

238): 

239 """ 

240 Update the status of an invite request (admin only). 

241 

242 Used to approve or reject invite requests. 

243 """ 

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

245 raise HTTPException(status_code=403, detail="Only admins can update invite requests") 

246 

247 try: 

248 # Get current request 

249 existing = service_client.table("invite_requests").select("*").eq("id", request_id).execute() 

250 

251 if not existing.data: 

252 raise HTTPException(status_code=404, detail="Invite request not found") 

253 

254 # Get user ID 

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

256 

257 # Update the request 

258 update_data = { 

259 "status": status_update.status, 

260 "reviewed_by": user_id, 

261 "reviewed_at": datetime.utcnow().isoformat(), 

262 } 

263 

264 if status_update.admin_notes: 

265 update_data["admin_notes"] = status_update.admin_notes 

266 

267 result = service_client.table("invite_requests").update(update_data).eq("id", request_id).execute() 

268 

269 if result.data: 

270 return { 

271 "success": True, 

272 "message": f"Invite request {status_update.status}", 

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

274 } 

275 else: 

276 raise HTTPException(status_code=500, detail="Failed to update invite request") 

277 

278 except HTTPException: 

279 raise 

280 except Exception: 

281 logger.exception("Error updating invite request") 

282 raise HTTPException(status_code=500, detail="Failed to update invite request") from None 

283 

284 

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

286async def delete_invite_request(request_id: str, current_user=Depends(get_current_user_required)): 

287 """ 

288 Delete an invite request (admin only). 

289 

290 Use with caution - this permanently removes the request. 

291 """ 

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

293 raise HTTPException(status_code=403, detail="Only admins can delete invite requests") 

294 

295 try: 

296 # Check if request exists 

297 existing = service_client.table("invite_requests").select("id").eq("id", request_id).execute() 

298 

299 if not existing.data: 

300 raise HTTPException(status_code=404, detail="Invite request not found") 

301 

302 # Delete the request 

303 service_client.table("invite_requests").delete().eq("id", request_id).execute() 

304 

305 return {"success": True, "message": "Invite request deleted successfully"} 

306 

307 except HTTPException: 

308 raise 

309 except Exception: 

310 logger.exception("Error deleting invite request") 

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