From 4bd23d8afa95e14e1904622c1de397eda857a4b1 Mon Sep 17 00:00:00 2001 From: Cam Date: Sun, 6 Jul 2025 23:54:14 -0500 Subject: [PATCH] Major refactor to make testing easier This refactors a lot of the code in the existing dailyClassReminder.py script to make it more readable and easier to add tests. Mostly, I've deleted unused code and simplified data structures where possible (e.g. where some parts of the data are never referenced). This is a pretty major refactor, so I wouldn't expect to port this in all in one PR. I wanted to put something together to show how a refactor could make the code more readable and easier to work with - that said, there are a ton of edge cases and weird behaviors that I'm not familiar with, so I want to make sure that we take it step-by-step to make changes and reduce the risk of these scripts breaking. --- dailyClassReminder.py | 339 +++++++++++++++++++------------------ requirements.txt | 11 ++ test_DailyClassReminder.py | 36 ++++ 3 files changed, 218 insertions(+), 168 deletions(-) create mode 100644 requirements.txt create mode 100644 test_DailyClassReminder.py diff --git a/dailyClassReminder.py b/dailyClassReminder.py index 909d197..8aa1db4 100644 --- a/dailyClassReminder.py +++ b/dailyClassReminder.py @@ -30,22 +30,30 @@ datefmt="%Y-%m-%d %H:%H:%S", ) - -# Get events for the next DELTA_DAYS days TODAY = datetime.date.today() -logging.info("\n\n----- Beginning class reminders for %s -----\n\n", TODAY.isoformat()) -DELTA_DAYS = (TODAY + datetime.timedelta(days=2)).isoformat() -SEARCH_FIELDS = [ - { - "field": "Event Start Date", - "operator": "GREATER_AND_EQUAL", - "value": TODAY.isoformat(), - }, - {"field": "Event Start Date", "operator": "LESS_AND_EQUAL", "value": DELTA_DAYS}, - {"field": "Event Archived", "operator": "EQUAL", "value": "No"}, -] -OUTPUT_FIELDS = [ +EMAIL_TEMPLATE =""" +Hi {teacher_first_name}, + +This is an automated email to remind you of the upcoming classes you are scheduled to teach at Asmbly. +Thank you for sharing your knowledge with the community! + +{pretty_events} + +Please note these are the registrations as of the time of this email and may not reflect final registrations for your class. +You can see more details about these events and registrants in your Neon backend account. +The login URL is https://asmbly.z2systems.com/np/admin/content/contentList.do +Email classes@asmbly.org if you have any questions about the above schedule. + +\t* Note: Some registrants are purchased under a single account and thus end up with the same email and phone number. + + +Thanks again! +Asmbly AdminBot +""" + + +NEON_EVENT_OUTPUT_FIELDS = [ "Event Name", "Event ID", "Event Topic", @@ -59,177 +67,172 @@ "Waiting List Status", ] -RESPONSE_EVENTS = neon.postEventSearch(SEARCH_FIELDS, OUTPUT_FIELDS) +def get_neon_events_in_next_2_days(n_days): + delta_days = (TODAY + datetime.timedelta(days=2)).isoformat() + search_fields = [ + { + "field": "Event Start Date", + "operator": "GREATER_AND_EQUAL", + "value": TODAY.isoformat(), + }, + {"field": "Event Start Date", "operator": "LESS_AND_EQUAL", "value": delta_days}, + {"field": "Event Archived", "operator": "EQUAL", "value": "No"}, + ] -# Remove duplicates in the list of teachers -TEACHERS = {item.get("Event Topic") for item in RESPONSE_EVENTS["searchResults"]} + return neon.postEventSearch(search_fields, NEON_EVENT_OUTPUT_FIELDS)['searchResults'] -# Import teacher contact info -CONTACT_INFO = "teachers.json" -with open(CONTACT_INFO, "r", encoding="utf-8") as f: - TEACHER_EMAILS = json.load(f) -# For use if script ran and failed to complete -ALREADY_SENT = [] +def load_teacher_contact_info(): + # Import teacher contact info + contact_info = "teachers.json" + with open(contact_info, "r", encoding="utf-8") as f: + TEACHER_EMAILS = json.load(f) -# Begin gathering data for emailing each teacher -# Send each teacher an email reminder about classes they are scheduled to teach -for teacher in TEACHERS: - if teacher is None: - logging.info("WARNING: No teacher assigned!") - TEACHER_EMAILS[None] = "classes@asmbly.org" - if teacher in ALREADY_SENT: - logging.info("Already emailed %s", teacher) - continue - # Find all events for each teacher - events = list( - filter( +# Find all events for each teacher +def get_events_by_teacher(all_events): + + # Remove duplicates in the list of teachers + teachers = {item.get("Event Topic") for item in all_events} + + events_by_teacher = {} + for teacher in teachers: + events = list(filter( lambda x, teach=teacher: x["Event Topic"] == teach, - RESPONSE_EVENTS["searchResults"], + all_events, + )) + sorted_events = sorted( + events, key=lambda x: datetime.datetime.fromisoformat(x["Event Start Date"]) ) + events_by_teacher[teacher] = sorted_events + return events_by_teacher + + +# Retrieve email and phone associated with this account ID +# Registrations with multiple attendees may have different emails listed in the UI +# but these aren't accessible from the API, so we will just use the info from the main account +def prettify_registrant(registrant): + acct_info = neon.getAccountIndividual(registrant['registrantAccountId']) + primary_contact = acct_info['individualAccount']['primaryContact'] + email = primary_contact['email1'] + + phone = [addr['phone1'] for addr in primary_contact['addresses'] if addr['phone1']][0] + if not phone: + phone = "N/A" + + results = '' + for attendee in registrant['tickets'][0]['attendees']: + results += f'\t{attendee["firstName"]} {attendee["lastName"]}: {email}, {phone}\n\t' + return results + + +def send_reminder_email(to_addr, cc_addr, subject, content): + mime_message = MIMEMultipart() + mime_message["To"] = to_addr + if cc_addr: + mime_message['CC'] = cc_addr + mime_message["Subject"] = subject + + mime_message.attach(MIMEText(content, "plain")) + sendMIMEmessage(mime_message) + + +# Build up formatted event info for email body +def format_event_info(event, pretty_registrants): + raw_time = event["Event Start Time"] + raw_date = event["Event Start Date"] + + datetime_date = datetime.datetime.strptime(raw_date, "%Y-%m-%d").date() + formatted_date = datetime.date.strftime(datetime_date, "%B %d") + start_time = datetime.datetime.strptime(raw_time, "%H:%M:%S").strftime( + "%I:%M %p" ) - logging.info("\n\n_____\n\nEmailing %s about %s event(s)...", teacher, len(events)) - sorted_events = sorted( - events, key=lambda x: datetime.datetime.fromisoformat(x["Event Start Date"]) + if datetime_date == TODAY: + date_string = f"TODAY - {formatted_date}" + elif datetime_date == TODAY + datetime.timedelta(days=1): + date_string = f"Tomorrow - {formatted_date}" + else: + date_string = formatted_date + info = f""" + {event["Event Name"]} + Date: {date_string} + Time: {start_time} + Number of registrants: {event["Registrants"]} + {pretty_registrants} + """ + return info + + +def compose_and_send_email(teacher, pretty_events): + if not teacher: + logging.info("WARNING: No teacher assigned!") + teacher_first_name = "N/A" + to_addr = 'classes@asmbly.org' + cc_addr = None + subject = f'Failed Class Reminder - {teacher}' + else: + teacher_first_name = teacher[: teacher.index(" ")] + to_addr = TEACHER_EMAILS[teacher] + cc_addr = 'classes@asmbly.org' + subject = 'Your upcoming classes at Asmbly' + + email_msg = EMAIL_TEMPLATE.format( + teacher_first_name=teacher_first_name, pretty_events=pretty_events ) + send_reminder_email(to_addr, cc_addr, subject, email_msg) - # Reformat event data so it looks nice in email - pretty_events = "" - for event in sorted_events: - event_id = event["Event ID"] - logging.info(event_id) - logging.info(event["Event Name"]) - - individual_event_reg = neon.getEventRegistrants(event_id) - # logging.info(individualEventReg) - - # Declare empty variable that may or may not get filled depending on whether there are registrations - # registrantDict will be a dictionary of dictionaries - ### outer key is the registration status - ### inner key is registrant account id - registrant_dict = { - "SUCCEEDED": [], - "DEFERRED": [], - "CANCELED": [], - "FAILED": [], - "REFUNDED": [], - } - # Registrant info formatted for email - pretty_registrants = "" - - # Get total number of attendees - This does not always coordinate with number of account IDs - attendee_count = neon.getEventRegistrantCount( - individual_event_reg["eventRegistrations"] - ) - logging.info(attendee_count) - - # Only add info if there are registrations - if attendee_count > 0: - - # Iterate over response to add registrant account IDs to dictionary organized by registration status - for registrant in individual_event_reg["eventRegistrations"]: - status = registrant["tickets"][0]["attendees"][0]["registrationStatus"] - acct_id = registrant["registrantAccountId"] - - # Retrieve email and phone associated with this account ID - # Registrations with multiple attendees may have different emails listed in the UI - # but these aren't accessible from the API, so we will just use the info from the main account - acct_info = neon.getAccountIndividual(acct_id) - email = acct_info["individualAccount"]["primaryContact"]["email1"] - phone = "" - try: - phone = acct_info["individualAccount"]["primaryContact"][ - "addresses" - ][0]["phone1"] - except KeyError: - phone = acct_info["individualAccount"]["primaryContact"][ - "addresses" - ][1]["phone1"] - - # Build a dictionary list of attendee names under this registration - attendee_list = {"name": [], "email": email, "phone": phone} - for attendee in registrant["tickets"][0]["attendees"]: - attendee = f'{attendee["firstName"]} {attendee["lastName"]}' - attendee_list["name"].append(attendee) - - # Build entry to add to registrantDict with all attendees associated with this acct Id - entry = {acct_id: attendee_list} - - # Add to registrantDict under the appropriate status - # First check that this registration status is in the dictionary - if status not in registrant_dict: - registrant_dict[status] = [] - registrant_dict[status].append(entry) - - for account in registrant_dict["SUCCEEDED"]: - for k, v in account.items(): - for it in v["name"]: - student = f"{it}: {v['email']}, {v['phone']}" - pretty_registrants += f"\t{student}\n\t" - else: - pretty_registrants += "\tNo attendees registered currently. Check Neon for updates as event approaches.\n\t" - - # Build up formatted event info for email body - raw_time = event["Event Start Time"] - raw_date = event["Event Start Date"] - datetime_date = datetime.datetime.strptime(raw_date, "%Y-%m-%d").date() - formatted_date = datetime.date.strftime(datetime_date, "%B %d") - start_time = datetime.datetime.strptime(raw_time, "%H:%M:%S").strftime( - "%I:%M %p" - ) - if datetime_date == TODAY: - date_string = f"TODAY - {formatted_date}" - elif datetime_date == TODAY + datetime.timedelta(days=1): - date_string = f"Tomorrow - {formatted_date}" - else: - date_string = formatted_date - info = f""" - {event["Event Name"]} - Date: {date_string} - Time: {start_time} - Number of registrants: {event["Registrants"]} - {pretty_registrants} - """ - pretty_events += info - - ##### GMAIL ##### - # Reformat date for email subject - formatted_today = TODAY.strftime("%B %d") - - teacher_first_name = teacher[: teacher.index(" ")] - - # Compose email - email_msg = f""" -Hi {teacher_first_name}, -This is an automated email to remind you of the upcoming classes you are scheduled to teach at Asmbly. -Thank you for sharing your knowledge with the community! +def get_successful_registrants(event_registration): + return [r for r in event_registration + if r['tickets'][0]['attendees'][0]['registrationStatus'] == 'SUCCEEDED'] -{pretty_events} -Please note these are the registrations as of the time of this email and may not reflect final registrations for your class. -You can see more details about these events and registrants in your Neon backend account. -The login URL is https://asmbly.z2systems.com/np/admin/content/contentList.do -Email classes@asmbly.org if you have any questions about the above schedule. +def prettify_registrants(registrants, attendee_count): + result = "" + if registrants and attendee_count > 0: + # Iterate over response to add registrant account IDs to + # dictionary organized by registration status + for registrant in registants: + result += prettify_registrant(registrant) + else: + result += "\tNo attendees registered currently. Check Neon for updates as event approaches.\n\t" + return result -\t* Note: Some registrants are purchased under a single account and thus end up with the same email and phone number. +def main(): + # Get events for the next 2 days + logging.info("\n\n----- Beginning class reminders for %s -----\n\n", TODAY.isoformat()) + load_teacher_contact_info() -Thanks again! -Asmbly AdminBot - """ + all_events = get_neon_events_in_next_2_days() - mime_message = MIMEMultipart() - try: - mime_message["To"] = TEACHER_EMAILS[teacher] - mime_message["CC"] = "classes@asmbly.org" - mime_message["Subject"] = "Your upcoming classes at Asmbly" - except KeyError: - mime_message["To"] = "classes@asmbly.org" - mime_message["Subject"] = f"Failed Class Reminder - {teacher}" + # Begin gathering data for emailing each teacher + # Send each teacher an email reminder about classes they are scheduled to teach + events_by_teacher = get_events_by_teacher(all_events) + for teacher, events in events_by_teacher.items(): + logging.info(f"\n\n_____\n\nEmailing {teacher} about {len(event)} event(s)...") - mime_message.attach(MIMEText(email_msg, "plain")) + # Reformat event data so it looks nice in email + pretty_events = "" + for event in events_by_teacher[teacher]: + event_id = event["Event ID"] + logging.info(event_id) + logging.info(event["Event Name"]) - sendMIMEmessage(mime_message) + individual_event_reg = neon.getEventRegistrants(event_id) + + # Get total number of attendees (doesn't always coincide with number of account IDs) + registration = individual_event_reg['eventRegistrations'] + attendee_count = neon.getEventRegistrantCount(registration) + logging.info(attendee_count) + + # Registrant info formatted for email + successful_registrants = get_successful_registrants(registration) + pretty_registrants = prettify_registrants(successful_registrants, attendee_count) + pretty_events += format_event_info(event, pretty_registrants) + + compose_and_send_email(teacher, pretty_events) + +if __name__ == '__main__': + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e591965 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +certifi==2025.6.15 +charset-normalizer==3.4.2 +idna==3.10 +iniconfig==2.1.0 +packaging==25.0 +pluggy==1.6.0 +Pygments==2.19.2 +pytest==8.4.1 +pytz==2025.2 +requests==2.32.4 +urllib3==2.5.0 diff --git a/test_DailyClassReminder.py b/test_DailyClassReminder.py new file mode 100644 index 0000000..e15e09e --- /dev/null +++ b/test_DailyClassReminder.py @@ -0,0 +1,36 @@ +from dailyClassReminder import * +from helpers import gmail +from email.mime.multipart import MIMEMultipart +import pytest +import pytest_mock +from helpers import neon + +class TestDailyClassReminder: + def test_compose_email(self): + send_reminder_email('test@test.com', 'classes@asmbly.org', 'Test Subject', EMAIL_TEMPLATE) + + def test_prettify_registrant(self, mocker): + test_registrant = { + 'registrantAccountId': 1, + 'tickets': [ + { + 'attendees': [{ + 'firstName': 'Cam', + 'lastName': 'Herringshaw', + }], + }, + ], + } + example_account_individual = { + 'individualAccount': { + 'primaryContact': { + 'email1': 'test@test.com', + 'addresses': [ + { 'phone1': '5551234567' }, + ] + } + } + } + + stub = mocker.patch('helpers.neon.getAccountIndividual', return_value=example_account_individual) + prettify_registrant(test_registrant)