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.
Files changed (55) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +17 -34
  3. backend/api/routes/file_upload.py +60 -84
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +11 -3
  6. backend/api/routes/rate_limits.py +428 -0
  7. backend/api/routes/review.py +13 -6
  8. backend/api/routes/tenant.py +120 -0
  9. backend/api/routes/users.py +229 -247
  10. backend/config.py +16 -0
  11. backend/exceptions.py +66 -0
  12. backend/main.py +30 -1
  13. backend/middleware/__init__.py +7 -1
  14. backend/middleware/tenant.py +192 -0
  15. backend/models/job.py +19 -3
  16. backend/models/tenant.py +208 -0
  17. backend/models/user.py +18 -0
  18. backend/services/email_service.py +253 -6
  19. backend/services/email_validation_service.py +646 -0
  20. backend/services/firestore_service.py +27 -0
  21. backend/services/job_defaults_service.py +113 -0
  22. backend/services/job_manager.py +73 -3
  23. backend/services/rate_limit_service.py +641 -0
  24. backend/services/stripe_service.py +61 -35
  25. backend/services/tenant_service.py +285 -0
  26. backend/services/user_service.py +85 -7
  27. backend/tests/conftest.py +7 -1
  28. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  29. backend/tests/test_admin_job_files.py +337 -0
  30. backend/tests/test_admin_job_reset.py +384 -0
  31. backend/tests/test_admin_job_update.py +326 -0
  32. backend/tests/test_audio_search.py +12 -8
  33. backend/tests/test_email_service.py +233 -0
  34. backend/tests/test_email_validation_service.py +298 -0
  35. backend/tests/test_file_upload.py +8 -6
  36. backend/tests/test_impersonation.py +223 -0
  37. backend/tests/test_job_creation_regression.py +4 -0
  38. backend/tests/test_job_manager.py +146 -1
  39. backend/tests/test_made_for_you.py +2088 -0
  40. backend/tests/test_models.py +139 -0
  41. backend/tests/test_rate_limit_service.py +396 -0
  42. backend/tests/test_rate_limits_api.py +392 -0
  43. backend/tests/test_tenant_api.py +350 -0
  44. backend/tests/test_tenant_middleware.py +345 -0
  45. backend/tests/test_tenant_models.py +406 -0
  46. backend/tests/test_tenant_service.py +418 -0
  47. backend/workers/video_worker.py +8 -3
  48. backend/workers/video_worker_orchestrator.py +26 -0
  49. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/METADATA +1 -1
  50. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/RECORD +55 -33
  51. lyrics_transcriber/frontend/src/api.ts +13 -5
  52. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  53. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/WHEEL +0 -0
  54. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.103.1.dist-info}/entry_points.txt +0 -0
  55. {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
+ )
@@ -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
- logger.info(f"Job {job_id}: Generating preview video (GCE preview: {use_gce_preview})")
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
- for key in ["karaoke_background", "style_karaoke_background"]:
503
- if key in style_assets:
504
- background_image_gcs_path = f"gs://{bucket_name}/{style_assets[key]}"
505
- gce_span.set_attribute("background_image", background_image_gcs_path)
506
- break
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)