Skip to content

Commit cd92f8b

Browse files
committed
Merge branch 'master' into VED-270-terraform-fixes
2 parents 0171961 + 10886ab commit cd92f8b

22 files changed

+300
-181
lines changed

.github/workflows/continuous-disintegration.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
name: Teardown
22

33
on:
4-
pull_request:
4+
pull_request_target:
55
types: [closed]
66

77
jobs:

.github/workflows/sonarcloud.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ jobs:
4747
id: recordforwarder
4848
continue-on-error: true
4949
run: |
50+
pip install poetry==1.8.4 moto==5.1.4
5051
PYTHONPATH=$(pwd)/backend:$(pwd)/backend/tests poetry run coverage run --source=backend -m unittest discover -s backend/tests -p "*batch*.py" || echo "recordforwarder tests failed" >> failed_tests.txt
5152
poetry run coverage xml -o sonarcloud-coverage-recordforwarder-coverage.xml
5253
@@ -72,7 +73,7 @@ jobs:
7273
id: fhirapi
7374
continue-on-error: true
7475
run: |
75-
pip install poetry==1.8.4 moto==4.2.11 coverage redis botocore==1.35.49 simplejson responses structlog fhir.resources jsonpath_ng pydantic==1.10.13 requests aws-lambda-typing cffi pyjwt boto3-stubs-lite[dynamodb]~=1.26.90 python-stdnum==1.20
76+
pip install poetry==1.8.4 moto==5.1.4 coverage redis botocore==1.35.49 simplejson responses structlog fhir.resources jsonpath_ng pydantic==1.10.13 requests aws-lambda-typing cffi pyjwt boto3-stubs-lite[dynamodb]~=1.26.90 python-stdnum==1.20
7677
poetry run coverage run --source=backend -m unittest discover -s backend || echo "fhir-api tests failed" >> failed_tests.txt
7778
poetry run coverage xml -o sonarcloud-coverage.xml
7879

azure/azure-pr-teardown-pipeline.yml

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,6 @@ resources:
1010
name: NHSDigital/api-management-utils
1111
ref: refs/heads/edge
1212
endpoint: NHSDigital
13-
- repository: immunisation-fhir-api
14-
type: github
15-
name: NHSDigital/immunisation-fhir-api
16-
ref: master
17-
endpoint: NHSDigital
1813

1914
variables:
2015
- template: project.yml
@@ -27,7 +22,7 @@ jobs:
2722
name: 'AWS-ECS'
2823
vmImage: 'ubuntu-latest'
2924
steps:
30-
- checkout: immunisation-fhir-api
25+
- checkout: self
3126

3227
- bash: |
3328
echo $(action_pr_number)

backend/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ python = "~3.11"
1212
boto3 = "~1.38.17"
1313
boto3-stubs-lite = {extras = ["dynamodb"], version = "~1.26.90"}
1414
aws-lambda-typing = "~2.18.0"
15-
moto = "~5.1.4"
15+
moto = "^5.1.4"
1616
requests = "~2.31.0"
1717
responses = "~0.24.1"
1818
pydantic = "~1.10.13"

backend/src/models/utils/pre_validator_utils.py

Lines changed: 32 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import re
2-
from datetime import datetime
2+
from datetime import datetime, timedelta
33
from decimal import Decimal
44
from typing import Union
55

