diff --git a/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt b/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt index 8690117bf..478d9a5ca 100644 --- a/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/cognito-backup/requirements-dev.txt @@ -4,25 +4,25 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/cognito-backup/requirements-dev.in # -aws-lambda-powertools==3.24.0 +aws-lambda-powertools==3.27.0 # via -r lambdas/python/cognito-backup/requirements-dev.in -boto3==1.42.42 +boto3==1.42.84 # via # -r lambdas/python/cognito-backup/requirements-dev.in # moto -botocore==1.42.42 +botocore==1.42.84 # via # -r lambdas/python/cognito-backup/requirements-dev.in # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via # joserfc # moto @@ -37,13 +37,13 @@ jmespath==1.1.0 # aws-lambda-powertools # boto3 # botocore -joserfc==1.6.1 +joserfc==1.6.3 # via moto markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[cognitoidp,s3]==5.1.20 +moto[cognitoidp,s3]==5.1.22 # via -r lambdas/python/cognito-backup/requirements-dev.in packaging==26.0 # via pytest @@ -53,7 +53,7 @@ py-partiql-parser==0.6.3 # via moto pycparser==3.0 # via cffi -pygments==2.19.2 +pygments==2.20.0 # via pytest pytest==9.0.2 # via -r lambdas/python/cognito-backup/requirements-dev.in @@ -65,11 +65,11 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -82,7 +82,7 @@ urllib3==2.6.3 # botocore # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py index 19be0ec21..367707b9b 100644 --- a/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py +++ b/backend/compact-connect/lambdas/python/common/cc_common/data_model/data_client.py @@ -421,6 +421,15 @@ def create_provider_privileges( privilege_jurisdictions=jurisdiction_postal_abbreviations, ) + if not jurisdiction_postal_abbreviations: + logger.error( + 'No list of jurisdictions provided. Cannot generate privileges for empty list.', + compact=compact, + provider_id=provider_id, + compact_transaction_id=compact_transaction_id, + ) + raise CCInternalException('No list of jurisdictions provided. Cannot generate privileges for empty list.') + license_jurisdiction = provider_record.licenseJurisdiction privileges = [] @@ -431,6 +440,8 @@ def create_provider_privileges( processed_transactions = [] privilege_update_records = [] + now = config.current_standard_datetime + for postal_abbreviation in jurisdiction_postal_abbreviations: # get the original privilege issuance date from an existing privilege record if it exists original_privilege = next( @@ -457,8 +468,6 @@ def create_provider_privileges( privileges.append(privilege_record) - now = config.current_standard_datetime - # Create privilege update record if this is updating an existing privilege if original_privilege: update_record = PrivilegeUpdateData.create_new( @@ -527,9 +536,15 @@ def create_provider_privileges( 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, 'sk': {'S': f'{compact}#PROVIDER'}, }, - 'UpdateExpression': 'ADD #privilegeJurisdictions :newJurisdictions', + 'UpdateExpression': 'ADD #privilegeJurisdictions :newJurisdictions ' + 'SET dateOfUpdate = :dateOfUpdate, providerDateOfUpdate = :providerDateOfUpdate', 'ExpressionAttributeNames': {'#privilegeJurisdictions': 'privilegeJurisdictions'}, - 'ExpressionAttributeValues': {':newJurisdictions': {'SS': jurisdiction_postal_abbreviations}}, + 'ExpressionAttributeValues': { + ':newJurisdictions': {'SS': jurisdiction_postal_abbreviations}, + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, + }, + 'ConditionExpression': 'attribute_exists(pk)', } } ) @@ -727,12 +742,14 @@ def complete_military_affiliation_initialization(self, compact: str, provider_id 'UpdateExpression': ( 'SET militaryStatus = :militaryStatus, ' 'militaryStatusNote = :militaryStatusNote, ' - 'dateOfUpdate = :dateOfUpdate' + 'dateOfUpdate = :dateOfUpdate, ' + 'providerDateOfUpdate = :providerDateOfUpdate' ), 'ExpressionAttributeValues': { ':militaryStatus': {'S': MilitaryStatus.TENTATIVE}, ':militaryStatusNote': {'S': ''}, - ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, }, 'ConditionExpression': 'attribute_exists(pk)', } @@ -765,7 +782,7 @@ def complete_military_affiliation_initialization(self, compact: str, provider_id 'ExpressionAttributeNames': {'#status': 'status'}, 'ExpressionAttributeValues': { ':status': {'S': status_value}, - ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + ':dateOfUpdate': {'S': now.isoformat()}, }, } } @@ -902,9 +919,13 @@ def end_military_affiliation(self, compact: str, provider_id: str) -> None: 'pk': {'S': provider_serialized_record['pk']}, 'sk': {'S': provider_serialized_record['sk']}, }, - 'UpdateExpression': ('SET dateOfUpdate = :dateOfUpdate REMOVE militaryStatus, militaryStatusNote'), + 'UpdateExpression': ( + 'SET dateOfUpdate = :dateOfUpdate, providerDateOfUpdate = :providerDateOfUpdate ' + 'REMOVE militaryStatus, militaryStatusNote' + ), 'ExpressionAttributeValues': { - ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, }, 'ConditionExpression': 'attribute_exists(pk)', } @@ -1040,12 +1061,14 @@ def process_military_audit( 'UpdateExpression': ( 'SET militaryStatus = :militaryStatus, ' 'militaryStatusNote = :militaryStatusNote, ' - 'dateOfUpdate = :dateOfUpdate' + 'dateOfUpdate = :dateOfUpdate, ' + 'providerDateOfUpdate = :providerDateOfUpdate' ), 'ExpressionAttributeValues': { ':militaryStatus': {'S': military_status}, ':militaryStatusNote': {'S': note_value}, - ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, }, 'ConditionExpression': 'attribute_exists(pk)', } @@ -1473,8 +1496,25 @@ def deactivate_privilege( 'UpdateExpression': 'SET administratorSetStatus = :status, dateOfUpdate = :dateOfUpdate', 'ExpressionAttributeValues': { ':status': {'S': ActiveInactiveStatus.INACTIVE}, - ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + ':dateOfUpdate': {'S': now.isoformat()}, + }, + }, + }, + # Update dateOfUpdate and providerDateOfUpdate on the top-level provider record + { + 'Update': { + 'TableName': self.config.provider_table.name, + 'Key': { + 'pk': {'S': f'{compact}#PROVIDER#{provider_id}'}, + 'sk': {'S': f'{compact}#PROVIDER'}, + }, + 'UpdateExpression': 'SET dateOfUpdate = :dateOfUpdate, ' + 'providerDateOfUpdate = :providerDateOfUpdate', + 'ExpressionAttributeValues': { + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, }, + 'ConditionExpression': 'attribute_exists(pk)', }, }, # Create a history record, reflecting this change @@ -1560,10 +1600,22 @@ def _generate_set_provider_encumbered_status_item( # licenses and providers share the same encumbered status enum provider_encumbered_status: LicenseEncumberedStatusEnum, ): - return self._generate_encumbered_status_update_item( - data=provider_data, - encumbered_status=provider_encumbered_status, - ) + data_record = provider_data.serialize_to_database_record() + now = self.config.current_standard_datetime + return { + 'Update': { + 'TableName': self.config.provider_table.name, + 'Key': {'pk': {'S': data_record['pk']}, 'sk': {'S': data_record['sk']}}, + 'UpdateExpression': 'SET encumberedStatus = :status, dateOfUpdate = :dateOfUpdate, ' + 'providerDateOfUpdate = :providerDateOfUpdate', + 'ExpressionAttributeValues': { + ':status': {'S': provider_encumbered_status}, + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, + }, + 'ConditionExpression': 'attribute_exists(pk)', + }, + } def _generate_put_transaction_item(self, item: dict): return {'Put': {'TableName': self.config.provider_table.name, 'Item': TypeSerializer().serialize(item)['M']}} @@ -2965,11 +3017,14 @@ def _get_provider_record_transaction_items_for_jurisdiction_with_no_known_licens }, 'UpdateExpression': 'SET ' 'currentHomeJurisdiction = :currentHomeJurisdiction, ' - 'dateOfUpdate = :dateOfUpdate', + 'dateOfUpdate = :dateOfUpdate, ' + 'providerDateOfUpdate = :providerDateOfUpdate', 'ExpressionAttributeValues': { ':currentHomeJurisdiction': {'S': selected_jurisdiction}, - ':dateOfUpdate': {'S': self.config.current_standard_datetime.isoformat()}, + ':dateOfUpdate': {'S': now.isoformat()}, + ':providerDateOfUpdate': {'S': now.isoformat()}, }, + 'ConditionExpression': 'attribute_exists(pk)', } }, ] @@ -3850,12 +3905,14 @@ def complete_provider_email_update( }, 'UpdateExpression': ( 'SET compactConnectRegisteredEmailAddress = :new_email, ' - 'dateOfUpdate = :date_of_update ' + 'dateOfUpdate = :date_of_update, ' + 'providerDateOfUpdate = :provider_date_of_update ' 'REMOVE pendingEmailAddress, emailVerificationCode, emailVerificationExpiry' ), 'ExpressionAttributeValues': { ':new_email': {'S': new_email_address}, - ':date_of_update': {'S': self.config.current_standard_datetime.isoformat()}, + ':date_of_update': {'S': now.isoformat()}, + ':provider_date_of_update': {'S': now.isoformat()}, }, # Ensure the provider record exists before updating 'ConditionExpression': 'attribute_exists(pk)', diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.in b/backend/compact-connect/lambdas/python/common/requirements-dev.in index 676fc10bc..6009679f2 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.in +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.in @@ -1,4 +1,6 @@ +# Keep attrs on 25.x to match root requirements.txt (jsii/cattrs); avoids pip-sync conflicts. moto[all]>=5.0.12, <6 boto3-stubs[full] Faker>=37, <38 cryptography>=46, <47 +attrs>=25, <26 diff --git a/backend/compact-connect/lambdas/python/common/requirements-dev.txt b/backend/compact-connect/lambdas/python/common/requirements-dev.txt index e7d219bcf..cf3255bf4 100644 --- a/backend/compact-connect/lambdas/python/common/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/common/requirements-dev.txt @@ -10,6 +10,7 @@ antlr4-python3-runtime==4.13.2 # via moto attrs==25.4.0 # via + # -r lambdas/python/common/requirements-dev.in # jsonschema # referencing aws-sam-translator==1.103.0 @@ -18,15 +19,15 @@ aws-sam-translator==1.103.0 # moto aws-xray-sdk==2.15.0 # via moto -boto3==1.42.42 +boto3==1.42.84 # via # aws-sam-translator # moto -boto3-stubs[full]==1.42.42 +boto3-stubs[full]==1.42.84 # via -r lambdas/python/common/requirements-dev.in -boto3-stubs-full==1.42.41 +boto3-stubs-full==1.42.83 # via boto3-stubs -botocore==1.42.42 +botocore==1.42.84 # via # aws-xray-sdk # boto3 @@ -34,15 +35,15 @@ botocore==1.42.42 # s3transfer botocore-stubs==1.42.41 # via boto3-stubs -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography cfn-lint==1.41.0 # via moto -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via # -r lambdas/python/common/requirements-dev.in # joserfc @@ -51,7 +52,7 @@ docker==7.1.0 # via moto faker==37.12.0 # via -r lambdas/python/common/requirements-dev.in -graphql-core==3.2.7 +graphql-core==3.2.8 # via moto idna==3.11 # via requests @@ -61,21 +62,21 @@ jmespath==1.1.0 # via # boto3 # botocore -joserfc==1.6.1 +joserfc==1.6.3 # via moto jsonpatch==1.33 # via cfn-lint -jsonpath-ng==1.7.0 +jsonpath-ng==1.8.0 # via moto -jsonpointer==3.0.0 +jsonpointer==3.1.1 # via jsonpatch -jsonschema==4.26.0 +jsonschema==4.24.1 # via # aws-sam-translator # moto # openapi-schema-validator # openapi-spec-validator -jsonschema-path==0.3.4 +jsonschema-path==0.4.5 # via openapi-spec-validator jsonschema-specifications==2025.9.1 # via @@ -87,22 +88,20 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[all]==5.1.20 +moto[all]==5.1.22 # via -r lambdas/python/common/requirements-dev.in mpmath==1.3.0 # via sympy -multipart==1.3.0 +multipart==1.3.1 # via moto networkx==3.6.1 # via cfn-lint -openapi-schema-validator==0.6.3 +openapi-schema-validator==0.8.1 # via openapi-spec-validator -openapi-spec-validator==0.7.2 +openapi-spec-validator==0.8.4 # via moto -pathable==0.4.4 +pathable==0.5.0 # via jsonschema-path -ply==3.11 - # via jsonpath-ng py-partiql-parser==0.6.3 # via moto pycparser==3.0 @@ -111,34 +110,43 @@ pydantic==2.12.4 # via # aws-sam-translator # moto + # openapi-schema-validator + # openapi-spec-validator + # pydantic-settings pydantic-core==2.41.5 # via pydantic +pydantic-settings==2.13.1 + # via + # openapi-schema-validator + # openapi-spec-validator pyparsing==3.3.2 # via moto python-dateutil==2.9.0.post0 # via # botocore # moto +python-dotenv==1.2.2 + # via pydantic-settings pyyaml==6.0.3 # via # cfn-lint # jsonschema-path # moto # responses -referencing==0.36.2 +referencing==0.37.0 # via # jsonschema # jsonschema-path # jsonschema-specifications -regex==2026.1.15 + # openapi-schema-validator +regex==2026.4.4 # via cfn-lint -requests==2.32.5 +requests==2.33.1 # via # docker - # jsonschema-path # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto rfc3339-validator==0.1.4 # via openapi-schema-validator @@ -154,7 +162,7 @@ six==1.17.0 # rfc3339-validator sympy==1.14.0 # via cfn-lint -types-awscrt==0.31.1 +types-awscrt==0.31.3 # via botocore-stubs types-s3transfer==0.16.0 # via boto3-stubs @@ -166,8 +174,10 @@ typing-extensions==4.15.0 # pydantic-core # typing-inspection typing-inspection==0.4.2 - # via pydantic -tzdata==2025.3 + # via + # pydantic + # pydantic-settings +tzdata==2026.1 # via faker urllib3==2.6.3 # via @@ -175,11 +185,11 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -wrapt==2.1.1 +wrapt==2.1.2 # via aws-xray-sdk -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto # The following packages are considered to be unsafe in a requirements file: diff --git a/backend/compact-connect/lambdas/python/common/requirements.txt b/backend/compact-connect/lambdas/python/common/requirements.txt index 6ff410463..328ca596c 100644 --- a/backend/compact-connect/lambdas/python/common/requirements.txt +++ b/backend/compact-connect/lambdas/python/common/requirements.txt @@ -8,23 +8,23 @@ argon2-cffi==25.1.0 # via -r lambdas/python/common/requirements.in argon2-cffi-bindings==25.1.0 # via argon2-cffi -aws-lambda-powertools==3.24.0 +aws-lambda-powertools==3.27.0 # via -r lambdas/python/common/requirements.in -boto3==1.42.42 +boto3==1.42.84 # via -r lambdas/python/common/requirements.in -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via # argon2-cffi-bindings # cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via -r lambdas/python/common/requirements.in idna==3.11 # via requests @@ -41,7 +41,7 @@ pycparser==3.0 # via cffi python-dateutil==2.9.0.post0 # via botocore -requests==2.32.5 +requests==2.33.1 # via -r lambdas/python/common/requirements.in s3transfer==0.16.0 # via boto3 diff --git a/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py b/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py index b17bd1aa1..3e4f6d575 100644 --- a/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py +++ b/backend/compact-connect/lambdas/python/common/tests/function/test_data_client.py @@ -6,7 +6,7 @@ from boto3.dynamodb.conditions import Key from cc_common.data_model.update_tier_enum import UpdateTierEnum from cc_common.exceptions import CCAwsServiceException, CCInvalidRequestException -from common_test.test_constants import DEFAULT_PROVIDER_ID +from common_test.test_constants import DEFAULT_LICENSE_JURISDICTION, DEFAULT_PROVIDER_ID from moto import mock_aws from tests.function import TstFunction @@ -152,14 +152,18 @@ def test_data_client_created_privilege_record(self): ) test_data_client = DataClient(self.config) + # add provider record + provider_record = self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'privilegeJurisdictions': {}} + ) response = test_data_client.create_provider_privileges( - compact='aslp', + compact=provider_record.compact, provider_id=DEFAULT_PROVIDER_ID, license_type='audiologist', jurisdiction_postal_abbreviations=['ky'], license_expiration_date=date.fromisoformat('2024-10-31'), - provider_record=self.test_data_generator.generate_default_provider(), + provider_record=provider_record, existing_privileges_for_license=[], compact_transaction_id='test_transaction_id', attestations=self.sample_privilege_attestations, @@ -244,8 +248,12 @@ def test_data_client_updates_privilege_records_for_specific_license_type(self): } ) - # Create the first privilege provider_uuid = str(uuid4()) + # add provider record + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'providerId': provider_uuid, 'privilegeJurisdictions': {}} + ) + # Create the first privilege original_privilege = PrivilegeData.from_database_record( { 'pk': f'aslp#PROVIDER#{provider_uuid}', @@ -474,6 +482,10 @@ def test_data_client_handles_large_privilege_purchase(self): test_data_client = DataClient(self.config) provider_uuid = str(uuid4()) + # add provider record + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'providerId': provider_uuid, 'privilegeJurisdictions': {}} + ) # use first 51 jurisdictions (will create 102 records - 51 privileges and 51 updates) jurisdictions = [jurisdiction for jurisdiction in self.config.jurisdictions[0:51]] @@ -487,7 +499,7 @@ def test_data_client_handles_large_privilege_purchase(self): 'providerId': provider_uuid, 'compact': 'aslp', 'jurisdiction': jurisdiction, - 'licenseJurisdiction': 'oh', + 'licenseJurisdiction': DEFAULT_LICENSE_JURISDICTION, 'licenseType': 'audiologist', 'privilegeId': f'AUD-{jurisdiction.upper()}-1', 'dateOfIssuance': datetime(2023, 11, 8, 23, 59, 59, tzinfo=UTC), diff --git a/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt b/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt index ce862d86b..776e2b17b 100644 --- a/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/compact-configuration/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/compact-configuration/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/compact-configuration/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt index 52f5198ea..786231f8a 100644 --- a/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/custom-resources/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/custom-resources/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/custom-resources/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt index 072d67ec2..1766e2eb8 100644 --- a/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/data-events/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/data-events/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/data-events/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py index f314af838..3aa165c65 100644 --- a/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py +++ b/backend/compact-connect/lambdas/python/data-events/tests/function/test_home_jurisdiction_events.py @@ -341,3 +341,31 @@ def test_no_notification_when_jurisdiction_config_not_found(self, mock_send_new_ # Verify no emails were sent mock_send_old_state.assert_not_called() mock_send_new_state.assert_not_called() + + def test_provider_date_of_update_is_set_after_home_jurisdiction_change(self): + """Test that providerDateOfUpdate is updated on the provider record after a home jurisdiction change + to a jurisdiction with no known license (Update expression path).""" + from cc_common.config import config + + self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={ + 'currentHomeJurisdiction': 'oh', + }, + date_of_update_override='2020-01-01T00:00:00+00:00', + ) + + config.data_client.update_provider_home_state_jurisdiction( + compact=DEFAULT_COMPACT, + provider_id=DEFAULT_PROVIDER_ID, + selected_jurisdiction='tx', + ) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{DEFAULT_COMPACT}#PROVIDER#{DEFAULT_PROVIDER_ID}', + 'sk': f'{DEFAULT_COMPACT}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) diff --git a/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt b/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt index 553a070a6..d9fddb9e3 100644 --- a/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/disaster-recovery/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/disaster-recovery/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/disaster-recovery/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt index 3c2661b1b..942019f56 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/provider-data-v1/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/provider-data-v1/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -35,7 +35,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/provider-data-v1/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -49,18 +49,18 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 six==1.17.0 # via python-dateutil -tzdata==2025.3 +tzdata==2026.1 # via faker urllib3==2.6.3 # via @@ -68,7 +68,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py index 9179e758d..49f0b85c4 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_encumbrance.py @@ -56,8 +56,12 @@ def _generate_test_body(): class TestPostPrivilegeEncumbrance(TstFunction): """Test suite for privilege encumbrance endpoints.""" - def _when_testing_privilege_encumbrance(self, body_overrides: dict | None = None): - self.test_data_generator.put_default_provider_record_in_provider_table() + def _when_testing_privilege_encumbrance( + self, body_overrides: dict | None = None, date_of_update_override: str | None = None + ): + self.test_data_generator.put_default_provider_record_in_provider_table( + date_of_update_override=date_of_update_override, + ) test_privilege_record = self.test_data_generator.put_default_privilege_record_in_provider_table() body = _generate_test_body() @@ -300,14 +304,39 @@ def test_privilege_encumbrance_handler_handles_event_publishing_failure(self, mo encumbrance_handler(event, self.mock_context) self.assertEqual('Event publishing failed', str(context.exception)) + def test_privilege_encumbrance_updates_provider_date_of_update(self): + """Test that encumbering a privilege updates providerDateOfUpdate on the provider record.""" + from handlers.encumbrance import encumbrance_handler + + event, test_privilege_record = self._when_testing_privilege_encumbrance( + date_of_update_override='2020-01-01T00:00:00+00:00', + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{test_privilege_record.compact}#PROVIDER#{test_privilege_record.providerId}', + 'sk': f'{test_privilege_record.compact}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) + @mock_aws @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) class TestPostLicenseEncumbrance(TstFunction): """Test suite for license encumbrance endpoints.""" - def _when_testing_valid_license_encumbrance(self, body_overrides: dict | None = None): - self.test_data_generator.put_default_provider_record_in_provider_table() + def _when_testing_valid_license_encumbrance( + self, body_overrides: dict | None = None, date_of_update_override: str | None = None + ): + self.test_data_generator.put_default_provider_record_in_provider_table( + date_of_update_override=date_of_update_override, + ) test_license_record = self.test_data_generator.put_default_license_record_in_provider_table() body = _generate_test_body() @@ -513,6 +542,27 @@ def test_license_encumbrance_handler_returns_400_if_encumbrance_date_in_future(s response_body, ) + def test_license_encumbrance_updates_provider_date_of_update(self): + """Test that encumbering a license updates providerDateOfUpdate on the provider record.""" + from handlers.encumbrance import encumbrance_handler + + event, test_license_record = self._when_testing_valid_license_encumbrance( + date_of_update_override='2020-01-01T00:00:00+00:00', + ) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode'], msg=json.loads(response['body'])) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{test_license_record.compact}#PROVIDER#{test_license_record.providerId}', + 'sk': f'{test_license_record.compact}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) + @mock_aws @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) @@ -520,11 +570,16 @@ class TestPatchPrivilegeEncumbranceLifting(TstFunction): """Test suite for privilege encumbrance lifting endpoints.""" def _setup_privilege_with_adverse_action( - self, adverse_action_overrides=None, privilege_overrides=None, license_overrides=None + self, + adverse_action_overrides=None, + privilege_overrides=None, + license_overrides=None, + date_of_update_override: str | None = None, ): """Helper method to set up a privilege with an adverse action for testing.""" self.test_data_generator.put_default_provider_record_in_provider_table( - value_overrides={'encumberedStatus': 'encumbered'} + value_overrides={'encumberedStatus': 'encumbered'}, + date_of_update_override=date_of_update_override, ) test_privilege_record = self.test_data_generator.put_default_privilege_record_in_provider_table( value_overrides=privilege_overrides or {'encumberedStatus': 'encumbered'} @@ -757,7 +812,9 @@ def test_should_not_update_provider_record_when_other_privilege_encumbrances_exi from handlers.encumbrance import encumbrance_handler # Set up first privilege with adverse action - privilege_record, adverse_action = self._setup_privilege_with_adverse_action() + privilege_record, adverse_action = self._setup_privilege_with_adverse_action( + date_of_update_override='2020-01-01T00:00:00+00:00' + ) # Set up second privilege with encumbered status (different jurisdiction) self.test_data_generator.put_default_privilege_record_in_provider_table( @@ -781,6 +838,7 @@ def test_should_not_update_provider_record_when_other_privilege_encumbrances_exi loaded_provider_data = provider_records.get_provider_record() self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + self.assertEqual('2020-01-01T00:00:00+00:00', loaded_provider_data.dateOfUpdate.isoformat()) def test_should_not_update_provider_record_when_encumbered_license_exists(self): from cc_common.data_model.provider_record_util import ProviderUserRecords @@ -892,16 +950,41 @@ def test_privilege_encumbrance_lifting_handler_handles_event_publishing_failure( encumbrance_handler(event, self.mock_context) self.assertEqual('Event publishing failed', str(context.exception)) + def test_should_update_provider_date_of_update_when_last_privilege_encumbrance_is_lifted(self): + """Test that lifting the last privilege encumbrance updates providerDateOfUpdate on the provider record.""" + from handlers.encumbrance import encumbrance_handler + + privilege_record, adverse_action = self._setup_privilege_with_adverse_action( + date_of_update_override='2020-01-01T00:00:00+00:00', + ) + event = self._generate_lift_encumbrance_event(privilege_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{privilege_record.compact}#PROVIDER#{privilege_record.providerId}', + 'sk': f'{privilege_record.compact}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) + @mock_aws @patch('cc_common.config._Config.current_standard_datetime', datetime.fromisoformat(DEFAULT_DATE_OF_UPDATE_TIMESTAMP)) class TestPatchLicenseEncumbranceLifting(TstFunction): """Test suite for license encumbrance lifting endpoints.""" - def _setup_license_with_adverse_action(self, adverse_action_overrides=None, license_overrides=None): + def _setup_license_with_adverse_action( + self, adverse_action_overrides=None, license_overrides=None, date_of_update_override: str | None = None + ): """Helper method to set up a license with an adverse action for testing.""" self.test_data_generator.put_default_provider_record_in_provider_table( - value_overrides={'encumberedStatus': 'encumbered'} + value_overrides={'encumberedStatus': 'encumbered'}, + date_of_update_override=date_of_update_override, ) test_license_record = self.test_data_generator.put_default_license_record_in_provider_table( value_overrides=license_overrides or {'encumberedStatus': 'encumbered'} @@ -1177,6 +1260,28 @@ def test_should_not_update_provider_record_when_encumbered_privilege_exists(self loaded_provider_data = provider_records.get_provider_record() self.assertEqual(LicenseEncumberedStatusEnum.ENCUMBERED, loaded_provider_data.encumberedStatus) + def test_should_update_provider_date_of_update_when_last_license_encumbrance_is_lifted(self): + """Test that lifting the last license encumbrance updates providerDateOfUpdate on the provider record.""" + from handlers.encumbrance import encumbrance_handler + + license_record, adverse_action = self._setup_license_with_adverse_action( + date_of_update_override='2020-01-01T00:00:00+00:00', + ) + event = self._generate_lift_encumbrance_event(license_record, adverse_action) + + response = encumbrance_handler(event, self.mock_context) + self.assertEqual(200, response['statusCode']) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{license_record.compact}#PROVIDER#{license_record.providerId}', + 'sk': f'{license_record.compact}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) + def test_should_return_access_denied_if_compact_admin_attempts_to_lift_license_encumbrance(self): """Verifying that only state admins are allowed to lift license encumbrances""" from handlers.encumbrance import encumbrance_handler diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_privileges.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_privileges.py index d71ecd192..cb45d0462 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_privileges.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_privileges.py @@ -95,6 +95,8 @@ def _assert_the_privilege_was_deactivated(self): expected_provider['privileges'][0]['dateOfUpdate'] = '2024-11-08T23:59:59+00:00' # remove activeSince Field, since the privilege in this case would not be active del expected_provider['privileges'][0]['activeSince'] + # deactivating a privilege now also updates the provider record's dateOfUpdate + expected_provider['dateOfUpdate'] = '2024-11-08T23:59:59+00:00' body = json.loads(resp['body']) self.assertEqual(expected_provider, body) @@ -301,3 +303,37 @@ def test_privilege_purchase_message_handler_sends_email(self): privilege_purchase_message_handler(purchase_event, self.mock_context) mock_email_service_client.send_privilege_purchase_email.assert_called_once() + + @patch('cc_common.config._Config.email_service_client') + def test_deactivate_privilege_updates_provider_date_of_update(self, mock_email_service_client): # noqa: ARG002 unused-argument + """Test that deactivating a privilege updates dateOfUpdate and providerDateOfUpdate on the provider record.""" + from common_test.test_constants import DEFAULT_DATE_OF_UPDATE_TIMESTAMP, DEFAULT_PROVIDER_ID + from handlers.privileges import deactivate_privilege + + self._load_provider_data() + + with open('../common/tests/resources/api-event.json') as f: + event = json.load(f) + + event['requestContext']['authorizer']['claims']['scope'] = 'openid email aslp/admin' + event['requestContext']['authorizer']['claims']['sub'] = TEST_STAFF_USER_ID + event['pathParameters'] = { + 'compact': 'aslp', + 'providerId': DEFAULT_PROVIDER_ID, + 'jurisdiction': 'ne', + 'licenseType': 'slp', + } + event['body'] = json.dumps({'deactivationNote': TEST_NOTE}) + + resp = deactivate_privilege(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'aslp#PROVIDER#{DEFAULT_PROVIDER_ID}', + 'sk': 'aslp#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py index 978d28c37..e4c0874d9 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users.py @@ -495,3 +495,28 @@ def test_patch_provider_military_affiliation_creates_provider_update_record(self self.assertEqual(['militaryStatus', 'militaryStatusNote'], update_data.removedValues) # Verify updated values is empty (no fields were updated, only removed) self.assertEqual({}, update_data.updatedValues) + + def test_patch_provider_military_affiliation_updates_provider_date_of_update(self): + """Test that ending military affiliation updates providerDateOfUpdate on the provider record.""" + from handlers.provider_users import provider_users_api_handler + + test_provider = self.test_data_generator.put_default_provider_record_in_provider_table( + value_overrides={'militaryStatus': 'declined'}, + date_of_update_override='2020-01-01T00:00:00+00:00', + ) + self.test_data_generator.put_default_military_affiliation_in_provider_table() + + event = self._generate_path_api_event(test_provider) + + resp = provider_users_api_handler(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{TEST_COMPACT}#PROVIDER#{test_provider.providerId}', + 'sk': f'{TEST_COMPACT}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_record['providerDateOfUpdate']) diff --git a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_email.py b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_email.py index cfddc3450..03d2df27d 100644 --- a/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_email.py +++ b/backend/compact-connect/lambdas/python/provider-data-v1/tests/function/test_handlers/test_provider_users_email.py @@ -5,7 +5,7 @@ from unittest.mock import patch from cc_common.exceptions import CCInternalException -from common_test.test_constants import DEFAULT_COMPACT, DEFAULT_PROVIDER_ID +from common_test.test_constants import DEFAULT_COMPACT, DEFAULT_DATE_OF_UPDATE_TIMESTAMP, DEFAULT_PROVIDER_ID from moto import mock_aws from .. import TstFunction @@ -443,6 +443,51 @@ def test_endpoint_updates_dynamo_provider_record_with_expected_values(self, mock self.assertIsNone(stored_provider_data.emailVerificationCode) self.assertIsNone(stored_provider_data.emailVerificationExpiry) + @patch('cc_common.config._Config.email_service_client') + def test_complete_provider_email_update_sets_provider_date_of_update_on_provider_record( + self, mock_email_service_client + ): + from handlers.provider_users import provider_users_api_handler + + self.config.cognito_client.admin_create_user( + UserPoolId=self.config.provider_user_pool_id, + Username=TEST_OLD_EMAIL, + UserAttributes=[ + {'Name': 'email', 'Value': TEST_OLD_EMAIL}, + {'Name': 'email_verified', 'Value': 'true'}, + {'Name': 'custom:compact', 'Value': DEFAULT_COMPACT}, + {'Name': 'custom:providerId', 'Value': DEFAULT_PROVIDER_ID}, + ], + MessageAction='SUPPRESS', + ) + + event = self._when_testing_provider_user_event_with_custom_claims() + stale_datetime = '2000-01-01T00:00:00+00:00' + self._provider_table.update_item( + Key={ + 'pk': f'{DEFAULT_COMPACT}#PROVIDER#{DEFAULT_PROVIDER_ID}', + 'sk': f'{DEFAULT_COMPACT}#PROVIDER', + }, + UpdateExpression='SET dateOfUpdate = :d, providerDateOfUpdate = :p', + ExpressionAttributeValues={ + ':d': stale_datetime, + ':p': stale_datetime, + }, + ) + + resp = provider_users_api_handler(event, self.mock_context) + self.assertEqual(200, resp['statusCode']) + + provider_item = self._provider_table.get_item( + Key={ + 'pk': f'{DEFAULT_COMPACT}#PROVIDER#{DEFAULT_PROVIDER_ID}', + 'sk': f'{DEFAULT_COMPACT}#PROVIDER', + } + )['Item'] + + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_item['dateOfUpdate']) + self.assertEqual(DEFAULT_DATE_OF_UPDATE_TIMESTAMP, provider_item['providerDateOfUpdate']) + @patch('cc_common.config._Config.email_service_client') def test_endpoint_creates_dynamo_provider_update_record_with_expected_values(self, mock_email_service_client): from cc_common.data_model.schema.provider import ProviderUpdateData diff --git a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt index 44b3730ec..235685b09 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements-dev.txt @@ -8,20 +8,20 @@ argon2-cffi==25.1.0 # via -r requirements-dev.in argon2-cffi-bindings==25.1.0 # via argon2-cffi -aws-lambda-powertools==3.24.0 +aws-lambda-powertools==3.27.0 # via -r requirements-dev.in boolean-py==5.0 # via license-expression -boto3==1.42.58 +boto3==1.42.84 # via # -r requirements-dev.in # moto -botocore==1.42.58 +botocore==1.42.84 # via # boto3 # moto # s3transfer -build==1.4.0 +build==1.4.2 # via pip-tools cachecontrol[filecache]==0.14.4 # via @@ -33,19 +33,19 @@ cffi==2.0.0 # via # argon2-cffi-bindings # cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -click==8.3.1 +click==8.3.2 # via pip-tools -coverage[toml]==7.13.4 +coverage[toml]==7.13.5 # via # -r requirements-dev.in # pytest-cov -cryptography==46.0.5 +cryptography==46.0.6 # via # -r requirements-dev.in # moto -cyclonedx-python-lib==11.6.0 +cyclonedx-python-lib==11.7.0 # via pip-audit defusedxml==0.7.1 # via py-serializable @@ -53,7 +53,7 @@ docker==7.1.0 # via moto faker==37.12.0 # via -r requirements-dev.in -filelock==3.24.3 +filelock==3.25.2 # via cachecontrol idna==3.11 # via requests @@ -78,7 +78,7 @@ marshmallow==3.26.2 # via -r requirements-dev.in mdurl==0.1.2 # via markdown-it-py -moto[dynamodb,s3]==5.1.21 +moto[dynamodb,s3]==5.1.22 # via -r requirements-dev.in msgpack==1.1.2 # via cachecontrol @@ -100,7 +100,7 @@ pip-requirements-parser==32.0.1 # via pip-audit pip-tools==7.5.3 # via -r requirements-dev.in -platformdirs==4.9.2 +platformdirs==4.9.4 # via pip-audit pluggy==1.6.0 # via @@ -112,7 +112,7 @@ py-serializable==2.1.0 # via cyclonedx-python-lib pycparser==3.0 # via cffi -pygments==2.19.2 +pygments==2.20.0 # via # pytest # rich @@ -126,7 +126,7 @@ pytest==9.0.2 # via # -r requirements-dev.in # pytest-cov -pytest-cov==7.0.0 +pytest-cov==7.1.0 # via -r requirements-dev.in python-dateutil==2.9.0.post0 # via @@ -136,7 +136,7 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # -r requirements-dev.in # cachecontrol @@ -148,7 +148,7 @@ responses==0.26.0 # via moto rich==14.3.3 # via pip-audit -ruff==0.15.4 +ruff==0.15.9 # via -r requirements-dev.in s3transfer==0.16.0 # via boto3 @@ -156,7 +156,7 @@ six==1.17.0 # via python-dateutil sortedcontainers==2.4.0 # via cyclonedx-python-lib -tomli==2.4.0 +tomli==2.4.1 # via pip-audit tomli-w==1.2.0 # via pip-audit @@ -164,7 +164,7 @@ typing-extensions==4.15.0 # via # aws-lambda-powertools # cyclonedx-python-lib -tzdata==2025.3 +tzdata==2026.1 # via faker urllib3==2.6.3 # via @@ -173,7 +173,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.6 +werkzeug==3.1.8 # via moto wheel==0.46.3 # via pip-tools diff --git a/backend/compact-connect/lambdas/python/purchases/requirements.txt b/backend/compact-connect/lambdas/python/purchases/requirements.txt index 0428304da..e921e9e66 100644 --- a/backend/compact-connect/lambdas/python/purchases/requirements.txt +++ b/backend/compact-connect/lambdas/python/purchases/requirements.txt @@ -6,9 +6,9 @@ # authorizenet==1.1.6 # via -r requirements.in -certifi==2026.1.4 +certifi==2026.2.25 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests idna==3.11 # via requests @@ -16,7 +16,7 @@ lxml==4.9.4 # via authorizenet pyxb-x==1.2.6.3 # via authorizenet -requests==2.32.5 +requests==2.33.1 # via authorizenet urllib3==2.6.3 # via diff --git a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py index c3d88f569..48207cf38 100644 --- a/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py +++ b/backend/compact-connect/lambdas/python/purchases/tests/function/test_handlers/test_purchase_privileges.py @@ -1245,3 +1245,42 @@ def test_post_purchase_privileges_stores_unsettled_transaction(self, mock_purcha expected_epoch = int(dt.timestamp()) expected_sk = f'COMPACT#{TEST_COMPACT}#TIME#{expected_epoch}#TX#{MOCK_TRANSACTION_ID}' self.assertEqual(expected_sk, unsettled_tx['sk']) + + @patch('handlers.privileges.PurchaseClient') + def test_post_purchase_privileges_updates_provider_date_of_update(self, mock_purchase_client_constructor): + """Test that purchasing privileges updates dateOfUpdate and providerDateOfUpdate on the provider record.""" + from handlers.privileges import post_purchase_privileges + + self._when_purchase_client_successfully_processes_request(mock_purchase_client_constructor) + + event = self._when_testing_provider_user_event_with_custom_claims(license_expiration_date='2050-01-01') + event['body'] = _generate_test_request_body() + + # provider with stale datetime that should be updated to the mock current time + stale_datetime = '2000-01-01T00:00:00+00:00' + self._provider_table.update_item( + Key={ + 'pk': f'{TEST_COMPACT}#PROVIDER#{TEST_PROVIDER_ID}', + 'sk': f'{TEST_COMPACT}#PROVIDER', + }, + UpdateExpression='SET dateOfUpdate = :d, providerDateOfUpdate = :p', + ExpressionAttributeValues={ + ':d': stale_datetime, + ':p': stale_datetime, + }, + ) + + resp = post_purchase_privileges(event, self.mock_context) + self.assertEqual(200, resp['statusCode'], resp['body']) + + expected_datetime = '2024-11-08T23:59:59+00:00' + + provider_record = self._provider_table.get_item( + Key={ + 'pk': f'{TEST_COMPACT}#PROVIDER#{TEST_PROVIDER_ID}', + 'sk': f'{TEST_COMPACT}#PROVIDER', + } + )['Item'] + + self.assertEqual(expected_datetime, provider_record['dateOfUpdate']) + self.assertEqual(expected_datetime, provider_record['providerDateOfUpdate']) diff --git a/backend/compact-connect/lambdas/python/search/requirements-dev.txt b/backend/compact-connect/lambdas/python/search/requirements-dev.txt index a8ef8fb85..34cf70918 100644 --- a/backend/compact-connect/lambdas/python/search/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/search/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb]==5.1.20 +moto[dynamodb]==5.1.22 # via -r lambdas/python/search/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -45,12 +45,12 @@ python-dateutil==2.9.0.post0 # moto pyyaml==6.0.3 # via responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -62,7 +62,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/search/requirements.txt b/backend/compact-connect/lambdas/python/search/requirements.txt index c16489c7c..1df821788 100644 --- a/backend/compact-connect/lambdas/python/search/requirements.txt +++ b/backend/compact-connect/lambdas/python/search/requirements.txt @@ -4,15 +4,15 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/search/requirements.in # -certifi==2026.1.4 +certifi==2026.2.25 # via # opensearch-py # requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests events==0.5 # via opensearch-py -grpcio==1.76.0 +grpcio==1.80.0 # via opensearch-protobufs idna==3.11 # via requests @@ -20,11 +20,11 @@ opensearch-protobufs==0.19.0 # via opensearch-py opensearch-py==3.1.0 # via -r lambdas/python/search/requirements.in -protobuf==6.33.5 +protobuf==7.34.1 # via opensearch-protobufs python-dateutil==2.9.0.post0 # via opensearch-py -requests==2.32.5 +requests==2.33.1 # via opensearch-py six==1.17.0 # via python-dateutil diff --git a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt index 65523ffcd..f3a4d6b83 100644 --- a/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-user-pre-token/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-user-pre-token/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via moto docker==7.1.0 # via moto @@ -33,7 +33,7 @@ markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[dynamodb,s3]==5.1.20 +moto[dynamodb,s3]==5.1.22 # via -r lambdas/python/staff-user-pre-token/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -47,12 +47,12 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 @@ -64,7 +64,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt index 0c39fa9b6..05d852394 100644 --- a/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt +++ b/backend/compact-connect/lambdas/python/staff-users/requirements-dev.txt @@ -4,20 +4,20 @@ # # pip-compile --no-emit-index-url --no-strip-extras lambdas/python/staff-users/requirements-dev.in # -boto3==1.42.42 +boto3==1.42.84 # via moto -botocore==1.42.42 +botocore==1.42.84 # via # boto3 # moto # s3transfer -certifi==2026.1.4 +certifi==2026.2.25 # via requests cffi==2.0.0 # via cryptography -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -cryptography==46.0.4 +cryptography==46.0.6 # via # joserfc # moto @@ -33,13 +33,13 @@ jmespath==1.1.0 # via # boto3 # botocore -joserfc==1.6.1 +joserfc==1.6.3 # via moto markupsafe==3.0.3 # via # jinja2 # werkzeug -moto[cognitoidp,dynamodb,s3]==5.1.20 +moto[cognitoidp,dynamodb,s3]==5.1.22 # via -r lambdas/python/staff-users/requirements-dev.in py-partiql-parser==0.6.3 # via moto @@ -53,18 +53,18 @@ pyyaml==6.0.3 # via # moto # responses -requests==2.32.5 +requests==2.33.1 # via # docker # moto # responses -responses==0.25.8 +responses==0.26.0 # via moto s3transfer==0.16.0 # via boto3 six==1.17.0 # via python-dateutil -tzdata==2025.3 +tzdata==2026.1 # via faker urllib3==2.6.3 # via @@ -72,7 +72,7 @@ urllib3==2.6.3 # docker # requests # responses -werkzeug==3.1.5 +werkzeug==3.1.8 # via moto -xmltodict==1.0.2 +xmltodict==1.0.4 # via moto diff --git a/backend/compact-connect/requirements-dev.txt b/backend/compact-connect/requirements-dev.txt index 0cd970211..cc6277dad 100644 --- a/backend/compact-connect/requirements-dev.txt +++ b/backend/compact-connect/requirements-dev.txt @@ -6,29 +6,29 @@ # boolean-py==5.0 # via license-expression -build==1.4.0 +build==1.4.2 # via pip-tools cachecontrol[filecache]==0.14.4 # via # cachecontrol # pip-audit -certifi==2026.1.4 +certifi==2026.2.25 # via requests -charset-normalizer==3.4.4 +charset-normalizer==3.4.7 # via requests -click==8.3.1 +click==8.3.2 # via pip-tools -coverage[toml]==7.13.3 +coverage[toml]==7.13.5 # via # -r requirements-dev.in # pytest-cov -cyclonedx-python-lib==11.6.0 +cyclonedx-python-lib==11.7.0 # via pip-audit defusedxml==0.7.1 # via py-serializable faker==37.12.0 # via -r requirements-dev.in -filelock==3.20.3 +filelock==3.25.2 # via cachecontrol idna==3.11 # via requests @@ -57,9 +57,9 @@ pip-audit==2.10.0 # via -r requirements-dev.in pip-requirements-parser==32.0.1 # via pip-audit -pip-tools==7.5.2 +pip-tools==7.5.3 # via -r requirements-dev.in -platformdirs==4.5.1 +platformdirs==4.9.4 # via pip-audit pluggy==1.6.0 # via @@ -67,7 +67,7 @@ pluggy==1.6.0 # pytest-cov py-serializable==2.1.0 # via cyclonedx-python-lib -pygments==2.19.2 +pygments==2.20.0 # via # pytest # rich @@ -81,23 +81,23 @@ pytest==9.0.2 # via # -r requirements-dev.in # pytest-cov -pytest-cov==7.0.0 +pytest-cov==7.1.0 # via -r requirements-dev.in -requests==2.32.5 +requests==2.33.1 # via # cachecontrol # pip-audit -rich==14.3.2 +rich==14.3.3 # via pip-audit -ruff==0.15.0 +ruff==0.15.9 # via -r requirements-dev.in sortedcontainers==2.4.0 # via cyclonedx-python-lib -tomli==2.4.0 +tomli==2.4.1 # via pip-audit tomli-w==1.2.0 # via pip-audit -tzdata==2025.3 +tzdata==2026.1 # via faker urllib3==2.6.3 # via requests diff --git a/backend/compact-connect/requirements.txt b/backend/compact-connect/requirements.txt index c73efbc3c..c3630a863 100644 --- a/backend/compact-connect/requirements.txt +++ b/backend/compact-connect/requirements.txt @@ -8,15 +8,15 @@ attrs==25.4.0 # via # cattrs # jsii -aws-cdk-asset-awscli-v1==2.2.263 +aws-cdk-asset-awscli-v1==2.2.273 # via aws-cdk-lib -aws-cdk-asset-node-proxy-agent-v6==2.1.0 +aws-cdk-asset-node-proxy-agent-v6==2.1.1 # via aws-cdk-lib -aws-cdk-aws-lambda-python-alpha==2.237.1a0 +aws-cdk-aws-lambda-python-alpha==2.248.0a0 # via -r requirements.in -aws-cdk-cloud-assembly-schema==48.20.0 +aws-cdk-cloud-assembly-schema==53.13.0 # via aws-cdk-lib -aws-cdk-lib==2.237.1 +aws-cdk-lib==2.248.0 # via # -r requirements.in # aws-cdk-aws-lambda-python-alpha @@ -25,7 +25,7 @@ cattrs==25.3.0 # via jsii cdk-nag==2.37.55 # via -r requirements.in -constructs==10.4.5 +constructs==10.6.0 # via # -r requirements.in # aws-cdk-aws-lambda-python-alpha @@ -33,7 +33,7 @@ constructs==10.4.5 # cdk-nag importlib-resources==6.5.2 # via jsii -jsii==1.126.0 +jsii==1.127.0 # via # aws-cdk-asset-awscli-v1 # aws-cdk-asset-node-proxy-agent-v6 diff --git a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py index d5fa30f16..cf0f60e5d 100644 --- a/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py +++ b/backend/compact-connect/tests/smoke/home_jurisdiction_change_smoke_tests.py @@ -9,6 +9,7 @@ from smoke_common import ( SmokeTestFailureException, call_provider_users_me_endpoint, + get_license_type_abbreviation, get_provider_user_auth_headers_cached, get_provider_user_records, load_smoke_test_env, @@ -205,7 +206,7 @@ def add_license_for_provider(provider_record: dict, jurisdiction: str): compact = provider_record['compact'] license_record = { 'pk': f'{compact}#PROVIDER#{provider_id}', - 'sk': f'{compact}#PROVIDER#license/{jurisdiction}/{license_type}#', + 'sk': f'{compact}#PROVIDER#license/{jurisdiction}/{get_license_type_abbreviation(license_type)}#', 'type': 'license', 'providerId': provider_id, 'compact': compact, @@ -213,7 +214,7 @@ def add_license_for_provider(provider_record: dict, jurisdiction: str): 'ssnLastFour': '1234', 'npi': '0608337260', 'licenseNumber': 'A0608337260', - 'licenseType': 'speech-language pathologist', + 'licenseType': license_type, 'givenName': 'Björk', 'middleName': 'Gunnar', 'familyName': 'Guðmundsdóttir', @@ -225,14 +226,14 @@ def add_license_for_provider(provider_record: dict, jurisdiction: str): 'homeAddressStreet1': '123 A St.', 'homeAddressStreet2': 'Apt 321', 'homeAddressCity': 'Columbus', - 'homeAddressState': 'oh', + 'homeAddressState': jurisdiction, 'homeAddressPostalCode': '43004', 'emailAddress': 'björk@example.com', 'phoneNumber': '+13213214321', 'jurisdictionUploadedLicenseStatus': 'active', 'licenseStatusName': 'DEFINITELY_A_HUMAN', 'jurisdictionUploadedCompactEligibility': 'eligible', - 'licenseGSIPK': 'C#aslp#J#oh', + 'licenseGSIPK': f'C#{compact}#J#{jurisdiction}', 'licenseGSISK': 'FN#gu%C3%B0mundsd%C3%B3ttir#GN#bj%C3%B6rk', } # put the license in for the new jurisdiction @@ -249,6 +250,10 @@ def test_home_jurisdiction_change_moves_privileges_when_valid_license_in_new_jur Test that when a provider changes their home jurisdiction to a jurisdiction where they have a valid license: 1. All their privileges are set to active 2. Their compactEligibility on the provider record is set to eligible + + NOTE: this test assumes that the compact your test provider is registered in has Alabama as a live state in + its configuration, if that state is not live in your environment for that compact, you will need to add it or + change the jurisdiction """ logger.info('Running home jurisdiction change test - changing to jurisdiction with valid license') diff --git a/backend/compact-connect/tests/smoke/smoke_common.py b/backend/compact-connect/tests/smoke/smoke_common.py index 7ee25bb60..6743b459d 100644 --- a/backend/compact-connect/tests/smoke/smoke_common.py +++ b/backend/compact-connect/tests/smoke/smoke_common.py @@ -243,7 +243,7 @@ def call_provider_users_me_endpoint(): ) # If we get a 403, the token may have expired - refresh it and retry once - if get_provider_data_response.status_code == 403: + if get_provider_data_response.status_code == 403 or get_provider_data_response.status_code == 401: logger.info('Received 403 response, refreshing provider user token and retrying...') # Clear the cached token to force a refresh if 'TEST_PROVIDER_USER_ID_TOKEN' in os.environ: