From 535fc9f2b5b34cbdba5297e5574eecffb5ffdcf2 Mon Sep 17 00:00:00 2001 From: Scott Idem Date: Tue, 7 Apr 2026 10:58:08 -0400 Subject: [PATCH] =?UTF-8?q?event:=20Zoom=20CSV=20import=20=E2=80=94=20use?= =?UTF-8?q?=20email=20as=20fallback=20external=5Fid;=20populate=20address/?= =?UTF-8?q?phone=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/routers/event_badge_importing.py | 251 ++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 3 deletions(-) diff --git a/app/routers/event_badge_importing.py b/app/routers/event_badge_importing.py index 027ea62..6002062 100644 --- a/app/routers/event_badge_importing.py +++ b/app/routers/event_badge_importing.py @@ -16,9 +16,9 @@ from app.methods.event_person_methods import create_event_person_obj, create_upd # from app.methods.event_presenter_methods import create_update_event_presenter_obj_v4, get_event_presenter_rec_list, load_event_presenter_obj from app.methods.hosted_file_methods import load_hosted_file_obj, save_file -from app.models.event_models import Event_Base +#from app.models.event_models import Event_Base # from app.models.event_location_models import Event_Location_Base -from app.models.event_person_models import Event_Person_Base +#from app.models.event_person_models import Event_Person_Base # from app.models.event_presentation_models import Event_Presentation_Base # from app.models.event_presenter_models import Event_Presenter_Base # from app.models.event_session_models import Event_Session_Base @@ -472,4 +472,249 @@ async def event_id_badge_import( if return_detail: return mk_resp(data=event_badge_person_li, status_message=f'Importing badges from file. Found {len(person_li)} badges.', response=commons.response) else: - return mk_resp(data=event_badge_person_summary_li, status_message=f'Checked for badges from file. Found {len(event_badge_person_li)} badges.', response=commons.response) \ No newline at end of file + return mk_resp(data=event_badge_person_summary_li, status_message=f'Checked for badges from file. Found {len(event_badge_person_li)} badges.', response=commons.response) + + +# ### BEGIN ### Zoom Events CSV Badge Import ### event_id_badge_import_zoom_csv() ### +# Accepts a Zoom Events registrant CSV export and upserts event_person records. +# Zoom CSV format: fixed columns (First name, Last name, Registrant email, Ticket name, +# Unique identifier, etc.) plus per-ticket-type custom fields using the pattern +# "FieldLabel_*_TicketTypeName". Delimiter is auto-detected (Zoom exports vary). +# Updated 2026-04-06 + +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. + Tries the exact ticket match first, then falls back to the first non-empty value + across all variants of that field prefix. + """ + exact_key = f'{field_prefix}_*_{ticket_name}' + if val := str(record.get(exact_key, '')).strip(): + return val + for key, val in record.items(): + if key.startswith(f'{field_prefix}_*_') and str(val).strip(): + return str(val).strip() + return '' + + +@router.post('/event/{event_id}/badge/import/zoom_csv', response_model=Resp_Body_Base) +async def event_id_badge_import_zoom_csv( + event_id: str = Path(min_length=11, max_length=22), + file: UploadFile = File(...), + + begin_at: int = 0, + end_at: int = 20000, + + return_detail: bool = False, + + commons: Common_Route_Params = Depends(common_route_params), + ): + """ + Import event badges from a Zoom Events registrant CSV export. + + Zoom exports fixed columns (First name, Last name, Registrant email, Ticket name, + Unique identifier) plus per-ticket-type custom fields in the format + "FieldLabel_*_TicketTypeName". The 'Unique identifier' column is used as the + external_registration_id. Delimiter is auto-detected. + """ + 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) + + # Zoom CSV layout: row 1 = "Report generated" metadata, row 2 = blank, row 3 = headers + # Delimiter is auto-detected (Zoom exports vary between comma and tab) + df = pandas.read_csv( + full_file_path, + sep=None, + engine='python', + skiprows=2, + na_filter=False, + dtype=str, + ) + + df_dict = df.to_dict(orient='records') + log.info(f'Zoom CSV total record count: {len(df_dict)}') + + loop_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 + + unique_id = str(record.get('Unique identifier', '')).strip() + email = str(record.get('Registrant email', '')).strip() + + if unique_id: + external_id = unique_id + elif email: + log.info('Using email as external_id fallback for Zoom CSV row') + external_id = email + else: + log.warning('Row missing Unique identifier and email — skipping.') + continue + + ticket_name = str(record.get('Ticket name', '')).strip() + given_name = str(record.get('First name', '')).strip() + family_name = str(record.get('Last name', '')).strip() + display_name = str(record.get('Display name', '')).strip() + + # Per-ticket-type custom fields + organization = _zoom_ticket_field(record, 'Organization', ticket_name) + professional_title = _zoom_ticket_field(record, 'Job title', ticket_name) + phone = (_zoom_ticket_field(record, 'Phone', ticket_name) + or _zoom_ticket_field(record, 'Phone number', ticket_name)) + address_line_1 = (_zoom_ticket_field(record, 'Address line 1', ticket_name) + or _zoom_ticket_field(record, 'Address', ticket_name)) + address_line_2 = _zoom_ticket_field(record, 'Address line 2', ticket_name) + address_line_3 = _zoom_ticket_field(record, 'Address line 3', ticket_name) + city = _zoom_ticket_field(record, 'City', ticket_name) + state_province = _zoom_ticket_field(record, 'State/Province', ticket_name) + state_province_abb = _zoom_ticket_field(record, 'State/Province Abb', ticket_name) + postal_code = (_zoom_ticket_field(record, 'Postal code', ticket_name) + or _zoom_ticket_field(record, 'Zip code', ticket_name) + or _zoom_ticket_field(record, 'Zip/Postal Code', ticket_name)) + country = _zoom_ticket_field(record, 'Country/Region', ticket_name) + country_alpha_2_code = _zoom_ticket_field(record, 'Country Alpha 2 Code', ticket_name) + country_subdivision_code = _zoom_ticket_field(record, 'Country Subdivision Code', ticket_name) + # location, full_address, location_long, location_short are computed by DB triggers + + 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, + 'external_registration_id': unique_id, + 'event_person_profile': { + 'enable': True, + 'given_name': given_name, + 'family_name': family_name, + 'full_name': display_name or f'{given_name} {family_name}'.strip(), + 'email': email, + 'phone': phone, + 'address_line_1': address_line_1, + 'address_line_2': address_line_2, + 'address_line_3': address_line_3, + 'city': city, + 'state_province': state_province, + 'state_province_abb': state_province_abb, + 'postal_code': postal_code, + 'country': country, + 'country_alpha_2_code': country_alpha_2_code, + 'country_subdivision_code': country_subdivision_code, + 'professional_title': professional_title, + 'affiliations': organization, + }, + 'event_badge': { + 'enable': True, + 'external_id': external_id, + 'external_registration_id': unique_id, + 'given_name': given_name, + 'family_name': family_name, + 'full_name': display_name or f'{given_name} {family_name}'.strip(), + 'email': email, + 'phone': phone, + 'address_line_1': address_line_1, + 'address_line_2': address_line_2, + 'address_line_3': address_line_3, + 'city': city, + 'state_province': state_province, + 'state_province_abb': state_province_abb, + 'postal_code': postal_code, + 'country': country, + 'country_alpha_2_code': country_alpha_2_code, + 'country_subdivision_code': country_subdivision_code, + 'professional_title': professional_title, + 'affiliations': organization, + 'badge_type': ticket_name, + }, + } + + event_badge_person_li.append(event_person_data) + event_badge_person_summary_li.append(event_person_summary) + + 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 + """ + + if event_person_result := sql_select(sql=sql_select_event_person, data=event_person_summary): + 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)}') + else: + 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'Updating existing Event Person ID: {event_person_id}') + if 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, + ): + log.warning(f'Event Person updated. ID: {event_person_id}') + else: + log.warning(f'Event Person not updated. ID: {event_person_id}') + else: + log.info('No Event Person found. Creating new...') + if result_id := create_update_event_person_obj_v4( + event_person_dict_obj=event_person_data, + account_id=account_id, + event_id=event_id, + ): + log.warning(f'Event Person created. ID: {result_id}') + else: + log.warning('Event Person not created.') + + 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