From bbd9a6efa34d3182aab10b2fd45738f96f4a38d0 Mon Sep 17 00:00:00 2001 From: Jad Date: Mon, 21 Oct 2024 10:47:48 -0700 Subject: [PATCH 1/5] setup common hosted email service --- submit-api/src/submit_api/config.py | 5 + .../src/submit_api/resources/__init__.py | 2 + submit-api/src/submit_api/resources/email.py | 50 ++++++++ .../src/submit_api/services/chefs_service.py | 110 ++++++++++++++++++ ...nagement_plan_submission_verification.html | 13 +++ submit-api/src/submit_api/utils/template.py | 16 +++ 6 files changed, 196 insertions(+) create mode 100644 submit-api/src/submit_api/resources/email.py create mode 100644 submit-api/src/submit_api/services/chefs_service.py create mode 100644 submit-api/src/submit_api/templates/management_plan_submission_verification.html create mode 100644 submit-api/src/submit_api/utils/template.py diff --git a/submit-api/src/submit_api/config.py b/submit-api/src/submit_api/config.py index 96b6b4a9..535d25c9 100644 --- a/submit-api/src/submit_api/config.py +++ b/submit-api/src/submit_api/config.py @@ -92,6 +92,11 @@ class DevConfig(_Config): # pylint: disable=too-few-public-methods TESTING = False DEBUG = True + + CHES_TOKEN_ENDPOINT = os.getenv('CHES_TOKEN_ENDPOINT') + CHES_CLIENT_ID = os.getenv('CHES_CLIENT_ID') + CHES_CLIENT_SECRET = os.getenv('CHES_CLIENT_SECRET') + CHES_BASE_URL = os.getenv('CHES_BASE_URL') print(f'SQLAlchemy URL (DevConfig): {_Config.SQLALCHEMY_DATABASE_URI}') diff --git a/submit-api/src/submit_api/resources/__init__.py b/submit-api/src/submit_api/resources/__init__.py index 710d734a..770d8cad 100644 --- a/submit-api/src/submit_api/resources/__init__.py +++ b/submit-api/src/submit_api/resources/__init__.py @@ -32,6 +32,7 @@ from .submission import API as SUBMISSION_API from .test import API as TEST_API from .user import API as USER_API +from .email import API as EMAIL_API __all__ = ('API_BLUEPRINT', 'OPS_BLUEPRINT') @@ -65,3 +66,4 @@ API.add_namespace(ITEM_API) API.add_namespace(SUBMISSION_API) API.add_namespace(TEST_API) +API.add_namespace(EMAIL_API) diff --git a/submit-api/src/submit_api/resources/email.py b/submit-api/src/submit_api/resources/email.py new file mode 100644 index 00000000..e614fd98 --- /dev/null +++ b/submit-api/src/submit_api/resources/email.py @@ -0,0 +1,50 @@ +# Copyright © 2024 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""API endpoints for managing an email resource.""" + +from http import HTTPStatus + +from flask_restx import Namespace, Resource, cors + +from submit_api.utils.util import cors_preflight + +from ..auth import auth +from .apihelper import Api as ApiHelper +from ..services.chefs_service import ChefsApiService, EmailDetails + +API = Namespace("email", description="Endpoints for sending emails") +"""Custom exception messages +""" + + +@cors_preflight("POST, OPTIONS") +@API.route("", methods=["POST", "OPTIONS"]) +class Item(Resource): + """Resource for managing projects.""" + + @staticmethod + @ApiHelper.swagger_decorators( + API, endpoint_description="send email" + ) + @API.response(HTTPStatus.BAD_REQUEST, "Bad Request") + @cors.crossdomain(origin="*") + @auth.require + def post(): + """Send email.""" + request_json = API.payload + email_details = EmailDetails( + **request_json + ) + response = ChefsApiService().send_email(email_details) + return response.json(), response.status_code diff --git a/submit-api/src/submit_api/services/chefs_service.py b/submit-api/src/submit_api/services/chefs_service.py new file mode 100644 index 00000000..8caac592 --- /dev/null +++ b/submit-api/src/submit_api/services/chefs_service.py @@ -0,0 +1,110 @@ +# Copyright © 2019 Province of British Columbia +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +"""Service for integrating with the Common Hosted Email Service.""" +import base64 +import json +from typing import Optional + +import requests +from attr import dataclass +from flask import current_app + +from submit_api.utils.template import Template + + +@dataclass +class EmailDetails: + """Email details class.""" + sender: str + recipients: list[str] + subject: str + body: Optional[str] + template_name: Optional[str] = None + body_args: Optional[dict] = {} + cc: Optional[list[str]] = [] + bcc: Optional[list[str]] = [] + + +class ChefsApiService: + """CHEFS api Service class.""" + + def __init__(self): + """Initiate class.""" + self.token_endpoint = current_app.config.get('CHES_TOKEN_ENDPOINT') + self.service_client_id = current_app.config.get('CHES_CLIENT_ID') + self.service_client_secret = current_app.config.get('CHES_CLIENT_SECRET') + self.ches_base_url = current_app.config.get('CHES_BASE_URL') + self.access_token = self._get_access_token() + + def _get_access_token(self): + + basic_auth_encoded = base64.b64encode( + bytes(f'{self.service_client_id}:{self.service_client_secret}', 'utf-8')).decode('utf-8') + data = 'grant_type=client_credentials' + response = requests.post( + self.token_endpoint, + data=data, + headers={ + 'Authorization': f'Basic {basic_auth_encoded}', + 'Content-Type': 'application/x-www-form-urlencoded' + } + ) + + response_json = response.json() + return response_json['access_token'] + + @staticmethod + def _get_email_body_from_template(template_name: str, body_args: dict): + if not template_name: + raise ValueError('Template name is required') + + template = Template.get_template(template_name) + if not template: + raise ValueError('Template not found') + + return template.render(body_args) + + def _get_email_body(self, email_details: EmailDetails): + if email_details.body: + body = email_details.body + body_type = 'text' + else: + body = self._get_email_body_from_template(email_details.template_name, email_details.body_args) + body_type = 'html' + return body, body_type + + def send_email(self, email_details: EmailDetails): + """Generate document based on template and data.""" + body, body_type = self._get_email_body(email_details) + + request_body = { + 'bodyType': body_type, + 'body': body, + 'subject': email_details.subject, + 'from': email_details.sender, + 'to': email_details.recipients, + 'cc': email_details.cc, + 'bcc': email_details.bcc + } + json_request_body = json.dumps(request_body) + + headers = { + 'Content-Type': 'application/json', + 'Authorization': f'Bearer {self.access_token}' + } + + url = f'{self.ches_base_url}/api/v1/email' + return requests.post(url, data=json_request_body, headers=headers) diff --git a/submit-api/src/submit_api/templates/management_plan_submission_verification.html b/submit-api/src/submit_api/templates/management_plan_submission_verification.html new file mode 100644 index 00000000..ca335344 --- /dev/null +++ b/submit-api/src/submit_api/templates/management_plan_submission_verification.html @@ -0,0 +1,13 @@ +

