From 7e2449f4d5d1e1b1d40ebb03ad2caae0ece1205d Mon Sep 17 00:00:00 2001 From: Benjamin Ng <60500272+benymng@users.noreply.github.com> Date: Sun, 14 Apr 2024 18:47:06 -0400 Subject: [PATCH] Better Email service and regular reminder email functionality --- backend/app/__init__.py | 23 ++- backend/app/graphql/__init__.py | 55 +++++--- backend/app/graphql/services.py | 4 + .../services/implementations/auth_service.py | 52 ++----- .../services/implementations/email_service.py | 6 + .../implementations/meal_request_service.py | 65 ++++++++- .../implementations/mock_email_service.py | 3 + .../implementations/reminder_email_service.py | 118 ++++++++++++++++ .../interfaces/meal_request_service.py | 18 +++ .../interfaces/reminder_email_service.py | 11 ++ .../committed_to_meal_request.html | 11 ++ .../donor_one_day_after_meal.html | 5 + .../donor_one_day_to_meal.html | 13 ++ .../email_templates/meal_request_success.html | 11 ++ .../onboarding_request_approved.html | 5 + .../onboarding_request_rejected.html | 3 + .../requestor_one_day_after_meal.html | 3 + .../requestor_one_day_to_meal.html | 11 ++ backend/email_templates/reset_password.html | 5 + .../email_templates/verification_email.html | 9 ++ backend/requirements.txt | 3 + backend/tests/graphql/conftest.py | 11 ++ .../tests/graphql/test_all_user_mutations.py | 39 ++++++ backend/tests/graphql/test_meal_request.py | 46 +++++- .../tests/graphql/test_onboarding_request.py | 12 +- .../graphql/test_reminder_email_service.py | 132 ++++++++++++++++++ 26 files changed, 601 insertions(+), 73 deletions(-) create mode 100644 backend/app/services/implementations/reminder_email_service.py create mode 100644 backend/app/services/interfaces/reminder_email_service.py create mode 100644 backend/email_templates/committed_to_meal_request.html create mode 100644 backend/email_templates/donor_one_day_after_meal.html create mode 100644 backend/email_templates/donor_one_day_to_meal.html create mode 100644 backend/email_templates/meal_request_success.html create mode 100644 backend/email_templates/onboarding_request_approved.html create mode 100644 backend/email_templates/onboarding_request_rejected.html create mode 100644 backend/email_templates/requestor_one_day_after_meal.html create mode 100644 backend/email_templates/requestor_one_day_to_meal.html create mode 100644 backend/email_templates/reset_password.html create mode 100644 backend/email_templates/verification_email.html create mode 100644 backend/tests/graphql/test_reminder_email_service.py diff --git a/backend/app/__init__.py b/backend/app/__init__.py index f9f384cd..6ab23529 100644 --- a/backend/app/__init__.py +++ b/backend/app/__init__.py @@ -12,8 +12,10 @@ from .graphql import schema as graphql_schema +from flask_apscheduler import APScheduler + + def create_app(config_name): - # configure Flask logger dictConfig( { "version": 1, @@ -56,6 +58,7 @@ def create_app(config_name): re.compile(r"^https:\/\/uw-blueprint-starter-code--pr.*\.web\.app$"), ] app.config["CORS_SUPPORTS_CREDENTIALS"] = True + app.config["SCHEDULER_API_ENABLED"] = True CORS(app) firebase_admin.initialize_app( @@ -85,6 +88,22 @@ def create_app(config_name): from . import models, graphql models.init_app(app) - graphql.init_app(app) + services = graphql.init_app(app) + + scheduler = APScheduler() + scheduler.init_app(app) + + # checks every hour for meal requests that were either yesterday or today and sends an email to the donor and requestor + @scheduler.task( + "interval", id="daily_job", seconds=60 * 60 * 24, misfire_grace_time=900 + ) + def dailyJob(): + try: + with scheduler.app.app_context(): + services["reminder_email_service"].send_regularly_scheduled_emails() + except Exception as e: + print("Error in Scheduled Task!", e) + + scheduler.start() return app diff --git a/backend/app/graphql/__init__.py b/backend/app/graphql/__init__.py index b79bc272..9f784a59 100644 --- a/backend/app/graphql/__init__.py +++ b/backend/app/graphql/__init__.py @@ -1,5 +1,6 @@ import graphene import os +from app.services.implementations.reminder_email_service import ReminderEmailService from flask import current_app @@ -53,28 +54,33 @@ class RootMutation( ) +def init_email_service(app): + print("Initializing email service") + if app.config["TESTING"]: + print("Using mock email service in testings!") + services["email_service"] = MockEmailService( + logger=current_app.logger, + credentials={}, + sender_email=os.getenv("MAILER_USER"), + display_name="Feeding Canadian Kids", + ) + else: + services["email_service"] = EmailService( + logger=current_app.logger, + credentials={ + "refresh_token": os.getenv("MAILER_REFRESH_TOKEN"), + "token_uri": "https://oauth2.googleapis.com/token", + "client_id": os.getenv("MAILER_CLIENT_ID"), + "client_secret": os.getenv("MAILER_CLIENT_SECRET"), + }, + sender_email=os.getenv("MAILER_USER"), + display_name="Feeding Canadian Kids", + ) + + def init_app(app): with app.app_context(): - if app.config["TESTING"]: - print("Using mock email service in testings!") - services["email_service"] = MockEmailService( - logger=current_app.logger, - credentials={}, - sender_email=os.getenv("MAILER_USER"), - display_name="Feeding Canadian Kids", - ) - else: - services["email_service"] = EmailService( - logger=current_app.logger, - credentials={ - "refresh_token": os.getenv("MAILER_REFRESH_TOKEN"), - "token_uri": "https://oauth2.googleapis.com/token", - "client_id": os.getenv("MAILER_CLIENT_ID"), - "client_secret": os.getenv("MAILER_CLIENT_SECRET"), - }, - sender_email=os.getenv("MAILER_USER"), - display_name="Feeding Canadian Kids", - ) + init_email_service(app) services["onsite_contact_service"] = OnsiteContactService( logger=current_app.logger ) @@ -90,4 +96,11 @@ def init_app(app): services["onboarding_request_service"] = OnboardingRequestService( logger=current_app.logger, email_service=services["email_service"] ) - services["meal_request_service"] = MealRequestService(logger=current_app.logger) + services["meal_request_service"] = MealRequestService( + logger=current_app.logger, email_service=services["email_service"] + ) + services["reminder_email_service"] = ReminderEmailService( + logger=current_app.logger, email_service=services["email_service"] + ) + + return services diff --git a/backend/app/graphql/services.py b/backend/app/graphql/services.py index afe665db..63299b63 100644 --- a/backend/app/graphql/services.py +++ b/backend/app/graphql/services.py @@ -1,11 +1,13 @@ from typing import TypedDict + from ..services.interfaces.onsite_contact_service import IOnsiteContactService from ..services.interfaces.user_service import IUserService from ..services.interfaces.auth_service import IAuthService from ..services.interfaces.email_service import IEmailService from ..services.interfaces.onboarding_request_service import IOnboardingRequestService from ..services.interfaces.meal_request_service import IMealRequestService +from ..services.interfaces.reminder_email_service import IReminderEmailService """ Global services for GraphQL that will be initialized with @@ -20,6 +22,7 @@ class ServicesObject(TypedDict): onboarding_request_service: IOnboardingRequestService meal_request_service: IMealRequestService onsite_contact_service: IOnsiteContactService + reminder_email_service: IReminderEmailService services: ServicesObject = { @@ -29,4 +32,5 @@ class ServicesObject(TypedDict): "onboarding_request_service": None, "meal_request_service": None, "onsite_contact_service": None, + "reminder_email_service": None, } # type: ignore diff --git a/backend/app/services/implementations/auth_service.py b/backend/app/services/implementations/auth_service.py index 48f0dcb4..530b664d 100644 --- a/backend/app/services/implementations/auth_service.py +++ b/backend/app/services/implementations/auth_service.py @@ -1,3 +1,4 @@ +from app.services.implementations.email_service import EmailService import firebase_admin.auth from ..interfaces.auth_service import IAuthService @@ -115,18 +116,9 @@ def forgot_password(self, email): set_password_link = "{url}/{ObjectID}/reset-password".format( url=url, ObjectID=user.id ) - - email_body = """ - Hello, -