@@ -104,49 +104,47 @@ def for_date_time(field_value: str, field_location: str):
104104
containing a valid datetime. Note that partial dates are valid for FHIR, but are not allowed for this API.
105105
Valid formats are any of the following:
106106
* 'YYYY-MM-DD' - Full date only
107-
* 'YYYY-MM-DDT00:00:00+00:00' - Full date, time without milliseconds, timezone
108-
* 'YYYY-MM-DDT00:00:00.000+00:00' - Full date, time with milliseconds (any level of precision), timezone
107+
* 'YYYY-MM-DDThh:mm:ss' - Full date, time without milliseconds
108+
* 'YYYY-MM-DDThh:mm:ss.f' - Full date, time with milliseconds (any level of precision)
109+
* 'YYYY-MM-DDThh:mm:ss%z' - Full date, time without milliseconds, timezone
110+
* 'YYYY-MM-DDThh:mm:ss.f%z' - Full date, time with milliseconds (any level of precision), timezone
109111
"""
110112

111113
if not isinstance(field_value, str):
112114
raise TypeError(f"{field_location} must be a string")
113115

114116
error_message = (
115-
f"{field_location} must be a valid datetime in the format 'YYYY-MM-DDThh:mm:ss+zz:zz' (where time element "
116-
+ "is optional, timezone must be given if and only if time is given, and milliseconds can be optionally "
117-
+ "included after the seconds). Note that partial dates are not allowed for "
118-
+ f"{field_location} for this service."
117+
f"{field_location} must be a valid datetime in one of the following formats:\n"
118+
"- 'YYYY-MM-DD' — Full date only\n"
119+
"- 'YYYY-MM-DDThh:mm:ss' — Full date and time without milliseconds\n"
120+
"- 'YYYY-MM-DDThh:mm:ss.f' — Full date and time with milliseconds (any level of precision)\n"
121+
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)\n"
122+
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone\n\n"
123+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
124+
f"Note that partial dates are not allowed for {field_location} in this service."
119125
)
120126

121-
# Full date only
122-
if "T" not in field_value:
123-
try:
124-
datetime.strptime(field_value, "%Y-%m-%d")
125-
except ValueError as error:
126-
raise ValueError(error_message) from error
127+
allowed_suffixes = {"+00:00", "+01:00", "+0000", "+0100",}
127128

128-
else:
129+
# List of accepted strict formats
130+
formats = [
131+
"%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f",
132+
"%Y-%m-%dT%H:%M:%S%z", "%Y-%m-%dT%H:%M:%S.%f%z",
133+
]
129134

130-
# Using %z in datetime.strptime function is more permissive than FHIR,
131-
# so check that timezone meets FHIR format requirements first
132-
timezone_pattern = re.compile(r"(\+|-)\d{2}:\d{2}")
133-
if not timezone_pattern.fullmatch(field_value[-6:]):
134-
raise ValueError(error_message)
135-
136-
# Full date, time without milliseconds, timezone
137-
if "." not in field_value:
138-
try:
139-
datetime.strptime(field_value, "%Y-%m-%dT%H:%M:%S%z")
140-
except ValueError as error:
141-
raise ValueError(error_message) from error
142-
143-
# Full date, time with milliseconds, timezone
144-
else:
145-
try:
146-
datetime.strptime(field_value, "%Y-%m-%dT%H:%M:%S.%f%z")
147-
except ValueError as error:
148-
raise ValueError(error_message) from error
149-
135+
for fmt in formats:
136+
try:
137+
fhir_date = datetime.strptime(field_value, fmt)
138+
139+
if fhir_date.tzinfo is not None:
140+
if not any(field_value.endswith(suffix) for suffix in allowed_suffixes):
141+
raise ValueError(error_message)
142+
return fhir_date.isoformat()
143+
except ValueError:
144+
continue
145+
146+
raise ValueError(error_message)
147+
150148
@staticmethod
151149
def for_snomed_code(field_value: str, field_location: str):
152150
"""

backend/tests/test_fhir_batch_repository.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
import boto3
55
import simplejson as json
66
import botocore.exceptions
7-
from moto import mock_dynamodb
7+
from moto import mock_aws
88
from uuid import uuid4
99
from models.errors import IdentifierDuplicationError, ResourceNotFoundError, UnhandledResponseError, ResourceFoundError
1010
from fhir_batch_repository import ImmunizationBatchRepository, create_table
@@ -15,7 +15,7 @@
1515
def _make_immunization_pk(_id):
1616
return f"Immunization#{_id}"
1717

18-
@mock_dynamodb
18+
@mock_aws
1919
class TestImmunizationBatchRepository(unittest.TestCase):
2020

2121
def setUp(self):
@@ -331,7 +331,7 @@ def test_delete_immunization_conditionalcheckfailedexception_error(self):
331331
)
332332
self.repository.delete_immunization(self.immunization, "supplier", "vax-type", self.table, False)
333333

334-
@mock_dynamodb
334+
@mock_aws
335335
@patch.dict(os.environ, {"DYNAMODB_TABLE_NAME": "TestTable"})
336336
class TestCreateTable(TestImmunizationBatchRepository):
337337

backend/tests/test_fhir_controller.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from unittest.mock import create_autospec, ANY, patch, Mock
1111
from urllib.parse import urlencode
1212
import urllib.parse
13-
from moto import mock_sqs
13+
from moto import mock_aws
1414
from authorization import Authorization
1515
from fhir_controller import FhirController
1616
from fhir_repository import ImmunizationRepository
@@ -903,7 +903,7 @@ def test_unauthorised_create_immunization(self):
903903
response = self.controller.create_immunization(aws_event)
904904
self.assertEqual(response["statusCode"], 403)
905905

