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/api/routes/admin.py
CHANGED
|
@@ -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
|
backend/api/routes/internal.py
CHANGED
|
@@ -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")
|