"""Add virtual participant page - create VIRTUAL_PARTICIPANT with CUSTOM_LIST.""" import streamlit as st from common.db import get_session from common.environment import IS_PROD, SCHEMA_NAME from common.services import ( add_virtual_participant, check_spotify_id_in_global_participant, check_spotify_id_in_virtual_participant, check_virtual_participant_name_exists, search_vendors, ) from common.types import VirtualParticipantForm from common.utils import init_session_state, parse_spotify_id st.title("➕ Add Virtual Participant") st.write("Create a new virtual participant with linked custom list") # 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("vp_type", "CUSTOM_LIST") init_session_state("vp_name", "") init_session_state("vp_spotify_id", "") init_session_state("selected_vendor_id", None) init_session_state("add_success", False) init_session_state("add_error", None) st.divider() # Step 1: Type selection st.subheader("Step 1: Select Type") vp_type = st.selectbox( "Virtual Participant Type *", options=["CUSTOM_LIST", "PENDING_ARTIST"], index=0 if st.session_state.vp_type == "CUSTOM_LIST" else 1, help=( "CUSTOM_LIST: A list without Spotify ID\n" "PENDING_ARTIST: An artist pending onboarding (optional Spotify ID)" ), key="vp_type_select", ) # Update session state if vp_type != st.session_state.vp_type: st.session_state.vp_type = vp_type # Clear Spotify ID when switching to CUSTOM_LIST if vp_type == "CUSTOM_LIST": st.session_state.vp_spotify_id = "" st.divider() # Step 2: Name input with validation st.subheader("Step 2: Enter Name") vp_name_input = st.text_input( "Name *", value=st.session_state.vp_name, help="Unique name for the virtual participant (case-insensitive)", key="vp_name_input", ) # Update session state if vp_name_input != st.session_state.vp_name: st.session_state.vp_name = vp_name_input # Real-time name validation name_is_valid = False if vp_name_input and len(vp_name_input.strip()) > 0: with st.spinner("Checking name availability..."): name_exists = check_virtual_participant_name_exists(session, vp_name_input) if name_exists: st.error("❌ Name already exists - please choose a different name") name_is_valid = False else: st.success("✅ Name is available") name_is_valid = True else: st.warning("âš ī¸ Name is required") st.divider() # Step 3: Spotify ID (conditional on type) spotify_id_is_valid = True # Default to valid (not required for CUSTOM_LIST) spotify_id_input = None if vp_type == "PENDING_ARTIST": st.subheader("Step 3: Enter Spotify ID (Optional)") spotify_id_input = st.text_input( "Spotify ID (Optional)", value=st.session_state.vp_spotify_id, help=( "Spotify Artist ID in any format:\n" "â€ĸ URL: https://open.spotify.com/artist/7tNO3vJC9zlHy2IJOx34ga\n" "â€ĸ URI: spotify:artist:4OGiMt96TFUKkKWf7Imlno\n" "â€ĸ Plain ID: 6zyG7x3rPnqitv8h5FATI9" ), key="vp_spotify_id_input", ) # Update session state if spotify_id_input != st.session_state.vp_spotify_id: st.session_state.vp_spotify_id = spotify_id_input # Real-time Spotify ID validation if spotify_id_input and len(spotify_id_input.strip()) > 0: try: parsed_id = parse_spotify_id(spotify_id_input) # Check format (22 alphanumeric characters) import re if not re.match(r"^[a-zA-Z0-9]{22}$", parsed_id): st.error( "❌ Invalid Spotify ID format (must be 22 alphanumeric characters)" ) spotify_id_is_valid = False else: # Check if exists in GLOBAL_PARTICIPANT with st.spinner("Validating Spotify ID..."): in_global = check_spotify_id_in_global_participant(session, parsed_id) if in_global: st.error( f"❌ Artist with Spotify ID '{parsed_id}' already exists in GLOBAL_PARTICIPANT. " "Use Add Artist page instead." ) spotify_id_is_valid = False else: # Check if exists in VIRTUAL_PARTICIPANT in_virtual = check_spotify_id_in_virtual_participant( session, parsed_id ) if in_virtual: st.error( f"❌ Virtual participant with Spotify ID '{parsed_id}' already exists" ) spotify_id_is_valid = False else: st.success(f"✅ Spotify ID is valid: {parsed_id}") spotify_id_is_valid = True except ValueError as e: st.error(f"❌ {str(e)}") spotify_id_is_valid = False else: # Spotify ID is optional for PENDING_ARTIST st.info("â„šī¸ Spotify ID is optional for PENDING_ARTIST") spotify_id_is_valid = True st.divider() # Step 4: Vendor selection st.subheader( f"Step {'4' if vp_type == 'PENDING_ARTIST' else '3'}: Select Vendor" ) col1, col2 = st.columns(2) with col1: vendor_id_search = st.text_input( "Vendor ID", value=st.session_state.selected_vendor_id or "", help="Enter exact vendor ID", key="vendor_id_search", ) with col2: vendor_name_search = st.text_input( "Or search by Vendor Name", help="Search for vendor by name", key="vendor_name_search", ) # Vendor search results with selection buttons 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"select_vendor_{vendor.vendor_id}_{idx}"): st.session_state.selected_vendor_id = str(vendor.vendor_id) st.rerun() else: st.warning("No vendors found") # Show vendor details when vendor ID is entered vendor_display_name = None final_vendor_id = vendor_id_search or st.session_state.selected_vendor_id if final_vendor_id: try: vendor_id_int = int(final_vendor_id) with st.spinner("Loading vendor information..."): vendors = search_vendors(session, vendor_id=vendor_id_int) if vendors: vendor_display_name = vendors[0].name st.success( f"✅ **Vendor: {vendor_display_name}** (ID: {vendor_id_int})" ) else: st.warning("âš ī¸ Vendor ID not found in database") except ValueError: st.warning("Invalid Vendor ID format") # Show subaccount info st.info("â„šī¸ Subaccount ID is automatically set to 0") st.divider() # Step 5: Submit section st.subheader( f"Step {'5' if vp_type == 'PENDING_ARTIST' else '4'}: Submit" ) # Validate if form is ready to submit form_is_ready = ( name_is_valid and final_vendor_id and spotify_id_is_valid ) # Show ready indicator if form_is_ready: st.success("✅ All required fields are filled - ready to submit!") else: st.warning("âš ī¸ Please complete all required fields above") with st.form("add_vp_form", clear_on_submit=False): # Submit button submitted = st.form_submit_button( "✅ Add Virtual Participant", type="primary" if form_is_ready else "secondary", disabled=not form_is_ready, ) if submitted: # Reset previous messages st.session_state.add_success = False st.session_state.add_error = None # Validate required fields if not vp_name_input or not vp_name_input.strip(): st.session_state.add_error = "Name is required (Step 2)" elif not final_vendor_id: st.session_state.add_error = ( f"Vendor ID is required (Step {'4' if vp_type == 'PENDING_ARTIST' else '3'})" ) else: # Try to add virtual participant try: vendor_id_int = int(final_vendor_id) # Prepare Spotify ID value final_spotify_id = None if vp_type == "PENDING_ARTIST" and spotify_id_input: final_spotify_id = parse_spotify_id(spotify_id_input.strip()) form_data = VirtualParticipantForm( name=vp_name_input.strip(), type=vp_type, vendor_id=vendor_id_int, spotify_id=final_spotify_id, subaccount_id=0, ) with st.spinner("Adding virtual participant..."): add_virtual_participant(session, form_data) st.session_state.add_success = True # Clear form fields st.session_state.vp_type = "CUSTOM_LIST" st.session_state.vp_name = "" st.session_state.vp_spotify_id = "" st.session_state.selected_vendor_id = None except ValueError as e: st.session_state.add_error = str(e) except Exception as e: st.session_state.add_error = f"Unexpected error: {str(e)}" # Trigger rerun to show messages outside form st.rerun() # Display messages outside form if st.session_state.add_success: st.success("✅ Virtual participant successfully created!") st.balloons() # Clear success flag after displaying st.session_state.add_success = False if st.session_state.add_error: st.error(f"❌ Error: {st.session_state.add_error}") # Clear error flag after displaying st.session_state.add_error = None st.divider() # Help section with st.expander("â„šī¸ Help & Rules"): st.markdown( """ ### Adding Virtual Participants **What is a Virtual Participant?** A virtual participant represents an entity that doesn't exist in GLOBAL_PARTICIPANT yet: - **CUSTOM_LIST**: A custom list without a Spotify ID (e.g., genre lists, event attendees) - **PENDING_ARTIST**: An artist pending onboarding (optional Spotify ID for future linking) **Required Fields:** - **Name**: Unique identifier (case-insensitive) - **Type**: CUSTOM_LIST or PENDING_ARTIST - **Vendor ID**: The vendor this virtual participant belongs to - **Subaccount ID**: Automatically set to 0 **Optional Fields:** - **Spotify ID** (PENDING_ARTIST only): Artist's Spotify identifier for future linking **What Gets Created:** This page creates TWO database records atomically: 1. **VIRTUAL_PARTICIPANT**: The entity record with name, type, and optional Spotify ID 2. **CUSTOM_LIST**: Links the virtual participant to a vendor **Business Rules:** 1. **Name Uniqueness**: - Names must be unique across ALL virtual participants (case-insensitive) - System checks in real-time 2. **Spotify ID Validation** (PENDING_ARTIST only): - Must be 22 alphanumeric characters - Cannot exist in GLOBAL_PARTICIPANT (use Add Artist page for existing artists) - Cannot exist in VIRTUAL_PARTICIPANT (no duplicates) - Supports multiple formats: URL, URI, or plain ID 3. **Type Rules**: - **CUSTOM_LIST**: Should NOT have a Spotify ID - **PENDING_ARTIST**: CAN have a Spotify ID (optional) 4. **Transaction Safety**: - Both records created atomically (all or nothing) - Automatic rollback on failure - No orphaned records **Spotify ID Formats:** All formats are supported and automatically parsed: - URL with params: `https://open.spotify.com/artist/7tNO3vJC9zlHy2IJOx34ga?si=...` - URL without params: `https://open.spotify.com/artist/7tNO3vJC9zlHy2IJOx34ga` - URI format: `spotify:artist:4OGiMt96TFUKkKWf7Imlno` - Plain ID: `6zyG7x3rPnqitv8h5FATI9` **Validation Messages:** - ✅ Green checkmark: Field is valid - ❌ Red X: Field has errors (must fix before submitting) - âš ī¸ Yellow warning: Field is incomplete - â„šī¸ Blue info: Optional field or helpful information **After Submission:** - Form automatically clears on success - Both VIRTUAL_PARTICIPANT and CUSTOM_LIST created with CREATED_BY tracking - Balloons celebration indicates successful creation """ )