karaoke-gen 0.99.3__py3-none-any.whl → 0.101.0__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 +13 -2
- backend/api/routes/file_upload.py +42 -1
- backend/api/routes/internal.py +6 -0
- backend/api/routes/jobs.py +9 -1
- backend/api/routes/review.py +13 -6
- backend/api/routes/tenant.py +120 -0
- backend/api/routes/users.py +167 -245
- backend/main.py +6 -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/firestore_service.py +6 -0
- backend/services/job_manager.py +32 -1
- backend/services/stripe_service.py +61 -35
- backend/services/tenant_service.py +285 -0
- backend/services/user_service.py +85 -7
- 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_email_service.py +233 -0
- 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 +2086 -0
- backend/tests/test_models.py +139 -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
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
- 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.101.0.dist-info}/WHEEL +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
- {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
backend/main.py
CHANGED
|
@@ -7,13 +7,14 @@ from fastapi import FastAPI
|
|
|
7
7
|
from fastapi.middleware.cors import CORSMiddleware
|
|
8
8
|
|
|
9
9
|
from backend.config import settings
|
|
10
|
-
from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin
|
|
10
|
+
from backend.api.routes import health, jobs, internal, file_upload, review, auth, audio_search, themes, users, admin, tenant
|
|
11
11
|
from backend.services.tracing import setup_tracing, instrument_app, get_current_trace_id
|
|
12
12
|
from backend.services.structured_logging import setup_structured_logging
|
|
13
13
|
from backend.services.spacy_preloader import preload_spacy_model
|
|
14
14
|
from backend.services.nltk_preloader import preload_all_nltk_resources
|
|
15
15
|
from backend.services.langfuse_preloader import preload_langfuse_handler
|
|
16
16
|
from backend.middleware.audit_logging import AuditLoggingMiddleware
|
|
17
|
+
from backend.middleware.tenant import TenantMiddleware
|
|
17
18
|
|
|
18
19
|
|
|
19
20
|
from backend.version import VERSION
|
|
@@ -128,6 +129,9 @@ app.add_middleware(
|
|
|
128
129
|
# Add audit logging middleware (captures all requests with request_id for correlation)
|
|
129
130
|
app.add_middleware(AuditLoggingMiddleware)
|
|
130
131
|
|
|
132
|
+
# Add tenant detection middleware (extracts tenant from subdomain/headers)
|
|
133
|
+
app.add_middleware(TenantMiddleware)
|
|
134
|
+
|
|
131
135
|
# Include routers
|
|
132
136
|
app.include_router(health.router, prefix="/api")
|
|
133
137
|
app.include_router(jobs.router, prefix="/api")
|
|
@@ -139,6 +143,7 @@ app.include_router(audio_search.router, prefix="/api") # Audio search (artist+t
|
|
|
139
143
|
app.include_router(themes.router, prefix="/api") # Theme selection for styles
|
|
140
144
|
app.include_router(users.router, prefix="/api") # User auth, credits, and Stripe webhooks
|
|
141
145
|
app.include_router(admin.router, prefix="/api") # Admin dashboard and management
|
|
146
|
+
app.include_router(tenant.router) # Tenant/white-label configuration (no /api prefix, router has it)
|
|
142
147
|
|
|
143
148
|
|
|
144
149
|
@app.get("/")
|
backend/middleware/__init__.py
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
"""Middleware package for FastAPI application."""
|
|
2
2
|
|
|
3
3
|
from backend.middleware.audit_logging import AuditLoggingMiddleware
|
|
4
|
+
from backend.middleware.tenant import TenantMiddleware, get_tenant_from_request, get_tenant_config_from_request
|
|
4
5
|
|
|
5
|
-
__all__ = [
|
|
6
|
+
__all__ = [
|
|
7
|
+
"AuditLoggingMiddleware",
|
|
8
|
+
"TenantMiddleware",
|
|
9
|
+
"get_tenant_from_request",
|
|
10
|
+
"get_tenant_config_from_request",
|
|
11
|
+
]
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant detection middleware for white-label B2B portals.
|
|
3
|
+
|
|
4
|
+
This middleware extracts the tenant ID from incoming requests and attaches
|
|
5
|
+
the tenant configuration to the request state for use by downstream handlers.
|
|
6
|
+
|
|
7
|
+
Tenant detection priority:
|
|
8
|
+
1. X-Tenant-ID header (explicitly set by frontend)
|
|
9
|
+
2. `tenant` query parameter (for development/testing)
|
|
10
|
+
3. Host header subdomain detection (production flow)
|
|
11
|
+
|
|
12
|
+
The middleware is non-blocking - if no tenant is detected, the request
|
|
13
|
+
proceeds as a default Nomad Karaoke request (tenant_id = None).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
21
|
+
from starlette.requests import Request
|
|
22
|
+
from starlette.responses import Response
|
|
23
|
+
|
|
24
|
+
from backend.services.tenant_service import get_tenant_service
|
|
25
|
+
from backend.models.tenant import TenantConfig
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# Only allow query param tenant override in non-production environments
|
|
30
|
+
IS_PRODUCTION = os.environ.get("ENV", "").lower() == "production" or \
|
|
31
|
+
os.environ.get("ENVIRONMENT", "").lower() == "production"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# Known non-tenant subdomains that should be treated as default Nomad Karaoke
|
|
35
|
+
NON_TENANT_SUBDOMAINS = {"gen", "api", "www", "buy", "admin", "app", "beta"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class TenantMiddleware(BaseHTTPMiddleware):
|
|
39
|
+
"""
|
|
40
|
+
Middleware that detects tenant from request and attaches config to state.
|
|
41
|
+
|
|
42
|
+
After this middleware runs, routes can access:
|
|
43
|
+
- request.state.tenant_id: str or None
|
|
44
|
+
- request.state.tenant_config: TenantConfig or None
|
|
45
|
+
|
|
46
|
+
Usage in route handlers:
|
|
47
|
+
```python
|
|
48
|
+
@router.get("/something")
|
|
49
|
+
async def handler(request: Request):
|
|
50
|
+
tenant_id = getattr(request.state, "tenant_id", None)
|
|
51
|
+
tenant_config = getattr(request.state, "tenant_config", None)
|
|
52
|
+
```
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
async def dispatch(self, request: Request, call_next) -> Response:
|
|
56
|
+
# Extract tenant ID from request
|
|
57
|
+
tenant_id = self._extract_tenant_id(request)
|
|
58
|
+
|
|
59
|
+
# Load tenant config if tenant detected
|
|
60
|
+
tenant_config: Optional[TenantConfig] = None
|
|
61
|
+
if tenant_id:
|
|
62
|
+
tenant_service = get_tenant_service()
|
|
63
|
+
tenant_config = tenant_service.get_tenant_config(tenant_id)
|
|
64
|
+
|
|
65
|
+
if tenant_config and not tenant_config.is_active:
|
|
66
|
+
# Tenant exists but is inactive - treat as default
|
|
67
|
+
logger.warning(f"Inactive tenant requested: {tenant_id}")
|
|
68
|
+
tenant_id = None
|
|
69
|
+
tenant_config = None
|
|
70
|
+
|
|
71
|
+
# Attach to request state
|
|
72
|
+
request.state.tenant_id = tenant_id
|
|
73
|
+
request.state.tenant_config = tenant_config
|
|
74
|
+
|
|
75
|
+
# Log tenant detection for debugging
|
|
76
|
+
if tenant_id:
|
|
77
|
+
logger.debug(f"Request tenant: {tenant_id} (path: {request.url.path})")
|
|
78
|
+
|
|
79
|
+
# Process request
|
|
80
|
+
response = await call_next(request)
|
|
81
|
+
|
|
82
|
+
# Add tenant ID to response headers for debugging
|
|
83
|
+
if tenant_id:
|
|
84
|
+
response.headers["X-Tenant-ID"] = tenant_id
|
|
85
|
+
|
|
86
|
+
return response
|
|
87
|
+
|
|
88
|
+
def _extract_tenant_id(self, request: Request) -> Optional[str]:
|
|
89
|
+
"""
|
|
90
|
+
Extract tenant ID from request using priority-based detection.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Tenant ID if detected, None for default Nomad Karaoke
|
|
94
|
+
"""
|
|
95
|
+
# Priority 1: X-Tenant-ID header (explicitly set by frontend)
|
|
96
|
+
header_tenant = request.headers.get("X-Tenant-ID")
|
|
97
|
+
if header_tenant:
|
|
98
|
+
return header_tenant.lower().strip()
|
|
99
|
+
|
|
100
|
+
# Priority 2: Query parameter (for development/testing only)
|
|
101
|
+
# Disabled in production to prevent tenant spoofing
|
|
102
|
+
if not IS_PRODUCTION:
|
|
103
|
+
query_tenant = request.query_params.get("tenant")
|
|
104
|
+
if query_tenant:
|
|
105
|
+
return query_tenant.lower().strip()
|
|
106
|
+
|
|
107
|
+
# Priority 3: Host header subdomain detection
|
|
108
|
+
host = request.headers.get("Host", "")
|
|
109
|
+
return self._extract_tenant_from_host(host)
|
|
110
|
+
|
|
111
|
+
def _extract_tenant_from_host(self, host: str) -> Optional[str]:
|
|
112
|
+
"""
|
|
113
|
+
Extract tenant ID from Host header subdomain.
|
|
114
|
+
|
|
115
|
+
Patterns handled:
|
|
116
|
+
- vocalstar.nomadkaraoke.com -> vocalstar
|
|
117
|
+
- vocalstar.gen.nomadkaraoke.com -> vocalstar
|
|
118
|
+
- localhost:3000 -> None (local dev)
|
|
119
|
+
- gen.nomadkaraoke.com -> None (main app)
|
|
120
|
+
- api.nomadkaraoke.com -> None (API)
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Tenant ID if subdomain matches tenant, None otherwise
|
|
124
|
+
"""
|
|
125
|
+
if not host:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
# Normalize
|
|
129
|
+
host_lower = host.lower()
|
|
130
|
+
|
|
131
|
+
# Remove port if present
|
|
132
|
+
if ":" in host_lower:
|
|
133
|
+
host_lower = host_lower.split(":")[0]
|
|
134
|
+
|
|
135
|
+
# Check if this is a nomadkaraoke.com domain
|
|
136
|
+
if "nomadkaraoke.com" not in host_lower:
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
# Split into parts
|
|
140
|
+
parts = host_lower.split(".")
|
|
141
|
+
|
|
142
|
+
# Need at least 3 parts: subdomain.nomadkaraoke.com
|
|
143
|
+
if len(parts) < 3:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
# First part is the potential tenant ID
|
|
147
|
+
potential_tenant = parts[0]
|
|
148
|
+
|
|
149
|
+
# Skip known non-tenant subdomains
|
|
150
|
+
if potential_tenant in NON_TENANT_SUBDOMAINS:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
# Verify tenant exists in GCS
|
|
154
|
+
tenant_service = get_tenant_service()
|
|
155
|
+
if tenant_service.tenant_exists(potential_tenant):
|
|
156
|
+
return potential_tenant
|
|
157
|
+
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get_tenant_from_request(request: Request) -> Optional[str]:
|
|
162
|
+
"""
|
|
163
|
+
Helper function to get tenant ID from request state.
|
|
164
|
+
|
|
165
|
+
Usage in route handlers:
|
|
166
|
+
```python
|
|
167
|
+
from backend.middleware.tenant import get_tenant_from_request
|
|
168
|
+
|
|
169
|
+
@router.get("/something")
|
|
170
|
+
async def handler(request: Request):
|
|
171
|
+
tenant_id = get_tenant_from_request(request)
|
|
172
|
+
```
|
|
173
|
+
"""
|
|
174
|
+
return getattr(request.state, "tenant_id", None)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_tenant_config_from_request(request: Request) -> Optional[TenantConfig]:
|
|
178
|
+
"""
|
|
179
|
+
Helper function to get tenant config from request state.
|
|
180
|
+
|
|
181
|
+
Usage in route handlers:
|
|
182
|
+
```python
|
|
183
|
+
from backend.middleware.tenant import get_tenant_config_from_request
|
|
184
|
+
|
|
185
|
+
@router.get("/something")
|
|
186
|
+
async def handler(request: Request):
|
|
187
|
+
config = get_tenant_config_from_request(request)
|
|
188
|
+
if config and not config.features.audio_search:
|
|
189
|
+
raise HTTPException(403, "Audio search not available")
|
|
190
|
+
```
|
|
191
|
+
"""
|
|
192
|
+
return getattr(request.state, "tenant_config", None)
|
backend/models/job.py
CHANGED
|
@@ -207,6 +207,9 @@ class Job(BaseModel):
|
|
|
207
207
|
webhook_url: Optional[str] = None # Webhook for notifications
|
|
208
208
|
user_email: Optional[str] = None # Email for notifications
|
|
209
209
|
non_interactive: bool = False # Skip interactive steps (lyrics review, instrumental selection)
|
|
210
|
+
|
|
211
|
+
# Multi-tenant support (None = default Nomad Karaoke)
|
|
212
|
+
tenant_id: Optional[str] = None # Tenant ID for white-label portal scoping
|
|
210
213
|
|
|
211
214
|
# Theme configuration (pre-made themes from GCS)
|
|
212
215
|
theme_id: Optional[str] = None # Theme identifier (e.g., "nomad", "default")
|
|
@@ -276,7 +279,12 @@ class Job(BaseModel):
|
|
|
276
279
|
review_token_expires_at: Optional[datetime] = None # Token expiry time (optional, for extra security)
|
|
277
280
|
instrumental_token: Optional[str] = None # Job-scoped token for instrumental review UI access (generated when entering AWAITING_INSTRUMENTAL_SELECTION)
|
|
278
281
|
instrumental_token_expires_at: Optional[datetime] = None # Token expiry time
|
|
279
|
-
|
|
282
|
+
|
|
283
|
+
# Made-for-you order tracking
|
|
284
|
+
made_for_you: bool = False # Flag indicating this is a made-for-you customer order
|
|
285
|
+
customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
|
|
286
|
+
customer_notes: Optional[str] = None # Notes provided by customer with their order
|
|
287
|
+
|
|
280
288
|
# Processing state
|
|
281
289
|
track_output_dir: Optional[str] = None # Local output directory (temp)
|
|
282
290
|
audio_hash: Optional[str] = None # Hash for deduplication
|
|
@@ -474,7 +482,12 @@ class JobCreate(BaseModel):
|
|
|
474
482
|
prep_only: bool = False # Stop after review, don't run finalisation
|
|
475
483
|
finalise_only: bool = False # Skip prep, run only finalisation
|
|
476
484
|
keep_brand_code: Optional[str] = None # Preserve existing brand code instead of generating new one
|
|
477
|
-
|
|
485
|
+
|
|
486
|
+
# Made-for-you order tracking
|
|
487
|
+
made_for_you: bool = False # Flag indicating this is a made-for-you customer order
|
|
488
|
+
customer_email: Optional[str] = None # Customer email for final delivery (job owned by admin during processing)
|
|
489
|
+
customer_notes: Optional[str] = None # Notes provided by customer with their order
|
|
490
|
+
|
|
478
491
|
# Request metadata (set by API endpoint from request headers)
|
|
479
492
|
request_metadata: Dict[str, Any] = Field(default_factory=dict)
|
|
480
493
|
"""
|
|
@@ -486,7 +499,10 @@ class JobCreate(BaseModel):
|
|
|
486
499
|
- server_version: Current server version
|
|
487
500
|
- custom_headers: All X-* headers
|
|
488
501
|
"""
|
|
489
|
-
|
|
502
|
+
|
|
503
|
+
# Tenant scoping for white-label portals
|
|
504
|
+
tenant_id: Optional[str] = None # Tenant ID for job scoping
|
|
505
|
+
|
|
490
506
|
@validator('url')
|
|
491
507
|
def validate_url(cls, v):
|
|
492
508
|
"""Validate URL is not empty."""
|
backend/models/tenant.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant data models for white-label B2B portals.
|
|
3
|
+
|
|
4
|
+
Tenants are B2B customers who get their own branded portal with custom
|
|
5
|
+
configuration. Each tenant has:
|
|
6
|
+
- Custom branding (logo, colors, site title)
|
|
7
|
+
- Feature flags (enable/disable audio search, distribution, etc.)
|
|
8
|
+
- Default settings (theme, distribution mode)
|
|
9
|
+
- Auth configuration (allowed email domains, fixed tokens)
|
|
10
|
+
|
|
11
|
+
Tenant configs are stored in GCS at tenants/{tenant_id}/config.json
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import List, Optional
|
|
16
|
+
|
|
17
|
+
from pydantic import BaseModel, Field
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TenantBranding(BaseModel):
|
|
21
|
+
"""Branding configuration for a tenant's portal."""
|
|
22
|
+
|
|
23
|
+
logo_url: Optional[str] = Field(
|
|
24
|
+
None, description="URL to tenant's logo image (PNG with transparency preferred)"
|
|
25
|
+
)
|
|
26
|
+
logo_height: int = Field(40, description="Logo height in pixels for header display")
|
|
27
|
+
primary_color: str = Field(
|
|
28
|
+
"#ff5bb8", description="Primary brand color (hex format)"
|
|
29
|
+
)
|
|
30
|
+
secondary_color: str = Field(
|
|
31
|
+
"#8b5cf6", description="Secondary brand color (hex format)"
|
|
32
|
+
)
|
|
33
|
+
accent_color: Optional[str] = Field(
|
|
34
|
+
None, description="Accent color for highlights (hex format)"
|
|
35
|
+
)
|
|
36
|
+
background_color: Optional[str] = Field(
|
|
37
|
+
None, description="Custom background color (defaults to dark theme)"
|
|
38
|
+
)
|
|
39
|
+
favicon_url: Optional[str] = Field(None, description="URL to custom favicon")
|
|
40
|
+
site_title: str = Field(
|
|
41
|
+
"Karaoke Generator", description="Browser tab title"
|
|
42
|
+
)
|
|
43
|
+
tagline: Optional[str] = Field(
|
|
44
|
+
None, description="Optional tagline shown below logo"
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class TenantFeatures(BaseModel):
|
|
49
|
+
"""Feature flags controlling what's available in the tenant's portal."""
|
|
50
|
+
|
|
51
|
+
# Audio input methods
|
|
52
|
+
audio_search: bool = Field(
|
|
53
|
+
True, description="Enable audio search (flacfetch integration)"
|
|
54
|
+
)
|
|
55
|
+
file_upload: bool = Field(True, description="Enable direct file upload")
|
|
56
|
+
youtube_url: bool = Field(True, description="Enable YouTube URL input")
|
|
57
|
+
|
|
58
|
+
# Distribution options
|
|
59
|
+
youtube_upload: bool = Field(True, description="Enable YouTube upload option")
|
|
60
|
+
dropbox_upload: bool = Field(True, description="Enable Dropbox upload option")
|
|
61
|
+
gdrive_upload: bool = Field(True, description="Enable Google Drive upload option")
|
|
62
|
+
|
|
63
|
+
# Customization options
|
|
64
|
+
theme_selection: bool = Field(
|
|
65
|
+
True, description="Allow user to select theme (false = use default)"
|
|
66
|
+
)
|
|
67
|
+
color_overrides: bool = Field(
|
|
68
|
+
True, description="Allow user to customize colors"
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# Output formats
|
|
72
|
+
enable_cdg: bool = Field(True, description="Generate CDG format output")
|
|
73
|
+
enable_4k: bool = Field(True, description="Generate 4K video output")
|
|
74
|
+
|
|
75
|
+
# Advanced features
|
|
76
|
+
admin_access: bool = Field(
|
|
77
|
+
False, description="Allow access to admin dashboard"
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class TenantDefaults(BaseModel):
|
|
82
|
+
"""Default settings applied to all jobs for this tenant."""
|
|
83
|
+
|
|
84
|
+
theme_id: Optional[str] = Field(
|
|
85
|
+
None, description="Default theme ID (required if theme_selection=false)"
|
|
86
|
+
)
|
|
87
|
+
locked_theme: Optional[str] = Field(
|
|
88
|
+
None,
|
|
89
|
+
description="If set, users cannot change theme - always uses this theme ID"
|
|
90
|
+
)
|
|
91
|
+
distribution_mode: str = Field(
|
|
92
|
+
"all",
|
|
93
|
+
description="Distribution mode: 'all', 'download_only', or 'cloud_only'"
|
|
94
|
+
)
|
|
95
|
+
brand_prefix: Optional[str] = Field(
|
|
96
|
+
None, description="Prefix for output filenames (e.g., 'VSTAR')"
|
|
97
|
+
)
|
|
98
|
+
youtube_description_template: Optional[str] = Field(
|
|
99
|
+
None, description="Default YouTube description template"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TenantAuth(BaseModel):
|
|
104
|
+
"""Authentication configuration for a tenant."""
|
|
105
|
+
|
|
106
|
+
allowed_email_domains: List[str] = Field(
|
|
107
|
+
default_factory=list,
|
|
108
|
+
description="Email domains allowed for magic link auth (e.g., ['vocal-star.com'])"
|
|
109
|
+
)
|
|
110
|
+
require_email_domain: bool = Field(
|
|
111
|
+
True,
|
|
112
|
+
description="If true, only allowed domains can sign up. If false, domains get auto-approved."
|
|
113
|
+
)
|
|
114
|
+
fixed_token_ids: List[str] = Field(
|
|
115
|
+
default_factory=list,
|
|
116
|
+
description="IDs of fixed API tokens for this tenant (tokens stored in auth_tokens)"
|
|
117
|
+
)
|
|
118
|
+
sender_email: Optional[str] = Field(
|
|
119
|
+
None,
|
|
120
|
+
description="Email sender address for this tenant (e.g., 'vocalstar@nomadkaraoke.com')"
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class TenantConfig(BaseModel):
|
|
125
|
+
"""Complete configuration for a white-label tenant."""
|
|
126
|
+
|
|
127
|
+
id: str = Field(..., description="Unique tenant identifier (e.g., 'vocalstar')")
|
|
128
|
+
name: str = Field(..., description="Display name (e.g., 'Vocal Star')")
|
|
129
|
+
subdomain: str = Field(
|
|
130
|
+
..., description="Full subdomain (e.g., 'vocalstar.nomadkaraoke.com')"
|
|
131
|
+
)
|
|
132
|
+
is_active: bool = Field(True, description="Whether tenant portal is active")
|
|
133
|
+
|
|
134
|
+
branding: TenantBranding = Field(default_factory=TenantBranding)
|
|
135
|
+
features: TenantFeatures = Field(default_factory=TenantFeatures)
|
|
136
|
+
defaults: TenantDefaults = Field(default_factory=TenantDefaults)
|
|
137
|
+
auth: TenantAuth = Field(default_factory=TenantAuth)
|
|
138
|
+
|
|
139
|
+
created_at: Optional[datetime] = None
|
|
140
|
+
updated_at: Optional[datetime] = None
|
|
141
|
+
|
|
142
|
+
def get_sender_email(self) -> str:
|
|
143
|
+
"""Get the email sender address for this tenant."""
|
|
144
|
+
if self.auth.sender_email:
|
|
145
|
+
return self.auth.sender_email
|
|
146
|
+
# Default pattern: {tenant_id}@nomadkaraoke.com
|
|
147
|
+
return f"{self.id}@nomadkaraoke.com"
|
|
148
|
+
|
|
149
|
+
def is_email_allowed(self, email: str) -> bool:
|
|
150
|
+
"""Check if an email address is allowed for this tenant."""
|
|
151
|
+
if not self.auth.allowed_email_domains:
|
|
152
|
+
# No domain restrictions
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
email_lower = email.lower()
|
|
156
|
+
for domain in self.auth.allowed_email_domains:
|
|
157
|
+
if email_lower.endswith(f"@{domain.lower()}"):
|
|
158
|
+
return True
|
|
159
|
+
|
|
160
|
+
return not self.auth.require_email_domain
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class TenantPublicConfig(BaseModel):
|
|
164
|
+
"""
|
|
165
|
+
Public tenant configuration returned to frontend.
|
|
166
|
+
|
|
167
|
+
This excludes sensitive auth details like token IDs.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
id: str
|
|
171
|
+
name: str
|
|
172
|
+
subdomain: str
|
|
173
|
+
is_active: bool
|
|
174
|
+
branding: TenantBranding
|
|
175
|
+
features: TenantFeatures
|
|
176
|
+
defaults: TenantDefaults
|
|
177
|
+
# Auth info limited to what frontend needs
|
|
178
|
+
allowed_email_domains: List[str] = Field(default_factory=list)
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_config(cls, config: TenantConfig) -> "TenantPublicConfig":
|
|
182
|
+
"""Create public config from full config."""
|
|
183
|
+
return cls(
|
|
184
|
+
id=config.id,
|
|
185
|
+
name=config.name,
|
|
186
|
+
subdomain=config.subdomain,
|
|
187
|
+
is_active=config.is_active,
|
|
188
|
+
branding=config.branding,
|
|
189
|
+
features=config.features,
|
|
190
|
+
defaults=TenantDefaults(
|
|
191
|
+
theme_id=config.defaults.theme_id,
|
|
192
|
+
locked_theme=config.defaults.locked_theme,
|
|
193
|
+
distribution_mode=config.defaults.distribution_mode,
|
|
194
|
+
# Don't expose brand_prefix or youtube_description_template
|
|
195
|
+
),
|
|
196
|
+
allowed_email_domains=config.auth.allowed_email_domains,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class TenantConfigResponse(BaseModel):
|
|
201
|
+
"""Response from GET /api/tenant/config endpoint."""
|
|
202
|
+
|
|
203
|
+
tenant: Optional[TenantPublicConfig] = Field(
|
|
204
|
+
None, description="Tenant config if found, null for default Nomad Karaoke"
|
|
205
|
+
)
|
|
206
|
+
is_default: bool = Field(
|
|
207
|
+
True, description="True if using default Nomad Karaoke config"
|
|
208
|
+
)
|
backend/models/user.py
CHANGED
|
@@ -45,11 +45,17 @@ class User(BaseModel):
|
|
|
45
45
|
|
|
46
46
|
Users are identified by email address. Authentication is via magic links
|
|
47
47
|
(no passwords). Credits are consumed when creating karaoke jobs.
|
|
48
|
+
|
|
49
|
+
For multi-tenant white-label portals, users are scoped to a tenant.
|
|
50
|
+
Users with tenant_id=None are default Nomad Karaoke users.
|
|
48
51
|
"""
|
|
49
52
|
email: str # Primary identifier
|
|
50
53
|
role: UserRole = UserRole.USER
|
|
51
54
|
credits: int = 0
|
|
52
55
|
|
|
56
|
+
# Multi-tenant support (None = default Nomad Karaoke)
|
|
57
|
+
tenant_id: Optional[str] = None
|
|
58
|
+
|
|
53
59
|
# Stripe integration
|
|
54
60
|
stripe_customer_id: Optional[str] = None
|
|
55
61
|
|
|
@@ -87,6 +93,9 @@ class MagicLinkToken(BaseModel):
|
|
|
87
93
|
|
|
88
94
|
Tokens are short-lived (15 minutes) and single-use.
|
|
89
95
|
Stored in Firestore with automatic TTL.
|
|
96
|
+
|
|
97
|
+
For multi-tenant portals, the tenant_id determines which portal
|
|
98
|
+
the user will be redirected to after verification.
|
|
90
99
|
"""
|
|
91
100
|
token: str # Secure random token
|
|
92
101
|
email: str
|
|
@@ -97,6 +106,9 @@ class MagicLinkToken(BaseModel):
|
|
|
97
106
|
ip_address: Optional[str] = None
|
|
98
107
|
user_agent: Optional[str] = None
|
|
99
108
|
|
|
109
|
+
# Multi-tenant support (None = default Nomad Karaoke)
|
|
110
|
+
tenant_id: Optional[str] = None
|
|
111
|
+
|
|
100
112
|
|
|
101
113
|
class Session(BaseModel):
|
|
102
114
|
"""
|
|
@@ -104,6 +116,8 @@ class Session(BaseModel):
|
|
|
104
116
|
|
|
105
117
|
Sessions are created after successful magic link verification.
|
|
106
118
|
Sessions expire after 7 days of inactivity or 30 days absolute.
|
|
119
|
+
|
|
120
|
+
For multi-tenant portals, sessions are scoped to a tenant.
|
|
107
121
|
"""
|
|
108
122
|
token: str # Secure random session token
|
|
109
123
|
user_email: str
|
|
@@ -114,6 +128,9 @@ class Session(BaseModel):
|
|
|
114
128
|
user_agent: Optional[str] = None
|
|
115
129
|
is_active: bool = True
|
|
116
130
|
|
|
131
|
+
# Multi-tenant support (None = default Nomad Karaoke)
|
|
132
|
+
tenant_id: Optional[str] = None
|
|
133
|
+
|
|
117
134
|
|
|
118
135
|
# Pydantic models for API requests/responses
|
|
119
136
|
|
|
@@ -149,6 +166,7 @@ class UserPublic(BaseModel):
|
|
|
149
166
|
display_name: Optional[str] = None
|
|
150
167
|
total_jobs_created: int = 0
|
|
151
168
|
total_jobs_completed: int = 0
|
|
169
|
+
tenant_id: Optional[str] = None
|
|
152
170
|
|
|
153
171
|
|
|
154
172
|
class AddCreditsRequest(BaseModel):
|