From 2b0d7670564949e1f94de5c623e1bf02f412dde2 Mon Sep 17 00:00:00 2001 From: epowell Date: Thu, 5 Feb 2026 22:40:53 -0600 Subject: [PATCH] Enhance attendee processing by adding email retrieval and Neon ID lookup functions --- attendanceToTestout.py | 43 +++++++++++++--- tests/neon_mocker.py | 35 ++++++++----- tests/test_attendanceToTestout.py | 81 +++++++++++++++++++++++++++++++ 3 files changed, 140 insertions(+), 19 deletions(-) diff --git a/attendanceToTestout.py b/attendanceToTestout.py index 7fe5dc5..e95a7c8 100644 --- a/attendanceToTestout.py +++ b/attendanceToTestout.py @@ -3,6 +3,7 @@ import logging import traceback import os +from typing import Optional import helpers.neon as neon from helpers.api import apiCall @@ -55,6 +56,29 @@ } +def _get_attendee_email(attendee: dict) -> Optional[str]: + for key in ("email", "email1", "emailAddress"): + if attendee.get(key): + return attendee.get(key) + return None + + +def _get_neon_id_by_email(email: str) -> Optional[int]: + if not email: + return None + results = neon.get_acct_by_email(email) + if not results: + logging.warning("No Neon account found for attendee email %s", email) + return None + if len(results) > 1: + logging.warning("Multiple Neon accounts found for attendee email %s; using first match", email) + try: + return int(results[0].get("Account ID")) + except (TypeError, ValueError): + logging.warning("Invalid Neon account ID for attendee email %s", email) + return None + + def toolTestingUpdate(className: str, neonId: int, inputDate: str): date = datetime.datetime.strftime( datetime.datetime.strptime(inputDate, "%Y-%m-%d"), "%m/%d/%Y" @@ -141,13 +165,18 @@ def main(): registrants = neon.getEventRegistrants(eventId)["eventRegistrations"] if type(registrants) is not type(None): for registrant in registrants: - attended = registrant["tickets"][0]["attendees"][0][ - "markedAttended" - ] - if attended == True: - toolTestingUpdate( - eventName, registrant["registrantAccountId"], eventDate - ) + for ticket in registrant.get("tickets", []): + for attendee in ticket.get("attendees", []): + if attendee.get("markedAttended") == True: + attendee_email = _get_attendee_email(attendee) + neon_id = _get_neon_id_by_email(attendee_email) + if neon_id: + toolTestingUpdate(eventName, neon_id, eventDate) + else: + logging.info( + "Skipping attendee without valid Neon account for email %s", + attendee_email, + ) else: logging.info("Event Search contained no results") except TypeError: diff --git a/tests/neon_mocker.py b/tests/neon_mocker.py index fdffef3..f8cffd1 100644 --- a/tests/neon_mocker.py +++ b/tests/neon_mocker.py @@ -10,7 +10,6 @@ import random -import string from typing import List, Dict, Any, Optional from neonUtil import N_baseURL import neonUtil @@ -188,11 +187,20 @@ def __init__( self.start_time = start_time self.end_time = end_time self.capacity = capacity - self._registrants: List[tuple] = [] # List of (NeonUserMock, status, marked_attended) + self._registrants: List[tuple] = [] # List of (NeonUserMock, status, marked_attended, attendees) - def add_registrant(self, account: 'NeonUserMock', status: str = "SUCCEEDED", marked_attended: bool = False) -> 'NeonEventMock': - """Add a registrant to this event.""" - self._registrants.append((account, status, marked_attended)) + def add_registrant( + self, + account: 'NeonUserMock', + status: str = "SUCCEEDED", + marked_attended: bool = False, + attendees: Optional[List[dict]] = None, + ) -> 'NeonEventMock': + """Add a registrant to this event. + + attendees can be a list of dicts with attendee fields to model multiple attendees. + """ + self._registrants.append((account, status, marked_attended, attendees)) return self def search_result(self) -> Dict[str, Any]: @@ -220,16 +228,19 @@ def mock(self, requests_mock): """ event_registrations = [] account_mocks = [] - for account, status, marked_attended in self._registrants: + for account, status, marked_attended, attendees in self._registrants: + if attendees is None: + attendees = [{ + "firstName": account.firstName, + "lastName": account.lastName, + "email": account.email, + "registrationStatus": status, + "markedAttended": marked_attended + }] event_registrations.append({ "registrantAccountId": account.account_id, "tickets": [{ - "attendees": [{ - "firstName": account.firstName, - "lastName": account.lastName, - "registrationStatus": status, - "markedAttended": marked_attended - }] + "attendees": attendees }] }) account.mock(requests_mock) diff --git a/tests/test_attendanceToTestout.py b/tests/test_attendanceToTestout.py index fa42c1b..d63aef9 100644 --- a/tests/test_attendanceToTestout.py +++ b/tests/test_attendanceToTestout.py @@ -17,6 +17,12 @@ def test_main_processes_attended_event(requests_mock): search_mock, [(registrants_mock, account_mocks)] = NeonEventMock.mock_events(requests_mock, [event]) + # Mock the account search by attendee email + account_search_mock = requests_mock.post( + f'{N_baseURL}/accounts/search', + json={"searchResults": [student.search_result()], "pagination": {"totalPages": 1, "currentPage": 0}} + ) + # Mock the PATCH to update the account with the new field patch_mock = requests_mock.patch( f'{N_baseURL}/accounts/{student.account_id}', @@ -30,6 +36,7 @@ def test_main_processes_attended_event(requests_mock): assert search_mock.called, "Event search API should be called" assert registrants_mock.called, "Event registrants API should be called" assert account_mocks[0].called, "Account info API should be called" + assert account_search_mock.called, "Account search API should be called" assert patch_mock.called, "Account PATCH API should be called to update custom field" # Verify the PATCH request contains the correct custom field ID @@ -46,6 +53,12 @@ def test_main_skips_already_marked_accounts(requests_mock): search_mock, [(registrants_mock, account_mocks)] = NeonEventMock.mock_events(requests_mock, [event]) + # Mock the account search by attendee email + account_search_mock = requests_mock.post( + f'{N_baseURL}/accounts/search', + json={"searchResults": [student.search_result()], "pagination": {"totalPages": 1, "currentPage": 0}} + ) + # Mock PATCH but it should NOT be called patch_mock = requests_mock.patch( f'{N_baseURL}/accounts/{student.account_id}', @@ -59,11 +72,79 @@ def test_main_skips_already_marked_accounts(requests_mock): assert search_mock.called, "Event search API should be called" assert registrants_mock.called, "Event registrants API should be called" assert account_mocks[0].called, "Account info API should be called" + assert account_search_mock.called, "Account search API should be called" # Verify PATCH was NOT called since account already has the field assert not patch_mock.called, "PATCH should not be called when field already exists" +def test_main_handles_multiple_attendees_by_email(requests_mock): + """Test that main() updates attendance for each attended attendee using their email""" + registrant = NeonUserMock(firstName="Reg", lastName="Owner") + attendee_1 = NeonUserMock(firstName="Ann", lastName="Attended") + attendee_2 = NeonUserMock(firstName="Skip", lastName="Absent") + + attendees = [ + { + "firstName": attendee_1.firstName, + "lastName": attendee_1.lastName, + "email": attendee_1.email, + "registrationStatus": "SUCCEEDED", + "markedAttended": True, + }, + { + "firstName": attendee_2.firstName, + "lastName": attendee_2.lastName, + "email": attendee_2.email, + "registrationStatus": "SUCCEEDED", + "markedAttended": False, + }, + ] + + event = NeonEventMock(event_name="Woodshop Safety")\ + .add_registrant(registrant, attendees=attendees) + + search_mock, [(registrants_mock, account_mocks)] = NeonEventMock.mock_events(requests_mock, [event]) + attendee_1.mock(requests_mock) + + def _matches_email(email): + def _matcher(request): + payload = request.json() + return payload["searchFields"][0]["value"] == email + return _matcher + + account_search_mock_1 = requests_mock.post( + f'{N_baseURL}/accounts/search', + json={"searchResults": [attendee_1.search_result()], "pagination": {"totalPages": 1, "currentPage": 0}}, + additional_matcher=_matches_email(attendee_1.email), + ) + account_search_mock_2 = requests_mock.post( + f'{N_baseURL}/accounts/search', + json={"searchResults": [attendee_2.search_result()], "pagination": {"totalPages": 1, "currentPage": 0}}, + additional_matcher=_matches_email(attendee_2.email), + ) + + patch_mock_1 = requests_mock.patch( + f'{N_baseURL}/accounts/{attendee_1.account_id}', + status_code=200 + ) + patch_mock_2 = requests_mock.patch( + f'{N_baseURL}/accounts/{attendee_2.account_id}', + status_code=200 + ) + + import attendanceToTestout + attendanceToTestout.main() + + assert search_mock.called, "Event search API should be called" + assert registrants_mock.called, "Event registrants API should be called" + assert account_mocks[0].called, "Registrant account API should be called" + assert account_search_mock_1.called, "Account search API should be called for attendee 1" + assert not account_search_mock_2.called, "Account search API should not be called for non-attended attendee" + assert patch_mock_1.called, "Attended attendee should be updated" + assert not patch_mock_2.called, "Non-attended attendee should not be updated" + + def test_main_handles_empty_search_results(requests_mock): """Test that main() handles empty event search results gracefully""" search_mock, _ = NeonEventMock.mock_events(requests_mock, [])