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.
Files changed (42) hide show
  1. backend/api/routes/admin.py +512 -1
  2. backend/api/routes/audio_search.py +13 -2
  3. backend/api/routes/file_upload.py +42 -1
  4. backend/api/routes/internal.py +6 -0
  5. backend/api/routes/jobs.py +9 -1
  6. backend/api/routes/review.py +13 -6
  7. backend/api/routes/tenant.py +120 -0
  8. backend/api/routes/users.py +167 -245
  9. backend/main.py +6 -1
  10. backend/middleware/__init__.py +7 -1
  11. backend/middleware/tenant.py +192 -0
  12. backend/models/job.py +19 -3
  13. backend/models/tenant.py +208 -0
  14. backend/models/user.py +18 -0
  15. backend/services/email_service.py +253 -6
  16. backend/services/firestore_service.py +6 -0
  17. backend/services/job_manager.py +32 -1
  18. backend/services/stripe_service.py +61 -35
  19. backend/services/tenant_service.py +285 -0
  20. backend/services/user_service.py +85 -7
  21. backend/tests/emulator/test_made_for_you_integration.py +167 -0
  22. backend/tests/test_admin_job_files.py +337 -0
  23. backend/tests/test_admin_job_reset.py +384 -0
  24. backend/tests/test_admin_job_update.py +326 -0
  25. backend/tests/test_email_service.py +233 -0
  26. backend/tests/test_impersonation.py +223 -0
  27. backend/tests/test_job_creation_regression.py +4 -0
  28. backend/tests/test_job_manager.py +146 -1
  29. backend/tests/test_made_for_you.py +2086 -0
  30. backend/tests/test_models.py +139 -0
  31. backend/tests/test_tenant_api.py +350 -0
  32. backend/tests/test_tenant_middleware.py +345 -0
  33. backend/tests/test_tenant_models.py +406 -0
  34. backend/tests/test_tenant_service.py +418 -0
  35. backend/workers/video_worker.py +8 -3
  36. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/METADATA +1 -1
  37. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/RECORD +42 -28
  38. lyrics_transcriber/frontend/src/api.ts +13 -5
  39. lyrics_transcriber/frontend/src/components/PreviewVideoSection.tsx +90 -57
  40. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/WHEEL +0 -0
  41. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/entry_points.txt +0 -0
  42. {karaoke_gen-0.99.3.dist-info → karaoke_gen-0.101.0.dist-info}/licenses/LICENSE +0 -0
@@ -15,10 +15,11 @@ from fastapi import APIRouter, Depends, HTTPException
15
15
  from pydantic import BaseModel
16
16
 
17
17
  from backend.api.dependencies import require_admin
18
- from backend.services.auth_service import UserType
18
+ from backend.services.auth_service import UserType, AuthResult
19
19
  from backend.services.user_service import get_user_service, UserService, USERS_COLLECTION
20
20
  from backend.services.job_manager import JobManager
21
21
  from backend.services.flacfetch_client import get_flacfetch_client, FlacfetchServiceError
22
+ from backend.services.storage_service import StorageService
22
23
  from backend.models.job import JobStatus
23
24
  from backend.utils.test_data import is_test_email
24
25
  from karaoke_gen.utils import sanitize_filename
@@ -56,6 +57,74 @@ class AdminStatsOverview(BaseModel):
56
57
  total_beta_testers: int
57
58
 
58
59
 