906-
@mock_sqs
906+
@mock_aws
907907
@patch("fhir_controller.sqs_client.send_message")
908908
def test_create_immunization_for_batch(self, mock_send_message):
909909
"""It should create Immunization and return resource's location"""
@@ -1027,7 +1027,7 @@ def test_invalid_nhs_number(self):
10271027
self.assertEqual(body["resourceType"], "OperationOutcome")
10281028
self.assertTrue(invalid_nhs_num in body["issue"][0]["diagnostics"])
10291029

1030-
@mock_sqs
1030+
@mock_aws
10311031
@patch("fhir_controller.sqs_client.send_message")
10321032
def test_invalid_nhs_number_batch(self, mock_send_message):
10331033
"""it should handle ValidationError when patient doesn't exist"""
@@ -1056,7 +1056,7 @@ def test_invalid_nhs_number_batch(self, mock_send_message):
10561056
self.assertEqual(body["resourceType"], "OperationOutcome")
10571057
self.assertTrue(invalid_nhs_num in body["issue"][0]["diagnostics"])
10581058

1059-
@mock_sqs
1059+
@mock_aws
10601060
@patch("fhir_controller.sqs_client.send_message")
10611061
def test_duplicate_record_batch(self, mock_send_message):
10621062
"""it should handle ValidationError when patient doesn't exist"""
@@ -1098,7 +1098,7 @@ def test_pds_unhandled_error(self):
10981098
body = json.loads(response["body"])
10991099
self.assertEqual(body["resourceType"], "OperationOutcome")
11001100

1101-
@mock_sqs
1101+
@mock_aws
11021102
@patch("fhir_controller.sqs_client.send_message")
11031103
def test_pds_unhandled_error_batch(self, mock_send_message):
11041104
"""it should respond with 500 if PDS returns error"""

backend/tests/test_forwarding_batch_lambda.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from unittest.mock import patch, MagicMock
55
import boto3
66
from boto3 import resource as boto3_resource
7-
from moto import mock_dynamodb
7+
from moto import mock_aws
88
from models.errors import (
99
MessageNotSuccessfulError,
1010
RecordProcessorError,
@@ -23,7 +23,7 @@
2323
from forwarding_batch_lambda import forward_lambda_handler, create_diagnostics_dictionary, forward_request_to_dynamo
2424

2525

26-
@mock_dynamodb
26+
@mock_aws
2727
@patch.dict(os.environ, ForwarderValues.MOCK_ENVIRONMENT_DICT)
2828
class TestForwardLambdaHandler(TestCase):
2929

backend/tests/test_immunization_pre_validator.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -514,7 +514,7 @@ def test_pre_validate_patient_address_postal_code(self):
514514
}
515515
result = self.validator.run_postalCode_validator(values)
516516
self.assertIsNone(result)
517-
517+
518518
def test_pre_validate_occurrence_date_time(self):
519519
"""Test pre_validate_occurrence_date_time accepts valid values and rejects invalid values"""
520520
ValidatorModelTests.test_date_time_value(

backend/tests/utils/pre_validation_test_utils.py

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -349,12 +349,16 @@ def test_date_time_value(
349349
expected_error_message=f"{field_location} must be a string",
350350
)
351351

352-
expected_error_message = (
353-
f"{field_location} must be a valid datetime in the format 'YYYY-MM-DDThh:mm:ss+zz:zz' (where time "
354-
"element is optional, timezone must be given if and only if time is given, and milliseconds can be "
355-
+ "optionally included after the seconds). Note that partial dates are not allowed for "
356-
+ f"{field_location} for this service."
357-
)
352+
expected_error_message = (
353+
f"{field_location} must be a valid datetime in one of the following formats:\n"
354+
"- 'YYYY-MM-DD' — Full date only\n"
355+
"- 'YYYY-MM-DDThh:mm:ss' — Full date and time without milliseconds\n"
356+
"- 'YYYY-MM-DDThh:mm:ss.f' — Full date and time with milliseconds (any level of precision)\n"
357+
"- 'YYYY-MM-DDThh:mm:ss%z' — Full date and time with timezone (e.g. +00:00 or +01:00)\n"
358+
"- 'YYYY-MM-DDThh:mm:ss.f%z' — Full date and time with milliseconds and timezone\n\n"
359+
"Only '+00:00' and '+01:00' are accepted as valid timezone offsets.\n"
360+
f"Note that partial dates are not allowed for {field_location} in this service."
361+
)
358362

359363
# Test invalid date time string formats
360364
for invalid_occurrence_date_time in InvalidValues.for_date_time_string_formats:

0 commit comments

Comments
 (0)