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.
-
- )}
-
+
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)