60
+ class FileInfo(BaseModel):
61
+ """Information about a single file with signed download URL."""
62
+ name: str
63
+ path: str # GCS path (gs://bucket/...)
64
+ download_url: str # Signed URL for download
65
+ category: str # e.g., "stems", "lyrics", "finals"
66
+ file_key: str # e.g., "instrumental_clean", "lrc"
67
+
68
+
69
+ class JobFilesResponse(BaseModel):
70
+ """Response containing all files for a job with signed download URLs."""
71
+ job_id: str
72
+ artist: Optional[str]
73
+ title: Optional[str]
74
+ files: List[FileInfo]
75
+ total_files: int
76
+
77
+
78
+ class JobUpdateRequest(BaseModel):
79
+ """Request model for updating job fields."""
80
+ # Editable text fields
81
+ artist: Optional[str] = None
82
+ title: Optional[str] = None
83
+ user_email: Optional[str] = None
84
+ theme_id: Optional[str] = None
85
+ brand_prefix: Optional[str] = None
86
+ discord_webhook_url: Optional[str] = None
87
+ youtube_description: Optional[str] = None
88
+ youtube_description_template: Optional[str] = None
89
+ customer_email: Optional[str] = None
90
+ customer_notes: Optional[str] = None
91
+
92
+ # Editable boolean fields
93
+ enable_cdg: Optional[bool] = None
94
+ enable_txt: Optional[bool] = None
95
+ enable_youtube_upload: Optional[bool] = None
96
+ non_interactive: Optional[bool] = None
97
+ prep_only: Optional[bool] = None
98
+
99
+
100
+ class JobUpdateResponse(BaseModel):
101
+ """Response from job update endpoint."""
102
+ status: str
103
+ job_id: str
104
+ updated_fields: List[str]
105
+ message: str
106
+
107
+
108
+ # Fields that are allowed to be updated via PATCH endpoint
109
+ EDITABLE_JOB_FIELDS = {
110
+ "artist",
111
+ "title",
112
+ "user_email",
113
+ "theme_id",
114
+ "brand_prefix",
115
+ "discord_webhook_url",
116
+ "youtube_description",
117
+ "youtube_description_template",
118
+ "customer_email",
119
+ "customer_notes",
120
+ "enable_cdg",
121
+ "enable_txt",
122
+ "enable_youtube_upload",
123
+ "non_interactive",
124
+ "prep_only",
125
+ }
126
+
127
+
59
128
  # =============================================================================
60
129
  # Admin Stats Endpoints
61
130
  # =============================================================================
@@ -702,6 +771,374 @@ class SendCompletionEmailResponse(BaseModel):
702
771
  message: str
703
772
 
704
773
 
