diff --git a/.github/workflows/full-lambdas-dispatch-deploy.yml b/.github/workflows/full-lambdas-dispatch-deploy.yml index fe2d16cbd..47ee5091a 100644 --- a/.github/workflows/full-lambdas-dispatch-deploy.yml +++ b/.github/workflows/full-lambdas-dispatch-deploy.yml @@ -174,4 +174,41 @@ jobs: function_name: ${{ github.event.client_payload.sandbox }}_SearchDocumentReferencesLambda zip_file: package_lambdas_document_reference_search_handler.zip - \ No newline at end of file + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: ${{ github.event.client_payload.environment }} + needs: ["python_lambdas_test"] + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environement + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ github.event.client_payload.sandbox }}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip \ No newline at end of file diff --git a/.github/workflows/lambdas-deploy-feature-to-sandbox.yml b/.github/workflows/lambdas-deploy-feature-to-sandbox.yml index de04b5b1c..44a18727c 100644 --- a/.github/workflows/lambdas-deploy-feature-to-sandbox.yml +++ b/.github/workflows/lambdas-deploy-feature-to-sandbox.yml @@ -193,7 +193,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: Make virtual environement + - name: Make virtual environment run: | make env @@ -371,3 +371,42 @@ jobs: aws_region: ${{ vars.AWS_REGION }} function_name: ${{ github.event.inputs.sandboxWorkspace}}_LogoutHandler zip_file: package_lambdas_logout_handler.zip + + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: development + needs: ["python_lambdas_test"] + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environment + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ github.event.inputs.sandboxWorkspace}}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip \ No newline at end of file diff --git a/.github/workflows/lambdas-deploy-to-perf-manual.yml b/.github/workflows/lambdas-deploy-to-perf-manual.yml index 4cc3463e4..ab183e50a 100644 --- a/.github/workflows/lambdas-deploy-to-perf-manual.yml +++ b/.github/workflows/lambdas-deploy-to-perf-manual.yml @@ -381,3 +381,42 @@ jobs: aws_region: ${{ vars.AWS_REGION }} function_name: ${{ github.event.inputs.sandboxWorkspace}}_LogoutHandler zip_file: package_lambdas_logout_handler.zip + + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: perf + needs: ["python_lambdas_test"] + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environment + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ github.event.inputs.sandboxWorkspace}}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip \ No newline at end of file diff --git a/.github/workflows/lambdas-deploy-to-pre-prod-manual.yml b/.github/workflows/lambdas-deploy-to-pre-prod-manual.yml index df31db964..b9829ee07 100644 --- a/.github/workflows/lambdas-deploy-to-pre-prod-manual.yml +++ b/.github/workflows/lambdas-deploy-to-pre-prod-manual.yml @@ -62,7 +62,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Make virtual environement - run: | + run: | make env - name: Start virtual environement @@ -408,3 +408,44 @@ jobs: aws_region: ${{ vars.AWS_REGION }} function_name: ${{ github.event.inputs.sandboxWorkspace}}_LogoutHandler zip_file: package_lambdas_logout_handler.zip + + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: development + needs: [ "python_lambdas_test" ] + strategy: + matrix: + python-version: [ "3.11" ] + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{needs.tag_and_release.outputs.tag}} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environment + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ github.event.inputs.sandboxWorkspace}}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip \ No newline at end of file diff --git a/.github/workflows/lambdas-deploy-to-prod-manual.yml b/.github/workflows/lambdas-deploy-to-prod-manual.yml index 514b25f73..f0d5f491b 100644 --- a/.github/workflows/lambdas-deploy-to-prod-manual.yml +++ b/.github/workflows/lambdas-deploy-to-prod-manual.yml @@ -380,3 +380,44 @@ jobs: aws_region: ${{ vars.AWS_REGION }} function_name: ${{ github.event.inputs.sandboxWorkspace}}_LogoutHandler zip_file: package_lambdas_logout_handler.zip + + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: prod + needs: ["python_lambdas_test"] + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.tagVersion}} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environment + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ github.event.inputs.sandboxWorkspace}}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip \ No newline at end of file diff --git a/.github/workflows/lambdas-deploy-to-test-manual.yml b/.github/workflows/lambdas-deploy-to-test-manual.yml index 2f5d0961b..94bda7e6a 100644 --- a/.github/workflows/lambdas-deploy-to-test-manual.yml +++ b/.github/workflows/lambdas-deploy-to-test-manual.yml @@ -376,3 +376,44 @@ jobs: aws_region: ${{ vars.AWS_REGION }} function_name: ${{ vars.BUILD_ENV }}_LogoutHandler zip_file: package_lambdas_logout_handler.zip + + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: test + needs: ["python_lambdas_test"] + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + with: + ref: ${{ github.event.inputs.buildBranch}} + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environment + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ vars.BUILD_ENV}}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip \ No newline at end of file diff --git a/.github/workflows/lambdas-dev-to-main-ci.yml b/.github/workflows/lambdas-dev-to-main-ci.yml index 3f01fffa0..cbdf92521 100644 --- a/.github/workflows/lambdas-dev-to-main-ci.yml +++ b/.github/workflows/lambdas-dev-to-main-ci.yml @@ -34,6 +34,7 @@ jobs: token_changed: ${{steps.filter.outputs.token}} authoriser_changed: ${{steps.filter.outputs.authoriser}} logout_changed: ${{steps.filter.outputs.logout}} + lloyd_george_stitch_changed: ${{steps.filter.outputs.lloyd_george_stitch}} steps: - name: Checkout uses: actions/checkout@v3 @@ -67,6 +68,9 @@ jobs: - 'lambdas/handlers/authoriser_handler.py' logout: - 'lambdas/handlers/logout_handler.py' + lloyd_george_stitch: + - 'lambdas/handlers/lloyd_george_record_stitch.py' + @@ -481,3 +485,51 @@ jobs: aws_region: ${{ vars.AWS_REGION }} function_name: ${{ vars.BUILD_ENV }_LogoutHandler zip_file: package_lambdas_logout_handler.zip + + + python_deploy_lloyd_george_record_stitch_lambda: + runs-on: ubuntu-latest + environment: development + needs: [ "python_lambdas_test", "identify_changed_functions" ] + if: | + (github.ref == 'refs/heads/main') + && (needs.identify_changed_functions.outputs.utils_changed == 'true' + || needs.identify_changed_functions.outputs.enums_changed == 'true' + || needs.identify_changed_functions.outputs.services_changed == 'true' + || needs.identify_changed_functions.outputs.models_changed == 'true' + || needs.identify_changed_functions.outputs.lloyd_george_stitch_changed == 'true' + ) + strategy: + matrix: + python-version: ["3.11"] + + steps: + - name: Checkout + uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Make virtual environment + run: | + make env + + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ secrets.AWS_ASSUME_ROLE }} + role-skip-session-tagging: true + aws-region: ${{ vars.AWS_REGION }} + + - name: Create release package for Lloyd George Stitch Lambda + run: | + make lambda_name=lloyd_george_record_stitch_handler zip + + - name: Upload Lambda Function for LloydGeorgeStitchLambda + uses: appleboy/lambda-action@master + with: + aws_region: ${{ vars.AWS_REGION }} + function_name: ${{ vars.BUILD_ENV}}_LloydGeorgeStitchLambda + zip_file: package_lambdas_lloyd_george_record_stitch_handler.zip diff --git a/.gitignore b/.gitignore index a70b54675..234a2f94e 100644 --- a/.gitignore +++ b/.gitignore @@ -95,3 +95,5 @@ node_modules/ *.zip .idea/ .vscode/ + +lambdas/tests/unit/helpers/data/pdf/tmp \ No newline at end of file diff --git a/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step1_individual_patient_search_and_verify.cy.js b/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step1_individual_patient_search_and_verify.cy.js index a73990cc3..ff48d6a50 100644 --- a/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step1_individual_patient_search_and_verify.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step1_individual_patient_search_and_verify.cy.js @@ -72,7 +72,7 @@ describe('GP Upload Workflow Step 1: Patient search and verify', () => { cy.wait('@search'); }; - it('(Smoke test) shows patient upload screen when patient search is used by a GP', () => { + it('(Smoke test) shows patient upload screen when patient search is used by a GP and Inactive patient radio button is selected', () => { navigateToSearch(roles.GP); if (!smokeTest) { @@ -93,8 +93,9 @@ describe('GP Upload Workflow Step 1: Patient search and verify', () => { cy.get('#gp-message').should('be.visible'); cy.get('#gp-message').should( 'have.text', - 'Ensure these patient details match the electronic health records and attachments you are about to upload.', + 'Ensure these patient details match the records and attachments that you upload', ); + cy.get('#inactive-radio-button').click(); cy.get('#verify-submit').click(); cy.url().should('include', 'submit'); @@ -124,8 +125,9 @@ describe('GP Upload Workflow Step 1: Patient search and verify', () => { cy.get('#error-box-summary').should('have.text', 'There is a problem'); }); - it('shows the upload documents page when upload patient is verified', () => { + it('shows the upload documents page when upload patient is verified and Inactive patient radio button selected', () => { navigateToVerify(roles.GP); + cy.get('#inactive-radio-button').click(); cy.get('#verify-submit').click(); cy.url().should('include', 'submit'); diff --git a/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step2_individual_patient_document_upload.cy.js b/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step2_individual_patient_document_upload.cy.js index 67b7fd0da..83f3dd1d8 100644 --- a/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step2_individual_patient_document_upload.cy.js +++ b/app/cypress/e2e/0-ndr-core-tests/gp_upload_workflow_step2_individual_patient_document_upload.cy.js @@ -58,6 +58,7 @@ const navigateToUploadPage = () => { cy.get('#search-submit').click(); cy.wait('@search'); + cy.get('#inactive-radio-button').click(); cy.get('#verify-submit').click(); }; diff --git a/app/package-lock.json b/app/package-lock.json index 73b09461b..61de8c660 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,6 +19,7 @@ "jsdom": "^22.1.0", "nhsuk-frontend": "^7.0.0", "nhsuk-react-components": "^2.2.2", + "pdfobject": "^2.2.12", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.45.4", @@ -20427,6 +20428,11 @@ "integrity": "sha512-d+RQGp0MAYTIaDBIMmOfMwz3E+LOZnxx1HZd5R18mmCZY0QBlK0LDZfPc8FW8Ed2DlvsuE6PRjroDY+wg4+j/Q==", "dev": true }, + "node_modules/pdfobject": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/pdfobject/-/pdfobject-2.2.12.tgz", + "integrity": "sha512-D0oyD/sj8j82AMaJhoyMaY1aD5TkbpU3FbJC6w9/cpJlZRpYHqAkutXw1Ca/FKjYPZmTAu58uGIfgOEaDlbY8A==" + }, "node_modules/peek-stream": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz", diff --git a/app/package.json b/app/package.json index f73acf6af..7ab7a58ec 100644 --- a/app/package.json +++ b/app/package.json @@ -25,6 +25,7 @@ "history": "^5.3.0", "nhsuk-frontend": "^7.0.0", "nhsuk-react-components": "^2.2.2", + "pdfobject": "^2.2.12", "react": "^18.2.0", "react-dom": "^18.2.0", "react-hook-form": "^7.45.4", diff --git a/app/src/App.tsx b/app/src/App.tsx index 04eb2437d..e65e0563e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -20,6 +20,7 @@ import PatientGuard from './components/blocks/patientGuard/PatientGuard'; import PatientResultPage from './pages/patientResultPage/PatientResultPage'; import UploadDocumentsPage from './pages/uploadDocumentsPage/UploadDocumentsPage'; import DocumentSearchResultsPage from './pages/documentSearchResultsPage/DocumentSearchResultsPage'; +import LloydGeorgeRecordPage from './pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage'; function App() { return ( @@ -68,6 +69,10 @@ function App() { element={} path={routes.UPLOAD_VERIFY} /> + } + path={routes.LLOYD_GEORGE} + /> } path={routes.UPLOAD_DOCUMENTS} diff --git a/app/src/components/generic/pdfViewer/PdfViewer.test.tsx b/app/src/components/generic/pdfViewer/PdfViewer.test.tsx new file mode 100644 index 000000000..a2d3ef8d6 --- /dev/null +++ b/app/src/components/generic/pdfViewer/PdfViewer.test.tsx @@ -0,0 +1,10 @@ +import { render, screen } from '@testing-library/react'; +import PdfViewer from './PdfViewer'; + +describe('PdfViewer', () => { + it('renders an embed element', () => { + render(); + + expect(screen.getByTitle('Embedded PDF')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/generic/pdfViewer/PdfViewer.tsx b/app/src/components/generic/pdfViewer/PdfViewer.tsx new file mode 100644 index 000000000..2c9159808 --- /dev/null +++ b/app/src/components/generic/pdfViewer/PdfViewer.tsx @@ -0,0 +1,14 @@ +import React, { useEffect } from 'react'; + +type Props = { fileUrl: String }; + +const PdfViewer = ({ fileUrl }: Props) => { + useEffect(() => { + const pdfObject = require('pdfobject'); + pdfObject.embed(fileUrl + '#toolbar=0', '#pdf-viewer'); + }, [fileUrl]); + + return
; +}; + +export default PdfViewer; diff --git a/app/src/helpers/requests/getLloydGeorgeRecord.ts b/app/src/helpers/requests/getLloydGeorgeRecord.ts new file mode 100644 index 000000000..306f0fe50 --- /dev/null +++ b/app/src/helpers/requests/getLloydGeorgeRecord.ts @@ -0,0 +1,35 @@ +import axios from 'axios'; +import { AuthHeaders } from '../../types/blocks/authHeaders'; + +type Args = { + nhsNumber: string; + baseUrl: string; + baseHeaders: AuthHeaders; +}; + +export type LloydGeorgeStitchResult = { + number_of_files: number; + total_file_size_in_byte: number; + last_updated: string; + presign_url: string; +}; + +async function getLloydGeorgeRecord({ + nhsNumber, + baseUrl, + baseHeaders, +}: Args): Promise { + const gatewayUrl = baseUrl + '/LloydGeorgeStitch'; + + const { data } = await axios.get(gatewayUrl, { + headers: { + ...baseHeaders, + }, + params: { + patientId: nhsNumber, + }, + }); + return data; +} + +export default getLloydGeorgeRecord; diff --git a/app/src/helpers/requests/uploadDocument.ts b/app/src/helpers/requests/uploadDocument.ts index 8fa4bf3bb..9a24779bf 100644 --- a/app/src/helpers/requests/uploadDocument.ts +++ b/app/src/helpers/requests/uploadDocument.ts @@ -105,8 +105,8 @@ const uploadDocumentsToS3 = async ({ const docGatewayResponse: S3Upload = data[document.file.name]; const formData = new FormData(); const docFields: S3UploadFields = docGatewayResponse.fields; - Object.keys(docFields).forEach((key) => { - formData.append(key, docFields.key); + Object.entries(docFields).forEach(([key, value]) => { + formData.append(key, value); }); formData.append('file', document.file); const s3url = docGatewayResponse.url; diff --git a/app/src/helpers/test/testBuilders.ts b/app/src/helpers/test/testBuilders.ts index b429421d9..22830b02f 100644 --- a/app/src/helpers/test/testBuilders.ts +++ b/app/src/helpers/test/testBuilders.ts @@ -7,6 +7,7 @@ import { import { PatientDetails } from '../../types/generic/patientDetails'; import { SearchResult } from '../../types/generic/searchResult'; import { UserAuth } from '../../types/blocks/userAuth'; +import { LloydGeorgeStitchResult } from '../requests/getLloydGeorgeRecord'; const buildUserAuth = (userAuthOverride?: Partial) => { const auth: UserAuth = { @@ -95,11 +96,22 @@ const buildSearchResult = (searchResultOverride?: Partial) => { return result; }; +const buildLgSearchResult = () => { + const result: LloydGeorgeStitchResult = { + number_of_files: 7, + total_file_size_in_byte: 7, + last_updated: '2023-10-03T09:11:54.618694Z', + presign_url: 'https://test-url', + }; + return result; +}; + export { - buildUserAuth, buildPatientDetails, buildTextFile, buildDocument, buildSearchResult, + buildLgSearchResult, + buildUserAuth, buildLgFile, }; diff --git a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx new file mode 100644 index 000000000..db3cadfdd --- /dev/null +++ b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.test.tsx @@ -0,0 +1,103 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import LloydGeorgeRecordPage from './LloydGeorgeRecordPage'; +import PatientDetailsProvider from '../../providers/patientProvider/PatientProvider'; +import { + buildPatientDetails, + buildLgSearchResult, + buildUserAuth, +} from '../../helpers/test/testBuilders'; +import { getFormattedDate } from '../../helpers/utils/formatDate'; +import axios from 'axios'; +import SessionProvider, { Session } from '../../providers/sessionProvider/SessionProvider'; + +jest.mock('axios'); +jest.mock('react-router'); + +const mockAxios = axios as jest.Mocked; +const mockPatientDetails = buildPatientDetails(); + +describe('LloydGeorgeRecordPage', () => { + beforeEach(() => { + process.env.REACT_APP_ENVIRONMENT = 'jest'; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders patient details', async () => { + const patientName = `${mockPatientDetails.givenName} ${mockPatientDetails.familyName}`; + const dob = getFormattedDate(new Date(mockPatientDetails.birthDate)); + + renderPage(); + + expect(screen.getByText(patientName)).toBeInTheDocument(); + expect(screen.getByText(`Date of birth: ${dob}`)).toBeInTheDocument(); + expect(screen.getByText(/NHS number/)).toBeInTheDocument(); + }); + + it('LG card with title', async () => { + renderPage(); + + expect(screen.getByText('Lloyd George record')).toBeInTheDocument(); + }); + + it('renders LG card with no docs available text if there is no LG record', async () => { + const errorResponse = { + response: { + status: 404, + message: '404 no docs found', + }, + }; + + mockAxios.get.mockImplementation(() => Promise.reject(errorResponse)); + + renderPage(); + + await waitFor(() => { + expect(screen.getByText('No documents are available')).toBeInTheDocument(); + }); + + expect(screen.queryByText('View record')).not.toBeInTheDocument(); + }); + + it('displays Loading... until the pdf is rendered', async () => { + mockAxios.get.mockReturnValue(Promise.resolve({ data: buildLgSearchResult() })); + + renderPage(); + + expect(screen.getByText('Loading...')).toBeInTheDocument(); + }); + + it('renders LG card with file info when LG record is returned by search', async () => { + mockAxios.get.mockReturnValue(Promise.resolve({ data: buildLgSearchResult() })); + + renderPage(); + + await waitFor(() => { + expect(screen.getByTitle('Embedded PDF')).toBeInTheDocument(); + }); + expect(screen.getByText('View record')).toBeInTheDocument(); + + expect(screen.getByText('Lloyd George record')).toBeInTheDocument(); + expect(screen.queryByText('No documents are available')).not.toBeInTheDocument(); + expect( + screen.getByText('7 files | File size: 7 bytes | File format: PDF'), + ).toBeInTheDocument(); + }); +}); + +const renderPage = () => { + const auth: Session = { + auth: buildUserAuth(), + isLoggedIn: true, + }; + render( + + + + + , + , + ); +}; diff --git a/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx new file mode 100644 index 000000000..f21fd26dd --- /dev/null +++ b/app/src/pages/lloydGeorgeRecordPage/LloydGeorgeRecordPage.tsx @@ -0,0 +1,128 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { usePatientDetailsContext } from '../../providers/patientProvider/PatientProvider'; +import { getFormattedDate } from '../../helpers/utils/formatDate'; +import { useNavigate } from 'react-router'; +import { Card, Details } from 'nhsuk-react-components'; +import { useBaseAPIUrl } from '../../providers/configProvider/ConfigProvider'; +import getLloydGeorgeRecord from '../../helpers/requests/getLloydGeorgeRecord'; +import PdfViewer from '../../components/generic/pdfViewer/PdfViewer'; +import { getFormattedDatetime } from '../../helpers/utils/formatDatetime'; +import { DOWNLOAD_STAGE } from '../../types/generic/downloadStage'; +import formatFileSize from '../../helpers/utils/formatFileSize'; +import useBaseAPIHeaders from '../../helpers/hooks/useBaseAPIHeaders'; + +function LloydGeorgeRecordPage() { + const [patientDetails] = usePatientDetailsContext(); + const [downloadStage, setDownloadStage] = useState(DOWNLOAD_STAGE.INITIAL); + const [numberOfFiles, setNumberOfFiles] = useState(0); + const [totalFileSizeInByte, setTotalFileSizeInByte] = useState(0); + const [lastUpdated, setLastUpdated] = useState(''); + const [lloydGeorgeUrl, setLloydGeorgeUrl] = useState(''); + const navigate = useNavigate(); + const baseUrl = useBaseAPIUrl(); + const baseHeaders = useBaseAPIHeaders(); + const mounted = useRef(false); + + const dob: String = patientDetails?.birthDate + ? getFormattedDate(new Date(patientDetails.birthDate)) + : ''; + + const nhsNumber: String = + patientDetails?.nhsNumber.slice(0, 3) + + ' ' + + patientDetails?.nhsNumber.slice(3, 6) + + ' ' + + patientDetails?.nhsNumber.slice(6, 10); + + const patientInfo = ( + <> +

+ {`${patientDetails?.givenName} ${patientDetails?.familyName}`} +

+

NHS number: {nhsNumber}

+

Date of birth: {dob}

+ + ); + + useEffect(() => { + const search = async () => { + setDownloadStage(DOWNLOAD_STAGE.PENDING); + const nhsNumber: string = patientDetails?.nhsNumber || ''; + try { + const { number_of_files, total_file_size_in_byte, last_updated, presign_url } = + await getLloydGeorgeRecord({ + nhsNumber, + baseUrl, + baseHeaders, + }); + if (presign_url?.startsWith('https://')) { + setNumberOfFiles(number_of_files); + setLastUpdated(getFormattedDatetime(new Date(last_updated))); + setLloydGeorgeUrl(presign_url); + setDownloadStage(DOWNLOAD_STAGE.SUCCEEDED); + setTotalFileSizeInByte(total_file_size_in_byte); + } + setDownloadStage(DOWNLOAD_STAGE.SUCCEEDED); + } catch (e) { + setDownloadStage(DOWNLOAD_STAGE.FAILED); + } + mounted.current = true; + }; + if (!mounted.current) { + void search(); + } + }, [ + patientDetails, + baseUrl, + baseHeaders, + navigate, + setDownloadStage, + setLloydGeorgeUrl, + setLastUpdated, + setNumberOfFiles, + setTotalFileSizeInByte, + ]); + + const pdfCardDescription = ( + <> +

Last updated: {lastUpdated}

+

+ {numberOfFiles} files | File size: {formatFileSize(totalFileSizeInByte)} | File + format: PDF +

+ + ); + const displayPdfCardDescription = () => { + if (downloadStage === DOWNLOAD_STAGE.SUCCEEDED) { + return pdfCardDescription; + } else if (downloadStage === DOWNLOAD_STAGE.FAILED) { + return <>No documents are available; + } else { + return <>Loading...; + } + }; + + return ( + <> + <>{patientInfo} + + + + Lloyd George record + + + {displayPdfCardDescription()} + + + + {downloadStage === DOWNLOAD_STAGE.SUCCEEDED && ( +
+ View record + +
+ )} + + ); +} + +export default LloydGeorgeRecordPage; diff --git a/app/src/pages/patientResultPage/PatientResultPage.test.tsx b/app/src/pages/patientResultPage/PatientResultPage.test.tsx index af53afdd9..34026cab6 100644 --- a/app/src/pages/patientResultPage/PatientResultPage.test.tsx +++ b/app/src/pages/patientResultPage/PatientResultPage.test.tsx @@ -8,6 +8,7 @@ import * as ReactRouter from 'react-router'; import { createMemoryHistory } from 'history'; import userEvent from '@testing-library/user-event'; import { routes } from '../../types/generic/routes'; +import { act } from 'react-dom/test-utils'; describe('PatientResultPage', () => { afterEach(() => { @@ -58,9 +59,16 @@ describe('PatientResultPage', () => { expect( screen.getByRole('heading', { name: 'Verify patient details' }), ).toBeInTheDocument(); + + expect( + screen.getByText('What is the current status of the patient?'), + ).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Active patient' })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: 'Inactive patient' })).toBeInTheDocument(); + expect( screen.getByText( - 'Ensure these patient details match the electronic health records and attachments you are about to upload.', + 'Ensure these patient details match the records and attachments that you upload', ), ).toBeInTheDocument(); }); @@ -74,6 +82,13 @@ describe('PatientResultPage', () => { expect( screen.getByRole('heading', { name: 'Verify patient details' }), ).toBeInTheDocument(); + + expect(screen.queryByText('Select patient status')).not.toBeInTheDocument(); + expect(screen.queryByRole('radio', { name: 'Active patient' })).not.toBeInTheDocument(); + expect( + screen.queryByRole('radio', { name: 'Inactive patient' }), + ).not.toBeInTheDocument(); + expect( screen.queryByText( 'Ensure these patient details match the electronic health records and attachments you are about to upload.', @@ -117,7 +132,26 @@ describe('PatientResultPage', () => { }); describe('Navigation', () => { - it('navigates to upload page when user has verified upload patient', async () => { + it('navigates to LG record page when GP user selects Active patient and submits', async () => { + const history = createMemoryHistory({ + initialEntries: ['/example'], + initialIndex: 1, + }); + + const uploadRole = USER_ROLE.GP; + + renderPatientResultPage({}, uploadRole, history); + expect(history.location.pathname).toBe('/example'); + + userEvent.click(screen.getByRole('radio', { name: 'Active patient' })); + userEvent.click(screen.getByRole('button', { name: 'Accept details are correct' })); + + await waitFor(() => { + expect(history.location.pathname).toBe(routes.LLOYD_GEORGE); + }); + }); + + it('navigates to Upload docs page when GP user selects Inactive patient and submits', async () => { const history = createMemoryHistory({ initialEntries: ['/example'], initialIndex: 1, @@ -128,12 +162,14 @@ describe('PatientResultPage', () => { renderPatientResultPage({}, uploadRole, history); expect(history.location.pathname).toBe('/example'); + userEvent.click(screen.getByRole('radio', { name: 'Inactive patient' })); userEvent.click(screen.getByRole('button', { name: 'Accept details are correct' })); await waitFor(() => { expect(history.location.pathname).toBe(routes.UPLOAD_DOCUMENTS); }); }); + it('navigates to download page when user has verified download patient', async () => { const history = createMemoryHistory({ initialEntries: ['/example'], diff --git a/app/src/pages/patientResultPage/PatientResultPage.tsx b/app/src/pages/patientResultPage/PatientResultPage.tsx index 929d56f5a..f286cea78 100644 --- a/app/src/pages/patientResultPage/PatientResultPage.tsx +++ b/app/src/pages/patientResultPage/PatientResultPage.tsx @@ -1,11 +1,13 @@ -import React from 'react'; +import React, { useState } from 'react'; import { USER_ROLE } from '../../types/generic/roles'; -import { Button, WarningCallout } from 'nhsuk-react-components'; +import { Button, Fieldset, Radios, WarningCallout } from 'nhsuk-react-components'; import { useNavigate } from 'react-router'; import { routes } from '../../types/generic/routes'; import PatientSummary from '../../components/generic/patientSummary/PatientSummary'; import { usePatientDetailsContext } from '../../providers/patientProvider/PatientProvider'; import BackButton from '../../components/generic/backButton/BackButton'; +import { FieldValues, useForm } from 'react-hook-form'; +import ErrorBox from '../../components/layout/errorBox/ErrorBox'; type Props = { role: USER_ROLE; @@ -16,11 +18,23 @@ function PatientResultPage({ role }: Props) { const userIsGP = role === USER_ROLE.GP; const [patientDetails] = usePatientDetailsContext(); const navigate = useNavigate(); + const [inputError, setInputError] = useState(''); + const { register, handleSubmit, formState, getFieldState } = useForm(); + const { ref: patientStatusRef, ...radioProps } = register('patientStatus'); + const { isDirty: isPatientStatusDirty } = getFieldState('patientStatus', formState); - const handleVerify = () => { + const submit = (fieldValues: FieldValues) => { if (userIsGP) { // Make PDS patient search request to upload documents to patient - navigate(routes.UPLOAD_DOCUMENTS); + if (!isPatientStatusDirty) { + setInputError('Select a patient status'); + return; + } + if (fieldValues.patientStatus === 'active') { + navigate(routes.LLOYD_GEORGE); + } else if (fieldValues.patientStatus === 'inactive') { + navigate(routes.UPLOAD_DOCUMENTS); + } } // PCSE Role @@ -29,10 +43,20 @@ function PatientResultPage({ role }: Props) { navigate(routes.DOWNLOAD_DOCUMENTS); } }; + return ( -
+
+ {inputError && ( + + )}

Verify patient details

+ {patientDetails && (patientDetails.superseded || patientDetails.restricted) && ( Information @@ -47,17 +71,46 @@ function PatientResultPage({ role }: Props) { )} )} + {patientDetails && } - {userIsGP && ( -

- Ensure these patient details match the electronic health records and attachments - you are about to upload. -

- )} - +
+ {userIsGP && ( + <> +
+ +

What is the current status of the patient?

+
+ + + Active patient + + + Inactive patient + + +
+ +

+ Ensure these patient details match the records and attachments that you + upload +

+ + )} + +

If patient details are incorrect, please contact the{' '} (UPLOAD_STAGE.Selecting); const [documents, setDocuments] = useState>([]); @@ -37,18 +38,14 @@ function UploadDocumentsPage(props: Props) { const uploadDocuments = async () => { if (patientDetails) { setStage(UPLOAD_STAGE.Uploading); - await Promise.all( - documents.map((document) => - uploadDocument({ - nhsNumber: patientDetails.nhsNumber, - docType: DOCUMENT_TYPE.LLOYD_GEORGE, - setDocumentState, - documents, - baseUrl, - baseHeaders, - }), - ), - ); + await uploadDocument({ + nhsNumber: patientDetails.nhsNumber, + docType: DOCUMENT_TYPE.LLOYD_GEORGE, + setDocumentState, + documents, + baseUrl, + baseHeaders, + }); setStage(UPLOAD_STAGE.Complete); } }; diff --git a/app/src/types/generic/downloadStage.ts b/app/src/types/generic/downloadStage.ts new file mode 100644 index 000000000..4ed97c103 --- /dev/null +++ b/app/src/types/generic/downloadStage.ts @@ -0,0 +1,6 @@ +export enum DOWNLOAD_STAGE { + INITIAL = 'INITIAL', + PENDING = 'PENDING', + SUCCEEDED = 'SUCCEEDED', + FAILED = 'FAILED', +} diff --git a/app/src/types/generic/routes.ts b/app/src/types/generic/routes.ts index f170e8d20..f80ea2feb 100644 --- a/app/src/types/generic/routes.ts +++ b/app/src/types/generic/routes.ts @@ -11,6 +11,8 @@ export enum routes { DOWNLOAD_DOCUMENTS = '/search/results', DELETE_DOCUMENTS = '/search/results/delete', + LLOYD_GEORGE = '/search/patient/lloyd-george-record', + UPLOAD_SEARCH = '/search/upload', UPLOAD_VERIFY = '/search/upload/result', UPLOAD_DOCUMENTS = '/upload/submit', diff --git a/lambdas/handlers/lloyd_george_record_stitch_handler.py b/lambdas/handlers/lloyd_george_record_stitch_handler.py new file mode 100755 index 000000000..c46779723 --- /dev/null +++ b/lambdas/handlers/lloyd_george_record_stitch_handler.py @@ -0,0 +1,178 @@ +import json +import logging +import os +import tempfile +from urllib import parse +from urllib.parse import urlparse + +from botocore.exceptions import ClientError +from enums.metadata_field_names import DocumentReferenceMetadataFields +from pypdf.errors import PyPdfError +from services.dynamo_service import DynamoDBService +from services.pdf_stitch_service import stitch_pdf +from services.s3_service import S3Service +from utils.decorators.ensure_env_var import ensure_environment_variables +from utils.decorators.validate_patient_id import ( + extract_nhs_number_from_event, validate_patient_id) +from utils.exceptions import DynamoDbException +from utils.lambda_response import ApiGatewayResponse +from utils.order_response_by_filenames import order_response_by_filenames + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +@validate_patient_id +@ensure_environment_variables( + names=["LLOYD_GEORGE_DYNAMODB_NAME", "LLOYD_GEORGE_BUCKET_NAME"] +) +def lambda_handler(event, context): + nhs_number = extract_nhs_number_from_event(event) + lloyd_george_table_name = os.environ["LLOYD_GEORGE_DYNAMODB_NAME"] + lloyd_george_bucket_name = os.environ["LLOYD_GEORGE_BUCKET_NAME"] + + try: + response = get_lloyd_george_records_for_patient( + lloyd_george_table_name, nhs_number + ) + if len(response["Items"]) == 0: + return ApiGatewayResponse( + 404, f"Lloyd george record not found for patient {nhs_number}", "GET" + ).create_api_gateway_response() + + ordered_lg_records = order_response_by_filenames(response["Items"]) + + s3_service = S3Service() + all_lg_parts = download_lloyd_george_files( + lloyd_george_bucket_name, ordered_lg_records, s3_service + ) + except (ClientError, DynamoDbException) as e: + logger.error(e) + return ApiGatewayResponse( + 500, f"Unable to retrieve documents for patient {nhs_number}", "GET" + ).create_api_gateway_response() + + try: + filename_for_stitched_file = make_filename_for_stitched_file(response["Items"]) + stitched_lg_record = stitch_pdf(all_lg_parts) + + number_of_files = len(response["Items"]) + last_updated = get_most_recent_created_date(response["Items"]) + total_file_size = get_total_file_size(all_lg_parts) + presign_url = upload_stitched_lg_record_and_retrieve_presign_url( + stitched_lg_record=stitched_lg_record, + filename_on_bucket=f"{nhs_number}/{filename_for_stitched_file}", + upload_bucket_name=lloyd_george_bucket_name, + s3_service=s3_service, + ) + response = json.dumps( + { + "number_of_files": number_of_files, + "last_updated": last_updated, + "presign_url": presign_url, + "total_file_size_in_byte": total_file_size, + } + ) + return ApiGatewayResponse(200, response, "GET").create_api_gateway_response() + except (ClientError, PyPdfError, FileNotFoundError) as e: + logger.error(e) + return ApiGatewayResponse( + 500, + "Unable to return stitched pdf file due to internal error", + "GET", + ).create_api_gateway_response() + + +def get_lloyd_george_records_for_patient( + lloyd_george_table_name: str, nhs_number: str +) -> dict: + try: + dynamo_service = DynamoDBService() + response = dynamo_service.query_service( + lloyd_george_table_name, + "NhsNumberIndex", + "NhsNumber", + nhs_number, + [ + DocumentReferenceMetadataFields.ID, + DocumentReferenceMetadataFields.FILE_LOCATION, + DocumentReferenceMetadataFields.NHS_NUMBER, + DocumentReferenceMetadataFields.FILE_NAME, + DocumentReferenceMetadataFields.CREATED, + ], + ) + if response is None or ("Items" not in response): + logger.error(f"Unrecognised response from DynamoDB: {response}") + raise DynamoDbException("Unrecognised response from DynamoDB") + return response + except ClientError as e: + logger.error(e) + raise DynamoDbException("Unexpected error when getting Lloyd George record") + + +def download_lloyd_george_files( + lloyd_george_bucket_name: str, ordered_lg_records: list[dict], s3_service: S3Service +) -> list[str]: + all_lg_parts = [] + temp_folder = tempfile.mkdtemp() + for lg_part in ordered_lg_records: + file_location_on_s3 = lg_part[ + DocumentReferenceMetadataFields.FILE_LOCATION.field_name + ] + original_file_name = lg_part[DocumentReferenceMetadataFields.FILE_NAME.field_name] # fmt: skip + + s3_file_name = urlparse(file_location_on_s3).path.lstrip("/") + + local_file_name = os.path.join(temp_folder, original_file_name) + s3_service.download_file( + lloyd_george_bucket_name, s3_file_name, local_file_name + ) + all_lg_parts.append(local_file_name) + return all_lg_parts + + +def make_filename_for_stitched_file(dynamo_response: list[dict]) -> str: + # Build a filename with this pattern: + # Combined_Lloyd_George_Record_[Joe Bloggs]_[1234567890]_[25-12-2019].pdf + + filename_key = DocumentReferenceMetadataFields.FILE_NAME.field_name + base_filename = dynamo_response[0][filename_key] + end_of_total_page_numbers = base_filename.index("_") + + return "Combined" + base_filename[end_of_total_page_numbers:] + + +def get_most_recent_created_date(dynamo_response: list[dict]) -> str: + created_date_key = DocumentReferenceMetadataFields.CREATED.field_name + return max(lg_part[created_date_key] for lg_part in dynamo_response) + + +def get_total_file_size(filepaths: list[str]) -> int: + # Return the sum of a list of files (unit: byte) + return sum(os.path.getsize(filepath) for filepath in filepaths) + + +def upload_stitched_lg_record_and_retrieve_presign_url( + stitched_lg_record: str, + filename_on_bucket: str, + upload_bucket_name: str, + s3_service: S3Service, +): + lifecycle_policy_tag = os.environ.get( + "STITCHED_FILE_LIFECYCLE_POLICY_TAG", "autodelete" + ) + extra_args = { + "Tagging": parse.urlencode({lifecycle_policy_tag: "true"}), + "ContentDisposition": "inline", + "ContentType": "application/pdf", + } + s3_service.upload_file_with_extra_args( + file_name=stitched_lg_record, + s3_bucket_name=upload_bucket_name, + file_key=filename_on_bucket, + extra_args=extra_args, + ) + presign_url_response = s3_service.create_download_presigned_url( + s3_bucket_name=upload_bucket_name, file_key=filename_on_bucket + ) + return presign_url_response diff --git a/lambdas/requirements.txt b/lambdas/requirements.txt index c7c1cbe05..02aea1f0c 100644 --- a/lambdas/requirements.txt +++ b/lambdas/requirements.txt @@ -12,6 +12,7 @@ oauthlib==3.2.2 PyJWT==2.8.0 pycparser==2.21 pydantic==2.3.0 +pypdf==3.16.2 python-dateutil==2.8.2 PyYAML==6.0.1 requests==2.31.0 diff --git a/lambdas/services/dynamo_service.py b/lambdas/services/dynamo_service.py index d2b071ae2..d86e3f98f 100644 --- a/lambdas/services/dynamo_service.py +++ b/lambdas/services/dynamo_service.py @@ -11,7 +11,7 @@ class DynamoDBService: def __init__(self): - self.dynamodb = boto3.resource("dynamodb") + self.dynamodb = boto3.resource("dynamodb", region_name="eu-west-2") def get_table(self, table_name): try: diff --git a/lambdas/services/pdf_stitch_service.py b/lambdas/services/pdf_stitch_service.py new file mode 100755 index 000000000..dfb201ab8 --- /dev/null +++ b/lambdas/services/pdf_stitch_service.py @@ -0,0 +1,22 @@ +import logging +from uuid import uuid4 + +from pypdf import PdfReader, PdfWriter + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def stitch_pdf(filenames: list[str]) -> str: + # Given a list of local pdf files, stitch them into one file and return the local file path of resulting file. + # Using /tmp/ as it is the only writable location on lambdas. + merger = PdfWriter() + for filename in filenames: + merger.append(filename) + output_filename = f"/tmp/{str(uuid4())}.pdf" + merger.write(output_filename) + return output_filename + + +def count_page_number(filename: str) -> int: + return len(PdfReader(filename).pages) diff --git a/lambdas/services/s3_service.py b/lambdas/services/s3_service.py index ec6a7d858..c2a4b8c43 100644 --- a/lambdas/services/s3_service.py +++ b/lambdas/services/s3_service.py @@ -1,4 +1,5 @@ import logging +from typing import Any, Mapping import boto3 from botocore.client import Config as BotoConfig @@ -39,3 +40,12 @@ def download_file(self, s3_bucket_name: str, file_key: str, download_path: str): def upload_file(self, file_name: str, s3_bucket_name: str, file_key: str): return self.client.upload_file(file_name, s3_bucket_name, file_key) + + def upload_file_with_extra_args( + self, + file_name: str, + s3_bucket_name: str, + file_key: str, + extra_args: Mapping[str, Any], + ): + return self.client.upload_file(file_name, s3_bucket_name, file_key, extra_args) diff --git a/lambdas/tests/unit/handlers/test_lloyd_george_record_stitch_handler.py b/lambdas/tests/unit/handlers/test_lloyd_george_record_stitch_handler.py new file mode 100755 index 000000000..66647efdf --- /dev/null +++ b/lambdas/tests/unit/handlers/test_lloyd_george_record_stitch_handler.py @@ -0,0 +1,241 @@ +import json +import tempfile +from unittest.mock import patch + +import pypdf.errors +import pytest +from botocore.exceptions import ClientError +from handlers.lloyd_george_record_stitch_handler import lambda_handler +from services.dynamo_service import DynamoDBService +from tests.unit.conftest import MOCK_LG_BUCKET +from tests.unit.services.test_s3_service import MOCK_PRESIGNED_URL_RESPONSE +from utils.lambda_response import ApiGatewayResponse + + +def test_respond_200_with_presign_url( + valid_id_event, + context, + set_env, + mock_dynamo_db, + mock_s3, + mock_stitch_pdf, + mock_get_total_file_size, +): + actual = lambda_handler(valid_id_event, context) + + expected_response_object = { + "number_of_files": 3, + "last_updated": "2023-10-02T09:46:17.231923Z", + "presign_url": MOCK_PRESIGNED_URL_RESPONSE, + "total_file_size_in_byte": MOCK_TOTAL_FILE_SIZE, + } + expected = ApiGatewayResponse( + 200, json.dumps(expected_response_object), "GET" + ).create_api_gateway_response() + + assert actual == expected + + +def test_aws_services_are_correctly_called( + joe_bloggs_event, + context, + set_env, + mock_dynamo_db, + mock_s3, + mock_stitch_pdf, + mock_tempfile, + mock_get_total_file_size, +): + lambda_handler(joe_bloggs_event, context) + + mock_dynamo_db.assert_called_once() + + assert mock_s3.download_file.call_count == len(MOCK_LG_DYNAMODB_RESPONSE["Items"]) + for mock_record in MOCK_LG_DYNAMODB_RESPONSE["Items"]: + file_name_on_s3 = mock_record["NhsNumber"] + "/" + mock_record["ID"] + local_filename = "/tmp/" + mock_record["FileName"] + mock_s3.download_file.assert_any_call( + MOCK_LG_BUCKET, file_name_on_s3, local_filename + ) + + mock_s3.upload_file_with_extra_args.assert_called_with( + file_key="1234567890/Combined_Lloyd_George_Record_[Joe Bloggs]_[1234567890]_[25-12-2019].pdf", + file_name=MOCK_STITCHED_FILE, + s3_bucket_name=MOCK_LG_BUCKET, + extra_args={ + "Tagging": "autodelete=true", + "ContentDisposition": "inline", + "ContentType": "application/pdf", + }, + ) + + +def test_respond_400_throws_error_when_no_nhs_number_supplied( + missing_id_event, context +): + actual = lambda_handler(missing_id_event, context) + expected = ApiGatewayResponse( + 400, "An error occurred due to missing key: 'patientId'", "GET" + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_500_throws_error_when_environment_variables_not_set( + joe_bloggs_event, context +): + actual = lambda_handler(joe_bloggs_event, context) + expected = ApiGatewayResponse( + 500, + "An error occurred due to missing key: 'LLOYD_GEORGE_DYNAMODB_NAME'", + "GET", + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_400_throws_error_when_nhs_number_not_valid(invalid_id_event, context): + actual = lambda_handler(invalid_id_event, context) + expected = ApiGatewayResponse( + 400, "Invalid NHS number", "GET" + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_500_throws_error_when_dynamo_service_fails_to_connect( + joe_bloggs_event, context, set_env, mock_dynamo_db +): + mock_dynamo_db.side_effect = MOCK_CLIENT_ERROR + actual = lambda_handler(joe_bloggs_event, context) + expected = ApiGatewayResponse( + 500, "Unable to retrieve documents for patient 1234567890", "GET" + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_500_throws_error_when_fail_to_download_lloyd_george_file( + joe_bloggs_event, context, set_env, mock_dynamo_db, mock_s3 +): + mock_s3.download_file.side_effect = MOCK_CLIENT_ERROR + actual = lambda_handler(joe_bloggs_event, context) + expected = ApiGatewayResponse( + 500, "Unable to retrieve documents for patient 1234567890", "GET" + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_404_throws_error_when_no_lloyd_george_for_patient_in_record( + valid_id_event, context, set_env, mock_dynamo_db +): + mock_dynamo_db.return_value = MOCK_LG_DYNAMODB_RESPONSE_NO_RECORD + actual = lambda_handler(valid_id_event, context) + expected = ApiGatewayResponse( + 404, "Lloyd george record not found for patient 9000000009", "GET" + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_500_throws_error_when_fail_to_stitch_lloyd_george_file( + valid_id_event, context, set_env, mock_dynamo_db, mock_s3, mock_stitch_pdf +): + mock_stitch_pdf.side_effect = pypdf.errors.ParseError + + actual = lambda_handler(valid_id_event, context) + expected = ApiGatewayResponse( + 500, "Unable to return stitched pdf file due to internal error", "GET" + ).create_api_gateway_response() + assert actual == expected + + +def test_respond_500_throws_error_when_fail_to_upload_lloyd_george_file( + joe_bloggs_event, context, set_env, mock_dynamo_db, mock_s3, mock_stitch_pdf +): + mock_s3.upload_file_with_extra_args.side_effect = MOCK_CLIENT_ERROR + actual = lambda_handler(joe_bloggs_event, context) + expected = ApiGatewayResponse( + 500, "Unable to return stitched pdf file due to internal error", "GET" + ).create_api_gateway_response() + assert actual == expected + + +MOCK_CLIENT_ERROR = ClientError( + {"Error": {"Code": "500", "Message": "test error"}}, "testing" +) + +MOCK_LG_DYNAMODB_RESPONSE_NO_RECORD = {"Items": [], "Count": 0} + +MOCK_LG_DYNAMODB_RESPONSE = { + "Items": [ + { + "ID": "uuid_for_page_3", + "NhsNumber": "1234567890", + "FileLocation": f"s3://{MOCK_LG_BUCKET}/1234567890/uuid_for_page_3", + "FileName": "3of3_Lloyd_George_Record_[Joe Bloggs]_[1234567890]_[25-12-2019].pdf", + "Created": "2023-10-02T09:46:17.231923Z", + }, + { + "ID": "uuid_for_page_1", + "NhsNumber": "1234567890", + "FileLocation": f"s3://{MOCK_LG_BUCKET}/1234567890/uuid_for_page_1", + "FileName": "1of3_Lloyd_George_Record_[Joe Bloggs]_[1234567890]_[25-12-2019].pdf", + "Created": "2023-10-02T09:46:17.231921Z", + }, + { + "ID": "uuid_for_page_2", + "NhsNumber": "1234567890", + "FileLocation": f"s3://{MOCK_LG_BUCKET}/1234567890/uuid_for_page_2", + "FileName": "2of3_Lloyd_George_Record_[Joe Bloggs]_[1234567890]_[25-12-2019].pdf", + "Created": "2023-10-02T09:46:17.231922Z", + }, + ] +} + +MOCK_STITCHED_FILE = "filename_of_stitched_lg_in_local_storage.pdf" +MOCK_TOTAL_FILE_SIZE = 1024 * 256 + + +@pytest.fixture +def mock_dynamo_db(): + with patch.object(DynamoDBService, "query_service") as mocked_query_service: + mocked_query_service.return_value = MOCK_LG_DYNAMODB_RESPONSE + yield mocked_query_service + + +@pytest.fixture +def mock_s3(): + with patch("handlers.lloyd_george_record_stitch_handler.S3Service") as mock_class: + mock_s3_service_instance = mock_class.return_value + mock_s3_service_instance.create_download_presigned_url.return_value = ( + MOCK_PRESIGNED_URL_RESPONSE + ) + yield mock_s3_service_instance + + +@pytest.fixture +def mock_stitch_pdf(): + with patch( + "handlers.lloyd_george_record_stitch_handler.stitch_pdf" + ) as mocked_stitch_pdf: + mocked_stitch_pdf.return_value = MOCK_STITCHED_FILE + yield mocked_stitch_pdf + + +@pytest.fixture +def mock_tempfile(): + with patch.object(tempfile, "mkdtemp", return_value="/tmp/"): + yield + + +@pytest.fixture +def joe_bloggs_event(): + api_gateway_proxy_event = { + "queryStringParameters": {"patientId": "1234567890"}, + } + return api_gateway_proxy_event + + +@pytest.fixture +def mock_get_total_file_size(): + with patch( + "handlers.lloyd_george_record_stitch_handler.get_total_file_size" + ) as patched: + patched.return_value = MOCK_TOTAL_FILE_SIZE + yield patched diff --git a/lambdas/tests/unit/helpers/data/pdf/file1.pdf b/lambdas/tests/unit/helpers/data/pdf/file1.pdf new file mode 100644 index 000000000..0319721ae Binary files /dev/null and b/lambdas/tests/unit/helpers/data/pdf/file1.pdf differ diff --git a/lambdas/tests/unit/helpers/data/pdf/file2.pdf b/lambdas/tests/unit/helpers/data/pdf/file2.pdf new file mode 100644 index 000000000..ec54a44aa Binary files /dev/null and b/lambdas/tests/unit/helpers/data/pdf/file2.pdf differ diff --git a/lambdas/tests/unit/helpers/data/pdf/file3.pdf b/lambdas/tests/unit/helpers/data/pdf/file3.pdf new file mode 100644 index 000000000..d42d59a41 Binary files /dev/null and b/lambdas/tests/unit/helpers/data/pdf/file3.pdf differ diff --git a/lambdas/tests/unit/services/test_pdf_stitch_service.py b/lambdas/tests/unit/services/test_pdf_stitch_service.py new file mode 100644 index 000000000..1b8ccb572 --- /dev/null +++ b/lambdas/tests/unit/services/test_pdf_stitch_service.py @@ -0,0 +1,26 @@ +import os + +import pytest +from services.pdf_stitch_service import count_page_number, stitch_pdf + + +def test_stitch_pdf(): + test_pdf_folder = "tests/unit/helpers/data/pdf/" + input_test_files = [ + f"{test_pdf_folder}/{filename}" + for filename in ["file1.pdf", "file2.pdf", "file3.pdf"] + ] + + stitched_file = stitch_pdf(input_test_files) + assert count_page_number(stitched_file) == sum( + count_page_number(filepath) for filepath in input_test_files + ) + + os.remove(stitched_file) + + +def test_stitch_pdf_raise_error_when_input_file_not_found(): + test_file = "non-exist-file.pdf" + + with pytest.raises(FileNotFoundError): + stitch_pdf([test_file]) diff --git a/lambdas/tests/unit/services/test_s3_service.py b/lambdas/tests/unit/services/test_s3_service.py index c9d64dd56..711fd6a2e 100755 --- a/lambdas/tests/unit/services/test_s3_service.py +++ b/lambdas/tests/unit/services/test_s3_service.py @@ -88,3 +88,19 @@ def test_upload_file(mocker): service.upload_file(TEST_FILE_NAME, MOCK_BUCKET, TEST_FILE_KEY) mock_upload_file.assert_called_once_with(TEST_FILE_NAME, MOCK_BUCKET, TEST_FILE_KEY) + + +def test_upload_file_with_extra_args(mocker): + mocker.patch("boto3.client") + service = S3Service() + mock_upload_file = mocker.patch.object(service.client, "upload_file") + + test_extra_args = {"mock_tag": 123, "apple": "red", "banana": "true"} + + service.upload_file_with_extra_args( + TEST_FILE_NAME, MOCK_BUCKET, TEST_FILE_KEY, test_extra_args + ) + + mock_upload_file.assert_called_once_with( + TEST_FILE_NAME, MOCK_BUCKET, TEST_FILE_KEY, test_extra_args + ) diff --git a/lambdas/tests/unit/utils/decorators/conftest.py b/lambdas/tests/unit/utils/decorators/conftest.py new file mode 100644 index 000000000..0eecebf5e --- /dev/null +++ b/lambdas/tests/unit/utils/decorators/conftest.py @@ -0,0 +1,40 @@ +from dataclasses import dataclass + +import pytest + + +@pytest.fixture +def valid_id_event(): + api_gateway_proxy_event = { + "queryStringParameters": {"patientId": "9000000009"}, + } + return api_gateway_proxy_event + + +@pytest.fixture +def invalid_id_event(): + api_gateway_proxy_event = { + "queryStringParameters": {"patientId": "900000000900"}, + } + return api_gateway_proxy_event + + +@pytest.fixture +def missing_id_event(): + api_gateway_proxy_event = { + "queryStringParameters": {"invalid": ""}, + } + return api_gateway_proxy_event + + +@pytest.fixture +def context(): + @dataclass + class LambdaContext: + function_name: str = "test" + aws_request_id: str = "88888888-4444-4444-4444-121212121212" + invoked_function_arn: str = ( + "arn:aws:lambda:eu-west-1:123456789101:function:test" + ) + + return LambdaContext() diff --git a/lambdas/tests/unit/utils/decorators/test_validate_patient_id.py b/lambdas/tests/unit/utils/decorators/test_validate_patient_id.py new file mode 100644 index 000000000..cdfc35661 --- /dev/null +++ b/lambdas/tests/unit/utils/decorators/test_validate_patient_id.py @@ -0,0 +1,43 @@ +from unittest.mock import MagicMock + +from utils.decorators.validate_patient_id import validate_patient_id +from utils.lambda_response import ApiGatewayResponse + +actual_lambda_logic = MagicMock() + + +@validate_patient_id +def lambda_handler(event, _context): + actual_lambda_logic() + return "200 OK" + + +def test_respond_with_400_when_patient_id_missing(missing_id_event, context): + expected = ApiGatewayResponse( + 400, "An error occurred due to missing key: 'patientId'", "GET" + ).create_api_gateway_response() + + actual = lambda_handler(missing_id_event, context) + + assert actual == expected + actual_lambda_logic.assert_not_called() + + +def test_respond_with_400_when_patient_id_invalid(invalid_id_event, context): + expected = ApiGatewayResponse( + 400, "Invalid NHS number", "GET" + ).create_api_gateway_response() + + actual = lambda_handler(invalid_id_event, context) + + assert actual == expected + actual_lambda_logic.assert_not_called() + + +def test_respond_with_lambda_response_when_patient_id_is_valid(valid_id_event, context): + expected = "200 OK" + + actual = lambda_handler(valid_id_event, context) + + assert actual == expected + actual_lambda_logic.assert_called_once() diff --git a/lambdas/tests/unit/utils/test_order_response_by_filenames.py b/lambdas/tests/unit/utils/test_order_response_by_filenames.py new file mode 100644 index 000000000..229a618c9 --- /dev/null +++ b/lambdas/tests/unit/utils/test_order_response_by_filenames.py @@ -0,0 +1,69 @@ +import logging + +from utils.order_response_by_filenames import order_response_by_filenames + + +def build_expected_output(total_page_number: int) -> list[dict]: + output = [] + for i in range(total_page_number): + output.append(build_dynamo_response_item(i + 1, total_page_number)) + return output + + +def build_dynamo_response_item(curr_page_number: int, total_page_number: int) -> dict: + return { + "ID": "some_uuid", + "NhsNumber": "1234567890", + "FileLocation": "s3://ndr-dev-lloyd-george-store/9e9867f0-9767-402d-a4d6-c1af4575a6bf", + "FileName": f"{curr_page_number}of{total_page_number}" + f"_Lloyd_George_Record_[Joe Bloggs]_[123456789]_[25-12-2019].pdf", + } + + +def test_order_response_by_filenames_base_case(): + dynamo_response_not_in_order = [ + build_dynamo_response_item(curr_page_number=i, total_page_number=3) + for i in [3, 1, 2] + ] + + expected = build_expected_output(total_page_number=3) + actual = order_response_by_filenames(dynamo_response_not_in_order) + + assert actual == expected + + +def test_order_response_by_filenames_more_then_10_pages(): + dynamo_response_not_in_order = [ + build_dynamo_response_item(curr_page_number=i, total_page_number=15) + for i in [6, 7, 10, 11, 12, 1, 8, 3, 4, 5, 13, 9, 2, 14, 15] + ] + + expected = build_expected_output(total_page_number=15) + actual = order_response_by_filenames(dynamo_response_not_in_order) + + assert actual == expected + + +def test_order_response_by_filenames_missing_page(caplog): + dynamo_response_missing_page_10_to_12 = [ + build_dynamo_response_item(curr_page_number=i, total_page_number=15) + for i in [6, 7, 1, 8, 3, 4, 5, 13, 9, 2, 14, 15] + ] + all_pages_in_order = build_expected_output(total_page_number=15) + expected = all_pages_in_order[0:9] + all_pages_in_order[12:] + + actual = order_response_by_filenames(dynamo_response_missing_page_10_to_12) + + assert actual == expected + + +def test_warning_message_logged_when_some_pages_missing(caplog): + dynamo_response_missing_page_10_to_12 = [ + build_dynamo_response_item(curr_page_number=i, total_page_number=15) + for i in [6, 7, 1, 8, 3, 4, 5, 13, 9, 2, 14, 15] + ] + with caplog.at_level(logging.INFO): + order_response_by_filenames(dynamo_response_missing_page_10_to_12) + + assert caplog.records[-1].message == "Some pages of the Lloyd George document appear missing" # fmt: skip + assert caplog.records[-1].levelname == "WARNING" diff --git a/lambdas/utils/decorators/__init__.py b/lambdas/utils/decorators/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lambdas/utils/decorators/ensure_env_var.py b/lambdas/utils/decorators/ensure_env_var.py new file mode 100644 index 000000000..341ef171c --- /dev/null +++ b/lambdas/utils/decorators/ensure_env_var.py @@ -0,0 +1,36 @@ +import logging +import os +from typing import Callable + +from utils.lambda_response import ApiGatewayResponse + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def ensure_environment_variables(names: list[str]) -> Callable: + """A decorator for lambda handler. + Verify that the lambda environment got a set of specific environment variables. + If not, returns a 500 Internal server error response and log the missing env var. + + Usage: + @ensure_environment_variables(names=["LLOYD_GEORGE_BUCKET_NAME", "LLOYD_GEORGE_DYNAMODB_NAME"]) + def lambda_handler(event, context): + ... + """ + + def wrapper(lambda_func: Callable): + def interceptor(event, context): + for name in names: + if name not in os.environ: + logger.info(f"missing env var: '{name}'") + return ApiGatewayResponse( + 500, f"An error occurred due to missing key: '{name}'", "GET" + ).create_api_gateway_response() + + # Validation done. Return control flow to original lambda handler + return lambda_func(event, context) + + return interceptor + + return wrapper diff --git a/lambdas/utils/decorators/validate_patient_id.py b/lambdas/utils/decorators/validate_patient_id.py new file mode 100644 index 000000000..285de552a --- /dev/null +++ b/lambdas/utils/decorators/validate_patient_id.py @@ -0,0 +1,40 @@ +from typing import Callable + +from utils.exceptions import InvalidResourceIdException +from utils.lambda_response import ApiGatewayResponse +from utils.utilities import validate_id + + +def extract_nhs_number_from_event(event) -> str: + # Reusable method to get nhs number from event. + return event["queryStringParameters"]["patientId"] + + +def validate_patient_id(lambda_func: Callable): + """A decorator for lambda handler. + Verify that the incoming event contains a valid patientId (nhs number). + If not, returns a 400 Bad request response before actual lambda was triggered. + + Usage: + @validate_patient_id + def lambda_handler(event, context): + ... + """ + + def interceptor(event, context): + try: + nhs_number = extract_nhs_number_from_event(event) + validate_id(nhs_number) + except InvalidResourceIdException: + return ApiGatewayResponse( + 400, "Invalid NHS number", "GET" + ).create_api_gateway_response() + except KeyError as e: + return ApiGatewayResponse( + 400, f"An error occurred due to missing key: {str(e)}", "GET" + ).create_api_gateway_response() + + # Validation done. Return control flow to original lambda handler + return lambda_func(event, context) + + return interceptor diff --git a/lambdas/utils/exceptions.py b/lambdas/utils/exceptions.py index 9b7960886..bc2eb751b 100644 --- a/lambdas/utils/exceptions.py +++ b/lambdas/utils/exceptions.py @@ -28,3 +28,15 @@ class DynamoDbException(Exception): class ManifestDownloadException(Exception): pass + + +class MissingEnvVarException(Exception): + pass + + +class InvalidParamException(Exception): + pass + + +class FileProcessingException(Exception): + pass diff --git a/lambdas/utils/order_response_by_filenames.py b/lambdas/utils/order_response_by_filenames.py new file mode 100644 index 000000000..83b88704e --- /dev/null +++ b/lambdas/utils/order_response_by_filenames.py @@ -0,0 +1,33 @@ +import logging + +from enums.metadata_field_names import DocumentReferenceMetadataFields + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +# this method assume the input has a key FileName with the Lloyd George naming convention like this: +# 1of3_Lloyd_George_Record_[Joe Bloggs]_[123456789]_[25-12-2019].pdf +def order_response_by_filenames(dynamodb_response: list[dict]) -> list[dict]: + filename_key = DocumentReferenceMetadataFields.FILE_NAME.field_name + sorted_response = sorted( + dynamodb_response, key=lambda item: extract_page_number(item[filename_key]) + ) + if len(dynamodb_response) != extract_total_pages( + dynamodb_response[0][filename_key] + ): + logger.warning("Some pages of the Lloyd George document appear missing") + return sorted_response + + +def extract_page_number(filename: str) -> int: + pos_to_trim = filename.index("of") + page_number_as_string = filename[0:pos_to_trim] + return int(page_number_as_string) + + +def extract_total_pages(filename: str) -> int: + start_pos = filename.index("of") + 2 + end_pos = filename.index("_") + page_number_as_string = filename[start_pos:end_pos] + return int(page_number_as_string)