karaoke-gen 0.101.0__py3-none-any.whl → 0.105.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. backend/Dockerfile.base +1 -0
  2. backend/api/routes/admin.py +226 -3
  3. backend/api/routes/audio_search.py +4 -32
  4. backend/api/routes/file_upload.py +18 -83
  5. backend/api/routes/jobs.py +2 -2
  6. backend/api/routes/push.py +238 -0
  7. backend/api/routes/rate_limits.py +428 -0
  8. backend/api/routes/users.py +79 -19
  9. backend/config.py +25 -1
  10. backend/exceptions.py +66 -0
  11. backend/main.py +26 -1
  12. backend/models/job.py +4 -0
  13. backend/models/user.py +20 -2
  14. backend/services/email_validation_service.py +646 -0
  15. backend/services/firestore_service.py +21 -0
  16. backend/services/gce_encoding/main.py +22 -8
  17. backend/services/job_defaults_service.py +113 -0
  18. backend/services/job_manager.py +109 -13
  19. backend/services/push_notification_service.py +409 -0
  20. backend/services/rate_limit_service.py +641 -0
  21. backend/services/stripe_service.py +2 -2
  22. backend/tests/conftest.py +8 -1
  23. backend/tests/test_admin_delete_outputs.py +352 -0
  24. backend/tests/test_audio_search.py +12 -8
  25. backend/tests/test_email_validation_service.py +298 -0
  26. backend/tests/test_file_upload.py +8 -6
  27. backend/tests/test_gce_encoding_worker.py +229 -0
  28. backend/tests/test_impersonation.py +18 -3
  29. backend/tests/test_made_for_you.py +6 -4
  30. backend/tests/test_push_notification_service.py +460 -0
  31. backend/tests/test_push_routes.py +357 -0
  32. backend/tests/test_rate_limit_service.py +396 -0
  33. backend/tests/test_rate_limits_api.py +392 -0
  34. backend/tests/test_stripe_service.py +205 -0
  35. backend/workers/video_worker_orchestrator.py +42 -0
  36. karaoke_gen/instrumental_review/static/index.html +35 -9
  37. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/METADATA +2 -1
  38. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/RECORD +41 -26
  39. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/WHEEL +0 -0
  40. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/entry_points.txt +0 -0
  41. {karaoke_gen-0.101.0.dist-info → karaoke_gen-0.105.4.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,238 @@
1
+ """
2
+ Push Notification API routes.
3
+
4
+ Provides endpoints for managing Web Push notification subscriptions:
5
+ - GET /api/push/vapid-public-key: Get VAPID public key for client-side subscription
6
+ - POST /api/push/subscribe: Register a push subscription
7
+ - POST /api/push/unsubscribe: Remove a push subscription
8
+ - GET /api/push/subscriptions: List user's subscriptions
9
+ - POST /api/push/test: Send a test notification (admin only)
10
+ """
11
+ import logging
12
+ from typing import Optional, Dict, List
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+ from pydantic import BaseModel
16
+
17
+ from backend.config import get_settings
18
+ from backend.api.dependencies import require_auth, require_admin
19
+ from backend.services.auth_service import AuthResult
20
+ from backend.services.push_notification_service import get_push_notification_service
21
+
22
+
23
+ logger = logging.getLogger(__name__)
24
+ router = APIRouter(prefix="/push", tags=["push"])
25
+
26
+
27
+ # Request/Response Models
28
+
29
+ class VapidPublicKeyResponse(BaseModel):
30
+ """Response containing VAPID public key."""
31
+ enabled: bool
32
+ vapid_public_key: Optional[str] = None
33
+
34
+
35
+ class SubscribeRequest(BaseModel):
36
+ """Request to subscribe to push notifications."""
37
+ endpoint: str
38
+ keys: Dict[str, str] # p256dh and auth
39
+ device_name: Optional[str] = None
40
+
41
+
42
+ class SubscribeResponse(BaseModel):
43
+ """Response after subscribing."""
44
+ status: str
45
+ message: str
46
+
47
+
48
+ class UnsubscribeRequest(BaseModel):
49
+ """Request to unsubscribe from push notifications."""
50
+ endpoint: str
51
+
52
+
53
+ class UnsubscribeResponse(BaseModel):
54
+ """Response after unsubscribing."""
55
+ status: str
56
+ message: str
57
+
58
+
59
+ class SubscriptionInfo(BaseModel):
60
+ """Information about a push subscription."""
61
+ endpoint: str
62
+ device_name: Optional[str] = None
63
+ created_at: Optional[str] = None
64
+ last_used_at: Optional[str] = None
65
+
66
+
67
+ class SubscriptionsListResponse(BaseModel):
68
+ """Response containing user's subscriptions."""
69
+ subscriptions: List[SubscriptionInfo]
70
+ count: int
71
+
72
+
73
+ class TestNotificationRequest(BaseModel):
74
+ """Request to send a test notification."""
75
+ title: Optional[str] = "Test Notification"
76
+ body: Optional[str] = "This is a test push notification from Karaoke Generator"
77
+
78
+
79
+ class TestNotificationResponse(BaseModel):
80
+ """Response after sending test notification."""
81
+ status: str
82
+ sent_count: int
83
+ message: str
84
+
85
+
86
+ # Routes
87
+
88
+ @router.get("/vapid-public-key", response_model=VapidPublicKeyResponse)
89
+ async def get_vapid_public_key():
90
+ """
91
+ Get the VAPID public key for push subscription.
92
+
93
+ This endpoint is public - no authentication required.
94
+ Returns the public key needed for client-side PushManager.subscribe().
95
+ """
96
+ settings = get_settings()
97
+ push_service = get_push_notification_service()
98
+
99
+ if not settings.enable_push_notifications:
100
+ return VapidPublicKeyResponse(enabled=False)
101
+
102
+ public_key = push_service.get_public_key()
103
+ if not public_key:
104
+ return VapidPublicKeyResponse(enabled=False)
105
+
106
+ return VapidPublicKeyResponse(
107
+ enabled=True,
108
+ vapid_public_key=public_key
109
+ )
110
+
111
+
112
+ @router.post("/subscribe", response_model=SubscribeResponse)
113
+ async def subscribe_push(
114
+ request: SubscribeRequest,
115
+ auth_result: AuthResult = Depends(require_auth)
116
+ ):
117
+ """
118
+ Register a push notification subscription for the current user.
119
+
120
+ Requires authentication. Users can have up to 5 subscriptions
121
+ (configurable via MAX_PUSH_SUBSCRIPTIONS_PER_USER).
122
+ """
123
+ settings = get_settings()
124
+ if not settings.enable_push_notifications:
125
+ raise HTTPException(status_code=503, detail="Push notifications are not enabled")
126
+
127
+ if not auth_result.user_email:
128
+ raise HTTPException(status_code=401, detail="User email not available")
129
+
130
+ push_service = get_push_notification_service()
131
+
132
+ # Validate keys
133
+ if "p256dh" not in request.keys or "auth" not in request.keys:
134
+ raise HTTPException(status_code=400, detail="Missing required keys (p256dh, auth)")
135
+
136
+ success = await push_service.add_subscription(
137
+ user_email=auth_result.user_email,
138
+ endpoint=request.endpoint,
139
+ keys=request.keys,
140
+ device_name=request.device_name
141
+ )
142
+
143
+ if not success:
144
+ raise HTTPException(status_code=500, detail="Failed to save subscription")
145
+
146
+ return SubscribeResponse(
147
+ status="success",
148
+ message="Push subscription registered successfully"
149
+ )
150
+
151
+
152
+ @router.post("/unsubscribe", response_model=UnsubscribeResponse)
153
+ async def unsubscribe_push(
154
+ request: UnsubscribeRequest,
155
+ auth_result: AuthResult = Depends(require_auth)
156
+ ):
157
+ """
158
+ Remove a push notification subscription.
159
+
160
+ Requires authentication. Users can only remove their own subscriptions.
161
+ """
162
+ if not auth_result.user_email:
163
+ raise HTTPException(status_code=401, detail="User email not available")
164
+
165
+ push_service = get_push_notification_service()
166
+
167
+ success = await push_service.remove_subscription(
168
+ user_email=auth_result.user_email,
169
+ endpoint=request.endpoint
170
+ )
171
+
172
+ if not success:
173
+ # Don't error if subscription wasn't found - might already be removed
174
+ return UnsubscribeResponse(
175
+ status="success",
176
+ message="Subscription removed (or was not found)"
177
+ )
178
+
179
+ return UnsubscribeResponse(
180
+ status="success",
181
+ message="Push subscription removed successfully"
182
+ )
183
+
184
+
185
+ @router.get("/subscriptions", response_model=SubscriptionsListResponse)
186
+ async def list_subscriptions(
187
+ auth_result: AuthResult = Depends(require_auth)
188
+ ):
189
+ """
190
+ List all push notification subscriptions for the current user.
191
+
192
+ Requires authentication.
193
+ """
194
+ if not auth_result.user_email:
195
+ raise HTTPException(status_code=401, detail="User email not available")
196
+
197
+ push_service = get_push_notification_service()
198
+
199
+ subscriptions = await push_service.list_subscriptions(auth_result.user_email)
200
+
201
+ return SubscriptionsListResponse(
202
+ subscriptions=[SubscriptionInfo(**s) for s in subscriptions],
203
+ count=len(subscriptions)
204
+ )
205
+
206
+
207
+ @router.post("/test", response_model=TestNotificationResponse)
208
+ async def send_test_notification(
209
+ request: TestNotificationRequest,
210
+ auth_result: AuthResult = Depends(require_admin)
211
+ ):
212
+ """
213
+ Send a test push notification to the current user's devices.
214
+
215
+ Admin only. Useful for testing push notification setup.
216
+ """
217
+ settings = get_settings()
218
+ if not settings.enable_push_notifications:
219
+ raise HTTPException(status_code=503, detail="Push notifications are not enabled")
220
+
221
+ if not auth_result.user_email:
222
+ raise HTTPException(status_code=401, detail="User email not available")
223
+
224
+ push_service = get_push_notification_service()
225
+
226
+ sent_count = await push_service.send_push(
227
+ user_email=auth_result.user_email,
228
+ title=request.title or "Test Notification",
229
+ body=request.body or "This is a test push notification",
230
+ url="/app/",
231
+ tag="test"
232
+ )
233
+
234
+ return TestNotificationResponse(
235
+ status="success",
236
+ sent_count=sent_count,
237
+ message=f"Test notification sent to {sent_count} device(s)"
238
+ )
@@ -0,0 +1,428 @@
1
+ """
2
+ Admin API routes for rate limit management.
3
+
4
+ Handles:
5
+ - Rate limit statistics and monitoring
6
+ - Blocklist management (disposable domains, blocked emails, blocked IPs)
7
+ - User override management (whitelist/bypass permissions)
8
+ """
9
+
10
+ import logging
11
+ from datetime import datetime, timezone
12
+ from typing import List, Optional
13
+
14
+ from fastapi import APIRouter, Depends, HTTPException
15
+ from pydantic import BaseModel, EmailStr
16
+
17
+ from backend.api.dependencies import require_admin
18
+ from backend.services.auth_service import AuthResult
19
+ from backend.services.rate_limit_service import get_rate_limit_service, RateLimitService
20
+ from backend.services.email_validation_service import get_email_validation_service, EmailValidationService
21
+ from backend.config import settings
22
+
23
+ logger = logging.getLogger(__name__)
24
+ router = APIRouter(prefix="/admin/rate-limits", tags=["admin", "rate-limits"])
25
+
26
+
27
+ # =============================================================================
28
+ # Response Models
29
+ # =============================================================================
30
+
31
+ class RateLimitStatsResponse(BaseModel):
32
+ """Current rate limit statistics."""
33
+ # Configuration
34
+ jobs_per_day_limit: int
35
+ youtube_uploads_per_day_limit: int
36
+ beta_ip_per_day_limit: int
37
+ rate_limiting_enabled: bool
38
+
39
+ # Current usage
40
+ youtube_uploads_today: int
41
+ youtube_uploads_remaining: int
42
+
43
+ # Blocklist stats
44
+ disposable_domains_count: int
45
+ blocked_emails_count: int
46
+ blocked_ips_count: int
47
+
48
+ # User override stats
49
+ total_overrides: int
50
+
51
+
52
+ class UserRateLimitStatusResponse(BaseModel):
53
+ """Rate limit status for a specific user."""
54
+ email: str
55
+ jobs_today: int
56
+ jobs_limit: int
57
+ jobs_remaining: int
58
+ has_bypass: bool
59
+ custom_limit: Optional[int]
60
+ bypass_reason: Optional[str]
61
+
62
+
63
+ class BlocklistsResponse(BaseModel):
64
+ """All blocklist data."""
65
+ disposable_domains: List[str]
66
+ blocked_emails: List[str]
67
+ blocked_ips: List[str]
68
+ updated_at: Optional[datetime]
69
+ updated_by: Optional[str]
70
+
71
+
72
+ class DomainRequest(BaseModel):
73
+ """Request to add/remove a domain."""
74
+ domain: str
75
+
76
+
77
+ class EmailRequest(BaseModel):
78
+ """Request to add/remove an email."""
79
+ email: str
80
+
81
+
82
+ class IPRequest(BaseModel):
83
+ """Request to add/remove an IP."""
84
+ ip_address: str
85
+
86
+
87
+ class UserOverride(BaseModel):
88
+ """User override configuration."""
89
+ email: str
90
+ bypass_job_limit: bool
91
+ custom_daily_job_limit: Optional[int]
92
+ reason: str
93
+ created_by: str
94
+ created_at: datetime
95
+
96
+
97
+ class UserOverrideRequest(BaseModel):
98
+ """Request to set/update a user override."""
99
+ bypass_job_limit: bool = False
100
+ custom_daily_job_limit: Optional[int] = None
101
+ reason: str
102
+
103
+
104
+ class UserOverridesListResponse(BaseModel):
105
+ """List of all user overrides."""
106
+ overrides: List[UserOverride]
107
+ total: int
108
+
109
+
110
+ class SuccessResponse(BaseModel):
111
+ """Generic success response."""
112
+ success: bool
113
+ message: str
114
+
115
+
116
+ # =============================================================================
117
+ # Statistics Endpoints
118
+ # =============================================================================
119
+
120
+ @router.get("/stats", response_model=RateLimitStatsResponse)
121
+ async def get_rate_limit_stats(
122
+ auth_result: AuthResult = Depends(require_admin),
123
+ ):
124
+ """
125
+ Get current rate limit statistics.
126
+
127
+ Returns configuration values, current usage counts, and blocklist stats.
128
+ """
129
+ rate_limit_service = get_rate_limit_service()
130
+ email_validation = get_email_validation_service()
131
+
132
+ # Get YouTube uploads today
133
+ youtube_today = rate_limit_service.get_youtube_uploads_today()
134
+ youtube_limit = settings.rate_limit_youtube_uploads_per_day
135
+ youtube_remaining = max(0, youtube_limit - youtube_today) if youtube_limit > 0 else -1
136
+
137
+ # Get blocklist stats
138
+ blocklist_stats = email_validation.get_blocklist_stats()
139
+
140
+ # Get override count
141
+ overrides = rate_limit_service.get_all_overrides()
142
+
143
+ return RateLimitStatsResponse(
144
+ jobs_per_day_limit=settings.rate_limit_jobs_per_day,
145
+ youtube_uploads_per_day_limit=settings.rate_limit_youtube_uploads_per_day,
146
+ beta_ip_per_day_limit=settings.rate_limit_beta_ip_per_day,
147
+ rate_limiting_enabled=settings.enable_rate_limiting,
148
+ youtube_uploads_today=youtube_today,
149
+ youtube_uploads_remaining=youtube_remaining,
150
+ disposable_domains_count=blocklist_stats["disposable_domains_count"],
151
+ blocked_emails_count=blocklist_stats["blocked_emails_count"],
152
+ blocked_ips_count=blocklist_stats["blocked_ips_count"],
153
+ total_overrides=len(overrides),
154
+ )
155
+
156
+
157
+ @router.get("/users/{email}", response_model=UserRateLimitStatusResponse)
158
+ async def get_user_rate_limit_status(
159
+ email: str,
160
+ auth_result: AuthResult = Depends(require_admin),
161
+ ):
162
+ """
163
+ Get rate limit status for a specific user.
164
+
165
+ Returns their current usage and any override settings.
166
+ """
167
+ rate_limit_service = get_rate_limit_service()
168
+
169
+ jobs_today = rate_limit_service.get_user_job_count_today(email)
170
+ override = rate_limit_service.get_user_override(email)
171
+
172
+ if override:
173
+ has_bypass = override.get("bypass_job_limit", False)
174
+ custom_limit = override.get("custom_daily_job_limit")
175
+ bypass_reason = override.get("reason")
176
+ jobs_limit = custom_limit if custom_limit is not None else settings.rate_limit_jobs_per_day
177
+ else:
178
+ has_bypass = False
179
+ custom_limit = None
180
+ bypass_reason = None
181
+ jobs_limit = settings.rate_limit_jobs_per_day
182
+
183
+ jobs_remaining = max(0, jobs_limit - jobs_today) if not has_bypass else -1
184
+
185
+ return UserRateLimitStatusResponse(
186
+ email=email,
187
+ jobs_today=jobs_today,
188
+ jobs_limit=jobs_limit,
189
+ jobs_remaining=jobs_remaining,
190
+ has_bypass=has_bypass,
191
+ custom_limit=custom_limit,
192
+ bypass_reason=bypass_reason,
193
+ )
194
+
195
+
196
+ # =============================================================================
197
+ # Blocklist Management Endpoints
198
+ # =============================================================================
199
+
200
+ @router.get("/blocklists", response_model=BlocklistsResponse)
201
+ async def get_blocklists(
202
+ auth_result: AuthResult = Depends(require_admin),
203
+ ):
204
+ """
205
+ Get all blocklist data.
206
+
207
+ Returns disposable domains, blocked emails, and blocked IPs.
208
+ """
209
+ email_validation = get_email_validation_service()
210
+
211
+ # Force refresh to get latest data
212
+ config = email_validation.get_blocklist_config(force_refresh=True)
213
+
214
+ # Get the raw document for metadata
215
+ from google.cloud import firestore
216
+ from backend.services.firestore_service import get_firestore_client
217
+ from backend.services.email_validation_service import BLOCKLISTS_COLLECTION, BLOCKLIST_CONFIG_DOC
218
+
219
+ db = get_firestore_client()
220
+ doc = db.collection(BLOCKLISTS_COLLECTION).document(BLOCKLIST_CONFIG_DOC).get()
221
+
222
+ updated_at = None
223
+ updated_by = None
224
+ if doc.exists:
225
+ data = doc.to_dict()
226
+ updated_at = data.get("updated_at")
227
+ updated_by = data.get("updated_by")
228
+
229
+ return BlocklistsResponse(
230
+ disposable_domains=sorted(list(config["disposable_domains"])),
231
+ blocked_emails=sorted(list(config["blocked_emails"])),
232
+ blocked_ips=sorted(list(config["blocked_ips"])),
233
+ updated_at=updated_at,
234
+ updated_by=updated_by,
235
+ )
236
+
237
+
238
+ @router.post("/blocklists/disposable-domains", response_model=SuccessResponse)
239
+ async def add_disposable_domain(
240
+ request: DomainRequest,
241
+ auth_result: AuthResult = Depends(require_admin),
242
+ ):
243
+ """Add a domain to the disposable domains blocklist."""
244
+ email_validation = get_email_validation_service()
245
+
246
+ domain = request.domain.lower().strip()
247
+ if not domain or "." not in domain:
248
+ raise HTTPException(status_code=400, detail="Invalid domain format")
249
+
250
+ email_validation.add_disposable_domain(domain, auth_result.user_email)
251
+
252
+ return SuccessResponse(
253
+ success=True,
254
+ message=f"Domain '{domain}' added to disposable domains blocklist"
255
+ )
256
+
257
+
258
+ @router.delete("/blocklists/disposable-domains/{domain}", response_model=SuccessResponse)
259
+ async def remove_disposable_domain(
260
+ domain: str,
261
+ auth_result: AuthResult = Depends(require_admin),
262
+ ):
263
+ """Remove a domain from the disposable domains blocklist."""
264
+ email_validation = get_email_validation_service()
265
+
266
+ domain = domain.lower().strip()
267
+ if not email_validation.remove_disposable_domain(domain, auth_result.user_email):
268
+ raise HTTPException(status_code=404, detail="Domain not found in blocklist")
269
+
270
+ return SuccessResponse(
271
+ success=True,
272
+ message=f"Domain '{domain}' removed from disposable domains blocklist"
273
+ )
274
+
275
+
276
+ @router.post("/blocklists/blocked-emails", response_model=SuccessResponse)
277
+ async def add_blocked_email(
278
+ request: EmailRequest,
279
+ auth_result: AuthResult = Depends(require_admin),
280
+ ):
281
+ """Add an email to the blocked emails list."""
282
+ email_validation = get_email_validation_service()
283
+
284
+ email = request.email.lower().strip()
285
+ if not email or "@" not in email:
286
+ raise HTTPException(status_code=400, detail="Invalid email format")
287
+
288
+ email_validation.add_blocked_email(email, auth_result.user_email)
289
+
290
+ return SuccessResponse(
291
+ success=True,
292
+ message=f"Email '{email}' added to blocked emails list"
293
+ )
294
+
295
+
296
+ @router.delete("/blocklists/blocked-emails/{email}", response_model=SuccessResponse)
297
+ async def remove_blocked_email(
298
+ email: str,
299
+ auth_result: AuthResult = Depends(require_admin),
300
+ ):
301
+ """Remove an email from the blocked emails list."""
302
+ email_validation = get_email_validation_service()
303
+
304
+ email = email.lower().strip()
305
+ if not email_validation.remove_blocked_email(email, auth_result.user_email):
306
+ raise HTTPException(status_code=404, detail="Email not found in blocklist")
307
+
308
+ return SuccessResponse(
309
+ success=True,
310
+ message=f"Email '{email}' removed from blocked emails list"
311
+ )
312
+
313
+
314
+ @router.post("/blocklists/blocked-ips", response_model=SuccessResponse)
315
+ async def add_blocked_ip(
316
+ request: IPRequest,
317
+ auth_result: AuthResult = Depends(require_admin),
318
+ ):
319
+ """Add an IP address to the blocked IPs list."""
320
+ email_validation = get_email_validation_service()
321
+
322
+ ip_address = request.ip_address.strip()
323
+ if not ip_address:
324
+ raise HTTPException(status_code=400, detail="Invalid IP address")
325
+
326
+ email_validation.add_blocked_ip(ip_address, auth_result.user_email)
327
+
328
+ return SuccessResponse(
329
+ success=True,
330
+ message=f"IP '{ip_address}' added to blocked IPs list"
331
+ )
332
+
333
+
334
+ @router.delete("/blocklists/blocked-ips/{ip_address}", response_model=SuccessResponse)
335
+ async def remove_blocked_ip(
336
+ ip_address: str,
337
+ auth_result: AuthResult = Depends(require_admin),
338
+ ):
339
+ """Remove an IP address from the blocked IPs list."""
340
+ email_validation = get_email_validation_service()
341
+
342
+ ip_address = ip_address.strip()
343
+ if not email_validation.remove_blocked_ip(ip_address, auth_result.user_email):
344
+ raise HTTPException(status_code=404, detail="IP not found in blocklist")
345
+
346
+ return SuccessResponse(
347
+ success=True,
348
+ message=f"IP '{ip_address}' removed from blocked IPs list"
349
+ )
350
+
351
+
352
+ # =============================================================================
353
+ # User Override Management Endpoints
354
+ # =============================================================================
355
+
356
+ @router.get("/overrides", response_model=UserOverridesListResponse)
357
+ async def get_all_overrides(
358
+ auth_result: AuthResult = Depends(require_admin),
359
+ ):
360
+ """Get all user overrides."""
361
+ rate_limit_service = get_rate_limit_service()
362
+
363
+ overrides_data = rate_limit_service.get_all_overrides()
364
+
365
+ overrides = [
366
+ UserOverride(
367
+ email=email,
368
+ bypass_job_limit=data.get("bypass_job_limit", False),
369
+ custom_daily_job_limit=data.get("custom_daily_job_limit"),
370
+ reason=data.get("reason", ""),
371
+ created_by=data.get("created_by", "unknown"),
372
+ created_at=data.get("created_at", datetime.now(timezone.utc)),
373
+ )
374
+ for email, data in overrides_data.items()
375
+ ]
376
+
377
+ return UserOverridesListResponse(
378
+ overrides=overrides,
379
+ total=len(overrides),
380
+ )
381
+
382
+
383
+ @router.put("/overrides/{email}", response_model=SuccessResponse)
384
+ async def set_user_override(
385
+ email: str,
386
+ request: UserOverrideRequest,
387
+ auth_result: AuthResult = Depends(require_admin),
388
+ ):
389
+ """Set or update a user override."""
390
+ rate_limit_service = get_rate_limit_service()
391
+
392
+ email = email.lower().strip()
393
+ if not email or "@" not in email:
394
+ raise HTTPException(status_code=400, detail="Invalid email format")
395
+
396
+ if not request.reason or len(request.reason.strip()) < 3:
397
+ raise HTTPException(status_code=400, detail="Reason is required (min 3 characters)")
398
+
399
+ rate_limit_service.set_user_override(
400
+ user_email=email,
401
+ bypass_job_limit=request.bypass_job_limit,
402
+ custom_daily_job_limit=request.custom_daily_job_limit,
403
+ reason=request.reason,
404
+ admin_email=auth_result.user_email,
405
+ )
406
+
407
+ return SuccessResponse(
408
+ success=True,
409
+ message=f"Override set for user '{email}'"
410
+ )
411
+
412
+
413
+ @router.delete("/overrides/{email}", response_model=SuccessResponse)
414
+ async def remove_user_override(
415
+ email: str,
416
+ auth_result: AuthResult = Depends(require_admin),
417
+ ):
418
+ """Remove a user override."""
419
+ rate_limit_service = get_rate_limit_service()
420
+
421
+ email = email.lower().strip()
422
+ if not rate_limit_service.remove_user_override(email, auth_result.user_email):
423
+ raise HTTPException(status_code=404, detail="Override not found for user")
424
+
425
+ return SuccessResponse(
426
+ success=True,
427
+ message=f"Override removed for user '{email}'"
428
+ )