karaoke-gen 0.99.3__py3-none-any.whl → 0.103.1__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.
- backend/api/routes/admin.py +512 -1
- backend/api/routes/audio_search.py +17 -34
- backend/api/routes/file_upload.py +60 -84
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +11 -3
- backend/api/routes/rate_limits.py +428 -0
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +229 -247
- backend/config.py +16 -0
- backend/exceptions.py +66 -0
- backend/main.py +30 -1
- backend/middleware/__init__.py +7 -1
- backend/middleware/tenant.py +192 -0
- backend/models/job.py +19 -3
- backend/models/tenant.py +208 -0
- backend/models/user.py +18 -0
- backend/services/email_service.py +253 -6
- backend/services/email_validation_service.py +646 -0
- backend/services/firestore_service.py +27 -0
- backend/services/job_defaults_service.py +113 -0
- backend/services/job_manager.py +73 -3
- backend/services/rate_limit_service.py +641 -0
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- backend/tests/conftest.py +7 -1
- backend/tests/emulator/test_made_for_you_integration.py +167 -0
- backend/tests/test_admin_job_files.py +337 -0
- backend/tests/test_admin_job_reset.py +384 -0
- backend/tests/test_admin_job_update.py +326 -0
- backend/tests/test_audio_search.py +12 -8
- backend/tests/test_email_service.py +233 -0
- backend/tests/test_email_validation_service.py +298 -0
- backend/tests/test_file_upload.py +8 -6
- backend/tests/test_impersonation.py +223 -0
- backend/tests/test_job_creation_regression.py +4 -0
- backend/tests/test_job_manager.py +146 -1
- backend/tests/test_made_for_you.py +2088 -0
- backend/tests/test_models.py +139 -0
- backend/tests/test_rate_limit_service.py +396 -0
- backend/tests/test_rate_limits_api.py +392 -0
- backend/tests/test_tenant_api.py +350 -0
- backend/tests/test_tenant_middleware.py +345 -0
- backend/tests/test_tenant_models.py +406 -0
- backend/tests/test_tenant_service.py +418 -0
- backend/workers/video_worker.py +8 -3
- backend/workers/video_worker_orchestrator.py +26 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
- lyrics_transcriber/frontend/src/api.ts +13 -5
- lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
+
)
|
backend/api/routes/review.py
CHANGED
|
@@ -412,7 +412,10 @@ async def generate_preview_video(
|
|
|
412
412
|
|
|
413
413
|
# Check if GCE preview encoding is enabled
|
|
414
414
|
use_gce_preview = encoding_service.is_preview_enabled
|
|
415
|
-
|
|
415
|
+
|
|
416
|
+
# Check if user wants theme background image (slower) or black background (faster, default)
|
|
417
|
+
use_background_image = updated_data.get("use_background_image", False)
|
|
418
|
+
logger.info(f"Job {job_id}: Generating preview video (GCE: {use_gce_preview}, background image: {use_background_image})")
|
|
416
419
|
|
|
417
420
|
# Use tracing and job_log_context for full observability
|
|
418
421
|
with create_span("generate-preview-video", {"job_id": job_id, "use_gce": use_gce_preview}) as span:
|
|
@@ -498,12 +501,16 @@ async def generate_preview_video(
|
|
|
498
501
|
# Get background image and font from style assets if available
|
|
499
502
|
style_assets = job.style_assets or {}
|
|
500
503
|
|
|
504
|
+
# Only use background image if user explicitly requested it
|
|
505
|
+
# Default is black background for faster preview generation (~10s vs ~30-60s)
|
|
501
506
|
background_image_gcs_path = None
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
+
if use_background_image:
|
|
508
|
+
for key in ["karaoke_background", "style_karaoke_background"]:
|
|
509
|
+
if key in style_assets:
|
|
510
|
+
background_image_gcs_path = f"gs://{bucket_name}/{style_assets[key]}"
|
|
511
|
+
gce_span.set_attribute("background_image", background_image_gcs_path)
|
|
512
|
+
break
|
|
513
|
+
gce_span.set_attribute("use_background_image", use_background_image)
|
|
507
514
|
|
|
508
515
|
font_gcs_path = None
|
|
509
516
|
for key in ["font", "style_font"]:
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant API routes for white-label portal configuration.
|
|
3
|
+
|
|
4
|
+
These endpoints are public (no auth required) since the frontend needs
|
|
5
|
+
to fetch tenant branding before the user logs in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from fastapi import APIRouter, Query, Request
|
|
12
|
+
|
|
13
|
+
from backend.models.tenant import TenantConfigResponse, TenantPublicConfig
|
|
14
|
+
from backend.services.tenant_service import get_tenant_service
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
router = APIRouter(prefix="/api/tenant", tags=["tenant"])
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@router.get("/config", response_model=TenantConfigResponse)
|
|
22
|
+
async def get_tenant_config(
|
|
23
|
+
request: Request,
|
|
24
|
+
tenant: Optional[str] = Query(
|
|
25
|
+
None,
|
|
26
|
+
description="Tenant ID override (for development). If not provided, detected from subdomain.",
|
|
27
|
+
),
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Get tenant configuration for the current portal.
|
|
31
|
+
|
|
32
|
+
This endpoint detects the tenant from:
|
|
33
|
+
1. Query parameter `tenant` (for development/testing)
|
|
34
|
+
2. X-Tenant-ID header (set by frontend)
|
|
35
|
+
3. Host header subdomain
|
|
36
|
+
|
|
37
|
+
Returns the public tenant configuration (branding, features, defaults)
|
|
38
|
+
or indicates this is the default Nomad Karaoke portal.
|
|
39
|
+
"""
|
|
40
|
+
tenant_service = get_tenant_service()
|
|
41
|
+
|
|
42
|
+
# Priority 1: Query parameter (for dev/testing)
|
|
43
|
+
tenant_id = tenant
|
|
44
|
+
|
|
45
|
+
# Priority 2: X-Tenant-ID header
|
|
46
|
+
if not tenant_id:
|
|
47
|
+
tenant_id = request.headers.get("X-Tenant-ID")
|
|
48
|
+
|
|
49
|
+
# Priority 3: Detect from Host header
|
|
50
|
+
if not tenant_id:
|
|
51
|
+
host = request.headers.get("Host", "")
|
|
52
|
+
# Check if this is a tenant subdomain
|
|
53
|
+
# Pattern: {tenant}.nomadkaraoke.com or {tenant}.gen.nomadkaraoke.com
|
|
54
|
+
if host and "nomadkaraoke.com" in host.lower():
|
|
55
|
+
parts = host.lower().split(".")
|
|
56
|
+
# Skip known non-tenant subdomains
|
|
57
|
+
if parts[0] not in ["gen", "api", "www", "buy", "admin"]:
|
|
58
|
+
potential_tenant = parts[0]
|
|
59
|
+
if tenant_service.tenant_exists(potential_tenant):
|
|
60
|
+
tenant_id = potential_tenant
|
|
61
|
+
|
|
62
|
+
# If no tenant detected, return default config
|
|
63
|
+
if not tenant_id:
|
|
64
|
+
logger.debug("No tenant detected, returning default config")
|
|
65
|
+
return TenantConfigResponse(tenant=None, is_default=True)
|
|
66
|
+
|
|
67
|
+
# Load tenant config
|
|
68
|
+
public_config = tenant_service.get_public_config(tenant_id)
|
|
69
|
+
|
|
70
|
+
if not public_config:
|
|
71
|
+
logger.warning(f"Tenant not found: {tenant_id}")
|
|
72
|
+
return TenantConfigResponse(tenant=None, is_default=True)
|
|
73
|
+
|
|
74
|
+
if not public_config.is_active:
|
|
75
|
+
logger.warning(f"Tenant is inactive: {tenant_id}")
|
|
76
|
+
return TenantConfigResponse(tenant=None, is_default=True)
|
|
77
|
+
|
|
78
|
+
logger.info(f"Returning config for tenant: {tenant_id}")
|
|
79
|
+
return TenantConfigResponse(tenant=public_config, is_default=False)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@router.get("/config/{tenant_id}", response_model=TenantConfigResponse)
|
|
83
|
+
async def get_tenant_config_by_id(tenant_id: str):
|
|
84
|
+
"""
|
|
85
|
+
Get tenant configuration by explicit tenant ID.
|
|
86
|
+
|
|
87
|
+
This is useful for admin tools or debugging.
|
|
88
|
+
"""
|
|
89
|
+
tenant_service = get_tenant_service()
|
|
90
|
+
public_config = tenant_service.get_public_config(tenant_id)
|
|
91
|
+
|
|
92
|
+
if not public_config:
|
|
93
|
+
return TenantConfigResponse(tenant=None, is_default=True)
|
|
94
|
+
|
|
95
|
+
# Check if tenant is active
|
|
96
|
+
if not public_config.is_active:
|
|
97
|
+
logger.warning(f"Tenant is inactive: {tenant_id}")
|
|
98
|
+
return TenantConfigResponse(tenant=None, is_default=True)
|
|
99
|
+
|
|
100
|
+
return TenantConfigResponse(tenant=public_config, is_default=False)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@router.get("/asset/{tenant_id}/{asset_name}")
|
|
104
|
+
async def get_tenant_asset(tenant_id: str, asset_name: str):
|
|
105
|
+
"""
|
|
106
|
+
Get a signed URL for a tenant asset (logo, favicon, etc.).
|
|
107
|
+
|
|
108
|
+
This redirects to the signed GCS URL for the asset.
|
|
109
|
+
"""
|
|
110
|
+
from fastapi.responses import RedirectResponse
|
|
111
|
+
|
|
112
|
+
tenant_service = get_tenant_service()
|
|
113
|
+
url = tenant_service.get_asset_url(tenant_id, asset_name)
|
|
114
|
+
|
|
115
|
+
if not url:
|
|
116
|
+
from fastapi import HTTPException
|
|
117
|
+
|
|
118
|
+
raise HTTPException(status_code=404, detail="Asset not found")
|
|
119
|
+
|
|
120
|
+
return RedirectResponse(url=url)
|