Coverage for src/alprina_cli/api/routes/badge.py: 19%

166 statements  

« prev     ^ index     » next       coverage.py v7.11.3, created at 2025-11-14 11:27 +0100

1""" 

2Badge API routes for Alprina security badge system. 

3Handles badge configuration, generation, and verification. 

4""" 

5 

6from fastapi import APIRouter, HTTPException, Header, Response, Request 

7from pydantic import BaseModel 

8from typing import Optional, Literal 

9from datetime import datetime, timedelta 

10import logging 

11 

12from ..services.neon_service import neon_service 

13from ...services.badge_generator import BadgeGenerator 

14 

15logger = logging.getLogger(__name__) 

16 

17router = APIRouter() 

18badge_generator = BadgeGenerator() 

19 

20 

21# Pydantic models 

22class BadgeConfig(BaseModel): 

23 enabled: bool = False 

24 style: Literal["standard", "minimal", "detailed"] = "standard" 

25 theme: Literal["light", "dark"] = "light" 

26 size: Literal["small", "medium", "large"] = "medium" 

27 custom_text: Optional[str] = None 

28 show_grade: bool = True 

29 show_date: bool = False 

30 

31 

32class BadgeConfigResponse(BaseModel): 

33 id: str 

34 user_id: str 

35 enabled: bool 

36 style: str 

37 theme: str 

38 size: str 

39 custom_text: Optional[str] 

40 show_grade: bool 

41 show_date: bool 

42 embed_code_iframe: str 

43 embed_code_static: str 

44 verification_url: str 

45 created_at: datetime 

46 updated_at: datetime 

47 

48 

49class BadgeAnalytics(BaseModel): 

50 total_impressions: int 

51 total_clicks: int 

52 total_verifications: int 

53 impressions_today: int 

54 clicks_today: int 

55 verifications_today: int 

56 

57 

58class VerificationData(BaseModel): 

59 user_id: str 

60 company_name: Optional[str] 

61 security_grade: str 

62 last_scan_date: Optional[datetime] 

63 total_scans: int 

64 status: str 

65 verified: bool 

66 

67 

68# Badge configuration endpoints 

69@router.get("/config") 

70async def get_badge_config( 

71 authorization: str = Header(None) 

72) -> BadgeConfigResponse: 

73 """Get user's badge configuration.""" 

74 if not authorization or not authorization.startswith("Bearer "): 

75 raise HTTPException(status_code=401, detail="Missing or invalid authorization header") 

76 

77 user_id = authorization.replace("Bearer ", "") 

78 

79 try: 

80 # Get or create badge config 

81 query = """ 

82 SELECT 

83 id, user_id, enabled, style, theme, size, 

84 custom_text, show_grade, show_date, 

85 created_at, updated_at 

86 FROM badge_configs 

87 WHERE user_id = $1 

88 """ 

89 result = await neon_service.execute(query, user_id) 

90 

91 if not result: 

92 # Create default config 

93 insert_query = """ 

94 INSERT INTO badge_configs (user_id, enabled, style, theme, size) 

95 VALUES ($1, false, 'standard', 'light', 'medium') 

96 RETURNING id, user_id, enabled, style, theme, size, 

97 custom_text, show_grade, show_date, 

98 created_at, updated_at 

99 """ 

100 result = await neon_service.execute(insert_query, user_id) 

101 

102 config = result[0] 

103 

104 # Generate embed codes 

105 base_url = "https://alprina.com" # TODO: Use environment variable 

106 verification_url = f"{base_url}/verify/{user_id}" 

107 

108 iframe_code = f'<iframe src="{base_url}/badge/{user_id}" width="200" height="80" frameborder="0" style="border: none;"></iframe>' 

109 static_code = f'<a href="{verification_url}" target="_blank"><img src="{base_url}/api/v1/badge/{user_id}/svg" alt="Secured by Alprina" /></a>' 

110 

