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)