774
+ # =============================================================================
775
+ # Job Files Endpoint
776
+ # =============================================================================
777
+
778
+ def _extract_files_recursive(
779
+ file_urls: Dict[str, Any],
780
+ storage: StorageService,
781
+ category: str = "",
782
+ expiration_minutes: int = 120,
783
+ ) -> List[FileInfo]:
784
+ """
785
+ Recursively extract files from nested file_urls structure.
786
+
787
+ Only includes entries that are GCS paths (gs://...).
788
+ Skips non-GCS entries like YouTube URLs.
789
+
790
+ Args:
791
+ file_urls: Dictionary of file URLs (may be nested)
792
+ storage: StorageService instance for generating signed URLs
793
+ category: Current category name (for nested calls)
794
+ expiration_minutes: How long signed URLs should be valid
795
+
796
+ Returns:
797
+ List of FileInfo objects with signed download URLs
798
+ """
799
+ files = []
800
+
801
+ for key, value in file_urls.items():
802
+ if isinstance(value, dict):
803
+ # Nested structure - recurse with key as category
804
+ nested_files = _extract_files_recursive(
805
+ value,
806
+ storage,
807
+ category=key if not category else f"{category}.{key}",
808
+ expiration_minutes=expiration_minutes,
809
+ )
810
+ files.extend(nested_files)
811
+ elif isinstance(value, str) and value.startswith("gs://"):
812
+ # GCS path - generate signed URL
813
+ try:
814
+ signed_url = storage.generate_signed_url(value, expiration_minutes=expiration_minutes)
815
+ # Extract filename from path
816
+ name = value.split("/")[-1] if "/" in value else value
817
+ files.append(FileInfo(
818
+ name=name,
819
+ path=value,
820
+ download_url=signed_url,
821
+ category=category,
822
+ file_key=key,
823
+ ))
824
+ except Exception as e:
825
+ # Log but don't fail - file might not exist
826
+ logger.warning(f"Failed to generate signed URL for {value}: {e}")
827
+ # Skip non-GCS values (e.g., youtube URLs, video IDs)
828
+
829
+ return files
830
+
831
+
832
+ @router.get("/jobs/{job_id}/files", response_model=JobFilesResponse)
833
+ async def get_job_files(
834
+ job_id: str,
835
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
836
+ ):
837
+ """
838
+ Get all files for a job with signed download URLs.
839
+
840
+ Returns a list of all files associated with the job, including:
841
+ - Input audio file
842
+ - Stem separation results (vocals, instrumentals, etc.)
843
+ - Lyrics files (LRC, ASS, corrections JSON)
844
+ - Screen files (title, end screens)
845
+ - Video files (with/without vocals)
846
+ - Final output files (various formats)
847
+ - Package files (CDG, TXT zips)
848
+
849
+ Each file includes a signed URL that's valid for 2 hours.
850
+ Non-GCS entries (like YouTube URLs) are excluded.
851
+
852
+ Requires admin authentication.
853
+ """
854
+ job_manager = JobManager()
855
+ job = job_manager.get_job(job_id)
856
+
857
+ if not job:
858
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
859
+
860
+ # Extract all files with signed URLs
861
+ storage = StorageService()
862
+ file_urls = job.file_urls or {}
863
+
864
+ files = _extract_files_recursive(file_urls, storage)
865
+
866
+ return JobFilesResponse(
867
+ job_id=job.job_id,
868
+ artist=job.artist,
869
+ title=job.title,
870
+ files=files,
871
+ total_files=len(files),
872
+ )
873
+
874
+
875
+ @router.patch("/jobs/{job_id}", response_model=JobUpdateResponse)
876
+ async def update_job(
877
+ job_id: str,
878
+ request: Dict[str, Any],
879
+ auth_data: AuthResult = Depends(require_admin),
880
+ ):
881
+ """
882
+ Update editable fields of a job (admin only).
883
+
884
+ This endpoint allows admins to update certain job fields without
885
+ affecting the job's processing state. It's useful for:
886
+ - Correcting artist/title typos
887
+ - Changing user assignment
888
+ - Updating delivery settings (email, theme, etc.)
889
+
890
+ Editable fields:
891
+ - artist, title: Track metadata
892
+ - user_email: Job owner
893
+ - theme_id: Visual theme
894
+ - enable_cdg, enable_txt, enable_youtube_upload: Output options
895
+ - customer_email, customer_notes: Made-for-you order info
896
+ - brand_prefix: Brand code prefix
897
+ - non_interactive, prep_only: Workflow options
898
+ - discord_webhook_url: Notification URL
899
+ - youtube_description, youtube_description_template: YouTube settings
900
+
901
+ Non-editable fields (will return 400 error):
902
+ - job_id, status, progress: System-managed
903
+ - created_at, updated_at: Timestamps
904
+ - state_data, file_urls, timeline: Processing state
905
+ - worker_logs, worker_ids: Audit/tracking data
906
+
907
+ For status changes, use the reset endpoint instead.
908
+ """
909
+ admin_email = auth_data.user_email or "unknown"
910
+
911
+ # Check for non-editable fields in request
912
+ non_editable_fields = set(request.keys()) - EDITABLE_JOB_FIELDS
913
+ if non_editable_fields:
914
+ raise HTTPException(
915
+ status_code=400,
916
+ detail=f"The following fields are not editable: {', '.join(sorted(non_editable_fields))}. "
917
+ f"Editable fields are: {', '.join(sorted(EDITABLE_JOB_FIELDS))}"
918
+ )
919
+
920
+ # Filter to only include provided fields (non-None values)
921
+ updates = {k: v for k, v in request.items() if v is not None}
922
+
923
+ if not updates:
924
+ raise HTTPException(
925
+ status_code=400,
926
+ detail="No valid fields provided for update. "
927
+ f"Editable fields are: {', '.join(sorted(EDITABLE_JOB_FIELDS))}"
928
+ )
929
+
930
+ job_manager = JobManager()
931
+ job = job_manager.get_job(job_id)
932
+
933
+ if not job:
934
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
935
+
936
+ # Perform the update
937
+ success = job_manager.update_job(job_id, updates)
938
+
939
+ if not success:
940
+ raise HTTPException(
941
+ status_code=500,
942
+ detail="Failed to update job. Please try again."
943
+ )
944
+
945
+ # Log the admin action
946
+ logger.info(
947
+ f"Admin {admin_email} updated job {job_id}. "
948
+ f"Updated fields: {list(updates.keys())}"
949
+ )
950
+
951
+ return JobUpdateResponse(
952
+ status="success",
953
+ job_id=job_id,
954
+ updated_fields=list(updates.keys()),
955
+ message=f"Successfully updated {len(updates)} field(s)",
956
+ )
957
+
958
+
959
+ # =============================================================================
960
+ # Job Reset Endpoint
961
+ # =============================================================================
962
+
963
+ class JobResetRequest(BaseModel):
964
+ """Request model for resetting a job to a specific state."""
965
+ target_state: str
966
+
967
+
968
+ class JobResetResponse(BaseModel):
969
+ """Response from job reset endpoint."""
970
+ status: str
971
+ job_id: str
972
+ previous_status: str
973
+ new_status: str
974
+ message: str
975
+ cleared_data: List[str]
976
+
977
+
978
+ # States that are allowed as reset targets
979
+ ALLOWED_RESET_STATES = {
980
+ "pending",
981
+ "awaiting_audio_selection",
982
+ "awaiting_review",
983
+ "awaiting_instrumental_selection",
984
+ }
985
+
986
+ # State data keys to clear for each reset target
987
+ # Keys not in this mapping are preserved
988
+ STATE_DATA_CLEAR_KEYS = {
989
+ "pending": [
990
+ "audio_search_results",
991
+ "audio_search_count",
992
+ "remote_search_id",
993
+ "audio_selection",
994
+ "review_complete",
995
+ "corrected_lyrics",
996
+ "instrumental_selection",
997
+ "video_progress",
998
+ "render_progress",
999
+ "screens_progress",
1000
+ ],
1001
+ "awaiting_audio_selection": [
1002
+ "audio_selection",
1003
+ "review_complete",
1004
+ "corrected_lyrics",
1005
+ "instrumental_selection",
1006
+ "video_progress",
1007
+ "render_progress",
1008
+ "screens_progress",
1009
+ ],
1010
+ "awaiting_review": [
1011
+ "review_complete",
1012
+ "corrected_lyrics",
1013
+ "instrumental_selection",
1014
+ "video_progress",
1015
+ "render_progress",
1016
+ "screens_progress",
1017
+ ],
1018
+ "awaiting_instrumental_selection": [
1019
+ "instrumental_selection",
1020
+ "video_progress",
1021
+ "render_progress",
1022
+ "screens_progress",
1023
+ ],
1024
+ }
1025
+
1026
+
1027
+ @router.post("/jobs/{job_id}/reset", response_model=JobResetResponse)
1028
+ async def reset_job(
1029
+ job_id: str,
1030
+ request: JobResetRequest,
1031
+ auth_data: AuthResult = Depends(require_admin),
1032
+ user_service: UserService = Depends(get_user_service),
1033
+ ):
1034
+ """
1035
+ Reset a job to a specific state for re-processing (admin only).
1036
+
1037
+ This endpoint allows admins to reset a job back to specific workflow
1038
+ checkpoints to re-do parts of the processing. This is useful for:
1039
+ - Re-running audio search after flacfetch updates
1040
+ - Re-reviewing lyrics after corrections
1041
+ - Re-selecting instrumental after hearing the result
1042
+ - Restarting a failed job from the beginning
1043
+
1044
+ Allowed target states:
1045
+ - pending: Restart from the beginning (clears all processing data)
1046
+ - awaiting_audio_selection: Re-select audio source
1047
+ - awaiting_review: Re-review lyrics (preserves audio stems)
1048
+ - awaiting_instrumental_selection: Re-select instrumental (preserves review)
1049
+
1050
+ State data is cleared based on the target state to ensure a clean
1051
+ re-processing from that point forward.
1052
+ """
1053
+ admin_email = auth_data.user_email or "unknown"
1054
+ target_state = request.target_state.lower()
1055
+
1056
+ # Validate target state
1057
+ if target_state not in ALLOWED_RESET_STATES:
1058
+ raise HTTPException(
1059
+ status_code=400,
1060
+ detail=f"Invalid target state '{target_state}'. "
1061
+ f"Allowed states are: {', '.join(sorted(ALLOWED_RESET_STATES))}"
1062
+ )
1063
+
1064
+ job_manager = JobManager()
1065
+ job = job_manager.get_job(job_id)
1066
+
1067
+ if not job:
1068
+ raise HTTPException(status_code=404, detail=f"Job {job_id} not found")
1069
+
1070
+ previous_status = job.status
1071
+
1072
+ # Build update payload
1073
+ updates = {
1074
+ "status": target_state,
1075
+ "progress": 0,
1076
+ "message": f"Job reset to {target_state} by admin",
1077
+ "updated_at": datetime.utcnow().isoformat(),
1078
+ }
1079
+
1080
+ # Clear state data keys based on target state
1081
+ keys_to_clear = STATE_DATA_CLEAR_KEYS.get(target_state, [])
1082
+ cleared_keys = []
1083
+ current_state_data = job.state_data or {}
1084
+
1085
+ for key in keys_to_clear:
1086
+ if key in current_state_data:
1087
+ cleared_keys.append(key)
1088
+
1089
+ # Add timeline event
1090
+ timeline_event = {
1091
+ "status": target_state,
1092
+ "timestamp": datetime.utcnow().isoformat(),
1093
+ "message": f"Admin reset from {previous_status} to {target_state}",
1094
+ }
1095
+
1096
+ # Perform the update with state_data clearing
1097
+ # We need to set the cleared keys to DELETE_FIELD
1098
+ success = job_manager.update_job(job_id, updates)
1099
+
1100
+ if not success:
1101
+ raise HTTPException(
1102
+ status_code=500,
1103
+ detail="Failed to reset job. Please try again."
1104
+ )
1105
+
1106
+ # Clear the state data keys separately using direct Firestore update
1107
+ from google.cloud.firestore_v1 import DELETE_FIELD, ArrayUnion
1108
+
1109
+ job_ref = user_service.db.collection("jobs").document(job_id)
1110
+
1111
+ if cleared_keys:
1112
+ clear_updates = {}
1113
+ for key in cleared_keys:
1114
+ clear_updates[f"state_data.{key}"] = DELETE_FIELD
1115
+
1116
+ # Add timeline event
1117
+ clear_updates["timeline"] = ArrayUnion([timeline_event])
1118
+
1119
+ job_ref.update(clear_updates)
1120
+ else:
1121
+ # Just add timeline event
1122
+ job_ref.update({
1123
+ "timeline": ArrayUnion([timeline_event])
1124
+ })
1125
+
1126
+ # Log the admin action
1127
+ logger.info(
1128
+ f"Admin {admin_email} reset job {job_id} from {previous_status} to {target_state}. "
1129
+ f"Cleared state_data keys: {cleared_keys}"
1130
+ )
1131
+
1132
+ return JobResetResponse(
1133
+ status="success",
1134
+ job_id=job_id,
1135
+ previous_status=previous_status,
1136
+ new_status=target_state,
1137
+ message=f"Job reset from {previous_status} to {target_state}",
1138
+ cleared_data=cleared_keys,
1139
+ )
1140
+
1141
+
705
1142
  @router.get("/jobs/{job_id}/completion-message", response_model=CompletionMessageResponse)