Hello {{ name }},

+

Thank you for submitting the management plan on behalf of {{ certificate_holder_name }} through our new submission tool. We have successfully received your submission, and the details are as follows:

+

Management Plan Name: {{ management_plan_name }}

+

Submission Date: {{ submission_date }}

+

Submitted Documents:

+ +

If you have any questions or require further assistance, please don't hesitate to contact us.

+

Best regards,

+

EAO Management Plan Team

diff --git a/submit-api/src/submit_api/utils/template.py b/submit-api/src/submit_api/utils/template.py new file mode 100644 index 00000000..165b61f9 --- /dev/null +++ b/submit-api/src/submit_api/utils/template.py @@ -0,0 +1,16 @@ +"""Template Services.""" + +import os +from jinja2 import Environment, FileSystemLoader + +templates_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), '../../..', 'templates')) +ENV = Environment(loader=FileSystemLoader(templates_dir), autoescape=True) + + +class Template: + """Template helper class.""" + + @staticmethod + def get_template(template_filename): + """Get a template from the common template folder.""" + return ENV.get_template(template_filename) From 2fde932c4bc61bf118193d69c106d7aed7ea765b Mon Sep 17 00:00:00 2001 From: Jad Date: Mon, 21 Oct 2024 13:46:07 -0700 Subject: [PATCH 2/5] Handle refresh token for subsequent calls --- submit-api/src/submit_api/resources/email.py | 4 +- .../src/submit_api/services/chefs_service.py | 47 +++++++++++-------- 2 files changed, 30 insertions(+), 21 deletions(-) diff --git a/submit-api/src/submit_api/resources/email.py b/submit-api/src/submit_api/resources/email.py index e614fd98..36ed8f5d 100644 --- a/submit-api/src/submit_api/resources/email.py +++ b/submit-api/src/submit_api/resources/email.py @@ -46,5 +46,5 @@ def post(): email_details = EmailDetails( **request_json ) - response = ChefsApiService().send_email(email_details) - return response.json(), response.status_code + response_json, status = ChefsApiService().send_email(email_details) + return response_json, status diff --git a/submit-api/src/submit_api/services/chefs_service.py b/submit-api/src/submit_api/services/chefs_service.py index 8caac592..f2f026d5 100644 --- a/submit-api/src/submit_api/services/chefs_service.py +++ b/submit-api/src/submit_api/services/chefs_service.py @@ -16,12 +16,11 @@ """Service for integrating with the Common Hosted Email Service.""" import base64 import json -from typing import Optional - +from datetime import datetime, timedelta +from typing import Optional, List import requests from attr import dataclass from flask import current_app - from submit_api.utils.template import Template @@ -29,13 +28,18 @@ class EmailDetails: """Email details class.""" sender: str - recipients: list[str] + recipients: List[str] subject: str - body: Optional[str] + body: Optional[str] = None template_name: Optional[str] = None - body_args: Optional[dict] = {} - cc: Optional[list[str]] = [] - bcc: Optional[list[str]] = [] + body_args: Optional[dict] = None + cc: Optional[List[str]] = None + bcc: Optional[List[str]] = None + + def __post_init__(self): + self.body_args = self.body_args or {} + self.cc = self.cc or [] + self.bcc = self.bcc or [] class ChefsApiService: @@ -47,12 +51,12 @@ def __init__(self): self.service_client_id = current_app.config.get('CHES_CLIENT_ID') self.service_client_secret = current_app.config.get('CHES_CLIENT_SECRET') self.ches_base_url = current_app.config.get('CHES_BASE_URL') - self.access_token = self._get_access_token() + self.access_token, self.token_expiry = self._get_access_token() def _get_access_token(self): - basic_auth_encoded = base64.b64encode( - bytes(f'{self.service_client_id}:{self.service_client_secret}', 'utf-8')).decode('utf-8') + bytes(f'{self.service_client_id}:{self.service_client_secret}', 'utf-8') + ).decode('utf-8') data = 'grant_type=client_credentials' response = requests.post( self.token_endpoint, @@ -62,19 +66,23 @@ def _get_access_token(self): 'Content-Type': 'application/x-www-form-urlencoded' } ) - + response.raise_for_status() response_json = response.json() - return response_json['access_token'] + expires_in = response_json['expires_in'] + expiry_time = datetime.now() + timedelta(seconds=expires_in) + return response_json['access_token'], expiry_time + + def _ensure_valid_token(self): + if datetime.now() >= self.token_expiry: + self.access_token, self.token_expiry = self._get_access_token() @staticmethod def _get_email_body_from_template(template_name: str, body_args: dict): if not template_name: raise ValueError('Template name is required') - template = Template.get_template(template_name) if not template: raise ValueError('Template not found') - return template.render(body_args) def _get_email_body(self, email_details: EmailDetails): @@ -88,8 +96,9 @@ def _get_email_body(self, email_details: EmailDetails): def send_email(self, email_details: EmailDetails): """Generate document based on template and data.""" - body, body_type = self._get_email_body(email_details) + self._ensure_valid_token() + body, body_type = self._get_email_body(email_details) request_body = { 'bodyType': body_type, 'body': body, @@ -100,11 +109,11 @@ def send_email(self, email_details: EmailDetails): 'bcc': email_details.bcc } json_request_body = json.dumps(request_body) - headers = { 'Content-Type': 'application/json', 'Authorization': f'Bearer {self.access_token}' } - url = f'{self.ches_base_url}/api/v1/email' - return requests.post(url, data=json_request_body, headers=headers) + response = requests.post(url, data=json_request_body, headers=headers) + response.raise_for_status() + return response.json() From cb3b767b8e8014b05242b62ba5931f691a7ebca3 Mon Sep 17 00:00:00 2001 From: Jad Date: Mon, 21 Oct 2024 13:49:29 -0700 Subject: [PATCH 3/5] return response and status --- submit-api/src/submit_api/resources/email.py | 2 +- submit-api/src/submit_api/services/chefs_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/submit-api/src/submit_api/resources/email.py b/submit-api/src/submit_api/resources/email.py index 36ed8f5d..139ee243 100644 --- a/submit-api/src/submit_api/resources/email.py +++ b/submit-api/src/submit_api/resources/email.py @@ -39,7 +39,7 @@ class Item(Resource): ) @API.response(HTTPStatus.BAD_REQUEST, "Bad Request") @cors.crossdomain(origin="*") - @auth.require + # @auth.require def post(): """Send email.""" request_json = API.payload diff --git a/submit-api/src/submit_api/services/chefs_service.py b/submit-api/src/submit_api/services/chefs_service.py index f2f026d5..2230f011 100644 --- a/submit-api/src/submit_api/services/chefs_service.py +++ b/submit-api/src/submit_api/services/chefs_service.py @@ -116,4 +116,4 @@ def send_email(self, email_details: EmailDetails): url = f'{self.ches_base_url}/api/v1/email' response = requests.post(url, data=json_request_body, headers=headers) response.raise_for_status() - return response.json() + return response.json(), response.status_code From 49df7048c1420f717b883fccbe883dff1ffa3a52 Mon Sep 17 00:00:00 2001 From: Jad Date: Mon, 21 Oct 2024 14:30:55 -0700 Subject: [PATCH 4/5] use auth require and fix linting issue --- submit-api/src/submit_api/resources/email.py | 2 +- submit-api/src/submit_api/services/chefs_service.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/submit-api/src/submit_api/resources/email.py b/submit-api/src/submit_api/resources/email.py index 139ee243..36ed8f5d 100644 --- a/submit-api/src/submit_api/resources/email.py +++ b/submit-api/src/submit_api/resources/email.py @@ -39,7 +39,7 @@ class Item(Resource): ) @API.response(HTTPStatus.BAD_REQUEST, "Bad Request") @cors.crossdomain(origin="*") - # @auth.require + @auth.require def post(): """Send email.""" request_json = API.payload diff --git a/submit-api/src/submit_api/services/chefs_service.py b/submit-api/src/submit_api/services/chefs_service.py index 2230f011..56da2120 100644 --- a/submit-api/src/submit_api/services/chefs_service.py +++ b/submit-api/src/submit_api/services/chefs_service.py @@ -38,7 +38,7 @@ class EmailDetails: def __post_init__(self): self.body_args = self.body_args or {} - self.cc = self.cc or [] + self.cc = self.cc or [] # pylint: disable=invalid-name self.bcc = self.bcc or [] From 964cff9b002e7ed5a34db031e3d2acb53ceddd25 Mon Sep 17 00:00:00 2001 From: Jad Date: Mon, 21 Oct 2024 14:45:28 -0700 Subject: [PATCH 5/5] Add timeout parameter for ches requests --- submit-api/src/submit_api/resources/email.py | 4 ++-- .../services/{chefs_service.py => ches_service.py} | 12 ++++++++---- 2 files changed, 10 insertions(+), 6 deletions(-) rename submit-api/src/submit_api/services/{chefs_service.py => ches_service.py} (95%) diff --git a/submit-api/src/submit_api/resources/email.py b/submit-api/src/submit_api/resources/email.py index 36ed8f5d..0c6be039 100644 --- a/submit-api/src/submit_api/resources/email.py +++ b/submit-api/src/submit_api/resources/email.py @@ -21,7 +21,7 @@ from ..auth import auth from .apihelper import Api as ApiHelper -from ..services.chefs_service import ChefsApiService, EmailDetails +from ..services.ches_service import ChesApiService, EmailDetails API = Namespace("email", description="Endpoints for sending emails") """Custom exception messages @@ -46,5 +46,5 @@ def post(): email_details = EmailDetails( **request_json ) - response_json, status = ChefsApiService().send_email(email_details) + response_json, status = ChesApiService().send_email(email_details) return response_json, status diff --git a/submit-api/src/submit_api/services/chefs_service.py b/submit-api/src/submit_api/services/ches_service.py similarity index 95% rename from submit-api/src/submit_api/services/chefs_service.py rename to submit-api/src/submit_api/services/ches_service.py index 56da2120..d2d58834 100644 --- a/submit-api/src/submit_api/services/chefs_service.py +++ b/submit-api/src/submit_api/services/ches_service.py @@ -27,6 +27,7 @@ @dataclass class EmailDetails: """Email details class.""" + sender: str recipients: List[str] subject: str @@ -37,13 +38,14 @@ class EmailDetails: bcc: Optional[List[str]] = None def __post_init__(self): + """Post init method to initialize optional fields.""" self.body_args = self.body_args or {} self.cc = self.cc or [] # pylint: disable=invalid-name self.bcc = self.bcc or [] -class ChefsApiService: - """CHEFS api Service class.""" +class ChesApiService: + """CHES api Service class.""" def __init__(self): """Initiate class.""" @@ -64,7 +66,8 @@ def _get_access_token(self): headers={ 'Authorization': f'Basic {basic_auth_encoded}', 'Content-Type': 'application/x-www-form-urlencoded' - } + }, + timeout=10 ) response.raise_for_status() response_json = response.json() @@ -114,6 +117,7 @@ def send_email(self, email_details: EmailDetails): 'Authorization': f'Bearer {self.access_token}' } url = f'{self.ches_base_url}/api/v1/email' - response = requests.post(url, data=json_request_body, headers=headers) + response = requests.post(url, data=json_request_body, headers=headers, + timeout=10) response.raise_for_status() return response.json(), response.status_code