From 35fa5132e7ecfdf4d4388767e39f9dc8f40b397d Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 2 Jun 2026 10:27:59 -0400 Subject: [PATCH] feat(event_badge): add Splash (Cvent) XLSX badge import endpoint Adds POST /event/{event_id}/badge/import/splash_xlsx to handle Splash registrant XLSX exports for Axonius DC 2026 and future events. Includes _split_full_name helper for splitting 'Full Name' into given/family name components. Co-Authored-By: Claude Sonnet 4.6 --- app/routers/event_badge_importing.py | 228 ++++++++++++++++++++++++++- 1 file changed, 227 insertions(+), 1 deletion(-) diff --git a/app/routers/event_badge_importing.py b/app/routers/event_badge_importing.py index 18feaa0..3accbee 100644 --- a/app/routers/event_badge_importing.py +++ b/app/routers/event_badge_importing.py @@ -501,6 +501,14 @@ async def event_id_badge_import( # WHERE badge_type = 'Adapt26 Sponsor'; +def _split_full_name(full_name: str) -> tuple: + """Split 'First Last' on last space into (given_name, family_name).""" + parts = full_name.strip().rsplit(' ', 1) + if len(parts) == 2: + return parts[0], parts[1] + return full_name.strip(), '' + + def _zoom_ticket_field(record: dict, field_prefix: str, ticket_name: str) -> str: """ Extracts a per-ticket-type field value from a Zoom CSV row. @@ -811,4 +819,222 @@ async def event_id_badge_import_zoom_csv( if return_detail: return mk_resp(data=event_badge_person_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_li)} records.', response=commons.response) else: - return mk_resp(data=event_badge_person_summary_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_summary_li)} records.', response=commons.response) \ No newline at end of file + return mk_resp(data=event_badge_person_summary_li, status_message=f'Zoom CSV import complete. Processed {len(event_badge_person_summary_li)} records.', response=commons.response) + + +# ### BEGIN ### Splash (Cvent) XLSX Badge Import ### event_id_badge_import_splash_xlsx() ### +# Accepts a Splash (Cvent) registrant XLSX export and inserts/updates event_person records. +# Splash exports fixed columns: Full Name, Email, Time of RSVP, Status, plus custom +# fields prefixed with "Custom: ". Email is used as external_id. Full Name is split +# on the last space into given_name/family_name and also stored directly as full_name. +# Updated 2026-06-02 + +@router.post('/event/{event_id}/badge/import/splash_xlsx', response_model=Resp_Body_Base) +async def event_id_badge_import_splash_xlsx( + event_id: str = Path(min_length=11, max_length=22), + file: UploadFile = File(...), + + begin_at: int = 0, + end_at: int = 20000, + + import_status_filter: str = 'Attending', # set to '' to import all statuses + + return_detail: bool = False, + + commons: Common_Route_Params = Depends(common_route_params), + ): + """ + Import event badges from a Splash (Cvent) registrant XLSX export. + + Splash exports fixed columns (Full Name, Email, Time of RSVP, Status) plus + custom fields prefixed with "Custom: ". Email is used as external_id. + Full Name is split on the last space into given_name/family_name and also + stored directly as full_name. Pass import_status_filter='' to import all + statuses (default is 'Attending'). + """ + log.setLevel(logging.INFO) + + account_id = commons.x_account_id + + event_id_random = event_id + if event_id := redis_lookup_id_random(record_id_random=event_id, table_name='event'): pass + else: return mk_resp(data=None, status_code=404, response=commons.response) + + link_to_type = 'event' + link_to_id = event_id + + file_info = await save_file( + file=file, + account_id=account_id, + link_to_type=link_to_type, + link_to_id=link_to_id, + ) + if file_info['saved']: + log.info('File saved') + else: + log.error('Something may have gone wrong while saving the uploaded file?') + return mk_resp(data=None, status_code=500, response=commons.response) + + hosted_files_path = settings.FILES_PATH['hosted_files_root'] + subdirectory_dest = os.path.join(hosted_files_path, file_info.get('subdirectory_path')) + hash_filename = file_info.get('hash_sha256') + '.file' + full_file_path = pathlib.Path(os.path.join(subdirectory_dest, hash_filename)) + + if not full_file_path.exists(): + log.warning(f'Not found at full file path: {full_file_path}') + return mk_resp(data=None, status_code=500, response=commons.response) + + df = pandas.read_excel(full_file_path, dtype=str, na_filter=False) + + df_dict = df.to_dict(orient='records') + log.info(f'Splash XLSX total record count: {len(df_dict)}') + + loop_count = 0 + skipped_count = 0 + event_badge_person_li = [] + event_badge_person_summary_li = [] + + log.setLevel(logging.DEBUG) + for record in df_dict: + log.info(f'Loop Count: {loop_count}') + loop_count += 1 + if loop_count <= begin_at: continue + if loop_count > end_at: break + + # Status filter — skip rows that don't match when a filter is set. + if import_status_filter: + status = str(record.get('Status', '')).strip() + if status != import_status_filter: + log.info(f'Skipping row with status "{status}" (filter: "{import_status_filter}")') + skipped_count += 1 + continue + + email = str(record.get('Email', '')).strip() + if not email: + log.warning('Row missing Email — skipping.') + skipped_count += 1 + continue + external_id = email + + full_name = str(record.get('Full Name', '')).strip() + given_name, family_name = _split_full_name(full_name) + + professional_title = str(record.get('Custom: Job Title', '')).strip() + organization = str(record.get('Custom: Company Name', '')).strip() + country = str(record.get('Custom: Country', '')).strip() + state_province = str(record.get('Custom: State', '')).strip() + dietary_restrictions = str(record.get('Custom: Please note any dietary restrictions or preferences.', '')).strip() + + # "Custom: Opt-In" → agree_to_tc / allow_tracking + opt_in_raw = str(record.get('Custom: Opt-In', '')).strip().lower() + if opt_in_raw in ('yes', 'y', 'true', '1', 'opt-in', 'opt_in'): + agree_to_tc_val = True + allow_tracking_val = True + elif opt_in_raw in ('no', 'n', 'false', '0', 'opt-out', 'opt_out'): + agree_to_tc_val = False + allow_tracking_val = False + else: + agree_to_tc_val = None + allow_tracking_val = None + + event_person_summary = { + 'event_id': event_id, + 'event_id_random': event_id_random, + 'external_id': external_id, + 'given_name': given_name, + 'family_name': family_name, + 'email': email, + } + + event_person_data = { + 'account_id': account_id, + 'event_id': event_id, + 'enable': True, + 'external_id': external_id, + 'event_person_profile': { + 'event_id': event_id, + 'enable': True, + 'given_name': given_name, + 'family_name': family_name, + 'full_name': full_name, + 'email': email, + 'professional_title': professional_title, + 'affiliations': organization, + 'country': country, + 'state_province': state_province, + }, + 'event_badge': { + 'enable': True, + 'external_id': external_id, + 'given_name': given_name, + 'family_name': family_name, + 'full_name': full_name, + 'email': email, + 'professional_title': professional_title, + 'affiliations': organization, + 'country': country, + 'state_province': state_province, + 'other_1': dietary_restrictions, + # TEMPORARY: Axonius DC event badge template mu_7SRuJYum (23). + 'event_badge_template_id': 23, + 'event_badge_template_id_random': 'mu_7SRuJYum', + 'badge_type_code': 'attendee', + 'agree_to_tc': agree_to_tc_val, + 'allow_tracking': allow_tracking_val, + }, + } + + sql_select_event_person = """ + SELECT id AS event_person_id, id_random AS event_person_id_random, + external_id AS event_person_external_id, + event_badge_id AS event_badge_id, + event_person_profile_id AS event_person_profile_id + FROM `event_person` + WHERE event_person.event_id = :event_id + AND event_person.external_id = :external_id + /*LIMIT 2*/; + """ + + event_person_result = sql_select(sql=sql_select_event_person, data=event_person_summary) + if event_person_result: + if isinstance(event_person_result, list): + log.error(f'Found more than one Event Person with external_id={external_id}. Count: {len(event_person_result)}') + event_person_result = event_person_result[0] + + event_person_id = event_person_result.get('event_person_id') + event_badge_id = event_person_result.get('event_badge_id') + event_person_profile_id = event_person_result.get('event_person_profile_id') + log.info(f'Found Event Person. Updating existing... Event Person ID: {event_person_id}') + + updated_id = create_update_event_person_obj_v4( + event_person_dict_obj=event_person_data, + event_person_id=event_person_id, + account_id=account_id, + event_id=event_id, + event_badge_id=event_badge_id, + event_person_profile_id=event_person_profile_id, + ) + if updated_id: + log.warning(f'Event Person updated. ID: {updated_id}') + else: + log.warning(f'Event Person not updated. ID: {event_person_id}') + else: + log.info('No Event Person found. Creating new...') + result_id = create_update_event_person_obj_v4( + event_person_dict_obj=event_person_data, + account_id=account_id, + event_id=event_id, + ) + if result_id: + log.warning(f'Event Person created. ID: {result_id}') + else: + log.warning('Event Person not created.') + + event_badge_person_li.append(event_person_data) + event_badge_person_summary_li.append(event_person_summary) + + processed = len(event_badge_person_li) + if return_detail: + return mk_resp(data=event_badge_person_li, status_message=f'Splash XLSX import complete. Processed {processed} records, skipped {skipped_count}.', response=commons.response) + else: + return mk_resp(data=event_badge_person_summary_li, status_message=f'Splash XLSX import complete. Processed {processed} records, skipped {skipped_count}.', response=commons.response) \ No newline at end of file