111 return BadgeConfigResponse( 

112 id=str(config['id']), 

113 user_id=str(config['user_id']), 

114 enabled=config['enabled'], 

115 style=config['style'], 

116 theme=config['theme'], 

117 size=config['size'], 

118 custom_text=config['custom_text'], 

119 show_grade=config['show_grade'], 

120 show_date=config['show_date'], 

121 embed_code_iframe=iframe_code, 

122 embed_code_static=static_code, 

123 verification_url=verification_url, 

124 created_at=config['created_at'], 

125 updated_at=config['updated_at'] 

126 ) 

127 

128 except Exception as e: 

129 logger.error(f"Error getting badge config: {str(e)}") 

130 raise HTTPException(status_code=500, detail="Failed to get badge configuration") 

131 

132 

133@router.put("/config") 

134async def update_badge_config( 

135 config: BadgeConfig, 

136 authorization: str = Header(None) 

137) -> dict: 

138 """Update user's badge configuration.""" 

139 if not authorization or not authorization.startswith("Bearer "): 

140 raise HTTPException(status_code=401, detail="Missing or invalid authorization header") 

141 

142 user_id = authorization.replace("Bearer ", "") 

143 

144 try: 

145 # Upsert badge config 

146 query = """ 

147 INSERT INTO badge_configs ( 

148 user_id, enabled, style, theme, size, 

149 custom_text, show_grade, show_date 

150 ) 

151 VALUES ($1, $2, $3, $4, $5, $6, $7, $8) 

152 ON CONFLICT (user_id) 

153 DO UPDATE SET 

154 enabled = EXCLUDED.enabled, 

155 style = EXCLUDED.style, 

156 theme = EXCLUDED.theme, 

157 size = EXCLUDED.size, 

158 custom_text = EXCLUDED.custom_text, 

159 show_grade = EXCLUDED.show_grade, 

160 show_date = EXCLUDED.show_date, 

161 updated_at = NOW() 

162 RETURNING id 

163 """ 

164 

165 result = await neon_service.execute( 

166 query, 

167 user_id, 

168 config.enabled, 

169 config.style, 

170 config.theme, 

171 config.size, 

172 config.custom_text, 

173 config.show_grade, 

174 config.show_date 

175 ) 

176 

177 return { 

178 "success": True, 

179 "message": "Badge configuration updated successfully", 

180 "config_id": str(result[0]['id']) 

181 } 

182 

183 except Exception as e: 

184 logger.error(f"Error updating badge config: {str(e)}") 

185 raise HTTPException(status_code=500, detail="Failed to update badge configuration") 

186 

187 

188# Badge generation endpoint 

189@router.get("/{user_id}/svg") 

190async def generate_badge_svg( 

191 user_id: str, 

192 style: Optional[str] = "standard", 

193 theme: Optional[str] = "light", 

194 size: Optional[str] = "medium" 

195) -> Response: 

196 """Generate SVG badge for a user.""" 

197 try: 

198 # Get user's badge config 

199 config_query = """ 

200 SELECT enabled, style, theme, size, custom_text, show_grade, show_date 

201 FROM badge_configs 

202 WHERE user_id = $1 

203 """ 

204 config_result = await neon_service.execute(config_query, user_id) 

205 

206 # Check if badge is enabled 

207 if not config_result or not config_result[0]['enabled']: 

208 raise HTTPException(status_code=404, detail="Badge not enabled for this user") 

209 

210 config = config_result[0] 

211 

212 # Get user's latest security data 

213 scan_query = """ 

214 SELECT 

215 security_score, 

216 scan_date, 

217 critical_count, 

218 high_count, 

219 medium_count, 

220 low_count 

221 FROM scans 

222 WHERE user_id = $1 

223 ORDER BY scan_date DESC 

224 LIMIT 1 

225 """ 

226 scan_result = await neon_service.execute(scan_query, user_id) 

227 

228 # Calculate grade 

229 if scan_result: 

230 scan = scan_result[0] 

231 score = scan.get('security_score', 0) 

232 

233 if score >= 90: 

