From 275e18be107648e91d308c48cbb286aaf33d734e Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Thu, 13 Nov 2025 15:46:52 -0500 Subject: [PATCH 01/88] add missing file (#209) Co-authored-by: Blaine Price --- infrastructure/modules/domains/variables.tf | 1 + 1 file changed, 1 insertion(+) create mode 100644 infrastructure/modules/domains/variables.tf diff --git a/infrastructure/modules/domains/variables.tf b/infrastructure/modules/domains/variables.tf new file mode 100644 index 00000000..20748f29 --- /dev/null +++ b/infrastructure/modules/domains/variables.tf @@ -0,0 +1 @@ +variable "tier" {} \ No newline at end of file From 3e3b4c84ba478ff6c27434ef6c43a30e5005efd2 Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Thu, 13 Nov 2025 16:15:32 -0500 Subject: [PATCH 02/88] Update load balancer listener config, update allowed hosts in django (#210) Co-authored-by: Blaine Price --- infrastructure/modules/fhir-api/main.tf | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/fhir-api/main.tf b/infrastructure/modules/fhir-api/main.tf index a14a6eb1..7ef11922 100644 --- a/infrastructure/modules/fhir-api/main.tf +++ b/infrastructure/modules/fhir-api/main.tf @@ -206,7 +206,7 @@ resource "aws_ecs_task_definition" "app" { }, { name = "DJANGO_ALLOWED_HOSTS" - value = aws_lb.fhir_api_alb.dns_name + value = jsonencode([aws_lb.fhir_api_alb.dns_name, var.networking.api_domain, var.networking.directory_domain]) }, { name = "DJANGO_LOGLEVEL" @@ -376,7 +376,8 @@ resource "aws_lb_listener" "forward_to_task_group_https" { count = var.redirect_to_strategy_page && var.networking.enable_ssl_directory ? 0 : 1 load_balancer_arn = aws_lb.fhir_api_alb.arn port = 443 - protocol = "HTTP" + protocol = "HTTPS" + certificate_arn = data.aws_acm_certificate.directory_ssl_cert[0].arn default_action { type = "forward" From d86786edaaa4a872cc6d91527cd2117cb7220694 Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Thu, 13 Nov 2025 16:29:33 -0500 Subject: [PATCH 03/88] update allowed hosts (#211) Co-authored-by: Blaine Price --- infrastructure/modules/fhir-api/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/fhir-api/main.tf b/infrastructure/modules/fhir-api/main.tf index 7ef11922..0ed03fda 100644 --- a/infrastructure/modules/fhir-api/main.tf +++ b/infrastructure/modules/fhir-api/main.tf @@ -206,7 +206,7 @@ resource "aws_ecs_task_definition" "app" { }, { name = "DJANGO_ALLOWED_HOSTS" - value = jsonencode([aws_lb.fhir_api_alb.dns_name, var.networking.api_domain, var.networking.directory_domain]) + value = "${aws_lb.fhir_api_alb.dns_name},${var.networking.api_domain},${var.networking.directory_domain}" }, { name = "DJANGO_LOGLEVEL" From 28a47c387b1287da91b51ee80e7cd8050b53527b Mon Sep 17 00:00:00 2001 From: Blaine Price Date: Fri, 14 Nov 2025 10:23:30 -0500 Subject: [PATCH 04/88] remove strategy page redirect - because it is difficult to read and reason about - the strategy of asking terraform to hop from one load balancer listener to another on the fly is brittle and failed out on me during a prod deploy --- infrastructure/modules/fhir-api/main.tf | 69 +------------------------ 1 file changed, 1 insertion(+), 68 deletions(-) diff --git a/infrastructure/modules/fhir-api/main.tf b/infrastructure/modules/fhir-api/main.tf index 0ed03fda..63477071 100644 --- a/infrastructure/modules/fhir-api/main.tf +++ b/infrastructure/modules/fhir-api/main.tf @@ -328,39 +328,6 @@ resource "aws_lb_listener" "forward_to_task_group" { } } -resource "aws_lb_listener" "forward_to_strategy_page" { - count = var.redirect_to_strategy_page ? 1 : 0 - load_balancer_arn = aws_lb.fhir_api_alb.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "redirect" - redirect { - status_code = "HTTP_302" - host = "www.cms.gov" - path = "/priorities/health-technology-ecosystem/overview" - } - } -} - -resource "aws_lb_listener_rule" "preview_flag" { - count = var.redirect_to_strategy_page ? 1 : 0 - listener_arn = aws_lb_listener.forward_to_strategy_page[0].arn - - condition { - query_string { - key = "preview" - value = "true" - } - } - - action { - type = "forward" - target_group_arn = aws_lb_target_group.fhir_api_tg.arn - } -} - # Port 443 Traffic # TODO: upgrade all incoming traffic to HTTPS after: # - internal domain names are registered @@ -373,7 +340,7 @@ data "aws_acm_certificate" "directory_ssl_cert" { } resource "aws_lb_listener" "forward_to_task_group_https" { - count = var.redirect_to_strategy_page && var.networking.enable_ssl_directory ? 0 : 1 + count = var.networking.enable_ssl_directory ? 1 : 0 load_balancer_arn = aws_lb.fhir_api_alb.arn port = 443 protocol = "HTTPS" @@ -385,40 +352,6 @@ resource "aws_lb_listener" "forward_to_task_group_https" { } } -resource "aws_lb_listener" "forward_to_strategy_page_https" { - count = var.redirect_to_strategy_page && var.networking.enable_ssl_directory ? 1 : 0 - load_balancer_arn = aws_lb.fhir_api_alb.arn - port = 443 - protocol = "HTTPS" - certificate_arn = data.aws_acm_certificate.directory_ssl_cert[0].arn - - default_action { - type = "redirect" - redirect { - status_code = "HTTP_302" - host = "www.cms.gov" - path = "/priorities/health-technology-ecosystem/overview" - } - } -} - -resource "aws_lb_listener_rule" "preview_flag_https" { - count = var.redirect_to_strategy_page ? 1 : 0 - listener_arn = aws_lb_listener.forward_to_strategy_page_https[0].arn - - condition { - query_string { - key = "preview" - value = "true" - } - } - - action { - type = "forward" - target_group_arn = aws_lb_target_group.fhir_api_tg.arn - } -} - # api.directory.cms.gov and friends resource "aws_alb" "fhir_api_alb_redirect" { From 2a5b02efa3a92128fdeed1c8284b3e920b988817 Mon Sep 17 00:00:00 2001 From: Blaine Price Date: Fri, 14 Nov 2025 10:29:28 -0500 Subject: [PATCH 05/88] clean up remaining redirect to strategy page config --- infrastructure/envs/dev/main.tf | 1 - infrastructure/envs/prod/main.tf | 1 - infrastructure/modules/fhir-api/main.tf | 1 - infrastructure/modules/fhir-api/variables.tf | 1 - 4 files changed, 4 deletions(-) diff --git a/infrastructure/envs/dev/main.tf b/infrastructure/envs/dev/main.tf index 1416ac24..23dcf715 100644 --- a/infrastructure/envs/dev/main.tf +++ b/infrastructure/envs/dev/main.tf @@ -128,7 +128,6 @@ module "fhir-api" { fhir_api_migration_image = var.migration_image fhir_api_image = var.fhir_api_image ecs_cluster_id = module.ecs.cluster_id - redirect_to_strategy_page = false desired_task_count = 2 require_authentication = var.require_authentication db = { diff --git a/infrastructure/envs/prod/main.tf b/infrastructure/envs/prod/main.tf index 29e34feb..0939f020 100644 --- a/infrastructure/envs/prod/main.tf +++ b/infrastructure/envs/prod/main.tf @@ -127,7 +127,6 @@ module "fhir-api" { account_name = local.account_name fhir_api_migration_image = var.migration_image fhir_api_image = var.fhir_api_image - redirect_to_strategy_page = var.redirect_to_strategy_page private_load_balancer = var.fhir_api_private_load_balancer ecs_cluster_id = module.ecs.cluster_id desired_task_count = 3 diff --git a/infrastructure/modules/fhir-api/main.tf b/infrastructure/modules/fhir-api/main.tf index 63477071..1999d8d9 100644 --- a/infrastructure/modules/fhir-api/main.tf +++ b/infrastructure/modules/fhir-api/main.tf @@ -317,7 +317,6 @@ resource "aws_lb_target_group" "fhir_api_tg" { # - ssl certs are requested and validated resource "aws_lb_listener" "forward_to_task_group" { - count = var.redirect_to_strategy_page ? 0 : 1 load_balancer_arn = aws_lb.fhir_api_alb.arn port = 80 protocol = "HTTP" diff --git a/infrastructure/modules/fhir-api/variables.tf b/infrastructure/modules/fhir-api/variables.tf index 7e5de7b5..3c890e11 100644 --- a/infrastructure/modules/fhir-api/variables.tf +++ b/infrastructure/modules/fhir-api/variables.tf @@ -4,7 +4,6 @@ variable "fhir_api_migration_image" {} variable "fhir_api_port" { default = 8000 } -variable "redirect_to_strategy_page" {} variable "private_load_balancer" { default = true } variable "ecs_cluster_id" {} variable "desired_task_count" {} From 9d1e35499e51e044575d8dd9ed0d15dc4a362ea9 Mon Sep 17 00:00:00 2001 From: Blaine Price Date: Fri, 14 Nov 2025 10:34:02 -0500 Subject: [PATCH 06/88] terraform fmt --- infrastructure/envs/dev/main.tf | 12 ++++++------ infrastructure/envs/prod/main.tf | 14 +++++++------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/infrastructure/envs/dev/main.tf b/infrastructure/envs/dev/main.tf index 23dcf715..9d3cafea 100644 --- a/infrastructure/envs/dev/main.tf +++ b/infrastructure/envs/dev/main.tf @@ -124,12 +124,12 @@ module "ecs" { module "fhir-api" { source = "../../modules/fhir-api" - account_name = local.account_name - fhir_api_migration_image = var.migration_image - fhir_api_image = var.fhir_api_image - ecs_cluster_id = module.ecs.cluster_id - desired_task_count = 2 - require_authentication = var.require_authentication + account_name = local.account_name + fhir_api_migration_image = var.migration_image + fhir_api_image = var.fhir_api_image + ecs_cluster_id = module.ecs.cluster_id + desired_task_count = 2 + require_authentication = var.require_authentication db = { db_instance_master_user_secret_arn = module.api-db.db_instance_master_user_secret_arn db_instance_address = module.api-db.db_instance_address diff --git a/infrastructure/envs/prod/main.tf b/infrastructure/envs/prod/main.tf index 0939f020..11618fe4 100644 --- a/infrastructure/envs/prod/main.tf +++ b/infrastructure/envs/prod/main.tf @@ -124,13 +124,13 @@ module "ecs" { module "fhir-api" { source = "../../modules/fhir-api" - account_name = local.account_name - fhir_api_migration_image = var.migration_image - fhir_api_image = var.fhir_api_image - private_load_balancer = var.fhir_api_private_load_balancer - ecs_cluster_id = module.ecs.cluster_id - desired_task_count = 3 - require_authentication = var.require_authentication + account_name = local.account_name + fhir_api_migration_image = var.migration_image + fhir_api_image = var.fhir_api_image + private_load_balancer = var.fhir_api_private_load_balancer + ecs_cluster_id = module.ecs.cluster_id + desired_task_count = 3 + require_authentication = var.require_authentication db = { db_instance_master_user_secret_arn = module.api-db.db_instance_master_user_secret_arn db_instance_address = module.api-db.db_instance_address From eb9020eda99b7818374d4f3877e93c5c667d011e Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Mon, 17 Nov 2025 13:48:05 -0500 Subject: [PATCH 07/88] Wbprice/internal dns configuration (#213) * create internal dns configuration to be imported into infoblocks --------- Co-authored-by: Blaine Price --- infrastructure/envs/dev/main.tf | 13 ++++++++- infrastructure/envs/prod/main.tf | 12 ++++++++ infrastructure/modules/dns/main.tf | 37 +++++++++++++++++++++++++ infrastructure/modules/dns/variables.tf | 7 +++++ infrastructure/modules/domains/main.tf | 6 ++-- 5 files changed, 71 insertions(+), 4 deletions(-) create mode 100644 infrastructure/modules/dns/main.tf create mode 100644 infrastructure/modules/dns/variables.tf diff --git a/infrastructure/envs/dev/main.tf b/infrastructure/envs/dev/main.tf index 9d3cafea..5233d799 100644 --- a/infrastructure/envs/dev/main.tf +++ b/infrastructure/envs/dev/main.tf @@ -39,6 +39,18 @@ module "domains" { tier = var.tier } +module "dns" { + source = "../../modules/dns" + + enable_internal_domain_for_directory = true + api_domain = module.domains.api_domain + api_alb_dns_name = module.fhir-api.api_alb_dns_name + directory_domain = module.domains.directory_domain + directory_alb_dns_name = module.fhir-api.api_dot_alb_dns_name + etl_domain = module.domains.etl_domain + etl_alb_dns_name = module.etl.dagster_ui_alb_dns_name +} + module "repositories" { source = "../../modules/repositories" @@ -221,4 +233,3 @@ module "github-actions" { [module.networking.github_action_runner_security_group_id] ) } - diff --git a/infrastructure/envs/prod/main.tf b/infrastructure/envs/prod/main.tf index 11618fe4..ef65bab7 100644 --- a/infrastructure/envs/prod/main.tf +++ b/infrastructure/envs/prod/main.tf @@ -37,6 +37,18 @@ module "domains" { tier = var.tier } +module "dns" { + source = "../../modules/dns" + + enable_internal_domain_for_directory = false + api_domain = module.domains.api_domain + api_alb_dns_name = module.fhir-api.api_alb_dns_name + directory_domain = module.domains.directory_domain + directory_alb_dns_name = module.fhir-api.api_dot_alb_dns_name + etl_domain = module.domains.etl_domain + etl_alb_dns_name = module.etl.dagster_ui_alb_dns_name +} + module "repositories" { source = "../../modules/repositories" diff --git a/infrastructure/modules/dns/main.tf b/infrastructure/modules/dns/main.tf new file mode 100644 index 00000000..8992bdeb --- /dev/null +++ b/infrastructure/modules/dns/main.tf @@ -0,0 +1,37 @@ +resource "aws_route53_zone" "internal_dns" { + name = var.directory_domain +} + +resource "aws_route53_record" "ns" { + zone_id = aws_route53_zone.internal_dns.zone_id + name = var.directory_domain + type = "NS" + ttl = "30" + records = aws_route53_zone.internal_dns.name_servers +} + +resource "aws_route53_record" "directory" { + count = var.enable_internal_domain_for_directory ? 1 : 0 + zone_id = aws_route53_zone.internal_dns.zone_id + name = var.directory_domain + type = "CNAME" + ttl = "300" + records = [var.directory_alb_dns_name] +} + +resource "aws_route53_record" "api" { + count = var.enable_internal_domain_for_directory ? 1 : 0 + zone_id = aws_route53_zone.internal_dns.zone_id + name = var.api_domain + type = "CNAME" + ttl = "300" + records = [var.api_alb_dns_name] +} + +resource "aws_route53_record" "etl" { + zone_id = aws_route53_zone.internal_dns.zone_id + name = var.etl_domain + type = "CNAME" + ttl = "300" + records = [var.etl_alb_dns_name] +} diff --git a/infrastructure/modules/dns/variables.tf b/infrastructure/modules/dns/variables.tf new file mode 100644 index 00000000..ed29ce82 --- /dev/null +++ b/infrastructure/modules/dns/variables.tf @@ -0,0 +1,7 @@ +variable "api_domain" {} +variable "api_alb_dns_name" {} +variable "directory_domain" {} +variable "directory_alb_dns_name" {} +variable "etl_domain" {} +variable "etl_alb_dns_name" {} +variable "enable_internal_domain_for_directory" {} diff --git a/infrastructure/modules/domains/main.tf b/infrastructure/modules/domains/main.tf index d3842124..f8332e4c 100644 --- a/infrastructure/modules/domains/main.tf +++ b/infrastructure/modules/domains/main.tf @@ -13,10 +13,10 @@ locals { prod = { etl = "etl.directory.internal.cms.gov" api = "api.directory.cms.gov" # public route - directory = "directory.cms.gov" # public route + directory = "directory.cms.gov" # public route } } - api_domain = local.domains[var.tier]["api"] + api_domain = local.domains[var.tier]["api"] directory_domain = local.domains[var.tier]["directory"] - etl_domain = local.domains[var.tier]["etl"] + etl_domain = local.domains[var.tier]["etl"] } From 66b4aa8ba5221cb501af4967adbb3beed1611fad Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Mon, 17 Nov 2025 13:55:04 -0500 Subject: [PATCH 08/88] Remove sandbox terraform definitions and github actions workflows (#214) Co-authored-by: Blaine Price --- .github/workflows/deploy-to-sandbox.yml | 67 --- infrastructure/README.md | 6 - infrastructure/envs/sandbox/main.tf | 510 ------------------- infrastructure/envs/sandbox/outputs.tf | 20 - infrastructure/envs/sandbox/terraform.tfvars | 2 - infrastructure/envs/sandbox/variables.tf | 9 - 6 files changed, 614 deletions(-) delete mode 100644 .github/workflows/deploy-to-sandbox.yml delete mode 100644 infrastructure/envs/sandbox/main.tf delete mode 100644 infrastructure/envs/sandbox/outputs.tf delete mode 100644 infrastructure/envs/sandbox/terraform.tfvars delete mode 100644 infrastructure/envs/sandbox/variables.tf diff --git a/.github/workflows/deploy-to-sandbox.yml b/.github/workflows/deploy-to-sandbox.yml deleted file mode 100644 index fc47ff28..00000000 --- a/.github/workflows/deploy-to-sandbox.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Deploy to Sandbox -on: - push: - branches: - - main - -permissions: - id-token: write - -jobs: - deploy: - runs-on: ubuntu-latest - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@main - with: - role-to-assume: arn:aws-us-gov:iam::250902968334:role/GithubActionsDeployRole - aws-region: us-gov-west-1 - - name: Login to Amazon ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - name: Build, tag, push migration image to Amazon ECR - working-directory: flyway - env: - REGISTRY: ${{ steps.login-ecr.outputs.registry }} - REPOSITORY: ndh-migrations - IMAGE_TAG: ${{ github.sha }} - run: | - docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . - docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG - docker tag $REGISTRY/$REPOSITORY:$IMAGE_TAG $REGISTRY/$REPOSITORY - docker push $REGISTRY/$REPOSITORY:latest - echo "TF_VAR_migration_image=$REGISTRY/$REPOSITORY:$IMAGE_TAG" >> "$GITHUB_ENV" - - name: Setup NodeJS - uses: actions/setup-node@v4 - - name: Build Frontend Assets - working-directory: frontend - env: - VITE_API_BASE_URL: "" - run: | - npm ci - npm run build - - name: Build, tag, and push docker image to Amazon ECR - working-directory: backend - env: - REGISTRY: ${{ steps.login-ecr.outputs.registry }} - REPOSITORY: ndh - IMAGE_TAG: ${{ github.sha }} - run: | - docker build -t $REGISTRY/$REPOSITORY:$IMAGE_TAG . - docker push $REGISTRY/$REPOSITORY:$IMAGE_TAG - docker tag $REGISTRY/$REPOSITORY:$IMAGE_TAG $REGISTRY/$REPOSITORY - docker push $REGISTRY/$REPOSITORY:latest - echo "TF_VAR_container_image=$REGISTRY/$REPOSITORY:$IMAGE_TAG" >> "$GITHUB_ENV" - - name: Configure Terraform - uses: hashicorp/setup-terraform@v3 - - name: Update Infrastructure, Deploy API - working-directory: infrastructure - env: - REGISTRY: ${{ steps.login-ecr.outputs.registry }} - REPOSITORY: ndh - IMAGE_TAG: ${{ github.sha }} - run: | - terraform -chdir=envs/sandbox init - terraform -chdir=envs/sandbox apply -auto-approve diff --git a/infrastructure/README.md b/infrastructure/README.md index 86bc04da..d460997d 100644 --- a/infrastructure/README.md +++ b/infrastructure/README.md @@ -6,7 +6,6 @@ ## Update Cadence -- `sandbox` will be updated whenever `main` is updated until it is torn down - The `dev` environment is to be updated whenever updates to `main` are merged - `test` / `uat` is updated whenever a release is cut - Releasing to `prod` is manual, requires sign-off from the PM and eng team @@ -14,7 +13,6 @@ ## Naming Conventions The naming scheme for resources should be consistent but not too verbose. -Sandbox resources do not follow a consistent naming scheme. `{project-name}-${region}-${tier}-${description}-${index?}` @@ -41,7 +39,6 @@ npd-east-dev-load-fips-bronze-job 1. Create an environment specific `.env` file, using `.env.template` as a reference ``` (one of) - .env.sandbox .env.dev .env.test .env.prod @@ -49,7 +46,6 @@ npd-east-dev-load-fips-bronze-job 2. Assume an AWS Role using `./ctkey.sh` ``` (one of) - ./ctkey.sh sandbox ./ctkey.sh dev ./ctkey.sh test ./ctkey.sh prod @@ -57,7 +53,6 @@ npd-east-dev-load-fips-bronze-job 3. Initialize terraform ``` (one of) - terraform -chdir=envs/sandbox init terraform -chdir=envs/dev init terraform -chdir=envs/test init terraform -chdir=envs/prod init @@ -65,7 +60,6 @@ npd-east-dev-load-fips-bronze-job 4. Deploy resources using terraform ``` (one of) - terraform -chdir=envs/sandbox apply terraform -chdir=envs/dev apply terraform -chdir=envs/test apply terraform -chdir=envs/prod apply diff --git a/infrastructure/envs/sandbox/main.tf b/infrastructure/envs/sandbox/main.tf deleted file mode 100644 index c6618437..00000000 --- a/infrastructure/envs/sandbox/main.tf +++ /dev/null @@ -1,510 +0,0 @@ -terraform { - required_version = ">= 1.0" - - required_providers { - aws = { - source = "hashicorp/aws" - version = "~> 5.0" - } - } - - backend "s3" { - bucket = "npd-terraform" - key = "terraform.tfstate" - region = "us-gov-west-1" - use_lockfile = true - } -} - -provider "aws" { - region = "us-gov-west-1" -} - -data "aws_region" "current" {} -data "aws_partition" "current" {} -data "aws_caller_identity" "current" {} - -locals { - account_name = "dsac-gov-west-sandbox" - - iam_path = "/delegatedadmin/developer/" - permissions_boundary = "arn:${data.aws_partition.current.partition}:iam::${data.aws_caller_identity.current.account_id}:policy/cms-cloud-admin/developer-boundary-policy" -} - -data "aws_vpc" "default" { - filter { - name = "tag:Name" - values = [local.account_name] - } -} - -data "aws_subnets" "private" { - filter { - name = "tag:Name" - values = ["${local.account_name}-private-a", "${local.account_name}-private-b"] - } -} - -data "aws_subnets" "public" { - filter { - name = "tag:Name" - values = ["${local.account_name}-public-a", "${local.account_name}-public-b"] - } -} - -resource "aws_default_security_group" "default" { - vpc_id = data.aws_vpc.default.id -} - -resource "aws_ecr_repository" "app" { - name = var.name -} - -resource "aws_ecr_repository" "migrations" { - name = "${var.name}-migrations" -} - -module "ecs" { - source = "terraform-aws-modules/ecs/aws" - version = "5.12.1" - - cluster_name = var.name - - fargate_capacity_providers = { - FARGATE = { - default_capacity_provider_strategy = { - weight = 50 - base = 20 - } - } - FARGATE_SPOT = { - default_capacity_provider_strategy = { - weight = 50 - } - } - } -} - -resource "aws_iam_role" "ecs_task_execution" { - name = "ecs-task-execution-role" - - assume_role_policy = jsonencode({ - Version = "2012-10-17" - Statement = [{ - Effect = "Allow" - Principal = { Service = "ecs-tasks.amazonaws.com" } - Action = "sts:AssumeRole" - }] - }) - - path = local.iam_path - permissions_boundary = local.permissions_boundary -} - -resource "aws_iam_role_policy_attachment" "ecs_task_execution" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = "arn:aws-us-gov:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" -} - -resource "aws_iam_policy" "ecs_task_can_access_database_secret" { - name = "ecs-task-can-access-database-secret" - description = "Allows ECS tasks to access the RDS secret from Secrets Manager" - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = "secretsmanager:GetSecretValue", - Effect = "Allow" - Resource = [ - module.rds.db_instance_master_user_secret_arn, - aws_secretsmanager_secret.django_secret.arn - ] - } - ] - }) -} - -resource "aws_iam_role_policy_attachment" "ecs_task_can_access_database_secret_attachement" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = aws_iam_policy.ecs_task_can_access_database_secret.arn -} - -resource "aws_iam_policy" "ecs_task_logs_policy" { - name = "ecs-task-logs-policy" - description = "Allow ECS tasks to write logs to CloudWatch" - path = "/delegatedadmin/developer/" - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Action = [ - "logs:CreateLogStream", - "logs:PutLogEvents" - ] - Effect = "Allow" - Resource = "arn:${data.aws_partition.current.partition}:logs:*:${data.aws_caller_identity.current.account_id}:log-group:/ecs/${var.name}*:*" - }, - ] - }) -} - -resource "aws_iam_role_policy_attachment" "ecs_task_logs" { - role = aws_iam_role.ecs_task_execution.name - policy_arn = aws_iam_policy.ecs_task_logs_policy.arn -} - -resource "aws_cloudwatch_log_group" "logs" { - name = "/ecs/${var.name}" -} - -data "aws_secretsmanager_random_password" "django_secret_value" { - password_length = 20 -} - -resource "aws_secretsmanager_secret" "django_secret" { - name_prefix = "${var.name}-django-secret" - description = "Secret value to use with the Django application" -} - -resource "aws_secretsmanager_secret_version" "django_secret_version" { - secret_id = aws_secretsmanager_secret.django_secret.id - secret_string_wo = data.aws_secretsmanager_random_password.django_secret_value.random_password - secret_string_wo_version = 1 -} - -resource "aws_ecs_task_definition" "app" { - family = "${var.name}-task" - requires_compatibilities = ["FARGATE"] - network_mode = "awsvpc" - cpu = "512" - memory = "1024" - execution_role_arn = aws_iam_role.ecs_task_execution.arn - - container_definitions = jsonencode([ - # In the past, I've put the migration container in a separate task and invoked it manually to avoid the case - # where we have (for example) 4 API containers and 4 flyway containers and the 4 flyway containers all try to update - # the database at once. Flyway looks like it uses a Postgres advisory lock to solve this - # (https://documentation.red-gate.com/fd/flyway-postgresql-transactional-lock-setting-277579114.html). - # If we have problems, we can pull this container definition into it's own task and schedule it to run before new - # API containers are deployed - { - name = "${var.name}-migrations" - image = var.migration_image - essential = false - command = ["migrate"] - environment = [ - { - name = "FLYWAY_URL" - value = "jdbc:postgresql://${module.rds.db_instance_address}:${module.rds.db_instance_port}/${var.app_db_name}" - } - ], - secrets = [ - { - name = "FLYWAY_USER" - valueFrom = "${module.rds.db_instance_master_user_secret_arn}:username::" - }, - { - name = "FLYWAY_PASSWORD" - valueFrom = "${module.rds.db_instance_master_user_secret_arn}:password::" - }, - ] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = "/ecs/${var.name}" - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = var.name - } - } - }, - { - name = var.name - image = var.container_image - essential = true - environment = [ - { - name = "NPD_DB_NAME" - value = var.app_db_name - }, - { - name = "NPD_DB_HOST" - value = module.rds.db_instance_address - }, - { - name = "NPD_DB_PORT" - value = tostring(module.rds.db_instance_port) - }, - { - name = "NPD_DB_ENGINE" - value = "django.db.backends.postgresql" - }, - { - name = "DEBUG" - value = "" - }, - { - name = "DJANGO_ALLOWED_HOSTS" - value = aws_lb.alb.dns_name - }, - { - name = "DJANGO_LOGLEVEL" - value = "WARNING" - }, - { - name = "NPD_PROJECT_NAME" - value = "ndh" - }, - { - name = "CACHE_LOCATION", - value = "" - } - ] - secrets = [ - { - name = "NPD_DJANGO_SECRET" - valuefrom = aws_secretsmanager_secret_version.django_secret_version.arn - }, - { - name = "NPD_DB_USER" - valueFrom = "${module.rds.db_instance_master_user_secret_arn}:username::" - }, - { - name = "NPD_DB_PASSWORD" - valueFrom = "${module.rds.db_instance_master_user_secret_arn}:password::" - }, - ] - portMappings = [{ containerPort = var.container_port }] - logConfiguration = { - logDriver = "awslogs" - options = { - "awslogs-group" = "/ecs/${var.name}" - "awslogs-region" = data.aws_region.current.name - "awslogs-stream-prefix" = var.name - } - } - # TODO: Implement for your app - # healthCheck = { - # command = [] - # interval = 10 - # timeout = 5 - # retries = 10 - # startPeriod = 30 - # } - } - ]) -} - -resource "aws_ecs_service" "app" { - name = "${var.name}-service" - cluster = module.ecs.cluster_id - task_definition = aws_ecs_task_definition.app.arn - launch_type = "FARGATE" - desired_count = 1 - - network_configuration { - subnets = data.aws_subnets.public.ids - security_groups = [aws_security_group.ecs.id] - assign_public_ip = true - } - - load_balancer { - target_group_arn = aws_lb_target_group.api.arn - container_name = var.name - container_port = var.container_port - } -} - -resource "aws_security_group" "ecs" { - name = "${var.name}-sg" - vpc_id = data.aws_vpc.default.id - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } - - ingress { - from_port = 80 - to_port = 80 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - ingress { - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } - - ingress { - from_port = var.container_port - to_port = var.container_port - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - } -} - -resource "aws_db_subnet_group" "db" { - name = "${var.name}-db-subnet" - subnet_ids = data.aws_subnets.public.ids -} - -data "aws_ec2_managed_prefix_list" "cmsvpn" { - filter { - name = "prefix-list-name" - values = ["cmscloud-v4-shared-services-prod-1"] - } -} - -resource "aws_security_group" "rds_sg" { - name = "${var.name}-rds-sg" - description = "Allow ECS tasks to access RDS" - vpc_id = data.aws_vpc.default.id - - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] # We wouldn't do this in prod, but this is to make local connections easier - } - - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - security_groups = [aws_security_group.ecs.id] - } - - ingress { - from_port = 5432 - to_port = 5432 - protocol = "tcp" - prefix_list_ids = [data.aws_ec2_managed_prefix_list.cmsvpn.id] - } - - egress { - from_port = 0 - to_port = 0 - protocol = "-1" - cidr_blocks = ["0.0.0.0/0"] - } -} - -resource "aws_lb" "alb" { - name = "${var.name}-alb" - internal = false - load_balancer_type = "application" - security_groups = [aws_security_group.ecs.id] - subnets = data.aws_subnets.public.ids -} - -resource "aws_lb_target_group" "api" { - name = "${var.name}-api-tg" - port = var.container_port - protocol = "HTTP" - vpc_id = data.aws_vpc.default.id - target_type = "ip" - - health_check { - path = "/fhir/healthCheck" - port = var.container_port - interval = 30 - timeout = 5 - healthy_threshold = 2 - unhealthy_threshold = 10 - matcher = "200" - } -} - -resource "aws_lb_listener" "http" { - load_balancer_arn = aws_lb.alb.arn - port = 80 - protocol = "HTTP" - - default_action { - type = "forward" - target_group_arn = aws_lb_target_group.api.arn - } -} - -module "rds" { - source = "terraform-aws-modules/rds/aws" - version = "6.12.0" - - identifier = "${var.name}-db" - engine = "postgres" - engine_version = "17" - family = "postgres17" - instance_class = var.db_instance_class - allocated_storage = 100 - db_name = var.db_name - username = var.db_name - publicly_accessible = false - vpc_security_group_ids = [aws_security_group.rds_sg.id] - db_subnet_group_name = aws_db_subnet_group.db.name - backup_retention_period = 7 # Remove automated snapshots after 7 days - backup_window = "03:00-04:00" # 11PM EST -} - -### Frontend - -resource "aws_s3_bucket" "frontend_bucket" { - bucket = "${var.name}-frontend-bucket" -} - -resource "aws_s3_bucket_public_access_block" "frontend_public_access_block" { - bucket = aws_s3_bucket.frontend_bucket.id - - block_public_acls = false - block_public_policy = false - ignore_public_acls = false - restrict_public_buckets = false -} - -resource "aws_s3_bucket_ownership_controls" "frontend_public_access_ownership_control" { - bucket = aws_s3_bucket.frontend_bucket.id - rule { - object_ownership = "BucketOwnerPreferred" - } -} - -resource "aws_s3_bucket_acl" "frontend_bucket_acl" { - depends_on = [ - aws_s3_bucket_ownership_controls.frontend_public_access_ownership_control, - aws_s3_bucket_public_access_block.frontend_public_access_block, - ] - - bucket = aws_s3_bucket.frontend_bucket.id - acl = "public-read" -} - -resource "aws_s3_bucket_website_configuration" "frontend_bucket_website_configuration" { - bucket = aws_s3_bucket.frontend_bucket.id - index_document { - suffix = "index.html" - } - error_document { - key = "index.html" - } -} - -resource "aws_s3_bucket_policy" "frontend_bucket_policy" { - bucket = aws_s3_bucket.frontend_bucket.id - - policy = jsonencode({ - Version = "2012-10-17" - Statement = [ - { - Sid = "PublicReadGetObject" - Effect = "Allow" - Principal = "*" - Action = "s3:GetObject" - Resource = "${aws_s3_bucket.frontend_bucket.arn}/*" - } - ] - }) -} \ No newline at end of file diff --git a/infrastructure/envs/sandbox/outputs.tf b/infrastructure/envs/sandbox/outputs.tf deleted file mode 100644 index 57c6b1d6..00000000 --- a/infrastructure/envs/sandbox/outputs.tf +++ /dev/null @@ -1,20 +0,0 @@ -output "db_instance_endpoint" { - description = "The connection endpoint" - value = module.rds.db_instance_endpoint -} - -output "alb_dns_name" { - description = "The DNS name for the application load balancer" - value = aws_lb.alb.dns_name -} - -output "frontend_s3_bucket_website" { - description = "The S3 bucket website endpoint hosting the frontend" - value = aws_s3_bucket_website_configuration.frontend_bucket_website_configuration.website_endpoint -} - -output "frontend_s3_bucket" { - description = "The S3 bucket hosting the frontend" - value = aws_s3_bucket.frontend_bucket.bucket -} - diff --git a/infrastructure/envs/sandbox/terraform.tfvars b/infrastructure/envs/sandbox/terraform.tfvars deleted file mode 100644 index 0c515054..00000000 --- a/infrastructure/envs/sandbox/terraform.tfvars +++ /dev/null @@ -1,2 +0,0 @@ -name = "ndh" -db_name = "ndh" \ No newline at end of file diff --git a/infrastructure/envs/sandbox/variables.tf b/infrastructure/envs/sandbox/variables.tf deleted file mode 100644 index 2db90700..00000000 --- a/infrastructure/envs/sandbox/variables.tf +++ /dev/null @@ -1,9 +0,0 @@ -variable "name" { default = "ndh-pg-app" } -variable "db_name" { default = "ndh" } -variable "app_db_name" { default = "npd" } -variable "container_port" { default = 8000 } -variable "container_image" { default = "250902968334.dkr.ecr.us-gov-west-1.amazonaws.com/ndh:latest" } -variable "migration_image" { default = "250902968334.dkr.ecr.us-gov-west-1.amazonaws.com/ndh-migrations:latest" } -variable "ecs_cpu" { default = 512 } -variable "ecs_memory" { default = 1024 } -variable "db_instance_class" { default = "db.t3.micro" } \ No newline at end of file From e3caf02b18d430dfb9b9a7a7ef273e576a659eaf Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Mon, 17 Nov 2025 13:55:57 -0500 Subject: [PATCH 09/88] specify storage autoscaling limits (#215) Co-authored-by: Blaine Price --- infrastructure/envs/dev/main.tf | 2 ++ infrastructure/envs/prod/main.tf | 2 ++ 2 files changed, 4 insertions(+) diff --git a/infrastructure/envs/dev/main.tf b/infrastructure/envs/dev/main.tf index 5233d799..8016b017 100644 --- a/infrastructure/envs/dev/main.tf +++ b/infrastructure/envs/dev/main.tf @@ -75,6 +75,7 @@ module "api-db" { family = "postgres17" instance_class = "db.t3.large" allocated_storage = 100 + max_allocated_storage = 1000 storage_type = "gp3" publicly_accessible = false username = "npd" @@ -96,6 +97,7 @@ module "etl-db" { family = "postgres17" instance_class = "db.t3.large" allocated_storage = 500 + max_allocated_storage = 1000 publicly_accessible = false username = "npd_etl" db_name = "npd_etl" diff --git a/infrastructure/envs/prod/main.tf b/infrastructure/envs/prod/main.tf index ef65bab7..28fdcc6e 100644 --- a/infrastructure/envs/prod/main.tf +++ b/infrastructure/envs/prod/main.tf @@ -73,6 +73,7 @@ module "api-db" { family = "postgres17" instance_class = "db.t3.large" allocated_storage = 100 + max_allocated_storage = 1000 storage_type = "gp3" publicly_accessible = false username = "npd" @@ -95,6 +96,7 @@ module "etl-db" { family = "postgres17" instance_class = "db.t3.large" allocated_storage = 500 + max_allocated_storage = 1000 publicly_accessible = false username = "npd_etl" db_name = "npd_etl" From 46351936b6daffd3d8c3ab81b1f586d5904cf0a0 Mon Sep 17 00:00:00 2001 From: Isaac Milarsky Date: Mon, 17 Nov 2025 15:02:23 -0600 Subject: [PATCH 10/88] [NDH-303] Implement Custom Sorting in FHIR API (#195) * add custom sorting to endpoint view set Signed-off-by: Isaac Milarsky * add custom sorting for practitioner role view set Signed-off-by: Isaac Milarsky * add location view set Signed-off-by: Isaac Milarsky * fix errors Signed-off-by: Isaac Milarsky * add tests for custom sorting functionality Signed-off-by: Isaac Milarsky * conform to proper parameter name in ticket Signed-off-by: Isaac Milarsky * add sort parameter to swagger docs Signed-off-by: Isaac Milarsky --------- Signed-off-by: Isaac Milarsky --- backend/npdfhir/tests.py | 181 +++++++++++++++++++++++++++++++++++++-- backend/npdfhir/views.py | 91 +++++++++++++++++--- 2 files changed, 256 insertions(+), 16 deletions(-) diff --git a/backend/npdfhir/tests.py b/backend/npdfhir/tests.py index bd0716d4..770429cd 100644 --- a/backend/npdfhir/tests.py +++ b/backend/npdfhir/tests.py @@ -79,7 +79,7 @@ def test_list_default(self): self.assertEqual(response["Content-Type"], "application/fhir+json") self.assertIn("results", response.data) - def test_list_in_proper_order(self): + def test_list_in_default_order(self): url = self.list_url response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -110,6 +110,7 @@ def test_list_in_proper_order(self): self.assertEqual( names, sorted_names, f"Expected endpoints list sorted by name but got {names}\n Sorted: {sorted_names}") + def test_list_returns_fhir_bundle(self): response = self.client.get(self.list_url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -264,7 +265,7 @@ def test_list_default(self): self.assertEqual(response["Content-Type"], "application/fhir+json") self.assertIn("results", response.data) - def test_list_in_proper_order(self): + def test_list_in_default_order(self): url = reverse("fhir-organization-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -293,6 +294,35 @@ def test_list_in_proper_order(self): self.assertEqual( names, sorted_names, f"Expected fhir orgs sorted by org name but got {names}\n Sorted: {sorted_names}") + def test_list_in_descending_order(self): + url = reverse("fhir-organization-list") + response = self.client.get(url, {"_sort": '-primary_name'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/fhir+json") + + # Extract names + # Note: have to normalize the names to have python sorting match sql + names = [ + d['resource'].get('name', {}) + for d in response.data["results"]["entry"] + ] + + sorted_names = [ + {}, + 'ZUNI HOME HEALTH CARE AGENCY', + 'ZEELAND COMMUNITY HOSPITAL', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNG C. BAE, M.D.', + 'YORKTOWN EMERGENCY MEDICAL SERVICE', + 'YODORINCMISSIONPLAZAPHARMACY', + 'YOAKUM COMMUNITY HOSPITAL', + 'YARMOUTH AUDIOLOGY' + ] + + self.assertEqual( + names, sorted_names, f"Expected fhir org list sorted descending by name but got {names}\n Sorted: {sorted_names}") + def test_list_with_custom_page_size(self): url = reverse("fhir-organization-list") response = self.client.get(url, {"page_size": 2}) @@ -440,7 +470,7 @@ def test_list_default(self): self.assertEqual(response["Content-Type"], "application/fhir+json") self.assertIn("results", response.data) - def test_list_in_proper_order(self): + def test_list_in_default_order(self): url = reverse("fhir-location-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -468,7 +498,78 @@ def test_list_in_proper_order(self): ] self.assertEqual( - names, sorted_names, f"Expected fhir orgs sorted by org name but got {names}\n Sorted: {sorted_names}") + names, sorted_names, f"Expected fhir locations sorted by name but got {names}\n Sorted: {sorted_names}") + + def test_list_in_descending_order(self): + url = reverse("fhir-location-list") + response = self.client.get(url, {"_sort": '-name'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/fhir+json") + + # Extract names + # Note: have to normalize the names to have python sorting match sql + names = [ + d['resource'].get('name', {}) + for d in response.data["results"]["entry"] + ] + + sorted_names = [ + 'ZEELAND COMMUNITY HOSPITAL', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNGSTOWN ORTHOPAEDIC ASSOCIATES LTD', + 'YOUNG C. BAE, M.D.', + 'YORKTOWN EMERGENCY MEDICAL SERVICE', + 'YODORINCMISSIONPLAZAPHARMACY', + 'YOAKUM COMMUNITY HOSPITAL' + ] + + self.assertEqual( + names, sorted_names, f"Expected locations list sorted by name in descending but got {names}\n Sorted: {sorted_names}") + + def test_list_in_order_by_address(self): + url = reverse("fhir-location-list") + response = self.client.get(url, {"_sort": 'address_full'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/fhir+json") + + # Extract names + # Note: have to normalize the names to have python sorting match sql + names = [ + d['resource'].get('name', {}) + for d in response.data["results"]["entry"] + ] + + #Names correspond to following addresses + #10000 W Bluemound Rd, Wauwatosa, WI 53226 + #10004 S 152nd St, Omaha, NE 68138 + #1000 5th St, International Falls, MN 56649 + #1000 Airport Rd, Lakewood, NJ 8701 + #1000 E Center St, Kingsport, TN 37660 + #1000 E Main St, Danville, IN 46122 + #1000 Greenley Rd, Sonora, CA 95370 + #1000 Regency Ct, Toledo, OH 43623 + + + sorted_names = [ + 'FROEDTERT MEMORIAL LUTHERAN HOSPITAL, INC.', + 'FROEDTERT MEMORIAL LUTHERAN HOSPITAL, INC.', + 'AMBER ENTERPRISES INC.', + 'COUNTY OF KOOCHICHING', + 'OCEAN HOME HEALTH SUPPLY, LLC', + 'PULMONARY MANAGEMENT, INC.', + 'MEDICATION MANAGEMENT CENTER, LLC.', + 'HENDRICKS COUNTY HOSPITAL', + 'BAY AREA REHABILITATION MEDICAL GROUP', + 'PROHAB REHABILITATION SERVICES, INC.' + ] + + self.assertEqual( + names, sorted_names, f"Expected locations list sorted by address ascending but got {names}\n Sorted: {sorted_names}") + + def test_list_with_custom_page_size(self): url = reverse("fhir-location-list") @@ -542,7 +643,7 @@ def test_list_default(self): self.assertEqual(response["Content-Type"], "application/fhir+json") self.assertIn("results", response.data) - def test_list_in_proper_order(self): + def test_list_in_default_order(self): url = reverse("fhir-practitioner-list") response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -574,8 +675,76 @@ def test_list_in_proper_order(self): ] self.assertEqual( - names, sorted_names, f"Expected fhir orgs sorted by org name but got {names}\n Sorted: {sorted_names}") + names, sorted_names, f"Expected fhir practitioners sorted by family then first name but got {names}\n Sorted: {sorted_names}") + + def test_list_in_alternate_order(self): + url = reverse("fhir-practitioner-list") + response = self.client.get(url, {"_sort": 'primary_first_name,primary_last_name'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/fhir+json") + + # print(response.data["results"]["entry"][0]['resource']['name'][0]) + + # for name in response.data["results"]["entry"]: + # print(name['resource']['name'][-1]) + + # Extract names + names = [ + (d['resource']['name'][-1].get('family', {}), + d['resource']['name'][-1]['given'][0]) + for d in response.data["results"]["entry"] + ] + + sorted_names = [ + ('CUTLER', 'A'), + ('NIZAM', 'A'), + ('SALAIS', 'A'), + ('JANOS', 'AARON'), + ('NOONBERG', 'AARON'), + ('PITNEY', 'AARON'), + ('SOLOMON', 'AARON'), + ('STEIN', 'AARON'), + ('ALI', 'ABBAS'), + ('JAFRI', 'ABBAS') + ] + + self.assertEqual( + names, sorted_names, f"Expected fhir practitioners sorted by first then family name but got {names}\n Sorted: {sorted_names}") + + + def test_list_in_descending_order(self): + url = reverse("fhir-practitioner-list") + response = self.client.get(url, {"_sort": '-primary_last_name,-primary_first_name'}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response["Content-Type"], "application/fhir+json") + + # Extract names + # Note: have to normalize the names to have python sorting match sql + names = [ + (d['resource']['name'][-1].get('family', {}), + d['resource']['name'][-1]['given'][0]) + for d in response.data["results"]["entry"] + ] + + sorted_names = [ + ('ZWERLING', 'HAYWARD'), + ('ZUROSKE', 'GLEN'), + ('ZUCKERBERG', 'EDWARD'), + ('ZUCKER', 'WILLIAM'), + ('ZUCCALA', 'SCOTT'), + ('ZOVE', 'DANIEL'), + ('ZORN', 'GUNNAR'), + ('ZOOG', 'EUGENE'), + ('ZOLMAN', 'MARK'), + ('ZOLLER', 'DAVID') + ] + + self.assertEqual( + names, sorted_names, f"Expected fhir practitioners sorted by family then first name in descending but got {names}\n Sorted: {sorted_names}") + + + def test_list_with_custom_page_size(self): url = reverse("fhir-practitioner-list") response = self.client.get(url, {"page_size": 2}) diff --git a/backend/npdfhir/views.py b/backend/npdfhir/views.py index 6abd387a..024020a0 100644 --- a/backend/npdfhir/views.py +++ b/backend/npdfhir/views.py @@ -1,6 +1,9 @@ from uuid import UUID -from django.db.models import Q, OuterRef, Subquery +from django.contrib.postgres.search import SearchVector +from django.core.cache import cache +from django.db.models import Q, F, OuterRef, Subquery, Value, CharField +from django.db.models.functions import Concat from django.http import HttpResponse from django.shortcuts import get_object_or_404 from django.utils.html import escape @@ -10,6 +13,7 @@ from rest_framework.views import APIView from rest_framework.renderers import BrowsableAPIRenderer from rest_framework.response import Response +from rest_framework.filters import SearchFilter, OrderingFilter from .pagination import CustomPaginator from .renderers import FHIRRenderer @@ -49,14 +53,20 @@ def health(request): return HttpResponse("healthy") +class ParamOrderingFilter(OrderingFilter): + ordering_param = '_sort' + + class FHIREndpointViewSet(viewsets.GenericViewSet): """ ViewSet for FHIR Endpoint Resources """ queryset = EndpointInstance.objects.none() renderer_classes = [FHIRRenderer, BrowsableAPIRenderer] - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, SearchFilter, ParamOrderingFilter] filterset_class = EndpointFilterSet + ordering_fields = ['name', 'address', 'ehr_vendor_name'] + ordering = ['name'] pagination_class = CustomPaginator @extend_schema( @@ -80,6 +90,8 @@ def list(self, request): 'endpointinstancetopayload_set__payload_type', 'endpointinstancetopayload_set__mime_type', 'endpointinstancetootherid_set' + ).annotate( + ehr_vendor_name=F('ehr_vendor__name') ).order_by('name') endpoints = self.filter_queryset(endpoints) @@ -132,10 +144,15 @@ class FHIRPractitionerViewSet(viewsets.GenericViewSet): """ queryset = Provider.objects.none() renderer_classes = [FHIRRenderer, BrowsableAPIRenderer] - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, SearchFilter, ParamOrderingFilter] filterset_class = PractitionerFilterSet pagination_class = CustomPaginator + + ordering_fields = ['primary_last_name', 'primary_first_name', 'npi_value'] + ordering = ['primary_last_name', 'primary_first_name'] + + # permission_classes = [permissions.IsAuthenticated] @extend_schema( responses={ 200: OpenApiResponse( @@ -172,7 +189,8 @@ def list(self, request): 'individual__individualtoemail_set', 'providertootherid_set', 'providertotaxonomy_set' ).annotate( primary_last_name=Subquery(primary_last_name_subquery), - primary_first_name=Subquery(primary_first_name_subquery) + primary_first_name=Subquery(primary_first_name_subquery), + npi_value=F('npi__npi') ).order_by('primary_last_name', 'primary_first_name') providers = self.filter_queryset(providers) @@ -233,10 +251,14 @@ class FHIRPractitionerRoleViewSet(viewsets.GenericViewSet): """ queryset = ProviderToLocation.objects.none() renderer_classes = [FHIRRenderer, BrowsableAPIRenderer] - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, SearchFilter, ParamOrderingFilter] filterset_class = PractitionerRoleFilterSet pagination_class = CustomPaginator + ordering_fields = ['location_name','practitioner_first_name','practitioner_last_name'] + ordering = ['location__name'] + + # permission_classes = [permissions.IsAuthenticated] @extend_schema( responses={ 200: OpenApiResponse( @@ -250,10 +272,32 @@ def list(self, request): Default sort order: aschending by location name """ + all_params = request.query_params + + primary_last_name_subquery = ( + IndividualToName.objects + .filter(individual=OuterRef('provider_to_organization__individual__individual')) + .order_by('last_name') + .values('last_name')[:1] + ) + + primary_first_name_subquery = ( + IndividualToName.objects + .filter(individual=OuterRef('provider_to_organization__individual__individual')) + .order_by('first_name') + .values('first_name')[:1] + ) + + practitionerroles = ( ProviderToLocation.objects .select_related('location') .prefetch_related('provider_to_organization') + .annotate( + location_name=F('location__name'), + practitioner_first_name=Subquery(primary_first_name_subquery), + practitioner_last_name=Subquery(primary_last_name_subquery), + ) .order_by('location__name') ).all() @@ -301,10 +345,14 @@ class FHIROrganizationViewSet(viewsets.GenericViewSet): """ queryset = Organization.objects.none() renderer_classes = [FHIRRenderer, BrowsableAPIRenderer] - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, SearchFilter, ParamOrderingFilter] filterset_class = OrganizationFilterSet pagination_class = CustomPaginator + ordering_fields = ['primary_name'] + ordering = ['primary_name'] + + # permission_classes = [permissions.IsAuthenticated] @extend_schema( responses={ 200: OpenApiResponse( @@ -416,10 +464,15 @@ class FHIRLocationViewSet(viewsets.GenericViewSet): """ queryset = Location.objects.none() renderer_classes = [FHIRRenderer, BrowsableAPIRenderer] - filter_backends = [DjangoFilterBackend] + filter_backends = [DjangoFilterBackend, SearchFilter, ParamOrderingFilter] filterset_class = LocationFilterSet pagination_class = CustomPaginator + ordering_fields = ['organization_name','address_full','name'] + ordering = ['name'] + + + # permission_classes = [permissions.IsAuthenticated] @extend_schema( responses={ 200: OpenApiResponse( @@ -433,9 +486,27 @@ def list(self, request): Default sort order: ascending by location name """ - locations = Location.objects.all().prefetch_related( - 'address__address_us', 'address__address_us__state_code' - ).order_by('name') + locations = ( + Location.objects.all() + .select_related( + "organization", + "address__address_us", + "address__address_us__state_code", + ) + .annotate( + organization_name=F("organization__organizationtoname__name"), + address_full=Concat( + F("address__address_us__delivery_line_1"), + Value(", "), + F("address__address_us__city_name"), + Value(", "), + F("address__address_us__state_code__abbreviation"), + Value(" "), + F("address__address_us__zipcode"), + output_field=CharField(), + ), + ).order_by('name') + ) locations = self.filter_queryset(locations) paginated_locations = self.paginate_queryset(locations) From 6ffa866ed2069a683e4aed4f0294b5cb10f711fc Mon Sep 17 00:00:00 2001 From: Blaine Price <1wbprice@gmail.com> Date: Mon, 17 Nov 2025 16:11:33 -0500 Subject: [PATCH 11/88] Use an alias to describe directory domain (#217) Co-authored-by: Blaine Price --- infrastructure/envs/dev/main.tf | 1 + infrastructure/envs/prod/main.tf | 1 + infrastructure/modules/dns/main.tf | 10 +++++++--- infrastructure/modules/dns/variables.tf | 1 + infrastructure/modules/fhir-api/outputs.tf | 4 ++++ 5 files changed, 14 insertions(+), 3 deletions(-) diff --git a/infrastructure/envs/dev/main.tf b/infrastructure/envs/dev/main.tf index 8016b017..0bb45e3a 100644 --- a/infrastructure/envs/dev/main.tf +++ b/infrastructure/envs/dev/main.tf @@ -47,6 +47,7 @@ module "dns" { api_alb_dns_name = module.fhir-api.api_alb_dns_name directory_domain = module.domains.directory_domain directory_alb_dns_name = module.fhir-api.api_dot_alb_dns_name + directory_alb_zone_id = module.fhir-api.api_alb_zone_id etl_domain = module.domains.etl_domain etl_alb_dns_name = module.etl.dagster_ui_alb_dns_name } diff --git a/infrastructure/envs/prod/main.tf b/infrastructure/envs/prod/main.tf index 28fdcc6e..25484839 100644 --- a/infrastructure/envs/prod/main.tf +++ b/infrastructure/envs/prod/main.tf @@ -45,6 +45,7 @@ module "dns" { api_alb_dns_name = module.fhir-api.api_alb_dns_name directory_domain = module.domains.directory_domain directory_alb_dns_name = module.fhir-api.api_dot_alb_dns_name + directory_alb_zone_id = module.fhir-api.api_alb_zone_id etl_domain = module.domains.etl_domain etl_alb_dns_name = module.etl.dagster_ui_alb_dns_name } diff --git a/infrastructure/modules/dns/main.tf b/infrastructure/modules/dns/main.tf index 8992bdeb..7f213994 100644 --- a/infrastructure/modules/dns/main.tf +++ b/infrastructure/modules/dns/main.tf @@ -14,9 +14,13 @@ resource "aws_route53_record" "directory" { count = var.enable_internal_domain_for_directory ? 1 : 0 zone_id = aws_route53_zone.internal_dns.zone_id name = var.directory_domain - type = "CNAME" - ttl = "300" - records = [var.directory_alb_dns_name] + type = "A" + + alias { + name = var.directory_alb_dns_name + zone_id = var.directory_alb_zone_id + evaluate_target_health = true + } } resource "aws_route53_record" "api" { diff --git a/infrastructure/modules/dns/variables.tf b/infrastructure/modules/dns/variables.tf index ed29ce82..fa1c5999 100644 --- a/infrastructure/modules/dns/variables.tf +++ b/infrastructure/modules/dns/variables.tf @@ -2,6 +2,7 @@ variable "api_domain" {} variable "api_alb_dns_name" {} variable "directory_domain" {} variable "directory_alb_dns_name" {} +variable "directory_alb_zone_id" {} variable "etl_domain" {} variable "etl_alb_dns_name" {} variable "enable_internal_domain_for_directory" {} diff --git a/infrastructure/modules/fhir-api/outputs.tf b/infrastructure/modules/fhir-api/outputs.tf index b4715502..40d94df6 100644 --- a/infrastructure/modules/fhir-api/outputs.tf +++ b/infrastructure/modules/fhir-api/outputs.tf @@ -4,4 +4,8 @@ output "api_alb_dns_name" { output "api_dot_alb_dns_name" { value = aws_alb.fhir_api_alb_redirect.dns_name +} + +output "api_alb_zone_id" { + value = aws_lb.fhir_api_alb.zone_id } \ No newline at end of file From cf8c33fd77d8449d77c1035230c0862fab13c114 Mon Sep 17 00:00:00 2001 From: Adam Bachman Date: Mon, 17 Nov 2025 16:55:25 -0500 Subject: [PATCH 12/88] [NDH-446] ensure login header, footer, and body are responsive on mobile (#216) --- frontend/src/components/Footer.tsx | 59 ++++++++++++++++++----- frontend/src/components/Header.module.css | 15 ++++-- frontend/src/components/Header.tsx | 25 +++++++--- frontend/src/pages/Login.module.css | 5 -- frontend/src/pages/Login.tsx | 2 +- 5 files changed, 76 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index 88d41d3c..7d54e536 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -26,45 +26,82 @@ const FooterLogo = () => { export const Footer = () => { const { t } = useTranslation() + const logoColumnClasses = classNames( + "ds-l-md-col--5", + "ds-l-sm-col--12", + "ds-u-padding-bottom--4", + "ds-u-md-padding-bottom--0", + ) + + const columnClasses = classNames( + "ds-l-md-col--2", + "ds-l-sm-col--12", + "ds-u-padding-y--3", + "ds-u-md-padding-y--0", + ) + const linkHeaderClasses = classNames( "usa-footer__primary-link", - "ds-u-padding-top--0", + "ds-u-padding-top--none", + "ds-u-sm-padding-top--1", + "ds-u-md-padding-top--0", + "ds-u-padding-left--0", + "ds-u-margin-top--0", "ds-u-margin-top--0", ) + return (