""" Streamlit application for Publishing Delivery Date Updates. This app allows users to upload XLSX files containing publishing delivery data, validate the content, and update delivery dates in both QA and Production environments. """ import streamlit as st import pandas as pd import asyncio import os from typing import Dict, Any, List # Import our custom modules from data_processing import read_excel_file, process_dataframe, ValidationError from graphql_client import create_client, GraphQLClient, BatchResult from auth_authorization import is_user_authorized def initialize_session_state(): """Initialize Streamlit session state variables.""" if "uploaded_data" not in st.session_state: st.session_state.uploaded_data = None if "processed_data" not in st.session_state: st.session_state.processed_data = None if "qa_results" not in st.session_state: st.session_state.qa_results = None if "prod_results" not in st.session_state: st.session_state.prod_results = None if "selected_user" not in st.session_state: st.session_state.selected_user = None if "current_step" not in st.session_state: st.session_state.current_step = "user_selection" def load_available_users() -> List[Dict[str, str]]: """Load available users from the CSV file.""" import csv users = [] csv_path = os.path.join(os.path.dirname(__file__), "./users.csv") # Get environment setting environment = os.getenv("Environment", "dev").lower() test_user_identity_id = "7dc01da2-cda8-4a3f-ad40-f3308b4a37d7" try: with open(csv_path, "r") as file: reader = csv.DictReader(file) for row in reader: # Clean up the row data (remove extra commas) name = row.get("NAME", "").strip() identity_id = row.get("ORCHARD_IDENTITY_ID", "").strip() profile_id = row.get("ORCHARD_PROFILE_ID", "").strip().rstrip(",") if name and identity_id and profile_id: # Filter out Test User in production environment if environment == "prod" and identity_id == test_user_identity_id: continue users.append( { "name": name, "identity_id": identity_id, "profile_id": profile_id, } ) except FileNotFoundError: st.error( "Users configuration file not found. Please ensure graphql-publishing/users.csv exists." ) return [] except Exception as e: st.error(f"Error loading users: {str(e)}") return [] return users def reset_session_state(): """Reset session state to start over.""" st.session_state.uploaded_data = None st.session_state.processed_data = None st.session_state.qa_results = None st.session_state.prod_results = None st.session_state.selected_user = None st.session_state.current_step = "user_selection" def display_validation_errors(errors: List[ValidationError]): """Display validation errors in a user-friendly format.""" st.error(f"Found {len(errors)} validation error(s):") # Group errors by type for better display error_groups = {} for error in errors: error_type = f"{error.column} errors" if error_type not in error_groups: error_groups[error_type] = [] error_groups[error_type].append(error) # Display errors by group for error_type, error_list in error_groups.items(): with st.expander(f"{error_type} ({len(error_list)} items)"): for error in error_list: st.write(f"• {error}") def display_batch_results( results: List[BatchResult], environment: str ) -> Dict[str, int]: """ Display results from batch processing and return summary statistics. Args: results: List of BatchResult objects environment: Environment name for display Returns: Dict with summary statistics """ total_successful = sum(batch.successful_updates for batch in results) total_failed = sum(batch.failed_updates for batch in results) total_processed = total_successful + total_failed # Display summary metrics col1, col2, col3 = st.columns(3) with col1: st.metric("Total Processed", total_processed) with col2: st.metric("Successful", total_successful) with col3: st.metric("Failed", total_failed) # Display success rate if total_processed > 0: success_rate = (total_successful / total_processed) * 100 st.metric("Success Rate", f"{success_rate:.1f}%") # Display failed requests if any if total_failed > 0: failed_results = [] for batch in results: for result in batch.results: if not result.success: failed_results.append(result) st.error(f"{total_failed} requests failed in {environment}:") with st.expander("View Failed Requests Details"): error_df_data = [] for result in failed_results: error_df_data.append( { "Pub Song ID": result.pub_song_id, "Error Message": result.error_message, } ) if error_df_data: error_df = pd.DataFrame(error_df_data) st.dataframe(error_df, use_container_width=True) return { "total": total_processed, "successful": total_successful, "failed": total_failed, } async def process_updates_async( client: GraphQLClient, compositions: List[Dict[str, Any]], progress_bar, status_text ) -> List[BatchResult]: """ Process updates asynchronously with progress tracking. Args: client: GraphQL client compositions: List of composition data progress_bar: Streamlit progress bar status_text: Streamlit status text element Returns: List of BatchResult objects """ results = [] processed_count = 0 def update_progress(completed: int, total: int): nonlocal processed_count processed_count = completed progress = completed / total progress_bar.progress(progress) status_text.text(f"Processing... {completed}/{total} compositions") async for batch_number, batch_result in client.update_delivery_dates_batch( compositions, progress_callback=update_progress ): results.append(batch_result) return results def run_async_updates( client: GraphQLClient, compositions: List[Dict[str, Any]], progress_bar, status_text ) -> List[BatchResult]: """ Run async updates in a way that's compatible with Streamlit. Args: client: GraphQL client compositions: List of composition data progress_bar: Streamlit progress bar status_text: Streamlit status text element Returns: List of BatchResult objects """ # Try to get or create event loop try: loop = asyncio.get_event_loop() if loop.is_running(): # If loop is already running, we need to run in a thread import concurrent.futures with concurrent.futures.ThreadPoolExecutor() as executor: future = executor.submit( lambda: asyncio.run( process_updates_async( client, compositions, progress_bar, status_text ) ) ) return future.result() else: return loop.run_until_complete( process_updates_async(client, compositions, progress_bar, status_text) ) except RuntimeError: # No event loop exists, create new one return asyncio.run( process_updates_async(client, compositions, progress_bar, status_text) ) def main(): """Main Streamlit application.""" st.set_page_config( page_title="Publishing Delivery Recorder", page_icon="📅", layout="wide" ) # Check if Auth0 is enabled (required for qa/prod, optional for local dev) environment = os.getenv("Environment", "dev").lower() auth_enabled = os.getenv("DISABLE_AUTH0", "false").lower() != "true" # For qa/prod, always require Auth0 regardless of DISABLE_AUTH0 setting if environment in ["qa", "prod"]: auth_enabled = True # Authentication gate - show login screen if not authenticated if auth_enabled and not st.user.is_logged_in: st.title("📅 Publishing Delivery Recorder") st.markdown("---") st.markdown( """ ### Welcome! This application allows you to update publishing delivery dates in QA and PROD environments. **Please log in to continue.** """ ) col1, col2, col3 = st.columns([1, 1, 1]) with col2: if st.button( "🔐 Log in with Auth0", type="primary", use_container_width=True ): st.login("auth0") st.markdown("---") st.info("Please contact the app administrator if you don't have access.") return # Authorization check - verify user has access to current environment (if auth enabled) if auth_enabled: user_email = st.user.email if hasattr(st.user, "email") else "" authorized, reason = is_user_authorized(user_email, environment) else: # Auth disabled for local development authorized = True reason = "Auth disabled for local development" if not authorized: st.title("📅 Publishing Delivery Recorder") st.markdown("---") st.error("⛔ **Access Denied**") st.warning(reason) st.markdown("---") st.info( f"**Current Environment:** `{environment.upper()}`\n\n" f"**Your Email:** `{user_email}`\n\n" "If you believe this is an error, please contact the app administrator." ) col1, col2, col3 = st.columns([1, 1, 1]) with col2: if st.button("🚪 Log out", type="primary", use_container_width=True): st.logout() return initialize_session_state() st.title("📅 Publishing Delivery Recorder") st.markdown( "Upload an XLSX file to update delivery dates in QA and Production environments." ) # Sidebar with environment status with st.sidebar: # Display current step st.header("Current Step") steps = { "user_selection": "1️⃣ User Selection", "upload": "2️⃣ File Upload", "validate": "3️⃣ Validation", "qa_upload": "4️⃣ QA Upload", "prod_upload": "5️⃣ Production Upload", "complete": "✅ Complete", } st.write( steps.get(st.session_state.current_step, st.session_state.current_step) ) # Show selected user if available if st.session_state.selected_user: st.subheader("Selected User") st.write(f"👤 {st.session_state.selected_user['name']}") if st.button("🔄 Start Over"): reset_session_state() st.rerun() st.divider() # Display authenticated user info if auth_enabled: st.subheader("👤 Authenticated User") st.write(f"**{st.user.name}**") if st.user.email: st.caption(st.user.email) if st.button("🚪 Log out", use_container_width=True): st.logout() else: st.info("🔓 Auth disabled for local development") st.caption(f"Environment: {environment}") # Step 1: User Selection if st.session_state.current_step == "user_selection": st.header("Step 1: Select User Identity") st.markdown("Select the user identity to use for this delivery update process.") users = load_available_users() if not users: st.error("No users available. Please check the configuration.") return # Create a selectbox with user names user_names = [user["name"] for user in users] selected_name = st.selectbox( "Choose a user identity:", user_names, help="This determines which profile and identity will be used for authentication", ) if selected_name: # Find the selected user selected_user = next( user for user in users if user["name"] == selected_name ) # Display user details st.subheader("Selected User Details") col1, col2 = st.columns(2) with col1: st.info(f"**Name:** {selected_user['name']}") st.info(f"**Profile ID:** {selected_user['profile_id']}") with col2: st.info(f"**Identity ID:** {selected_user['identity_id']}") if st.button("✅ Confirm User Selection", type="primary"): st.session_state.selected_user = selected_user st.session_state.current_step = "upload" st.rerun() # Step 2: File Upload elif st.session_state.current_step == "upload": st.header("Step 2: Upload XLSX or CSV File") st.markdown( "Upload an Excel or CSV file containing **Pub Song ID** and **timestamp** columns." ) uploaded_file = st.file_uploader( "Choose an XLSX or CSV file", type=["xlsx", "csv"], help="The file should contain 'Pub Song ID' and 'timestamp' columns", ) if uploaded_file is not None: try: with st.spinner("Reading file..."): df = read_excel_file(uploaded_file) st.session_state.uploaded_data = df st.session_state.current_step = "validate" st.rerun() except ValueError as e: st.error(f"Error reading file: {str(e)}") st.info( "Please ensure your file is a valid XLSX or CSV format with 'Pub Song ID' and 'timestamp' columns." ) # Step 3: Validation elif ( st.session_state.current_step == "validate" and st.session_state.uploaded_data is not None ): st.header("Step 3: Data Validation") with st.spinner("Validating data..."): processed_data = process_dataframe(st.session_state.uploaded_data) st.session_state.processed_data = processed_data st.success( f"File processed! Found {len(processed_data.valid_rows)} valid rows out of {processed_data.total_rows} total rows." ) # Display summary col1, col2, col3 = st.columns(3) with col1: st.metric("Total Rows", processed_data.total_rows) with col2: st.metric("Valid Rows", len(processed_data.valid_rows)) with col3: st.metric("Invalid Rows", len(processed_data.invalid_rows)) # Show preview of valid data if len(processed_data.valid_rows) > 0: st.subheader("Preview of Valid Data") preview_df = processed_data.valid_rows.head(10) st.dataframe(preview_df, use_container_width=True) # Show validation errors if any if processed_data.has_errors: display_validation_errors(processed_data.errors) st.subheader("Invalid Rows") st.dataframe(processed_data.invalid_rows, use_container_width=True) col1, col2 = st.columns(2) with col1: if st.button( "⚠️ Ignore errors and proceed with valid rows", type="secondary" ): if len(processed_data.valid_rows) > 0: st.session_state.current_step = "qa_upload" st.rerun() else: st.error("No valid rows to process!") with col2: if st.button("🔄 Start over with a new file", type="primary"): reset_session_state() st.rerun() else: # No errors, show success and let user proceed manually if len(processed_data.valid_rows) > 0: st.success("✅ All data is valid!") col1, col2 = st.columns(2) with col1: if st.button("🚀 Proceed to QA Upload", type="primary"): st.session_state.current_step = "qa_upload" st.rerun() with col2: if st.button("🔄 Start over with a new file"): reset_session_state() st.rerun() else: st.error("No valid rows found in the file!") if st.button("🔄 Start over with a new file"): reset_session_state() st.rerun() # Step 4: QA Upload elif ( st.session_state.current_step == "qa_upload" and st.session_state.processed_data is not None ): st.header("Step 4: QA Environment Upload") processed_data = st.session_state.processed_data compositions = processed_data.valid_rows.to_dict("records") st.info( f"Ready to upload {len(compositions)} compositions to the QA environment." ) col1, col2 = st.columns(2) with col1: if st.button("🚀 Upload to QA", type="primary"): try: if not st.session_state.selected_user: st.error( "No user selected. Please start over and select a user." ) return client = create_client("qa", st.session_state.selected_user) st.subheader("QA Upload Progress") progress_bar = st.progress(0) status_text = st.empty() with st.spinner("Uploading to QA environment..."): qa_results = run_async_updates( client, compositions, progress_bar, status_text ) st.session_state.qa_results = qa_results status_text.text("QA upload completed!") # Display results st.subheader("QA Upload Results") qa_stats = display_batch_results(qa_results, "QA") if qa_stats["failed"] == 0: st.success( "🎉 All QA uploads successful! Ready to proceed to Production." ) st.session_state.current_step = "prod_upload" st.rerun() else: st.warning( "Some QA uploads failed. Please review the errors before proceeding to Production." ) col3, col4 = st.columns(2) with col3: if st.button( "📅 Proceed to Production anyway", type="secondary" ): st.session_state.current_step = "prod_upload" st.rerun() with col4: if st.button("🔄 Start over", type="primary"): reset_session_state() st.rerun() except Exception as e: st.error(f"Error during QA upload: {str(e)}") with col2: if st.button("🔄 Start over"): reset_session_state() st.rerun() # Step 5: Production Upload elif ( st.session_state.current_step == "prod_upload" and st.session_state.qa_results is not None ): st.header("Step 5: Production Environment Upload") processed_data = st.session_state.processed_data compositions = processed_data.valid_rows.to_dict("records") # Show QA results summary st.subheader("QA Results Summary") qa_stats = display_batch_results(st.session_state.qa_results, "QA") st.warning("⚠️ **PRODUCTION DEPLOYMENT WARNING**") st.markdown(""" You are about to upload delivery dates to the **Production** environment. This action is **irreversible** and will affect live data. Please confirm that: - ✅ You have reviewed the QA results above - ✅ You are authorized to make changes to Production - ✅ The delivery dates are correct """) confirm_prod = st.checkbox( "I understand and want to proceed with Production upload" ) col1, col2 = st.columns(2) with col1: if st.button( "🚨 CONFIRM and Upload to PROD", type="primary", disabled=not confirm_prod, ): try: if not st.session_state.selected_user: st.error( "No user selected. Please start over and select a user." ) return client = create_client("prod", st.session_state.selected_user) st.subheader("Production Upload Progress") progress_bar = st.progress(0) status_text = st.empty() with st.spinner("Uploading to Production environment..."): prod_results = run_async_updates( client, compositions, progress_bar, status_text ) st.session_state.prod_results = prod_results status_text.text("Production upload completed!") # Display results st.subheader("Production Upload Results") display_batch_results(prod_results, "Production") st.session_state.current_step = "complete" st.rerun() except Exception as e: st.error(f"Error during Production upload: {str(e)}") with col2: if st.button("🔄 Start over"): reset_session_state() st.rerun() # Step 6: Complete elif st.session_state.current_step == "complete": st.header("✅ Upload Complete!") st.success("All uploads have been completed successfully!") # Display final summary col1, col2 = st.columns(2) with col1: st.subheader("QA Environment Results") if st.session_state.qa_results: display_batch_results(st.session_state.qa_results, "QA") with col2: st.subheader("Production Environment Results") if st.session_state.prod_results: display_batch_results(st.session_state.prod_results, "Production") st.info( "Upload process completed. You can start over with a new file if needed." ) if st.button("🔄 Start New Upload"): reset_session_state() st.rerun() if __name__ == "__main__": main()