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
« 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"""
6from fastapi import APIRouter, HTTPException, Header, Response, Request
7from pydantic import BaseModel
8from typing import Optional, Literal
9from datetime import datetime, timedelta
10import logging
12from ..services.neon_service import neon_service
13from ...services.badge_generator import BadgeGenerator
15logger = logging.getLogger(__name__)
17router = APIRouter()
18badge_generator = BadgeGenerator()
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
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
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
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
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")
77 user_id = authorization.replace("Bearer ", "")
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)
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)
102 config = result[0]
104 # Generate embed codes
105 base_url = "https://alprina.com" # TODO: Use environment variable
106 verification_url = f"{base_url}/verify/{user_id}"
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>'
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 )
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")
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")
142 user_id = authorization.replace("Bearer ", "")
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 """
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 )
177 return {
178 "success": True,
179 "message": "Badge configuration updated successfully",
180 "config_id": str(result[0]['id'])
181 }
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")
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)
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")
210 config = config_result[0]
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)
228 # Calculate grade
229 if scan_result:
230 scan = scan_result[0]
231 score = scan.get('security_score', 0)
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"
246 last_scan = scan.get('scan_date')
247 else:
248 grade = "N/A"
249 last_scan = None
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']
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 )
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)}")
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 )
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")
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)
310 if not user_result:
311 raise HTTPException(status_code=404, detail="User not found")
313 user = user_result[0]
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)
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
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)
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"
360 if critical == 0 and high == 0:
361 status = "Excellent"
362 elif critical == 0:
363 status = "Good"
364 else:
365 status = "Needs Attention"
367 last_scan = scan.get('scan_date')
368 else:
369 grade = "N/A"
370 status = "No Scans"
371 last_scan = None
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")
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)}")
392 # Extract company name from email or use full name
393 company_name = user.get('full_name') or user.get('email', '').split('@')[0]
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 )
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")
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")
421 user_id = authorization.replace("Bearer ", "")
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 """
435 result = await neon_service.execute(query, user_id)
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 )
447 analytics = result[0]
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 """
458 verify_result = await neon_service.execute(verify_query, user_id)
459 verify_data = verify_result[0] if verify_result else {}
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 )
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")