706
1143
  async def get_job_completion_message(
707
1144
  job_id: str,
@@ -833,3 +1270,77 @@ async def send_job_completion_email(
833
1270
  status_code=500,
834
1271
  detail="Failed to send email. Check email service configuration."
835
1272
  )
1273
+
1274
+
1275
+ # =============================================================================
1276
+ # User Impersonation
1277
+ # =============================================================================
1278
+
1279
+ class ImpersonateUserResponse(BaseModel):
1280
+ """Response from impersonate user endpoint."""
1281
+ session_token: str
1282
+ user_email: str
1283
+ message: str
1284
+
1285
+
1286
+ @router.post("/users/{email}/impersonate", response_model=ImpersonateUserResponse)
1287
+ async def impersonate_user(
1288
+ email: str,
1289
+ auth_data: Tuple[str, UserType, int] = Depends(require_admin),
1290
+ user_service: UserService = Depends(get_user_service),
1291
+ ):
1292
+ """
1293
+ Create a session token to impersonate a user (admin only).
1294
+
1295
+ This allows admins to view the application exactly as a specific user would see it.
1296
+ The admin's original session remains valid and can be restored client-side.
1297
+
1298
+ Security:
1299
+ - Only admins can impersonate
1300
+ - Creates a real session (auditable in Firestore)
1301
+ - Impersonation is logged for security audit
1302
+
1303
+ Args:
1304
+ email: Email of the user to impersonate
1305
+
1306
+ Returns:
1307
+ session_token: A valid session token for the target user
1308
+ user_email: The impersonated user's email
1309
+ message: Success message
1310
+ """
1311
+ admin_email = auth_data[0]
1312
+ target_email = email.lower()
1313
+
1314
+ # Cannot impersonate yourself
1315
+ if target_email == admin_email.lower():
1316
+ raise HTTPException(
1317
+ status_code=400,
1318
+ detail="Cannot impersonate yourself"
1319
+ )
1320
+
1321
+ # Verify target user exists
1322
+ target_user = user_service.get_user(target_email)
1323
+ if not target_user:
1324
+ raise HTTPException(
1325
+ status_code=404,
1326
+ detail=f"User {target_email} not found"
1327
+ )
1328
+
1329
+ # Create a real session for the target user
1330
+ session = user_service.create_session(
1331
+ user_email=target_email,
1332
+ ip_address=None, # Not tracking IP for impersonation
1333
+ user_agent=f"Impersonation by {admin_email}",
1334
+ )
1335
+
1336
+ # Log impersonation for audit trail
1337
+ logger.info(
1338
+ f"IMPERSONATION: Admin {admin_email} started impersonating user {target_email}. "
1339
+ f"Session token prefix: {session.token[:12]}..."
1340
+ )
1341
+
1342
+ return ImpersonateUserResponse(
1343
+ session_token=session.token,
1344
+ user_email=target_email,
1345
+ message=f"Now impersonating {target_email}",
1346
+ )
@@ -38,6 +38,7 @@ from backend.config import get_settings
38
38
  from backend.version import VERSION
39
39
  from backend.api.dependencies import require_auth
40
40
  from backend.services.auth_service import UserType, AuthResult
41
+ from backend.middleware.tenant import get_tenant_config_from_request
41
42
  from pathlib import Path
42
43
 
43
44
  logger = logging.getLogger(__name__)
@@ -479,17 +480,25 @@ async def search_audio(
479
480
  ):
480
481
  """
481
482
  Search for audio by artist and title, creating a new job.
482
-
483
+
483
484
  This endpoint:
484
485
  1. Creates a job in PENDING state
485
486
  2. Searches for audio using flacfetch
486
487
  3. Either returns search results for user selection, or
487
488
  4. If auto_download=True, automatically selects best and starts processing
488
-
489
+
489
490
  Use cases:
490
491
  - Interactive mode (default): Returns results, user calls /select endpoint
491
492
  - Auto mode (auto_download=True): Automatically selects and downloads best
492
493
  """
494
+ # Check tenant feature flag
495
+ tenant_config = get_tenant_config_from_request(request)
496
+ if tenant_config and not tenant_config.features.audio_search:
497
+ raise HTTPException(
498
+ status_code=403,
499
+ detail="Audio search is not available for this portal"
500
+ )
501
+
493
502
  try:
494
503
  # Apply default distribution settings
495
504
  settings = get_settings()
@@ -585,6 +594,8 @@ async def search_audio(
585
594
  auto_download=body.auto_download,
586
595
  request_metadata=request_metadata,
587
596
  non_interactive=body.non_interactive,
597
+ # Tenant scoping
598
+ tenant_id=tenant_config.id if tenant_config else None,
588
599
  )
589
600
  job = job_manager.create_job(job_create)
590
601
  job_id = job.job_id
@@ -34,6 +34,7 @@ from backend.version import VERSION
34
34
  from backend.services.metrics import metrics
35
35
  from backend.api.dependencies import require_auth
36
36
  from backend.services.auth_service import UserType, AuthResult
37
+ from backend.middleware.tenant import get_tenant_config_from_request
37
38
 
38
39
  logger = logging.getLogger(__name__)
39
40
  router = APIRouter(tags=["jobs"])
@@ -446,6 +447,14 @@ async def upload_and_create_job(
446
447
  The style_params JSON can reference the uploaded images/fonts by their original
447
448
  filenames, and the backend will update the paths to GCS locations.
448
449
  """
450
+ # Check tenant feature flag
451
+ tenant_config = get_tenant_config_from_request(request)
452
+ if tenant_config and not tenant_config.features.file_upload:
453
+ raise HTTPException(
454
+ status_code=403,
455
+ detail="File upload is not available for this portal"
456
+ )
457
+
449
458
  try:
450
459
  # Validate main audio file type
451
460
  file_ext = Path(file.filename).suffix.lower()
@@ -632,10 +641,12 @@ async def upload_and_create_job(
632
641
  request_metadata=request_metadata,
633
642
  # Non-interactive mode
634
643
  non_interactive=non_interactive,
644
+ # Tenant scoping
645
+ tenant_id=tenant_config.id if tenant_config else None,
635
646
  )
636
647
  job = job_manager.create_job(job_create)
637
648
  job_id = job.job_id
638
-
649
+
639
650
  # Record job creation metric
640
651
  metrics.record_job_created(job_id, source="upload")
641
652
 
@@ -1029,6 +1040,14 @@ async def create_job_with_upload_urls(
1029
1040
  - Works with any HTTP client (no HTTP/2 required)
1030
1041
  - Resumable uploads possible with GCS
1031
1042
  """
1043
+ # Check tenant feature flag
1044
+ tenant_config = get_tenant_config_from_request(request)
1045
+ if tenant_config and not tenant_config.features.file_upload:
1046
+ raise HTTPException(
1047
+ status_code=403,
1048
+ detail="File upload is not available for this portal"
1049
+ )
1050
+
1032
1051
  try:
1033
1052
  # Validate files list
1034
1053
  if not body.files:
@@ -1153,6 +1172,8 @@ async def create_job_with_upload_urls(
1153
1172
  other_stems_models=body.other_stems_models,
1154
1173
  request_metadata=request_metadata,
1155
1174
  non_interactive=body.non_interactive,
1175
+ # Tenant scoping
1176
+ tenant_id=tenant_config.id if tenant_config else None,
1156
1177
  )
1157
1178
  job = job_manager.create_job(job_create)
1158
1179
  job_id = job.job_id
@@ -1481,6 +1502,14 @@ async def create_job_from_url(
1481
1502
  Note: YouTube rate limiting may cause occasional download failures.
1482
1503
  The backend will retry automatically.
1483
1504
  """
1505
+ # Check tenant feature flag
1506
+ tenant_config = get_tenant_config_from_request(request)
1507
+ if tenant_config and not tenant_config.features.youtube_url:
1508
+ raise HTTPException(
1509
+ status_code=403,
1510
+ detail="URL-based job creation is not available for this portal"
1511
+ )
1512
+
1484
1513
  try:
1485
1514
  # Validate URL
1486
1515
  if not _validate_url(body.url):
@@ -1583,6 +1612,8 @@ async def create_job_from_url(
1583
1612
  other_stems_models=body.other_stems_models,
1584
1613
  request_metadata=request_metadata,
1585
1614
  non_interactive=body.non_interactive,
1615
+ # Tenant scoping
1616
+ tenant_id=tenant_config.id if tenant_config else None,
1586
1617
  )
1587
1618
  job = job_manager.create_job(job_create)
1588
1619
  job_id = job.job_id
@@ -1703,6 +1734,14 @@ async def create_finalise_only_job(
1703
1734
 
1704
1735
  The endpoint returns signed URLs for uploading all the prep files.
1705
1736
  """
1737
+ # Check tenant feature flag - finalise-only requires file upload capability
1738
+ tenant_config = get_tenant_config_from_request(request)
1739
+ if tenant_config and not tenant_config.features.file_upload:
1740
+ raise HTTPException(
1741
+ status_code=403,
1742
+ detail="File upload is not available for this portal"
1743
+ )
1744
+
1706
1745
  try:
1707
1746
  # Validate files list
1708
1747
  if not body.files:
@@ -1834,6 +1873,8 @@ async def create_finalise_only_job(
1834
1873
  finalise_only=True,
1835
1874
  keep_brand_code=body.keep_brand_code,
1836
1875
  request_metadata=request_metadata,
1876
+ # Tenant scoping
1877
+ tenant_id=tenant_config.id if tenant_config else None,
1837
1878
  )
1838
1879
  job = job_manager.create_job(job_create)
1839
1880
  job_id = job.job_id
@@ -371,6 +371,12 @@ async def check_idle_reminder(
371
371
  add_span_event("already_sent")
372
372
  return {"status": "already_sent", "job_id": job_id, "message": "Reminder already sent"}
373
373
 
374
+ # Skip reminders for made-for-you jobs (admin handles these directly, no intermediate customer emails)
375
+ if getattr(job, 'made_for_you', False):
376
+ logger.info(f"[job:{job_id}] Made-for-you job, skipping customer reminder (admin handles)")
377
+ add_span_event("made_for_you_skip")
378
+ return {"status": "skipped", "job_id": job_id, "message": "Made-for-you job - admin handles directly"}
379
+
374
380
  # Check if user has an email
375
381
  if not job.user_email:
376
382
  logger.warning(f"[job:{job_id}] No user email, cannot send reminder")