- We have received your reset password request. - Please reset your password using the following link. -

- Reset Password - """.format( - reset_link=set_password_link - ) - + email_body = EmailService.read_email_template( + "email_templates/reset_password.html" + ).format(reset_link=set_password_link) self.email_service.send_email(email, "FCK Reset Password Link", email_body) except Exception as e: @@ -175,17 +167,9 @@ def send_email_verification_link(self, email): verification_link = firebase_admin.auth.generate_email_verification_link( email ) - email_body = """ - Hello, -

- Please click the following link to verify your email and - activate your account. - This link is only valid for 1 hour. -

- Verify email - """.format( - verification_link=verification_link - ) + email_body = EmailService.read_email_template( + "email_templates/verification_email.html" + ).format(verification_link=verification_link) self.email_service.send_email(email, "Verify your email", email_body) except Exception as e: self.logger.error( @@ -209,16 +193,9 @@ def send_onboarding_request_approve_email(self, objectID, email): url=url, ObjectID=objectID ) - email_body = """ - Hello, -

- We have received your onboarding request and it has been approved. - Please set your password using the following link. -

- Reset Password - """.format( - reset_link=set_password_link - ) + email_body = EmailService.read_email_template( + "email_templates/onboarding_request_approved.html" + ).format(set_password_link=set_password_link) self.email_service.send_email( email, "Onboarding request approved. Set Password", email_body @@ -240,12 +217,9 @@ def send_onboarding_request_rejected_email(self, email): raise Exception(error_message) try: - email_body = """ - Hello, -

