From cedffabb755652260b19595060e07d43bbca4213 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Thu, 5 Dec 2024 14:14:19 +0000 Subject: [PATCH 1/7] Add missing induction fields to reporting DB table (#1735) --- .../DqtReporting/Migrations/0039_InductionFields.sql | 8 ++++++++ .../TeachingRecordSystem.Core.csproj | 1 + 2 files changed, 9 insertions(+) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/DqtReporting/Migrations/0039_InductionFields.sql diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/DqtReporting/Migrations/0039_InductionFields.sql b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/DqtReporting/Migrations/0039_InductionFields.sql new file mode 100644 index 000000000..be284fcdb --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Services/DqtReporting/Migrations/0039_InductionFields.sql @@ -0,0 +1,8 @@ +alter table trs_persons add cpd_induction_status int +alter table trs_persons add cpd_induction_start_date date +alter table trs_persons add cpd_induction_completed_date date +alter table trs_persons add cpd_induction_modified_on datetime +alter table trs_persons add cpd_induction_cpd_modified_on datetime +alter table trs_persons add cpd_induction_first_modified_on datetime +alter table trs_persons add induction_exemption_reasons int +alter table trs_persons add induction_modified_on datetime diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj b/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj index 946e62e5f..11aa31fa4 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/TeachingRecordSystem.Core.csproj @@ -140,6 +140,7 @@ + From 9e90d111e5bbce2b45b160f475dbd10ecabaa64f Mon Sep 17 00:00:00 2001 From: Colin Saliceti Date: Thu, 5 Dec 2024 17:46:09 +0000 Subject: [PATCH 2/7] [2158] Replace terrafile (#1738) --- .github/workflows/pr.yml | 5 ++-- .gitignore | 1 - Makefile | 23 +++++++++++-------- global_config/dev.sh | 1 + global_config/domains.sh | 1 + global_config/dv_review.sh | 1 + global_config/pre-production.sh | 1 + global_config/production.sh | 1 + global_config/test.sh | 1 + terraform/aks/config/dev_Terrafile | 3 --- terraform/aks/config/dv_review_Terrafile | 3 --- terraform/aks/config/pre-production_Terrafile | 3 --- terraform/aks/config/production_Terrafile | 3 --- terraform/aks/config/test_Terrafile | 3 --- .../environment_domains/config/dev_Terrafile | 3 --- .../config/pre-production_Terrafile | 3 --- .../config/production_Terrafile | 3 --- .../environment_domains/config/test_Terrafile | 3 --- .../infrastructure/config/trs_Terrafile | 3 --- 19 files changed, 22 insertions(+), 43 deletions(-) delete mode 100644 terraform/aks/config/dev_Terrafile delete mode 100644 terraform/aks/config/dv_review_Terrafile delete mode 100644 terraform/aks/config/pre-production_Terrafile delete mode 100644 terraform/aks/config/production_Terrafile delete mode 100644 terraform/aks/config/test_Terrafile delete mode 100644 terraform/domains/environment_domains/config/dev_Terrafile delete mode 100644 terraform/domains/environment_domains/config/pre-production_Terrafile delete mode 100644 terraform/domains/environment_domains/config/production_Terrafile delete mode 100644 terraform/domains/environment_domains/config/test_Terrafile delete mode 100644 terraform/domains/infrastructure/config/trs_Terrafile diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index 4602e284e..07b2250b1 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -64,10 +64,11 @@ jobs: run: terraform fmt -check -diff working-directory: terraform/aks + - name: Download terraform modules + run: make ci dev vendor-modules + - name: Validate run: | - curl -sL https://github.com/coretech/terrafile/releases/download/v0.8/terrafile_0.8_Linux_x86_64.tar.gz | tar xz terrafile - ./terrafile -p vendor/modules -f config/dev_Terrafile terraform init -backend=false terraform validate -no-color working-directory: terraform/aks diff --git a/.gitignore b/.gitignore index 6c36d234c..83972360a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,6 @@ docs/build docs/source/dqt-api.yaml **/.DS_Store -bin/terrafile terraform/domains/environment_domains/vendor/ terraform/domains/infrastructure/vendor/ terraform/aks/vendor/ diff --git a/Makefile b/Makefile index 5511fea48..f34cba629 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,6 @@ .DEFAULT_GOAL :=help SHELL :=/bin/bash -TERRAFILE_VERSION=0.8 RG_TAGS={"Product" : "Teaching Record System"} ARM_TEMPLATE_TAG=1.1.10 REGION=UK South @@ -60,11 +59,11 @@ ci: ## Run in automation environment $(eval CONFIRM_DEPLOY=true) $(eval SKIP_CONFIRM=true) -bin/terrafile: ## Install terrafile to manage terraform modules - curl -sL https://github.com/coretech/terrafile/releases/download/v${TERRAFILE_VERSION}/terrafile_${TERRAFILE_VERSION}_$$(uname)_$$(uname -m).tar.gz \ - | tar xz -C ./bin terrafile +vendor-modules: + rm -rf terraform/aks/vendor/modules/aks + git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_TAG} https://github.com/DFE-Digital/terraform-modules.git terraform/aks/vendor/modules/aks -terraform-init: bin/terrafile +terraform-init: vendor-modules $(eval export TF_VAR_service_name=$(SERVICE_SHORT)) $(eval export TF_VAR_service_short_name=$(SERVICE_SHORT)) $(eval export TF_VAR_config=${CONFIG}) @@ -72,7 +71,7 @@ terraform-init: bin/terrafile $(eval export TF_VAR_azure_resource_prefix=$(AZURE_RESOURCE_PREFIX)) [[ "${SP_AUTH}" != "true" ]] && az account set -s $(AZURE_SUBSCRIPTION) || true - ./bin/terrafile -p terraform/aks/vendor/modules -f terraform/aks/config/${CONFIG}_Terrafile + terraform -chdir=terraform/aks init -upgrade -backend-config config/${CONFIG}.backend.tfvars $(backend_key) -reconfigure terraform-plan: terraform-init # make [env] terraform-plan init @@ -91,8 +90,10 @@ deploy-azure-resources: set-azure-account # make dev deploy-azure-resources CONF validate-azure-resources: set-azure-account # make dev validate-azure-resources az deployment sub create --name "resourcedeploy-trs-$(shell date +%Y%m%d%H%M%S)" -l "${REGION}" --template-uri "https://raw.githubusercontent.com/DFE-Digital/tra-shared-services/main/azure/resourcedeploy.json" --parameters "resourceGroupName=${AZURE_RESOURCE_PREFIX}-${SERVICE_SHORT}-${CONFIG_SHORT}-rg" 'tags=${RG_TAGS}' "tfStorageAccountName=${AZURE_RESOURCE_PREFIX}${SERVICE_SHORT}tfstate${CONFIG_SHORT}" "tfStorageContainerName=${SERVICE_SHORT}-tfstate" "dbBackupStorageAccountName=${AZURE_BACKUP_STORAGE_ACCOUNT_NAME}" "dbBackupStorageContainerName=${AZURE_BACKUP_STORAGE_CONTAINER_NAME}" "keyVaultNames=['${AZURE_RESOURCE_PREFIX}-${SERVICE_SHORT}-${CONFIG_SHORT}-api-kv', '${AZURE_RESOURCE_PREFIX}-${SERVICE_SHORT}-${CONFIG_SHORT}-authz-kv', '${AZURE_RESOURCE_PREFIX}-${SERVICE_SHORT}-${CONFIG_SHORT}-inf-kv', '${AZURE_RESOURCE_PREFIX}-${SERVICE_SHORT}-${CONFIG_SHORT}-ui-kv', '${AZURE_RESOURCE_PREFIX}-${SERVICE_SHORT}-${CONFIG_SHORT}-worker-kv']" --what-if -domains-init: bin/terrafile domains set-azure-pd-subscription ## make [env] domains-init - terraform init for environment dns/afd resources - ./bin/terrafile -p terraform/domains/environment_domains/vendor/modules -f terraform/domains/environment_domains/config/${CONFIG}_Terrafile +domains-init: domains set-azure-pd-subscription ## make [env] domains-init - terraform init for environment dns/afd resources + rm -rf terraform/domains/environment_domains/vendor/modules/domains + git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_TAG} https://github.com/DFE-Digital/terraform-modules.git terraform/domains/environment_domains/vendor/modules/domains + terraform -chdir=terraform/domains/environment_domains init -reconfigure -upgrade -backend-config=config/${CONFIG}_backend.tfvars domains-plan: domains-init ## terraform plan for environment dns/afd resources @@ -101,8 +102,10 @@ domains-plan: domains-init ## terraform plan for environment dns/afd resources domains-apply: domains-init ## terraform apply for environment dns/afd resources, needs CONFIRM_DEPLOY=1 for production terraform -chdir=terraform/domains/environment_domains apply -var-file config/${CONFIG}.tfvars.json -domains-infra-init: bin/terrafile domains set-azure-pd-subscription ## make domains-infra-init - terraform init for dns/afd core resources, eg Main FrontDoor resource - ./bin/terrafile -p terraform/domains/infrastructure/vendor/modules -f terraform/domains/infrastructure/config/trs_Terrafile +domains-infra-init: domains set-azure-pd-subscription ## make domains-infra-init - terraform init for dns/afd core resources, eg Main FrontDoor resource + rm -rf terraform/domains/infrastructure/vendor/modules/domains + git -c advice.detachedHead=false clone --depth=1 --single-branch --branch ${TERRAFORM_MODULES_TAG} https://github.com/DFE-Digital/terraform-modules.git terraform/domains/infrastructure/vendor/modules/domains + terraform -chdir=terraform/domains/infrastructure init -reconfigure -upgrade domains-infra-plan: domains-infra-init ## terraform plan for dns core resources diff --git a/global_config/dev.sh b/global_config/dev.sh index b295e7f56..087e833a2 100644 --- a/global_config/dev.sh +++ b/global_config/dev.sh @@ -2,3 +2,4 @@ CONFIG=dev CONFIG_SHORT=dv AZURE_SUBSCRIPTION=s189-teacher-services-cloud-test AZURE_RESOURCE_PREFIX=s189t01 +TERRAFORM_MODULES_TAG=main diff --git a/global_config/domains.sh b/global_config/domains.sh index 1a778f8bf..d408c57b4 100644 --- a/global_config/domains.sh +++ b/global_config/domains.sh @@ -2,3 +2,4 @@ CONFIG=production CONFIG_SHORT=pd AZURE_SUBSCRIPTION=s189-teacher-services-cloud-production AZURE_RESOURCE_PREFIX=s189p01 +TERRAFORM_MODULES_TAG=stable diff --git a/global_config/dv_review.sh b/global_config/dv_review.sh index a3907e7a5..3c6ba4551 100644 --- a/global_config/dv_review.sh +++ b/global_config/dv_review.sh @@ -2,3 +2,4 @@ CONFIG=dv_review CONFIG_SHORT=rv AZURE_SUBSCRIPTION=s189-teacher-services-cloud-development AZURE_RESOURCE_PREFIX=s189d01 +TERRAFORM_MODULES_TAG=main diff --git a/global_config/pre-production.sh b/global_config/pre-production.sh index 39c91607b..939586d28 100644 --- a/global_config/pre-production.sh +++ b/global_config/pre-production.sh @@ -2,3 +2,4 @@ CONFIG=pre-production CONFIG_SHORT=pp AZURE_SUBSCRIPTION=s189-teacher-services-cloud-test AZURE_RESOURCE_PREFIX=s189t01 +TERRAFORM_MODULES_TAG=testing diff --git a/global_config/production.sh b/global_config/production.sh index 1a778f8bf..d408c57b4 100644 --- a/global_config/production.sh +++ b/global_config/production.sh @@ -2,3 +2,4 @@ CONFIG=production CONFIG_SHORT=pd AZURE_SUBSCRIPTION=s189-teacher-services-cloud-production AZURE_RESOURCE_PREFIX=s189p01 +TERRAFORM_MODULES_TAG=stable diff --git a/global_config/test.sh b/global_config/test.sh index 414992f81..c75e50e52 100644 --- a/global_config/test.sh +++ b/global_config/test.sh @@ -2,3 +2,4 @@ CONFIG=test CONFIG_SHORT=ts AZURE_SUBSCRIPTION=s189-teacher-services-cloud-test AZURE_RESOURCE_PREFIX=s189t01 +TERRAFORM_MODULES_TAG=testing diff --git a/terraform/aks/config/dev_Terrafile b/terraform/aks/config/dev_Terrafile deleted file mode 100644 index 65af53b11..000000000 --- a/terraform/aks/config/dev_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -aks: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "main" diff --git a/terraform/aks/config/dv_review_Terrafile b/terraform/aks/config/dv_review_Terrafile deleted file mode 100644 index 65af53b11..000000000 --- a/terraform/aks/config/dv_review_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -aks: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "main" diff --git a/terraform/aks/config/pre-production_Terrafile b/terraform/aks/config/pre-production_Terrafile deleted file mode 100644 index b4c222c13..000000000 --- a/terraform/aks/config/pre-production_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -aks: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "testing" diff --git a/terraform/aks/config/production_Terrafile b/terraform/aks/config/production_Terrafile deleted file mode 100644 index 5b2b118f0..000000000 --- a/terraform/aks/config/production_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -aks: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "stable" diff --git a/terraform/aks/config/test_Terrafile b/terraform/aks/config/test_Terrafile deleted file mode 100644 index b4c222c13..000000000 --- a/terraform/aks/config/test_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -aks: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "testing" diff --git a/terraform/domains/environment_domains/config/dev_Terrafile b/terraform/domains/environment_domains/config/dev_Terrafile deleted file mode 100644 index dfce270ef..000000000 --- a/terraform/domains/environment_domains/config/dev_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -domains: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "testing" diff --git a/terraform/domains/environment_domains/config/pre-production_Terrafile b/terraform/domains/environment_domains/config/pre-production_Terrafile deleted file mode 100644 index dfce270ef..000000000 --- a/terraform/domains/environment_domains/config/pre-production_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -domains: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "testing" diff --git a/terraform/domains/environment_domains/config/production_Terrafile b/terraform/domains/environment_domains/config/production_Terrafile deleted file mode 100644 index 58e60b3c8..000000000 --- a/terraform/domains/environment_domains/config/production_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -domains: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "stable" diff --git a/terraform/domains/environment_domains/config/test_Terrafile b/terraform/domains/environment_domains/config/test_Terrafile deleted file mode 100644 index dfce270ef..000000000 --- a/terraform/domains/environment_domains/config/test_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -domains: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "testing" diff --git a/terraform/domains/infrastructure/config/trs_Terrafile b/terraform/domains/infrastructure/config/trs_Terrafile deleted file mode 100644 index 58e60b3c8..000000000 --- a/terraform/domains/infrastructure/config/trs_Terrafile +++ /dev/null @@ -1,3 +0,0 @@ -domains: - source: "https://github.com/DFE-Digital/terraform-modules" - version: "stable" From 068d945e7f9fe347a607a1098b0eb09f05b75c2d Mon Sep 17 00:00:00 2001 From: James Gunn Date: Fri, 6 Dec 2024 12:05:36 +0000 Subject: [PATCH 3/7] Add new version of {Get|Find} endpoints with new induction status (#1737) --- .../src/TeachingRecordSystem.Api/ApiError.cs | 4 + .../Infrastructure/Mapping/OptionMapper.cs | 12 + .../src/TeachingRecordSystem.Api/Program.cs | 4 +- .../Implementation/Dtos/DqtInductionStatus.cs | 49 ++++ ...tatusInfo.cs => DqtInductionStatusInfo.cs} | 4 +- .../V3/Implementation/Dtos/InductionInfo.cs | 8 + .../V3/Implementation/Dtos/InductionStatus.cs | 49 ---- .../FindPersonByLastNameAndDateOfBirth.cs | 137 +---------- .../Operations/FindPersonsBase.cs | 142 +++++++++++ .../FindPersonsByTrnAndDateOfBirth.cs | 131 +--------- .../V3/Implementation/Operations/GetPerson.cs | 229 ++++++++++++------ .../Controllers/TeacherController.cs | 3 +- .../Controllers/TeachersController.cs | 3 +- .../Responses/FindTeachersResponse.cs | 2 +- .../V20240101/Responses/GetTeacherResponse.cs | 16 +- .../Controllers/TeacherController.cs | 3 +- .../Controllers/TeachersController.cs | 3 +- .../V20240606/Controllers/PersonController.cs | 3 +- .../Controllers/PersonsController.cs | 3 +- .../V20240606/Responses/FindPersonResponse.cs | 2 +- .../V20240606/Responses/GetPersonResponse.cs | 10 +- .../V3/V20240814/MapperProfile.cs | 3 +- .../V20240814/Responses/FindPersonResponse.cs | 7 +- .../Responses/FindPersonsResponse.cs | 10 +- .../V20240920/Controllers/PersonController.cs | 3 +- .../Controllers/PersonsController.cs | 26 +- .../V20240920/Responses/FindPersonResponse.cs | 4 +- .../Responses/FindPersonsResponse.cs | 6 +- .../V20240920/Responses/GetPersonResponse.cs | 50 +--- .../V3/VNext/Controllers/PersonController.cs | 40 +++ .../V3/VNext/Controllers/PersonsController.cs | 67 +++-- .../Requests/SetInductionStatusRequest.cs | 2 +- .../V3/VNext/Responses/FindPersonResponse.cs | 16 ++ .../V3/VNext/Responses/FindPersonsResponse.cs | 19 ++ .../V3/VNext/Responses/GetPersonResponse.cs | 18 ++ ...ductionStatus.cs => DqtInductionStatus.cs} | 2 +- ...tatusInfo.cs => DqtInductionStatusInfo.cs} | 4 +- .../ApiSchema/V3/VNext/Dtos/InductionInfo.cs | 8 + .../Models/InductionStatus.cs | 3 + .../V3/V20240101/GetTeacherByTrnTests.cs | 19 ++ .../V3/V20240101/GetTeacherTests.cs | 19 ++ .../V3/V20240606/GetPersonByTrnTests.cs | 19 ++ .../V3/V20240606/GetPersonTests.cs | 19 ++ .../FindPersonsByTrnAndDateOfBirthTests.cs | 18 +- ...FindPersonByLastNameAndDateOfBirthTests.cs | 70 ++++++ .../FindPersonsByTrnAndDateOfBirthTests.cs | 92 +++++++ .../V3/VNext/GetPersonByTrnTests.cs | 65 +++++ .../V3/VNext/GetPersonTests.cs | 69 ++++++ 48 files changed, 994 insertions(+), 501 deletions(-) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatus.cs rename TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/{InductionStatusInfo.cs => DqtInductionStatusInfo.cs} (55%) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionInfo.cs delete mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatus.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsBase.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonController.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/GetPersonResponse.cs rename TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/{InductionStatus.cs => DqtInductionStatus.cs} (87%) rename TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/{InductionStatusInfo.cs => DqtInductionStatusInfo.cs} (53%) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionInfo.cs create mode 100644 TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonByLastNameAndDateOfBirthTests.cs create mode 100644 TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonsByTrnAndDateOfBirthTests.cs create mode 100644 TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonTests.cs diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs index 83e215376..f0b937457 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs @@ -10,6 +10,7 @@ public static class ErrorCodes public static int SpecifiedResourceUrlDoesNotExist => 10028; public static int TrnRequestAlreadyCreated => 10029; public static int TrnRequestDoesNotExist => 10031; + public static int ForbiddenForAppropriateBody => 10040; } public static ApiError PersonNotFound(string trn, DateOnly? dateOfBirth = null, string? nationalInsuranceNumber = null) @@ -38,6 +39,9 @@ public static ApiError TrnRequestAlreadyCreated(string requestId) => public static ApiError TrnRequestDoesNotExist(string requestId) => new(ErrorCodes.TrnRequestDoesNotExist, "TRN request does not exist.", $"TRN request ID: '{requestId}'"); + public static ApiError ForbiddenForAppropriateBody() => + new(ErrorCodes.ForbiddenForAppropriateBody, "Forbidden.", ""); + public IActionResult ToActionResult(int statusCode = 400) { var problemDetails = new ProblemDetails() diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Mapping/OptionMapper.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Mapping/OptionMapper.cs index a89b2bd91..3f0edbc8f 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Mapping/OptionMapper.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Infrastructure/Mapping/OptionMapper.cs @@ -1,4 +1,5 @@ using Optional; +using Optional.Unsafe; namespace TeachingRecordSystem.Api.Infrastructure.Mapping; @@ -20,3 +21,14 @@ public Option Convert(T sourceMember, ResolutionContext context) => Option.Some(sourceMember); } +public class UnwrapFromOptionValueConverter : IValueConverter, TDestination> +{ + public TDestination Convert(Option sourceMember, ResolutionContext context) => + context.Mapper.Map(sourceMember.ValueOrFailure()); +} + +public class UnwrapFromOptionValueConverter : IValueConverter, T> +{ + public T Convert(Option sourceMember, ResolutionContext context) => + sourceMember.ValueOrFailure(); +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs index a9523e2c5..ae90820cf 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/Program.cs @@ -185,7 +185,9 @@ public static void Main(string[] args) cfg.CreateMap(typeof(Option<>), typeof(Option<>)).ConvertUsing(typeof(OptionToOptionTypeConverter<,>)); }) .AddTransient(typeof(WrapWithOptionValueConverter<>)) - .AddTransient(typeof(WrapWithOptionValueConverter<,>)); + .AddTransient(typeof(WrapWithOptionValueConverter<,>)) + .AddTransient(typeof(UnwrapFromOptionValueConverter<>)) + .AddTransient(typeof(UnwrapFromOptionValueConverter<,>)); services.Scan(scan => { diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatus.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatus.cs new file mode 100644 index 000000000..ca112b5b2 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatus.cs @@ -0,0 +1,49 @@ +using TeachingRecordSystem.Core.Dqt.Models; + +namespace TeachingRecordSystem.Api.V3.Implementation.Dtos; + +public enum DqtInductionStatus +{ + Exempt = 1, + Fail = 2, + FailedInWales = 3, + InductionExtended = 4, + InProgress = 5, + NotYetCompleted = 6, + Pass = 7, + PassedInWales = 8, + RequiredToComplete = 9, +} + +public static class DqtInductionStatusExtensions +{ + public static DqtInductionStatus ConvertToDqtInductionStatus(this dfeta_InductionStatus input) => input switch + { + dfeta_InductionStatus.Exempt => DqtInductionStatus.Exempt, + dfeta_InductionStatus.Fail => DqtInductionStatus.Fail, + dfeta_InductionStatus.FailedinWales => DqtInductionStatus.FailedInWales, + dfeta_InductionStatus.InductionExtended => DqtInductionStatus.InductionExtended, + dfeta_InductionStatus.InProgress => DqtInductionStatus.InProgress, + dfeta_InductionStatus.NotYetCompleted => DqtInductionStatus.NotYetCompleted, + dfeta_InductionStatus.Pass => DqtInductionStatus.Pass, + dfeta_InductionStatus.PassedinWales => DqtInductionStatus.PassedInWales, + dfeta_InductionStatus.RequiredtoComplete => DqtInductionStatus.RequiredToComplete, + _ => throw new ArgumentException($"Unknown {nameof(DqtInductionStatus)}: '{input}'.") + }; + + public static string GetDescription(this dfeta_InductionStatus input) => ConvertToDqtInductionStatus(input).GetDescription(); + + public static string GetDescription(this DqtInductionStatus input) => input switch + { + DqtInductionStatus.Exempt => "Exempt", + DqtInductionStatus.Fail => "Fail", + DqtInductionStatus.FailedInWales => "Failed in Wales", + DqtInductionStatus.InductionExtended => "Extended", + DqtInductionStatus.InProgress => "In progress", + DqtInductionStatus.NotYetCompleted => "Not yet completed", + DqtInductionStatus.Pass => "Pass", + DqtInductionStatus.PassedInWales => "Passed in Wales", + DqtInductionStatus.RequiredToComplete => "Required to complete", + _ => throw new ArgumentException($"Unknown {nameof(DqtInductionStatus)}: '{input}'.") + }; +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatusInfo.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatusInfo.cs similarity index 55% rename from TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatusInfo.cs rename to TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatusInfo.cs index 26c5b6bc9..95768f56b 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatusInfo.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/DqtInductionStatusInfo.cs @@ -1,7 +1,7 @@ namespace TeachingRecordSystem.Api.V3.Implementation.Dtos; -public record InductionStatusInfo +public record DqtInductionStatusInfo { - public required InductionStatus Status { get; init; } + public required DqtInductionStatus Status { get; init; } public required string StatusDescription { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionInfo.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionInfo.cs new file mode 100644 index 000000000..21806a33f --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionInfo.cs @@ -0,0 +1,8 @@ +namespace TeachingRecordSystem.Api.V3.Implementation.Dtos; + +public record InductionInfo +{ + public required InductionStatus Status { get; init; } + public required DateOnly? StartDate { get; init; } + public required DateOnly? CompletedDate { get; init; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatus.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatus.cs deleted file mode 100644 index 8d76ad5f5..000000000 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/InductionStatus.cs +++ /dev/null @@ -1,49 +0,0 @@ -using TeachingRecordSystem.Core.Dqt.Models; - -namespace TeachingRecordSystem.Api.V3.Implementation.Dtos; - -public enum InductionStatus -{ - Exempt = 1, - Fail = 2, - FailedInWales = 3, - InductionExtended = 4, - InProgress = 5, - NotYetCompleted = 6, - Pass = 7, - PassedInWales = 8, - RequiredToComplete = 9, -} - -public static class InductionStatusExtensions -{ - public static InductionStatus ConvertToInductionStatus(this dfeta_InductionStatus input) => input switch - { - dfeta_InductionStatus.Exempt => InductionStatus.Exempt, - dfeta_InductionStatus.Fail => InductionStatus.Fail, - dfeta_InductionStatus.FailedinWales => InductionStatus.FailedInWales, - dfeta_InductionStatus.InductionExtended => InductionStatus.InductionExtended, - dfeta_InductionStatus.InProgress => InductionStatus.InProgress, - dfeta_InductionStatus.NotYetCompleted => InductionStatus.NotYetCompleted, - dfeta_InductionStatus.Pass => InductionStatus.Pass, - dfeta_InductionStatus.PassedinWales => InductionStatus.PassedInWales, - dfeta_InductionStatus.RequiredtoComplete => InductionStatus.RequiredToComplete, - _ => throw new ArgumentException($"Unknown {nameof(InductionStatus)}: '{input}'.") - }; - - public static string GetDescription(this dfeta_InductionStatus input) => ConvertToInductionStatus(input).GetDescription(); - - public static string GetDescription(this InductionStatus input) => input switch - { - InductionStatus.Exempt => "Exempt", - InductionStatus.Fail => "Fail", - InductionStatus.FailedInWales => "Failed in Wales", - InductionStatus.InductionExtended => "Extended", - InductionStatus.InProgress => "In progress", - InductionStatus.NotYetCompleted => "Not yet completed", - InductionStatus.Pass => "Pass", - InductionStatus.PassedInWales => "Passed in Wales", - InductionStatus.RequiredToComplete => "Required to complete", - _ => throw new ArgumentException($"Unknown {nameof(InductionStatus)}: '{input}'.") - }; -} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonByLastNameAndDateOfBirth.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonByLastNameAndDateOfBirth.cs index 5c5fcf2e0..a202c5152 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonByLastNameAndDateOfBirth.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonByLastNameAndDateOfBirth.cs @@ -1,146 +1,27 @@ -using System.Collections.Immutable; -using Microsoft.Xrm.Sdk.Query; -using TeachingRecordSystem.Api.V3.Implementation.Dtos; using TeachingRecordSystem.Core.DataStore.Postgres; using TeachingRecordSystem.Core.Dqt; using TeachingRecordSystem.Core.Dqt.Models; using TeachingRecordSystem.Core.Dqt.Queries; -using InductionStatusInfo = TeachingRecordSystem.Api.V3.Implementation.Dtos.InductionStatusInfo; namespace TeachingRecordSystem.Api.V3.Implementation.Operations; -public record FindPersonByLastNameAndDateOfBirthCommand(string LastName, DateOnly? DateOfBirth); - -public record FindPersonByLastNameAndDateOfBirthResult(int Total, IReadOnlyCollection Items); - -public record FindPersonByLastNameAndDateOfBirthResultItem -{ - public required string Trn { get; init; } - public required DateOnly DateOfBirth { get; init; } - public required string FirstName { get; init; } - public required string MiddleName { get; init; } - public required string LastName { get; init; } - public required IReadOnlyCollection Sanctions { get; init; } - public required IReadOnlyCollection Alerts { get; init; } - public required IReadOnlyCollection PreviousNames { get; init; } - public required InductionStatusInfo? InductionStatus { get; init; } - public required QtsInfo? Qts { get; init; } - public required EytsInfo? Eyts { get; init; } -} +public record FindPersonByLastNameAndDateOfBirthCommand(string LastName, DateOnly DateOfBirth); public class FindPersonByLastNameAndDateOfBirthHandler( TrsDbContext dbContext, ICrmQueryDispatcher crmQueryDispatcher, PreviousNameHelper previousNameHelper, - ReferenceDataCache referenceDataCache) + ReferenceDataCache referenceDataCache) : + FindPersonsHandlerBase(dbContext, crmQueryDispatcher, previousNameHelper, referenceDataCache) { - public async Task> HandleAsync(FindPersonByLastNameAndDateOfBirthCommand command) + public async Task> HandleAsync(FindPersonByLastNameAndDateOfBirthCommand command) { - var matched = await crmQueryDispatcher.ExecuteQueryAsync( + var matched = await CrmQueryDispatcher.ExecuteQueryAsync( new GetActiveContactsByLastNameAndDateOfBirthQuery( - command.LastName!, - command.DateOfBirth!.Value, - new ColumnSet( - Contact.Fields.dfeta_TRN, - Contact.Fields.BirthDate, - Contact.Fields.FirstName, - Contact.Fields.MiddleName, - Contact.Fields.LastName, - Contact.Fields.dfeta_StatedFirstName, - Contact.Fields.dfeta_StatedMiddleName, - Contact.Fields.dfeta_StatedLastName, - Contact.Fields.dfeta_InductionStatus))); - - var contactsById = matched.ToDictionary(r => r.Id, r => r); - - var getAlertsTask = dbContext.Alerts - .Include(a => a.AlertType) - .ThenInclude(at => at.AlertCategory) - .Where(a => contactsById.Keys.Contains(a.PersonId)) - .GroupBy(a => a.PersonId) - .ToDictionaryAsync(a => a.Key, a => a.ToArray()); - - var getPreviousNamesTask = crmQueryDispatcher.ExecuteQueryAsync(new GetPreviousNamesByContactIdsQuery(contactsById.Keys)); - - var getQtsRegistrationsTask = crmQueryDispatcher.ExecuteQueryAsync( - new GetActiveQtsRegistrationsByContactIdsQuery( - contactsById.Keys, - new ColumnSet( - dfeta_qtsregistration.Fields.CreatedOn, - dfeta_qtsregistration.Fields.dfeta_EarlyYearsStatusId, - dfeta_qtsregistration.Fields.dfeta_EYTSDate, - dfeta_qtsregistration.Fields.dfeta_QTSDate, - dfeta_qtsregistration.Fields.dfeta_PersonId, - dfeta_qtsregistration.Fields.dfeta_TeacherStatusId))); - - await Task.WhenAll(getAlertsTask, getPreviousNamesTask, getQtsRegistrationsTask); - - var alerts = getAlertsTask.Result; - var previousNames = getPreviousNamesTask.Result; - var qtsRegistrations = getQtsRegistrationsTask.Result; + command.LastName, + command.DateOfBirth, + ContactColumnSet)); - return new FindPersonByLastNameAndDateOfBirthResult( - Total: matched.Length, - Items: await matched - .ToAsyncEnumerable() - .SelectAwait(async r => new FindPersonByLastNameAndDateOfBirthResultItem() - { - Trn = r.dfeta_TRN, - DateOfBirth = r.BirthDate!.Value.ToDateOnlyWithDqtBstFix(isLocalTime: false), - FirstName = r.ResolveFirstName(), - MiddleName = r.ResolveMiddleName(), - LastName = r.ResolveLastName(), - Sanctions = alerts.GetValueOrDefault(r.Id, []) - .Where(a => Constants.LegacyExposableSanctionCodes.Contains(a.AlertType.DqtSanctionCode) && a.IsOpen) - .Select(a => new SanctionInfo() - { - Code = a.AlertType.DqtSanctionCode!, - StartDate = a.StartDate - }) - .AsReadOnly(), - Alerts = alerts.GetValueOrDefault(r.Id, []) - .Where(a => !a.AlertType.InternalOnly) - .Select(a => - { - return new Alert() - { - AlertId = a.AlertId, - AlertType = new() - { - AlertTypeId = a.AlertType.AlertTypeId, - AlertCategory = new() - { - AlertCategoryId = a.AlertType.AlertCategory.AlertCategoryId, - Name = a.AlertType.AlertCategory.Name - }, - Name = a.AlertType.Name, - DqtSanctionCode = a.AlertType.DqtSanctionCode! - }, - Details = a.Details, - StartDate = a.StartDate, - EndDate = a.EndDate - }; - }) - .AsReadOnly(), - PreviousNames = previousNameHelper.GetFullPreviousNames(previousNames[r.Id], contactsById[r.Id]) - .Select(name => new NameInfo() - { - FirstName = name.FirstName, - MiddleName = name.MiddleName, - LastName = name.LastName - }) - .AsReadOnly(), - InductionStatus = r.dfeta_InductionStatus?.ConvertToInductionStatus() is Dtos.InductionStatus inductionStatus ? - new InductionStatusInfo() - { - Status = inductionStatus, - StatusDescription = inductionStatus.GetDescription() - } : - null, - Qts = await QtsInfo.CreateAsync(qtsRegistrations[r.Id].OrderBy(qr => qr.CreatedOn).FirstOrDefault(s => s.dfeta_QTSDate is not null), referenceDataCache), - Eyts = await EytsInfo.CreateAsync(qtsRegistrations[r.Id].OrderBy(qr => qr.CreatedOn).FirstOrDefault(s => s.dfeta_EYTSDate is not null), referenceDataCache), - }) - .OrderBy(c => c.Trn) - .ToArrayAsync()); + return await CreateResultAsync(matched); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsBase.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsBase.cs new file mode 100644 index 000000000..b7e4432ae --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsBase.cs @@ -0,0 +1,142 @@ +using Microsoft.Xrm.Sdk.Query; +using TeachingRecordSystem.Api.V3.Implementation.Dtos; +using TeachingRecordSystem.Core.DataStore.Postgres; +using TeachingRecordSystem.Core.Dqt; +using TeachingRecordSystem.Core.Dqt.Models; +using TeachingRecordSystem.Core.Dqt.Queries; + +namespace TeachingRecordSystem.Api.V3.Implementation.Operations; + +public record FindPersonsResult(int Total, IReadOnlyCollection Items); + +public record FindPersonsResultItem +{ + public required string Trn { get; init; } + public required DateOnly DateOfBirth { get; init; } + public required string FirstName { get; init; } + public required string MiddleName { get; init; } + public required string LastName { get; init; } + public required IReadOnlyCollection Sanctions { get; init; } + public required IReadOnlyCollection Alerts { get; init; } + public required IReadOnlyCollection PreviousNames { get; init; } + public required InductionStatus InductionStatus { get; init; } + public required DqtInductionStatusInfo? DqtInductionStatus { get; init; } + public required QtsInfo? Qts { get; init; } + public required EytsInfo? Eyts { get; init; } +} + +public abstract class FindPersonsHandlerBase( + TrsDbContext dbContext, + ICrmQueryDispatcher crmQueryDispatcher, + PreviousNameHelper previousNameHelper, + ReferenceDataCache referenceDataCache) +{ + protected TrsDbContext DbContext => dbContext; + protected ICrmQueryDispatcher CrmQueryDispatcher => crmQueryDispatcher; + protected PreviousNameHelper PreviousNameHelper => previousNameHelper; + protected ReferenceDataCache ReferenceDataCache => referenceDataCache; + + protected static ColumnSet ContactColumnSet { get; } = new( + Contact.Fields.dfeta_TRN, + Contact.Fields.BirthDate, + Contact.Fields.FirstName, + Contact.Fields.MiddleName, + Contact.Fields.LastName, + Contact.Fields.dfeta_StatedFirstName, + Contact.Fields.dfeta_StatedMiddleName, + Contact.Fields.dfeta_StatedLastName, + Contact.Fields.dfeta_InductionStatus); + + protected async Task CreateResultAsync(IEnumerable matched) + { + var contactsById = matched.ToDictionary(r => r.Id, r => r); + + var getAlertsTask = dbContext.Alerts + .Include(a => a.AlertType) + .ThenInclude(at => at.AlertCategory) + .Where(a => contactsById.Keys.Contains(a.PersonId)) + .GroupBy(a => a.PersonId) + .ToDictionaryAsync(a => a.Key, a => a.ToArray()); + + var getPreviousNamesTask = crmQueryDispatcher.ExecuteQueryAsync(new GetPreviousNamesByContactIdsQuery(contactsById.Keys)); + + var getQtsRegistrationsTask = crmQueryDispatcher.ExecuteQueryAsync( + new GetActiveQtsRegistrationsByContactIdsQuery( + contactsById.Keys, + new ColumnSet( + dfeta_qtsregistration.Fields.CreatedOn, + dfeta_qtsregistration.Fields.dfeta_EarlyYearsStatusId, + dfeta_qtsregistration.Fields.dfeta_EYTSDate, + dfeta_qtsregistration.Fields.dfeta_QTSDate, + dfeta_qtsregistration.Fields.dfeta_PersonId, + dfeta_qtsregistration.Fields.dfeta_TeacherStatusId))); + + await Task.WhenAll(getAlertsTask, getPreviousNamesTask, getQtsRegistrationsTask); + + var alerts = getAlertsTask.Result; + var previousNames = getPreviousNamesTask.Result; + var qtsRegistrations = getQtsRegistrationsTask.Result; + + var items = await matched + .ToAsyncEnumerable() + .SelectAwait(async r => new FindPersonsResultItem() + { + Trn = r.dfeta_TRN, + DateOfBirth = r.BirthDate!.Value.ToDateOnlyWithDqtBstFix(isLocalTime: false), + FirstName = r.ResolveFirstName(), + MiddleName = r.ResolveMiddleName(), + LastName = r.ResolveLastName(), + Sanctions = alerts.GetValueOrDefault(r.Id, []) + .Where(a => Constants.LegacyExposableSanctionCodes.Contains(a.AlertType.DqtSanctionCode) && a.IsOpen) + .Select(a => new SanctionInfo() + { + Code = a.AlertType.DqtSanctionCode!, + StartDate = a.StartDate + }) + .AsReadOnly(), + Alerts = alerts.GetValueOrDefault(r.Id, []) + .Where(a => !a.AlertType.InternalOnly) + .Select(a => new Alert() + { + AlertId = a.AlertId, + AlertType = new() + { + AlertTypeId = a.AlertType.AlertTypeId, + AlertCategory = new() + { + AlertCategoryId = a.AlertType.AlertCategory.AlertCategoryId, + Name = a.AlertType.AlertCategory.Name + }, + Name = a.AlertType.Name, + DqtSanctionCode = a.AlertType.DqtSanctionCode! + }, + Details = a.Details, + StartDate = a.StartDate, + EndDate = a.EndDate + }) + .AsReadOnly(), + PreviousNames = previousNameHelper.GetFullPreviousNames(previousNames[r.Id], contactsById[r.Id]) + .Select(name => new NameInfo() + { + FirstName = name.FirstName, + MiddleName = name.MiddleName, + LastName = name.LastName + }) + .AsReadOnly(), + InductionStatus = r.dfeta_InductionStatus.ToInductionStatus(), + DqtInductionStatus = r.dfeta_InductionStatus?.ConvertToDqtInductionStatus() is Dtos.DqtInductionStatus inductionStatus ? + new DqtInductionStatusInfo() + { + Status = inductionStatus, + StatusDescription = inductionStatus.GetDescription() + } : + null, + Qts = await QtsInfo.CreateAsync(qtsRegistrations[r.Id].OrderBy(qr => qr.CreatedOn).FirstOrDefault(s => s.dfeta_QTSDate is not null), referenceDataCache), + Eyts = await EytsInfo.CreateAsync(qtsRegistrations[r.Id].OrderBy(qr => qr.CreatedOn).FirstOrDefault(s => s.dfeta_EYTSDate is not null), referenceDataCache), + }) + .OrderBy(c => c.Trn) + .ToArrayAsync(); + + return new FindPersonsResult(items.Length, items); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsByTrnAndDateOfBirth.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsByTrnAndDateOfBirth.cs index e7ccc1f57..75491e03d 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsByTrnAndDateOfBirth.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/FindPersonsByTrnAndDateOfBirth.cs @@ -1,54 +1,25 @@ -using System.Collections.Immutable; -using Microsoft.Xrm.Sdk.Query; -using TeachingRecordSystem.Api.V3.Implementation.Dtos; using TeachingRecordSystem.Core.DataStore.Postgres; using TeachingRecordSystem.Core.Dqt; using TeachingRecordSystem.Core.Dqt.Models; using TeachingRecordSystem.Core.Dqt.Queries; -using InductionStatusInfo = TeachingRecordSystem.Api.V3.Implementation.Dtos.InductionStatusInfo; namespace TeachingRecordSystem.Api.V3.Implementation.Operations; public record FindPersonsByTrnAndDateOfBirthCommand(IEnumerable<(string Trn, DateOnly DateOfBirth)> Persons); -public record FindPersonsByTrnAndDateOfBirthResult(int Total, IReadOnlyCollection Items); - -public record FindPersonsByTrnAndDateOfBirthResultItem -{ - public required string Trn { get; init; } - public required DateOnly DateOfBirth { get; init; } - public required string FirstName { get; init; } - public required string MiddleName { get; init; } - public required string LastName { get; init; } - public required IReadOnlyCollection Sanctions { get; init; } - public required IReadOnlyCollection Alerts { get; init; } - public required IReadOnlyCollection PreviousNames { get; init; } - public required InductionStatusInfo? InductionStatus { get; init; } - public required QtsInfo? Qts { get; init; } - public required EytsInfo? Eyts { get; init; } -} - public class FindPersonsByTrnAndDateOfBirthHandler( TrsDbContext dbContext, ICrmQueryDispatcher crmQueryDispatcher, PreviousNameHelper previousNameHelper, - ReferenceDataCache referenceDataCache) + ReferenceDataCache referenceDataCache) : + FindPersonsHandlerBase(dbContext, crmQueryDispatcher, previousNameHelper, referenceDataCache) { - public async Task> HandleAsync(FindPersonsByTrnAndDateOfBirthCommand command) + public async Task> HandleAsync(FindPersonsByTrnAndDateOfBirthCommand command) { - var contacts = await crmQueryDispatcher.ExecuteQueryAsync( + var contacts = await CrmQueryDispatcher.ExecuteQueryAsync( new GetActiveContactsByTrnsQuery( command.Persons.Select(p => p.Trn).Where(trn => !string.IsNullOrEmpty(trn)).Distinct(), - new ColumnSet( - Contact.Fields.dfeta_TRN, - Contact.Fields.BirthDate, - Contact.Fields.FirstName, - Contact.Fields.MiddleName, - Contact.Fields.LastName, - Contact.Fields.dfeta_StatedFirstName, - Contact.Fields.dfeta_StatedMiddleName, - Contact.Fields.dfeta_StatedLastName, - Contact.Fields.dfeta_InductionStatus))); + ContactColumnSet)); // Remove any results where the request DOB doesn't match the contact's DOB // (we can't easily do this in the query itself). @@ -58,96 +29,6 @@ public async Task> HandleAsync(F .Select(kvp => kvp.Value!) .ToArray(); - var contactsById = matched.ToDictionary(c => c.Id, c => c); - - var getAlertsTask = dbContext.Alerts - .Include(a => a.AlertType) - .ThenInclude(at => at.AlertCategory) - .Where(a => contactsById.Keys.ToArray().Contains(a.PersonId)) - .GroupBy(a => a.PersonId) - .ToDictionaryAsync(a => a.Key, a => a.ToArray()); - - var getPreviousNamesTask = crmQueryDispatcher.ExecuteQueryAsync(new GetPreviousNamesByContactIdsQuery(contactsById.Keys)); - - var getQtsRegistrationsTask = crmQueryDispatcher.ExecuteQueryAsync( - new GetActiveQtsRegistrationsByContactIdsQuery( - contactsById.Keys, - new ColumnSet( - dfeta_qtsregistration.Fields.CreatedOn, - dfeta_qtsregistration.Fields.dfeta_EarlyYearsStatusId, - dfeta_qtsregistration.Fields.dfeta_EYTSDate, - dfeta_qtsregistration.Fields.dfeta_QTSDate, - dfeta_qtsregistration.Fields.dfeta_PersonId, - dfeta_qtsregistration.Fields.dfeta_TeacherStatusId))); - - await Task.WhenAll(getAlertsTask, getPreviousNamesTask, getQtsRegistrationsTask); - - var alerts = getAlertsTask.Result; - var previousNames = getPreviousNamesTask.Result; - var qtsRegistrations = getQtsRegistrationsTask.Result; - - return new FindPersonsByTrnAndDateOfBirthResult( - Total: matched.Length, - Items: await matched - .ToAsyncEnumerable() - .SelectAwait(async r => new FindPersonsByTrnAndDateOfBirthResultItem() - { - Trn = r.dfeta_TRN, - DateOfBirth = r.BirthDate!.Value.ToDateOnlyWithDqtBstFix(isLocalTime: false), - FirstName = r.ResolveFirstName(), - MiddleName = r.ResolveMiddleName(), - LastName = r.ResolveLastName(), - Sanctions = alerts.GetValueOrDefault(r.Id, []) - .Where(a => Constants.LegacyExposableSanctionCodes.Contains(a.AlertType.DqtSanctionCode) && a.IsOpen) - .Select(a => new SanctionInfo() - { - Code = a.AlertType.DqtSanctionCode!, - StartDate = a.StartDate - }) - .AsReadOnly(), - Alerts = alerts.GetValueOrDefault(r.Id, []) - .Where(a => !a.AlertType.InternalOnly) - .Select(a => - { - return new Alert() - { - AlertId = a.AlertId, - AlertType = new() - { - AlertTypeId = a.AlertType.AlertTypeId, - AlertCategory = new() - { - AlertCategoryId = a.AlertType.AlertCategory.AlertCategoryId, - Name = a.AlertType.AlertCategory.Name - }, - Name = a.AlertType.Name, - DqtSanctionCode = a.AlertType.DqtSanctionCode! - }, - Details = a.Details, - StartDate = a.StartDate, - EndDate = a.EndDate - }; - }) - .AsReadOnly(), - PreviousNames = previousNameHelper.GetFullPreviousNames(previousNames[r.Id], contactsById[r.Id]) - .Select(name => new NameInfo() - { - FirstName = name.FirstName, - MiddleName = name.MiddleName, - LastName = name.LastName - }) - .AsReadOnly(), - InductionStatus = r.dfeta_InductionStatus?.ConvertToInductionStatus() is Dtos.InductionStatus inductionStatus ? - new InductionStatusInfo() - { - Status = inductionStatus, - StatusDescription = inductionStatus.GetDescription() - } : - null, - Qts = await QtsInfo.CreateAsync(qtsRegistrations[r.Id].OrderBy(qr => qr.CreatedOn).FirstOrDefault(s => s.dfeta_QTSDate is not null), referenceDataCache), - Eyts = await EytsInfo.CreateAsync(qtsRegistrations[r.Id].OrderBy(qr => qr.CreatedOn).FirstOrDefault(s => s.dfeta_EYTSDate is not null), referenceDataCache), - }) - .OrderBy(c => c.Trn) - .ToArrayAsync()); + return await CreateResultAsync(matched); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/GetPerson.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/GetPerson.cs index b10291aa4..7f59a50b3 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/GetPerson.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/GetPerson.cs @@ -14,6 +14,7 @@ public record GetPersonCommand( GetPersonCommandIncludes Include, DateOnly? DateOfBirth, bool ApplyLegacyAlertsBehavior, + bool ApplyAppropriateBodyUserRestrictions, string? NationalInsuranceNumber = null); [Flags] @@ -45,7 +46,8 @@ public record GetPersonResult public required string? EmailAddress { get; set; } public required QtsInfo? Qts { get; init; } public required EytsInfo? Eyts { get; init; } - public required Option Induction { get; init; } + public required Option Induction { get; init; } + public required Option DqtInduction { get; init; } public required Option> InitialTeacherTraining { get; init; } public required Option> NpqQualifications { get; init; } public required Option> MandatoryQualifications { get; init; } @@ -56,17 +58,22 @@ public record GetPersonResult public required Option AllowIdSignInWithProhibitions { get; init; } } -public record GetPersonResultInduction +public record GetPersonResultInduction : InductionInfo +{ + public required string? CertificateUrl { get; init; } +} + +public record GetPersonResultDqtInduction { public required DateOnly? StartDate { get; init; } public required DateOnly? EndDate { get; init; } - public required Dtos.InductionStatus? Status { get; init; } + public required DqtInductionStatus Status { get; init; } public required string? StatusDescription { get; init; } public required string? CertificateUrl { get; init; } - public required IReadOnlyCollection Periods { get; init; } + public required IReadOnlyCollection Periods { get; init; } } -public record GetPersonResultInductionPeriod +public record GetPersonResultDqtInductionPeriod { public required DateOnly? StartDate { get; init; } public required DateOnly? EndDate { get; init; } @@ -81,15 +88,15 @@ public record GetPersonResultInductionPeriodAppropriateBody public record GetPersonResultInitialTeacherTraining { - public required GetPersonResultInitialTeacherTrainingQualification? Qualification { get; init; } - public required DateOnly? StartDate { get; init; } - public required DateOnly? EndDate { get; init; } - public required IttProgrammeType? ProgrammeType { get; init; } - public required string? ProgrammeTypeDescription { get; init; } - public required IttOutcome? Result { get; init; } - public required GetPersonResultInitialTeacherTrainingAgeRange? AgeRange { get; init; } + public required Option Qualification { get; init; } + public required Option StartDate { get; init; } + public required Option EndDate { get; init; } + public required Option ProgrammeType { get; init; } + public required Option ProgrammeTypeDescription { get; init; } + public required Option Result { get; init; } + public required Option AgeRange { get; init; } public required GetPersonResultInitialTeacherTrainingProvider? Provider { get; init; } - public required IReadOnlyCollection Subjects { get; init; } + public required Option> Subjects { get; init; } } public record GetPersonResultInitialTeacherTrainingQualification @@ -155,6 +162,20 @@ public class GetPersonHandler( { public async Task> HandleAsync(GetPersonCommand command) { + if (command.ApplyAppropriateBodyUserRestrictions) + { + if ((command.Include & ~(GetPersonCommandIncludes.Induction | GetPersonCommandIncludes.Alerts | + GetPersonCommandIncludes.InitialTeacherTraining)) != 0) + { + return ApiError.ForbiddenForAppropriateBody(); + } + + if (command.DateOfBirth is null) + { + return ApiError.ForbiddenForAppropriateBody(); + } + } + var contactDetail = await crmQueryDispatcher.ExecuteQueryAsync( new GetActiveContactDetailByTrnQuery( command.Trn, @@ -321,7 +342,7 @@ async Task WithTrsDbLockAsync(Func> action) WithTrsDbLockAsync(() => dbContext.Alerts.Include(a => a.AlertType).ThenInclude(at => at.AlertCategory).Where(a => a.PersonId == contact.Id).ToArrayAsync()) : null; - IEnumerable? previousNames = previousNameHelper.GetFullPreviousNames(contactDetail.PreviousNames, contactDetail.Contact) + IEnumerable previousNames = previousNameHelper.GetFullPreviousNames(contactDetail.PreviousNames, contactDetail.Contact) .Select(name => new NameInfo() { FirstName = name.FirstName, @@ -356,6 +377,15 @@ async Task WithTrsDbLockAsync(Func> action) Option.Some(contact.dfeta_AllowIDSignInWithProhibitions == true) : default; + Option dqtInduction = default; + Option induction = default; + if (command.Include.HasFlag(GetPersonCommandIncludes.Induction)) + { + var mappedInduction = MapInduction((await getInductionTask!).Induction, (await getInductionTask!).InductionPeriods, contact); + dqtInduction = Option.Some(mappedInduction.DqtInduction); + induction = Option.Some(mappedInduction.Induction); + } + return new GetPersonResult() { Trn = command.Trn, @@ -369,25 +399,10 @@ async Task WithTrsDbLockAsync(Func> action) Qts = await QtsInfo.CreateAsync(qts, referenceDataCache), Eyts = await EytsInfo.CreateAsync(eyts, referenceDataCache), EmailAddress = contact.EMailAddress1, - Induction = command.Include.HasFlag(GetPersonCommandIncludes.Induction) ? - Option.Some(MapInduction(await getInductionTask!, contact)) : - default, + Induction = induction, + DqtInduction = dqtInduction, InitialTeacherTraining = command.Include.HasFlag(GetPersonCommandIncludes.InitialTeacherTraining) ? - Option.Some((await getIttTask!) - .Select(i => new GetPersonResultInitialTeacherTraining() - { - Qualification = MapIttQualification(i), - ProgrammeType = i.dfeta_ProgrammeType?.ConvertToEnumByValue(), - ProgrammeTypeDescription = i.dfeta_ProgrammeType?.ConvertToEnumByValue().GetDescription(), - StartDate = i.dfeta_ProgrammeStartDate.ToDateOnlyWithDqtBstFix(isLocalTime: true), - EndDate = i.dfeta_ProgrammeEndDate.ToDateOnlyWithDqtBstFix(isLocalTime: true), - Result = i.dfeta_Result.HasValue ? i.dfeta_Result.Value.ConvertFromITTResult() : null, - AgeRange = MapAgeRange(i.dfeta_AgeRangeFrom, i.dfeta_AgeRangeTo), - Provider = MapIttProvider(i), - Subjects = MapSubjects(i) - }) - .OrderByDescending(i => i.StartDate) - .AsReadOnly()) : + Option.Some(MapInitialTeacherTraining((await getIttTask!), command.ApplyAppropriateBodyUserRestrictions)) : default, NpqQualifications = command.Include.HasFlag(GetPersonCommandIncludes.NpqQualifications) ? Option.Some(MapNpqQualifications(await getQualificationsTask!)) : @@ -420,26 +435,23 @@ async Task WithTrsDbLockAsync(Func> action) return !a.AlertType.InternalOnly; }) - .Select(a => + .Select(a => new Alert() { - return new Alert() + AlertId = a.AlertId, + AlertType = new() { - AlertId = a.AlertId, - AlertType = new() + AlertTypeId = a.AlertType.AlertTypeId, + AlertCategory = new() { - AlertTypeId = a.AlertType.AlertTypeId, - AlertCategory = new() - { - AlertCategoryId = a.AlertType.AlertCategory.AlertCategoryId, - Name = a.AlertType.AlertCategory.Name - }, - Name = a.AlertType.Name, - DqtSanctionCode = a.AlertType.DqtSanctionCode! + AlertCategoryId = a.AlertType.AlertCategory.AlertCategoryId, + Name = a.AlertType.AlertCategory.Name }, - Details = a.Details, - StartDate = a.StartDate, - EndDate = a.EndDate - }; + Name = a.AlertType.Name, + DqtSanctionCode = a.AlertType.DqtSanctionCode! + }, + Details = a.Details, + StartDate = a.StartDate, + EndDate = a.EndDate }) .AsReadOnly()) : default, @@ -450,41 +462,55 @@ async Task WithTrsDbLockAsync(Func> action) }; } - private static GetPersonResultInduction? MapInduction(InductionRecord data, TeachingRecordSystem.Core.Dqt.Models.Contact contact) + private static (GetPersonResultDqtInduction? DqtInduction, GetPersonResultInduction Induction) MapInduction( + dfeta_induction? induction, + IEnumerable? inductionPeriods, + Contact contact) { - var inductionStatus = contact.dfeta_InductionStatus?.ConvertToInductionStatus(); - return data.Induction != null ? - new GetPersonResultInduction() + var status = contact.dfeta_InductionStatus.ToInductionStatus(); + var dqtStatus = contact.dfeta_InductionStatus?.ConvertToDqtInductionStatus(); + + var startDate = status.RequiresStartDate() + ? induction?.dfeta_StartDate.ToDateOnlyWithDqtBstFix(isLocalTime: true) + : null; + + var completedDate = status.RequiresCompletedDate() + ? induction?.dfeta_CompletionDate.ToDateOnlyWithDqtBstFix(isLocalTime: true) + : null; + + var canGenerateCertificate = dqtStatus is DqtInductionStatus.Pass or DqtInductionStatus.PassedInWales + && completedDate.HasValue; + + var certificateUrl = canGenerateCertificate ? "/v3/certificates/induction" : null; + + var dqtInduction = dqtStatus is not null + ? new GetPersonResultDqtInduction() { - StartDate = data.Induction.dfeta_StartDate.ToDateOnlyWithDqtBstFix(isLocalTime: true), - EndDate = data.Induction.dfeta_CompletionDate.ToDateOnlyWithDqtBstFix(isLocalTime: true), - Status = inductionStatus, + StartDate = startDate, + EndDate = completedDate, + Status = dqtStatus.Value, StatusDescription = contact.dfeta_InductionStatus?.GetDescription(), - CertificateUrl = - (data.Induction.dfeta_InductionStatus == dfeta_InductionStatus.Pass || data.Induction.dfeta_InductionStatus == dfeta_InductionStatus.PassedinWales) && - data.Induction.dfeta_CompletionDate is not null ? - "/v3/certificates/induction" : - null, - Periods = data.InductionPeriods.Select(MapInductionPeriod).ToArray() - } : - inductionStatus.HasValue ? - new GetPersonResultInduction() - { - StartDate = null, - EndDate = null, - Status = inductionStatus, - StatusDescription = contact.dfeta_InductionStatus?.GetDescription(), - CertificateUrl = null, - Periods = Array.Empty() - } : - null; + CertificateUrl = certificateUrl, + Periods = (inductionPeriods ?? []).Select(MapInductionPeriod).ToArray() + } + : null; + + var inductionInfo = new GetPersonResultInduction() + { + Status = status, + StartDate = startDate, + CompletedDate = completedDate, + CertificateUrl = certificateUrl, + }; + + return (dqtInduction, inductionInfo); } - private static GetPersonResultInductionPeriod MapInductionPeriod(dfeta_inductionperiod inductionPeriod) + private static GetPersonResultDqtInductionPeriod MapInductionPeriod(dfeta_inductionperiod inductionPeriod) { var appropriateBody = inductionPeriod.Extract("appropriatebody", Account.PrimaryIdAttribute); - return new GetPersonResultInductionPeriod() + return new GetPersonResultDqtInductionPeriod() { StartDate = inductionPeriod.dfeta_StartDate.ToDateOnlyWithDqtBstFix(isLocalTime: true), EndDate = inductionPeriod.dfeta_EndDate.ToDateOnlyWithDqtBstFix(isLocalTime: true), @@ -498,6 +524,59 @@ private static GetPersonResultInductionPeriod MapInductionPeriod(dfeta_induction }; } + private static IReadOnlyCollection MapInitialTeacherTraining( + dfeta_initialteachertraining[] itt, + bool userHasAppropriateBodyRole) + { + if (userHasAppropriateBodyRole) + { + return itt + .SelectMany(i => + { + var provider = MapIttProvider(i); + if (provider is null) + { + return Array.Empty(); + } + + return + [ + new GetPersonResultInitialTeacherTraining + { + Provider = provider, + Qualification = default, + StartDate = default, + EndDate = default, + ProgrammeType = default, + ProgrammeTypeDescription = default, + Result = default, + AgeRange = default, + Subjects = default, + } + ]; + }) + .AsReadOnly(); + } + + IEnumerable mapped = itt + .Select(i => new GetPersonResultInitialTeacherTraining() + { + Qualification = Option.Some(MapIttQualification(i)), + ProgrammeType = Option.Some(i.dfeta_ProgrammeType?.ConvertToEnumByValue()), + ProgrammeTypeDescription = Option.Some( + i.dfeta_ProgrammeType?.ConvertToEnumByValue().GetDescription()), + StartDate = Option.Some(i.dfeta_ProgrammeStartDate.ToDateOnlyWithDqtBstFix(isLocalTime: true)), + EndDate = Option.Some(i.dfeta_ProgrammeEndDate.ToDateOnlyWithDqtBstFix(isLocalTime: true)), + Result = Option.Some(i.dfeta_Result?.ConvertFromITTResult()), + AgeRange = Option.Some(MapAgeRange(i.dfeta_AgeRangeFrom, i.dfeta_AgeRangeTo)), + Provider = MapIttProvider(i), + Subjects = Option.Some(MapSubjects(i)) + }) + .OrderByDescending(i => i.StartDate); + + return mapped.AsReadOnly(); + } + private static GetPersonResultInitialTeacherTrainingQualification? MapIttQualification(dfeta_initialteachertraining initialTeacherTraining) { var qualification = initialTeacherTraining.Extract("qualification", dfeta_ittqualification.PrimaryIdAttribute); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeacherController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeacherController.cs index 860a8f063..e65d11103 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeacherController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeacherController.cs @@ -29,7 +29,8 @@ public async Task GetAsync( Trn: User.FindFirstValue("trn")!, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, DateOfBirth: null, - ApplyLegacyAlertsBehavior: true); + ApplyLegacyAlertsBehavior: true, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeachersController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeachersController.cs index 6b097b5f9..b85f43346 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeachersController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Controllers/TeachersController.cs @@ -30,7 +30,8 @@ public async Task GetAsync( trn, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, DateOfBirth: null, - ApplyLegacyAlertsBehavior: true); + ApplyLegacyAlertsBehavior: true, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs index 9427a06ef..58ac27745 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/FindTeachersResponse.cs @@ -11,7 +11,7 @@ public record FindTeachersResponse public required IReadOnlyCollection Results { get; init; } } -[AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResultItem))] +[AutoMap(typeof(FindPersonsResultItem))] public record FindTeachersResponseResult { public required string Trn { get; init; } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/GetTeacherResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/GetTeacherResponse.cs index 28d9f48c1..fbcda4510 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/GetTeacherResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240101/Responses/GetTeacherResponse.cs @@ -1,6 +1,7 @@ using System.Text.Json.Serialization; using AutoMapper.Configuration.Annotations; using Optional; +using TeachingRecordSystem.Api.Infrastructure.Mapping; using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos; @@ -21,6 +22,7 @@ public record GetTeacherResponse public required string? Email { get; set; } public required GetTeacherResponseQts? Qts { get; init; } public required GetTeacherResponseEyts? Eyts { get; init; } + [SourceMember(nameof(GetPersonResult.DqtInduction))] public required Option Induction { get; init; } public required Option> InitialTeacherTraining { get; init; } public required Option> NpqQualifications { get; init; } @@ -48,19 +50,19 @@ public record GetTeacherResponseEyts public required string? StatusDescription { get; init; } } -[AutoMap(typeof(GetPersonResultInduction))] +[AutoMap(typeof(GetPersonResultDqtInduction))] public record GetTeacherResponseInduction { public required DateOnly? StartDate { get; init; } public required DateOnly? EndDate { get; init; } - public required TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos.InductionStatus? Status { get; init; } + public required TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos.DqtInductionStatus? Status { get; init; } public required string? StatusDescription { get; init; } [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public required string? CertificateUrl { get; init; } public required IReadOnlyCollection Periods { get; init; } } -[AutoMap(typeof(GetPersonResultInductionPeriod))] +[AutoMap(typeof(GetPersonResultDqtInductionPeriod))] public record GetTeacherResponseInductionPeriod { public required DateOnly? StartDate { get; init; } @@ -78,14 +80,22 @@ public record GetTeacherResponseInductionPeriodAppropriateBody [AutoMap(typeof(GetPersonResultInitialTeacherTraining))] public record GetTeacherResponseInitialTeacherTraining { + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required GetTeacherResponseInitialTeacherTrainingQualification? Qualification { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required DateOnly? StartDate { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required DateOnly? EndDate { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required IttProgrammeType? ProgrammeType { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required string? ProgrammeTypeDescription { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required IttOutcome? Result { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required GetTeacherResponseInitialTeacherTrainingAgeRange? AgeRange { get; init; } public required GetTeacherResponseInitialTeacherTrainingProvider? Provider { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter, IReadOnlyCollection>))] public required IReadOnlyCollection Subjects { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs index 2dee737e1..557a4502e 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeacherController.cs @@ -29,7 +29,8 @@ public async Task GetAsync( Trn: User.FindFirstValue("trn")!, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, DateOfBirth: null, - ApplyLegacyAlertsBehavior: true); + ApplyLegacyAlertsBehavior: true, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs index 65fc44305..329306c91 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240416/Controllers/TeachersController.cs @@ -31,7 +31,8 @@ public async Task GetAsync( trn, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, dateOfBirth, - ApplyLegacyAlertsBehavior: true); + ApplyLegacyAlertsBehavior: true, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs index f3f8a2a73..3652f9ea9 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonController.cs @@ -29,7 +29,8 @@ public async Task GetAsync( Trn: User.FindFirstValue("trn")!, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, DateOfBirth: null, - ApplyLegacyAlertsBehavior: true); + ApplyLegacyAlertsBehavior: true, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonsController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonsController.cs index 46ebfbe65..0728d1f0c 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonsController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Controllers/PersonsController.cs @@ -31,7 +31,8 @@ public async Task GetAsync( trn, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, dateOfBirth, - ApplyLegacyAlertsBehavior: true); + ApplyLegacyAlertsBehavior: true, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs index ee444423c..749fb979b 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/FindPersonResponse.cs @@ -10,6 +10,6 @@ public partial record FindPersonResponse public required IReadOnlyCollection Results { get; init; } } -[AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResultItem))] +[AutoMap(typeof(FindPersonsResultItem))] [GenerateVersionedDto(typeof(V20240101.Responses.FindTeachersResponseResult))] public partial record FindPersonResponseResult; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs index 2144442d2..22126382e 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240606/Responses/GetPersonResponse.cs @@ -1,4 +1,6 @@ +using AutoMapper.Configuration.Annotations; using Optional; +using TeachingRecordSystem.Api.Infrastructure.Mapping; using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos; @@ -18,6 +20,7 @@ public partial record GetPersonResponse public required string? EmailAddress { get; set; } public required GetPersonResponseQts? Qts { get; init; } public required GetPersonResponseEyts? Eyts { get; init; } + [SourceMember(nameof(GetPersonResult.DqtInduction))] public required Option Induction { get; init; } public required Option> InitialTeacherTraining { get; init; } public required Option> NpqQualifications { get; init; } @@ -37,14 +40,14 @@ public partial record GetPersonResponseQts; [GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseEyts))] public partial record GetPersonResponseEyts; -[AutoMap(typeof(GetPersonResultInduction))] +[AutoMap(typeof(GetPersonResultDqtInduction))] [GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInduction), excludeMembers: ["Periods"])] public partial record GetPersonResponseInduction { public required IReadOnlyCollection Periods { get; init; } } -[AutoMap(typeof(GetPersonResultInductionPeriod))] +[AutoMap(typeof(GetPersonResultDqtInductionPeriod))] [GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInductionPeriod), excludeMembers: ["AppropriateBody"])] public partial record GetPersonResponseInductionPeriod { @@ -59,9 +62,12 @@ public partial record GetPersonResponseInductionPeriodAppropriateBody; [GenerateVersionedDto(typeof(V20240101.Responses.GetTeacherResponseInitialTeacherTraining), excludeMembers: ["Qualification", "AgeRange", "Provider", "Subjects"])] public partial record GetPersonResponseInitialTeacherTraining { + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required GetPersonResponseInitialTeacherTrainingQualification? Qualification { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter))] public required GetPersonResponseInitialTeacherTrainingAgeRange? AgeRange { get; init; } public required GetPersonResponseInitialTeacherTrainingProvider? Provider { get; init; } + [ValueConverter(typeof(UnwrapFromOptionValueConverter, IReadOnlyCollection>))] public required IReadOnlyCollection Subjects { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/MapperProfile.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/MapperProfile.cs index 1f9f66c95..42f26db86 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/MapperProfile.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/MapperProfile.cs @@ -1,5 +1,4 @@ using TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos; -using InductionStatusInfo = TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos.InductionStatusInfo; namespace TeachingRecordSystem.Api.V3.V20240814; @@ -8,7 +7,7 @@ public class MapperProfile : Profile public MapperProfile() { CreateMap(); - CreateMap(); + CreateMap(); CreateMap(); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonResponse.cs index c1db1dcee..345f4c634 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonResponse.cs @@ -1,7 +1,7 @@ +using AutoMapper.Configuration.Annotations; using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Api.V3.V20240814.Requests; using TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos; -using InductionStatusInfo = TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos.InductionStatusInfo; namespace TeachingRecordSystem.Api.V3.V20240814.Responses; @@ -12,11 +12,12 @@ public partial record FindPersonResponse public required IReadOnlyCollection Results { get; init; } } -[AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResultItem))] +[AutoMap(typeof(FindPersonsResultItem))] [GenerateVersionedDto(typeof(V20240101.Responses.FindTeachersResponseResult))] public partial record FindPersonResponseResult { - public required InductionStatusInfo InductionStatus { get; init; } + [SourceMember(nameof(FindPersonsResultItem.DqtInductionStatus))] + public required DqtInductionStatusInfo InductionStatus { get; init; } public required QtsInfo? Qts { get; init; } public required EytsInfo? Eyts { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonsResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonsResponse.cs index 50467f623..07a1ee108 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonsResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240814/Responses/FindPersonsResponse.cs @@ -2,19 +2,18 @@ using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos; using TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos; -using InductionStatusInfo = TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos.InductionStatusInfo; namespace TeachingRecordSystem.Api.V3.V20240814.Responses; -[AutoMap(typeof(FindPersonsByTrnAndDateOfBirthResult))] +[AutoMap(typeof(FindPersonsResult))] public record FindPersonsResponse { public required int Total { get; init; } - [SourceMember(nameof(FindPersonsByTrnAndDateOfBirthResult.Items))] + [SourceMember(nameof(FindPersonsResult.Items))] public required IReadOnlyCollection Results { get; init; } } -[AutoMap(typeof(FindPersonsByTrnAndDateOfBirthResultItem))] +[AutoMap(typeof(FindPersonsResultItem))] public record FindPersonsResponseResult { public required string Trn { get; init; } @@ -24,7 +23,8 @@ public record FindPersonsResponseResult public required string LastName { get; init; } public required IReadOnlyCollection Sanctions { get; init; } public required IReadOnlyCollection PreviousNames { get; init; } - public required InductionStatusInfo InductionStatus { get; init; } + [SourceMember(nameof(FindPersonsResultItem.DqtInductionStatus))] + public required DqtInductionStatusInfo InductionStatus { get; init; } public required QtsInfo? Qts { get; init; } public required EytsInfo? Eyts { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonController.cs index 0cd998871..e448682ac 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonController.cs @@ -29,7 +29,8 @@ public async Task GetAsync( Trn: User.FindFirstValue("trn")!, include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, DateOfBirth: null, - ApplyLegacyAlertsBehavior: false); + ApplyLegacyAlertsBehavior: false, + ApplyAppropriateBodyUserRestrictions: false); var result = await handler.HandleAsync(command); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonsController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonsController.cs index b4840182d..1e7fbb280 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonsController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Controllers/PersonsController.cs @@ -19,7 +19,7 @@ public class PersonsController(IMapper mapper) : ControllerBase Description = "Gets the details of the person corresponding to the given TRN.")] [ProducesResponseType(typeof(GetPersonResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [Authorize(Policy = AuthorizationPolicies.ApiKey, Roles = $"{ApiRoles.GetPerson},{ApiRoles.AppropriateBody}")] public async Task GetAsync( [FromRoute] string trn, @@ -29,29 +29,18 @@ public async Task GetAsync( { include ??= GetPersonRequestIncludes.None; - if (User.IsInRole(ApiRoles.AppropriateBody)) - { - if ((include & ~(GetPersonRequestIncludes.Induction | GetPersonRequestIncludes.Alerts | GetPersonRequestIncludes.InitialTeacherTraining)) != 0) - { - return Forbid(); - } - - if (dateOfBirth is null) - { - return Forbid(); - } - } - var command = new GetPersonCommand( trn, (GetPersonCommandIncludes)include, dateOfBirth, - ApplyLegacyAlertsBehavior: false); + ApplyLegacyAlertsBehavior: false, + ApplyAppropriateBodyUserRestrictions: User.IsInRole(ApiRoles.AppropriateBody)); var result = await handler.HandleAsync(command); - return result.ToActionResult(r => Ok(GetPersonResponse.Map(r, mapper, User.IsInRole(ApiRoles.AppropriateBody)))) - .MapErrorCode(ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status404NotFound); + return result.ToActionResult(r => Ok(mapper.Map(r))) + .MapErrorCode(ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status404NotFound) + .MapErrorCode(ApiError.ErrorCodes.ForbiddenForAppropriateBody, StatusCodes.Status403Forbidden); } [HttpPost("find")] @@ -68,8 +57,7 @@ public async Task FindPersonsAsync( { var command = new FindPersonsByTrnAndDateOfBirthCommand(request.Persons.Select(p => (p.Trn, p.DateOfBirth))); var result = await handler.HandleAsync(command); - var response = mapper.Map(result); - return Ok(response); + return result.ToActionResult(r => Ok(mapper.Map(r))); } [HttpGet("")] diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonResponse.cs index f5db43ae9..1eb06d1b1 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonResponse.cs @@ -1,6 +1,6 @@ using TeachingRecordSystem.Api.V3.Implementation.Operations; -using TeachingRecordSystem.Api.V3.V20240920.Requests; using TeachingRecordSystem.Core.ApiSchema.V3.V20240920.Dtos; +using FindPersonRequest = TeachingRecordSystem.Api.V3.V20240920.Requests.FindPersonRequest; namespace TeachingRecordSystem.Api.V3.V20240920.Responses; @@ -11,7 +11,7 @@ public partial record FindPersonResponse public required IReadOnlyCollection Results { get; init; } } -[AutoMap(typeof(FindPersonByLastNameAndDateOfBirthResultItem))] +[AutoMap(typeof(FindPersonsResultItem))] [GenerateVersionedDto(typeof(V20240814.Responses.FindPersonResponseResult), excludeMembers: ["Sanctions"])] public partial record FindPersonResponseResult { diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonsResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonsResponse.cs index 96817b1cc..75ee4478e 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonsResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/FindPersonsResponse.cs @@ -4,15 +4,15 @@ namespace TeachingRecordSystem.Api.V3.V20240920.Responses; -[AutoMap(typeof(FindPersonsByTrnAndDateOfBirthResult))] +[AutoMap(typeof(FindPersonsResult))] [GenerateVersionedDto(typeof(V20240814.Responses.FindPersonsResponse), excludeMembers: ["Results"])] public partial record FindPersonsResponse { - [SourceMember(nameof(FindPersonsByTrnAndDateOfBirthResult.Items))] + [SourceMember(nameof(FindPersonsResult.Items))] public required IReadOnlyCollection Results { get; init; } } -[AutoMap(typeof(FindPersonsByTrnAndDateOfBirthResultItem))] +[AutoMap(typeof(FindPersonsResultItem))] [GenerateVersionedDto(typeof(V20240814.Responses.FindPersonsResponseResult), excludeMembers: ["Sanctions"])] public partial record FindPersonsResponseResult { diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/GetPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/GetPersonResponse.cs index ebe15ab4d..56f99a691 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/GetPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/V20240920/Responses/GetPersonResponse.cs @@ -1,6 +1,5 @@ using AutoMapper.Configuration.Annotations; using Optional; -using TeachingRecordSystem.Api.Infrastructure.Mapping; using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Api.V3.V20240606.Responses; using TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos; @@ -13,41 +12,12 @@ namespace TeachingRecordSystem.Api.V3.V20240920.Responses; public partial record GetPersonResponse { public required Option> Alerts { get; init; } + [SourceMember(nameof(GetPersonResult.DqtInduction))] public required Option Induction { get; init; } public required Option> InitialTeacherTraining { get; init; } - - public static GetPersonResponse Map(GetPersonResult result, IMapper mapper, bool userHasAppropriateBodyRole) - { - var response = mapper.Map(result); - - if (userHasAppropriateBodyRole) - { - response = response with - { - InitialTeacherTraining = response.InitialTeacherTraining - .Map(itts => itts - .Select(itt => new GetPersonResponseInitialTeacherTraining() - { - Provider = itt.Provider, - Qualification = default, - StartDate = default, - EndDate = default, - ProgrammeType = default, - ProgrammeTypeDescription = default, - Result = default, - AgeRange = default, - Subjects = default - }) - .Where(itt => itt.Provider is not null) - .AsReadOnly()) - }; - } - - return response; - } } -[AutoMap(typeof(GetPersonResultInduction))] +[AutoMap(typeof(GetPersonResultDqtInduction))] [GenerateVersionedDto(typeof(V20240606.Responses.GetPersonResponseInduction), excludeMembers: ["Periods"])] public partial record GetPersonResponseInduction; @@ -55,28 +25,12 @@ public partial record GetPersonResponseInduction; public partial record GetPersonResponseInitialTeacherTraining { public required GetPersonResponseInitialTeacherTrainingProvider? Provider { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option Qualification { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option StartDate { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option EndDate { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option ProgrammeType { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option ProgrammeTypeDescription { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option Result { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter))] public required Option AgeRange { get; init; } - - [ValueConverter(typeof(WrapWithOptionValueConverter, IReadOnlyCollection>))] public required Option> Subjects { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonController.cs new file mode 100644 index 000000000..ef67df8c7 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonController.cs @@ -0,0 +1,40 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Swashbuckle.AspNetCore.Annotations; +using TeachingRecordSystem.Api.Infrastructure.ModelBinding; +using TeachingRecordSystem.Api.Infrastructure.Security; +using TeachingRecordSystem.Api.V3.Implementation.Operations; +using TeachingRecordSystem.Api.V3.V20240920.Requests; +using TeachingRecordSystem.Api.V3.VNext.Responses; + +namespace TeachingRecordSystem.Api.V3.VNext.Controllers; + +[Route("person")] +public class PersonController(IMapper mapper) : ControllerBase +{ + [Authorize(AuthorizationPolicies.IdentityUserWithTrn)] + [HttpGet] + [SwaggerOperation( + OperationId = "GetCurrentPerson", + Summary = "Get the authenticated person's details", + Description = "Gets the details for the authenticated person.")] + [ProducesResponseType(typeof(GetPersonResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(void), StatusCodes.Status403Forbidden)] + public async Task GetAsync( + [FromQuery, ModelBinder(typeof(FlagsEnumStringListModelBinder)), SwaggerParameter("The additional properties to include in the response.")] GetPersonRequestIncludes? include, + [FromServices] GetPersonHandler handler) + { + var command = new GetPersonCommand( + Trn: User.FindFirstValue("trn")!, + include is not null ? (GetPersonCommandIncludes)include : GetPersonCommandIncludes.None, + DateOfBirth: null, + ApplyLegacyAlertsBehavior: false, + ApplyAppropriateBodyUserRestrictions: false); + + var result = await handler.HandleAsync(command); + + return result.ToActionResult(r => Ok(mapper.Map(r))) + .MapErrorCode(ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status403Forbidden); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs index e0527a50c..7d5008625 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs @@ -5,8 +5,11 @@ using TeachingRecordSystem.Api.Infrastructure.Security; using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Api.V3.V20240920.Requests; -using TeachingRecordSystem.Api.V3.V20240920.Responses; using TeachingRecordSystem.Api.V3.VNext.Requests; +using TeachingRecordSystem.Api.V3.VNext.Responses; +using FindPersonResponse = TeachingRecordSystem.Api.V3.VNext.Responses.FindPersonResponse; +using FindPersonResponseResult = TeachingRecordSystem.Api.V3.VNext.Responses.FindPersonResponseResult; +using FindPersonsResponse = TeachingRecordSystem.Api.V3.VNext.Responses.FindPersonsResponse; namespace TeachingRecordSystem.Api.V3.VNext.Controllers; @@ -32,7 +35,7 @@ public IActionResult SetInductionStatus([FromRoute] string trn, [FromBody] SetIn Description = "Gets the details of the person corresponding to the given TRN.")] [ProducesResponseType(typeof(GetPersonResponse), StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] [Authorize(Policy = AuthorizationPolicies.ApiKey, Roles = $"{ApiRoles.GetPerson},{ApiRoles.AppropriateBody}")] public async Task GetAsync( [FromRoute] string trn, @@ -43,19 +46,6 @@ public async Task GetAsync( { include ??= GetPersonRequestIncludes.None; - if (User.IsInRole(ApiRoles.AppropriateBody)) - { - if ((include & ~(GetPersonRequestIncludes.Induction | GetPersonRequestIncludes.Alerts | GetPersonRequestIncludes.InitialTeacherTraining)) != 0) - { - return Forbid(); - } - - if (dateOfBirth is null || nationalInsuranceNumber is not null) - { - return Forbid(); - } - } - // For now we don't support both a DOB and NINO being passed if (dateOfBirth is not null && nationalInsuranceNumber is not null) { @@ -67,12 +57,55 @@ public async Task GetAsync( (GetPersonCommandIncludes)include, dateOfBirth, ApplyLegacyAlertsBehavior: false, + ApplyAppropriateBodyUserRestrictions: User.IsInRole(ApiRoles.AppropriateBody), nationalInsuranceNumber); var result = await handler.HandleAsync(command); return result - .ToActionResult(r => Ok(GetPersonResponse.Map(r, mapper, User.IsInRole(ApiRoles.AppropriateBody)))) - .MapErrorCode(ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status404NotFound); + .ToActionResult(r => Ok(mapper.Map(r))) + .MapErrorCode(ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status404NotFound) + .MapErrorCode(ApiError.ErrorCodes.ForbiddenForAppropriateBody, StatusCodes.Status403Forbidden); + } + + [HttpPost("find")] + [SwaggerOperation( + OperationId = "FindPersons", + Summary = "Find persons", + Description = "Finds persons matching the specified criteria.")] + [ProducesResponseType(typeof(FindPersonsResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [Authorize(Policy = AuthorizationPolicies.ApiKey, Roles = ApiRoles.GetPerson)] + public async Task FindPersonsAsync( + [FromBody] FindPersonsRequest request, + [FromServices] FindPersonsByTrnAndDateOfBirthHandler handler) + { + var command = new FindPersonsByTrnAndDateOfBirthCommand(request.Persons.Select(p => (p.Trn, p.DateOfBirth))); + var result = await handler.HandleAsync(command); + return result.ToActionResult(r => Ok(mapper.Map(r))); + } + + [HttpGet("")] + [SwaggerOperation( + OperationId = "FindPerson", + Summary = "Find person", + Description = "Finds a person matching the specified criteria.")] + [ProducesResponseType(typeof(FindPersonResponse), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [Authorize(Policy = AuthorizationPolicies.ApiKey, Roles = ApiRoles.GetPerson)] + public async Task FindPersonsAsync( + FindPersonRequest request, + [FromServices] FindPersonByLastNameAndDateOfBirthHandler handler) + { + var command = new FindPersonByLastNameAndDateOfBirthCommand(request.LastName!, request.DateOfBirth!.Value); + var result = await handler.HandleAsync(command); + + return result.ToActionResult(r => + Ok(new FindPersonResponse() + { + Total = r.Total, + Query = request, + Results = r.Items.Select(mapper.Map).AsReadOnly() + })); } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs index 636386005..446dcac4b 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs @@ -2,7 +2,7 @@ namespace TeachingRecordSystem.Api.V3.VNext.Requests; public record SetInductionStatusRequest { - public required TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos.InductionStatus InductionStatus { get; init; } + public required TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos.DqtInductionStatus DqtInductionStatus { get; init; } public required DateOnly StartDate { get; init; } public DateOnly? CompletionDate { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs new file mode 100644 index 000000000..fb3b8ee0b --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs @@ -0,0 +1,16 @@ +using TeachingRecordSystem.Api.V3.Implementation.Operations; + +namespace TeachingRecordSystem.Api.V3.VNext.Responses; + +[GenerateVersionedDto(typeof(V20240920.Responses.FindPersonResponse), excludeMembers: ["Results"])] +public partial record FindPersonResponse +{ + public required IReadOnlyCollection Results { get; init; } +} + +[AutoMap(typeof(FindPersonsResultItem))] +[GenerateVersionedDto(typeof(V20240920.Responses.FindPersonResponseResult), excludeMembers: ["InductionStatus"])] +public partial record FindPersonResponseResult +{ + public required InductionStatus InductionStatus { get; init; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs new file mode 100644 index 000000000..be0e7fa8a --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs @@ -0,0 +1,19 @@ +using AutoMapper.Configuration.Annotations; +using TeachingRecordSystem.Api.V3.Implementation.Operations; + +namespace TeachingRecordSystem.Api.V3.VNext.Responses; + +[AutoMap(typeof(FindPersonsResult))] +[GenerateVersionedDto(typeof(V20240920.Responses.FindPersonsResponse), excludeMembers: ["Results"])] +public partial record FindPersonsResponse +{ + [SourceMember(nameof(FindPersonsResult.Items))] + public required IReadOnlyCollection Results { get; init; } +} + +[AutoMap(typeof(FindPersonsResultItem))] +[GenerateVersionedDto(typeof(V20240920.Responses.FindPersonsResponseResult), excludeMembers: ["InductionStatus"])] +public partial record FindPersonsResponseResult +{ + public required InductionStatus InductionStatus { get; init; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/GetPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/GetPersonResponse.cs new file mode 100644 index 000000000..8f1e96b03 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/GetPersonResponse.cs @@ -0,0 +1,18 @@ +using Optional; +using TeachingRecordSystem.Api.V3.Implementation.Operations; +using TeachingRecordSystem.Core.ApiSchema.V3.VNext.Dtos; + +namespace TeachingRecordSystem.Api.V3.VNext.Responses; + +[AutoMap(typeof(GetPersonResult))] +[GenerateVersionedDto(typeof(V20240920.Responses.GetPersonResponse), excludeMembers: ["Induction"])] +public partial record GetPersonResponse +{ + public required Option Induction { get; init; } +} + +[AutoMap(typeof(GetPersonResultInduction))] +public partial record GetPersonResponseInduction : InductionInfo +{ + public required string? CertificateUrl { get; init; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/InductionStatus.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/DqtInductionStatus.cs similarity index 87% rename from TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/InductionStatus.cs rename to TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/DqtInductionStatus.cs index c7d02a2c8..7dfb308e3 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/InductionStatus.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240101/Dtos/DqtInductionStatus.cs @@ -1,6 +1,6 @@ namespace TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos; -public enum InductionStatus +public enum DqtInductionStatus { Exempt, Fail, diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/InductionStatusInfo.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/DqtInductionStatusInfo.cs similarity index 53% rename from TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/InductionStatusInfo.cs rename to TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/DqtInductionStatusInfo.cs index 915e49e4b..5521aa68e 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/InductionStatusInfo.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/V20240814/Dtos/DqtInductionStatusInfo.cs @@ -1,7 +1,7 @@ namespace TeachingRecordSystem.Core.ApiSchema.V3.V20240814.Dtos; -public record InductionStatusInfo +public record DqtInductionStatusInfo { - public required V20240101.Dtos.InductionStatus Status { get; init; } + public required V20240101.Dtos.DqtInductionStatus Status { get; init; } public required string StatusDescription { get; init; } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionInfo.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionInfo.cs new file mode 100644 index 000000000..6fbc55314 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionInfo.cs @@ -0,0 +1,8 @@ +namespace TeachingRecordSystem.Core.ApiSchema.V3.VNext.Dtos; + +public record InductionInfo +{ + public required InductionStatus Status { get; init; } + public required DateOnly? StartDate { get; init; } + public required DateOnly? CompletedDate { get; init; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/InductionStatus.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/InductionStatus.cs index f652e190c..12aaf4f84 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/InductionStatus.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/Models/InductionStatus.cs @@ -35,6 +35,9 @@ public static class InductionStatusRegistry public static bool RequiresCompletedDate(this InductionStatus status) => _info[status].RequiresCompletedDate; + public static InductionStatus ToInductionStatus(this dfeta_InductionStatus status) => + ToInductionStatus((dfeta_InductionStatus?)status); + public static InductionStatus ToInductionStatus(this dfeta_InductionStatus? status) => status switch { null => InductionStatus.None, diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherByTrnTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherByTrnTests.cs index 3bb31f6a2..0ef4df701 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherByTrnTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherByTrnTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using TeachingRecordSystem.Api.V3.Implementation.Dtos; using static TeachingRecordSystem.TestCommon.TestData; @@ -133,6 +134,24 @@ public async Task Get_ValidRequestWithInduction_ReturnsExpectedInductionContent( responseInduction); } + [Fact] + public async Task Get_ValidRequestWithInductionAndPersonHasNullDqtStatus_ReturnsNullInductionContent() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p.WithTrn()); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, $"/v3/teachers/{person.Trn}?include=Induction"); + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + Assert.Equal(JsonValueKind.Null, responseInduction.ValueKind); + } + [Fact] public async Task Get_ValidRequestWithInitialTeacherTraining_ReturnsExpectedInitialTeacherTrainingContent() { diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherTests.cs index 2420e85b9..99e66afcb 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240101/GetTeacherTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using TeachingRecordSystem.Api.V3.Implementation.Dtos; using static TeachingRecordSystem.TestCommon.TestData; @@ -213,6 +214,24 @@ public async Task Get_ValidRequestWithInduction_ReturnsExpectedInductionContent( responseInduction); } + [Fact] + public async Task Get_ValidRequestWithInductionAndPersonHasNullDqtStatus_ReturnsNullInductionContent() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p.WithTrn()); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/v3/teacher?include=Induction"); + + // Act + var response = await GetHttpClientWithIdentityAccessToken(person.Trn!).SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + Assert.Equal(JsonValueKind.Null, responseInduction.ValueKind); + } + [Fact] public async Task Get_ValidRequestWithInitialTeacherTraining_ReturnsExpectedInitialTeacherTrainingContent() { diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonByTrnTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonByTrnTests.cs index 6d2e10fe1..275ebd1d0 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonByTrnTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonByTrnTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using TeachingRecordSystem.Api.V3.Implementation.Dtos; using static TeachingRecordSystem.TestCommon.TestData; @@ -132,6 +133,24 @@ public async Task Get_ValidRequestWithInduction_ReturnsExpectedInductionContent( responseInduction); } + [Fact] + public async Task Get_ValidRequestWithInductionAndPersonHasNullDqtStatus_ReturnsNullInductionContent() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p.WithTrn()); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, $"/v3/persons/{person.Trn}?include=Induction"); + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + Assert.Equal(JsonValueKind.Null, responseInduction.ValueKind); + } + [Fact] public async Task Get_ValidRequestWithInitialTeacherTraining_ReturnsExpectedInitialTeacherTrainingContent() { diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonTests.cs index 125f975a3..bd09cd1e1 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240606/GetPersonTests.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using TeachingRecordSystem.Api.V3.Implementation.Dtos; using static TeachingRecordSystem.TestCommon.TestData; @@ -208,6 +209,24 @@ public async Task Get_ValidRequestWithInduction_ReturnsExpectedInductionContent( responseInduction); } + [Fact] + public async Task Get_ValidRequestWithInductionAndPersonHasNullDqtStatus_ReturnsNullInductionContent() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p.WithTrn()); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/v3/person?include=Induction"); + + // Act + var response = await GetHttpClientWithIdentityAccessToken(person.Trn!).SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + Assert.Equal(JsonValueKind.Null, responseInduction.ValueKind); + } + [Fact] public async Task Get_ValidRequestWithInitialTeacherTraining_ReturnsExpectedInitialTeacherTrainingContent() { diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240920/FindPersonsByTrnAndDateOfBirthTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240920/FindPersonsByTrnAndDateOfBirthTests.cs index 70a165eef..b3dcb8669 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240920/FindPersonsByTrnAndDateOfBirthTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/V20240920/FindPersonsByTrnAndDateOfBirthTests.cs @@ -13,7 +13,6 @@ public FindPersonsByTrnAndDateOfBirthTests(HostFixture hostFixture) : base(hostF public async Task Get_ValidRequestWithMatchOnPersonWithAlerts_ReturnsExpectedAlertsContent() { // Arrange - var findBy = "LastNameAndDateOfBirth"; var lastName = "Smith"; var dateOfBirth = new DateOnly(1990, 1, 1); @@ -28,9 +27,20 @@ public async Task Get_ValidRequestWithMatchOnPersonWithAlerts_ReturnsExpectedAle var alert = person.Alerts.Single(); - var request = new HttpRequestMessage( - HttpMethod.Get, - $"/v3/persons?findBy={findBy}&lastName={lastName}&dateOfBirth={dateOfBirth:yyyy-MM-dd}"); + var request = new HttpRequestMessage(HttpMethod.Post, $"/v3/persons/find") + { + Content = JsonContent.Create(new + { + persons = new[] + { + new + { + trn = person.Trn, + dateOfBirth = person.DateOfBirth + } + } + }) + }; // Act var response = await GetHttpClientWithApiKey().SendAsync(request); diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonByLastNameAndDateOfBirthTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonByLastNameAndDateOfBirthTests.cs new file mode 100644 index 000000000..faecce063 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonByLastNameAndDateOfBirthTests.cs @@ -0,0 +1,70 @@ +namespace TeachingRecordSystem.Api.Tests.V3.VNext; + +[Collection(nameof(DisableParallelization))] +public class FindPersonByLastNameAndDateOfBirthTests : TestBase +{ + public FindPersonByLastNameAndDateOfBirthTests(HostFixture hostFixture) : base(hostFixture) + { + XrmFakedContext.DeleteAllEntities(); + SetCurrentApiClient([ApiRoles.GetPerson]); + } + + [Fact] + public async Task Get_PersonHasNullDqtInductionStatus_ReturnsNoneInductionStatus() + { + // Arrange + var lastName = "Smith"; + var dateOfBirth = new DateOnly(1990, 1, 1); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithLastName(lastName) + .WithDateOfBirth(dateOfBirth)); + + var request = new HttpRequestMessage( + HttpMethod.Get, + $"/v3/persons?findBy=LastNameAndDateOfBirth&lastName={lastName}&dateOfBirth={dateOfBirth:yyyy-MM-dd}"); + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("results").EnumerateArray().Single().GetProperty("inductionStatus").GetString(); + Assert.Equal(InductionStatus.None.ToString(), responseInduction); + } + + [Fact] + public async Task Get_PersonHasNonNullDqtInductionStatus_ReturnsExpectedStatus() + { + // Arrange + var lastName = "Smith"; + var dateOfBirth = new DateOnly(1990, 1, 1); + var dqtInductionStatus = dfeta_InductionStatus.Pass; + var inductionStatus = dqtInductionStatus.ToInductionStatus(); + var inductionStartDate = new DateOnly(1996, 2, 3); + var inductionCompletedDate = new DateOnly(1996, 6, 7); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithLastName(lastName) + .WithDateOfBirth(dateOfBirth) + .WithDqtInduction( + dqtInductionStatus, + inductionExemptionReason: null, + inductionStartDate, + inductionCompletedDate)); + + var request = new HttpRequestMessage( + HttpMethod.Get, + $"/v3/persons?findBy=LastNameAndDateOfBirth&lastName={lastName}&dateOfBirth={dateOfBirth:yyyy-MM-dd}"); + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("results").EnumerateArray().Single().GetProperty("inductionStatus").GetString(); + Assert.Equal(inductionStatus.ToString(), responseInduction); + } +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonsByTrnAndDateOfBirthTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonsByTrnAndDateOfBirthTests.cs new file mode 100644 index 000000000..b19a56c68 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/FindPersonsByTrnAndDateOfBirthTests.cs @@ -0,0 +1,92 @@ +namespace TeachingRecordSystem.Api.Tests.V3.VNext; + +[Collection(nameof(DisableParallelization))] +public class FindPersonsByTrnAndDateOfBirthTests : TestBase +{ + public FindPersonsByTrnAndDateOfBirthTests(HostFixture hostFixture) : base(hostFixture) + { + XrmFakedContext.DeleteAllEntities(); + SetCurrentApiClient([ApiRoles.GetPerson]); + } + + [Fact] + public async Task Get_PersonHasNullDqtInductionStatus_ReturnsNoneInductionStatus() + { + // Arrange + var lastName = "Smith"; + var dateOfBirth = new DateOnly(1990, 1, 1); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithLastName(lastName) + .WithDateOfBirth(dateOfBirth)); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/v3/persons/find") + { + Content = JsonContent.Create(new + { + persons = new[] + { + new + { + trn = person.Trn, + dateOfBirth = person.DateOfBirth + } + } + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("results").EnumerateArray().Single().GetProperty("inductionStatus").GetString(); + Assert.Equal(InductionStatus.None.ToString(), responseInduction); + } + + [Fact] + public async Task Get_PersonHasNonNullDqtInductionStatus_ReturnsExpectedStatus() + { + // Arrange + var lastName = "Smith"; + var dateOfBirth = new DateOnly(1990, 1, 1); + var dqtInductionStatus = dfeta_InductionStatus.Pass; + var inductionStatus = dqtInductionStatus.ToInductionStatus(); + var inductionStartDate = new DateOnly(1996, 2, 3); + var inductionCompletedDate = new DateOnly(1996, 6, 7); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithLastName(lastName) + .WithDateOfBirth(dateOfBirth) + .WithDqtInduction( + dqtInductionStatus, + inductionExemptionReason: null, + inductionStartDate, + inductionCompletedDate)); + + var request = new HttpRequestMessage(HttpMethod.Post, $"/v3/persons/find") + { + Content = JsonContent.Create(new + { + persons = new[] + { + new + { + trn = person.Trn, + dateOfBirth = person.DateOfBirth + } + } + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("results").EnumerateArray().Single().GetProperty("inductionStatus").GetString(); + Assert.Equal(inductionStatus.ToString(), responseInduction); + } +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonByTrnTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonByTrnTests.cs index fef73f12d..76f35ad1c 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonByTrnTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonByTrnTests.cs @@ -106,4 +106,69 @@ await TestData.CreateTpsEmploymentAsync( // Assert await AssertEx.JsonResponseAsync(response, expectedStatusCode: 200); } + + [Fact] + public async Task Get_WithNonNullDqtInductionStatus_ReturnsExpectedInduction() + { + // Arrange + var dqtStatus = dfeta_InductionStatus.Pass; + var status = dqtStatus.ToInductionStatus(); + var startDate = new DateOnly(1996, 2, 3); + var completedDate = new DateOnly(1996, 6, 7); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithDqtInduction( + dqtStatus, + inductionExemptionReason: null, + startDate, + completedDate)); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, $"/v3/persons/{person.Trn}?include=Induction"); + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + + AssertEx.JsonObjectEquals( + new + { + status = status.ToString(), + startDate = startDate.ToString("yyyy-MM-dd"), + completedDate = completedDate.ToString("yyyy-MM-dd"), + certificateUrl = "/v3/certificates/induction" + }, + responseInduction); + } + + [Fact] + public async Task Get_WithNullDqtInductionStatus_ReturnsNoneInductionStatus() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p.WithTrn()); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, $"/v3/persons/{person.Trn}?include=Induction"); + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + + AssertEx.JsonObjectEquals( + new + { + status = InductionStatus.None.ToString(), + startDate = (DateOnly?)null, + completedDate = (DateOnly?)null, + certificateUrl = (string?)null + }, + responseInduction); + } } diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonTests.cs new file mode 100644 index 000000000..8ce9880bb --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/GetPersonTests.cs @@ -0,0 +1,69 @@ +namespace TeachingRecordSystem.Api.Tests.V3.VNext; + +public class GetPersonTests(HostFixture hostFixture) : TestBase(hostFixture) +{ + [Fact] + public async Task Get_WithNonNullDqtInductionStatus_ReturnsExpectedInduction() + { + // Arrange + var dqtStatus = dfeta_InductionStatus.Pass; + var status = dqtStatus.ToInductionStatus(); + var startDate = new DateOnly(1996, 2, 3); + var completedDate = new DateOnly(1996, 6, 7); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithDqtInduction( + dqtStatus, + inductionExemptionReason: null, + startDate, + completedDate)); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/v3/person?include=Induction"); + + // Act + var response = await GetHttpClientWithIdentityAccessToken(person.Trn!).SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + + AssertEx.JsonObjectEquals( + new + { + status = status.ToString(), + startDate = startDate.ToString("yyyy-MM-dd"), + completedDate = completedDate.ToString("yyyy-MM-dd"), + certificateUrl = "/v3/certificates/induction" + }, + responseInduction); + } + + [Fact] + public async Task Get_WithNullDqtInductionStatus_ReturnsNoneInductionStatus() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p.WithTrn()); + + // Arrange + var request = new HttpRequestMessage(HttpMethod.Get, "/v3/person?include=Induction"); + + // Act + var response = await GetHttpClientWithIdentityAccessToken(person.Trn!).SendAsync(request); + + // Assert + var jsonResponse = await AssertEx.JsonResponseAsync(response); + var responseInduction = jsonResponse.RootElement.GetProperty("induction"); + + AssertEx.JsonObjectEquals( + new + { + status = InductionStatus.None.ToString(), + startDate = (DateOnly?)null, + completedDate = (DateOnly?)null, + certificateUrl = (string?)null + }, + responseInduction); + } +} From bc483daacd1a4ac5f858bd754e563d50cd9c5082 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Fri, 6 Dec 2024 15:03:00 +0000 Subject: [PATCH 4/7] Fix infinite redirect (#1739) --- .../FormFlow/Filters/ActivateInstanceFilter.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.UiCommon/FormFlow/Filters/ActivateInstanceFilter.cs b/TeachingRecordSystem/src/TeachingRecordSystem.UiCommon/FormFlow/Filters/ActivateInstanceFilter.cs index 265e73d49..830e9d286 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.UiCommon/FormFlow/Filters/ActivateInstanceFilter.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.UiCommon/FormFlow/Filters/ActivateInstanceFilter.cs @@ -1,4 +1,3 @@ -using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Abstractions; using Microsoft.AspNetCore.Mvc.Filters; @@ -36,8 +35,10 @@ public async Task OnResourceExecutionAsync(ResourceExecutingContext context, Res if (journeyDescriptor.AppendUniqueKey) { // Need to redirect back to ourselves with the unique ID appended - var currentUrl = context.HttpContext.Request.GetEncodedUrl(); - var newUrl = QueryHelpers.AddQueryString(currentUrl, Constants.UniqueKeyQueryParameterName, newInstance.InstanceId.UniqueKey!); + var request = context.HttpContext.Request; + var qs = QueryHelpers.ParseQuery(request.QueryString.ToString()); + qs[Constants.UniqueKeyQueryParameterName] = newInstance.InstanceId.UniqueKey!; + var newUrl = QueryHelpers.AddQueryString(request.Path, qs); context.Result = new RedirectResult(newUrl); return; } From 444ea97dd99c549289342a4d4c60939a15184f43 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Fri, 6 Dec 2024 15:26:42 +0000 Subject: [PATCH 5/7] Add API endpoint for CPD to set induction status (#1722) --- .../src/TeachingRecordSystem.Api/ApiError.cs | 29 + .../V3/Implementation/Dtos/Gender.cs | 2 +- .../Operations/SetCpdInductionStatus.cs | 112 ++++ .../{SetDeceasedRequest.cs => SetDeceased.cs} | 0 .../V3/VNext/Controllers/PersonsController.cs | 28 +- .../Controllers/TrnRequestsController.cs | 5 +- .../VNext/Requests/CreateTrnRequestRequest.cs | 2 +- .../Requests/SetCpdInductionStatusRequest.cs | 11 + .../Requests/SetInductionStatusRequest.cs | 8 - .../V3/VNext/Responses/FindPersonResponse.cs | 1 + .../V3/VNext/Responses/FindPersonsResponse.cs | 1 + .../src/TeachingRecordSystem.Core/ApiRoles.cs | 4 +- .../V3/VNext/Dtos/InductionStatus.cs | 12 + .../V3/VNext/CreateTrnRequestTests.cs | 2 +- .../V3/VNext/SetCpdInductionStatusTests.cs | 501 ++++++++++++++++++ 15 files changed, 697 insertions(+), 21 deletions(-) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetCpdInductionStatus.cs rename TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/{SetDeceasedRequest.cs => SetDeceased.cs} (100%) create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetCpdInductionStatusRequest.cs delete mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs create mode 100644 TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionStatus.cs create mode 100644 TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/SetCpdInductionStatusTests.cs diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs index f0b937457..a7abd4ad5 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/ApiError.cs @@ -10,6 +10,14 @@ public static class ErrorCodes public static int SpecifiedResourceUrlDoesNotExist => 10028; public static int TrnRequestAlreadyCreated => 10029; public static int TrnRequestDoesNotExist => 10031; + public static int UnexpectedInductionStatus => 10032; + public static int StaleRequest => 10033; + public static int PersonDoesNotHaveQts => 10034; + public static int InvalidInductionStatus => 10035; + public static int InductionStartDateIsRequired => 10036; + public static int InductionCompletedDateIsRequired => 10037; + public static int InductionStartDateIsNotPermitted => 10038; + public static int InductionCompletedDateIsNotPermitted => 10039; public static int ForbiddenForAppropriateBody => 10040; } @@ -39,6 +47,27 @@ public static ApiError TrnRequestAlreadyCreated(string requestId) => public static ApiError TrnRequestDoesNotExist(string requestId) => new(ErrorCodes.TrnRequestDoesNotExist, "TRN request does not exist.", $"TRN request ID: '{requestId}'"); + public static ApiError StaleRequest(DateTime timestamp) => + new(ErrorCodes.StaleRequest, "Request is stale.", $"Timestamp: {timestamp:yyyy-MM-dd}"); + + public static ApiError PersonDoesNotHaveQts(string trn) => + new(ErrorCodes.PersonDoesNotHaveQts, "Person does not have QTS.", $"TRN: '{trn}'"); + + public static ApiError InvalidInductionStatus(InductionStatus status) => + new(ErrorCodes.InvalidInductionStatus, "Invalid induction status.", $"Status: '{status}'"); + + public static ApiError InductionStartDateIsRequired(InductionStatus status) => + new(ErrorCodes.InductionStartDateIsRequired, "Induction start date is required.", $"Status: '{status}'"); + + public static ApiError InductionStartDateIsNotPermitted(InductionStatus status) => + new(ErrorCodes.InductionStartDateIsNotPermitted, "Induction start date is not permitted.", $"Status: '{status}'"); + + public static ApiError InductionCompletedDateIsRequired(InductionStatus status) => + new(ErrorCodes.InductionCompletedDateIsRequired, "Induction completed date is required.", $"Status: '{status}'"); + + public static ApiError InductionCompletedDateIsNotPermitted(InductionStatus status) => + new(ErrorCodes.InductionCompletedDateIsNotPermitted, "Induction completed date is not permitted.", $"Status: '{status}'"); + public static ApiError ForbiddenForAppropriateBody() => new(ErrorCodes.ForbiddenForAppropriateBody, "Forbidden.", ""); diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/Gender.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/Gender.cs index 8c8b40012..7e49dbaa2 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/Gender.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Dtos/Gender.cs @@ -1,6 +1,6 @@ using TeachingRecordSystem.Core.Dqt.Models; -namespace TeachingRecordSystem.Api.V3.Core.SharedModels; +namespace TeachingRecordSystem.Api.V3.Implementation.Dtos; public enum Gender { diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetCpdInductionStatus.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetCpdInductionStatus.cs new file mode 100644 index 000000000..3d0d5c140 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetCpdInductionStatus.cs @@ -0,0 +1,112 @@ +using System.Diagnostics; +using Microsoft.Xrm.Sdk.Query; +using TeachingRecordSystem.Core.DataStore.Postgres; +using TeachingRecordSystem.Core.DataStore.Postgres.Models; +using TeachingRecordSystem.Core.Dqt; +using TeachingRecordSystem.Core.Dqt.Models; +using TeachingRecordSystem.Core.Dqt.Queries; +using TeachingRecordSystem.Core.Services.TrsDataSync; + +namespace TeachingRecordSystem.Api.V3.Implementation.Operations; + +public record SetCpdInductionStatusCommand( + string Trn, + InductionStatus Status, + DateOnly? StartDate, + DateOnly? CompletedDate, + DateTime CpdModifiedOn); + +public record SetCpdInductionStatusResult; + +public class SetCpdInductionStatusHandler( + TrsDbContext dbContext, + ICrmQueryDispatcher crmQueryDispatcher, + TrsDataSyncHelper syncHelper, + IClock clock) +{ + public async Task> HandleAsync(SetCpdInductionStatusCommand command) + { + if (command.Status is not InductionStatus.RequiredToComplete and not InductionStatus.InProgress + and not InductionStatus.Passed and not InductionStatus.Failed) + { + return ApiError.InvalidInductionStatus(command.Status); + } + + if (command.Status.RequiresStartDate() && command.StartDate is null) + { + return ApiError.InductionStartDateIsRequired(command.Status); + } + + if (!command.Status.RequiresStartDate() && command.StartDate is not null) + { + return ApiError.InductionStartDateIsNotPermitted(command.Status); + } + + if (command.Status.RequiresCompletedDate() && command.CompletedDate is null) + { + return ApiError.InductionCompletedDateIsRequired(command.Status); + } + + if (!command.Status.RequiresCompletedDate() && command.CompletedDate is not null) + { + return ApiError.InductionCompletedDateIsNotPermitted(command.Status); + } + + var dqtContact = await crmQueryDispatcher.ExecuteQueryAsync( + new GetActiveContactByTrnQuery(command.Trn, new ColumnSet(Contact.Fields.dfeta_QTSDate))); + + if (dqtContact is null) + { + return ApiError.PersonNotFound(command.Trn); + } + + if (dqtContact.dfeta_QTSDate is null) + { + return ApiError.PersonDoesNotHaveQts(command.Trn); + } + + await using var txn = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.ReadCommitted); + + var person = await GetPersonAsync(); + + if (person is null) + { + // The person record hasn't synced to TRS yet - force that to happen so we can assign induction status + var synced = await syncHelper.SyncPersonAsync(dqtContact.Id); + if (!synced) + { + throw new Exception($"Could not sync Person with contact ID: '{dqtContact.Id}'."); + } + + person = await GetPersonAsync(); + Debug.Assert(person is not null); + } + + if (person.CpdInductionCpdModifiedOn is DateTime cpdInductionCpdModifiedOn && + command.CpdModifiedOn < cpdInductionCpdModifiedOn) + { + return ApiError.StaleRequest(cpdInductionCpdModifiedOn); + } + + person.SetCpdInductionStatus( + command.Status, + command.StartDate, + command.CompletedDate, + command.CpdModifiedOn, + PostgresModels.SystemUser.SystemUserId, + clock.UtcNow, + out var updatedEvent); + + if (updatedEvent is not null) + { + dbContext.AddEvent(updatedEvent); + } + + await dbContext.SaveChangesAsync(); + await txn.CommitAsync(); + + return new SetCpdInductionStatusResult(); + + Task GetPersonAsync() => dbContext.Persons.SingleOrDefaultAsync(p => p.Trn == command.Trn); + } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetDeceasedRequest.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetDeceased.cs similarity index 100% rename from TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetDeceasedRequest.cs rename to TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/Implementation/Operations/SetDeceased.cs diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs index 7d5008625..5a0d82db6 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/PersonsController.cs @@ -16,17 +16,33 @@ namespace TeachingRecordSystem.Api.V3.VNext.Controllers; [Route("persons")] public class PersonsController(IMapper mapper) : ControllerBase { - [HttpPut("{trn}/induction")] + [HttpPut("{trn}/cpd-induction")] [SwaggerOperation( OperationId = "SetPersonInductionStatus", Summary = "Set person induction status", Description = "Sets the induction details of the person with the given TRN.")] [ProducesResponseType(typeof(void), StatusCodes.Status204NoContent)] - [ProducesResponseType(StatusCodes.Status400BadRequest)] - [ProducesResponseType(typeof(void), StatusCodes.Status404NotFound)] - [Authorize(Policy = AuthorizationPolicies.ApiKey, Roles = ApiRoles.SetInduction)] - public IActionResult SetInductionStatus([FromRoute] string trn, [FromBody] SetInductionStatusRequest request) => - NoContent(); + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)] + [Authorize(Policy = AuthorizationPolicies.ApiKey, Roles = ApiRoles.SetCpdInduction)] + public async Task SetCpdInductionStatusAsync( + [FromRoute] string trn, + [FromBody] SetCpdInductionStatusRequest request, + [FromServices] SetCpdInductionStatusHandler handler) + { + var command = new SetCpdInductionStatusCommand( + trn, + mapper.Map(request.Status), + request.StartDate, + request.CompletedDate, + request.ModifiedOn.UtcDateTime); + + var result = await handler.HandleAsync(command); + + return result.ToActionResult(_ => NoContent()) + .MapErrorCode(ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status404NotFound) + .MapErrorCode(ApiError.ErrorCodes.StaleRequest, StatusCodes.Status409Conflict); + } [HttpGet("{trn}")] [SwaggerOperation( diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/TrnRequestsController.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/TrnRequestsController.cs index 74fbd5e70..27883fc82 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/TrnRequestsController.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Controllers/TrnRequestsController.cs @@ -2,9 +2,10 @@ using Microsoft.AspNetCore.Mvc; using Swashbuckle.AspNetCore.Annotations; using TeachingRecordSystem.Api.Infrastructure.Security; +using TeachingRecordSystem.Api.V3.Implementation.Dtos; using TeachingRecordSystem.Api.V3.Implementation.Operations; using TeachingRecordSystem.Api.V3.VNext.Requests; -using TeachingRecordSystem.Core.ApiSchema.V3.V20240606.Dtos; +using TrnRequestInfo = TeachingRecordSystem.Core.ApiSchema.V3.V20240606.Dtos.TrnRequestInfo; namespace TeachingRecordSystem.Api.V3.VNext.Controllers; @@ -43,7 +44,7 @@ public async Task CreateTrnRequestAsync( AddressLine1 = request.Person.Address?.AddressLine1, AddressLine2 = request.Person.Address?.AddressLine2, AddressLine3 = request.Person.Address?.AddressLine3, - GenderCode = request.Person.GenderCode.HasValue ? Core.SharedModels.GenderExtensions.ConvertToContact_GenderCode(request.Person.GenderCode!.Value) : null, + GenderCode = request.Person.GenderCode.HasValue ? GenderExtensions.ConvertToContact_GenderCode(request.Person.GenderCode!.Value) : null, City = request.Person.Address?.City, Postcode = request.Person.Address?.Postcode, Country = request.Person.Address?.Country, diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/CreateTrnRequestRequest.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/CreateTrnRequestRequest.cs index e37c00ae8..404eeae92 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/CreateTrnRequestRequest.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/CreateTrnRequestRequest.cs @@ -1,5 +1,5 @@ -using TeachingRecordSystem.Api.V3.Core.SharedModels; +using TeachingRecordSystem.Api.V3.Implementation.Dtos; namespace TeachingRecordSystem.Api.V3.VNext.Requests; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetCpdInductionStatusRequest.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetCpdInductionStatusRequest.cs new file mode 100644 index 000000000..17d08a706 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetCpdInductionStatusRequest.cs @@ -0,0 +1,11 @@ +using InductionStatus = TeachingRecordSystem.Core.ApiSchema.V3.VNext.Dtos.InductionStatus; + +namespace TeachingRecordSystem.Api.V3.VNext.Requests; + +public record SetCpdInductionStatusRequest +{ + public required InductionStatus Status { get; init; } + public DateOnly? StartDate { get; init; } + public DateOnly? CompletedDate { get; init; } + public DateTimeOffset ModifiedOn { get; init; } +} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs deleted file mode 100644 index 446dcac4b..000000000 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Requests/SetInductionStatusRequest.cs +++ /dev/null @@ -1,8 +0,0 @@ -namespace TeachingRecordSystem.Api.V3.VNext.Requests; - -public record SetInductionStatusRequest -{ - public required TeachingRecordSystem.Core.ApiSchema.V3.V20240101.Dtos.DqtInductionStatus DqtInductionStatus { get; init; } - public required DateOnly StartDate { get; init; } - public DateOnly? CompletionDate { get; init; } -} diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs index fb3b8ee0b..c5a300c5b 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonResponse.cs @@ -1,4 +1,5 @@ using TeachingRecordSystem.Api.V3.Implementation.Operations; +using InductionStatus = TeachingRecordSystem.Core.ApiSchema.V3.VNext.Dtos.InductionStatus; namespace TeachingRecordSystem.Api.V3.VNext.Responses; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs index be0e7fa8a..3a77e70ce 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Api/V3/VNext/Responses/FindPersonsResponse.cs @@ -1,5 +1,6 @@ using AutoMapper.Configuration.Annotations; using TeachingRecordSystem.Api.V3.Implementation.Operations; +using InductionStatus = TeachingRecordSystem.Core.ApiSchema.V3.VNext.Dtos.InductionStatus; namespace TeachingRecordSystem.Api.V3.VNext.Responses; diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiRoles.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiRoles.cs index f2efe1920..b644820c6 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiRoles.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiRoles.cs @@ -10,7 +10,7 @@ public static class ApiRoles public const string AssignQtls = "AssignQtls"; public const string AppropriateBody = "AppropriateBody"; public const string UpdateRole = "UpdateRole"; - public const string SetInduction = "SetInduction"; + public const string SetCpdInduction = "SetCpdInduction"; public static IReadOnlyCollection All { get; } = new[] { @@ -22,6 +22,6 @@ public static class ApiRoles AssignQtls, AppropriateBody, UpdateRole, - SetInduction + SetCpdInduction }; } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionStatus.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionStatus.cs new file mode 100644 index 000000000..1a1d1dee2 --- /dev/null +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/ApiSchema/V3/VNext/Dtos/InductionStatus.cs @@ -0,0 +1,12 @@ +namespace TeachingRecordSystem.Core.ApiSchema.V3.VNext.Dtos; + +public enum InductionStatus +{ + None = 0, + RequiredToComplete = 1, + Exempt = 2, + InProgress = 3, + Passed = 4, + Failed = 5, + FailedInWales = 6, +} diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/CreateTrnRequestTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/CreateTrnRequestTests.cs index b78b2c97b..083e0be4a 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/CreateTrnRequestTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/CreateTrnRequestTests.cs @@ -1,4 +1,4 @@ -using TeachingRecordSystem.Api.V3.Core.SharedModels; +using TeachingRecordSystem.Api.V3.Implementation.Dtos; using TeachingRecordSystem.Api.V3.VNext.Requests; using TeachingRecordSystem.Core.Dqt.Queries; using TeachingRecordSystem.Core.Services.DqtOutbox; diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/SetCpdInductionStatusTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/SetCpdInductionStatusTests.cs new file mode 100644 index 000000000..5399306b1 --- /dev/null +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.Api.Tests/V3/VNext/SetCpdInductionStatusTests.cs @@ -0,0 +1,501 @@ +using SystemUser = TeachingRecordSystem.Core.DataStore.Postgres.Models.SystemUser; + +namespace TeachingRecordSystem.Api.Tests.V3.VNext; + +public class SetCpdInductionStatusTests : TestBase +{ + public SetCpdInductionStatusTests(HostFixture hostFixture) : base(hostFixture) + { + SetCurrentApiClient([ApiRoles.SetCpdInduction]); + } + + public static TheoryData AllStatusesExceptNoneData => new() + { + InductionStatus.RequiredToComplete, + InductionStatus.InProgress, + InductionStatus.Passed, + InductionStatus.Failed, + InductionStatus.Exempt, + InductionStatus.FailedInWales + }; + + [Fact] + public async Task Put_UserDoesNotHavePermission_ReturnsForbidden() + { + // Arrange + SetCurrentApiClient(roles: []); + + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.RequiredToComplete, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + Assert.Equal(StatusCodes.Status403Forbidden, (int)response.StatusCode); + } + + [Fact] + public async Task Put_PersonDoesNotExist_ReturnsNotFound() + { + // Arrange + var trn = await TestData.GenerateTrnAsync(); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.RequiredToComplete, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.PersonNotFound, StatusCodes.Status404NotFound); + } + + [Fact] + public async Task Put_PersonDoesNotHaveQts_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn()); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.RequiredToComplete, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.PersonDoesNotHaveQts, StatusCodes.Status400BadRequest); + } + + [Theory] + [InlineData(InductionStatus.None)] + [InlineData(InductionStatus.Exempt)] + [InlineData(InductionStatus.FailedInWales)] + public async Task Put_StatusIsInvalid_ReturnsError(InductionStatus status) + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = status, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InvalidInductionStatus, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_TimestampIsBeforePreviousUpdate_ReturnsConflict() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + + await WithDbContextAsync(async dbContext => + { + dbContext.Attach(person.Person); + + person.Person.SetCpdInductionStatus( + InductionStatus.InProgress, + startDate, + completedDate: null, + cpdModifiedOn: Clock.UtcNow, + updatedBy: SystemUser.SystemUserId, + now: Clock.UtcNow, + out _); + + await dbContext.SaveChangesAsync(); + }); + + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Passed, + startDate = startDate, + completedDate = completedDate, + modifiedOn = Clock.UtcNow.AddDays(-1) + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.StaleRequest, StatusCodes.Status409Conflict); + } + + [Fact] + public async Task Put_RequiredToCompleteWithStartDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.RequiredToComplete, + startDate = startDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionStartDateIsNotPermitted, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_RequiredToCompleteWithCompletedDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.RequiredToComplete, + completedDate = completedDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionCompletedDateIsNotPermitted, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_InProgressWithoutStartDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.InProgress, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionStartDateIsRequired, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_InProgressWithCompletedDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.InProgress, + startDate = startDate, + completedDate = completedDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionCompletedDateIsNotPermitted, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_FailedWithoutStartDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Failed, + completedDate = completedDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionStartDateIsRequired, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_FailedWithoutCompletedDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Failed, + startDate = startDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionCompletedDateIsRequired, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_PassedWithoutStartDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Passed, + completedDate = completedDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionStartDateIsRequired, StatusCodes.Status400BadRequest); + } + + [Fact] + public async Task Put_PassedWithoutCompletedDate_ReturnsError() + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts()); + + var startDate = person.QtsDate!.Value.AddDays(6); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Passed, + startDate = startDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + await AssertEx.JsonResponseIsErrorAsync(response, ApiError.ErrorCodes.InductionCompletedDateIsRequired, StatusCodes.Status400BadRequest); + } + + [Theory] + [MemberData(nameof(AllStatusesExceptNoneData))] + public async Task Put_ValidRequestWithRequiredToComplete_UpdatesDbAndReturnsNoContent(InductionStatus currentStatus) + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts() + .WithInductionStatus(currentStatus)); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.RequiredToComplete, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + Assert.Equal(StatusCodes.Status204NoContent, (int)response.StatusCode); + } + + [Theory] + [MemberData(nameof(AllStatusesExceptNoneData))] + public async Task Put_ValidRequestWithInProgress_UpdatesDbAndReturnsNoContent(InductionStatus currentStatus) + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts() + .WithInductionStatus(currentStatus)); + + var startDate = person.QtsDate!.Value.AddDays(6); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.InProgress, + startDate = startDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + Assert.Equal(StatusCodes.Status204NoContent, (int)response.StatusCode); + } + + [Theory] + [MemberData(nameof(AllStatusesExceptNoneData))] + public async Task Put_ValidRequestWithFailed_UpdatesDbAndReturnsNoContent(InductionStatus currentStatus) + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts() + .WithInductionStatus(currentStatus)); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Failed, + startDate = startDate, + completedDate = completedDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + Assert.Equal(StatusCodes.Status204NoContent, (int)response.StatusCode); + } + + [Theory] + [MemberData(nameof(AllStatusesExceptNoneData))] + public async Task Put_ValidRequestWithPassed_UpdatesDbAndReturnsNoContent(InductionStatus currentStatus) + { + // Arrange + var person = await TestData.CreatePersonAsync(p => p + .WithTrn() + .WithQts() + .WithInductionStatus(currentStatus)); + + var startDate = person.QtsDate!.Value.AddDays(6); + var completedDate = startDate.AddMonths(12); + + var request = new HttpRequestMessage(HttpMethod.Put, $"/v3/persons/{person.Trn}/cpd-induction") + { + Content = CreateJsonContent(new + { + status = InductionStatus.Passed, + startDate = startDate, + completedDate = completedDate, + modifiedOn = Clock.UtcNow + }) + }; + + // Act + var response = await GetHttpClientWithApiKey().SendAsync(request); + + // Act + Assert.Equal(StatusCodes.Status204NoContent, (int)response.StatusCode); + } +} From d2d301a0a9479cd05e2870aff39d9ad4511fa5db Mon Sep 17 00:00:00 2001 From: MrKevJoy <60096576+MrKevJoy@users.noreply.github.com> Date: Mon, 9 Dec 2024 13:22:24 +0000 Subject: [PATCH 6/7] Require a token for accessing Request TRN journey (#1734) --- .../Pages/RequestTrn/Index.cshtml.cs | 16 +++++++++++++++- .../appsettings.Testing.json | 3 ++- .../PageTests/RequestTrn/IndexTests.cs | 19 ++++++++++++++++++- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Pages/RequestTrn/Index.cshtml.cs b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Pages/RequestTrn/Index.cshtml.cs index ce54b678f..f3a09a1b5 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Pages/RequestTrn/Index.cshtml.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/Pages/RequestTrn/Index.cshtml.cs @@ -1,10 +1,24 @@ +using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using TeachingRecordSystem.UiCommon.FormFlow; namespace TeachingRecordSystem.AuthorizeAccess.Pages.RequestTrn; [Journey(RequestTrnJourneyState.JourneyName), ActivatesJourney, RequireJourneyInstance] -public class IndexModel : PageModel +public class IndexModel(IConfiguration configuration) : PageModel { public JourneyInstance? JourneyInstance { get; set; } + + [FromQuery] + public string? AccessToken { get; set; } + + public ActionResult OnGet() + { + var whitelistedAccessToken = configuration.GetRequiredValue("RequestTrnAccessToken"); + if (!whitelistedAccessToken.Equals(AccessToken, StringComparison.Ordinal)) + { + return NotFound(); + } + return Page(); + } } diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/appsettings.Testing.json b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/appsettings.Testing.json index a1507c044..03f632c95 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/appsettings.Testing.json +++ b/TeachingRecordSystem/src/TeachingRecordSystem.AuthorizeAccess/appsettings.Testing.json @@ -7,5 +7,6 @@ "Microsoft.AspNetCore": "Fatal" } } - } + }, + "RequestTrnAccessToken": "n8hhN5MSrNXxCzRo" } diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/PageTests/RequestTrn/IndexTests.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/PageTests/RequestTrn/IndexTests.cs index abfbd4c7c..edc67fe1b 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/PageTests/RequestTrn/IndexTests.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.AuthorizeAccess.Tests/PageTests/RequestTrn/IndexTests.cs @@ -6,10 +6,11 @@ public class IndexTests(HostFixture hostFixture) : TestBase(hostFixture) public async Task Get_ValidRequest_RendersExpectedContent() { // Arrange + var accessToken = HostFixture.Services.GetRequiredService().GetValue("RequestTrnAccessToken"); var state = CreateNewState(); var journeyInstance = await CreateJourneyInstance(state); - var request = new HttpRequestMessage(HttpMethod.Get, $"/request-trn?{journeyInstance.GetUniqueIdQueryParameter()}"); + var request = new HttpRequestMessage(HttpMethod.Get, $"/request-trn?{journeyInstance.GetUniqueIdQueryParameter()}&AccessToken={accessToken}"); // Act var response = await HttpClient.SendAsync(request); @@ -17,4 +18,20 @@ public async Task Get_ValidRequest_RendersExpectedContent() // Assert await AssertEx.HtmlResponseAsync(response); } + + [Fact] + public async Task Get_MissingAccessToken_ReturnsBadRequest() + { + // Arrange + var state = CreateNewState(); + var journeyInstance = await CreateJourneyInstance(state); + + var request = new HttpRequestMessage(HttpMethod.Get, $"/request-trn?{journeyInstance.GetUniqueIdQueryParameter()}&AccessToken="); + + // Act + var response = await HttpClient.SendAsync(request); + + // Assert + Assert.Equal(StatusCodes.Status404NotFound, (int)response.StatusCode); + } } From b0f90bba461014892f6346d27c05b59bb34fd324 Mon Sep 17 00:00:00 2001 From: James Gunn Date: Mon, 9 Dec 2024 14:40:26 +0000 Subject: [PATCH 7/7] Allow missing induction dates in tests (#1743) --- .../DataStore/Postgres/Models/Person.cs | 4 +- .../TestData.CreatePerson.cs | 245 +++++++----------- 2 files changed, 94 insertions(+), 155 deletions(-) diff --git a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/Person.cs b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/Person.cs index 39d217397..bfabf04a8 100644 --- a/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/Person.cs +++ b/TeachingRecordSystem/src/TeachingRecordSystem.Core/DataStore/Postgres/Models/Person.cs @@ -140,8 +140,8 @@ public void SetInductionStatus( DateTime now, out PersonInductionUpdatedEvent? @event) { - // FUTURE When we have QTS in TRS - assert person has QTS - AssertInductionChangeIsValid(status, startDate, completedDate, exemptionReasons); + // N.B. We allow missing data fields as some migrated data has missing fields + // and we want to be able to test such scenarios. var changes = PersonInductionUpdatedEventChanges.None | (InductionStatus != status ? PersonInductionUpdatedEventChanges.InductionStatus : 0) | diff --git a/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs index 732a3f83a..ee2b12885 100644 --- a/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs +++ b/TeachingRecordSystem/tests/TeachingRecordSystem.TestCommon/TestData.CreatePerson.cs @@ -50,7 +50,7 @@ public class CreatePersonBuilder private string? _trnToken; private string? _slugId; private int? _loginFailedCounter; - private CreatePersonMandatoryQualificationBuilder.CreatePersonInductionBuilder? _inductionBuilder; + private CreatePersonInductionBuilder? _inductionBuilder; public Guid PersonId { get; } = Guid.NewGuid(); @@ -291,13 +291,30 @@ public CreatePersonBuilder WithLoginFailedCounter(int? loginFailedCounter) } public CreatePersonBuilder WithInductionStatus(InductionStatus status) => - WithInductionStatus(i => i.WithStatus(status)); + WithInductionStatus(i => + { + var qtsDate = GetQtsDate(); + var startDate = CreatePersonInductionBuilder.GetDefaultStartDate(status, qtsDate); + var completedDate = CreatePersonInductionBuilder.GetDefaultCompletedDate(status, startDate); + var exemptionReasons = CreatePersonInductionBuilder.GetDefaultExemptionReasons(status); + + if (!Person.ValidateInductionData(status, startDate, completedDate, exemptionReasons, out var error)) + { + throw new InvalidOperationException(error); + } + + i + .WithStatus(status) + .WithStartDate(startDate) + .WithCompletedDate(completedDate) + .WithExemptionReasons(exemptionReasons); + }); - public CreatePersonBuilder WithInductionStatus(Action configure) + public CreatePersonBuilder WithInductionStatus(Action configure) { EnsureTrn(); - _inductionBuilder ??= new(this); + _inductionBuilder ??= new(); configure(_inductionBuilder); return this; @@ -1042,178 +1059,100 @@ await dbContext.MandatoryQualificationProviders.SingleAsync(p => p.MandatoryQual return (QualificationId, events); } + } - public class CreatePersonInductionBuilder(CreatePersonBuilder createPersonBuilder) - { - private Option _status; - private Option _startDate; - private Option _completedDate; - private Option _exemptionReasons; + public class CreatePersonInductionBuilder + { + private Option _status; + private Option _startDate; + private Option _completedDate; + private Option _exemptionReasons; - public bool HasStatusRequiringQts => _status.HasValue && _status.ValueOrFailure() != InductionStatus.None; + public bool HasStatusRequiringQts => _status.HasValue && _status.ValueOrFailure() != InductionStatus.None; - public CreatePersonInductionBuilder WithStatus(InductionStatus status) + public CreatePersonInductionBuilder WithStatus(InductionStatus status) + { + if (_status.HasValue && _status.ValueOrFailure() != status) { - if (_status.HasValue && _status.ValueOrFailure() != status) - { - throw new InvalidOperationException("Status has already been set."); - } - - var qtsDate = createPersonBuilder.GetQtsDate(); - - if (status != InductionStatus.None && !qtsDate.HasValue) - { - throw new InvalidOperationException("Person requires QTS."); - } - else if (status == InductionStatus.None && qtsDate.HasValue) - { - throw new InvalidOperationException($"Status cannot be '{status}' when person has QTS."); - } - - _status = Option.Some(status); - return this; + throw new InvalidOperationException("Status has already been set."); } - public CreatePersonInductionBuilder WithStartDate(DateOnly? startDate) - { - if (_startDate.HasValue) - { - throw new InvalidOperationException("Start date has already been set."); - } - - if (!_status.HasValue) - { - throw new InvalidOperationException("Status must be specified before the start date."); - } - - var status = _status.ValueOrFailure(); - - if (!Person.ValidateInductionData( - status, - startDate, - GetDefaultCompletedDate(status, startDate), - GetDefaultExemptionReasons(status), - out var error)) - { - throw new InvalidOperationException(error); - } - - _startDate = Option.Some(startDate); - return this; - } + _status = Option.Some(status); + return this; + } - public CreatePersonInductionBuilder WithCompletedDate(DateOnly? completedDate) + public CreatePersonInductionBuilder WithStartDate(DateOnly? startDate) + { + if (_startDate.HasValue) { - if (_completedDate.HasValue) - { - throw new InvalidOperationException("Completed date has already been set."); - } - - if (!_status.HasValue) - { - throw new InvalidOperationException("Status must be specified before the start date."); - } - - if (!_startDate.HasValue) - { - throw new InvalidOperationException("Start date must be specified before the completed date."); - } - - var status = _status.ValueOrFailure(); - var startDate = _startDate.ValueOrFailure(); + throw new InvalidOperationException("Start date has already been set."); + } - if (!Person.ValidateInductionData( - status, - startDate, - completedDate, - GetDefaultExemptionReasons(status), - out var error)) - { - throw new InvalidOperationException(error); - } + _startDate = Option.Some(startDate); + return this; + } - _completedDate = Option.Some(completedDate); - return this; + public CreatePersonInductionBuilder WithCompletedDate(DateOnly? completedDate) + { + if (_completedDate.HasValue) + { + throw new InvalidOperationException("Completed date has already been set."); } - public CreatePersonInductionBuilder WithExemptionReasons(InductionExemptionReasons exemptionReasons) - { - if (_exemptionReasons.HasValue) - { - throw new InvalidOperationException("Exemption reasons have already been set."); - } + _completedDate = Option.Some(completedDate); + return this; + } - if (!_status.HasValue) - { - throw new InvalidOperationException("Status must be specified before the exemption reasons."); - } + public CreatePersonInductionBuilder WithExemptionReasons(InductionExemptionReasons exemptionReasons) + { + if (_exemptionReasons.HasValue) + { + throw new InvalidOperationException("Exemption reasons have already been set."); + } - var status = _status.ValueOrFailure(); + _exemptionReasons = Option.Some(exemptionReasons); + return this; + } - if (status is not InductionStatus.Exempt && exemptionReasons != InductionExemptionReasons.None) - { - throw new InvalidOperationException($"Exemption reasons cannot be specified unless the status is {InductionStatus.Exempt}."); - } + internal IReadOnlyCollection Execute( + Person person, + CreatePersonBuilder createPersonBuilder, + TestData testData, + TrsDbContext dbContext) + { + var qtsDate = createPersonBuilder.GetQtsDate(); - if (status is InductionStatus.Exempt && exemptionReasons == InductionExemptionReasons.None) - { - throw new InvalidOperationException($"Exemption reasons cannot be {InductionExemptionReasons.None} when the status is {InductionStatus.Exempt}."); - } + var status = _status.ValueOr(qtsDate.HasValue ? InductionStatus.RequiredToComplete : InductionStatus.None); + var startDate = _startDate.ValueOrDefault(); + var completedDate = _completedDate.ValueOrDefault(); + var exemptionReasons = _exemptionReasons.ValueOr(InductionExemptionReasons.None); - _exemptionReasons = Option.Some(exemptionReasons); - return this; - } + person.SetInductionStatus( + status, + startDate, + completedDate, + exemptionReasons, + updatedBy: SystemUser.SystemUserId, + testData.Clock.UtcNow, + out var @event); - internal IReadOnlyCollection Execute( - Person person, - CreatePersonBuilder createPersonBuilder, - TestData testData, - TrsDbContext dbContext) + if (@event is not null) { - var qtsDate = createPersonBuilder.GetQtsDate(); - - var status = _status.ValueOr(qtsDate.HasValue ? InductionStatus.RequiredToComplete : InductionStatus.None); - var startDate = _startDate.ValueOr(GetDefaultStartDate(status, qtsDate)); - var completedDate = _completedDate.ValueOr(GetDefaultCompletedDate(status, startDate)); - var exemptionReasons = _exemptionReasons.ValueOr(GetDefaultExemptionReasons(status)); - - if (!Person.ValidateInductionData( - status, - startDate, - completedDate, - exemptionReasons, - out var error)) - { - throw new InvalidOperationException(error); - } - - person.SetInductionStatus( - status, - startDate, - completedDate, - exemptionReasons, - updatedBy: SystemUser.SystemUserId, - testData.Clock.UtcNow, - out var @event); - - if (@event is not null) - { - dbContext.AddEvent(@event); - return [@event]; - } - - return []; + dbContext.AddEvent(@event); + return [@event]; } - private static DateOnly? GetDefaultStartDate(InductionStatus status, DateOnly? qtsDate) => - status.RequiresStartDate() ? qtsDate!.Value.AddMonths(6) : null; + return []; + } - private static DateOnly? GetDefaultCompletedDate(InductionStatus status, DateOnly? startDate) => - status.RequiresCompletedDate() ? startDate!.Value.AddMonths(12) : null; + internal static DateOnly? GetDefaultStartDate(InductionStatus status, DateOnly? qtsDate) => + status.RequiresStartDate() ? qtsDate!.Value.AddMonths(6) : null; - private static InductionExemptionReasons GetDefaultExemptionReasons(InductionStatus status) => - status is InductionStatus.Exempt ? (InductionExemptionReasons)1 : InductionExemptionReasons.None; - } + internal static DateOnly? GetDefaultCompletedDate(InductionStatus status, DateOnly? startDate) => + status.RequiresCompletedDate() ? startDate!.Value.AddMonths(12) : null; + + internal static InductionExemptionReasons GetDefaultExemptionReasons(InductionStatus status) => + status is InductionStatus.Exempt ? (InductionExemptionReasons)1 : InductionExemptionReasons.None; } public record CreatePersonResult