Spaces:
Sleeping
Sleeping
| import streamlit as st | |
| import json | |
| import pandas as pd | |
| import io | |
| import hashlib | |
| import plotly.graph_objects as go | |
| from datetime import datetime | |
| from config_manager import ConfigManager | |
| class GuestManager: | |
| def __init__(self): | |
| self.config_manager = ConfigManager() | |
| self.guest_list_data = None | |
| self.rsvp_data = None | |
| def render(self, config): | |
| st.markdown("## 👥 Guest Management") | |
| # Load current data | |
| self.load_data() | |
| # Import section | |
| st.markdown("### Import Data") | |
| import_type = st.radio("Import Type", ["Guest List (CSV)", "RSVP Responses (CSV)"], horizontal=True) | |
| if import_type == "Guest List (CSV)": | |
| uploaded_file = st.file_uploader("Upload Guest List (CSV)", type=['csv'], help="Upload a CSV file with guest data") | |
| else: | |
| uploaded_file = st.file_uploader("Upload RSVP Responses (CSV)", type=['csv'], help="Upload a CSV file with RSVP responses") | |
| if uploaded_file is not None: | |
| try: | |
| df = pd.read_csv(uploaded_file) | |
| if import_type == "Guest List (CSV)": | |
| self.process_guest_list_csv(df) | |
| st.success("Guest list processed successfully!") | |
| st.rerun() | |
| else: | |
| self.process_rsvp_csv(df, config) | |
| st.success("RSVP data processed successfully!") | |
| st.rerun() | |
| except Exception as e: | |
| st.error(f"Error processing CSV: {e}") | |
| # Display current data | |
| if self.guest_list_data is not None: | |
| self.render_guest_table(config) | |
| else: | |
| st.info("Please upload a guest list CSV file to get started.") | |
| def get_confirmed_attendees(self, guests): | |
| """Get list of guests who have RSVPed 'Yes' to at least one event""" | |
| confirmed = [] | |
| for guest in guests: | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| if any(status == 'Yes' for status in rsvp_by_event.values()): | |
| confirmed.append(guest) | |
| return confirmed | |
| def render_confirmed_attendees_view(self, confirmed_guests, config): | |
| """Render detailed view for confirmed attendees with stats""" | |
| # Create tabs for different views | |
| tab1, tab2, tab3, tab4 = st.tabs(["Guest List", "Attendance by Event", "Meal Choices", "Summary Stats"]) | |
| with tab1: | |
| self.render_guests_table(confirmed_guests, config) | |
| with tab2: | |
| st.markdown("### Attendance by Event for Confirmed Guests") | |
| self.render_attendance_chart(confirmed_guests, config) | |
| with tab3: | |
| st.markdown("### Meal Choices for Confirmed Guests") | |
| self.render_meal_choices_chart(confirmed_guests, config) | |
| with tab4: | |
| st.markdown("### Summary Statistics") | |
| self.render_confirmed_attendees_stats(confirmed_guests, config) | |
| def render_attendance_chart(self, confirmed_guests, config): | |
| """Render attendance chart for confirmed guests with guest list functionality""" | |
| wedding_events = config.get('wedding_events', []) | |
| # Count attendance for each event | |
| event_attendance = {} | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name: | |
| event_attendance[event_name] = {'Yes': 0, 'No': 0, 'Pending': 0} | |
| for guest in confirmed_guests: | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| for event_name in event_attendance.keys(): | |
| rsvp_status = rsvp_by_event.get(event_name, 'Pending') | |
| if rsvp_status in event_attendance[event_name]: | |
| event_attendance[event_name][rsvp_status] += 1 | |
| else: | |
| event_attendance[event_name]['Pending'] += 1 | |
| # Create bar chart | |
| event_names = list(event_attendance.keys()) | |
| yes_counts = [event_attendance[event]['Yes'] for event in event_names] | |
| no_counts = [event_attendance[event]['No'] for event in event_names] | |
| pending_counts = [event_attendance[event]['Pending'] for event in event_names] | |
| fig = go.Figure(data=[ | |
| go.Bar(name='Yes', x=event_names, y=yes_counts, marker_color='#4a7c59'), | |
| go.Bar(name='No', x=event_names, y=no_counts, marker_color='#d32f2f'), | |
| go.Bar(name='Pending', x=event_names, y=pending_counts, marker_color='#ffa726') | |
| ]) | |
| fig.update_layout( | |
| title="Event Attendance for Confirmed Guests", | |
| barmode='stack', | |
| xaxis_tickangle=-45, | |
| height=400 | |
| ) | |
| st.plotly_chart(fig, use_container_width=True) | |
| # Add guest list functionality below the chart | |
| st.markdown("---") | |
| st.markdown("### 📋 View Guest Lists by Event") | |
| if event_names: | |
| # Create columns for event selection and guest list display | |
| col1, col2 = st.columns([1, 2]) | |
| with col1: | |
| selected_event = st.selectbox( | |
| "Select Event to View Guest List:", | |
| event_names, | |
| help="Choose an event to see the list of guests who confirmed attendance" | |
| ) | |
| # Show attendance summary for selected event | |
| if selected_event in event_attendance: | |
| st.markdown("#### Attendance Summary") | |
| st.metric("Confirmed", event_attendance[selected_event]['Yes']) | |
| st.metric("Declined", event_attendance[selected_event]['No']) | |
| st.metric("Pending", event_attendance[selected_event]['Pending']) | |
| with col2: | |
| if selected_event: | |
| self.render_event_guest_list(confirmed_guests, selected_event, config) | |
| else: | |
| st.info("No events configured yet.") | |
| def render_event_guest_list(self, confirmed_guests, selected_event, config): | |
| """Render the list of guests who confirmed for a specific event""" | |
| st.markdown(f"#### Guest List for {selected_event}") | |
| # Get guests who confirmed for this event | |
| confirmed_for_event = [] | |
| declined_for_event = [] | |
| pending_for_event = [] | |
| for guest in confirmed_guests: | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| rsvp_status = rsvp_by_event.get(selected_event, 'Pending') | |
| if rsvp_status == 'Yes': | |
| confirmed_for_event.append(guest) | |
| elif rsvp_status == 'No': | |
| declined_for_event.append(guest) | |
| else: | |
| pending_for_event.append(guest) | |
| # Create tabs for different RSVP statuses | |
| tab1, tab2, tab3 = st.tabs([ | |
| f"✅ Confirmed ({len(confirmed_for_event)})", | |
| f"❌ Declined ({len(declined_for_event)})", | |
| f"⏳ Pending ({len(pending_for_event)})" | |
| ]) | |
| with tab1: | |
| if confirmed_for_event: | |
| self.render_guest_list_table(confirmed_for_event, selected_event, config, "confirmed") | |
| else: | |
| st.info("No guests have confirmed for this event yet.") | |
| with tab2: | |
| if declined_for_event: | |
| self.render_guest_list_table(declined_for_event, selected_event, config, "declined") | |
| else: | |
| st.info("No guests have declined this event.") | |
| with tab3: | |
| if pending_for_event: | |
| self.render_guest_list_table(pending_for_event, selected_event, config, "pending") | |
| else: | |
| st.info("All guests have responded to this event.") | |
| def render_guest_list_table(self, guests, event_name, config, status="confirmed"): | |
| """Render a table of guests for a specific event""" | |
| if not guests: | |
| return | |
| # Create data for the table | |
| table_data = [] | |
| for guest in guests: | |
| first_name = guest.get('first_name', '') | |
| last_name = guest.get('last_name', '') | |
| # Handle guests with "nan" names | |
| if first_name in ['nan', ''] and last_name in ['nan', '']: | |
| first_name = '[Unassigned Plus One]' | |
| last_name = '' | |
| # Get meal selection for this event | |
| meal_selections = guest.get('meal_selections', {}) | |
| meal_choice = meal_selections.get(event_name, 'Not Selected') | |
| # Check if this event requires meal choice | |
| wedding_events = config.get('wedding_events', []) | |
| event_requires_meal = False | |
| for event in wedding_events: | |
| if event.get('name') == event_name and event.get('requires_meal_choice', False): | |
| event_requires_meal = True | |
| break | |
| row = { | |
| 'Name': f"{first_name} {last_name}".strip(), | |
| 'Group': guest.get('group', ''), | |
| 'Party': guest.get('party', ''), | |
| 'Phone': guest.get('phone', ''), | |
| 'Plus One Name': guest.get('plus_one_name', '') | |
| } | |
| # Add meal choice column only if event requires meal choice | |
| if event_requires_meal: | |
| row['Meal Choice'] = meal_choice | |
| table_data.append(row) | |
| if table_data: | |
| df = pd.DataFrame(table_data) | |
| # Display the table | |
| st.dataframe( | |
| df, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=min(400, len(table_data) * 35 + 50) # Dynamic height based on number of guests | |
| ) | |
| # Add export functionality | |
| csv_data = df.to_csv(index=False) | |
| st.download_button( | |
| label=f"📥 Download {event_name} Guest List (CSV)", | |
| data=csv_data, | |
| file_name=f"{event_name.replace(' ', '_')}_{status}_guest_list.csv", | |
| mime="text/csv", | |
| key=f"download_{event_name.replace(' ', '_')}_{status}_{len(guests)}" | |
| ) | |
| def render_meal_choices_chart(self, confirmed_guests, config): | |
| """Render meal choices chart for confirmed guests""" | |
| wedding_events = config.get('wedding_events', []) | |
| # Get events that require meal choices | |
| meal_events = [event for event in wedding_events if event.get('requires_meal_choice', False)] | |
| if not meal_events: | |
| st.info("No events require meal choices.") | |
| return | |
| for event in meal_events: | |
| event_name = event.get('name', '') | |
| meal_options = event.get('meal_options', []) | |
| if not meal_options: | |
| continue | |
| st.markdown(f"#### {event_name}") | |
| # Count meal choices for this event | |
| meal_counts = {option: 0 for option in meal_options} | |
| meal_counts['Not Selected'] = 0 | |
| for guest in confirmed_guests: | |
| meal_selections = guest.get('meal_selections', {}) | |
| selected_meal = meal_selections.get(event_name, 'Not Selected') | |
| if selected_meal in meal_counts: | |
| meal_counts[selected_meal] += 1 | |
| else: | |
| meal_counts['Not Selected'] += 1 | |
| # Create pie chart | |
| fig = go.Figure(data=[go.Pie( | |
| labels=list(meal_counts.keys()), | |
| values=list(meal_counts.values()), | |
| marker_colors=['#4a7c59', '#ff9800', '#2196f3', '#9c27b0', '#f44336', '#ffa726'] | |
| )]) | |
| fig.update_layout(title=f"Meal Choices for {event_name}") | |
| st.plotly_chart(fig, use_container_width=True) | |
| def render_confirmed_attendees_stats(self, confirmed_guests, config): | |
| """Render summary statistics for confirmed attendees""" | |
| wedding_events = config.get('wedding_events', []) | |
| # Basic stats | |
| col1, col2, col3, col4 = st.columns(4) | |
| with col1: | |
| st.metric("Total Confirmed", len(confirmed_guests)) | |
| with col2: | |
| plus_ones = sum(1 for guest in confirmed_guests if guest.get('plus_one', False)) | |
| st.metric("With Plus Ones", plus_ones) | |
| with col3: | |
| total_attending = len(confirmed_guests) + plus_ones | |
| st.metric("Total Attending", total_attending) | |
| with col4: | |
| # Calculate average attendance across all events | |
| total_yes = 0 | |
| total_possible = len(confirmed_guests) * len(wedding_events) | |
| for guest in confirmed_guests: | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| total_yes += sum(1 for status in rsvp_by_event.values() if status == 'Yes') | |
| avg_attendance = (total_yes / total_possible * 100) if total_possible > 0 else 0 | |
| st.metric("Avg Event Attendance", f"{avg_attendance:.1f}%") | |
| # Event-specific stats | |
| st.markdown("#### Event Attendance Summary") | |
| event_stats = [] | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| yes_count = 0 | |
| for guest in confirmed_guests: | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| if rsvp_by_event.get(event_name) == 'Yes': | |
| yes_count += 1 | |
| attendance_rate = (yes_count / len(confirmed_guests) * 100) if confirmed_guests else 0 | |
| event_stats.append({ | |
| 'Event': event_name, | |
| 'Attending': yes_count, | |
| 'Attendance Rate': f"{attendance_rate:.1f}%" | |
| }) | |
| if event_stats: | |
| stats_df = pd.DataFrame(event_stats) | |
| st.dataframe(stats_df, use_container_width=True, hide_index=True) | |
| def convert_csv_to_guests(self, df): | |
| """Convert CSV data to guest format""" | |
| guest_data = [] | |
| for _, row in df.iterrows(): | |
| first_name = str(row.get('First Name (Empty Rows are +1s)', '')).strip() | |
| last_name = str(row.get('Last Name', '')).strip() | |
| group = str(row.get('Group', '')).strip() | |
| group_size = str(row.get('Group Size', '')).strip() | |
| party = str(row.get('Party', '')).strip() | |
| street_address = str(row.get(' Street address', '')).strip() | |
| apt_suite = str(row.get(' Apt/Suite', '')).strip() | |
| city = str(row.get(' City', '')).strip() | |
| state = str(row.get(' State', '')).strip() | |
| zip_code = str(row.get(' ZIP', '')).strip() | |
| country = str(row.get('Country', '')).strip() | |
| # Skip completely empty rows | |
| if not first_name and not last_name and not group: | |
| continue | |
| # Build full address | |
| address_parts = [] | |
| if street_address and street_address != 'nan': | |
| address_parts.append(street_address) | |
| if apt_suite and apt_suite != 'nan': | |
| address_parts.append(f"Apt {apt_suite}") | |
| if city and city != 'nan': | |
| address_parts.append(city) | |
| if state and state != 'nan': | |
| address_parts.append(state) | |
| if zip_code and zip_code != 'nan': | |
| address_parts.append(zip_code) | |
| if country and country != 'nan': | |
| address_parts.append(country) | |
| full_address = ', '.join(address_parts) if address_parts else '' | |
| # Determine if this is a plus one (empty first name but has party/group) | |
| is_plus_one = not first_name and (group or party) | |
| if is_plus_one: | |
| # This is a plus one entry - create a guest entry with "nan" names | |
| # The plus_one_name will be filled in from RSVP data | |
| guest = { | |
| 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), | |
| 'first_name': 'nan', | |
| 'last_name': 'nan', | |
| 'party': party, | |
| 'group': group, | |
| 'group_size': group_size, | |
| 'phone': '', | |
| 'address': full_address, # Use full address as address info | |
| 'lodging': '', # Leave lodging empty for manual entry | |
| 'allergies': '', | |
| 'plus_one': False, # This will be updated from RSVP data | |
| 'plus_one_name': '', # This will be filled from RSVP data | |
| 'plus_one_phone': '', | |
| 'plus_one_allergies': '', | |
| 'rsvp_by_event': {}, | |
| 'meal_selections': {}, | |
| 'created_date': datetime.now().isoformat() | |
| } | |
| else: | |
| # Create guest object for main guests | |
| guest = { | |
| 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), | |
| 'first_name': first_name, | |
| 'last_name': last_name, | |
| 'party': party, | |
| 'group': group, | |
| 'group_size': group_size, | |
| 'phone': '', | |
| 'address': full_address, # Use full address as address info | |
| 'lodging': '', # Leave lodging empty for manual entry | |
| 'allergies': '', | |
| 'plus_one': False, # Will be updated from RSVP data | |
| 'plus_one_name': '', | |
| 'plus_one_phone': '', | |
| 'plus_one_allergies': '', | |
| 'rsvp_by_event': {}, | |
| 'meal_selections': {}, | |
| 'created_date': datetime.now().isoformat() | |
| } | |
| guest_data.append(guest) | |
| return guest_data | |
| def update_guests_with_rsvp(self, df, config): | |
| """Update existing guests with RSVP responses from CSV""" | |
| import json | |
| existing_guests = self.config_manager.load_json_data('guests.json') | |
| updated_count = 0 | |
| # Track new meal options found in RSVP data | |
| new_meal_options_by_event = {} | |
| # Track which plus one names have been assigned to avoid duplicates across all parties | |
| global_assigned_plus_ones = set() | |
| for _, row in df.iterrows(): | |
| party_code = str(row.get('party_code', '')).strip() | |
| name = str(row.get('name', '')).strip() | |
| overall_rsvp = str(row.get('overall_rsvp', '')).strip() | |
| bringing_plus_one = str(row.get('bringing_plus_one', '')).strip().lower() == 'yes' | |
| party_attendees = str(row.get('party_attendees', '')).strip() | |
| event_responses = str(row.get('event_responses', '')).strip() | |
| dietary_restrictions = str(row.get('dietary_restrictions', '')).strip() | |
| phone_number = str(row.get('phone_number', '')).strip() | |
| # Skip empty rows | |
| if not party_code and not name: | |
| continue | |
| # Parse party attendees to get all names in the party | |
| party_attendee_names = [] | |
| if party_attendees and party_attendees != 'nan': | |
| party_attendee_names = [name.strip() for name in party_attendees.split(';') if name.strip()] | |
| # Parse dietary restrictions | |
| dietary_dict = {} | |
| if dietary_restrictions and dietary_restrictions != 'nan' and dietary_restrictions != '{}': | |
| try: | |
| dietary_dict = json.loads(dietary_restrictions) | |
| except: | |
| pass | |
| # Parse event responses | |
| event_dict = {} | |
| if event_responses and event_responses != 'nan' and event_responses != '{}': | |
| try: | |
| event_dict = json.loads(event_responses) | |
| except Exception as e: | |
| st.warning(f"Could not parse event responses for {name}: {e}") | |
| # First, ensure all party attendees exist as separate guest entries | |
| # This handles cases where RSVP data includes people not in the original guest list | |
| for attendee_name in party_attendee_names: | |
| # Check if this attendee already exists as a guest | |
| attendee_exists = False | |
| for guest in existing_guests: | |
| guest_name = f"{guest.get('first_name', '')} {guest.get('last_name', '')}".strip() | |
| if attendee_name.lower() == guest_name.lower(): | |
| attendee_exists = True | |
| break | |
| # If attendee doesn't exist, create a new guest entry | |
| if not attendee_exists: | |
| # Parse the attendee name into first and last name | |
| name_parts = attendee_name.strip().split() | |
| if len(name_parts) >= 2: | |
| first_name = name_parts[0] | |
| last_name = ' '.join(name_parts[1:]) | |
| else: | |
| first_name = attendee_name | |
| last_name = '' | |
| # Find the group/party info from existing guests with the same party code | |
| group_name = party_code | |
| party_name = 'Lara' # Default party | |
| address = '' | |
| group_size = '1' | |
| for guest in existing_guests: | |
| guest_group = guest.get('group', '').strip() | |
| if party_code.lower() == guest_group.lower(): | |
| group_name = guest_group | |
| party_name = guest.get('party', 'Lara') | |
| address = guest.get('address', '') | |
| group_size = guest.get('group_size', '1') | |
| break | |
| # Create new guest entry | |
| new_guest = { | |
| 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), | |
| 'first_name': first_name, | |
| 'last_name': last_name, | |
| 'party': party_name, | |
| 'group': group_name, | |
| 'group_size': group_size, | |
| 'phone': phone_number if phone_number != 'nan' else '', | |
| 'address': address, | |
| 'lodging': '', | |
| 'allergies': dietary_dict.get(attendee_name, ''), | |
| 'plus_one': False, # This is a main guest, not a plus one | |
| 'plus_one_name': '', | |
| 'plus_one_phone': '', | |
| 'plus_one_allergies': '', | |
| 'rsvp_by_event': {}, | |
| 'meal_selections': {}, | |
| 'created_date': datetime.now().isoformat() | |
| } | |
| # Apply RSVP data to the new guest | |
| if event_dict: | |
| rsvp_by_event = {} | |
| meal_selections = {} | |
| for event_name, event_data in event_dict.items(): | |
| if isinstance(event_data, dict): | |
| attendees = event_data.get('attendees', []) | |
| party_rsvp = event_data.get('rsvp', 'Pending') | |
| # Determine individual RSVP status | |
| if attendee_name in attendees: | |
| rsvp_status = party_rsvp | |
| else: | |
| rsvp_status = 'No' | |
| rsvp_by_event[event_name] = rsvp_status | |
| # Update meal choices if provided and guest is attending | |
| if rsvp_status == 'Yes': | |
| meal_choice = event_data.get('meal_choice', {}) | |
| if meal_choice and isinstance(meal_choice, dict) and attendee_name in meal_choice: | |
| selected_meal = meal_choice[attendee_name] | |
| meal_selections[event_name] = selected_meal | |
| # Track new meal options for this event | |
| if event_name not in new_meal_options_by_event: | |
| new_meal_options_by_event[event_name] = set() | |
| new_meal_options_by_event[event_name].add(selected_meal) | |
| new_guest['rsvp_by_event'] = rsvp_by_event | |
| new_guest['meal_selections'] = meal_selections | |
| existing_guests.append(new_guest) | |
| updated_count += 1 | |
| # Now update existing guests with RSVP data | |
| # Find matching guest(s) by name or party | |
| matching_guests = [] | |
| for guest in existing_guests: | |
| guest_name = f"{guest.get('first_name', '')} {guest.get('last_name', '')}".strip() | |
| guest_party = guest.get('party', '').strip() | |
| guest_group = guest.get('group', '').strip() | |
| # Match by exact name, party code, or exact group name (including guests with "nan" names) | |
| # Use exact group matching to prevent partial matches (e.g., "Perrin" matching "Ben Perrin") | |
| if (name and name.lower() in guest_name.lower()) or \ | |
| (party_code and (party_code.lower() == guest_party.lower() or party_code.lower() == guest_group.lower())): | |
| matching_guests.append(guest) | |
| # Track which plus one names have been assigned for this specific party | |
| party_assigned_plus_ones = set() | |
| # Update matching guests | |
| for guest in matching_guests: | |
| guest_name = f"{guest.get('first_name', '')} {guest.get('last_name', '')}".strip() | |
| is_nan_guest = guest.get('first_name') == 'nan' and guest.get('last_name') == 'nan' | |
| # Update phone if provided | |
| if phone_number and phone_number != 'nan': | |
| guest['phone'] = self._format_phone_number(phone_number) | |
| # Handle guests with "nan" names (these are plus ones that need names) | |
| if is_nan_guest and party_attendee_names: | |
| # Find the plus one name that matches this guest's party/group | |
| # Look for names that aren't the main guest name | |
| main_guest_name = name # The main guest from RSVP data | |
| plus_one_candidates = [name for name in party_attendee_names if name.lower() != main_guest_name.lower()] | |
| # Find the first plus one candidate that hasn't been assigned yet for this party | |
| plus_one_name = None | |
| for candidate in plus_one_candidates: | |
| if candidate not in party_assigned_plus_ones: | |
| plus_one_name = candidate | |
| party_assigned_plus_ones.add(candidate) | |
| break | |
| if plus_one_name: | |
| # Parse the plus one name into first and last name | |
| name_parts = plus_one_name.strip().split() | |
| if len(name_parts) >= 2: | |
| guest['first_name'] = name_parts[0] | |
| guest['last_name'] = ' '.join(name_parts[1:]) # Join remaining parts as last name | |
| else: | |
| guest['first_name'] = plus_one_name | |
| guest['last_name'] = '' | |
| guest['plus_one'] = False # This is now a named guest | |
| guest['plus_one_name'] = '' # Clear the plus one name field | |
| # Update allergies for the plus one | |
| if plus_one_name in dietary_dict: | |
| guest['allergies'] = dietary_dict[plus_one_name] | |
| # Apply party RSVP to this plus one | |
| if event_dict: | |
| rsvp_by_event = {} | |
| meal_selections = {} | |
| for event_name, event_data in event_dict.items(): | |
| if isinstance(event_data, dict): | |
| # Check if this plus one is specifically listed as an attendee | |
| attendees = event_data.get('attendees', []) | |
| party_rsvp = event_data.get('rsvp', 'Pending') | |
| # Determine individual RSVP status | |
| if plus_one_name in attendees: | |
| # Plus one is listed as attending, use party RSVP | |
| rsvp_status = party_rsvp | |
| else: | |
| # Plus one is not listed as attending, mark as "No" | |
| rsvp_status = 'No' | |
| rsvp_by_event[event_name] = rsvp_status | |
| # Update meal choices if provided and plus one is attending | |
| if rsvp_status == 'Yes': | |
| meal_choice = event_data.get('meal_choice', {}) | |
| if meal_choice and isinstance(meal_choice, dict) and plus_one_name in meal_choice: | |
| selected_meal = meal_choice[plus_one_name] | |
| meal_selections[event_name] = selected_meal | |
| # Track new meal options for this event | |
| if event_name not in new_meal_options_by_event: | |
| new_meal_options_by_event[event_name] = set() | |
| new_meal_options_by_event[event_name].add(selected_meal) | |
| guest['rsvp_by_event'] = rsvp_by_event | |
| guest['meal_selections'] = meal_selections | |
| # Handle named guests (main guests) - but don't assign plus ones if they're separate guests | |
| elif not is_nan_guest: | |
| # Check if this guest should have a plus one | |
| # Only assign plus one if there are attendees not already represented as separate guests | |
| other_attendees = [name for name in party_attendee_names if name.lower() != guest_name.lower()] | |
| # Check if any of these other attendees are already separate guests | |
| separate_guests = [] | |
| for other_attendee in other_attendees: | |
| for existing_guest in existing_guests: | |
| existing_guest_name = f"{existing_guest.get('first_name', '')} {existing_guest.get('last_name', '')}".strip() | |
| if other_attendee.lower() == existing_guest_name.lower(): | |
| separate_guests.append(other_attendee) | |
| # Only assign plus one if there are attendees that aren't separate guests | |
| plus_one_candidates = [name for name in other_attendees if name not in separate_guests] | |
| if plus_one_candidates: | |
| guest['plus_one'] = True | |
| guest['plus_one_name'] = plus_one_candidates[0] # Take the first plus one | |
| # Update plus one allergies | |
| if plus_one_candidates[0] in dietary_dict: | |
| guest['plus_one_allergies'] = dietary_dict[plus_one_candidates[0]] | |
| else: | |
| guest['plus_one'] = False | |
| guest['plus_one_name'] = '' | |
| # Update allergies for the main guest | |
| if guest_name in dietary_dict: | |
| guest['allergies'] = dietary_dict[guest_name] | |
| # Apply party RSVP to main guest | |
| if event_dict: | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| meal_selections = guest.get('meal_selections', {}) | |
| for event_name, event_data in event_dict.items(): | |
| if isinstance(event_data, dict): | |
| # Check if this guest is specifically listed as an attendee | |
| attendees = event_data.get('attendees', []) | |
| party_rsvp = event_data.get('rsvp', 'Pending') | |
| # Determine individual RSVP status | |
| if guest_name in attendees: | |
| # Guest is listed as attending, use party RSVP | |
| rsvp_status = party_rsvp | |
| else: | |
| # Guest is not listed as attending, mark as "No" | |
| rsvp_status = 'No' | |
| rsvp_by_event[event_name] = rsvp_status | |
| # Update meal choices if provided and guest is attending | |
| if rsvp_status == 'Yes': | |
| meal_choice = event_data.get('meal_choice', {}) | |
| if meal_choice and isinstance(meal_choice, dict) and guest_name in meal_choice: | |
| selected_meal = meal_choice[guest_name] | |
| meal_selections[event_name] = selected_meal | |
| # Track new meal options for this event | |
| if event_name not in new_meal_options_by_event: | |
| new_meal_options_by_event[event_name] = set() | |
| new_meal_options_by_event[event_name].add(selected_meal) | |
| guest['rsvp_by_event'] = rsvp_by_event | |
| guest['meal_selections'] = meal_selections | |
| updated_count += 1 | |
| # Update event configuration with new meal options | |
| config_updated = False | |
| if new_meal_options_by_event: | |
| wedding_events = config.get('wedding_events', []) | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name in new_meal_options_by_event: | |
| # Get existing meal options for this event | |
| existing_meal_options = set(event.get('meal_options', [])) | |
| # Get new meal options from RSVP data | |
| new_meal_options = new_meal_options_by_event[event_name] | |
| # Merge existing and new meal options | |
| all_meal_options = existing_meal_options.union(new_meal_options) | |
| # Update the event configuration | |
| event['meal_options'] = sorted(list(all_meal_options)) | |
| config_updated = True | |
| # Save updated configuration if changes were made | |
| if config_updated: | |
| self.config_manager.save_config(config) | |
| st.info(f"Updated event meal options with new choices from RSVP data") | |
| # Clean up any remaining "nan" guests that weren't updated | |
| # These are likely plus ones that don't have corresponding RSVP data | |
| for guest in existing_guests: | |
| if guest.get('first_name') == 'nan' and guest.get('last_name') == 'nan': | |
| # If this nan guest still has no name, it means no RSVP data was provided | |
| # We can either keep it as a placeholder or remove it | |
| # For now, we'll keep it but mark it as inactive | |
| guest['plus_one'] = False | |
| guest['plus_one_name'] = '' | |
| # Clean up duplicate guests | |
| existing_guests = self.remove_duplicate_guests(existing_guests) | |
| # Save updated guests | |
| if updated_count > 0: | |
| self.config_manager.save_json_data('guests.json', existing_guests) | |
| return updated_count | |
| def remove_duplicate_guests(self, guests): | |
| """Remove duplicate guests based on name and group""" | |
| seen_guests = set() | |
| unique_guests = [] | |
| for guest in guests: | |
| first_name = guest.get('first_name', '').strip() | |
| last_name = guest.get('last_name', '').strip() | |
| group = guest.get('group', '').strip() | |
| # Create a unique identifier for this guest | |
| guest_key = f"{first_name.lower()}_{last_name.lower()}_{group.lower()}" | |
| if guest_key not in seen_guests: | |
| seen_guests.add(guest_key) | |
| unique_guests.append(guest) | |
| else: | |
| # This is a duplicate - we can optionally log it or merge data | |
| st.warning(f"Removed duplicate guest: {first_name} {last_name} from {group}") | |
| return unique_guests | |
| def render_guest_form(self, config): | |
| """Render form to add new guest""" | |
| with st.form("guest_form"): | |
| st.markdown("#### Basic Information") | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| first_name = st.text_input("First Name *", placeholder="Enter first name") | |
| last_name = st.text_input("Last Name *", placeholder="Enter last name") | |
| group = st.text_input("Group", placeholder="Enter group name") | |
| party = st.text_input("Party/Family", placeholder="Enter party or family name") | |
| with col2: | |
| phone = st.text_input("Phone Number", placeholder="Enter phone number") | |
| address = st.text_input("Address", placeholder="Home address") | |
| lodging = st.text_input("Wedding Lodging", placeholder="Hotel, Airbnb, etc. for wedding") | |
| allergies = st.text_area("Allergies/Dietary Restrictions", placeholder="List any allergies or dietary restrictions") | |
| st.markdown("#### Plus One Information") | |
| plus_one = st.checkbox("Bringing Plus One") | |
| if plus_one: | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| plus_one_name = st.text_input("Plus One Name", placeholder="Enter plus one's name") | |
| plus_one_phone = st.text_input("Plus One Phone", placeholder="Enter plus one's phone") | |
| with col2: | |
| plus_one_allergies = st.text_area("Plus One Allergies", placeholder="Plus one's allergies/dietary restrictions") | |
| else: | |
| plus_one_name = "" | |
| plus_one_phone = "" | |
| plus_one_allergies = "" | |
| # RSVP and meal selections for each event | |
| wedding_events = config.get('wedding_events', []) | |
| if wedding_events: | |
| st.markdown("#### RSVP & Meal Selections") | |
| rsvp_by_event = {} | |
| meal_selections = {} | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name: | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| # RSVP selection | |
| rsvp_options = ['Pending', 'Yes', 'No'] | |
| rsvp = st.selectbox(f"RSVP for {event_name}", rsvp_options, key=f"add_rsvp_{event_name}") | |
| rsvp_by_event[event_name] = rsvp | |
| with col2: | |
| # Meal selection (only if event requires meal choice) | |
| if event.get('requires_meal_choice', False): | |
| meal_options = event.get('meal_options', []) | |
| if meal_options: | |
| meal_options_with_default = ['Not Selected'] + meal_options | |
| meal = st.selectbox(f"Meal for {event_name}", meal_options_with_default, key=f"add_meal_{event_name}") | |
| meal_selections[event_name] = meal | |
| submitted = st.form_submit_button("Add Guest", type="primary") | |
| if submitted: | |
| if first_name and last_name: | |
| new_guest = { | |
| 'id': datetime.now().strftime("%Y%m%d_%H%M%S_%f"), | |
| 'first_name': first_name, | |
| 'last_name': last_name, | |
| 'group': group, | |
| 'party': party, | |
| 'phone': phone, | |
| 'address': address, | |
| 'lodging': lodging, | |
| 'allergies': allergies, | |
| 'plus_one': plus_one, | |
| 'plus_one_name': plus_one_name, | |
| 'plus_one_phone': plus_one_phone, | |
| 'plus_one_allergies': plus_one_allergies, | |
| 'rsvp_by_event': rsvp_by_event if wedding_events else {}, | |
| 'meal_selections': meal_selections if wedding_events else {}, | |
| 'created_date': datetime.now().isoformat() | |
| } | |
| # Load existing guests and add new one | |
| guests = self.config_manager.load_json_data('guests.json') | |
| guests.append(new_guest) | |
| if self.config_manager.save_json_data('guests.json', guests): | |
| st.success("Guest added successfully!") | |
| st.rerun() | |
| else: | |
| st.error("Error saving guest") | |
| else: | |
| st.error("Please enter at least first and last name") | |
| def render_guests_table(self, guests, config): | |
| """Render guests in an editable table format""" | |
| # Get wedding events for RSVP and meal options | |
| wedding_events = config.get('wedding_events', []) | |
| # Create DataFrame for display | |
| df_data = [] | |
| for guest in guests: | |
| # Handle guests with "nan" names (these are plus ones) | |
| first_name = guest.get('first_name', '') | |
| last_name = guest.get('last_name', '') | |
| # If first/last name is "nan" or empty, this is likely an unassigned plus one | |
| if first_name in ['nan', ''] and last_name in ['nan', '']: | |
| # This is an unassigned plus one placeholder | |
| first_name = '[Unassigned Plus One]' | |
| last_name = '' | |
| row = { | |
| 'First Name': first_name, | |
| 'Last Name': last_name, | |
| 'Group': guest.get('group', ''), | |
| 'Party': guest.get('party', ''), | |
| 'Phone': guest.get('phone', ''), | |
| 'Address': guest.get('address', ''), | |
| 'Wedding Lodging': guest.get('lodging', ''), | |
| 'Allergies': guest.get('allergies', ''), | |
| 'Plus One Allergies': guest.get('plus_one_allergies', '') | |
| } | |
| # Add RSVP columns for each event | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name: | |
| row[f'RSVP - {event_name}'] = rsvp_by_event.get(event_name, 'Pending') | |
| # Add meal selection columns for events that require meal choice | |
| meal_selections = guest.get('meal_selections', {}) | |
| for event in wedding_events: | |
| if event.get('requires_meal_choice', False): | |
| event_name = event.get('name', '') | |
| if event_name: | |
| row[f'Meal - {event_name}'] = meal_selections.get(event_name, 'Not Selected') | |
| df_data.append(row) | |
| if df_data: | |
| df = pd.DataFrame(df_data) | |
| # Display the table | |
| st.dataframe( | |
| df, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=400 | |
| ) | |
| # Edit/Delete guest section | |
| st.markdown("### Manage Guest") | |
| guest_ids = [f"{guest.get('first_name', '')} {guest.get('last_name', '')}" for guest in guests] | |
| selected_guest_name = st.selectbox("Select Guest to Manage", guest_ids) | |
| if selected_guest_name: | |
| # Find the selected guest | |
| selected_guest = None | |
| for guest in guests: | |
| if f"{guest.get('first_name', '')} {guest.get('last_name', '')}" == selected_guest_name: | |
| selected_guest = guest | |
| break | |
| if selected_guest: | |
| # Create tabs for edit and delete | |
| tab1, tab2 = st.tabs(["Edit Guest", "Delete Guest"]) | |
| with tab1: | |
| self.render_guest_edit_form(selected_guest, guests, config) | |
| with tab2: | |
| self.render_guest_delete_form(selected_guest, guests) | |
| def render_guest_edit_form(self, guest, all_guests, config): | |
| """Render form to edit guest information""" | |
| with st.form(f"edit_guest_{guest.get('id', '')}"): | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| first_name = st.text_input("First Name", value=guest.get('first_name', ''), key=f"edit_first_{guest.get('id', '')}") | |
| last_name = st.text_input("Last Name", value=guest.get('last_name', ''), key=f"edit_last_{guest.get('id', '')}") | |
| group = st.text_input("Group", value=guest.get('group', ''), key=f"edit_group_{guest.get('id', '')}") | |
| party = st.text_input("Party/Family", value=guest.get('party', ''), key=f"edit_party_{guest.get('id', '')}") | |
| phone = st.text_input("Phone Number", value=guest.get('phone', ''), key=f"edit_phone_{guest.get('id', '')}") | |
| address = st.text_input("Address", value=guest.get('address', ''), key=f"edit_address_{guest.get('id', '')}") | |
| lodging = st.text_input("Wedding Lodging", value=guest.get('lodging', ''), key=f"edit_lodging_{guest.get('id', '')}") | |
| with col2: | |
| allergies = st.text_area("Allergies/Dietary Restrictions", value=guest.get('allergies', ''), key=f"edit_allergies_{guest.get('id', '')}") | |
| plus_one = st.checkbox("Bringing Plus One", value=guest.get('plus_one', False), key=f"edit_plus_one_{guest.get('id', '')}") | |
| if plus_one: | |
| plus_one_name = st.text_input("Plus One Name", value=guest.get('plus_one_name', ''), key=f"edit_plus_one_name_{guest.get('id', '')}") | |
| plus_one_phone = st.text_input("Plus One Phone", value=guest.get('plus_one_phone', ''), key=f"edit_plus_one_phone_{guest.get('id', '')}") | |
| plus_one_allergies = st.text_area("Plus One Allergies", value=guest.get('plus_one_allergies', ''), key=f"edit_plus_one_allergies_{guest.get('id', '')}") | |
| else: | |
| plus_one_name = "" | |
| plus_one_phone = "" | |
| plus_one_allergies = "" | |
| # RSVP and meal selections for each event | |
| st.markdown("#### RSVP & Meal Selections") | |
| wedding_events = config.get('wedding_events', []) | |
| rsvp_by_event = guest.get('rsvp_by_event', {}) | |
| meal_selections = guest.get('meal_selections', {}) | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name: | |
| col1, col2 = st.columns(2) | |
| with col1: | |
| # RSVP selection | |
| current_rsvp = rsvp_by_event.get(event_name, 'Pending') | |
| rsvp_options = ['Pending', 'Yes', 'No'] | |
| rsvp_index = rsvp_options.index(current_rsvp) if current_rsvp in rsvp_options else 0 | |
| new_rsvp = st.selectbox(f"RSVP for {event_name}", rsvp_options, index=rsvp_index, key=f"edit_rsvp_{event_name}_{guest.get('id', '')}") | |
| rsvp_by_event[event_name] = new_rsvp | |
| with col2: | |
| # Meal selection (only if event requires meal choice) | |
| if event.get('requires_meal_choice', False): | |
| meal_options = event.get('meal_options', []) | |
| if meal_options: | |
| current_meal = meal_selections.get(event_name, 'Not Selected') | |
| meal_options_with_default = ['Not Selected'] + meal_options | |
| meal_index = meal_options_with_default.index(current_meal) if current_meal in meal_options_with_default else 0 | |
| new_meal = st.selectbox(f"Meal for {event_name}", meal_options_with_default, index=meal_index, key=f"edit_meal_{event_name}_{guest.get('id', '')}") | |
| meal_selections[event_name] = new_meal | |
| submitted = st.form_submit_button("Save Changes", type="primary") | |
| if submitted: | |
| # Update guest data | |
| guest['first_name'] = first_name | |
| guest['last_name'] = last_name | |
| guest['group'] = group | |
| guest['party'] = party | |
| guest['phone'] = phone | |
| guest['address'] = address | |
| guest['lodging'] = lodging | |
| guest['allergies'] = allergies | |
| guest['plus_one'] = plus_one | |
| guest['plus_one_name'] = plus_one_name | |
| guest['plus_one_phone'] = plus_one_phone | |
| guest['plus_one_allergies'] = plus_one_allergies | |
| guest['rsvp_by_event'] = rsvp_by_event | |
| guest['meal_selections'] = meal_selections | |
| # Save updated guests | |
| if self.config_manager.save_json_data('guests.json', all_guests): | |
| st.success("Guest updated successfully!") | |
| st.rerun() | |
| else: | |
| st.error("Error saving guest changes") | |
| def render_guest_delete_form(self, guest, all_guests): | |
| """Render form to delete guest""" | |
| st.markdown("### ⚠️ Delete Guest") | |
| # Show guest information for confirmation | |
| st.markdown(f"**Guest to Delete:** {guest.get('first_name', '')} {guest.get('last_name', '')}") | |
| st.markdown(f"**Party:** {guest.get('party', 'N/A')}") | |
| st.markdown(f"**Phone:** {guest.get('phone', 'N/A')}") | |
| if guest.get('plus_one', False): | |
| st.markdown(f"**Plus One:** {guest.get('plus_one_name', 'N/A')}") | |
| st.warning("⚠️ **Warning:** This action cannot be undone. All guest information, RSVP responses, and meal selections will be permanently deleted.") | |
| # Confirmation checkbox | |
| confirm_delete = st.checkbox("I understand this action cannot be undone", key=f"confirm_delete_{guest.get('id', '')}") | |
| # Delete button | |
| if st.button("🗑️ Delete Guest", type="secondary", disabled=not confirm_delete, key=f"delete_guest_{guest.get('id', '')}"): | |
| # Remove guest from list | |
| all_guests = [g for g in all_guests if g.get('id') != guest.get('id')] | |
| # Save updated guests | |
| if self.config_manager.save_json_data('guests.json', all_guests): | |
| st.success(f"Guest {guest.get('first_name', '')} {guest.get('last_name', '')} has been deleted successfully!") | |
| st.rerun() | |
| else: | |
| st.error("Error deleting guest") | |
| def get_guest_summary(self, guests): | |
| """Get summary statistics for guests""" | |
| total_guests = len(guests) | |
| plus_ones = sum(1 for guest in guests if guest.get('plus_one', False)) | |
| total_attendees = total_guests + plus_ones | |
| return { | |
| 'total_guests': total_guests, | |
| 'plus_ones': plus_ones, | |
| 'total_attendees': total_attendees | |
| } | |
| def load_data(self): | |
| """Load guest list and RSVP data from storage""" | |
| self.guest_list_data = self.config_manager.load_json_data('guest_list_data.json') | |
| self.rsvp_data = self.config_manager.load_json_data('rsvp_data.json') | |
| def save_data(self): | |
| """Save guest list and RSVP data to storage""" | |
| if self.guest_list_data is not None: | |
| self.config_manager.save_json_data('guest_list_data.json', self.guest_list_data) | |
| if self.rsvp_data is not None: | |
| self.config_manager.save_json_data('rsvp_data.json', self.rsvp_data) | |
| def process_guest_list_csv(self, df): | |
| """Process guest list CSV and create structured data""" | |
| groups = {} | |
| for _, row in df.iterrows(): | |
| first_name = str(row.get('First Name (Empty Rows are +1s)', '')).strip() | |
| last_name = str(row.get('Last Name', '')).strip() | |
| group_name = str(row.get('Group', '')).strip() | |
| group_size = str(row.get('Group Size', '')).strip() | |
| party = str(row.get('Party', '')).strip() | |
| # Skip empty rows | |
| if not group_name: | |
| continue | |
| # Initialize group if not exists | |
| if group_name not in groups: | |
| groups[group_name] = { | |
| 'group_name': group_name, | |
| 'group_size': int(group_size) if group_size.isdigit() else 0, | |
| 'party': party, | |
| 'address': self._build_address(row), | |
| 'named_guests': [], | |
| 'plus_one_spots': 0 | |
| } | |
| # Check if this is a named guest or plus one spot | |
| # A named guest has at least a first name (last name can be empty) | |
| # A plus one spot has both first and last names empty | |
| if first_name and first_name != 'nan': | |
| # Named guest (even if last name is empty) | |
| full_name = f"{first_name} {last_name}".strip() if last_name and last_name != 'nan' else first_name | |
| groups[group_name]['named_guests'].append({ | |
| 'first_name': first_name, | |
| 'last_name': last_name if last_name and last_name != 'nan' else '', | |
| 'full_name': full_name | |
| }) | |
| else: | |
| # Plus one spot (both first and last names are empty) | |
| groups[group_name]['plus_one_spots'] += 1 | |
| self.guest_list_data = groups | |
| self.save_data() | |
| def process_rsvp_csv(self, df, config): | |
| """Process RSVP CSV and update guest data""" | |
| if self.guest_list_data is None: | |
| st.error("Please upload guest list first") | |
| return | |
| rsvp_responses = {} | |
| for _, row in df.iterrows(): | |
| group_code = str(row.get('group_code', '')).strip() | |
| name = str(row.get('name', '')).strip() | |
| overall_rsvp = str(row.get('overall_rsvp', '')).strip() | |
| bringing_plus_one = str(row.get('bringing_plus_one', '')).strip().lower() == 'yes' | |
| group_attendees = str(row.get('group_attendees', '')).strip() | |
| event_responses = str(row.get('event_responses', '')).strip() | |
| dietary_restrictions = str(row.get('dietary_restrictions', '')).strip() | |
| phone_number = str(row.get('phone_number', '')).strip() | |
| if not group_code: | |
| continue | |
| # Parse event responses | |
| event_dict = {} | |
| if event_responses and event_responses != 'nan' and event_responses != '{}': | |
| try: | |
| event_dict = json.loads(event_responses) | |
| except: | |
| pass | |
| # Parse dietary restrictions | |
| dietary_dict = {} | |
| if dietary_restrictions and dietary_restrictions != 'nan' and dietary_restrictions != '{}': | |
| try: | |
| dietary_dict = json.loads(dietary_restrictions) | |
| except: | |
| pass | |
| # Parse group attendees | |
| attendee_names = [] | |
| if group_attendees and group_attendees != 'nan': | |
| attendee_names = [name.strip() for name in group_attendees.split(';') if name.strip()] | |
| rsvp_responses[group_code] = { | |
| 'group_code': group_code, | |
| 'name': name, | |
| 'overall_rsvp': overall_rsvp, | |
| 'bringing_plus_one': bringing_plus_one, | |
| 'group_attendees': attendee_names, | |
| 'event_responses': event_dict, | |
| 'dietary_restrictions': dietary_dict, | |
| 'phone_number': self._format_phone_number(phone_number) | |
| } | |
| self.rsvp_data = rsvp_responses | |
| self.save_data() | |
| def _format_phone_number(self, phone): | |
| """Format phone number for display""" | |
| if not phone or phone.strip() == '' or phone == 'nan': | |
| return 'No phone provided' | |
| # Remove all non-digit characters | |
| digits_only = ''.join(filter(str.isdigit, phone)) | |
| # Handle empty result | |
| if not digits_only: | |
| return 'No phone provided' | |
| # US phone numbers (10 digits) | |
| if len(digits_only) == 10: | |
| return f"({digits_only[:3]}) {digits_only[3:6]}-{digits_only[6:]}" | |
| # US phone numbers with country code (11 digits starting with 1) | |
| elif len(digits_only) == 11 and digits_only.startswith('1'): | |
| return f"+1 ({digits_only[1:4]}) {digits_only[4:7]}-{digits_only[7:]}" | |
| # International numbers (more than 11 digits or doesn't start with 1) | |
| elif len(digits_only) > 11 or (len(digits_only) == 11 and not digits_only.startswith('1')): | |
| # Format as international with + prefix | |
| return f"+{digits_only}" | |
| # Other cases - return as is with some formatting | |
| else: | |
| return phone.strip() | |
| def _build_address(self, row): | |
| """Build full address from row data""" | |
| address_parts = [] | |
| street = str(row.get(' Street address', '')).strip() | |
| apt = str(row.get(' Apt/Suite', '')).strip() | |
| city = str(row.get(' City', '')).strip() | |
| state = str(row.get(' State', '')).strip() | |
| zip_code = str(row.get(' ZIP', '')).strip() | |
| country = str(row.get('Country', '')).strip() | |
| if street and street != 'nan': | |
| address_parts.append(street) | |
| if apt and apt != 'nan': | |
| address_parts.append(f"Apt {apt}") | |
| if city and city != 'nan': | |
| address_parts.append(city) | |
| if state and state != 'nan': | |
| address_parts.append(state) | |
| if zip_code and zip_code != 'nan': | |
| address_parts.append(zip_code) | |
| if country and country != 'nan': | |
| address_parts.append(country) | |
| return ', '.join(address_parts) | |
| def render_guest_table(self, config): | |
| """Render the comprehensive guest table""" | |
| if not self.guest_list_data: | |
| return | |
| st.markdown("### Guest List Overview") | |
| # Create comprehensive guest list | |
| all_guests = self._create_comprehensive_guest_list(config) | |
| if not all_guests: | |
| st.info("No guests to display") | |
| return | |
| # Add filter options | |
| col1, col2 = st.columns([1, 3]) | |
| with col1: | |
| filter_option = st.selectbox( | |
| "Filter Guests:", | |
| ["All Guests", "Confirmed Guests Only"], | |
| help="Filter to show all guests or only those who have confirmed attendance to at least one event" | |
| ) | |
| # Filter guests based on selection | |
| if filter_option == "Confirmed Guests Only": | |
| confirmed_guests = [guest for guest in all_guests if any(status == 'Yes' for status in guest.get('rsvp_by_event', {}).values())] | |
| display_guests = confirmed_guests | |
| st.info(f"Showing {len(confirmed_guests)} confirmed guests out of {len(all_guests)} total guests") | |
| else: | |
| display_guests = all_guests | |
| st.info(f"Showing all {len(all_guests)} guests") | |
| # Create DataFrame for display | |
| df_data = [] | |
| for guest in display_guests: | |
| row = { | |
| 'Name': guest['display_name'], | |
| 'Group': guest['group_name'], | |
| 'Party': guest['party'], | |
| 'Phone': guest.get('phone', ''), | |
| 'Address': guest.get('address', '') | |
| } | |
| # Add RSVP columns for each event | |
| wedding_events = config.get('wedding_events', []) | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name: | |
| row[f'RSVP - {event_name}'] = guest.get('rsvp_by_event', {}).get(event_name, 'Pending') | |
| # Add meal selection columns for events that require meal choice | |
| for event in wedding_events: | |
| if event.get('requires_meal_choice', False): | |
| event_name = event.get('name', '') | |
| if event_name: | |
| row[f'Meal - {event_name}'] = guest.get('meal_selections', {}).get(event_name, 'Not Selected') | |
| df_data.append(row) | |
| if df_data: | |
| df = pd.DataFrame(df_data) | |
| # Display the table | |
| st.dataframe( | |
| df, | |
| use_container_width=True, | |
| hide_index=True, | |
| height=min(600, len(df_data) * 35 + 50) | |
| ) | |
| # Summary statistics | |
| self._render_summary_stats(display_guests, config) | |
| def _create_comprehensive_guest_list(self, config): | |
| """Create a comprehensive list of all guests with RSVP data""" | |
| all_guests = [] | |
| for group_name, group_data in self.guest_list_data.items(): | |
| # Get RSVP data for this group | |
| rsvp_data = self.rsvp_data.get(group_name, {}) if self.rsvp_data else {} | |
| # Add named guests | |
| for named_guest in group_data['named_guests']: | |
| guest = { | |
| 'display_name': named_guest['full_name'], | |
| 'first_name': named_guest['first_name'], | |
| 'last_name': named_guest['last_name'], | |
| 'group_name': group_name, | |
| 'party': group_data['party'], | |
| 'address': group_data['address'], | |
| 'type': 'Named Guest', | |
| 'phone': rsvp_data.get('phone_number', ''), | |
| 'rsvp_by_event': {}, | |
| 'meal_selections': {} | |
| } | |
| # Apply RSVP data | |
| self._apply_rsvp_to_guest(guest, rsvp_data, config) | |
| all_guests.append(guest) | |
| # Add plus one spots | |
| plus_one_spots = group_data['plus_one_spots'] | |
| if plus_one_spots > 0: | |
| # Get plus one names from RSVP data | |
| group_attendees = rsvp_data.get('group_attendees', []) | |
| named_guest_names = [g['full_name'] for g in group_data['named_guests']] | |
| # Find plus one names (attendees not in named guests) | |
| plus_one_names = [name for name in group_attendees if name not in named_guest_names] | |
| # Create plus one entries | |
| for i in range(plus_one_spots): | |
| if i < len(plus_one_names): | |
| # Named plus one | |
| plus_one_name = plus_one_names[i] | |
| guest = { | |
| 'display_name': plus_one_name, | |
| 'first_name': plus_one_name.split()[0] if plus_one_name.split() else plus_one_name, | |
| 'last_name': ' '.join(plus_one_name.split()[1:]) if len(plus_one_name.split()) > 1 else '', | |
| 'group_name': group_name, | |
| 'party': group_data['party'], | |
| 'address': group_data['address'], | |
| 'type': 'Plus One (Named)', | |
| 'phone': '', | |
| 'rsvp_by_event': {}, | |
| 'meal_selections': {} | |
| } | |
| else: | |
| # Unnamed plus one | |
| guest = { | |
| 'display_name': f'Unnamed Plus One {i+1}', | |
| 'first_name': '', | |
| 'last_name': '', | |
| 'group_name': group_name, | |
| 'party': group_data['party'], | |
| 'address': group_data['address'], | |
| 'type': 'Plus One (Unnamed)', | |
| 'phone': '', | |
| 'rsvp_by_event': {}, | |
| 'meal_selections': {} | |
| } | |
| # Apply RSVP data | |
| self._apply_rsvp_to_guest(guest, rsvp_data, config) | |
| all_guests.append(guest) | |
| return all_guests | |
| def _apply_rsvp_to_guest(self, guest, rsvp_data, config): | |
| """Apply RSVP data to a guest""" | |
| wedding_events = config.get('wedding_events', []) | |
| # Initialize all events with "Pending" status | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name: | |
| guest['rsvp_by_event'][event_name] = 'Pending' | |
| if not rsvp_data: | |
| return | |
| event_responses = rsvp_data.get('event_responses', {}) | |
| group_attendees = rsvp_data.get('group_attendees', []) | |
| dietary_restrictions = rsvp_data.get('dietary_restrictions', {}) | |
| guest_name = guest['display_name'] | |
| # Apply dietary restrictions | |
| if guest_name in dietary_restrictions: | |
| guest['allergies'] = dietary_restrictions[guest_name] | |
| # Apply event responses | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| if event_name in event_responses: | |
| event_data = event_responses[event_name] | |
| attendees = event_data.get('attendees', []) | |
| party_rsvp = event_data.get('rsvp', 'Pending') | |
| # Determine individual RSVP status | |
| if guest_name in attendees: | |
| guest['rsvp_by_event'][event_name] = party_rsvp | |
| # Apply meal choice if attending and event requires meal choice | |
| if party_rsvp == 'Yes' and event.get('requires_meal_choice', False): | |
| meal_choice = event_data.get('meal_choice', {}) | |
| if guest_name in meal_choice: | |
| guest['meal_selections'][event_name] = meal_choice[guest_name] | |
| else: | |
| guest['rsvp_by_event'][event_name] = 'No' | |
| def _render_summary_stats(self, all_guests, config): | |
| """Render summary statistics""" | |
| st.markdown("### Summary Statistics") | |
| col1, col2, col3, col4, col5 = st.columns(5) | |
| with col1: | |
| total_guests = len(all_guests) | |
| st.metric("Total Guests", total_guests) | |
| with col2: | |
| named_guests = len([g for g in all_guests if g['type'] == 'Named Guest']) | |
| st.metric("Named Guests", named_guests) | |
| with col3: | |
| plus_ones = len([g for g in all_guests if 'Plus One' in g['type']]) | |
| st.metric("Plus Ones", plus_ones) | |
| with col4: | |
| confirmed = len([g for g in all_guests if any(status == 'Yes' for status in g.get('rsvp_by_event', {}).values())]) | |
| st.metric("Confirmed Attendees", confirmed) | |
| with col5: | |
| # Count confirmed plus ones (plus ones who have confirmed to at least one event) | |
| confirmed_plus_ones = len([g for g in all_guests if 'Plus One' in g['type'] and any(status == 'Yes' for status in g.get('rsvp_by_event', {}).values())]) | |
| st.metric("Confirmed Plus Ones", confirmed_plus_ones) | |
| # Event attendance breakdown | |
| st.markdown("#### Event Attendance") | |
| wedding_events = config.get('wedding_events', []) | |
| event_stats = [] | |
| for event in wedding_events: | |
| event_name = event.get('name', '') | |
| yes_count = len([g for g in all_guests if g.get('rsvp_by_event', {}).get(event_name) == 'Yes']) | |
| no_count = len([g for g in all_guests if g.get('rsvp_by_event', {}).get(event_name) == 'No']) | |
| pending_count = len([g for g in all_guests if g.get('rsvp_by_event', {}).get(event_name) == 'Pending']) | |
| event_stats.append({ | |
| 'Event': event_name, | |
| 'Yes': yes_count, | |
| 'No': no_count, | |
| 'Pending': pending_count | |
| }) | |
| if event_stats: | |
| stats_df = pd.DataFrame(event_stats) | |
| st.dataframe(stats_df, use_container_width=True, hide_index=True) | |
| # Meal choice breakdown | |
| st.markdown("#### Meal Choices") | |
| wedding_events = config.get('wedding_events', []) | |
| # Get events that require meal choices | |
| meal_events = [event for event in wedding_events if event.get('requires_meal_choice', False)] | |
| if meal_events: | |
| for event in meal_events: | |
| event_name = event.get('name', '') | |
| meal_options = event.get('meal_options', []) | |
| if meal_options: | |
| st.markdown(f"**{event_name}**") | |
| # Count meal choices for this event | |
| meal_counts = {option: 0 for option in meal_options} | |
| meal_counts['Not Selected'] = 0 | |
| outdated_choices = {} | |
| for guest in all_guests: | |
| meal_selections = guest.get('meal_selections', {}) | |
| selected_meal = meal_selections.get(event_name, 'Not Selected') | |
| if selected_meal in meal_counts: | |
| meal_counts[selected_meal] += 1 | |
| elif selected_meal != 'Not Selected': | |
| # This is an outdated meal choice | |
| if selected_meal not in outdated_choices: | |
| outdated_choices[selected_meal] = [] | |
| outdated_choices[selected_meal].append(guest['display_name']) | |
| else: | |
| meal_counts['Not Selected'] += 1 | |
| # Display meal choice counts | |
| col1, col2, col3, col4 = st.columns(4) | |
| cols = [col1, col2, col3, col4] | |
| for i, (meal, count) in enumerate(meal_counts.items()): | |
| if i < len(cols): | |
| with cols[i]: | |
| st.metric(meal, count) | |
| # Show outdated meal choices warning | |
| if outdated_choices: | |
| st.warning("⚠️ **Outdated Meal Choices Detected!**") | |
| st.markdown("The following guests selected meal options that are no longer available on the current menu:") | |
| for outdated_meal, guests in outdated_choices.items(): | |
| with st.expander(f"🔍 {outdated_meal} ({len(guests)} guests)", expanded=False): | |
| st.markdown("**Guests who selected this outdated option:**") | |
| # Create a table for better formatting | |
| guest_data = [] | |
| for guest_name in guests: | |
| # Find the guest to get their phone number | |
| guest_info = next((g for g in all_guests if g['display_name'] == guest_name), None) | |
| # Get phone number - prefer individual phone, fall back to group contact | |
| phone = 'No phone provided' | |
| if guest_info: | |
| # Check if guest has individual phone number | |
| individual_phone = guest_info.get('phone', '') | |
| if individual_phone and individual_phone.strip() and individual_phone != 'No phone provided': | |
| phone = individual_phone | |
| else: | |
| # Use group contact phone number | |
| group_name = guest_info.get('group_name', '') | |
| if group_name and self.rsvp_data and group_name in self.rsvp_data: | |
| group_phone = self.rsvp_data[group_name].get('phone_number', '') | |
| if group_phone and group_phone.strip() and group_phone != 'No phone provided': | |
| phone = f"{group_phone} (Group Contact)" | |
| guest_data.append({ | |
| 'Name': guest_name, | |
| 'Phone': phone | |
| }) | |
| if guest_data: | |
| guest_df = pd.DataFrame(guest_data) | |
| st.dataframe(guest_df, use_container_width=True, hide_index=True) | |
| st.markdown("**Action Required:**") | |
| st.markdown(f"Please contact these {len(guests)} guests to update their meal choice from '{outdated_meal}' to one of the current options: {', '.join(meal_options)}") | |
| else: | |
| st.info("No events require meal choices.") |