- This is a notification that your onboarding request has been rejected. -

- """ + email_body = EmailService.EmailService.read_email_template( + "email_templates/onboarding_request_rejected.html" + ) self.email_service.send_email( email, "Onboarding request rejected", email_body ) diff --git a/backend/app/services/implementations/email_service.py b/backend/app/services/implementations/email_service.py index 4881d091..ca8822fc 100644 --- a/backend/app/services/implementations/email_service.py +++ b/backend/app/services/implementations/email_service.py @@ -10,6 +10,11 @@ class EmailService(IEmailService): EmailService implementation for handling email related functionality """ + @staticmethod + def read_email_template(file_path: str): + with open(file_path, "r") as file: + return file.read() + def __init__(self, logger, credentials, sender_email, display_name=None): """ Create an instance of EmailService @@ -24,6 +29,7 @@ def __init__(self, logger, credentials, sender_email, display_name=None): :param display_name: the sender's display name, defaults to None :type display_name: str, optional """ + self.logger = logger creds = Credentials(None, **credentials) self.service = build("gmail", "v1", credentials=creds) diff --git a/backend/app/services/implementations/meal_request_service.py b/backend/app/services/implementations/meal_request_service.py index 00478308..52d84e18 100644 --- a/backend/app/services/implementations/meal_request_service.py +++ b/backend/app/services/implementations/meal_request_service.py @@ -1,4 +1,6 @@ from typing import List +from app.services.interfaces.email_service import IEmailService +from app.services.implementations.email_service import EmailService from ...models.meal_request import MealInfo, MealRequest from ..interfaces.meal_request_service import IMealRequestService from datetime import datetime @@ -11,8 +13,9 @@ class MealRequestService(IMealRequestService): - def __init__(self, logger): + def __init__(self, logger, email_service: IEmailService): self.logger = logger + self.email_service = email_service def create_meal_request( self, @@ -125,6 +128,13 @@ def commit_to_meal_request( raise Exception( f'meal request "{meal_request_id}" is not open for commitment' ) + meal_requestor_id = meal_request.requestor.id + meal_requestor = User.objects(id=meal_requestor_id).first() + + self.send_donor_commit_email(meal_request, donor.info.email) + self.send_requestor_commit_email( + meal_request, meal_requestor.info.email + ) meal_request.donation_info = DonationInfo( donor=donor, @@ -324,3 +334,56 @@ def get_meal_requests_by_ids(self, ids: str) -> List[MealRequestDTO]: ] return meal_request_dtos + + def send_donor_commit_email(self, meal_request, email): + if not self.email_service: + error_message = """ + Attempted to call committed_to_meal_request but this + instance of AuthService does not have an EmailService instance + """ + self.logger.error(error_message) + raise Exception(error_message) + + try: + email_body = EmailService.read_email_template( + "email_templates/committed_to_meal_request.html" + ).format( + dropoff_location=meal_request.drop_off_location, + dropoff_time=meal_request.drop_off_datetime, + num_meals=meal_request.meal_info.portions, + ) + self.email_service.send_email( + email, "Thank you for committing to a meal request!", email_body + ) + + except Exception as e: + self.logger.error( + f"Failed to send committed to meal request email for user {meal_request.id if meal_request else ''} {email}" + ) + raise e + + def send_requestor_commit_email(self, meal_request, email): + if not self.email_service: + error_message = """ + Attempted to call meal_request_success but this + instance of AuthService does not have an EmailService instance + """ + self.logger.error(error_message) + raise Exception(error_message) + + try: + email_body = EmailService.read_email_template( + "email_templates/meal_request_success.html" + ).format( + dropoff_location=meal_request.drop_off_location, + dropoff_time=meal_request.drop_off_datetime, + num_meals=meal_request.meal_info.portions, + ) + self.email_service.send_email( + email, "Your meal request has been fulfilled!", email_body + ) + except Exception as e: + self.logger.error( + f"Failed to send committed to meal request email for user {meal_request.id if meal_request else ''} {email}" + ) + raise e diff --git a/backend/app/services/implementations/mock_email_service.py b/backend/app/services/implementations/mock_email_service.py index 1fe006e2..950026a2 100644 --- a/backend/app/services/implementations/mock_email_service.py +++ b/backend/app/services/implementations/mock_email_service.py @@ -38,6 +38,9 @@ def send_email(self, to, subject, body): "subject": subject, "body": body, } + print( + f"MockEmailService: Sent email to {message['to']} from {message['from_']} with subject '{message['subject']}'" + ) self.emails_sent.append(message) def get_last_email_sent(self): diff --git a/backend/app/services/implementations/reminder_email_service.py b/backend/app/services/implementations/reminder_email_service.py new file mode 100644 index 00000000..b51b69e3 --- /dev/null +++ b/backend/app/services/implementations/reminder_email_service.py @@ -0,0 +1,118 @@ +from app.services.interfaces.reminder_email_service import IReminderEmailService +from app.services.interfaces.email_service import IEmailService +from ...models.user import User +from ...models.meal_request import MealRequest +from datetime import datetime, timedelta +from app.services.implementations.email_service import EmailService + + +class ReminderEmailService(IReminderEmailService): + def __init__(self, logger, email_service: IEmailService): + self.logger = logger + self.email_service = email_service + + def get_meal_requests_one_day_away(self): + """ + Helper function to get meal requests that are one day away. + Returns: + list of meal requests + """ + try: + tomorrow_time = datetime.now() + timedelta(days=1) + meal_requests = MealRequest.objects( + drop_off_datetime__gt=tomorrow_time, + drop_off_datetime__lt=tomorrow_time + timedelta(hours=1), + ) + except Exception as e: + self.logger.error("Failed to get meal requests one day away") + raise e + + return meal_requests + + def get_meal_requests_one_day_ago(self): + """ + Helper function to get meal requests that are one day ago. + Returns: + list of meal requests + """ + try: + yesterday_time = datetime.now() - timedelta(days=1) + meal_requests = MealRequest.objects( + drop_off_datetime__gt=yesterday_time - timedelta(hours=1), + drop_off_datetime__lt=yesterday_time, + ) + except Exception as e: + self.logger.error("Failed to get meal requests one day ago") + raise e + + return meal_requests + + def send_email(self, email, meal_request, template_file_path, subject_line): + try: + email_body = EmailService.read_email_template(template_file_path).format( + dropoff_location=meal_request.drop_off_location, + dropoff_time=meal_request.drop_off_datetime, + num_meals=meal_request.meal_info.portions, + ) + self.email_service.send_email(email, subject_line, email_body) + except Exception as e: + self.logger.error( + f"Failed to send reminder email for meal request one meal away for user {meal_request.id if meal_request else ''} {email}" + ) + raise e + self.logger.info(f"Sent reminder email for meal request to {email}") + + def send_time_delayed_emails( + self, meal_requests, template_file_paths, subject_lines + ): + """ + Helper function to send emails to donors and requestors. + Args: + meal_requests: list of meal requests + """ + for meal_request in meal_requests: + meal_requestor_email = meal_request.requestor.info.email + self.send_email( + meal_requestor_email, + meal_request, + template_file_paths["requestor"], + subject_lines["requestor"], + ) + if hasattr(meal_request, "donation_info") and meal_request.donation_info: + donor_id = meal_request.donation_info.donor.id + donor = User.objects.get(id=donor_id) + donor_email = donor.info.email + self.send_email( + donor_email, + meal_request, + template_file_paths["donor"], + subject_lines["donor"], + ) + + def send_regularly_scheduled_emails(self): + """Sends scheduled emails to donors and requestors.""" + # Send emails for meal requests that are one day away + self.send_time_delayed_emails( + self.get_meal_requests_one_day_away(), + { + "donor": "email_templates/donor_one_day_to_meal.html", + "requestor": "email_templates/requestor_one_day_to_meal.html", + }, + { + "donor": "Your meal donation is only one day away!", + "requestor": "Your meal request is only one day away!", + }, + ) + + # Send emails for meal requests that are one day ago + self.send_time_delayed_emails( + self.get_meal_requests_one_day_ago(), + { + "donor": "email_templates/donor_one_day_after_meal.html", + "requestor": "email_templates/requestor_one_day_after_meal.html", + }, + { + "donor": "Thank you again for your meal donation!", + "requestor": "We hope you enjoyed your meal!", + }, + ) diff --git a/backend/app/services/interfaces/meal_request_service.py b/backend/app/services/interfaces/meal_request_service.py index 901d7a75..31e05fe0 100644 --- a/backend/app/services/interfaces/meal_request_service.py +++ b/backend/app/services/interfaces/meal_request_service.py @@ -134,3 +134,21 @@ def get_meal_requests_by_donor_id( :raises Exception: if MealRequest could not be retrieved """ pass + + @abstractmethod + def send_donor_commit_email(self, meal_request_id, email): + """ + Sends an email to the user with the given email, notifying them that they have committed to a meal request + :param meal_request_id: the id of the meal request + :type email: str + :raises Exception: if unable to send email + """ + + @abstractmethod + def send_requestor_commit_email(self, meal_request_id, email): + """ + Sends an email to the user with the given email, notifying them that their meal request was successful + :param meal_request_id: the id of the meal request + :type email: str + :raises Exception: if unable to send email + """ diff --git a/backend/app/services/interfaces/reminder_email_service.py b/backend/app/services/interfaces/reminder_email_service.py new file mode 100644 index 00000000..9dd88bf7 --- /dev/null +++ b/backend/app/services/interfaces/reminder_email_service.py @@ -0,0 +1,11 @@ +from abc import ABC + +from app.services.interfaces.email_service import IEmailService + + +class IReminderEmailService(ABC): + def __init__(self, logger, email_service: IEmailService): + pass + + def send_regularly_scheduled_emails(self): + pass diff --git a/backend/email_templates/committed_to_meal_request.html b/backend/email_templates/committed_to_meal_request.html new file mode 100644 index 00000000..582d22ce --- /dev/null +++ b/backend/email_templates/committed_to_meal_request.html @@ -0,0 +1,11 @@ +Hello,

+Thank you for committing to a meal request! +

+The information of the meal request is as follows: +

+ +Dropoff Location: {dropoff_location} +
+Dropoff Time: {dropoff_time} +
+Number of Meals: {num_meals} diff --git a/backend/email_templates/donor_one_day_after_meal.html b/backend/email_templates/donor_one_day_after_meal.html new file mode 100644 index 00000000..cd2404da --- /dev/null +++ b/backend/email_templates/donor_one_day_after_meal.html @@ -0,0 +1,5 @@ +Hello,

+Thank you again for your donation!

+Your meal request was supplied yesterday. +

+Thank you again for the donation!

diff --git a/backend/email_templates/donor_one_day_to_meal.html b/backend/email_templates/donor_one_day_to_meal.html new file mode 100644 index 00000000..14f3eeec --- /dev/null +++ b/backend/email_templates/donor_one_day_to_meal.html @@ -0,0 +1,13 @@ +Hello,

+The meal request you donated is scheduled for tomorrow!

+

+The information of the meal request is as follows: +

+ +Dropoff Location: {dropoff_location} +
+Dropoff Time: {dropoff_time} +
+Number of Meals: {num_meals} +

+Thank you for the donation!

diff --git a/backend/email_templates/meal_request_success.html b/backend/email_templates/meal_request_success.html new file mode 100644 index 00000000..8ce58a63 --- /dev/null +++ b/backend/email_templates/meal_request_success.html @@ -0,0 +1,11 @@ +Hello,

+Your meal request has been fulfilled! +

+The information of the meal request is as follows: +

+ +Dropoff Location: {dropoff_location} +
+Dropoff Time: {dropoff_time} +
+Number of Meals: {num_meals} diff --git a/backend/email_templates/onboarding_request_approved.html b/backend/email_templates/onboarding_request_approved.html new file mode 100644 index 00000000..80f6d511 --- /dev/null +++ b/backend/email_templates/onboarding_request_approved.html @@ -0,0 +1,5 @@ +Hello,

+We have received your onboarding request and it has been approved. Please set +your password using the following link. +

+Reset Password diff --git a/backend/email_templates/onboarding_request_rejected.html b/backend/email_templates/onboarding_request_rejected.html new file mode 100644 index 00000000..95552cec --- /dev/null +++ b/backend/email_templates/onboarding_request_rejected.html @@ -0,0 +1,3 @@ +Hello,

+This is a notification that your onboarding request has been rejected. +

diff --git a/backend/email_templates/requestor_one_day_after_meal.html b/backend/email_templates/requestor_one_day_after_meal.html new file mode 100644 index 00000000..12bc7797 --- /dev/null +++ b/backend/email_templates/requestor_one_day_after_meal.html @@ -0,0 +1,3 @@ +Hello,

+We hope you enjoyed your requested meal!

+Please let us know if you have any feedback or suggestions for us.

diff --git a/backend/email_templates/requestor_one_day_to_meal.html b/backend/email_templates/requestor_one_day_to_meal.html new file mode 100644 index 00000000..54c154cd --- /dev/null +++ b/backend/email_templates/requestor_one_day_to_meal.html @@ -0,0 +1,11 @@ +Hello,

+Your meal request is scheduled for tomorrow!

+

+The information of the meal request is as follows: +

+ +Dropoff Location: {dropoff_location} +
+Dropoff Time: {dropoff_time} +
+Number of Meals: {num_meals} diff --git a/backend/email_templates/reset_password.html b/backend/email_templates/reset_password.html new file mode 100644 index 00000000..8f99720d --- /dev/null +++ b/backend/email_templates/reset_password.html @@ -0,0 +1,5 @@ +Hello,

+We have received your reset password request. Please reset your password using +the following link. +

+Reset Password diff --git a/backend/email_templates/verification_email.html b/backend/email_templates/verification_email.html new file mode 100644 index 00000000..a1ef5317 --- /dev/null +++ b/backend/email_templates/verification_email.html @@ -0,0 +1,9 @@ +

Hello,

+

+

+ Please click the following link to verify your email and activate your + account. + This link is only valid for 1 hour. +

+ Verify email +

diff --git a/backend/requirements.txt b/backend/requirements.txt index 084834d3..c96930f1 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -16,6 +16,8 @@ Flask==1.1.2 Flask-Cors==3.0.10 Flask-Migrate==3.0.1 Flask-SQLAlchemy==2.5.1 +APScheduler==3.10.4 +Flask-APScheduler<=1.13.1 google-api-core==1.26.3 google-api-python-client==2.2.0 google-auth==1.29.0 @@ -79,3 +81,4 @@ uritemplate==3.0.1 urllib3==1.26.4 Werkzeug==1.0.1 zipp==3.4.1 +tzlocal==5.2 \ No newline at end of file diff --git a/backend/tests/graphql/conftest.py b/backend/tests/graphql/conftest.py index 1f104c70..e2340711 100644 --- a/backend/tests/graphql/conftest.py +++ b/backend/tests/graphql/conftest.py @@ -5,6 +5,8 @@ from app.models.onboarding_request import OnboardingRequest from app.models.meal_request import MealRequest from app.models.onsite_contact import OnsiteContact +from app.services.implementations.mock_email_service import MockEmailService +from app.services.implementations.reminder_email_service import ReminderEmailService from tests.graphql.mock_test_data import ( MOCK_INFO1_SNAKE, MOCK_INFO2_SNAKE, @@ -16,6 +18,7 @@ MOCK_USER3_SNAKE, MOCK_MEALREQUEST1_SNAKE, ) +from flask import current_app @pytest.fixture(scope="session", autouse=True) @@ -106,3 +109,11 @@ def onsite_contact_setup(user_setup): yield asp, donor, [asp_onsite_contact, asp_onsite_contact2], donor_onsite_contact asp_onsite_contact.delete() donor_onsite_contact.delete() + + +@pytest.fixture(scope="function", autouse=True) +def reminder_email_setup(): + mock_email_service = MockEmailService.instance + logger = current_app.logger + reminder_email_service = ReminderEmailService(logger, mock_email_service) # type: ignore + yield reminder_email_service diff --git a/backend/tests/graphql/test_all_user_mutations.py b/backend/tests/graphql/test_all_user_mutations.py index dd7db0e4..20f6d4b3 100644 --- a/backend/tests/graphql/test_all_user_mutations.py +++ b/backend/tests/graphql/test_all_user_mutations.py @@ -3,6 +3,7 @@ MOCK_INFO1_CAMEL, MOCK_INFO3_CAMEL, ) +from app.services.implementations.mock_email_service import MockEmailService from copy import deepcopy @@ -403,3 +404,41 @@ def test_deactivate_user_by_id(user_setup, mocker): }} }}""" ) + + +class FirebaseReturnValueMock: + def __init__(self, auth_id): + self.uid = auth_id + + +def test_reset_password(user_setup, mocker): + user_1, user_2, user_3 = user_setup + firebase_return_mock = FirebaseReturnValueMock(user_2.auth_id) + + mocker.patch( + "firebase_admin.auth.get_user_by_email", + return_value=firebase_return_mock, + ) + + reset_password = graphql_schema.execute( + f"""mutation ForgotPassword {{ + forgotPassword ( + email: "{str(user_2.info.email)}" + ) {{ + success + }} + }}""" + ) + assert reset_password.errors is None + + email_service = MockEmailService.instance + assert email_service is not None + last_email = email_service.get_last_email_sent() + assert last_email is not None + assert last_email["subject"] == "FCK Reset Password Link" + assert last_email["to"] == user_2.info.email + assert "We have received your reset password request." in last_email["body"] + assert ( + last_email["from_"] + == "Feeding Canadian Kids " + ) diff --git a/backend/tests/graphql/test_meal_request.py b/backend/tests/graphql/test_meal_request.py index c3c8ab0b..644cd64b 100644 --- a/backend/tests/graphql/test_meal_request.py +++ b/backend/tests/graphql/test_meal_request.py @@ -1,6 +1,7 @@ from app.graphql import schema as graphql_schema from app.models.meal_request import MealRequest, MealStatus from app.models.user_info import UserInfoRole +from app.services.implementations.mock_email_service import MockEmailService """ Tests for MealRequestchema and query/mutation logic @@ -227,6 +228,49 @@ def test_commit_to_meal_request(meal_request_setup): assert meal_request_in_db["donation_info"]["meal_description"] == "Pizza" assert meal_request_in_db["donation_info"]["additional_info"] == "No nuts" + email_service = MockEmailService.instance + assert email_service is not None + all_emails_sent = email_service.get_all_emails_sent() + donor_email = all_emails_sent[-2] + requestor_email = all_emails_sent[-1] + + assert donor_email is not None + assert requestor_email is not None + + assert donor_email["subject"] == "Thank you for committing to a meal request!" + assert donor_email["to"] == donor.info.email + assert "Thank you for committing to a meal request!" in donor_email["body"] + assert ( + f"Number of Meals: {str(meal_request.meal_info.portions)}" + in donor_email["body"] + ) + assert f"Dropoff Location: {meal_request.drop_off_location}" in donor_email["body"] + assert ( + f"Dropoff Time: {meal_request.drop_off_datetime.replace('T', ' ')}" + in donor_email["body"] + ) + + assert requestor_email["subject"] == "Your meal request has been fulfilled!" + assert requestor_email["to"] == meal_request.requestor.info.email + assert "Your meal request has been fulfilled!" in requestor_email["body"] + assert ( + f"Number of Meals: {str(meal_request.meal_info.portions)}" + in donor_email["body"] + ) + assert f"Dropoff Location: {meal_request.drop_off_location}" in donor_email["body"] + assert ( + f"Dropoff Time: {meal_request.drop_off_datetime.replace('T', ' ')}" + in donor_email["body"] + ) + assert ( + donor_email["from_"] + == "Feeding Canadian Kids " + ) + assert ( + requestor_email["from_"] + == "Feeding Canadian Kids " + ) + # Only user's with role "Donor" should be able to commit # to meal requests, otherwise an error is thrown @@ -466,7 +510,7 @@ def test_get_meal_request_by_requestor_id(meal_request_setup): def test_cancel_donation_as_admin(meal_request_setup, user_setup): _, _, meal_request = meal_request_setup - _, _, admin = user_setup + requestor, donor, admin = user_setup test_commit_to_meal_request(meal_request_setup) diff --git a/backend/tests/graphql/test_onboarding_request.py b/backend/tests/graphql/test_onboarding_request.py index 2547d7ad..dcd58a0f 100644 --- a/backend/tests/graphql/test_onboarding_request.py +++ b/backend/tests/graphql/test_onboarding_request.py @@ -427,15 +427,9 @@ def test_approve_onboarding_request(): assert last_email is not None assert last_email["subject"] == "Onboarding request approved. Set Password" assert last_email["to"] == "test@test.com" - # TODO: BEN Please change this to load from your new email management framework. - assert last_email["body"].startswith( - """ - Hello, -

- We have received your onboarding request and it has been approved. - Please set your password using the following link. -

- """ + assert ( + "We have received your onboarding request and it has been approved" + in last_email["body"] ) assert ( last_email["from_"] diff --git a/backend/tests/graphql/test_reminder_email_service.py b/backend/tests/graphql/test_reminder_email_service.py new file mode 100644 index 00000000..bda07fa7 --- /dev/null +++ b/backend/tests/graphql/test_reminder_email_service.py @@ -0,0 +1,132 @@ +from app.graphql import schema as graphql_schema +from app.services.implementations.mock_email_service import MockEmailService +from app.services.interfaces.reminder_email_service import IReminderEmailService +from datetime import datetime, timedelta + + +def test_meal_yesterday(reminder_email_setup, user_setup, meal_request_setup): + _, _, meal_request = meal_request_setup + users = user_setup + reminder_email_service: IReminderEmailService = reminder_email_setup # type: ignore + + dropoff_time = datetime.now() - timedelta(days=1) - timedelta(minutes=20) + meal_request.drop_off_datetime = dropoff_time + meal_request.save() + + requestor_email, donor_email = get_emails_and_check_destinations( + reminder_email_service, meal_request, users + ) + + assert "We hope you enjoyed your requested meal!" in requestor_email["body"] + assert requestor_email["subject"] == "We hope you enjoyed your meal!" + + assert "Your meal request was supplied yesterday." in donor_email["body"] + assert donor_email["subject"] == "Thank you again for your meal donation!" + + +def test_meal_tomorrow(reminder_email_setup, user_setup, meal_request_setup): + _, _, meal_request = meal_request_setup + users = user_setup + reminder_email_service: IReminderEmailService = reminder_email_setup # type: ignore + + dropoff_time = datetime.now() + timedelta(days=1) + timedelta(minutes=20) + meal_request.drop_off_datetime = dropoff_time + meal_request.save() + + requestor_email, donor_email = get_emails_and_check_destinations( + reminder_email_service, meal_request, users + ) + + assert "Your meal request is scheduled for tomorrow!" in requestor_email["body"] + assert ( + f"Dropoff Location: {meal_request.drop_off_location}" in requestor_email["body"] + ) + assert str(meal_request.drop_off_location) in requestor_email["body"] + assert requestor_email["subject"] == "Your meal request is only one day away!" + + assert ( + "The meal request you donated is scheduled for tomorrow!" in donor_email["body"] + ) + assert f"Dropoff Location: {meal_request.drop_off_location}" in donor_email["body"] + assert str(meal_request.drop_off_location) in donor_email["body"] + assert donor_email["subject"] == "Your meal donation is only one day away!" + + +def get_emails_and_check_destinations(reminder_email_service, meal_request, users): + requestor = users[0] + donor = users[1] + meal_request.requestor = requestor + commit_to_meal_request(donor, meal_request) + + email_service: MockEmailService = MockEmailService.instance # type: ignore + email_service.clear_emails_sent() + + reminder_email_service.send_regularly_scheduled_emails() + + # # Check that the correct emails were sent + all_emails_sent = email_service.get_all_emails_sent() + if all_emails_sent[0]["to"] == requestor.info.email: + requestor_email = all_emails_sent[0] + donor_email = all_emails_sent[1] + else: + requestor_email = all_emails_sent[1] + donor_email = all_emails_sent[0] + + assert requestor_email["to"] == requestor.info.email + assert donor_email["to"] == donor.info.email + assert ( + requestor_email["from_"] + == "Feeding Canadian Kids " + ) + assert ( + donor_email["from_"] + == "Feeding Canadian Kids " + ) + + return requestor_email, donor_email + + +def commit_to_meal_request(donor, meal_request): + commit_to_meal_request_mutation = f""" + mutation testCommitToMealRequest {{ + commitToMealRequest( + requestor: "{str(donor.id)}", + mealRequestIds: ["{str(meal_request.id)}"], + mealDescription: "Pizza", + additionalInfo: "No nuts" + ) + {{ + mealRequests {{ + id + requestor {{ + id + }} + status + dropOffDatetime + dropOffLocation + mealInfo {{ + portions + dietaryRestrictions + }} + onsiteStaff {{ + name + email + phone + }} + dateCreated + dateUpdated + deliveryInstructions + donationInfo {{ + donor {{ + id + }} + commitmentDate + mealDescription + additionalInfo + }} + }} + }} + }} + """ + result = graphql_schema.execute(commit_to_meal_request_mutation) + assert not result.errors