"""Bulk add artists page - upload CSV to add multiple artists at once.""" import streamlit as st from common.db import get_session from common.environment import IS_PROD, SCHEMA_NAME from common.services import ( is_vendor_sme, parse_csv_for_bulk_upload, search_vendors, validate_bulk_artists, ) from common.types import BulkValidationError from common.utils import ( format_roster_type_display, init_session_state, search_countries, validate_country_code, ) st.title("📤 Bulk Add Artists to Roster") st.write("Upload CSV file to add multiple artists at once (max 250 artists)") # Get session session = get_session() # Show schema indicator if IS_PROD: st.error("âš ī¸ PRODUCTION MODE - Writes are BLOCKED for safety") else: st.info(f"📊 Using schema: {SCHEMA_NAME}") # Initialize session state init_session_state("bulk_vendor_id", None) init_session_state("bulk_roster_type", None) init_session_state("bulk_country_code", "") init_session_state("bulk_csv_df", None) init_session_state("bulk_valid_forms", None) init_session_state("bulk_validation_errors", None) init_session_state("bulk_already_exists", None) init_session_state("bulk_upload_success", False) init_session_state("bulk_upload_message", None) init_session_state("bulk_last_vendor_id", None) init_session_state("bulk_last_roster_type", None) init_session_state("bulk_last_country_code", None) init_session_state("bulk_last_file_id", None) st.divider() # Step 1: Vendor Selection st.subheader("Step 1: Select Vendor") col1, col2 = st.columns(2) with col1: vendor_id_search = st.text_input( "Vendor ID", value=st.session_state.bulk_vendor_id or "", help="Enter exact vendor ID", key="bulk_vendor_id_search", ) with col2: vendor_name_search = st.text_input( "Or search by Vendor Name", help="Search for vendor by name", key="bulk_vendor_name_search", ) # Vendor search results if vendor_name_search: with st.spinner("Searching vendors..."): vendors = search_vendors(session, vendor_name=vendor_name_search) if vendors: st.write(f"Found {len(vendors)} vendor(s):") for idx, vendor in enumerate(vendors[:10]): col_a, col_b = st.columns([4, 1]) with col_a: st.code(f"ID: {vendor.vendor_id} | Name: {vendor.name}") with col_b: if st.button("Select", key=f"bulk_select_vendor_{vendor.vendor_id}_{idx}"): st.session_state.bulk_vendor_id = str(vendor.vendor_id) # Clear validation when vendor changes st.session_state.bulk_valid_forms = None st.session_state.bulk_validation_errors = None st.session_state.bulk_already_exists = None st.rerun() else: st.warning("No vendors found") # Detect vendor brand type vendor_brand_type = None available_roster_types = [] vendor_display_name = None final_vendor_id = vendor_id_search or st.session_state.bulk_vendor_id if final_vendor_id: try: vendor_id = int(final_vendor_id) with st.spinner("Loading vendor information..."): vendors = search_vendors(session, vendor_id=vendor_id) if vendors: vendor_display_name = vendors[0].name is_sme = is_vendor_sme(session, vendor_id) vendor_brand_type = "SME" if is_sme else "NON_SME" if is_sme: available_roster_types = ["MAIN_REP", "LOCAL_REP"] st.success( f"✅ **Vendor: {vendor_display_name}** (ID: {vendor_id})\n\n" "đŸĸ SME Brand (Sony Music) - can add to Rep Owner or IRL Rep" ) else: available_roster_types = ["ARTIST_ROSTER"] st.success( f"✅ **Vendor: {vendor_display_name}** (ID: {vendor_id})\n\n" "đŸĸ Non-SME Brand - will be added to ARTIST_ROSTER" ) except ValueError: st.warning("Invalid Vendor ID format") st.divider() # Step 2: Roster Type Selection st.subheader("Step 2: Select Roster Type") roster_type: str | None = None if vendor_brand_type == "SME": default_index = 0 if ( st.session_state.bulk_roster_type and st.session_state.bulk_roster_type in available_roster_types ): default_index = available_roster_types.index(st.session_state.bulk_roster_type) roster_type = st.selectbox( "Target Roster *", options=available_roster_types, index=default_index, format_func=format_roster_type_display, help="Select Rep Owner or IRL Rep for SME brands", key="bulk_roster_type_select", ) if roster_type != st.session_state.bulk_roster_type: print(f"🔄 Roster type changed: {st.session_state.bulk_roster_type} -> {roster_type}") st.session_state.bulk_roster_type = roster_type # Clear validation when roster type changes st.session_state.bulk_valid_forms = None st.session_state.bulk_validation_errors = None st.session_state.bulk_already_exists = None print(f"❌ Cleared validation due to roster type change") elif vendor_brand_type == "NON_SME": roster_type = "ARTIST_ROSTER" st.text_input( "Target Roster *", value="ARTIST_ROSTER", disabled=True, help="Non-SME brands automatically use ARTIST_ROSTER", key="bulk_roster_type_display", ) if st.session_state.bulk_roster_type != "ARTIST_ROSTER": print(f"🔄 NON-SME: Roster type changed to ARTIST_ROSTER from {st.session_state.bulk_roster_type}") st.session_state.bulk_roster_type = "ARTIST_ROSTER" # Clear validation when roster type changes st.session_state.bulk_valid_forms = None st.session_state.bulk_validation_errors = None st.session_state.bulk_already_exists = None print(f"❌ Cleared validation due to NON-SME roster type change") else: st.selectbox( "Target Roster *", options=[], help="Select Vendor first (Step 1) to see available roster types", key="bulk_roster_type_placeholder", disabled=True, ) st.divider() # Step 3: IRL Configuration (conditional) country_code_input = None if roster_type == "LOCAL_REP": st.subheader("Step 3: Select Country (IRL Rep)") st.write("**Country applies to ALL artists in the CSV**") col1, col2 = st.columns([2, 1]) with col1: country_search = st.text_input( "Search Country by Name", help="Type country name to search (e.g., 'United States', 'Ukraine', 'Japan')", key="bulk_country_search_input", ) with col2: country_code_raw = st.text_input( "Or enter Country Code *", value=st.session_state.bulk_country_code, max_chars=2, help="2-letter ISO code (e.g., US, UA, JP)", key="bulk_country_code_input", ) # Show search results if country_search and len(country_search) >= 2: countries = search_countries(country_search) if countries: st.write(f"Found {len(countries)} countrie(s):") for idx, (code, name) in enumerate(countries[:10]): col_a, col_b = st.columns([4, 1]) with col_a: st.code(f"{name} ({code})") with col_b: if st.button("Select", key=f"bulk_select_country_{code}_{idx}"): st.session_state.bulk_country_code = code # Clear validation when country changes st.session_state.bulk_valid_forms = None st.session_state.bulk_validation_errors = None st.session_state.bulk_already_exists = None st.rerun() else: st.warning("No countries found") # Update session state country_code_input = country_code_raw.upper() if country_code_input != st.session_state.bulk_country_code: st.session_state.bulk_country_code = country_code_input if st.session_state.bulk_country_code: country_code_input = st.session_state.bulk_country_code # Validate country code if country_code_input: is_valid, country_name = validate_country_code(country_code_input) if is_valid and country_name: st.success(f"✅ Valid country code: **{country_code_input}** ({country_name})") else: if len(country_code_input) != 2: st.error("❌ Country code must be exactly 2 characters") elif not country_code_input.isalpha(): st.error("❌ Country code must contain only letters") elif not country_code_input.isupper(): st.error("❌ Country code must be uppercase") else: st.error(f"❌ Invalid ISO Alpha-2 country code: **{country_code_input}**") st.divider() # Step 4: CSV Format Instructions if roster_type: st.subheader("Step 4: CSV Format Instructions") with st.expander("📋 CSV Format & Requirements", expanded=True): st.info("**Maximum 250 artists per upload**") if roster_type == "MAIN_REP": st.markdown( """ **Required Columns:** 1. `artist_name` - Artist display name 2. `spotify_id` - Spotify ID (URL/URI/plain ID format) **Optional Columns:** 3. `status` - ACTIVE or INACTIVE (defaults to ACTIVE if missing) **Format Options:** - 2 columns: `artist_name, spotify_id` (all treated as ACTIVE) - 3 columns: `artist_name, spotify_id, status` **Example CSV:** ``` artist_name,spotify_id,status Taylor Swift,06HL4z0CvFAxyc27GXpf02,ACTIVE Ed Sheeran,6eUKZXaKkcviH0Ku9w2n3V,INACTIVE ``` """ ) st.markdown( """ **📄 CSV Template:** [View Google Sheets Template](https://docs.google.com/spreadsheets/d/11-ZCcA0Qxz1rtpB3AIcit5yj0yFSY3p4xUQmSscC0Bw/edit?gid=0#gid=0) **How to use:** 1. Open the template above 2. Copy it to your Google Drive (File → Make a copy) 3. Fill in your artist data 4. Export as CSV: **File → Download → Comma Separated Values (.csv)** 5. Upload the CSV file below """ ) elif roster_type == "LOCAL_REP": st.markdown( """ **Required Columns:** 1. `artist_name` - Artist display name 2. `spotify_id` - Spotify ID (URL/URI/plain ID format) **Note:** Country selected above applies to ALL artists in CSV **Example CSV:** ``` artist_name,spotify_id Taylor Swift,06HL4z0CvFAxyc27GXpf02 Ed Sheeran,6eUKZXaKkcviH0Ku9w2n3V ``` """ ) st.markdown( """ **📄 CSV Template:** [View Google Sheets Template](https://docs.google.com/spreadsheets/d/11-ZCcA0Qxz1rtpB3AIcit5yj0yFSY3p4xUQmSscC0Bw/edit?gid=1111618980#gid=1111618980) **How to use:** 1. Open the template above 2. Copy it to your Google Drive (File → Make a copy) 3. Fill in your artist data 4. Export as CSV: **File → Download → Comma Separated Values (.csv)** 5. Upload the CSV file below """ ) else: # ARTIST_ROSTER st.markdown( """ **Required Columns:** 1. `artist_name` - Artist display name 2. `spotify_id` - Spotify ID (URL/URI/plain ID format) **Example CSV:** ``` artist_name,spotify_id Taylor Swift,06HL4z0CvFAxyc27GXpf02 Ed Sheeran,6eUKZXaKkcviH0Ku9w2n3V ``` """ ) st.markdown( """ **📄 CSV Template:** [View Google Sheets Template](https://docs.google.com/spreadsheets/d/11-ZCcA0Qxz1rtpB3AIcit5yj0yFSY3p4xUQmSscC0Bw/edit?gid=1111618980#gid=1111618980) **How to use:** 1. Open the template above 2. Copy it to your Google Drive (File → Make a copy) 3. Fill in your artist data 4. Export as CSV: **File → Download → Comma Separated Values (.csv)** 5. Upload the CSV file below """ ) st.markdown( """ **Spotify ID Formats Supported:** - URL: `https://open.spotify.com/artist/06HL4z0CvFAxyc27GXpf02` - URI: `spotify:artist:06HL4z0CvFAxyc27GXpf02` - Plain ID: `06HL4z0CvFAxyc27GXpf02` **Validation Rules:** - All artists must exist in database (matched by Spotify ID) - Duplicate Spotify IDs with different names = ERROR - Duplicate entries (same ID + same name) = Auto-deduplicated - Artists already in roster with different data = ERROR - Artists already in roster with same data = SKIPPED (reported at end) """ ) st.divider() # Step 5: CSV Upload st.subheader("Step 5: Upload CSV File") # Check if ready to upload upload_ready = False if roster_type == "LOCAL_REP": if country_code_input: is_valid, _ = validate_country_code(country_code_input) upload_ready = bool(final_vendor_id and is_valid) else: upload_ready = False else: upload_ready = bool(final_vendor_id and roster_type) if not upload_ready: if not final_vendor_id: st.warning("âš ī¸ Please select a vendor first (Step 1)") elif not roster_type: st.warning("âš ī¸ Please select roster type (Step 2)") elif roster_type == "LOCAL_REP" and not country_code_input: st.warning("âš ī¸ Please select country for IRL Rep (Step 3)") elif roster_type == "LOCAL_REP": st.warning("âš ī¸ Please enter valid country code (Step 3)") uploaded_file = st.file_uploader( "Upload CSV File", type=["csv"], help="Upload CSV with artist data (max 250 artists)", key="bulk_csv_uploader", disabled=not upload_ready, ) if uploaded_file is not None and roster_type: # Get unique file identifier (using file_id if available, otherwise name+size) if hasattr(uploaded_file, 'file_id'): current_file_id = uploaded_file.file_id else: current_file_id = f"{uploaded_file.name}_{uploaded_file.size}" print(f"📁 File uploaded: {uploaded_file.name}, ID: {current_file_id}, Last ID: {st.session_state.bulk_last_file_id}") print(f"📊 Current validation state: valid_forms={len(st.session_state.bulk_valid_forms or [])}, errors={len(st.session_state.bulk_validation_errors or [])}") # Clear validation results only if this is a NEW file (not the same file after rerun) if current_file_id != st.session_state.bulk_last_file_id: print(f"🆕 NEW FILE detected - clearing validation") st.session_state.bulk_valid_forms = None st.session_state.bulk_validation_errors = None st.session_state.bulk_already_exists = None st.session_state.bulk_last_file_id = current_file_id else: print(f"✅ SAME FILE - keeping validation results") # Parse CSV df, parse_errors = parse_csv_for_bulk_upload(uploaded_file, roster_type) if parse_errors: st.error("❌ CSV Parsing Errors:") for error in parse_errors: st.code(error) else: # Show preview st.success(f"✅ CSV uploaded successfully - {len(df)} rows") with st.expander("Preview First 10 Rows", expanded=False): st.dataframe(df.head(10), use_container_width=True) # Validate button if st.button("🔍 Validate Artists", type="primary", use_container_width=True): try: if not roster_type: st.error("Roster type is required") else: # Show progress indicators progress_text = st.empty() progress_bar = st.progress(0) progress_text.text(f"🔍 Starting validation of {len(df)} artists...") # Store progress indicators in session state so validate function can update them st.session_state.validation_progress_bar = progress_bar st.session_state.validation_progress_text = progress_text st.session_state.validation_total = len(df) valid_forms, validation_errors, already_exists = validate_bulk_artists( session, df, int(final_vendor_id), roster_type, country_code_input, ) # Clear progress indicators progress_bar.empty() progress_text.empty() # Log validation results print(f"✅ Validation complete:") print(f" - Valid forms: {len(valid_forms)}") print(f" - Errors: {len(validation_errors)}") print(f" - Already exists: {len(already_exists)}") # Store in session state st.session_state.bulk_valid_forms = valid_forms st.session_state.bulk_validation_errors = validation_errors st.session_state.bulk_already_exists = already_exists print(f"💾 Stored in session state - about to rerun") print(f" Session state before rerun: valid_forms={len(st.session_state.bulk_valid_forms or [])}, errors={len(st.session_state.bulk_validation_errors or [])}") # Show immediate success message before rerun if not validation_errors: st.success(f"✅ Validation complete - {len(valid_forms)} artists ready to add!") else: st.error(f"❌ Found {len(validation_errors)} validation errors") st.rerun() except Exception as e: import traceback st.error(f"Validation error: {str(e)}") st.code(traceback.format_exc()) print(f"❌ Validation exception: {str(e)}") print(traceback.format_exc()) # Step 6: Show validation results if st.session_state.bulk_validation_errors is not None: errors = st.session_state.bulk_validation_errors already_exists = st.session_state.bulk_already_exists or [] valid_forms = st.session_state.bulk_valid_forms or [] print(f"📋 Showing validation results:") print(f" Valid forms: {len(valid_forms)}") print(f" Errors: {len(errors)}") print(f" Already exists: {len(already_exists)}") st.divider() st.subheader("Step 6: Validation Results") if errors: # Show errors grouped by type st.error( f"❌ Found {len(errors)} validation error(s) - Fix these before uploading" ) # Group errors by type errors_by_type: dict[str, list[BulkValidationError]] = {} for error in errors: if error.error_type not in errors_by_type: errors_by_type[error.error_type] = [] errors_by_type[error.error_type].append(error) # Display each error type if "DUPLICATE_SPOTIFY_ID" in errors_by_type: with st.expander( f"🔴 Duplicate Spotify IDs ({len(errors_by_type['DUPLICATE_SPOTIFY_ID'])})", expanded=True, ): for err in errors_by_type["DUPLICATE_SPOTIFY_ID"]: st.code(err.format_display()) if "ARTIST_NOT_FOUND" in errors_by_type: with st.expander( f"🔴 Artists Not Found ({len(errors_by_type['ARTIST_NOT_FOUND'])})", expanded=True, ): st.warning( "These artists are not in the database. Check Spotify IDs or remove these rows." ) for err in errors_by_type["ARTIST_NOT_FOUND"]: st.code(err.format_display()) if "ALREADY_EXISTS_DIFFERENT_DATA" in errors_by_type: with st.expander( f"🔴 Already Exists with Different Data ({len(errors_by_type['ALREADY_EXISTS_DIFFERENT_DATA'])})", expanded=True, ): st.warning("These artists already exist in roster with different data.") for err in errors_by_type["ALREADY_EXISTS_DIFFERENT_DATA"]: st.code(err.format_display()) if "VALIDATION_ERROR" in errors_by_type: with st.expander( f"🔴 Validation Errors ({len(errors_by_type['VALIDATION_ERROR'])})", expanded=True, ): for err in errors_by_type["VALIDATION_ERROR"]: st.code(err.format_display()) else: # No errors - show success st.success(f"✅ Validation passed - {len(valid_forms)} artist(s) ready to add") if already_exists: st.info( f"â„šī¸ {len(already_exists)} artist(s) already in roster with same data (will be skipped)" ) with st.expander("View Artists Already in Roster", expanded=False): for artist in already_exists: st.code(artist.format_display()) # Submit button if st.button( f"✅ Add {len(valid_forms)} Artist(s) to Roster", type="primary", use_container_width=True, ): with st.spinner(f"Adding {len(valid_forms)} artists..."): progress_bar = st.progress(0) status_text = st.empty() try: success_count = 0 insert_errors = [] for i, form in enumerate(valid_forms): try: status_text.text( f"Adding artist {i + 1}/{len(valid_forms)}..." ) from common.services import add_artist_to_roster add_artist_to_roster(session, form) success_count += 1 except Exception as e: insert_errors.append( f"Artist {i + 1} ({form.artist_uuid}): {str(e)}" ) progress_bar.progress((i + 1) / len(valid_forms)) progress_bar.empty() status_text.empty() # Store result st.session_state.bulk_upload_success = True st.session_state.bulk_upload_message = { "success_count": success_count, "skipped_count": len(already_exists), "skipped_artists": already_exists, "errors": insert_errors, } # Clear validation state st.session_state.bulk_valid_forms = None st.session_state.bulk_validation_errors = None st.session_state.bulk_already_exists = None st.rerun() except Exception as e: st.error(f"Bulk insert error: {str(e)}") # Show upload success message if st.session_state.bulk_upload_success and st.session_state.bulk_upload_message: msg = st.session_state.bulk_upload_message st.divider() st.subheader("✅ Upload Complete") st.success(f"Successfully added {msg['success_count']} artist(s) to roster!") st.balloons() if msg["skipped_count"] > 0: st.info( f"â„šī¸ Skipped {msg['skipped_count']} artist(s) (already in roster with same data)" ) with st.expander("View Skipped Artists", expanded=False): for artist in msg["skipped_artists"]: st.code(artist.format_display()) if msg["errors"]: st.warning(f"âš ī¸ {len(msg['errors'])} error(s) during insert:") for error in msg["errors"]: st.code(error) # Clear success message st.session_state.bulk_upload_success = False st.session_state.bulk_upload_message = None st.divider() # Help section with st.expander("â„šī¸ Help & Rules"): st.markdown( f""" ### Bulk Upload Process **Steps:** 1. Select vendor (determines brand type) 2. Select roster type (Rep Owner/IRL Rep for SME, ARTIST_ROSTER for non-SME) 3. For IRL Rep: Select country first (applies to ALL artists) 4. Review CSV format instructions 5. Upload CSV file (max 250 artists) 6. Validate artists (checks database and existing roster) 7. Submit to add validated artists **Brand Types:** **SME (Sony Music) Brands:** - Can upload to Rep Owner OR IRL Rep - Rep Owner: Optional status column (ACTIVE/INACTIVE) - IRL Rep: Select country first, applies to ALL artists **Non-SME Brands:** - Upload to ARTIST_ROSTER only - No additional columns required **Validation Rules:** - All artists must exist in database (matched by Spotify ID) - Duplicate Spotify IDs with different names = ERROR (upload stops) - Duplicate entries (same ID + same name) = Auto-deduplicated - Artists already in roster with different data = ERROR (upload stops) - Artists already in roster with same data = SKIPPED (reported at end) **Safety Features:** - Validation happens before any inserts - MERGE queries prevent duplicates - Upload stops completely if ANY errors found - Current schema: {SCHEMA_NAME} """ )