diff --git a/services/api/src/main.py b/services/api/src/main.py index b15f6d5d..f9f93dfb 100644 --- a/services/api/src/main.py +++ b/services/api/src/main.py @@ -27,6 +27,7 @@ from admin_new_org import AdminNewOrg from admin_org import AdminOrg from contact_support import ContactSupportResource +from rsu_error_summary import RSUErrorSummaryResource import smtp_error_handler log_level = os.environ.get("LOGGING_LEVEL", "INFO") @@ -61,6 +62,7 @@ api.add_resource(AdminNotification, "/admin-notification") api.add_resource(AdminNewNotification, "/admin-new-notification") api.add_resource(ContactSupportResource, "/contact-support") +api.add_resource(RSUErrorSummaryResource, "/rsu-error-summary") if __name__ == "__main__": app.run(host="0.0.0.0", port=5000) diff --git a/services/api/src/middleware.py b/services/api/src/middleware.py index 97858c4c..e8ea4e29 100644 --- a/services/api/src/middleware.py +++ b/services/api/src/middleware.py @@ -61,6 +61,7 @@ def get_user_role(token): "/rsu-geo-query": True, "/admin-new-notification": False, "/admin-notification": False, + "/rsu-error-summary": False, } @@ -69,7 +70,7 @@ def check_auth_exempt(method, path): if method == "OPTIONS": return True - exempt_paths = ["/", "/contact-support"] + exempt_paths = ["/", "/contact-support", "/rsu-error-summary"] if path in exempt_paths: return True diff --git a/services/api/src/rsu_error_summary.py b/services/api/src/rsu_error_summary.py new file mode 100644 index 00000000..63d0d878 --- /dev/null +++ b/services/api/src/rsu_error_summary.py @@ -0,0 +1,75 @@ +import logging +import os +from flask import abort, request +from flask_restful import Resource +from marshmallow import Schema +from marshmallow import fields + +from common.emailSender import EmailSender + +class RSUErrorSummarySchema(Schema): + emails = fields.Str(required=True) + subject = fields.Str(required=True) + message = fields.Str(required=True) + +class RSUErrorSummaryResource(Resource): + options_headers = { + "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Allow-Methods": "POST,OPTIONS", + "Access-Control-Max-Age": "3600", + } + + headers = { + "Access-Control-Allow-Origin": os.environ["CORS_DOMAIN"], + "Access-Control-Allow-Headers": "Content-Type,Authorization", + "Access-Control-Allow-Methods": "POST,OPTIONS", + "Content-Type": "application/json", + } + + def options(self): + # CORS support + return ("", 204, self.options_headers) + + def post(self): + logging.debug("RSUErrorSummary POST requested") + # Check for main body values + if not request.json: + logging.error("No JSON body provided") + abort(400) + + self.validate_input(request.json) + + try: + email_addresses = request.json["emails"].split(",") + subject = request.json["subject"] + message = request.json["message"] + + for email_address in email_addresses: + logging.info(f"Sending email to {email_address}") + emailSender = EmailSender( + os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"], + 587, + ) + emailSender.send( + sender=os.environ["CSM_EMAIL_TO_SEND_FROM"], + recipient=email_address, + subject=subject, + message=message, + replyEmail="", + username=os.environ["CSM_EMAIL_APP_USERNAME"], + password=os.environ["CSM_EMAIL_APP_PASSWORD"], + pretty=True, + ) + except Exception as e: + logging.error(f"Exception encountered: {e}") + abort(500) + return ("", 200, self.headers) + + def validate_input(self, input): + try: + schema = RSUErrorSummarySchema() + schema.load(input) + except Exception as e: + logging.error(f"Exception encountered: {e}") + abort(400) \ No newline at end of file diff --git a/services/api/tests/data/rsu_error_summary_data.py b/services/api/tests/data/rsu_error_summary_data.py new file mode 100644 index 00000000..cce2fd97 --- /dev/null +++ b/services/api/tests/data/rsu_error_summary_data.py @@ -0,0 +1,17 @@ +CSM_EMAIL_TO_SEND_FROM = "test@test.com" +CSM_EMAIL_APP_USERNAME = "testusername" +CSM_EMAIL_APP_PASSWORD = "testpassword" +DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS = "smtp.gmail.com" +DEFAULT_CSM_TARGET_SMTP_SERVER_PORT = 587 + + +rsu_error_summary_data_good = { + "subject": "test_subject", + "message": "test_message", + "emails": "some-reply@test.com", +} + +rsu_error_summary_data_bad = { + "subject": "test_subject", + "message": "test_message", +} diff --git a/services/api/tests/src/test_rsu_error_summary.py b/services/api/tests/src/test_rsu_error_summary.py new file mode 100644 index 00000000..f99b8258 --- /dev/null +++ b/services/api/tests/src/test_rsu_error_summary.py @@ -0,0 +1,150 @@ +import os +from unittest.mock import MagicMock + +import api.src.rsu_error_summary as rsu_error_summary +import api.tests.data.rsu_error_summary_data as rsu_error_summary_data + +# RSUErrorSummarySchema class tests --- + +def test_rsu_error_summary_schema(): + # prepare + schema = rsu_error_summary.RSUErrorSummarySchema() + + # execute + exceptionOccurred = False + try: + schema.load(rsu_error_summary_data.rsu_error_summary_data_good) + except Exception as e: + exceptionOccurred = True + + # assert + assert exceptionOccurred == False + + +def test_rsu_error_summary_schema_invalid(): + # prepare + schema = rsu_error_summary.RSUErrorSummarySchema() + + # execute + exceptionOccurred = False + try: + schema.load(rsu_error_summary_data.rsu_error_summary_data_bada) + except Exception as e: + exceptionOccurred = True + + # assert + assert exceptionOccurred == True + + +# RSUErrorSummaryResource class tests --- + +def test_options(): + # prepare + os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM + os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME + os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD + os.environ[ + "CSM_TARGET_SMTP_SERVER_ADDRESS" + ] = rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS + os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) + RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() + + # execute + result = RSUErrorSummaryResource.options() + + # assert + assert result == ("", 204, RSUErrorSummaryResource.options_headers) + + # cleanup + del os.environ["CSM_EMAIL_TO_SEND_FROM"] + del os.environ["CSM_EMAIL_APP_USERNAME"] + del os.environ["CSM_EMAIL_APP_PASSWORD"] + del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] + del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] + + +def test_post_success(): + # prepare + os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM + os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME + os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD + os.environ[ + "CSM_TARGET_SMTP_SERVER_ADDRESS" + ] = rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS + os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) + RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() + RSUErrorSummaryResource.validate_input = MagicMock() + RSUErrorSummaryResource.send = MagicMock() + rsu_error_summary.abort = MagicMock() + rsu_error_summary.request = MagicMock() + + # execute + result = RSUErrorSummaryResource.post() + + # assert + assert result == ("", 200, RSUErrorSummaryResource.headers) + + # cleanup + del os.environ["CSM_EMAIL_TO_SEND_FROM"] + del os.environ["CSM_EMAIL_APP_USERNAME"] + del os.environ["CSM_EMAIL_APP_PASSWORD"] + del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] + del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] + + +def test_post_no_json_body(): + # prepare + os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM + os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME + os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD + os.environ[ + "CSM_TARGET_SMTP_SERVER_ADDRESS" + ] = rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS + os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) + RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() + RSUErrorSummaryResource.validate_input = MagicMock() + RSUErrorSummaryResource.send = MagicMock() + rsu_error_summary.abort = MagicMock() + rsu_error_summary.request = MagicMock() + rsu_error_summary.request.json = None + + # execute + result = RSUErrorSummaryResource.post() + + # assert + assert rsu_error_summary.abort.call_count == 2 + assert result == ("", 200, RSUErrorSummaryResource.headers) + + # cleanup + del os.environ["CSM_EMAIL_TO_SEND_FROM"] + del os.environ["CSM_EMAIL_APP_USERNAME"] + del os.environ["CSM_EMAIL_APP_PASSWORD"] + del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] + del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] + + +def test_validate_input_good(): + # prepare + os.environ["CSM_EMAIL_TO_SEND_FROM"] = rsu_error_summary_data.CSM_EMAIL_TO_SEND_FROM + os.environ["CSM_EMAIL_APP_USERNAME"] = rsu_error_summary_data.CSM_EMAIL_APP_USERNAME + os.environ["CSM_EMAIL_APP_PASSWORD"] = rsu_error_summary_data.CSM_EMAIL_APP_PASSWORD + os.environ[ + "CSM_TARGET_SMTP_SERVER_ADDRESS" + ] = rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_ADDRESS + os.environ["CSM_TARGET_SMTP_SERVER_PORT"] = str(rsu_error_summary_data.DEFAULT_CSM_TARGET_SMTP_SERVER_PORT) + RSUErrorSummaryResource = rsu_error_summary.RSUErrorSummaryResource() + + # execute + result = RSUErrorSummaryResource.validate_input( + rsu_error_summary_data.rsu_error_summary_data_good + ) + + # assert + assert result == None + + # cleanup + del os.environ["CSM_EMAIL_TO_SEND_FROM"] + del os.environ["CSM_EMAIL_APP_USERNAME"] + del os.environ["CSM_EMAIL_APP_PASSWORD"] + del os.environ["CSM_TARGET_SMTP_SERVER_ADDRESS"] + del os.environ["CSM_TARGET_SMTP_SERVER_PORT"] \ No newline at end of file diff --git a/webapp/package-lock.json b/webapp/package-lock.json index b2481013..4c27fe74 100644 --- a/webapp/package-lock.json +++ b/webapp/package-lock.json @@ -60,6 +60,7 @@ "react-spinners": "^0.11.0", "react-table": "^7.8.0", "react-tabs": "^4.2.1", + "react-to-print": "^3.0.1", "react-widgets": "5.8.4", "reactstrap": "^9.2.2", "recharts": "^2.12.6", @@ -24960,6 +24961,14 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "node_modules/react-to-print": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/react-to-print/-/react-to-print-3.0.1.tgz", + "integrity": "sha512-WcGiim2VWiHW7TR0PB9Xqv/SOOtnDJbjhlSOeMB1N+lxyFLuoQ8GIoNAw/ci7+sCkbXreEKmiy1ZWgd87v7U2Q==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ~19" + } + }, "node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", diff --git a/webapp/package.json b/webapp/package.json index d744e430..7cbc325d 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -55,6 +55,7 @@ "react-spinners": "^0.11.0", "react-table": "^7.8.0", "react-tabs": "^4.2.1", + "react-to-print": "^3.0.1", "react-widgets": "5.8.4", "reactstrap": "^9.2.2", "recharts": "^2.12.6", diff --git a/webapp/src/EnvironmentVars.tsx b/webapp/src/EnvironmentVars.tsx index ba6f7b2e..f40e929b 100644 --- a/webapp/src/EnvironmentVars.tsx +++ b/webapp/src/EnvironmentVars.tsx @@ -63,6 +63,7 @@ class EnvironmentVars { static adminAddOrg = `${this.getBaseApiUrl()}/admin-new-org` static adminOrg = `${this.getBaseApiUrl()}/admin-org` static contactSupport = `${this.getBaseApiUrl()}/contact-support` + static rsuErrorSummary = `${this.getBaseApiUrl()}/rsu-error-summary` } export default EnvironmentVars diff --git a/webapp/src/apis/rsu-api.ts b/webapp/src/apis/rsu-api.ts index 231924f7..f2951fb0 100644 --- a/webapp/src/apis/rsu-api.ts +++ b/webapp/src/apis/rsu-api.ts @@ -171,6 +171,15 @@ class RsuApi { body: JSON.stringify(json), }) } + + // POST + postRsuErrorSummary = async (json: Object): Promise> => { + console.log('api: ', json) + return await apiHelper._postData({ + url: EnvironmentVars.rsuErrorSummary, + body: JSON.stringify(json), + }) + } } const rsuApiObject = new RsuApi() diff --git a/webapp/src/components/AdminTable.tsx b/webapp/src/components/AdminTable.tsx index 36e09829..25958ad0 100644 --- a/webapp/src/components/AdminTable.tsx +++ b/webapp/src/components/AdminTable.tsx @@ -11,6 +11,8 @@ interface AdminTableProps { data: any[] title: string editable?: any + selection?: boolean + tableLayout?: 'auto' | 'fixed' } const AdminTable = (props: AdminTableProps) => { @@ -25,9 +27,9 @@ const AdminTable = (props: AdminTableProps) => { title={props.title} editable={props.editable} options={{ - selection: true, + selection: props.selection === undefined ? true : props.selection, actionsColumnIndex: -1, - tableLayout: 'fixed', + tableLayout: props.tableLayout === undefined ? 'fixed' : props.tableLayout, rowStyle: { overflowWrap: 'break-word', }, diff --git a/webapp/src/components/RsuErrorSummary.test.tsx b/webapp/src/components/RsuErrorSummary.test.tsx new file mode 100644 index 00000000..a0662f3d --- /dev/null +++ b/webapp/src/components/RsuErrorSummary.test.tsx @@ -0,0 +1,20 @@ +import React from 'react' +import { render } from '@testing-library/react' +import RsuErrorSummary from './RsuErrorSummary' +import { replaceChaoticIds } from '../utils/test-utils' + +it('should take a snapshot', () => { + const { container } = render( +