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
|
@@ -46,11 +46,12 @@ CREDIT_PACKAGES = {
|
|
|
46
46
|
},
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
|
|
49
|
+
# Made-for-you service package (not a credit package - creates a job directly)
|
|
50
|
+
MADE_FOR_YOU_PACKAGE = {
|
|
51
51
|
"price_cents": 1500, # $15.00
|
|
52
|
-
"name": "
|
|
53
|
-
"description": "
|
|
52
|
+
"name": "Made For You Karaoke Video",
|
|
53
|
+
"description": "Professional 4K karaoke video with perfectly synced lyrics, delivered to your email within 24 hours",
|
|
54
|
+
"images": ["https://gen.nomadkaraoke.com/nomad-logo.png"],
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
|
|
@@ -65,6 +66,10 @@ class StripeService:
|
|
|
65
66
|
self.frontend_url = os.getenv("FRONTEND_URL", "https://gen.nomadkaraoke.com")
|
|
66
67
|
# After consolidation, buy URL is the same as frontend URL
|
|
67
68
|
self.buy_url = os.getenv("BUY_URL", self.frontend_url)
|
|
69
|
+
# Payment method configuration ID from Stripe Dashboard
|
|
70
|
+
# Enables Google Pay, Apple Pay, Link, and other wallet methods
|
|
71
|
+
# Get this from: Dashboard > Settings > Payment methods > [Your Config] > Copy ID
|
|
72
|
+
self.payment_method_config = os.getenv("STRIPE_PAYMENT_METHOD_CONFIG")
|
|
68
73
|
|
|
69
74
|
if self.secret_key:
|
|
70
75
|
stripe.api_key = self.secret_key
|
|
@@ -72,6 +77,11 @@ class StripeService:
|
|
|
72
77
|
else:
|
|
73
78
|
logger.warning("STRIPE_SECRET_KEY not set - payments disabled")
|
|
74
79
|
|
|
80
|
+
if self.payment_method_config:
|
|
81
|
+
logger.info(f"Using payment method config: {self.payment_method_config}")
|
|
82
|
+
else:
|
|
83
|
+
logger.info("No STRIPE_PAYMENT_METHOD_CONFIG set - using Stripe defaults")
|
|
84
|
+
|
|
75
85
|
def is_configured(self) -> bool:
|
|
76
86
|
"""Check if Stripe is properly configured."""
|
|
77
87
|
return bool(self.secret_key)
|
|
@@ -113,32 +123,40 @@ class StripeService:
|
|
|
113
123
|
if not cancel_url:
|
|
114
124
|
cancel_url = f"{self.buy_url}?cancelled=true"
|
|
115
125
|
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
payment_method_types
|
|
119
|
-
line_items
|
|
126
|
+
# Build checkout session params
|
|
127
|
+
session_params = {
|
|
128
|
+
# Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
|
|
129
|
+
'line_items': [{
|
|
120
130
|
'price_data': {
|
|
121
131
|
'currency': 'usd',
|
|
122
132
|
'product_data': {
|
|
123
133
|
'name': package['name'],
|
|
124
134
|
'description': package['description'],
|
|
135
|
+
'images': ['https://gen.nomadkaraoke.com/nomad-logo.png'],
|
|
125
136
|
},
|
|
126
137
|
'unit_amount': package['price_cents'],
|
|
127
138
|
},
|
|
128
139
|
'quantity': 1,
|
|
129
140
|
}],
|
|
130
|
-
mode
|
|
131
|
-
success_url
|
|
132
|
-
cancel_url
|
|
133
|
-
customer_email
|
|
134
|
-
metadata
|
|
141
|
+
'mode': 'payment',
|
|
142
|
+
'success_url': success_url,
|
|
143
|
+
'cancel_url': cancel_url,
|
|
144
|
+
'customer_email': user_email,
|
|
145
|
+
'metadata': {
|
|
135
146
|
'package_id': package_id,
|
|
136
147
|
'credits': str(package['credits']),
|
|
137
148
|
'user_email': user_email,
|
|
138
149
|
},
|
|
139
150
|
# Allow promotion codes
|
|
140
|
-
allow_promotion_codes
|
|
141
|
-
|
|
151
|
+
'allow_promotion_codes': True,
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Add payment method configuration if set (enables Google Pay, Link, etc.)
|
|
155
|
+
if self.payment_method_config:
|
|
156
|
+
session_params['payment_method_configuration'] = self.payment_method_config
|
|
157
|
+
|
|
158
|
+
# Create checkout session
|
|
159
|
+
session = stripe.checkout.Session.create(**session_params)
|
|
142
160
|
|
|
143
161
|
logger.info(f"Created checkout session {session.id} for {user_email}, package {package_id}")
|
|
144
162
|
return True, session.url, "Checkout session created"
|
|
@@ -150,7 +168,7 @@ class StripeService:
|
|
|
150
168
|
logger.error(f"Error creating checkout session: {e}")
|
|
151
169
|
return False, None, "Failed to create checkout session"
|
|
152
170
|
|
|
153
|
-
def
|
|
171
|
+
def create_made_for_you_checkout_session(
|
|
154
172
|
self,
|
|
155
173
|
customer_email: str,
|
|
156
174
|
artist: str,
|
|
@@ -162,7 +180,7 @@ class StripeService:
|
|
|
162
180
|
cancel_url: Optional[str] = None,
|
|
163
181
|
) -> Tuple[bool, Optional[str], str]:
|
|
164
182
|
"""
|
|
165
|
-
Create a Stripe Checkout session for a
|
|
183
|
+
Create a Stripe Checkout session for a made-for-you order.
|
|
166
184
|
|
|
167
185
|
This is for the full-service karaoke video creation where Nomad Karaoke
|
|
168
186
|
handles all the work (lyrics review, instrumental selection, etc.).
|
|
@@ -192,7 +210,7 @@ class StripeService:
|
|
|
192
210
|
|
|
193
211
|
# Build metadata for job creation after payment
|
|
194
212
|
metadata = {
|
|
195
|
-
'order_type': '
|
|
213
|
+
'order_type': 'made_for_you',
|
|
196
214
|
'customer_email': customer_email,
|
|
197
215
|
'artist': artist,
|
|
198
216
|
'title': title,
|
|
@@ -204,39 +222,47 @@ class StripeService:
|
|
|
204
222
|
# Truncate notes to fit Stripe's 500 char limit per metadata value
|
|
205
223
|
metadata['notes'] = notes[:500] if len(notes) > 500 else notes
|
|
206
224
|
|
|
207
|
-
#
|
|
208
|
-
|
|
209
|
-
payment_method_types
|
|
210
|
-
line_items
|
|
225
|
+
# Build checkout session params
|
|
226
|
+
session_params = {
|
|
227
|
+
# Omit payment_method_types to auto-enable Apple Pay, Google Pay, Link, etc.
|
|
228
|
+
'line_items': [{
|
|
211
229
|
'price_data': {
|
|
212
230
|
'currency': 'usd',
|
|
213
231
|
'product_data': {
|
|
214
|
-
'name':
|
|
215
|
-
'description':
|
|
232
|
+
'name': f"Karaoke Video: {artist} - {title}",
|
|
233
|
+
'description': MADE_FOR_YOU_PACKAGE['description'],
|
|
234
|
+
'images': MADE_FOR_YOU_PACKAGE['images'],
|
|
216
235
|
},
|
|
217
|
-
'unit_amount':
|
|
236
|
+
'unit_amount': MADE_FOR_YOU_PACKAGE['price_cents'],
|
|
218
237
|
},
|
|
219
238
|
'quantity': 1,
|
|
220
239
|
}],
|
|
221
|
-
mode
|
|
222
|
-
success_url
|
|
223
|
-
cancel_url
|
|
224
|
-
customer_email
|
|
225
|
-
metadata
|
|
226
|
-
allow_promotion_codes
|
|
227
|
-
|
|
240
|
+
'mode': 'payment',
|
|
241
|
+
'success_url': success_url,
|
|
242
|
+
'cancel_url': cancel_url,
|
|
243
|
+
'customer_email': customer_email,
|
|
244
|
+
'metadata': metadata,
|
|
245
|
+
'allow_promotion_codes': True,
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
# Add payment method configuration if set (enables Google Pay, Link, etc.)
|
|
249
|
+
if self.payment_method_config:
|
|
250
|
+
session_params['payment_method_configuration'] = self.payment_method_config
|
|
251
|
+
|
|
252
|
+
# Create checkout session
|
|
253
|
+
session = stripe.checkout.Session.create(**session_params)
|
|
228
254
|
|
|
229
255
|
logger.info(
|
|
230
|
-
f"Created
|
|
256
|
+
f"Created made-for-you checkout session {session.id} for {customer_email}, "
|
|
231
257
|
f"song: {artist} - {title}"
|
|
232
258
|
)
|
|
233
259
|
return True, session.url, "Checkout session created"
|
|
234
260
|
|
|
235
261
|
except stripe.error.StripeError as e:
|
|
236
|
-
logger.error(f"Stripe error creating
|
|
262
|
+
logger.error(f"Stripe error creating made-for-you checkout session: {e}")
|
|
237
263
|
return False, None, f"Payment error: {str(e)}"
|
|
238
264
|
except Exception as e:
|
|
239
|
-
logger.error(f"Error creating
|
|
265
|
+
logger.error(f"Error creating made-for-you checkout session: {e}")
|
|
240
266
|
return False, None, "Failed to create checkout session"
|
|
241
267
|
|
|
242
268
|
def verify_webhook_signature(self, payload: bytes, signature: str) -> Tuple[bool, Optional[Dict], str]:
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tenant service for managing white-label B2B portal configurations.
|
|
3
|
+
|
|
4
|
+
Tenant configs are stored in GCS at tenants/{tenant_id}/config.json.
|
|
5
|
+
The service provides:
|
|
6
|
+
- Tenant config loading with caching
|
|
7
|
+
- Subdomain to tenant ID resolution
|
|
8
|
+
- Tenant validation
|
|
9
|
+
|
|
10
|
+
Similar to ThemeService, configs are cached for 5 minutes to reduce GCS reads.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import logging
|
|
14
|
+
import threading
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from typing import Dict, Optional
|
|
17
|
+
|
|
18
|
+
from backend.models.tenant import TenantConfig, TenantPublicConfig
|
|
19
|
+
from backend.services.storage_service import StorageService
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# GCS paths for tenant configs
|
|
24
|
+
TENANTS_PREFIX = "tenants"
|
|
25
|
+
|
|
26
|
+
# Default sender email (shared with EmailService for consistency)
|
|
27
|
+
DEFAULT_SENDER_EMAIL = "gen@nomadkaraoke.com"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class TenantService:
|
|
31
|
+
"""Service for managing tenant configurations from GCS."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, storage: Optional[StorageService] = None):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the tenant service.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
storage: StorageService instance (creates new one if not provided)
|
|
39
|
+
"""
|
|
40
|
+
self.storage = storage or StorageService()
|
|
41
|
+
self._config_cache: Dict[str, TenantConfig] = {}
|
|
42
|
+
self._cache_times: Dict[str, datetime] = {}
|
|
43
|
+
self._subdomain_map: Dict[str, str] = {} # subdomain -> tenant_id
|
|
44
|
+
self._subdomain_map_time: Optional[datetime] = None
|
|
45
|
+
self.CACHE_TTL_SECONDS = 300 # 5 minute cache
|
|
46
|
+
|
|
47
|
+
def _get_config_path(self, tenant_id: str) -> str:
|
|
48
|
+
"""Get the GCS path for a tenant's config file."""
|
|
49
|
+
return f"{TENANTS_PREFIX}/{tenant_id}/config.json"
|
|
50
|
+
|
|
51
|
+
def _is_cache_valid(self, tenant_id: str) -> bool:
|
|
52
|
+
"""Check if the config cache for a tenant is still valid."""
|
|
53
|
+
if tenant_id not in self._config_cache or tenant_id not in self._cache_times:
|
|
54
|
+
return False
|
|
55
|
+
age = datetime.now() - self._cache_times[tenant_id]
|
|
56
|
+
return age.total_seconds() < self.CACHE_TTL_SECONDS
|
|
57
|
+
|
|
58
|
+
def _is_subdomain_map_valid(self) -> bool:
|
|
59
|
+
"""Check if the subdomain map cache is still valid."""
|
|
60
|
+
if not self._subdomain_map or self._subdomain_map_time is None:
|
|
61
|
+
return False
|
|
62
|
+
age = datetime.now() - self._subdomain_map_time
|
|
63
|
+
return age.total_seconds() < self.CACHE_TTL_SECONDS
|
|
64
|
+
|
|
65
|
+
def get_tenant_config(
|
|
66
|
+
self, tenant_id: str, force_refresh: bool = False
|
|
67
|
+
) -> Optional[TenantConfig]:
|
|
68
|
+
"""
|
|
69
|
+
Load tenant configuration from GCS with caching.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
tenant_id: The tenant identifier
|
|
73
|
+
force_refresh: Force reload from GCS even if cache is valid
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
TenantConfig if found, None otherwise
|
|
77
|
+
"""
|
|
78
|
+
if not force_refresh and self._is_cache_valid(tenant_id):
|
|
79
|
+
return self._config_cache[tenant_id]
|
|
80
|
+
|
|
81
|
+
try:
|
|
82
|
+
config_path = self._get_config_path(tenant_id)
|
|
83
|
+
if not self.storage.file_exists(config_path):
|
|
84
|
+
logger.debug(f"Tenant config not found: {tenant_id}")
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
data = self.storage.download_json(config_path)
|
|
88
|
+
config = TenantConfig(**data)
|
|
89
|
+
|
|
90
|
+
# Update cache
|
|
91
|
+
self._config_cache[tenant_id] = config
|
|
92
|
+
self._cache_times[tenant_id] = datetime.now()
|
|
93
|
+
|
|
94
|
+
# Update subdomain map and its timestamp
|
|
95
|
+
self._subdomain_map[config.subdomain.lower()] = tenant_id
|
|
96
|
+
self._subdomain_map_time = datetime.now()
|
|
97
|
+
|
|
98
|
+
logger.info(f"Loaded tenant config: {tenant_id}")
|
|
99
|
+
return config
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.error(f"Failed to load tenant config {tenant_id}: {e}")
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def get_tenant_by_subdomain(self, subdomain: str) -> Optional[TenantConfig]:
|
|
105
|
+
"""
|
|
106
|
+
Get tenant config by subdomain.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
subdomain: Full subdomain (e.g., 'vocalstar.nomadkaraoke.com')
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
TenantConfig if found, None otherwise
|
|
113
|
+
"""
|
|
114
|
+
subdomain_lower = subdomain.lower()
|
|
115
|
+
|
|
116
|
+
# Check cache first
|
|
117
|
+
if subdomain_lower in self._subdomain_map:
|
|
118
|
+
tenant_id = self._subdomain_map[subdomain_lower]
|
|
119
|
+
return self.get_tenant_config(tenant_id)
|
|
120
|
+
|
|
121
|
+
# Try to find tenant by listing all configs
|
|
122
|
+
# This is a fallback for when we don't have the subdomain cached
|
|
123
|
+
tenant_id = self._resolve_subdomain_to_tenant_id(subdomain_lower)
|
|
124
|
+
if tenant_id:
|
|
125
|
+
return self.get_tenant_config(tenant_id)
|
|
126
|
+
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
def _resolve_subdomain_to_tenant_id(self, subdomain: str) -> Optional[str]:
|
|
130
|
+
"""
|
|
131
|
+
Resolve a subdomain to tenant ID by checking GCS.
|
|
132
|
+
|
|
133
|
+
This extracts the tenant ID from the subdomain pattern:
|
|
134
|
+
- 'vocalstar.nomadkaraoke.com' -> 'vocalstar'
|
|
135
|
+
- 'vocalstar.gen.nomadkaraoke.com' -> 'vocalstar'
|
|
136
|
+
"""
|
|
137
|
+
# Extract potential tenant ID from subdomain
|
|
138
|
+
# Pattern: {tenant}.nomadkaraoke.com or {tenant}.gen.nomadkaraoke.com
|
|
139
|
+
parts = subdomain.split(".")
|
|
140
|
+
|
|
141
|
+
if len(parts) >= 3:
|
|
142
|
+
# First part is likely the tenant ID
|
|
143
|
+
potential_tenant_id = parts[0]
|
|
144
|
+
|
|
145
|
+
# Check if config exists
|
|
146
|
+
config_path = self._get_config_path(potential_tenant_id)
|
|
147
|
+
if self.storage.file_exists(config_path):
|
|
148
|
+
return potential_tenant_id
|
|
149
|
+
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
def get_public_config(self, tenant_id: str) -> Optional[TenantPublicConfig]:
|
|
153
|
+
"""
|
|
154
|
+
Get the public (frontend-safe) tenant configuration.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
tenant_id: The tenant identifier
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
TenantPublicConfig if found, None otherwise
|
|
161
|
+
"""
|
|
162
|
+
config = self.get_tenant_config(tenant_id)
|
|
163
|
+
if config:
|
|
164
|
+
return TenantPublicConfig.from_config(config)
|
|
165
|
+
return None
|
|
166
|
+
|
|
167
|
+
def get_public_config_by_subdomain(
|
|
168
|
+
self, subdomain: str
|
|
169
|
+
) -> Optional[TenantPublicConfig]:
|
|
170
|
+
"""
|
|
171
|
+
Get the public tenant configuration by subdomain.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
subdomain: Full subdomain
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
TenantPublicConfig if found, None otherwise
|
|
178
|
+
"""
|
|
179
|
+
config = self.get_tenant_by_subdomain(subdomain)
|
|
180
|
+
if config:
|
|
181
|
+
return TenantPublicConfig.from_config(config)
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
def tenant_exists(self, tenant_id: str) -> bool:
|
|
185
|
+
"""
|
|
186
|
+
Check if a tenant exists.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
tenant_id: The tenant identifier
|
|
190
|
+
|
|
191
|
+
Returns:
|
|
192
|
+
True if tenant config exists, False otherwise
|
|
193
|
+
"""
|
|
194
|
+
config_path = self._get_config_path(tenant_id)
|
|
195
|
+
return self.storage.file_exists(config_path)
|
|
196
|
+
|
|
197
|
+
def is_email_allowed_for_tenant(self, tenant_id: str, email: str) -> bool:
|
|
198
|
+
"""
|
|
199
|
+
Check if an email is allowed for a specific tenant.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
tenant_id: The tenant identifier
|
|
203
|
+
email: Email address to check
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
True if email is allowed, False otherwise
|
|
207
|
+
"""
|
|
208
|
+
config = self.get_tenant_config(tenant_id)
|
|
209
|
+
if not config:
|
|
210
|
+
return False
|
|
211
|
+
return config.is_email_allowed(email)
|
|
212
|
+
|
|
213
|
+
def get_tenant_sender_email(self, tenant_id: str) -> str:
|
|
214
|
+
"""
|
|
215
|
+
Get the email sender address for a tenant.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
tenant_id: The tenant identifier
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Sender email address
|
|
222
|
+
"""
|
|
223
|
+
config = self.get_tenant_config(tenant_id)
|
|
224
|
+
if config:
|
|
225
|
+
return config.get_sender_email()
|
|
226
|
+
# Default fallback (consistent with EmailService)
|
|
227
|
+
return DEFAULT_SENDER_EMAIL
|
|
228
|
+
|
|
229
|
+
def invalidate_cache(self, tenant_id: Optional[str] = None) -> None:
|
|
230
|
+
"""
|
|
231
|
+
Invalidate tenant config cache.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
tenant_id: Specific tenant to invalidate, or None for all
|
|
235
|
+
"""
|
|
236
|
+
if tenant_id:
|
|
237
|
+
self._config_cache.pop(tenant_id, None)
|
|
238
|
+
self._cache_times.pop(tenant_id, None)
|
|
239
|
+
# Also remove any subdomain map entries pointing to this tenant
|
|
240
|
+
subdomains_to_remove = [
|
|
241
|
+
subdomain
|
|
242
|
+
for subdomain, tid in self._subdomain_map.items()
|
|
243
|
+
if tid == tenant_id
|
|
244
|
+
]
|
|
245
|
+
for subdomain in subdomains_to_remove:
|
|
246
|
+
self._subdomain_map.pop(subdomain, None)
|
|
247
|
+
logger.info(f"Tenant config cache invalidated: {tenant_id}")
|
|
248
|
+
else:
|
|
249
|
+
self._config_cache.clear()
|
|
250
|
+
self._cache_times.clear()
|
|
251
|
+
self._subdomain_map.clear()
|
|
252
|
+
self._subdomain_map_time = None
|
|
253
|
+
logger.info("All tenant config caches invalidated")
|
|
254
|
+
|
|
255
|
+
def get_asset_url(self, tenant_id: str, asset_name: str) -> Optional[str]:
|
|
256
|
+
"""
|
|
257
|
+
Get a signed URL for a tenant asset (logo, favicon, etc.).
|
|
258
|
+
|
|
259
|
+
Args:
|
|
260
|
+
tenant_id: The tenant identifier
|
|
261
|
+
asset_name: Asset filename (e.g., 'logo.png')
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
Signed URL if asset exists, None otherwise
|
|
265
|
+
"""
|
|
266
|
+
asset_path = f"{TENANTS_PREFIX}/{tenant_id}/{asset_name}"
|
|
267
|
+
if self.storage.file_exists(asset_path):
|
|
268
|
+
return self.storage.generate_signed_url(asset_path, expiration_minutes=60)
|
|
269
|
+
return None
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# Singleton instance with thread-safe initialization
|
|
273
|
+
_tenant_service: Optional[TenantService] = None
|
|
274
|
+
_tenant_service_lock = threading.Lock()
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def get_tenant_service() -> TenantService:
|
|
278
|
+
"""Get or create the singleton TenantService instance (thread-safe)."""
|
|
279
|
+
global _tenant_service
|
|
280
|
+
if _tenant_service is None:
|
|
281
|
+
with _tenant_service_lock:
|
|
282
|
+
# Double-check after acquiring lock
|
|
283
|
+
if _tenant_service is None:
|
|
284
|
+
_tenant_service = TenantService()
|
|
285
|
+
return _tenant_service
|
backend/services/user_service.py
CHANGED
|
@@ -73,16 +73,26 @@ class UserService:
|
|
|
73
73
|
logger.exception(f"Error getting user {email}")
|
|
74
74
|
return None
|
|
75
75
|
|
|
76
|
-
def get_or_create_user(self, email: str) -> User:
|
|
76
|
+
def get_or_create_user(self, email: str, tenant_id: Optional[str] = None) -> User:
|
|
77
77
|
"""
|
|
78
78
|
Get existing user or create a new one.
|
|
79
79
|
|
|
80
80
|
New users receive a welcome credit to try the service.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
email: User's email address
|
|
84
|
+
tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
|
|
85
|
+
|
|
86
|
+
Note: If user exists but has a different tenant_id, the existing user is returned.
|
|
87
|
+
Users are uniquely identified by email, not email+tenant.
|
|
81
88
|
"""
|
|
82
89
|
email = email.lower()
|
|
83
90
|
user = self.get_user(email)
|
|
84
91
|
|
|
85
92
|
if user:
|
|
93
|
+
# If user exists but tenant_id differs, we still return the user
|
|
94
|
+
# This allows users to access multiple tenants with one account
|
|
95
|
+
# (though features may be restricted per-tenant)
|
|
86
96
|
return user
|
|
87
97
|
|
|
88
98
|
# Create new user with welcome credit
|
|
@@ -97,9 +107,10 @@ class UserService:
|
|
|
97
107
|
email=email,
|
|
98
108
|
credits=welcome_credit,
|
|
99
109
|
credit_transactions=[welcome_transaction],
|
|
110
|
+
tenant_id=tenant_id, # Associate with tenant on creation
|
|
100
111
|
)
|
|
101
112
|
self._save_user(user)
|
|
102
|
-
logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s)")
|
|
113
|
+
logger.info(f"Created new user: {email} with {welcome_credit} welcome credit(s) (tenant: {tenant_id or 'default'})")
|
|
103
114
|
return user
|
|
104
115
|
|
|
105
116
|
def _save_user(self, user: User) -> None:
|
|
@@ -153,6 +164,7 @@ class UserService:
|
|
|
153
164
|
display_name=user.display_name,
|
|
154
165
|
total_jobs_created=user.total_jobs_created,
|
|
155
166
|
total_jobs_completed=user.total_jobs_completed,
|
|
167
|
+
tenant_id=user.tenant_id,
|
|
156
168
|
)
|
|
157
169
|
|
|
158
170
|
# =========================================================================
|
|
@@ -163,18 +175,25 @@ class UserService:
|
|
|
163
175
|
self,
|
|
164
176
|
email: str,
|
|
165
177
|
ip_address: Optional[str] = None,
|
|
166
|
-
user_agent: Optional[str] = None
|
|
178
|
+
user_agent: Optional[str] = None,
|
|
179
|
+
tenant_id: Optional[str] = None
|
|
167
180
|
) -> MagicLinkToken:
|
|
168
181
|
"""
|
|
169
182
|
Create a magic link token for email authentication.
|
|
170
183
|
|
|
171
184
|
Returns the token object. The actual sending of the email
|
|
172
185
|
should be handled by the caller (or an email service).
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
email: User's email address
|
|
189
|
+
ip_address: Client IP for auditing
|
|
190
|
+
user_agent: Client user agent for auditing
|
|
191
|
+
tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
|
|
173
192
|
"""
|
|
174
193
|
email = email.lower()
|
|
175
194
|
|
|
176
|
-
# Ensure user exists
|
|
177
|
-
self.get_or_create_user(email)
|
|
195
|
+
# Ensure user exists (with tenant association)
|
|
196
|
+
self.get_or_create_user(email, tenant_id=tenant_id)
|
|
178
197
|
|
|
179
198
|
# Generate secure token
|
|
180
199
|
token = secrets.token_urlsafe(32)
|
|
@@ -185,6 +204,7 @@ class UserService:
|
|
|
185
204
|
expires_at=datetime.utcnow() + timedelta(minutes=MAGIC_LINK_EXPIRY_MINUTES),
|
|
186
205
|
ip_address=ip_address,
|
|
187
206
|
user_agent=user_agent,
|
|
207
|
+
tenant_id=tenant_id,
|
|
188
208
|
)
|
|
189
209
|
|
|
190
210
|
# Save to Firestore
|
|
@@ -194,6 +214,54 @@ class UserService:
|
|
|
194
214
|
logger.info(f"Created magic link for {email}")
|
|
195
215
|
return magic_link
|
|
196
216
|
|
|
217
|
+
def create_admin_login_token(
|
|
218
|
+
self,
|
|
219
|
+
email: str,
|
|
220
|
+
expiry_hours: int = 24,
|
|
221
|
+
) -> MagicLinkToken:
|
|
222
|
+
"""
|
|
223
|
+
Create an admin login token for email-embedded authentication links.
|
|
224
|
+
|
|
225
|
+
Similar to magic links but with configurable expiry (default 24 hours).
|
|
226
|
+
Used for made-for-you order notification emails to allow admin one-click login.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
email: Admin's email address to authenticate as
|
|
230
|
+
expiry_hours: Hours until token expires (default: 24, max: 168)
|
|
231
|
+
|
|
232
|
+
Returns:
|
|
233
|
+
MagicLinkToken object containing the token
|
|
234
|
+
|
|
235
|
+
Raises:
|
|
236
|
+
ValueError: If expiry_hours is out of valid range (1-168)
|
|
237
|
+
"""
|
|
238
|
+
# Validate expiry_hours (1 hour to 7 days)
|
|
239
|
+
if not 1 <= expiry_hours <= 168:
|
|
240
|
+
raise ValueError(f"expiry_hours must be between 1 and 168, got {expiry_hours}")
|
|
241
|
+
|
|
242
|
+
email = email.lower()
|
|
243
|
+
|
|
244
|
+
# Ensure user exists
|
|
245
|
+
self.get_or_create_user(email)
|
|
246
|
+
|
|
247
|
+
# Generate secure token
|
|
248
|
+
token = secrets.token_urlsafe(32)
|
|
249
|
+
|
|
250
|
+
admin_login = MagicLinkToken(
|
|
251
|
+
token=token,
|
|
252
|
+
email=email,
|
|
253
|
+
expires_at=datetime.utcnow() + timedelta(hours=expiry_hours),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Save to Firestore (same collection as magic links for unified verification)
|
|
257
|
+
doc_ref = self.db.collection(MAGIC_LINKS_COLLECTION).document(token)
|
|
258
|
+
doc_ref.set(admin_login.model_dump(mode='json'))
|
|
259
|
+
|
|
260
|
+
# Log with redacted email (show only domain) for PII protection
|
|
261
|
+
domain = email.split('@')[-1] if '@' in email else 'unknown'
|
|
262
|
+
logger.info(f"Created admin login token for ***@{domain} (expires in {expiry_hours}h)")
|
|
263
|
+
return admin_login
|
|
264
|
+
|
|
197
265
|
def verify_magic_link(self, token: str) -> Tuple[bool, Optional[User], str]:
|
|
198
266
|
"""
|
|
199
267
|
Verify a magic link token using a Firestore transaction to prevent race conditions.
|
|
@@ -262,9 +330,18 @@ class UserService:
|
|
|
262
330
|
self,
|
|
263
331
|
user_email: str,
|
|
264
332
|
ip_address: Optional[str] = None,
|
|
265
|
-
user_agent: Optional[str] = None
|
|
333
|
+
user_agent: Optional[str] = None,
|
|
334
|
+
tenant_id: Optional[str] = None
|
|
266
335
|
) -> Session:
|
|
267
|
-
"""
|
|
336
|
+
"""
|
|
337
|
+
Create a new session for an authenticated user.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
user_email: User's email address
|
|
341
|
+
ip_address: Client IP for auditing
|
|
342
|
+
user_agent: Client user agent for auditing
|
|
343
|
+
tenant_id: Tenant ID for white-label portals (None = default Nomad Karaoke)
|
|
344
|
+
"""
|
|
268
345
|
token = secrets.token_urlsafe(32)
|
|
269
346
|
token_prefix = token[:12]
|
|
270
347
|
|
|
@@ -274,6 +351,7 @@ class UserService:
|
|
|
274
351
|
expires_at=datetime.utcnow() + timedelta(days=SESSION_ABSOLUTE_EXPIRY_DAYS),
|
|
275
352
|
ip_address=ip_address,
|
|
276
353
|
user_agent=user_agent,
|
|
354
|
+
tenant_id=tenant_id,
|
|
277
355
|
)
|
|
278
356
|
|
|
279
357
|
# Serialize and write to Firestore
|
backend/tests/conftest.py
CHANGED
|
@@ -91,7 +91,13 @@ def mock_auth_dependency(request):
|
|
|
91
91
|
if 'emulator' in test_path or 'integration' in test_path:
|
|
92
92
|
yield
|
|
93
93
|
return
|
|
94
|
-
|
|
94
|
+
|
|
95
|
+
# Skip for service-only unit tests that don't need the FastAPI app
|
|
96
|
+
service_only_tests = ['test_rate_limit_service', 'test_email_validation_service', 'test_rate_limits_api']
|
|
97
|
+
if any(test_name in test_path for test_name in service_only_tests):
|
|
98
|
+
yield
|
|
99
|
+
return
|
|
100
|
+
|
|
95
101
|
# Skip if FIRESTORE_EMULATOR_HOST is set (running in emulator environment)
|
|
96
102
|
import os
|
|
97
103
|
if os.environ.get('FIRESTORE_EMULATOR_HOST'):
|