234 grade = "A+" 

235 elif score >= 85: 

236 grade = "A" 

237 elif score >= 80: 

238 grade = "B+" 

239 elif score >= 75: 

240 grade = "B" 

241 elif score >= 70: 

242 grade = "C+" 

243 else: 

244 grade = "C" 

245 

246 last_scan = scan.get('scan_date') 

247 else: 

248 grade = "N/A" 

249 last_scan = None 

250 

251 # Use query params or config defaults 

252 final_style = style or config['style'] 

253 final_theme = theme or config['theme'] 

254 final_size = size or config['size'] 

255 

256 # Generate SVG 

257 svg_content = badge_generator.generate_svg( 

258 style=final_style, 

259 theme=final_theme, 

260 size=final_size, 

261 grade=grade if config['show_grade'] else None, 

262 last_scan=last_scan if config['show_date'] else None, 

263 custom_text=config['custom_text'] 

264 ) 

265 

266 # Track impression (async, don't block) 

267 try: 

268 track_query = """ 

269 INSERT INTO badge_analytics (badge_config_id, event_type) 

270 SELECT id, 'impression' 

271 FROM badge_configs 

272 WHERE user_id = $1 

273 """ 

274 await neon_service.execute(track_query, user_id) 

275 except Exception as e: 

276 logger.warning(f"Failed to track impression: {str(e)}") 

277 

278 return Response( 

279 content=svg_content, 

280 media_type="image/svg+xml", 

281 headers={ 

282 "Cache-Control": "public, max-age=300", # 5 minutes 

283 "X-Content-Type-Options": "nosniff" 

284 } 

285 ) 

286 

287 except HTTPException: 

288 raise 

289 except Exception as e: 

290 logger.error(f"Error generating badge SVG: {str(e)}") 

291 raise HTTPException(status_code=500, detail="Failed to generate badge") 

292 

293 

294# Verification data endpoint 

295@router.get("/{user_id}/verify") 

296async def get_verification_data( 

297 user_id: str, 

298 request: Request 

299) -> VerificationData: 

300 """Get verification data for badge verification page.""" 

301 try: 

302 # Get user info 

303 user_query = """ 

304 SELECT email, full_name, tier, created_at 

305 FROM users 

306 WHERE id = $1 

307 """ 

308 user_result = await neon_service.execute(user_query, user_id) 

309 

310 if not user_result: 

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

312 

313 user = user_result[0] 

314 

315 # Get latest scan data 

316 scan_query = """ 

317 SELECT 

318 security_score, 

319 scan_date, 

320 critical_count, 

321 high_count, 

322 medium_count, 

323 low_count 

324 FROM scans 

325 WHERE user_id = $1 

326 ORDER BY scan_date DESC 

327 LIMIT 1 

328 """ 

329 scan_result = await neon_service.execute(scan_query, user_id) 

330 

331 # Get total scan count 

332 count_query = """ 

333 SELECT COUNT(*) as total 

334 FROM scans 

335 WHERE user_id = $1 

336 """ 

337 count_result = await neon_service.execute(count_query, user_id) 

338 total_scans = count_result[0]['total'] if count_result else 0 

339 

340 # Determine status 

341 if scan_result: 

342 scan = scan_result[0] 

343 score = scan.get('security_score', 0) 

344 critical = scan.get('critical_count', 0) 

345 high = scan.get('high_count', 0) 

346 

347 if score >= 90: 

348 grade = "A+" 

349 elif score >= 85: 

350 grade = "A" 

351 elif score >= 80: 

352 grade = "B+" 

353 elif score >= 75: 

354 grade = "B" 

355 elif score >= 70: 

356 grade = "C+" 

357 else: 

358 grade = "C" 

359 

360 if critical == 0 and high == 0: 

361 status = "Excellent" 

362 elif critical == 0: 

363 status = "Good" 

364 else: 

365 status = "Needs Attention" 

366 

367 last_scan = scan.get('scan_date') 

368 else: 

369 grade = "N/A" 

370 status = "No Scans" 

371 last_scan = None 

372 

373 # Track verification view 

374 try: 

375 track_query = """ 

376 INSERT INTO badge_verifications ( 

377 user_id, ip_address, user_agent, referrer 

378 ) 

379 VALUES ($1, $2, $3, $4) 

380 """ 

381 client_ip = request.client.host if request.client else None 

382 user_agent = request.headers.get("user-agent") 

383 referrer = request.headers.get("referer") 

384 

385 await neon_service.execute( 

386 track_query, 

387 user_id, client_ip, user_agent, referrer 

388 ) 

389 except Exception as e: 

390 logger.warning(f"Failed to track verification: {str(e)}") 

391 

392 # Extract company name from email or use full name 

393 company_name = user.get('full_name') or user.get('email', '').split('@')[0] 

394 

395 return VerificationData( 

396 user_id=user_id, 

397 company_name=company_name, 

398 security_grade=grade, 

399 last_scan_date=last_scan, 

400 total_scans=total_scans, 

401 status=status, 

402 verified=total_scans > 0 

403 ) 

404 

405 except HTTPException: 

406 raise 

407 except Exception as e: 

408 logger.error(f"Error getting verification data: {str(e)}") 

409 raise HTTPException(status_code=500, detail="Failed to get verification data") 

410 

411 

412# Analytics endpoint 

413@router.get("/analytics") 

414async def get_badge_analytics( 

415 authorization: str = Header(None) 

416) -> BadgeAnalytics: 

417 """Get badge analytics for the authenticated user.""" 

418 if not authorization or not authorization.startswith("Bearer "): 

419 raise HTTPException(status_code=401, detail="Missing or invalid authorization header") 

420 

421 user_id = authorization.replace("Bearer ", "") 

422 

423 try: 

424 query = """ 

425 SELECT 

426 COUNT(CASE WHEN event_type = 'impression' THEN 1 END) as total_impressions, 

427 COUNT(CASE WHEN event_type = 'click' THEN 1 END) as total_clicks, 

428 COUNT(CASE WHEN event_type = 'impression' AND created_at >= NOW() - INTERVAL '1 day' THEN 1 END) as impressions_today, 

429 COUNT(CASE WHEN event_type = 'click' AND created_at >= NOW() - INTERVAL '1 day' THEN 1 END) as clicks_today 

430 FROM badge_analytics ba 

431 JOIN badge_configs bc ON ba.badge_config_id = bc.id 

432 WHERE bc.user_id = $1 

433 """ 

434 

435 result = await neon_service.execute(query, user_id) 

436 

437 if not result: 

438 return BadgeAnalytics( 

439 total_impressions=0, 

440 total_clicks=0, 

441 total_verifications=0, 

442 impressions_today=0, 

443 clicks_today=0, 

444 verifications_today=0 

445 ) 

446 

447 analytics = result[0] 

448 

449 # Get verification counts 

450 verify_query = """ 

451 SELECT 

452 COUNT(*) as total_verifications, 

453 COUNT(CASE WHEN verified_at >= NOW() - INTERVAL '1 day' THEN 1 END) as verifications_today 

454 FROM badge_verifications 

455 WHERE user_id = $1 

456 """ 

457 

458 verify_result = await neon_service.execute(verify_query, user_id) 

459 verify_data = verify_result[0] if verify_result else {} 

460 

461 return BadgeAnalytics( 

462 total_impressions=analytics.get('total_impressions', 0), 

463 total_clicks=analytics.get('total_clicks', 0), 

464 total_verifications=verify_data.get('total_verifications', 0), 

465 impressions_today=analytics.get('impressions_today', 0), 

466 clicks_today=analytics.get('clicks_today', 0), 

467 verifications_today=verify_data.get('verifications_today', 0) 

468 ) 

469 

470 except Exception as e: 

471 logger.error(f"Error getting badge analytics: {str(e)}") 

472 raise HTTPException(status_code=500, detail="Failed to get badge analytics")