diff --git a/apps/pre-award/copilot/environments/addons/application-deadline-reminder.yml b/apps/pre-award/copilot/environments/addons/application-deadline-reminder.yml new file mode 100644 index 00000000..b242524a --- /dev/null +++ b/apps/pre-award/copilot/environments/addons/application-deadline-reminder.yml @@ -0,0 +1,113 @@ +Parameters: + App: + Type: String + Description: Your application's name. + Env: + Type: String + Description: The environment name your service, job, or workflow is being deployed to. + +Resources: + ApplicationDeadlineReminderRole: + Type: AWS::IAM::Role + Properties: + Policies: + - PolicyName: !Sub ApplicationDeadlineReminderPolicy${Env} + PolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - 'logs:CreateLogGroup' + - 'logs:CreateLogStream' + - 'logs:PutLogEvents' + Resource: + - 'arn:aws:logs:*:*:*' + Effect: Allow + - Action: + - 'ec2:DescribeNetworkInterfaces' + - 'ec2:CreateNetworkInterface' + - 'ec2:DeleteNetworkInterface' + - 'ec2:DescribeInstances' + - 'ec2:AttachNetworkInterface' + Resource: + - '*' + Effect: Allow + AssumeRolePolicyDocument: + Version: 2012-10-17 + Statement: + - Action: + - 'sts:AssumeRole' + Effect: Allow + Principal: + Service: + - lambda.amazonaws.com + - edgelambda.amazonaws.com + + ApplicationDeadlineReminderLambdaFunction: + Type: AWS::Lambda::Function + Properties: + Code: lambdas/application-deadline-reminder/ + Handler: lambda_function.lambda_handler + Timeout: 900 + MemorySize: 512 + Role: !GetAtt ApplicationDeadlineReminderRole.Arn + Runtime: python3.11 + Environment: + Variables: + ACCOUNTS_ENDPOINT: /accounts + ACCOUNT_STORE_API_HOST: http://fsd-account-store.${Env}.pre-award.local:8080 + APPLICATIONS_ENDPOINT: /applications + APPLICATION_REMINDER_STATUS: /funds/{round_id}/application_reminder_status?status=true + APPLICATION_STORE_API_HOST: http://fsd-application-store.${Env}.pre-award.local:8080 + FUND_ENDPOINT: /funds/{fund_id} + FUNDS_ENDPOINT: /funds + FUND_ROUNDS_ENDPOINT: /funds/{fund_id}/rounds + FUND_STORE_API_HOST: !Sub http://fsd-fund-store.${Env}.pre-award.local:8080 + NOTIFICATION_SERVICE_API_HOST: http://fsd-notification.${Env}.pre-award.local:8080 + NOTIFY_TEMPLATE_APPLICATION_DEADLINE_REMINDER: APPLICATION_DEADLINE_REMINDER + SEND_ENDPOINT: /send + VpcConfig: + SecurityGroupIds: + - Fn::ImportValue: "fsdfundstoreclusterSecurityGroup" + - Fn::ImportValue: !Sub ${App}-${Env}-InternalLoadBalancerSecurityGroup + - Fn::ImportValue: !Sub ${App}-${Env}-EnvironmentSecurityGroup + SubnetIds: + !Split + - ',' + - Fn::ImportValue: !Sub ${App}-${Env}-PrivateSubnets + + ApplicationDeadlineReminderLambdaVersion: + Type: AWS::Lambda::Version + Properties: + Description: Creation a version of the Application Deadline Reminder Lambda + FunctionName: !Ref ApplicationDeadlineReminderLambdaFunction + + ApplicationDeadlineReminderScheduledRule: + Type: AWS::Events::Rule + Properties: + Description: "Application Deadline Reminder Scheduled Rule" + ScheduleExpression: "cron(30 09 * * ? *)" + State: "ENABLED" + Targets: + - + Arn: !GetAtt ApplicationDeadlineReminderLambdaFunction.Arn + Id: "TargetApplicationDeadlineReminderFunctionV1" + + ApplicationDeadlineReminderPermissionForEventsToInvokeLambda: + Type: AWS::Lambda::Permission + Properties: + FunctionName: !Ref ApplicationDeadlineReminderLambdaFunction + Action: "lambda:InvokeFunction" + Principal: "events.amazonaws.com" + SourceArn: !GetAtt ApplicationDeadlineReminderScheduledRule.Arn + +Outputs: + ApplicationDeadlineReminderLambdaArn: + Description: The ARN of the Application Deadline Reminder Lambda + Value: !GetAtt ApplicationDeadlineReminderLambdaFunction.Arn + Export: + Name: !Join [ ':', [ !Ref 'AWS::StackName', 'ApplicationDeadlineReminderLambdaArn']] + ApplicationDeadlineReminderLambdaVersion: + Description: The version of the Application Deadline Reminder Lambda + Value: !GetAtt ApplicationDeadlineReminderLambdaVersion.Version + Export: + Name: !Join [ ':', [ !Ref 'AWS::StackName', 'ApplicationDeadlineReminderLambdaVersion']] diff --git a/apps/pre-award/lambdas/application-deadline-reminder/application_reminder.py b/apps/pre-award/lambdas/application-deadline-reminder/application_reminder.py new file mode 100644 index 00000000..664145d8 --- /dev/null +++ b/apps/pre-award/lambdas/application-deadline-reminder/application_reminder.py @@ -0,0 +1,167 @@ +from datetime import datetime + +from dateutil import tz + +from config import Config +from data import get_data +from data import get_account +from data import post_notification + +import requests +import json + +import logging + +# Logging to output to CloudWatch Logs +logging.getLogger('lambda_runtime').setLevel(logging.INFO) +logging.getLogger().setLevel(logging.DEBUG) + + +def application_deadline_reminder(): + + logging.info("Application deadline reminder task is now running!") + uk_timezone = tz.gettz("Europe/London") + current_datetime = datetime.now(uk_timezone).replace(tzinfo=None) + + funds = get_data(Config.FUND_STORE_API_HOST + Config.FUNDS_ENDPOINT) + + for fund in funds: + fund_id = fund.get("id") + fund_info = get_data( + Config.FUND_STORE_API_HOST + + Config.FUND_ENDPOINT.format(fund_id=fund_id) + ) + fund_name = fund_info.get("name") + round_info = get_data( + Config.FUND_STORE_API_HOST + + Config.FUND_ROUNDS_ENDPOINT.format(fund_id=fund_id) + ) + + for round in round_info: + round_deadline_str = round.get("deadline") + reminder_date_str = round.get("reminder_date") + round_id = round.get("id") + round_name = round.get("title") + contact_email = round.get("contact_email") + + if not reminder_date_str: + logging.info( + f"No reminder is set for the round {fund_name} {round_name}" + ) + continue + + application_reminder_sent = round.get("application_reminder_sent") + + round_deadline = datetime.strptime( + round_deadline_str, "%Y-%m-%dT%H:%M:%S" + ) + + reminder_date = datetime.strptime( + reminder_date_str, "%Y-%m-%dT%H:%M:%S" + ) + + if (not application_reminder_sent + and reminder_date < current_datetime < round_deadline + ): + status = { + "status_only": ["IN_PROGRESS", "NOT_STARTED", "COMPLETED"], + "fund_id": fund_id, + "round_id": round_id, + } + + endpoint = Config.APPLICATION_STORE_API_HOST + Config.APPLICATIONS_ENDPOINT + not_submitted_applications = requests.get(endpoint, params=status) + + all_applications = [] + for application in not_submitted_applications.json(): + application["round_name"] = round_name + application["fund_name"] = fund_name + application["contact_help_email"] = contact_email + account = get_account( + account_id=application.get("account_id") + ) + + application["account_email"] = account.get('email_address') + application["deadline_date"] = round_deadline_str + all_applications.append({"application": application}) + + logging.info(f"Total unsubmitted applications: {len(all_applications)}") + # Only one email per account_email + unique_email_account = {} + for application in all_applications: + unique_email_account[ + application["application"]["account_email"] + ] = application + + logging.info(f"Total unique email accounts: {len(unique_email_account)}") + unique_application_email_addresses = list(unique_email_account.values()) + + if len(unique_application_email_addresses) > 0: + for count, application in enumerate( + unique_application_email_addresses, start=1 + ): + email = application["application"]["account_email"] + logging.info( + f"Sending reminder {count} of {len(unique_email_account)}" + f" for {fund_name} {round_name}" + f" to {email}" + ) + + try: + response = post_notification( + template_type=Config.NOTIFY_TEMPLATE_APPLICATION_DEADLINE_REMINDER, + to_email=email, content=application) + + if response == 200 and len(unique_application_email_addresses) == count: + logging.info( + "The application reminder has been" + " sent successfully for" + f" {fund_name} {round_name}" + ) + + try: + application_reminder_endpoint = ( + Config.FUND_STORE_API_HOST + + Config.APPLICATION_REMINDER_STATUS.format( + round_id=round_id + ) + ) + response = requests.put( + application_reminder_endpoint + ) + if response.status_code == 200: + logging.info( + "The application_reminder_sent has been" + " set to True for" + f" {fund_name} {round_name}" + ) + except Exception as e: + logging.error( + "There was an issue updating the" + " application_reminder_sent column in the" + f" Round store for {fund_name} {round_name}." + f" Error {e}." + ) + + except Exception as e: + logging.info("There was a problem sending application(s)" + f" for {fund_name} {round_name}" + f" Error: {e}") + + else: + logging.info( + "Currently, there are no non-submitted applications" + f" for {fund_name} {round_name}" + ) + else: + if (current_datetime < reminder_date < round_deadline and + not application_reminder_sent): + days_to_reminder = reminder_date-current_datetime + logging.info( + "Application deadline reminder is due in " + f" {days_to_reminder.days} days" + f" for {fund_name} {round_name}." + ) + continue + continue + \ No newline at end of file diff --git a/apps/pre-award/lambdas/application-deadline-reminder/config.py b/apps/pre-award/lambdas/application-deadline-reminder/config.py new file mode 100644 index 00000000..0071f895 --- /dev/null +++ b/apps/pre-award/lambdas/application-deadline-reminder/config.py @@ -0,0 +1,26 @@ +from os import environ + +class Config: + # fund store + FUND_STORE_API_HOST=environ.get("FUND_STORE_API_HOST") + FUNDS_ENDPOINT=environ.get("FUNDS_ENDPOINT") + FUND_ENDPOINT=environ.get("FUND_ENDPOINT") + FUND_ROUNDS_ENDPOINT=environ.get("FUND_ROUNDS_ENDPOINT") + + # account store + ACCOUNT_STORE_API_HOST = environ.get("ACCOUNT_STORE_API_HOST") + ACCOUNTS_ENDPOINT = environ.get("ACCOUNTS_ENDPOINT") + + # application store + APPLICATION_STORE_API_HOST = environ.get("APPLICATION_STORE_API_HOST") + APPLICATION_REMINDER_STATUS = environ.get("APPLICATION_REMINDER_STATUS") + APPLICATIONS_ENDPOINT = environ.get("APPLICATIONS_ENDPOINT") + + # notification service + NOTIFICATION_SERVICE_API_HOST = environ.get("NOTIFICATION_SERVICE_API_HOST") + NOTIFY_TEMPLATE_APPLICATION_DEADLINE_REMINDER = environ.get("NOTIFY_TEMPLATE_APPLICATION_DEADLINE_REMINDER") + SEND_ENDPOINT = environ.get("SEND_ENDPOINT") + + + + \ No newline at end of file diff --git a/apps/pre-award/lambdas/application-deadline-reminder/data.py b/apps/pre-award/lambdas/application-deadline-reminder/data.py new file mode 100644 index 00000000..d2181915 --- /dev/null +++ b/apps/pre-award/lambdas/application-deadline-reminder/data.py @@ -0,0 +1,66 @@ +import json +from typing import Optional +from urllib.parse import urlencode +from config import Config + +import requests +import logging + +# Logging to output to CloudWatch Logs +logging.getLogger('lambda_runtime').setLevel(logging.INFO) +logging.getLogger().setLevel(logging.DEBUG) + + +def get_data(endpoint, params: Optional[dict] = None): + query_string = "" + if params: + params = {k: v for k, v in params.items() if v is not None} + query_string = urlencode(params) + + endpoint = endpoint + "?" + query_string + response = requests.get(endpoint) + + if response.status_code == 200: + data = response.json() + return data + else: + logging.error("There was a problem retrieving response from" + f" {endpoint}. Status code: {response.status_code}") + return None + + +def post_notification(template_type: str, to_email: str, content): + endpoint = Config.NOTIFICATION_SERVICE_API_HOST + Config.SEND_ENDPOINT + json_payload = { + "type": template_type, + "to": to_email, + "content": content, + } + + response = requests.post(endpoint, json=json_payload) + if response.status_code in [200, 201]: + logging.info( + f"Post successfully sent to {endpoint} with response code:" + f" '{response.status_code}'." + ) + return response.status_code + + else: + logging.error("Sorry, the notification could not be sent for endpoint:" + f" '{endpoint}', params: '{json_payload}', response:" + f" '{response.json()}'") + + +def get_account( + email: Optional[str] = None, account_id: Optional[str] = None +): + if email is account_id is None: + raise TypeError("Requires an email address or account_id") + + url = Config.ACCOUNT_STORE_API_HOST + Config.ACCOUNTS_ENDPOINT + params = {"email_address": email, "account_id": account_id} + response = get_data(url, params) + + if response and "account_id" in response: + return response + \ No newline at end of file diff --git a/apps/pre-award/lambdas/application-deadline-reminder/lambda_function.py b/apps/pre-award/lambdas/application-deadline-reminder/lambda_function.py new file mode 100644 index 00000000..338c4f49 --- /dev/null +++ b/apps/pre-award/lambdas/application-deadline-reminder/lambda_function.py @@ -0,0 +1,8 @@ +from application_reminder import application_deadline_reminder + +def lambda_handler(event, context): + + return { + 'statusCode': 200, + 'body': application_deadline_reminder(), + } \ No newline at end of file