diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index a6515f6f7c..71a94d524e 100644 --- a/.github/workflows/build-and-push-image.yml +++ b/.github/workflows/build-and-push-image.yml @@ -41,7 +41,7 @@ jobs: steps.check-prod-image.outputs.ops-build-needed }} steps: - name: Configure AWS Dev Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GithubDeployECSService aws-region: eu-west-2 @@ -57,7 +57,7 @@ jobs: fi - name: Configure AWS Production credentials if: env.PUSH_IMAGE_TO_PRODUCTION == 'true' - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::820242920762:role/GithubDeployECSService aws-region: eu-west-2 @@ -102,25 +102,25 @@ jobs: aws-role: ${{ fromJSON(needs.define-matrix.outputs.aws-roles) }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.git_ref }} - name: Write build SHA run: git rev-parse HEAD > public/sha - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: ${{ matrix.aws-role }} aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # yamllint disable rule:line-length - name: Build and push webapp image if: needs.check-image-presence.outputs.webapp-build-needed == 'true' - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . push: true @@ -132,7 +132,7 @@ jobs: }}/mavis/webapp:buildcache,mode=max,image-manifest=true,oci-mediatypes=true - name: Build and push ops image if: needs.check-image-presence.outputs.ops-build-needed == 'true' - uses: docker/build-push-action@v7 + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 with: context: . file: ops.Dockerfile diff --git a/.github/workflows/call-end-to-end-tests.yml b/.github/workflows/call-end-to-end-tests.yml index 34e9b12ef6..114d0e36e7 100644 --- a/.github/workflows/call-end-to-end-tests.yml +++ b/.github/workflows/call-end-to-end-tests.yml @@ -3,7 +3,10 @@ name: Call end-to-end tests on: workflow_call: inputs: - cross_service_tests: + fhir_api_tests: + required: true + type: boolean + reporting_tests: required: true type: boolean endpoint: @@ -31,7 +34,8 @@ jobs: # yamllint disable-line rule:line-length uses: NHSDigital/manage-vaccinations-in-schools-testing/.github/workflows/end-to-end-tests.yaml@main with: - cross_service_tests: ${{ inputs.cross_service_tests }} + fhir_api_tests: ${{ inputs.fhir_api_tests }} + reporting_tests: ${{ inputs.reporting_tests }} github_ref: ${{ inputs.github_ref }} endpoint: ${{ inputs.endpoint }} secrets: diff --git a/.github/workflows/continuous-deployment.yml b/.github/workflows/continuous-deployment.yml index 16c3a32dc2..01578c22cc 100644 --- a/.github/workflows/continuous-deployment.yml +++ b/.github/workflows/continuous-deployment.yml @@ -28,7 +28,8 @@ jobs: uses: ./.github/workflows/call-end-to-end-tests.yml secrets: inherit with: - cross_service_tests: true + fhir_api_tests: true + reporting_tests: true endpoint: https://qa.mavistesting.com github_ref: main slack-notification: diff --git a/.github/workflows/create_dockerized_db.yml b/.github/workflows/create_dockerized_db.yml index ae5cb9b552..cb12738052 100644 --- a/.github/workflows/create_dockerized_db.yml +++ b/.github/workflows/create_dockerized_db.yml @@ -29,11 +29,11 @@ jobs: RAILS_MASTER_KEY: intentionally-insecure-dev-key00 SKIP_TEST_DATABASE: true steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.github_ref || github.ref_name == 'next' && 'next' || github.ref_name }} repository: nhsuk/manage-vaccinations-in-schools - - uses: actions/setup-node@v6 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .tool-versions cache: yarn @@ -56,7 +56,7 @@ jobs: sleep 2 done ' - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Populate database for testing @@ -65,13 +65,13 @@ jobs: bin/rails feature_flags:enable_for_development bin/mavis gias import --input-file=spec/fixtures/dfe-schools.zip - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 # yamllint disable rule:line-length - name: get github ref short id: github-ref diff --git a/.github/workflows/data-replication-pipeline.yml b/.github/workflows/data-replication-pipeline.yml index 9c8fc4ca54..75604ca7df 100644 --- a/.github/workflows/data-replication-pipeline.yml +++ b/.github/workflows/data-replication-pipeline.yml @@ -70,7 +70,7 @@ jobs: git-sha: ${{ steps.get-git-sha.outputs.git-sha }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.git_ref_to_deploy }} - name: Get git sha @@ -93,11 +93,11 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ env.git_ref_to_deploy }} - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: ${{ env.aws_role }} aws-region: eu-west-2 @@ -125,7 +125,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Populate web task definition id: create-task-definition - uses: aws-actions/amazon-ecs-render-task-definition@v1 + uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1.8.4 with: task-definition-family: mavis-data-replication-task-definition-${{ inputs.environment }}-template @@ -140,7 +140,7 @@ jobs: mv ${{ steps.create-task-definition.outputs.task-definition }} ${{ runner.temp }}/data-replication-task-definition.json - name: Upload artifact for data-replication task definition - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.environment }}-data-replication-task-definition path: ${{ runner.temp }}/data-replication-task-definition.json @@ -189,12 +189,12 @@ jobs: id-token: write steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: ${{ env.aws_role }} aws-region: eu-west-2 - name: Download data-replication task definition artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }} name: ${{ inputs.environment }}-data-replication-task-definition @@ -205,7 +205,7 @@ jobs: jq --arg f "$family_name" '.family = $f' "$file_path" > tmpfile && mv tmpfile "$file_path" - name: Deploy data-replication service id: ecs-deploy - uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + uses: aws-actions/amazon-ecs-deploy-task-definition@fc8fc60f3a60ffd500fcb13b209c59d221ac8c8c # v2.6.1 with: task-definition: ${{ runner.temp }}/data-replication-task-definition.json cluster: mavis-${{ inputs.environment }}-data-replication diff --git a/.github/workflows/deploy-documentation.yml b/.github/workflows/deploy-documentation.yml index e557a03232..9c227712ad 100644 --- a/.github/workflows/deploy-documentation.yml +++ b/.github/workflows/deploy-documentation.yml @@ -18,8 +18,8 @@ jobs: cancel-in-progress: true steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true @@ -27,7 +27,7 @@ jobs: run: bundle exec rake rdoc:generate - name: Deploy to GitHub Pages - uses: JamesIves/github-pages-deploy-action@v4 + uses: JamesIves/github-pages-deploy-action@d92aa235d04922e8f08b40ce78cc5442fcfbfa2f # v4.8.0 with: branch: gh-pages folder: docs/rdoc diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 76f3444b8a..9716d5d539 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -80,7 +80,7 @@ jobs: fi fi - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ inputs.git_ref_to_deploy || github.sha }} - name: Get git sha @@ -109,12 +109,12 @@ jobs: repository_name: ${{ matrix.service == 'ops' && 'mavis/ops' || 'mavis/webapp' }} steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 id: checkout-code with: ref: ${{ needs.validate-and-resolve-sha.outputs.git-sha }} - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ env.aws_account_id }}:role/GithubDeployECSService aws-region: eu-west-2 @@ -131,7 +131,7 @@ jobs: echo "digest=$digest" >> "$GITHUB_OUTPUT" - name: Populate task definition id: create-task-definition - uses: aws-actions/amazon-ecs-render-task-definition@v1 + uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1.8.4 with: task-definition-family: mavis-${{ matrix.service }}-task-definition-${{ inputs.environment }}-template @@ -147,7 +147,7 @@ jobs: mv ${{ steps.create-task-definition.outputs.task-definition }} ${{ runner.temp }}/${{ matrix.service }}-task-definition.json - name: Upload artifact for ${{ matrix.service }} task definition - uses: actions/upload-artifact@v7 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: ${{ inputs.environment }}-${{ matrix.service }}-task-definition path: ${{ runner.temp }}/${{ matrix.service }}-task-definition.json @@ -196,14 +196,14 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ env.aws_account_id }}:role/GithubDeployECSService aws-region: eu-west-2 - name: Download ops task definition artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }} name: ${{ inputs.environment }}-ops-task-definition @@ -332,12 +332,12 @@ jobs: fromJSON(format('["{0}"]', inputs.server_types)) }} steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ env.aws_account_id }}:role/GithubDeployECSService aws-region: eu-west-2 - name: Download ${{ matrix.service }} task definition artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }} name: ${{ inputs.environment }}-${{ matrix.service }}-task-definition @@ -348,7 +348,7 @@ jobs: jq --arg f "$family_name" '.family = $f' "$file_path" > tmpfile && mv tmpfile "$file_path" - name: Deploy ${{ matrix.service }} service id: ecs-deploy - uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + uses: aws-actions/amazon-ecs-deploy-task-definition@fc8fc60f3a60ffd500fcb13b209c59d221ac8c8c # v2.6.1 with: task-definition: ${{ runner.temp }}/${{ matrix.service }}-task-definition.json cluster: ${{ env.cluster_name }} @@ -376,14 +376,14 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::${{ env.aws_account_id }}:role/GithubDeployECSService aws-region: eu-west-2 - name: Download ops task definition artifact - uses: actions/download-artifact@v8 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: path: ${{ runner.temp }} name: ${{ inputs.environment }}-ops-task-definition diff --git a/.github/workflows/draft-new-release.yml b/.github/workflows/draft-new-release.yml index 14f0e1b615..6765a9dddc 100644 --- a/.github/workflows/draft-new-release.yml +++ b/.github/workflows/draft-new-release.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Generate release notes diff --git a/.github/workflows/end-to-end-tests-aws.yml b/.github/workflows/end-to-end-tests-aws.yml index 2f3ce8aae4..28c9740c4d 100644 --- a/.github/workflows/end-to-end-tests-aws.yml +++ b/.github/workflows/end-to-end-tests-aws.yml @@ -26,11 +26,11 @@ jobs: application-image-git-ref: ${{ steps.check-image.outputs.GIT_REF_SHA }} steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 repository: nhsuk/manage-vaccinations-in-schools @@ -59,18 +59,18 @@ jobs: id-token: write steps: - name: Checkout code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ needs.check-development-image-presence.outputs.application-image-git-ref }} repository: nhsuk/manage-vaccinations-in-schools - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 + uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 - name: Build and push mavis/development docker image # yamllint disable rule:line-length run: | @@ -91,11 +91,11 @@ jobs: db_git_ref_sha: ${{ steps.check-image.outputs.GIT_REF_SHA }} steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 repository: nhsuk/manage-vaccinations-in-schools @@ -145,13 +145,13 @@ jobs: run_task_arn: ${{ steps.run-task.outputs.run-task-arn }} steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 - name: Render task definition web id: render-task-definition-web - uses: aws-actions/amazon-ecs-render-task-definition@v1 + uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1.8.4 with: task-definition-family: assurance-testing-mavis-development-task-definition-template container-name: mavis-development-web @@ -161,7 +161,7 @@ jobs: needs.check-development-image-presence.outputs.application-image-git-ref }} - name: Render task definition database id: render-task-definition-database - uses: aws-actions/amazon-ecs-render-task-definition@v1 + uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1.8.4 with: task-definition: ${{ steps.render-task-definition-web.outputs.task-definition }} container-name: mavis-development-db @@ -171,7 +171,7 @@ jobs: needs.check-database-image-presence.outputs.db_git_ref_sha }} - name: Render task definition sidekiq id: render-task-definition-sidekiq - uses: aws-actions/amazon-ecs-render-task-definition@v1 + uses: aws-actions/amazon-ecs-render-task-definition@77954e213ba1f9f9cb016b86a1d4f6fcdea0d57e # v1.8.4 with: task-definition: ${{ steps.render-task-definition-database.outputs.task-definition }} container-name: mavis-development-sidekiq @@ -197,7 +197,7 @@ jobs: echo "run-task-subnets=$subnet_id" >> "$GITHUB_OUTPUT" echo "run-task-security-groups=$security_group_id" >> "$GITHUB_OUTPUT" - name: Deploy task definition - uses: aws-actions/amazon-ecs-deploy-task-definition@v2 + uses: aws-actions/amazon-ecs-deploy-task-definition@fc8fc60f3a60ffd500fcb13b209c59d221ac8c8c # v2.6.1 id: run-task with: task-definition: "assurance-testing-mavis-development-task-definition.json" @@ -218,7 +218,7 @@ jobs: container_ip: ${{ steps.compile-outputs.outputs.container_ip }} steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 @@ -349,7 +349,8 @@ jobs: contents: write id-token: write with: - cross_service_tests: false + fhir_api_tests: false + reporting_tests: true github_ref: ${{ needs.find-correct-test-branch.outputs.test_branch }} endpoint: http://${{ needs.wait-for-task-stability.outputs.container_ip }}:4000 stop-docker-environment: @@ -360,7 +361,7 @@ jobs: id-token: write steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 diff --git a/.github/workflows/end-to-end-tests-local.yml b/.github/workflows/end-to-end-tests-local.yml index 361e109776..b6793a5f6c 100644 --- a/.github/workflows/end-to-end-tests-local.yml +++ b/.github/workflows/end-to-end-tests-local.yml @@ -22,16 +22,16 @@ jobs: RAILS_ENV: end_to_end steps: - name: Checkout base branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.base_ref }} - name: Setup Node.js on base branch - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .tool-versions cache: yarn - name: Setup Ruby on base branch - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Install JS dependencies on base branch @@ -39,16 +39,16 @@ jobs: - name: Setup database run: bin/rails db:setup - name: Checkout head branch - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: ref: ${{ github.head_ref }} - name: Setup Node.js on head branch - uses: actions/setup-node@v6 + uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .tool-versions cache: yarn - name: Setup Ruby on head branch - uses: ruby/setup-ruby@v1 + uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Install JS dependencies on head branch @@ -81,7 +81,7 @@ jobs: HEAD_REF: ${{ github.head_ref }} BASE_REF: ${{ github.base_ref }} - name: Clone testing repository - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: repository: NHSDigital/manage-vaccinations-in-schools-testing ref: ${{ steps.check-branch.outputs.test_branch }} @@ -89,7 +89,7 @@ jobs: - name: Setup testing repository environment file run: mv ${{ env.MAVIS_TEST_REPO }}/.env.generic ${{ env.MAVIS_TEST_REPO }}/.env - name: Setup uv - uses: astral-sh/setup-uv@v7 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Setup Playwright working-directory: ${{ env.MAVIS_TEST_REPO }} run: | diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a510d77148..149a4985c7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,16 +12,16 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: ruby/setup-ruby@v1 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - - uses: actions/setup-node@v6 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: cache: yarn node-version-file: .tool-versions - run: yarn install --immutable --immutable-cache --check-cache - - uses: jdx/mise-action@v4 + - uses: jdx/mise-action@1648a7812b9aeae629881980618f079932869151 # v4.0.1 with: install_args: actionlint hk pkl shellcheck yamllint - run: hk check --all diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2688593af0..7e8a2a8a19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,12 +25,12 @@ jobs: DATABASE_USER: postgres DATABASE_PASSWORD: postgres steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .tool-versions cache: yarn - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Precompile assets @@ -62,12 +62,12 @@ jobs: RAILS_ENV: development DATABASE_URL: postgres://postgres:postgres@localhost:5432/manage_vaccinations_development steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .tool-versions cache: yarn - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Check seeds run @@ -77,8 +77,8 @@ jobs: name: Jest runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-node@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: node-version-file: .tool-versions cache: yarn diff --git a/.github/workflows/update-version-numbers-slack.yml b/.github/workflows/update-version-numbers-slack.yml index 120e67ab17..40d33e6705 100644 --- a/.github/workflows/update-version-numbers-slack.yml +++ b/.github/workflows/update-version-numbers-slack.yml @@ -17,7 +17,7 @@ jobs: main_tag: ${{ steps.get_tags.outputs.main_tag }} steps: - name: Checkout code and fetch tags - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 - name: Get latest tags for branches diff --git a/.gitignore b/.gitignore index e63374ceb6..d5339587fd 100644 --- a/.gitignore +++ b/.gitignore @@ -90,3 +90,7 @@ dump.rdb # Data that gets downloaded by CLI tools db/data/dfe-schools.zip db/data/nhs-gp-practices.csv + +# Python +__pycache__/ +*.pyc diff --git a/.ruby-version b/.ruby-version index 9c63baa1a2..8b52f98145 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -ruby-4.0.2 +ruby-4.0.3 diff --git a/.tool-versions b/.tool-versions index 453c61cede..81e3b2884f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -4,7 +4,8 @@ hk 1.37.0 nodejs 22.15.0 pkl 0.31.0 postgres 17.2 +python 3.14.4 redis 8.2.1 -ruby 4.0.2 +ruby 4.0.3 shellcheck 0.11.0 yamllint 1.38.0 diff --git a/.yamllint.yaml b/.yamllint.yaml index 8fe11f057d..72d7e94cfe 100644 --- a/.yamllint.yaml +++ b/.yamllint.yaml @@ -10,6 +10,8 @@ rules: min-spaces-from-content: 1 document-start: disable line-length: - max: 100 + # Ideally, this value would be smaller, but it makes dealing with long lines + # including commit SHAs, etc. very difficult. + max: 120 truthy: ignore: .github/workflows/* diff --git a/Dockerfile b/Dockerfile index 886495fb40..546b4d5241 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,7 +2,7 @@ # check=error=true # Make sure RUBY_VERSION matches the Ruby version in .ruby-version -ARG RUBY_VERSION=4.0.2 +ARG RUBY_VERSION=4.0.3 ARG BUNDLE_WITHOUT="development:test" ARG RAILS_ENV="production" FROM docker.io/library/ruby:$RUBY_VERSION-slim AS base diff --git a/Gemfile b/Gemfile index f34c946b56..85d6ad7559 100644 --- a/Gemfile +++ b/Gemfile @@ -118,9 +118,7 @@ group :test do gem "capybara" gem "capybara_accessible_selectors", github: "citizensadvice/capybara_accessible_selectors" - gem "capybara-screenshot" gem "climate_control" - gem "cuprite" gem "database_cleaner-active_record" gem "its" gem "rack_session_access" diff --git a/Gemfile.lock b/Gemfile.lock index e59652107d..5db0fb4bdb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,6 +1,6 @@ GIT remote: https://github.com/citizensadvice/capybara_accessible_selectors.git - revision: e204122d3949530828ba8ea52e33409d86f72679 + revision: d518ee5271f60e18a555434160ae02b74e534046 specs: capybara_accessible_selectors (0.15.0) capybara (~> 3.36) @@ -129,7 +129,7 @@ GEM asciidoctor (>= 1.5.7, < 3.x) rexml ast (2.4.3) - async (2.38.0) + async (2.39.0) console (~> 1.29) fiber-annotation io-event (~> 1.11) @@ -139,17 +139,17 @@ GEM actioncable-next async (~> 2.9) async-websocket - async-container (0.34.3) + async-container (0.34.5) async (~> 2.22) - async-http (0.94.2) + async-http (0.95.0) async (>= 2.10.2) async-pool (~> 0.11) io-endpoint (~> 0.14) io-stream (~> 0.6) metrics (~> 0.12) - protocol-http (~> 0.58) - protocol-http1 (~> 0.36) - protocol-http2 (~> 0.22) + protocol-http (~> 0.62) + protocol-http1 (~> 0.39) + protocol-http2 (~> 0.26) protocol-url (~> 0.2) traces (~> 0.10) async-http-cache (0.4.6) @@ -162,11 +162,11 @@ GEM async-service (~> 0.12) async-pool (0.11.2) async (>= 2.0) - async-service (0.21.0) + async-service (0.22.0) async async-container (~> 0.34) string-format (~> 0.2) - async-utilization (0.3.1) + async-utilization (0.3.2) console (~> 1.0) async-websocket (0.30.0) async-http (~> 0.76) @@ -220,7 +220,7 @@ GEM i18n bcrypt (3.1.22) benchmark (0.5.0) - bigdecimal (4.1.1) + bigdecimal (4.1.2) bindata (2.5.1) bindex (0.8.1) bootsnap (1.23.0) @@ -237,9 +237,6 @@ GEM rack-test (>= 0.6.3) regexp_parser (>= 1.5, < 3.0) xpath (~> 3.2) - capybara-screenshot (1.0.27) - capybara (>= 1.0, < 4) - launchy caxlsx (4.4.2) htmlentities (~> 4.3, >= 4.3.4) marcel (~> 1.0) @@ -247,8 +244,6 @@ GEM rubyzip (>= 2.4, < 4) cgi (0.5.1) charlock_holmes (0.7.9) - childprocess (5.1.0) - logger (~> 1.5) climate_control (1.2.0) coderay (1.1.3) concurrent-ruby (1.3.6) @@ -271,9 +266,6 @@ GEM cssbundling-rails (1.4.3) railties (>= 6.0.0) csv (3.3.5) - cuprite (0.17) - capybara (~> 3.0) - ferrum (~> 0.17.0) data_migrate (11.3.1) activerecord (>= 6.1) railties (>= 6.1) @@ -302,7 +294,7 @@ GEM dry-cli (1.4.1) email_validator (2.2.4) activemodel - erb (6.0.2) + erb (6.0.3) erubi (1.13.1) et-orbi (1.4.0) tzinfo @@ -314,9 +306,9 @@ GEM factory_bot_rails (6.5.1) factory_bot (~> 6.5) railties (>= 6.1.0) - faker (3.6.1) + faker (3.7.1) i18n (>= 1.8.11, < 2) - falcon (0.55.2) + falcon (0.55.3) async async-container (~> 0.20) async-http (~> 0.75) @@ -346,12 +338,6 @@ GEM faraday (>= 1, < 3) faraday-net_http (3.4.2) net-http (~> 0.5) - ferrum (0.17.1) - addressable (~> 2.5) - base64 (~> 0.2) - concurrent-ruby (~> 1.1) - webrick (~> 1.7) - websocket-driver (~> 0.7) ffi (1.17.3-arm64-darwin) ffi (1.17.3-x86_64-linux-gnu) fhir_models (5.0.0) @@ -414,7 +400,7 @@ GEM activesupport io-console (0.8.2) io-endpoint (0.17.2) - io-event (1.14.4) + io-event (1.15.1) io-stream (0.11.1) irb (1.17.0) pp (>= 0.6.0) @@ -427,7 +413,7 @@ GEM jmespath (1.6.2) jsbundling-rails (1.3.1) railties (>= 6.0.0) - json (2.19.3) + json (2.19.4) json-jwt (1.17.0) activesupport (>= 4.2) aes_key_wrap @@ -446,10 +432,6 @@ GEM kramdown-parser-gfm (1.1.0) kramdown (~> 2.0) language_server-protocol (3.17.0.5) - launchy (3.1.1) - addressable (~> 2.8) - childprocess (~> 5.0) - logger (~> 1.6) lint_roller (1.1.0) listen (3.9.0) rb-fsevent (~> 0.10, >= 0.10.3) @@ -493,7 +475,7 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) - minitest (6.0.3) + minitest (6.0.5) drb (~> 2.0) prism (~> 1.5) msgpack (1.8.0) @@ -552,8 +534,8 @@ GEM ostruct (0.6.3) pagy (9.4.0) paint (2.3.0) - parallel (1.27.0) - parallel_tests (5.6.0) + parallel (1.28.0) + parallel_tests (5.7.0) parallel parser (3.3.10.2) ast (~> 2.4.1) @@ -571,13 +553,13 @@ GEM activesupport (>= 7.0.0) rack protocol-hpack (1.5.1) - protocol-http (0.60.0) - protocol-http1 (0.37.0) - protocol-http (~> 0.58) - protocol-http2 (0.24.0) + protocol-http (0.62.2) + protocol-http1 (0.39.0) + protocol-http (~> 0.62) + protocol-http2 (0.26.0) protocol-hpack (~> 1.4) - protocol-http (~> 0.47) - protocol-rack (0.21.1) + protocol-http (~> 0.62) + protocol-rack (0.22.1) io-stream (>= 0.10) protocol-http (~> 0.58) rack (>= 1.0) @@ -642,7 +624,7 @@ GEM rails-html-sanitizer (1.7.0) loofah (~> 2.25) nokogiri (>= 1.15.7, != 1.16.7, != 1.16.6, != 1.16.5, != 1.16.4, != 1.16.3, != 1.16.2, != 1.16.1, != 1.16.0.rc1, != 1.16.0) - rails_semantic_logger (4.19.0) + rails_semantic_logger (4.20.0) rack railties (>= 5.1) semantic_logger (~> 4.16) @@ -656,7 +638,7 @@ GEM tsort (>= 0.2) zeitwerk (~> 2.6) rainbow (3.1.1) - rake (13.3.1) + rake (13.4.2) rb-fsevent (0.11.2) rb-inotify (0.11.1) ffi (~> 1.0) @@ -771,7 +753,7 @@ GEM activerecord (>= 4.0.0) railties (>= 4.0.0) securerandom (0.4.1) - semantic_logger (4.17.0) + semantic_logger (4.18.0) concurrent-ruby (~> 1.0) sentry-rails (6.5.0) railties (>= 5.2.0) @@ -911,7 +893,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) xrb (0.11.2) - yard (0.9.38) + yard (0.9.42) yard-activesupport-concern (0.0.1) yard (>= 0.8) yard-solargraph (0.1.0) @@ -945,7 +927,6 @@ DEPENDENCIES bootsnap brakeman capybara - capybara-screenshot capybara_accessible_selectors! caxlsx charlock_holmes @@ -953,7 +934,6 @@ DEPENDENCIES config cssbundling-rails csv - cuprite data_migrate database_cleaner-active_record debug @@ -1038,7 +1018,7 @@ DEPENDENCIES with_advisory_lock RUBY VERSION - ruby 4.0.2 + ruby 4.0.3 BUNDLED WITH 4.0.8 diff --git a/app/assets/stylesheets/components/_filters.scss b/app/assets/stylesheets/components/_filters.scss index 055a2ed2a9..4f95db37de 100644 --- a/app/assets/stylesheets/components/_filters.scss +++ b/app/assets/stylesheets/components/_filters.scss @@ -14,6 +14,8 @@ padding-left: nhsuk-spacing(3); background-color: nhsuk-colour("grey-1"); + @include nhsuk-font-size(22); + // stylelint-disable-next-line max-nesting-depth @include nhsuk-media-query($from: tablet) { margin-left: #{nhsuk-spacing(-4) - 1px}; diff --git a/app/assets/stylesheets/components/_session-banner.scss b/app/assets/stylesheets/components/_session-banner.scss index 677c2fe5ac..6d40b7aa78 100644 --- a/app/assets/stylesheets/components/_session-banner.scss +++ b/app/assets/stylesheets/components/_session-banner.scss @@ -12,6 +12,7 @@ } .nhsuk-notification-banner { + margin-bottom: nhsuk-spacing(4); border-color: nhsuk-colour("white"); } @@ -31,7 +32,18 @@ color: $nhsuk-reverse-text-colour; } + .app-secondary-navigation { + margin: 0; + border-bottom: 0 none; + background: nhsuk-shade($nhsuk-brand-colour, 20%); + box-shadow: 0 -1px 0 0 $nhsuk-secondary-border-colour; + } + .app-secondary-navigation__list { - box-shadow: 0 -1px 0 0 $nhsuk-reverse-border-colour; + box-shadow: none; + + @include nhsuk-media-query($until: tablet) { + margin-left: #{$nhsuk-gutter-half * -1}; + } } } diff --git a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_index.scss b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_index.scss index fcc9da392b..5668984153 100644 --- a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_index.scss +++ b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_index.scss @@ -1,3 +1,2 @@ @forward "details"; @forward "summary-list"; -@forward "tables"; diff --git a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_tables.scss b/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_tables.scss deleted file mode 100644 index 823ca1f5b4..0000000000 --- a/app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_tables.scss +++ /dev/null @@ -1,4 +0,0 @@ -// Don’t show a grey background when hovering over table rows -.nhsuk-table__row:hover { - background: none; -} diff --git a/app/components/app_gillick_assessment_component.rb b/app/components/app_gillick_assessment_component.rb index 2adf5c16ce..3c3412f9b4 100644 --- a/app/components/app_gillick_assessment_component.rb +++ b/app/components/app_gillick_assessment_component.rb @@ -19,6 +19,7 @@ def gillick_assessment .gillick_assessments .order(created_at: :desc) .for_session(session) + .where(date: Date.current) .for_programme(programme) .first end diff --git a/app/components/app_import_format_details_component.rb b/app/components/app_import_format_details_component.rb index 33fea34d33..098eb9f066 100644 --- a/app/components/app_import_format_details_component.rb +++ b/app/components/app_import_format_details_component.rb @@ -288,7 +288,13 @@ def programme end def vaccine_and_batch - vaccines = team.vaccines.pluck(:upload_name).map { tag.i(it) } + vaccines = + team + .vaccines + .where.not(upload_name: [nil, ""]) + .order(:upload_name) + .pluck(:upload_name) + .map { tag.i(it) } [ { diff --git a/app/components/app_location_card_component.rb b/app/components/app_location_card_component.rb index fc661fba17..f203e0d05a 100644 --- a/app/components/app_location_card_component.rb +++ b/app/components/app_location_card_component.rb @@ -10,7 +10,7 @@ def initialize(location, patient_count:, next_session_date:, heading_level: 4) def call render AppCardComponent.new(link_to:, compact: true) do |card| - card.with_heading(level: @heading_level) { heading } + card.with_heading(level: @heading_level, size: "s") { heading } govuk_summary_list(rows:) end end diff --git a/app/components/app_patient_search_form_component.rb b/app/components/app_patient_search_form_component.rb index ea8442042e..033f00bced 100644 --- a/app/components/app_patient_search_form_component.rb +++ b/app/components/app_patient_search_form_component.rb @@ -2,11 +2,7 @@ class AppPatientSearchFormComponent < ViewComponent::Base # Remove these statuses once implemented. - HIDDEN_PROGRAMME_STATUSES = %w[ - needs_consent_request_failed - needs_consent_request_not_scheduled - needs_consent_request_scheduled - ].freeze + HIDDEN_PROGRAMME_STATUSES = %w[needs_consent_request_failed].freeze def initialize( form, diff --git a/app/components/app_patient_search_result_card_component.rb b/app/components/app_patient_search_result_card_component.rb index 512ae1ec9c..7b8892d005 100644 --- a/app/components/app_patient_search_result_card_component.rb +++ b/app/components/app_patient_search_result_card_component.rb @@ -38,7 +38,7 @@ def initialize( def call render AppCardComponent.new(link_to:, compact: true) do |card| - card.with_heading(level: @heading_level) do + card.with_heading(level: @heading_level, size: "s") do patient.full_name_with_known_as end govuk_summary_list(rows:) diff --git a/app/components/app_patient_session_consent_component.html.erb b/app/components/app_patient_session_consent_component.html.erb index e59be42717..4d9a5f48a7 100644 --- a/app/components/app_patient_session_consent_component.html.erb +++ b/app/components/app_patient_session_consent_component.html.erb @@ -21,6 +21,10 @@

<%= patient.full_name %> is ready for the vaccinator.

<% elsif consent_status_value == :no_contact_details %>

We cannot send consent requests because we have no parent contact details.

+ <% elsif consent_status_value == :request_not_scheduled %> +

No consent request is scheduled to be sent because the child has not been added to a scheduled session.

+ <% elsif consent_status_value == :request_scheduled %> +

A consent request will be sent on <%= session.send_consent_requests_at.to_fs(:long) %>.

<% end %>
diff --git a/app/components/app_patient_session_consent_component.rb b/app/components/app_patient_session_consent_component.rb index 9ecb56506c..5b43a0d8e2 100644 --- a/app/components/app_patient_session_consent_component.rb +++ b/app/components/app_patient_session_consent_component.rb @@ -63,7 +63,9 @@ def consent_status_generator patient:, consents:, vaccination_records:, - parents: + parents:, + sessions: [session], + consent_notifications: ) end @@ -76,19 +78,14 @@ def triage_status_generator consents:, triages:, vaccination_records:, - parents: + parents:, + sessions: [session], + consent_notifications: ) end def latest_consent_request - @latest_consent_request ||= - patient - .consent_notifications - .request - .has_all_programmes_of([programme]) - .for_academic_year(academic_year) - .order(sent_at: :desc) - .first + @latest_consent_request ||= consent_notifications.first end def consents @@ -116,8 +113,14 @@ def vaccination_records patient.vaccination_records.for_programme(programme).order_by_performed_at end + SEND_CONSENT_REQUEST_STATUSES = %i[ + no_response + request_scheduled + request_not_scheduled + ].freeze + def can_send_consent_request? - consent_status_value == :no_response && + consent_status_value.in?(SEND_CONSENT_REQUEST_STATUSES) && patient.send_notifications?(team: @session.team) && session.can_receive_consent? && patient.parents.any?(&:contactable?) end @@ -134,4 +137,13 @@ def who_refused def show_health_answers? grouped_consents.any?(&:response_given?) end + + def consent_notifications + patient + .consent_notifications + .request + .has_all_programmes_of([programme]) + .for_academic_year(academic_year) + .order(sent_at: :desc) + end end diff --git a/app/components/app_patient_session_programme_component.rb b/app/components/app_patient_session_programme_component.rb new file mode 100644 index 0000000000..fdaa6750c1 --- /dev/null +++ b/app/components/app_patient_session_programme_component.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +class AppPatientSessionProgrammeComponent < ViewComponent::Base + erb_template <<-ERB + <%= render AppCardComponent.new(feature: true) do |card| %> + <% card.with_heading(level: 4, colour:) { heading } %> + <% if details.present? %> +

<%= details %>

+ <% end %> + <% if programme_status.vaccinated? || programme_status.cannot_vaccinate? %> + <%= render AppPatientVaccinationTableComponent.new( + patient, + programme:, + academic_year:, + show_caption: true, + show_details: false + ) %> + <% end %> + <%= render AppActionLinkComponent.new( + text: action_link_text, + href: patient_programme_path(patient, programme.type) + ) %> + <% end %> + ERB + + def initialize(patient:, session:, programme:) + @patient = patient + @session = session + @programme = programme + end + + private + + attr_reader :patient, :session, :programme + + delegate :academic_year, to: :session + delegate :triage_summary, to: :helpers + + def heading + "#{resolver[:prefix]}: #{resolver[:text]}" + end + + def colour + resolver[:colour] + end + + def details + if triage_driven_cannot_vaccinate? && latest_triage && + (summary = triage_summary(latest_triage)).present? + summary + elsif programme_status.due? + criteria_label = + I18n.t( + programme_status.vaccine_criteria.to_param, + scope: :vaccine_criteria + ) + if criteria_label.present? + "#{patient.given_name} is ready to vaccinate (#{criteria_label.downcase})." + else + "#{patient.given_name} is ready to vaccinate." + end + elsif programme_status.vaccinated? + record = + patient + .vaccination_records + .for_programme(programme) + .order_by_performed_at + .first + nurse = [ + record&.performed_by_given_name, + record&.performed_by_family_name + ].compact_blank.join(" ") + if nurse.present? + "#{patient.given_name} was vaccinated by #{nurse} on #{record&.performed_at&.to_fs(:long)}." + else + "#{patient.given_name} was vaccinated on #{record&.performed_at&.to_fs(:long)}." + end + elsif programme_status.needs_triage? + if latest_triage&.invite_to_clinic? && + (summary = triage_summary(latest_triage)).present? + summary + else + "You need to decide if it’s safe to vaccinate #{patient.given_name}." + end + else + resolver[:details_text] + end + end + + def triage_driven_cannot_vaccinate? + programme_status.cannot_vaccinate_do_not_vaccinate? || + programme_status.cannot_vaccinate_delay_vaccination? + end + + def latest_triage + @latest_triage ||= + TriageFinder.call( + patient.triages.includes(:performed_by), + programme_type: programme.type, + academic_year: + ) + end + + def programme_status + @programme_status ||= patient.programme_status(programme, academic_year:) + end + + def action_link_text + "View child’s #{programme.name} record" + end + + def resolver + @resolver ||= + PatientProgrammeStatusResolver.call( + patient, + programme_type: programme.type, + academic_year: + ) + end +end diff --git a/app/components/app_patient_session_search_result_card_component.rb b/app/components/app_patient_session_search_result_card_component.rb index cdb2fb590b..53166b830a 100644 --- a/app/components/app_patient_session_search_result_card_component.rb +++ b/app/components/app_patient_session_search_result_card_component.rb @@ -36,7 +36,7 @@ def initialize( def call render AppCardComponent.new(link_to: card_link, compact: true) do |card| - card.with_heading(level: @heading_level) { heading } + card.with_heading(level: @heading_level, size: "s") { heading } safe_join([summary_list, registration_buttons].compact) end end diff --git a/app/components/app_patient_session_triage_component.rb b/app/components/app_patient_session_triage_component.rb index 4075ed14a5..2737a94c58 100644 --- a/app/components/app_patient_session_triage_component.rb +++ b/app/components/app_patient_session_triage_component.rb @@ -14,6 +14,13 @@ def initialize( @current_user = current_user @triage_form = triage_form || default_triage_form @parents = patient.parents + @patient_locations = + patient.patient_locations.includes( + location: [ + :location_programme_year_groups, + { team_locations: { sessions: :session_programme_year_groups } } + ] + ) end def render? @@ -28,7 +35,8 @@ def render? :programme, :current_user, :triage_form, - :parents + :parents, + :patient_locations delegate :academic_year, :team, to: :session @@ -77,7 +85,9 @@ def triage_status_generator consents:, triages:, vaccination_records:, - parents: + parents:, + sessions: [session], + consent_notifications: ) end @@ -89,7 +99,9 @@ def consent_status_generator patient:, consents:, vaccination_records:, - parents: + parents:, + sessions: [session], + consent_notifications: ) end @@ -118,4 +130,12 @@ def latest_triage def default_triage_form TriageForm.new(patient:, session:, programme:, current_user:) end + + def consent_notifications + patient + .consent_notifications + .request + .has_all_programmes_of([programme]) + .for_academic_year(academic_year) + end end diff --git a/app/components/app_patient_session_vaccination_component.rb b/app/components/app_patient_session_vaccination_component.rb deleted file mode 100644 index 1d606e038b..0000000000 --- a/app/components/app_patient_session_vaccination_component.rb +++ /dev/null @@ -1,51 +0,0 @@ -# frozen_string_literal: true - -class AppPatientSessionVaccinationComponent < ViewComponent::Base - erb_template <<-ERB -

Programme status

- - <%= render AppCardComponent.new(feature: true) do |card| %> - <% card.with_heading(level: 4, colour:) { heading } %> - <%= render AppPatientVaccinationTableComponent.new( - patient, - programme:, - academic_year:, - show_caption: true - ) %> - <% end %> - ERB - - def initialize(patient:, session:, programme:) - @patient = patient - @session = session - @programme = programme - end - - def render? - patient - .vaccination_records - .for_programme(programme) - .any? { it.show_in_academic_year?(academic_year) } - end - - private - - attr_reader :patient, :session, :programme - - delegate :academic_year, :team, to: :session - - def colour = resolved_status.fetch(:colour) - - def heading = - "#{resolved_status.fetch(:prefix)}: #{resolved_status.fetch(:text)}" - - def resolved_status - @resolved_status ||= - PatientProgrammeStatusResolver.call( - patient, - programme_type: programme.type, - academic_year:, - context_location_id: session.location_id - ) - end -end diff --git a/app/components/app_patient_vaccination_table_component.html.erb b/app/components/app_patient_vaccination_table_component.html.erb index e70734569f..e8af226e8f 100644 --- a/app/components/app_patient_vaccination_table_component.html.erb +++ b/app/components/app_patient_vaccination_table_component.html.erb @@ -1,13 +1,13 @@ <% if vaccination_records.present? %> <%= govuk_table(html_attributes: { class: "nhsuk-table-responsive" }) do |table| %> - <% table.with_caption(text: "Vaccination records", size: "s") if show_caption %> + <% table.with_caption(text: "Vaccination outcomes", size: "s") if show_caption %> <% table.with_head do |head| %> <% head.with_row do |row| %> <% row.with_cell(text: "Date") %> - <% row.with_cell(text: "Location") %> + <% row.with_cell(text: "Location") if show_details %> <% row.with_cell(text: "Programme") if show_programme %> - <% row.with_cell(text: "Source") %> + <% row.with_cell(text: "Source") if show_details %> <% row.with_cell(text: "Outcome") %> <% end %> <% end %> @@ -17,19 +17,21 @@ <% body.with_row do |row| %> <% row.with_cell do %> Date - <%= link_to vaccination_record.performed_at.to_date.to_fs(:long), + <%= link_to vaccination_record.performed_at.to_fs(:long), vaccination_record_path(vaccination_record) %> <% end %> - <% row.with_cell do %> - Location - <%= helpers.vaccination_record_location(vaccination_record) %> + <% if show_details %> + <% row.with_cell do %> + Location + <%= helpers.vaccination_record_location(vaccination_record) %> - <% if (location = vaccination_record.location) && location.has_address? %> -
- - <%= helpers.format_address_single_line(location) %> - + <% if (location = vaccination_record.location) && location.has_address? %> +
+ + <%= helpers.format_address_single_line(location) %> + + <% end %> <% end %> <% end %> @@ -40,9 +42,11 @@ <% end %> <% end %> - <% row.with_cell do %> - Source - <%= vaccination_record_source(vaccination_record) %> + <% if show_details %> + <% row.with_cell do %> + Source + <%= vaccination_record_source(vaccination_record) %> + <% end %> <% end %> <% row.with_cell do %> diff --git a/app/components/app_patient_vaccination_table_component.rb b/app/components/app_patient_vaccination_table_component.rb index 6ccc4b52a4..e11ffd6547 100644 --- a/app/components/app_patient_vaccination_table_component.rb +++ b/app/components/app_patient_vaccination_table_component.rb @@ -1,18 +1,25 @@ # frozen_string_literal: true class AppPatientVaccinationTableComponent < ViewComponent::Base - def initialize(patient, academic_year:, programme: nil, show_caption: false) + def initialize( + patient, + academic_year:, + programme: nil, + show_caption: false, + show_details: true + ) @patient = patient @academic_year = academic_year @programme = programme @show_caption = show_caption + @show_details = show_details end private delegate :govuk_table, :vaccination_record_source, to: :helpers - attr_reader :patient, :academic_year, :programme, :show_caption + attr_reader :patient, :academic_year, :programme, :show_caption, :show_details def show_programme = programme.nil? diff --git a/app/components/app_session_actions_component.rb b/app/components/app_session_actions_component.rb index 810854c258..56a72c8f70 100644 --- a/app/components/app_session_actions_component.rb +++ b/app/components/app_session_actions_component.rb @@ -6,8 +6,12 @@ class AppSessionActionsComponent < ViewComponent::Base <% card.with_heading(level: 3) { "Action required" } %> <% if rows.any? %> <%= govuk_summary_list(rows:) %> - <% else %> -

No action required

+ <% end %> + <% if policy(session).invite_to_clinic? %> + <%= render AppActionLinkComponent.new( + href: edit_session_invite_to_clinic_path(@session), + text: "Send clinic invitations", + ) %> <% end %> <% end %> ERB diff --git a/app/components/app_session_buttons_component.rb b/app/components/app_session_buttons_component.rb deleted file mode 100644 index 4f6ff40914..0000000000 --- a/app/components/app_session_buttons_component.rb +++ /dev/null @@ -1,29 +0,0 @@ -# frozen_string_literal: true - -class AppSessionButtonsComponent < ViewComponent::Base - erb_template <<-ERB -
- <% if policy(session).edit? %> - <%= govuk_button_link_to "Edit session", edit_session_path(session), secondary: true %> - - <%= govuk_button_link_to "Download offline spreadsheet", - session_path(session, format: :xlsx), - secondary: true %> - - <% if policy(session).invite_to_clinic? %> - <%= link_to "Send clinic invitations", edit_session_invite_to_clinic_path(@session) %> - <% end %> - <% end %> -
- ERB - - def initialize(session) - @session = session - end - - private - - attr_reader :session - - delegate :policy, :govuk_button_link_to, to: :helpers -end diff --git a/app/components/app_session_card_component.rb b/app/components/app_session_card_component.rb index 18dde741b5..765de07ddd 100644 --- a/app/components/app_session_card_component.rb +++ b/app/components/app_session_card_component.rb @@ -23,7 +23,7 @@ def initialize( def call render AppCardComponent.new(link_to:, compact: true) do |card| - card.with_heading(level: @heading_level) { heading } + card.with_heading(level: @heading_level, size: "s") { heading } safe_join([summary_list, button_group].compact) end end diff --git a/app/components/app_session_details_component.rb b/app/components/app_session_details_component.rb index 5ecce170f2..bb064a704e 100644 --- a/app/components/app_session_details_component.rb +++ b/app/components/app_session_details_component.rb @@ -3,7 +3,7 @@ class AppSessionDetailsComponent < ViewComponent::Base erb_template <<-ERB <%= render AppCardComponent.new do |card| %> - <% card.with_heading(level: 3) { "Session details" } %> + <% card.with_heading(level: 3, actions:) { "Session details" } %> <%= render AppSessionSummaryComponent.new( session, patient_count: session.patients.count, @@ -13,6 +13,11 @@ class AppSessionDetailsComponent < ViewComponent::Base show_status: true, show_consent_style: true ) %> + <% if helpers.policy(session).edit? %> + <%= govuk_button_link_to "Download offline spreadsheet", + session_path(session, format: :xlsx), + secondary: true %> + <% end %> <% end %> ERB @@ -25,4 +30,10 @@ def initialize(session) attr_reader :session delegate :govuk_button_link_to, to: :helpers + + def actions + return [] unless helpers.policy(session).edit? + + [{ text: "Edit session", href: helpers.edit_session_path(session) }] + end end diff --git a/app/components/app_session_overview_component.rb b/app/components/app_session_overview_component.rb index 89732e3d37..4ca1353406 100644 --- a/app/components/app_session_overview_component.rb +++ b/app/components/app_session_overview_component.rb @@ -4,21 +4,11 @@ class AppSessionOverviewComponent < ViewComponent::Base erb_template <<-ERB <%= render AppSessionStatsComponent.new(session) %> -
- <%= render AppSessionVaccinationsComponent.new(session) %> -
+ <%= render AppSessionVaccinationsComponent.new(session) %> -
- <%= render AppSessionActionsComponent.new(session) %> -
+ <%= render AppSessionActionsComponent.new(session) %> -
- <%= render AppSessionDetailsComponent.new(session) %> -
- -
- <%= render AppSessionButtonsComponent.new(session) %> -
+ <%= render AppSessionDetailsComponent.new(session) %> ERB def initialize(session) diff --git a/app/controllers/api/testing/reporting_refresh_controller.rb b/app/controllers/api/testing/reporting_refresh_controller.rb index 0946e59ff8..62e38a4782 100644 --- a/app/controllers/api/testing/reporting_refresh_controller.rb +++ b/app/controllers/api/testing/reporting_refresh_controller.rb @@ -2,7 +2,12 @@ class API::Testing::ReportingRefreshController < API::Testing::BaseController def create - ReportingAPI::RefreshJob.perform_later - render status: :accepted + if params[:wait].present? + ReportingAPI::RefreshJob.perform_now + render status: :ok + else + ReportingAPI::RefreshJob.perform_later + render status: :accepted + end end end diff --git a/app/controllers/class_imports_controller.rb b/app/controllers/class_imports_controller.rb index 8b4a48bf94..ff10481a78 100644 --- a/app/controllers/class_imports_controller.rb +++ b/app/controllers/class_imports_controller.rb @@ -28,7 +28,6 @@ def create **class_import_params ) - @class_import.load_data! if @class_import.invalid? render :new, status: :unprocessable_content and return end diff --git a/app/controllers/cohort_imports_controller.rb b/app/controllers/cohort_imports_controller.rb index 7d1a08239c..0b46990875 100644 --- a/app/controllers/cohort_imports_controller.rb +++ b/app/controllers/cohort_imports_controller.rb @@ -26,7 +26,6 @@ def create **cohort_import_params ) - @cohort_import.load_data! if @cohort_import.invalid? render :new, status: :unprocessable_content and return end diff --git a/app/controllers/draft_consents_controller.rb b/app/controllers/draft_consents_controller.rb index 10a125fbf2..5b0448b672 100644 --- a/app/controllers/draft_consents_controller.rb +++ b/app/controllers/draft_consents_controller.rb @@ -12,9 +12,9 @@ class DraftConsentsController < ApplicationController include WizardControllerConcern - before_action :set_triage_form, if: :includes_triage_step? - before_action :set_parent_options, if: -> { current_step == :who } before_action :set_back_link_path + before_action :set_new_or_existing_contact_options, if: :is_who_step? + before_action :set_triage_form, if: :includes_triage_step? def show authorize Consent, :edit? @@ -213,6 +213,58 @@ def set_steps self.steps = @draft_consent.wizard_steps end + def set_back_link_path + @back_link_path = + if @draft_consent.editing? + wizard_path("confirm") + elsif current_step == @draft_consent.wizard_steps.first + session_patient_programme_path(@session, @patient, @programme) + else + previous_wizard_path + end + end + + def is_who_step? = current_step == :who + + NewOrExistingContactOption = Struct.new(:value, :label, :hint) + + def set_new_or_existing_contact_options + @new_or_existing_contact_options = [] + + if @patient.can_self_consent_after_gillick_assessment?( + location: @session.location, + programme_type: @programme.type + ) + @new_or_existing_contact_options << NewOrExistingContactOption.new( + value: "patient", + label: "Child (Gillick competent)" + ) + end + + parent_relationships = + ( + @patient.parent_relationships.includes(:parent) + + @patient + .consents + .where(programme_type: @programme.type) + .filter_map(&:parent_relationship) + ).compact.uniq.sort_by(&:label) + + @new_or_existing_contact_options += + parent_relationships.map do |parent_relationship| + NewOrExistingContactOption.new( + value: parent_relationship.parent.id, + label: parent_relationship.label_with_parent, + hint: parent_relationship.parent.contact_label + ) + end + + @new_or_existing_contact_options << NewOrExistingContactOption.new( + value: "new", + label: "Add a new parental contact" + ) + end + def includes_triage_step? current_step.in?(%i[triage confirm]) && steps.include?("triage") end @@ -235,28 +287,6 @@ def set_triage_form end end - def set_parent_options - @parent_options = - ( - @patient.parent_relationships.includes(:parent) + - @patient - .consents - .select { it.programme_type == @programme.type } - .filter_map(&:parent_relationship) - ).compact.uniq.sort_by(&:label) - end - - def set_back_link_path - @back_link_path = - if @draft_consent.editing? - wizard_path("confirm") - elsif current_step == @draft_consent.wizard_steps.first - session_patient_programme_path(@session, @patient, @programme) - else - previous_wizard_path - end - end - # Returns: # { # question_0: %i[notes response], diff --git a/app/controllers/immunisation_imports_controller.rb b/app/controllers/immunisation_imports_controller.rb index a07b384266..fbc086ccd8 100644 --- a/app/controllers/immunisation_imports_controller.rb +++ b/app/controllers/immunisation_imports_controller.rb @@ -22,7 +22,6 @@ def create **immunisation_import_params ) - @immunisation_import.load_data! if @immunisation_import.invalid? render :new, status: :unprocessable_content and return end diff --git a/app/forms/triage_form.rb b/app/forms/triage_form.rb index 3cc672f958..9f4be40846 100644 --- a/app/forms/triage_form.rb +++ b/app/forms/triage_form.rb @@ -130,7 +130,9 @@ def consent_status_generator patient:, consents: patient.consents, vaccination_records: [], - parents: [] + parents: [], + sessions: [], + consent_notifications: [] ) end diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 2abf45709d..b6edeea650 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -39,9 +39,9 @@ def session_dates(session) end if dates.length == 2 - "#{min_date_str} – #{max_date_str}" + "#{min_date_str} to #{max_date_str}" else - "#{min_date_str} – #{max_date_str} (#{dates.length} dates)" + "#{min_date_str} to #{max_date_str} (#{dates.length} dates)" end end end @@ -58,18 +58,24 @@ def session_status(session) def session_title(session) programmes = session.programmes.map(&:name).to_sentence - dates = ("on #{session_dates(session)}" if session.dates.present?) items = if session.generic_clinic? - [programmes, "community clinic", dates].compact + [programmes, "community clinic"].compact else - [programmes, "session at", session.location.name, dates].compact + [programmes, "session at", session.location.name].compact end items.join(" ") end + def session_caption(session) + dates = session_dates(session) if session.dates.present? + year_groups = format_year_groups(session.year_groups) + + [dates, year_groups].compact.join(" – ") + end + def session_consent_style(session) session.outbreak ? "Outbreak request" : "Standard request" end diff --git a/app/helpers/triages_helper.rb b/app/helpers/triages_helper.rb index 94c6a3c903..5354dbaa52 100644 --- a/app/helpers/triages_helper.rb +++ b/app/helpers/triages_helper.rb @@ -61,16 +61,22 @@ def triage_summary(triage) triage.programme.has_multiple_vaccine_methods? vaccination_method = Vaccine.human_enum_name(:method_prefix, triage.vaccine_method) - "is safe to vaccinate using the #{vaccination_method} vaccine only." + " is safe to vaccinate using the #{vaccination_method} vaccine only." else - "is safe to vaccinate." + " is safe to vaccinate." end elsif triage.do_not_vaccinate? - "should not be vaccinated." + " should not be vaccinated." elsif triage.delay_vaccination? - "’s vaccination should be delayed." + if triage.delay_vaccination_until.present? + "’s vaccination should be delayed until #{triage.delay_vaccination_until.to_fs(:long)}." + else + "’s vaccination should be delayed." + end + elsif triage.invite_to_clinic? + "’s vaccination should take place at a clinic." end - "#{prefix}#{triage.patient.full_name} #{suffix}" if suffix + "#{prefix}#{triage.patient.given_name}#{suffix}" if suffix end end diff --git a/app/jobs/process_import_job.rb b/app/jobs/process_import_job.rb index ba0503f3b8..05bb95761e 100644 --- a/app/jobs/process_import_job.rb +++ b/app/jobs/process_import_job.rb @@ -6,11 +6,14 @@ class ProcessImportJob < ApplicationJob queue_as :imports def perform(import) + return if import.processed? + SemanticLogger.tagged(import_id: import.id) do Sentry.set_tags(import_id: import.id) import.parse_rows! + return if import.invalid? return if import.rows_are_invalid? import.process! diff --git a/app/jobs/process_patient_changeset_job.rb b/app/jobs/process_patient_changeset_job.rb index b708e42fcd..0176af93b1 100644 --- a/app/jobs/process_patient_changeset_job.rb +++ b/app/jobs/process_patient_changeset_job.rb @@ -19,7 +19,7 @@ def perform(patient_changeset_id) if patient_changeset.import.changesets.pending.none? import = patient_changeset.import - if Flipper.enabled?(:import_search_pds) + if Flipper.enabled?(:pds) && Flipper.enabled?(:pds_search_during_import) import.validate_pds_match_rate! return if import.low_pds_match_rate? end diff --git a/app/lib/careplus/client.rb b/app/lib/careplus/client.rb new file mode 100644 index 0000000000..456fcdab46 --- /dev/null +++ b/app/lib/careplus/client.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "cgi" +require "net/http" +require "uri" + +module Careplus + class Client + TARGET_NAMESPACE_BASE = "https://careplus.syhapp.thirdparty.nhs.uk" + + def initialize(username:, password:, namespace:, payload:) + @username = username + @password = password + @namespace = namespace + @payload = payload + end + + def send_csv + uri = + URI.parse("#{Settings.careplus.base_url}/#{namespace}/soap.SchImms.cls") + soap_body = build_soap_envelope + post_soap_request(uri, soap_body) + end + + def self.send_csv(...) = new(...).send_csv + + private_class_method :new + + private + + attr_reader :username, :password, :namespace, :payload + + def build_soap_envelope + escaped_payload = CGI.escapeHTML(payload) + target_namespace = "#{TARGET_NAMESPACE_BASE}/#{namespace}/webservices" + + <<~XML + + + + + #{username} + #{password} + #{escaped_payload} + + + + XML + end + + def post_soap_request(uri, body) + http = Net::HTTP.new(uri.host, uri.port) + http.use_ssl = uri.scheme == "https" + + request = Net::HTTP::Post.new(uri.request_uri) + request["Content-Type"] = "text/xml; charset=utf-8" + request.body = body + + http.request(request) + end + end +end diff --git a/app/lib/csv_parser.rb b/app/lib/csv_parser.rb index bf259485d4..0e6d2e99d9 100644 --- a/app/lib/csv_parser.rb +++ b/app/lib/csv_parser.rb @@ -41,7 +41,8 @@ def to_date parsed_values = DATE_FORMATS.lazy.filter_map do |format| - Date.strptime(value, format) + date = Date.strptime(value, format) + date.year >= 1000 ? date : nil rescue ArgumentError, TypeError nil end diff --git a/app/lib/mavis_cli.rb b/app/lib/mavis_cli.rb index a48466ce06..1542ba0294 100644 --- a/app/lib/mavis_cli.rb +++ b/app/lib/mavis_cli.rb @@ -58,6 +58,7 @@ def self.terminal_lines require_relative "mavis_cli/pds/get" require_relative "mavis_cli/pds/search" require_relative "mavis_cli/reports/export_automated_careplus" +require_relative "mavis_cli/reports/send_to_careplus" require_relative "mavis_cli/schools/add_programme_year_group" require_relative "mavis_cli/schools/add_to_team" require_relative "mavis_cli/schools/create" diff --git a/app/lib/mavis_cli/reports/export_automated_careplus.rb b/app/lib/mavis_cli/reports/export_automated_careplus.rb index 91f6ba5a31..616d63ce05 100644 --- a/app/lib/mavis_cli/reports/export_automated_careplus.rb +++ b/app/lib/mavis_cli/reports/export_automated_careplus.rb @@ -126,8 +126,8 @@ def call( # prevent this tool from creating database entries at all ActiveRecord::Base.transaction do - careplus_export = - CareplusExport.create!( + careplus_report = + CareplusReport.create!( team:, academic_year: academic_year_value, date_from: parsed_start_date, @@ -141,10 +141,10 @@ def call( ) now_iso = now.iso8601(6) - CareplusExportVaccinationRecord.insert_all!( + CareplusReportVaccinationRecord.insert_all!( records.map do |record| { - careplus_export_id: careplus_export.id, + careplus_report_id: careplus_report.id, vaccination_record_id: record.id, change_type: 0, created_at: now_iso, diff --git a/app/lib/mavis_cli/reports/send_to_careplus.rb b/app/lib/mavis_cli/reports/send_to_careplus.rb new file mode 100644 index 0000000000..1c33eaf426 --- /dev/null +++ b/app/lib/mavis_cli/reports/send_to_careplus.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require_relative "../../careplus/client" + +module MavisCLI + module Reports + class SendToCareplus < Dry::CLI::Command + desc "Send a CarePlus CSV file to the CarePlus endpoint" + + example [ + "--input=tmp/automated_export.csv", + "--input=tmp/automated_export.csv --ods_code=ABC123" + ] + + FALLBACK_NAMESPACE = "MOCK" + FALLBACK_USERNAME = "mavis_user" + FALLBACK_PASSWORD = "mavis_password" + + option :input, required: true, desc: "Path to the CSV file to send" + option :ods_code, + desc: "ODS code of the organisation (to use team credentials)" + option :workgroup, + desc: + "Team workgroup (required if the organisation has multiple teams)" + + def call(input:, ods_code: nil, workgroup: nil, **) + MavisCLI.load_rails + + unless File.exist?(input) + warn "File not found: '#{input}'" + return + end + + username, password, namespace = + resolve_credentials(ods_code:, workgroup:) + return if username.nil? + + csv_payload = File.read(input) + + response = + Careplus::Client.send_csv( + username:, + password:, + namespace:, + payload: csv_payload + ) + + if response.is_a?(Net::HTTPSuccess) + puts "Success (HTTP #{response.code})" + puts response.body + else + warn "Request failed with HTTP #{response.code}: #{response.message}" + warn response.body + end + end + + private + + def resolve_credentials(ods_code:, workgroup:) + if ods_code.nil? + return FALLBACK_USERNAME, FALLBACK_PASSWORD, FALLBACK_NAMESPACE + end + + organisation = Organisation.find_by(ods_code:) + if organisation.nil? + warn "Could not find organisation with ODS code '#{ods_code}'" + return nil, nil + end + + teams = organisation.teams + teams = teams.where(workgroup:) if workgroup + + if teams.empty? + warn( + if workgroup + "Could not find team '#{workgroup}' for organisation '#{ods_code}'" + else + "Organisation '#{ods_code}' has no teams." + end + ) + return nil, nil, nil + end + + if workgroup.nil? && teams.many? + warn "Organisation '#{ods_code}' has multiple teams. Specify --workgroup." + return nil, nil, nil + end + + team = teams.sole + + unless team.careplus_username.present? && + team.careplus_password.present? + warn "Team '#{team.name}' does not have CarePlus credentials configured." + return nil, nil, nil + end + + [ + team.careplus_username, + team.careplus_password, + team.careplus_namespace + ] + end + end + end + + register "reports" do |prefix| + prefix.register "send-to-careplus", Reports::SendToCareplus + end +end diff --git a/app/lib/mavis_cli/teams/list.rb b/app/lib/mavis_cli/teams/list.rb index a46c5f3692..4a31e994d3 100644 --- a/app/lib/mavis_cli/teams/list.rb +++ b/app/lib/mavis_cli/teams/list.rb @@ -26,15 +26,24 @@ def call(ods_code: nil) rows = teams.find_each.map do |team| - team.slice(:id, :name, :workgroup).merge( + team.slice(:id, :name, :workgroup, :type).merge( ods_code: team.organisation.ods_code, - programmes: team.programmes.map(&:name).join(", ") + programmes: team.programmes.map(&:name).join(", "), + nr_cut_off_date: team.national_reporting_cut_off_date ) end puts TableTennis.new( rows, - columns: %i[id name ods_code workgroup programmes], + columns: %i[ + id + name + type + ods_code + workgroup + programmes + nr_cut_off_date + ], zebra: true ) end diff --git a/app/lib/nhs/immunisations_api.rb b/app/lib/nhs/immunisations_api.rb index 078b5342c9..a2b807ce94 100644 --- a/app/lib/nhs/immunisations_api.rb +++ b/app/lib/nhs/immunisations_api.rb @@ -412,6 +412,11 @@ def check_bundle_link_params(bundle, request_params) uri = URI(link) bundle_params = URI.decode_www_form(uri.query).to_h + # The Imms API used to have a bug where they referred to `immunization.target` instead of `-immunization.target`. + # They have since fixed this, but they now include both versions in the `bundle.link` field. We must exclude the + # deprecated version from the comparison. + bundle_params.delete("immunization.target") + # We don't care about the order of the target values bundle_params["-immunization.target"] = bundle_params[ "-immunization.target" diff --git a/app/lib/nhs/pds.rb b/app/lib/nhs/pds.rb index 71a85a0459..cc497f53e5 100644 --- a/app/lib/nhs/pds.rb +++ b/app/lib/nhs/pds.rb @@ -34,7 +34,8 @@ class InvalidSearchData < StandardError class << self def get_patient(nhs_number) - return unless Settings.pds.enabled + return unless Flipper.enabled?(:pds) + NHS::API.connection.get( "personal-demographics/FHIR/R4/Patient/#{nhs_number}" ) @@ -55,7 +56,8 @@ def get_patient(nhs_number) end def search_patients(attributes) - return unless Settings.pds.enabled + return unless Flipper.enabled?(:pds) + if (missing_attrs = (attributes.keys.map(&:to_s) - SEARCH_FIELDS)).any? raise "Unrecognised attributes: #{missing_attrs.join(", ")}" end diff --git a/app/lib/notifier/patient.rb b/app/lib/notifier/patient.rb index 9eee4c152d..29d1d51930 100644 --- a/app/lib/notifier/patient.rb +++ b/app/lib/notifier/patient.rb @@ -3,6 +3,8 @@ class Notifier::Patient extend ActiveSupport::Concern + CONSENT_REMINDER_TYPES = %i[initial_reminder subsequent_reminder].freeze + def initialize(patient) @patient = patient end @@ -235,6 +237,8 @@ def send_consent_notification( SMSDeliveryJob.perform_later(sms_template, **params) end + PatientStatusUpdaterJob.perform_async(patient.id) + consent_notification end @@ -246,7 +250,13 @@ def generate_consent_templates( type: ) is_school = location.gias_school? - base_template = :"consent_#{is_school ? "school" : "clinic"}_#{type}" + + base_template = + if is_school && CONSENT_REMINDER_TYPES.include?(type) + :consent_school_reminder + else + :"consent_#{is_school ? "school" : "clinic"}_#{type}" + end # We can only handle a single programme group or variant in the template. group = ProgrammeGrouper.call(programmes).keys.sole diff --git a/app/lib/patient_status_updater.rb b/app/lib/patient_status_updater.rb index 2ea8d83efb..b9102b0871 100644 --- a/app/lib/patient_status_updater.rb +++ b/app/lib/patient_status_updater.rb @@ -42,16 +42,24 @@ def update_programme_statuses! merge_patient_scope(Patient::ProgrammeStatus) .where(academic_year: academic_years) - .includes( - :attendance_record, - :consents, - :patient, - :patient_locations, - :triages, - :vaccination_records, - :parents - ) - .find_in_batches do |batch| + .in_batches do |relation| + batch = + relation.includes( + :attendance_record, + :consents, + :patient, + :patient_locations, + :triages, + :vaccination_records, + :parents, + :consent_notifications, + patient_locations: { + location: [ + { team_locations: { sessions: :session_programme_year_groups } } + ] + } + ).to_a + batch.each(&:assign) Patient::ProgrammeStatus.import!( @@ -84,8 +92,15 @@ def update_registration_statuses! merge_patient_scope(Patient::RegistrationStatus) .joins(session: :team_location) .where(team_location: { academic_year: academic_years }) - .includes(:attendance_records, :patient, :session, :vaccination_records) - .find_in_batches do |batch| + .in_batches do |relation| + batch = + relation.includes( + :attendance_records, + :patient, + :session, + :vaccination_records + ).to_a + batch.each(&:assign_status) Patient::RegistrationStatus.import!( @@ -98,23 +113,6 @@ def update_registration_statuses! end end - def patient_statuses_to_import - @patient_statuses_to_import ||= - (patient_scope || Patient.all) - .pluck(:id, :birth_academic_year) - .flat_map do |patient_id, birth_academic_year| - academic_years.flat_map do |academic_year| - year_group = birth_academic_year.to_year_group(academic_year:) - - programme_types_per_year_group - .fetch(year_group, []) - .map do |programme_type| - [patient_id, programme_type, academic_year] - end - end - end - end - def programme_statuses_to_import @programme_statuses_to_import ||= (patient_scope || Patient.all) @@ -153,19 +151,6 @@ def patient_location_statuses_to_import end end - def programme_types_per_year_group - @programme_types_per_year_group ||= - Location::ProgrammeYearGroup - .joins(:location_year_group) - .where(location_year_group: { academic_year: academic_years }) - .distinct - .pluck(:programme_type, :"location_year_group.value") - .each_with_object({}) do |(programme_type, year_group), hash| - hash[year_group] ||= [] - hash[year_group] << programme_type - end - end - def programme_types_per_session_id_and_year_group @programme_types_per_session_id_and_year_group ||= Session::ProgrammeYearGroup @@ -180,4 +165,23 @@ def programme_types_per_session_id_and_year_group hash[session_id][year_group] << programme_type end end + + # We preload this association separately because including it in the nested + # `patient_locations` preload (see includes above) caused the updater process + # to be killed, even with very small batches. The likely cause is memory pressure + # from eager loading a deeply nested association graph. + # + # Preloading it here for the distinct `Location` records in each batch keeps + # `StatusGenerator::Programme` query-free without incurring the cost of the + # larger nested preload. + def preload_location_programme_year_groups(batch) + locations = batch.flat_map(&:patient_locations).map(&:location).uniq + + ActiveRecord::Associations::Preloader.new( + records: locations, + associations: { + location_programme_year_groups: :location_year_group + } + ).call + end end diff --git a/app/lib/status_generator/consent.rb b/app/lib/status_generator/consent.rb index 81c1880313..26ce6bbeb3 100644 --- a/app/lib/status_generator/consent.rb +++ b/app/lib/status_generator/consent.rb @@ -7,7 +7,9 @@ def initialize( patient:, consents:, vaccination_records:, - parents: + parents:, + sessions:, + consent_notifications: ) @programme_type = programme_type @academic_year = academic_year @@ -15,6 +17,8 @@ def initialize( @consents = consents @vaccination_records = vaccination_records @parents = parents + @sessions = sessions + @consent_notifications = consent_notifications end def programme @@ -32,6 +36,10 @@ def status :conflicts elsif status_should_be_no_contact_details? :no_contact_details + elsif status_should_be_request_scheduled? + :request_scheduled + elsif status_should_be_request_not_scheduled? + :request_not_scheduled elsif status_should_be_no_response? :no_response else @@ -62,7 +70,9 @@ def disease_types :patient, :consents, :vaccination_records, - :parents + :parents, + :sessions, + :consent_notifications def vaccinated? return @vaccinated if defined?(@vaccinated) @@ -131,6 +141,20 @@ def status_should_be_no_contact_details? parents.none?(&:contactable?) end + def status_should_be_request_scheduled? + return false if vaccinated? + + parents_contactable? && consent_notifications.empty? && + sessions.any? { consent_request_scheduled_in_future?(it) } + end + + def status_should_be_request_not_scheduled? + return false if vaccinated? + + parents_contactable? && consent_notifications.empty? && + (sessions.empty? || sessions.any? { consent_request_not_scheduled?(it) }) + end + def agreed_vaccine_methods @agreed_vaccine_methods ||= consents_for_status.map(&:vaccine_methods).inject(&:intersection) @@ -162,4 +186,21 @@ def latest_consents @latest_consents ||= ConsentGrouper.call(consents, programme_type:, academic_year:) end + + def parents_contactable? = parents.any?(&:contactable?) + + def consent_request_scheduled_in_future?(session) + send_at = session.send_consent_requests_at + + # Not using future? because it doesn't work with Timecop + send_at.present? && send_at > Time.current + end + + # Treat consent requests as not scheduled when send_consent_requests_at is + # missing or has already passed, such as for sessions activated on the + # same day they are created. + def consent_request_not_scheduled?(session) + send_at = session.send_consent_requests_at + send_at.nil? || send_at < Time.current + end end diff --git a/app/lib/status_generator/programme.rb b/app/lib/status_generator/programme.rb index cff78a6ee8..6aa1fffb1c 100644 --- a/app/lib/status_generator/programme.rb +++ b/app/lib/status_generator/programme.rb @@ -17,7 +17,8 @@ def initialize( triages:, attendance_record:, vaccination_records:, - parents: + parents:, + consent_notifications: ) @programme_type = programme_type @academic_year = academic_year @@ -28,6 +29,8 @@ def initialize( @attendance_record = attendance_record @vaccination_records = vaccination_records @parents = parents + @consent_notifications = + find_matching_consent_notifications(consent_notifications) @vaccination_criteria = VaccinationCriteria.new( @@ -172,7 +175,8 @@ def consent_vaccine_methods :triages, :attendance_record, :vaccination_criteria, - :parents + :parents, + :consent_notifications delegate :vaccinated?, :vaccinated_vaccination_record, @@ -237,11 +241,11 @@ def should_be_needs_consent_request_failed? end def should_be_needs_consent_request_scheduled? - false # TODO: Implement this status. + is_eligible? && consent_status == :request_scheduled end def should_be_needs_consent_request_not_scheduled? - false # TODO: Implement this status. + is_eligible? && consent_status == :request_not_scheduled end def should_be_needs_consent_no_contact_details? @@ -288,6 +292,13 @@ def default_programme_year_groups Programme.find(programme_type).default_year_groups end + def find_matching_consent_notifications(notifications) + notifications.select do |notification| + notification.programme_types.include?(programme_type) && + notification.session&.team_location&.academic_year == academic_year + end + end + def consent_generator @consent_generator ||= StatusGenerator::Consent.new( @@ -296,7 +307,9 @@ def consent_generator patient:, consents:, vaccination_records:, - parents: + parents:, + sessions:, + consent_notifications: ) end @@ -309,7 +322,34 @@ def triage_generator consents:, triages:, vaccination_records:, - parents: + parents:, + sessions:, + consent_notifications: ) end + + def sessions + @sessions ||= + patient_locations + .reject { it.location.generic_clinic? } + .flat_map { sessions_for(it) } + .uniq + end + + def sessions_for(patient_location) + patient_location + .location + .team_locations + .select { it.academic_year == academic_year } + .flat_map(&:sessions) + .select { it.programme_types.include?(programme_type) } + .select { session_in_patient_location_date_range?(it, patient_location) } + .reject(&:completed?) + end + + def session_in_patient_location_date_range?(session, patient_location) + return true if session.dates.empty? + + session.dates.any? { |date| date.in?(patient_location.date_range) } + end end diff --git a/app/lib/status_generator/triage.rb b/app/lib/status_generator/triage.rb index 0dcfa09561..82c5136904 100644 --- a/app/lib/status_generator/triage.rb +++ b/app/lib/status_generator/triage.rb @@ -8,7 +8,9 @@ def initialize( consents:, triages:, vaccination_records:, - parents: + parents:, + sessions:, + consent_notifications: ) @programme_type = programme_type @academic_year = academic_year @@ -17,6 +19,8 @@ def initialize( @triages = triages @vaccination_records = vaccination_records @parents = parents + @sessions = sessions + @consent_notifications = consent_notifications end def programme @@ -80,7 +84,9 @@ def vaccination_history_requires_triage? :consents, :triages, :vaccination_records, - :parents + :parents, + :sessions, + :consent_notifications def vaccinated? return @vaccinated if defined?(@vaccinated) @@ -133,7 +139,9 @@ def consent_generator patient:, consents:, vaccination_records:, - parents: + parents:, + sessions:, + consent_notifications: ) end diff --git a/app/lib/update_patients_from_pds.rb b/app/lib/update_patients_from_pds.rb index 7f7e519816..9312c67568 100644 --- a/app/lib/update_patients_from_pds.rb +++ b/app/lib/update_patients_from_pds.rb @@ -27,6 +27,6 @@ def self.call(...) = new(...).call attr_reader :patients, :queue def enqueue? - @enqueue ||= Settings.pds.enqueue_bulk_updates + Flipper.enabled?(:pds) && Flipper.enabled?(:pds_enqueue_bulk_updates) end end diff --git a/app/models/access_log_entry.rb b/app/models/access_log_entry.rb index 1e19b38315..0cc5c66d76 100644 --- a/app/models/access_log_entry.rb +++ b/app/models/access_log_entry.rb @@ -20,7 +20,6 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) # fk_rails_... (user_id => users.id) # class AccessLogEntry < ApplicationRecord diff --git a/app/models/careplus_export.rb b/app/models/careplus_report.rb similarity index 76% rename from app/models/careplus_export.rb rename to app/models/careplus_report.rb index 91ce3b3b20..c016e8324e 100644 --- a/app/models/careplus_export.rb +++ b/app/models/careplus_report.rb @@ -2,7 +2,7 @@ # == Schema Information # -# Table name: careplus_exports +# Table name: careplus_reports # # id :bigint not null, primary key # academic_year :integer not null @@ -21,24 +21,24 @@ # # Indexes # -# index_careplus_exports_on_programme_types (programme_types) USING gin -# index_careplus_exports_on_status_and_scheduled_at (status,scheduled_at) -# index_careplus_exports_on_team_id (team_id) -# index_careplus_exports_on_team_id_and_academic_year (team_id,academic_year) +# index_careplus_reports_on_programme_types (programme_types) USING gin +# index_careplus_reports_on_status_and_scheduled_at (status,scheduled_at) +# index_careplus_reports_on_team_id (team_id) +# index_careplus_reports_on_team_id_and_academic_year (team_id,academic_year) # # Foreign Keys # # fk_rails_... (team_id => teams.id) # -class CareplusExport < ApplicationRecord +class CareplusReport < ApplicationRecord include HasManyProgrammes audited associated_with: :team belongs_to :team - has_many :careplus_export_vaccination_records, dependent: :destroy - has_many :vaccination_records, through: :careplus_export_vaccination_records + has_many :careplus_report_vaccination_records, dependent: :destroy + has_many :vaccination_records, through: :careplus_report_vaccination_records enum :status, { pending: 0, sending: 1, sent: 2, failed: 3 }, validate: true diff --git a/app/models/careplus_export_vaccination_record.rb b/app/models/careplus_report_vaccination_record.rb similarity index 52% rename from app/models/careplus_export_vaccination_record.rb rename to app/models/careplus_report_vaccination_record.rb index 5280e4f85f..c2c405e00b 100644 --- a/app/models/careplus_export_vaccination_record.rb +++ b/app/models/careplus_report_vaccination_record.rb @@ -2,28 +2,28 @@ # == Schema Information # -# Table name: careplus_export_vaccination_records +# Table name: careplus_report_vaccination_records # # change_type :integer not null # created_at :datetime not null # updated_at :datetime not null -# careplus_export_id :bigint not null, primary key +# careplus_report_id :bigint not null, primary key # vaccination_record_id :bigint not null, primary key # # Indexes # -# idx_on_careplus_export_id_8ce4ed1ff0 (careplus_export_id) -# idx_on_vaccination_record_id_d4c93aefb7 (vaccination_record_id) +# idx_on_careplus_report_id_98876049c7 (careplus_report_id) +# idx_on_vaccination_record_id_e7f05454ab (vaccination_record_id) # # Foreign Keys # -# fk_rails_... (careplus_export_id => careplus_exports.id) ON DELETE => cascade +# fk_rails_... (careplus_report_id => careplus_reports.id) ON DELETE => cascade # fk_rails_... (vaccination_record_id => vaccination_records.id) # -class CareplusExportVaccinationRecord < ApplicationRecord - self.primary_key = %i[careplus_export_id vaccination_record_id] +class CareplusReportVaccinationRecord < ApplicationRecord + self.primary_key = %i[careplus_report_id vaccination_record_id] - belongs_to :careplus_export + belongs_to :careplus_report belongs_to :vaccination_record enum :change_type, { created: 0, updated: 1 }, validate: true diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 0b5c3ee620..7fb7a6a63d 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -6,7 +6,7 @@ module CSVImportable MAX_CSV_ROWS = 20_000 included do - attr_accessor :csv_is_malformed, :data, :rows + attr_accessor :rows encrypts :csv_data @@ -83,17 +83,37 @@ module CSVImportable before_save :ensure_processed_with_count_statistics end - def csv=(file) - self.csv_data = remove_bom_if_present(file&.read) - self.csv_filename = file&.original_filename - end + # Assign an uploaded CSV file to this import. + # + # Reads the uploaded file into {Import::CSVData}, stores the original filename, + # and updates {#rows_count} based on the parsed CSV data. + # + # If the file contains a UTF byte-order mark (BOM) (common when exporting from + # Excel), the encoding is detected and handled before reading. + # + # Raises an error if called on a persisted record, as changing the CSV file for + # an existing import is not allowed. + # + # @param source [ActionDispatch::Http::UploadedFile] the uploaded CSV file + # @raise [RuntimeError] if called on a persisted record + # @raise [ArgumentError] if `source` is not an uploaded file + def csv=(source) + if persisted? + raise "Cannot change the CSV file for an existing import. " \ + "Create a new import instead." + end - # CSV files exported from Excel may have a BOM. - # https://en.wikipedia.org/wiki/Byte_order_mark - # e.g. if you create a new class import from scratch in Excel on Mac v16, - # save the file as CSV, and upload it. - def remove_bom_if_present(data) - StringIO.new(data).tap(&:set_encoding_by_bom).read + if source.is_a?(ActionDispatch::Http::UploadedFile) + # CSV files exported from Excel may have a BOM. + # https://en.wikipedia.org/wiki/Byte_order_mark + # e.g. if you create a new class import from scratch in Excel on Mac v16, + # save the file as CSV, and upload it. + self.csv_data = source.to_io.tap(&:set_encoding_by_bom).read + self.csv_filename = source&.original_filename + self.rows_count = csv_data_object&.count + else + raise ArgumentError, "Expected an uploaded file, got #{source}" + end end # Needed so that validations match the form field name. @@ -101,27 +121,18 @@ def csv csv_data end - def csv_removed? - csv_removed_at != nil + def csv_data_object + @csv_data_object ||= Import::CSVData.new(csv_data) end - def load_data! - return if invalid? - - self.data ||= CSVParser.call(csv_data) - self.rows_count = data.count - rescue CSV::MalformedCSVError - self.csv_is_malformed = true + def csv_removed? + csv_removed_at != nil end def parse_rows! - load_data! if data.nil? return if invalid? - self.rows = - remove_trailing_blank_rows(data) - .then { |rows| has_instruction_row? ? rows.drop(1) : rows } - .map { |row_data| parse_row(row_data) } + self.rows = csv_data_object.records.map { |row_data| parse_row(row_data) } if invalid? self.serialized_errors = errors.to_hash @@ -130,46 +141,10 @@ def parse_rows! end end - def remove_trailing_blank_rows(table) - found_values = false - - # map(&:itself) because CSV::Table doesn't have a reverse method - rows_in_reverse_order = table.map(&:itself).reverse - - filtered_rows = - rows_in_reverse_order.select do |row| - if found_values - true - elsif row.fields.all?(&:blank?) - false - else - found_values = true - true - end - end - - filtered_rows.reverse - end - - def has_instruction_row? - data&.first&.[](0)&.to_s&.match?(/\A(Required|Optional)([,.:]|$)/) - end - def processed? processed_at != nil end - def process! - return if processed? - - parse_rows! if rows.nil? - return if invalid? - - process_import! - - TeamCachedCounts.new(team).reset_import_issues! - end - def remove! return if csv_removed? update!(csv_data: nil, csv_removed_at: Time.zone.now) @@ -186,13 +161,11 @@ def load_serialized_errors!(limit: nil) end def csv_is_valid - return unless csv_is_malformed - - errors.add(:csv, :invalid) + errors.add(:csv, :invalid) unless csv_data_object.well_formed? end def csv_is_not_too_large - return unless data + return unless csv_data if rows_count > MAX_CSV_ROWS errors.add(:csv, :too_many_rows, count: MAX_CSV_ROWS) @@ -200,10 +173,11 @@ def csv_is_not_too_large end def csv_has_records - return unless data + return unless csv_data csv_has_no_records = - data.empty? || (data.count == 1 && has_instruction_row?) + csv_data_object.empty? || + (csv_data_object.count == 1 && csv_data_object.has_instruction_row?) errors.add(:csv, :empty) if csv_has_no_records end @@ -214,7 +188,7 @@ def rows_are_valid check_rows_are_unique - row_offset = has_instruction_row? ? 3 : 2 + row_offset = csv_data_object.has_instruction_row? ? 3 : 2 rows.each.with_index do |row, index| next if row.errors.empty? diff --git a/app/models/concerns/patient_import_concern.rb b/app/models/concerns/patient_import_concern.rb index 5a75ea4aa1..1b39cad8ab 100644 --- a/app/models/concerns/patient_import_concern.rb +++ b/app/models/concerns/patient_import_concern.rb @@ -149,7 +149,8 @@ def school_move_does_not_move_patient?(school_move:, patient:) end def patient_archived_and_not_in_another_team?(patient:, team:) - patient.archived?(team:) && patient.teams.where.not(id: team.id).empty? + patient.archived?(team_id: team.id) && + patient.teams.where.not(id: team.id).empty? end def reset_counts(import) diff --git a/app/models/draft_vaccination_record.rb b/app/models/draft_vaccination_record.rb index 6cfd257d57..522f8be547 100644 --- a/app/models/draft_vaccination_record.rb +++ b/app/models/draft_vaccination_record.rb @@ -352,7 +352,9 @@ def vaccine_method_matches_consent_and_triage? patient:, consents: patient.consents, vaccination_records: [], - parents: [] + parents: [], + sessions: [], + consent_notifications: [] ) triage_generator = @@ -363,7 +365,9 @@ def vaccine_method_matches_consent_and_triage? consents: patient.consents, triages: patient.triages, vaccination_records: [], - parents: [] + parents: [], + sessions: [], + consent_notifications: [] ) approved_vaccine_methods = diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index d4fac6250e..51601a3e86 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -64,10 +64,38 @@ def records_count end end + def process! + raise "'rows' are empty. Call parse_rows! before processing." if rows.nil? + + counts = count_columns.index_with(0) + + @vaccination_records_batch = Set.new + @patients_batch = Set.new + @patient_locations_batch = Set.new + @archive_reasons_batch = Set.new + + ActiveRecord::Base.transaction do + rows.each do |row| + count_column_to_increment = process_row(row) + counts[count_column_to_increment] += 1 + bulk_import(rows: 100) + end + + bulk_import(rows: :all) + + postprocess_rows! + + update_columns(processed_at: Time.zone.now, status: :processed, **counts) + end + + post_commit! + end + private + # TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation. def check_rows_are_unique - row_offset = has_instruction_row? ? 3 : 2 + row_offset = csv_data_object.has_instruction_row? ? 3 : 2 rows .map(&:full_row_deduplication_attributes) @@ -124,32 +152,6 @@ def process_row(row) count_column_to_increment end - def process_import! - counts = count_columns.index_with(0) - - @vaccination_records_batch = Set.new - @patients_batch = Set.new - @patient_locations_batch = Set.new - @archive_reasons_batch = Set.new - - ActiveRecord::Base.transaction do - rows.each do |row| - count_column_to_increment = process_row(row) - counts[count_column_to_increment] += 1 - bulk_import(rows: 100) - end - - bulk_import(rows: :all) - - postprocess_rows! - - update_columns(processed_at: Time.zone.now, status: :processed, **counts) - end - - post_commit! - UpdatePatientsFromPDS.call(patients, queue: :imports) - end - def bulk_import(rows: 100) return if rows != :all && @vaccination_records_batch.size < rows @@ -209,18 +211,22 @@ def postprocess_rows! .find_each do |vaccination_record| NextDoseTriageFactory.call(vaccination_record:) end + end + def post_commit! PatientTeamUpdater.call(patient_scope: patients) PatientStatusUpdater.call(patient_scope: Patient.where(id: patients.ids)) + vaccination_records.sync_all_to_nhs_immunisations_api + vaccination_records .includes(:patient, :team, :subteam) .find_each do |vaccination_record| AlreadyHadNotificationSender.call(vaccination_record:) end - end - def post_commit! - vaccination_records.sync_all_to_nhs_immunisations_api + UpdatePatientsFromPDS.call(patients, queue: :imports) + + TeamCachedCounts.new(team).reset_import_issues! end end diff --git a/app/models/immunisation_import_row.rb b/app/models/immunisation_import_row.rb index 315b55b6df..b31d1a2a02 100644 --- a/app/models/immunisation_import_row.rb +++ b/app/models/immunisation_import_row.rb @@ -930,6 +930,11 @@ def validate_patient_date_of_birth patient_date_of_birth.header, "Enter a date of birth in the past." ) + elsif patient_date_of_birth.to_date < Date.new(2000, 1, 1) + errors.add( + patient_date_of_birth.header, + "is too old to still be in school" + ) end end diff --git a/app/models/import/csv_data.rb b/app/models/import/csv_data.rb new file mode 100644 index 0000000000..21df92721a --- /dev/null +++ b/app/models/import/csv_data.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +class Import::CSVData + attr_accessor :data, :malformed + + # Use with serialization in the Import model + def initialize(data) + @data = data + end + + def well_formed? + csv_table + !malformed + end + + def empty? = csv_table.blank? + + def csv_table + @csv_table ||= + begin + CSVParser.call(data) if data.present? + rescue CSV::MalformedCSVError + @malformed = true + nil + end + end + + def count = csv_table&.count || 0 + + def records(&block) + remove_trailing_blank_rows + .then { |rows| has_instruction_row? ? rows.drop(1) : rows } + .each(&block) + end + + def has_instruction_row? + csv_table&.first&.[](0)&.to_s&.match?(/\A(Required|Optional)([,.:]|$)/) + end + + private + + def remove_trailing_blank_rows + found_values = false + + # map(&:itself) because CSV::Table doesn't have a reverse method + rows_in_reverse_order = csv_table.map(&:itself).reverse + + filtered_rows = + rows_in_reverse_order.select do |row| + if found_values + true + elsif row.fields.all?(&:blank?) + false + else + found_values = true + true + end + end + + filtered_rows.reverse + end +end diff --git a/app/models/important_notice.rb b/app/models/important_notice.rb index 039b3a6693..cf453bd585 100644 --- a/app/models/important_notice.rb +++ b/app/models/important_notice.rb @@ -90,6 +90,7 @@ def message end def can_dismiss? - type.in?(%w[deceased restricted gillick_no_notify team_changed]) + type.in?(%w[deceased restricted gillick_no_notify team_changed]) || + patient.archived?(team_id:) end end diff --git a/app/models/notify_log_entry.rb b/app/models/notify_log_entry.rb index eb1bd91fd2..58577c668a 100644 --- a/app/models/notify_log_entry.rb +++ b/app/models/notify_log_entry.rb @@ -31,7 +31,7 @@ # # fk_rails_... (consent_form_id => consent_forms.id) # fk_rails_... (parent_id => parents.id) ON DELETE => nullify -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (sent_by_user_id => users.id) # class NotifyLogEntry < ApplicationRecord @@ -77,7 +77,13 @@ class NotifyLogEntry < ApplicationRecord "38727494-9a81-42b3-9c1f-5c31e55333e7" => :vaccination_administered_menacwy, "3abe7ca8-a889-484b-ab9f-07523302eb6a" => :vaccination_administered_td_ipv, "7238ee27-5840-40e5-b9b9-3130ba4cd4fa" => :vaccination_administered_flu, - "0b1095db-fb38-4105-9f01-a364fa8bbb1c" => :vaccination_administered_mmr + "0b1095db-fb38-4105-9f01-a364fa8bbb1c" => :vaccination_administered_mmr, + "ea03aada-0912-4373-91e1-80082071a7aa" => + :consent_school_subsequent_reminder_doubles, + "c942ce27-590e-4387-9aa8-5b9b4f2796d1" => + :consent_school_subsequent_reminder_flu, + "5f70d21d-00b6-41e6-bdc9-e64455972b43" => + :consent_school_subsequent_reminder_hpv }.freeze self.inheritance_column = nil diff --git a/app/models/onboarding.rb b/app/models/onboarding.rb index b9f6c2346f..89404cc292 100644 --- a/app/models/onboarding.rb +++ b/app/models/onboarding.rb @@ -24,8 +24,11 @@ class Onboarding TEAM_ATTRIBUTES = { point_of_care: %i[ + careplus_namespace + careplus_password careplus_staff_code careplus_staff_type + careplus_username careplus_venue_code days_before_consent_reminders days_before_consent_requests diff --git a/app/models/patient.rb b/app/models/patient.rb index 396918584e..2c6b86c282 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -511,11 +511,11 @@ def sessions .distinct end - def archived?(team:) + def archived?(team_id:) if archive_reasons.loaded? - archive_reasons.any? { it.team_id == team.id } + archive_reasons.any? { it.team_id == team_id } else - archive_reasons.exists?(team:) + archive_reasons.exists?(team_id:) end end @@ -547,6 +547,14 @@ def show_year_group?(team:) end end + def can_self_consent_after_gillick_assessment?(location:, programme_type:) + gillick_assessments + .where(location:, programme_type:, date: Date.current) + .order(created_at: :desc) + &.first + &.gillick_competent? || false + end + def programme_status(programme, academic_year:) # TODO: Update this method to accept the `programme_type` so that we can # then determine the right programme variant from the `disease_types` on diff --git a/app/models/patient/programme_status.rb b/app/models/patient/programme_status.rb index 9009503317..c3ea99ac3e 100644 --- a/app/models/patient/programme_status.rb +++ b/app/models/patient/programme_status.rb @@ -38,7 +38,18 @@ class Patient::ProgrammeStatus < ApplicationRecord belongs_to :location, optional: true has_many :patient_locations, - -> { includes(location: :location_programme_year_groups) }, + -> do + includes( + location: [ + :location_programme_year_groups, + { + team_locations: { + sessions: :session_programme_year_groups + } + } + ] + ) + end, through: :patient has_many :consents, @@ -65,6 +76,10 @@ class Patient::ProgrammeStatus < ApplicationRecord has_many :parents, through: :patient + has_many :consent_notifications, + -> { request.includes(session: :team_location) }, + through: :patient + GROUPS = %w[ not_eligible needs_consent @@ -122,6 +137,8 @@ class Patient::ProgrammeStatus < ApplicationRecord default: :not_eligible, validate: true + # If you add more consent_status enums here, check + # whether ReportingAPI::Total also needs updating. enum :consent_status, { no_response: 0, @@ -130,7 +147,9 @@ class Patient::ProgrammeStatus < ApplicationRecord conflicts: 3, not_required: 4, follow_up_requested: 5, - no_contact_details: 6 + no_contact_details: 6, + request_scheduled: 7, + request_not_scheduled: 8 }, default: :no_response, prefix: :consent, @@ -154,6 +173,10 @@ def has_refusal? = status.in?(HAS_REFUSAL_STATUSES.keys) def cannot_vaccinate? = status.in?(CANNOT_VACCINATE_STATUSES.keys) + def needs_triage? = status.in?(NEEDS_TRIAGE_STATUSES.keys) + + def due? = status.in?(DUE_STATUSES.keys) + def vaccinated? = status.in?(VACCINATED_STATUSES.keys) def group = GROUPS.find { status.starts_with?(it) } @@ -205,7 +228,8 @@ def generator triages:, attendance_record:, vaccination_records:, - parents: + parents:, + consent_notifications: ) end end diff --git a/app/models/patient_changeset.rb b/app/models/patient_changeset.rb index 66ee909a29..2b4575bc27 100644 --- a/app/models/patient_changeset.rb +++ b/app/models/patient_changeset.rb @@ -277,7 +277,7 @@ def school_move if patient.new_record? || patient.school != school || patient.not_in_team?(team:, academic_year:) || - patient.archived?(team:) || patient.school_moves.any? + patient.archived?(team_id: team.id) || patient.school_moves.any? school_move = patient.school_moves.includes(:teams).first || SchoolMove.new(patient:) diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index 949c4ff0a6..fb31e1885c 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -34,13 +34,15 @@ def records_count changesets.from_file.count end - def process_import! + def process! + raise "'rows' are empty. Call parse_rows! before processing." if rows.nil? + changesets = rows.each_with_index.map do |row, row_number| PatientChangeset.from_import_row(row:, import: self, row_number:) end - if Flipper.enabled?(:import_search_pds) + if Flipper.enabled?(:pds) && Flipper.enabled?(:pds_search_during_import) process_no_postcode_changesets(self.changesets.without_postcode) if self.changesets.with_postcode.any? enqueue_pds_cascading_searches(self.changesets.with_postcode) @@ -54,6 +56,8 @@ def process_import! return if changesets_are_invalid? enqueue_review_jobs(self.changesets) + + TeamCachedCounts.new(team).reset_import_issues! end def validate_pds_match_rate! @@ -158,7 +162,7 @@ def process_no_postcode_changesets(changesets) def enqueue_review_jobs(changesets) review_changesets = - if Flipper.enabled?(:import_search_pds) + if Flipper.enabled?(:pds) && Flipper.enabled?(:pds_search_during_import) changesets.with_postcode else changesets @@ -179,6 +183,8 @@ def enqueue_pds_cascading_searches(changesets) end end + # TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation. + # TODO: Currently entested, unlike the equivalent in ImmunisationImport. Add tests. def check_rows_are_unique rows .map(&:nhs_number_value) diff --git a/app/models/patient_merge_log_entry.rb b/app/models/patient_merge_log_entry.rb index db1cda8771..fa0b6f2e3b 100644 --- a/app/models/patient_merge_log_entry.rb +++ b/app/models/patient_merge_log_entry.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (user_id => users.id) # class PatientMergeLogEntry < ApplicationRecord diff --git a/app/models/patient_programme_vaccinations_search.rb b/app/models/patient_programme_vaccinations_search.rb index c2c3d03474..7e40989cf8 100644 --- a/app/models/patient_programme_vaccinations_search.rb +++ b/app/models/patient_programme_vaccinations_search.rb @@ -19,7 +19,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # class PatientProgrammeVaccinationsSearch < ApplicationRecord include BelongsToProgramme diff --git a/app/models/pds_search_result.rb b/app/models/pds_search_result.rb index 4751ebd4e4..d7805eb93b 100644 --- a/app/models/pds_search_result.rb +++ b/app/models/pds_search_result.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # class PDSSearchResult < ApplicationRecord belongs_to :patient diff --git a/app/models/reporting_api/total.rb b/app/models/reporting_api/total.rb index b5f68a9411..4a699f3980 100644 --- a/app/models/reporting_api/total.rb +++ b/app/models/reporting_api/total.rb @@ -42,6 +42,27 @@ class ReportingAPI::Total < ApplicationRecord CONSENT_REFUSED = 2 CONSENT_CONFLICTS = 3 CONSENT_NOT_REQUIRED = 4 + NO_CONTACT_DETAILS = 6 + REQUEST_SCHEDULED = 7 + REQUEST_NOT_SCHEDULED = 8 + + CONSENT_GIVEN_STATUSES = [CONSENT_GIVEN, CONSENT_NOT_REQUIRED].freeze + + NO_CONSENT_STATUSES = [ + CONSENT_NO_RESPONSE, + CONSENT_REFUSED, + CONSENT_CONFLICTS, + NO_CONTACT_DETAILS, + REQUEST_SCHEDULED, + REQUEST_NOT_SCHEDULED + ].freeze + + CONSENT_NO_RESPONSE_STATUSES = [ + CONSENT_NO_RESPONSE, + NO_CONTACT_DETAILS, + REQUEST_SCHEDULED, + REQUEST_NOT_SCHEDULED + ].freeze scope :not_archived, -> { where(is_archived: false) } scope :vaccinated, @@ -70,19 +91,17 @@ def self.vaccinated_count end def self.consent_given_count - where(consent_status: [CONSENT_GIVEN, CONSENT_NOT_REQUIRED]).distinct.count( - :patient_id - ) + where(consent_status: CONSENT_GIVEN_STATUSES).distinct.count(:patient_id) end def self.no_consent_count - where( - consent_status: [CONSENT_NO_RESPONSE, CONSENT_REFUSED, CONSENT_CONFLICTS] - ).distinct.count(:patient_id) + where(consent_status: NO_CONSENT_STATUSES).distinct.count(:patient_id) end def self.consent_no_response_count - where(consent_status: CONSENT_NO_RESPONSE).distinct.count(:patient_id) + where(consent_status: CONSENT_NO_RESPONSE_STATUSES).distinct.count( + :patient_id + ) end def self.consent_refused_count @@ -96,17 +115,19 @@ def self.consent_conflicts_count def self.with_aggregate_metrics vaccinated_condition = "status IN (#{VACCINATED_STATUSES.join(",")}) OR has_already_vaccinated_consent = true" - no_consent_condition = - "consent_status IN (#{CONSENT_NO_RESPONSE}, #{CONSENT_REFUSED}, #{CONSENT_CONFLICTS})" consent_given_condition = - "consent_status IN (#{CONSENT_GIVEN}, #{CONSENT_NOT_REQUIRED})" + "consent_status IN (#{CONSENT_GIVEN_STATUSES.join(",")})" + no_consent_condition = + "consent_status IN (#{NO_CONSENT_STATUSES.join(",")})" + consent_no_response_condition = + "consent_status IN (#{CONSENT_NO_RESPONSE_STATUSES.join(",")})" select( "COUNT(DISTINCT patient_id) AS cohort", "COUNT(DISTINCT patient_id) FILTER (WHERE #{vaccinated_condition}) AS vaccinated", "COUNT(DISTINCT patient_id) FILTER (WHERE NOT (#{vaccinated_condition})) AS not_vaccinated", "COUNT(DISTINCT patient_id) FILTER (WHERE #{consent_given_condition}) AS consent_given", "COUNT(DISTINCT patient_id) FILTER (WHERE #{no_consent_condition}) AS no_consent", - "COUNT(DISTINCT patient_id) FILTER (WHERE consent_status = #{CONSENT_NO_RESPONSE}) AS consent_no_response", + "COUNT(DISTINCT patient_id) FILTER (WHERE #{consent_no_response_condition}) AS consent_no_response", "COUNT(DISTINCT patient_id) FILTER (WHERE consent_status = #{CONSENT_REFUSED}) AS consent_refused", "COUNT(DISTINCT patient_id) FILTER (WHERE consent_status = #{CONSENT_CONFLICTS}) AS consent_conflicts" ) diff --git a/app/models/school_move_log_entry.rb b/app/models/school_move_log_entry.rb index 6a44d9cf53..9ead2783eb 100644 --- a/app/models/school_move_log_entry.rb +++ b/app/models/school_move_log_entry.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (school_id => locations.id) # fk_rails_... (team_id => teams.id) # fk_rails_... (user_id => users.id) diff --git a/app/models/team.rb b/app/models/team.rb index 40ba86e39e..8b7adec608 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -5,8 +5,11 @@ # Table name: teams # # id :bigint not null, primary key +# careplus_namespace :string +# careplus_password :string # careplus_staff_code :string # careplus_staff_type :string +# careplus_username :string # careplus_venue_code :string # days_before_consent_reminders :integer default(7), not null # days_before_consent_requests :integer default(21), not null @@ -80,6 +83,25 @@ class Team < ApplicationRecord normalizes :email, with: EmailAddressNormaliser.new normalizes :phone, with: PhoneNumberNormaliser.new + encrypts :careplus_username, :careplus_password + + scope :careplus_enabled, + -> do + where + .not(careplus_staff_code: [nil, ""]) + .where.not(careplus_staff_type: [nil, ""]) + .where.not(careplus_venue_code: [nil, ""]) + end + scope :has_careplus_credentials, + -> do + where + .not(careplus_namespace: [nil, ""]) + .where.not(careplus_username: nil) + .where.not(careplus_password: nil) + end + scope :eligible_for_automated_careplus_reports, + -> { careplus_enabled.has_careplus_credentials } + enum :type, { point_of_care: 0, national_reporting: 1, support: 2 }, validate: true, @@ -149,4 +171,9 @@ def careplus_enabled? careplus_staff_code.present? && careplus_staff_type.present? && careplus_venue_code.present? end + + def eligible_for_automated_careplus_reports? + careplus_enabled? && careplus_username.present? && + careplus_password.present? && careplus_namespace.present? + end end diff --git a/app/views/consent_forms/search.html.erb b/app/views/consent_forms/search.html.erb index bacd0f96d2..f0826cca22 100644 --- a/app/views/consent_forms/search.html.erb +++ b/app/views/consent_forms/search.html.erb @@ -39,7 +39,10 @@ <%= link_to "View full consent response", consent_form_path(@consent_form) %>

- <%= govuk_button_link_to "Create new record", patient_consent_form_path(@consent_form), secondary: true unless @nhs_number_taken %> + <%= render AppActionLinkComponent.new( + href: patient_consent_form_path(@consent_form), + text: "Create new record", + ) unless @nhs_number_taken %> <% end %>
diff --git a/app/views/draft_consents/who.html.erb b/app/views/draft_consents/who.html.erb index cf812fd7a6..5a6f9765d0 100644 --- a/app/views/draft_consents/who.html.erb +++ b/app/views/draft_consents/who.html.erb @@ -2,43 +2,19 @@ <%= govuk_back_link(href: @back_link_path) %> <% end %> -<% page_title = "Who are you trying to get consent from?" %> - -<%= h1 page_title: do %> - - <%= @patient.full_name %> - - <%= page_title %> -<% end %> - -<% gillick_competent = @patient.gillick_assessments.order(created_at: :desc).for_session(@session).for_programme(@programme)&.first&.gillick_competent? %> +<% legend = "Who are you trying to get consent from?" %> +<% content_for :page_title, legend %> <%= form_with model: @draft_consent, url: wizard_path, method: :put do |f| %> <%= f.mavis_error_summary %> - <%= f.govuk_radio_buttons_fieldset(:new_or_existing_contact, legend: nil) do %> - <% if gillick_competent %> - <%= f.govuk_radio_button :new_or_existing_contact, "patient", - label: { text: "Child (Gillick competent)" }, - link_errors: true %> - <% end %> - - <% if @parent_options.present? %> - <% @parent_options.each.with_index do |parent_relationship, i| %> - <% parent = parent_relationship.parent %> - <%= f.govuk_radio_button :new_or_existing_contact, parent.id, - label: { text: parent_relationship.label_with_parent }, - hint: { text: parent.contact_label }, - link_errors: !gillick_competent && i == 0 %> - <% end %> - - <%= f.govuk_radio_divider %> - <% end %> - - <%= f.govuk_radio_button :new_or_existing_contact, "new", - label: { text: "Add a new parental contact" }, - link_errors: !gillick_competent && @parent_options.empty? %> - <% end %> + <%= f.govuk_collection_radio_buttons :new_or_existing_contact, + @new_or_existing_contact_options, + :value, + :label, + :hint, + legend: { text: legend, tag: "h1", size: "l" }, + caption: { text: @patient.full_name, size: "l" } %> <%= f.govuk_submit "Continue" %> <% end %> diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index f7a1340762..2923c420e8 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -38,9 +38,7 @@ <%= render "layouts/header" %> -
- <%= yield :main_content %> -
+ <%= yield :main_content %> <%= yield :after_main %> diff --git a/app/views/layouts/default.html.erb b/app/views/layouts/default.html.erb index eca822cfb2..b4df0377f3 100644 --- a/app/views/layouts/default.html.erb +++ b/app/views/layouts/default.html.erb @@ -2,11 +2,11 @@
<%= yield :navigation %> -
+
<%= render(AppFlashMessageComponent.new(flash: flash)) %> <%= content_for?(:content) ? yield(:content) : yield %> -
+
<% end %> diff --git a/app/views/layouts/session.html.erb b/app/views/layouts/session.html.erb index 3b04b6ff72..e197c5a5b9 100644 --- a/app/views/layouts/session.html.erb +++ b/app/views/layouts/session.html.erb @@ -1,21 +1,21 @@ <% content_for :main_content do %> - <%= render "sessions/header" %> +
+ <%= render "sessions/header" %> -
- <%= yield :navigation %> +
+
+ <%= yield :navigation %> -
- <%= render(AppFlashMessageComponent.new(flash: flash)) %> +
+
+ <%= yield :before_content %> -
-
- <%= yield :before_content %> - - <%= yield %> + <%= yield %> +
-
+
<% end %> <%= render template: "layouts/application" %> diff --git a/app/views/notify_templates/email/consent_school_initial_reminder_doubles.text.erb b/app/views/notify_templates/email/consent_school_reminder_doubles.text.erb similarity index 98% rename from app/views/notify_templates/email/consent_school_initial_reminder_doubles.text.erb rename to app/views/notify_templates/email/consent_school_reminder_doubles.text.erb index f77b678712..066d0a357f 100644 --- a/app/views/notify_templates/email/consent_school_initial_reminder_doubles.text.erb +++ b/app/views/notify_templates/email/consent_school_reminder_doubles.text.erb @@ -1,6 +1,6 @@ --- template_id: "3523d4b8-530b-42dd-8b9b-7fed8d1dfff1" -template_name: consent_school_initial_reminder_doubles +template_name: consent_school_reminder_doubles subject: "Please respond to our request for consent by <%= consent_deadline %>" --- We wrote to you recently to let you know we’re coming to <%= location_name %> on <%= next_session_dates %> to offer your child their <%= vaccination %>. diff --git a/app/views/notify_templates/email/consent_school_initial_reminder_flu.text.erb b/app/views/notify_templates/email/consent_school_reminder_flu.text.erb similarity index 98% rename from app/views/notify_templates/email/consent_school_initial_reminder_flu.text.erb rename to app/views/notify_templates/email/consent_school_reminder_flu.text.erb index 90a726d580..a6409537d4 100644 --- a/app/views/notify_templates/email/consent_school_initial_reminder_flu.text.erb +++ b/app/views/notify_templates/email/consent_school_reminder_flu.text.erb @@ -1,6 +1,6 @@ --- template_id: "7f85a5b4-5240-4ae9-94f7-43913852943c" -template_name: consent_school_initial_reminder_flu +template_name: consent_school_reminder_flu subject: "Please respond to our request for consent by <%= consent_deadline %>" --- We wrote to you recently to let you know we’re coming to <%= location_name %> on <%= next_session_dates %> to offer your child their annual flu vaccination. diff --git a/app/views/notify_templates/email/consent_school_initial_reminder_hpv.text.erb b/app/views/notify_templates/email/consent_school_reminder_hpv.text.erb similarity index 97% rename from app/views/notify_templates/email/consent_school_initial_reminder_hpv.text.erb rename to app/views/notify_templates/email/consent_school_reminder_hpv.text.erb index 4754dcb7df..778be7f312 100644 --- a/app/views/notify_templates/email/consent_school_initial_reminder_hpv.text.erb +++ b/app/views/notify_templates/email/consent_school_reminder_hpv.text.erb @@ -1,6 +1,6 @@ --- template_id: "0d78bff0-9dde-4192-8cf8-10e83486b54f" -template_name: consent_school_initial_reminder_hpv +template_name: consent_school_reminder_hpv subject: "Please respond to our request for consent by <%= consent_deadline %>" --- We wrote to you recently to let you know we’re coming to <%= location_name %> on <%= next_session_dates %> to offer your child their <%= vaccination %>. diff --git a/app/views/notify_templates/email/consent_school_initial_reminder_mmr.text.erb b/app/views/notify_templates/email/consent_school_reminder_mmr.text.erb similarity index 97% rename from app/views/notify_templates/email/consent_school_initial_reminder_mmr.text.erb rename to app/views/notify_templates/email/consent_school_reminder_mmr.text.erb index 4884535a33..926d993851 100644 --- a/app/views/notify_templates/email/consent_school_initial_reminder_mmr.text.erb +++ b/app/views/notify_templates/email/consent_school_reminder_mmr.text.erb @@ -1,6 +1,6 @@ --- template_id: "5462c441-81c0-4ac0-821f-713b4178f8ba" -template_name: consent_school_initial_reminder_mmr +template_name: consent_school_reminder_mmr subject: "Please respond to our request for consent by <%= consent_deadline %>" --- We wrote to you recently about MMR catch-up vaccinations at <%= location_name %> on <%= next_session_dates %>. diff --git a/app/views/notify_templates/email/consent_school_initial_reminder_mmrv.text.erb b/app/views/notify_templates/email/consent_school_reminder_mmrv.text.erb similarity index 97% rename from app/views/notify_templates/email/consent_school_initial_reminder_mmrv.text.erb rename to app/views/notify_templates/email/consent_school_reminder_mmrv.text.erb index 56e5569c9c..ed131fe680 100644 --- a/app/views/notify_templates/email/consent_school_initial_reminder_mmrv.text.erb +++ b/app/views/notify_templates/email/consent_school_reminder_mmrv.text.erb @@ -1,6 +1,6 @@ --- template_id: "fe47875a-a0a6-40d9-bd41-a411ebb31cff" -template_name: consent_school_initial_reminder_mmrv +template_name: consent_school_reminder_mmrv subject: "Please respond to our request for consent by <%= consent_deadline %>" --- We wrote to you recently about <%= vaccine_and_dose %> catch-up vaccinations at <%= location_name %> on <%= next_session_dates %>. diff --git a/app/views/notify_templates/email/consent_school_subsequent_reminder_doubles.text.erb b/app/views/notify_templates/email/consent_school_subsequent_reminder_doubles.text.erb deleted file mode 100644 index b6047c31ea..0000000000 --- a/app/views/notify_templates/email/consent_school_subsequent_reminder_doubles.text.erb +++ /dev/null @@ -1,54 +0,0 @@ ---- -template_id: "ea03aada-0912-4373-91e1-80082071a7aa" -template_name: consent_school_subsequent_reminder_doubles -subject: "There’s still time for your child to get their <%= vaccination %>" ---- -We’re coming to <%= location_name %> on <%= next_session_dates %> to give the <%= vaccination %>. - -If you want your child to be vaccinated, you need to give your consent. - -^ Do not reply to this email to tell us your decision. The link to the online consent form is below. - -<% if has_multiple_dates? %>We cannot say on which of the above dates your child will be vaccinated because of the number of pupils involved.<% end %> - -If you’ve already responded to the consent request, you can ignore this message. - -## About the MenACWY vaccine - -The MenACWY vaccine helps protect against life-threatening illnesses including meningitis, sepsis and septicaemia (blood poisoning). - -It is recommended for all teenagers. Most people only need 1 dose of the vaccine. - -[Find out more about the MenACWY vaccine on NHS.UK](https://www.nhs.uk/vaccinations/menacwy-vaccine/) - -[Learn about the MenACWY vaccine on GOV.UK](https://www.gov.uk/government/publications/menacwy-vaccine-information-for-young-people) (with links to information in other languages) - -## About the Td/IPV vaccine - -The Td/IPV vaccine helps protect against tetanus, diphtheria and polio. - -It’s offered at around 13 or 14 years old (school year 9 or 10). It boosts the protection provided by the [6-in-1 vaccine](https://www.nhs.uk/vaccinations/6-in-1-vaccine/) and [4-in-1 pre-school booster](https://www.nhs.uk/vaccinations/4-in-1-preschool-booster-vaccine/) vaccine. - -[Find out more about the Td/IPV vaccine on NHS.UK](https://www.nhs.uk/vaccinations/td-ipv-vaccine-3-in-1-teenage-booster/) - -[Learn about the Td/IPV vaccine on GOV.UK](https://www.gov.uk/government/publications/a-guide-to-the-3-in-1-teenage-booster-tdipv) (with links to information in other languages) - -<%= talk_to_your_child_message %> - -## How to respond - -^ It’s important to let us know whether you do or do not want your child to have these vaccinations. It will take less than 5 minutes to respond using the link below. - -[Respond to the consent request now](<%= consent_link %>) - -If you do not respond, you’ll get automatic reminders. Responding will stop reminders. - -If you cannot use the online form, you can respond over the phone using the contact details below. Replies to this email cannot be accepted as consent. - -## Your data - -By responding, you’re agreeing to your data being processed as set out in our [privacy notice](<%= team_privacy_notice_url %>). - -## Contact us - -Speak to a member of our team by calling <%= subteam_phone %>, or email <%= subteam_email %>. diff --git a/app/views/notify_templates/email/consent_school_subsequent_reminder_flu.text.erb b/app/views/notify_templates/email/consent_school_subsequent_reminder_flu.text.erb deleted file mode 100644 index 3926bb5301..0000000000 --- a/app/views/notify_templates/email/consent_school_subsequent_reminder_flu.text.erb +++ /dev/null @@ -1,50 +0,0 @@ ---- -template_id: "c942ce27-590e-4387-9aa8-5b9b4f2796d1" -template_name: consent_school_subsequent_reminder_flu -subject: "There’s still time for your child to get their <%= vaccination %>" ---- -We’re coming to <%= location_name %> on <%= next_session_dates %> to give pupils their annual flu vaccination. - -If you want your child to be vaccinated, you need to give your consent. - -^ Do not reply to this email to tell us your decision. The link to the online consent form is below. - -<% if has_multiple_dates? %>We cannot say on which of the above dates your child will have the vaccination because of the number of pupils involved.<% end %> - -If you’ve already responded to the consent request, you can ignore this message. - -## About the children’s flu vaccine - -The vaccine protects against flu, which can cause serious health problems such as bronchitis and pneumonia. It is recommended for children from Reception to Year 11 every year. - -[Find out more about the children’s flu vaccine on NHS.UK](https://www.nhs.uk/vaccinations/child-flu-vaccine/) - -You can also find a range of [information resources about the vaccine on GOV.UK](https://www.gov.uk/government/publications/flu-vaccination-leaflets-and-posters), including in other languages. - -## How the vaccine is given - -Most children are given the vaccine as a nasal spray. This is a quick and painless spray up the nose and offers the best protection against flu. - -The nasal spray contains a small amount of gelatine derived from pigs (porcine gelatine). If your child does not use gelatine products, or cannot have the nasal spray for medical reasons, you can choose an alternative injected vaccine that has no gelatine or any other animal product. You can request this in the consent form. - -[Find out more about the use of gelatine in the flu vaccine (including the views of faith communities)](https://www.gov.uk/government/publications/vaccines-and-porcine-gelatine) - -<%= talk_to_your_child_message %> - -## How to respond - -^ It’s important to let us know whether you do or do not want your child to have the vaccination. It will take less than 5 minutes to respond using the link below. - -[Respond to the consent request now](<%= consent_link %>) - -If you do not respond, you’ll get automatic reminders. Responding will stop reminders. - -If you cannot use the online form, you can respond over the phone using the contact details below. Replies to this email cannot be accepted as consent. - -## Your data - -By responding, you’re agreeing to your data being processed as set out in our [privacy notice](<%= team_privacy_notice_url %>). - -## Contact us - -Speak to a member of our team by calling <%= subteam_phone %>, or email <%= subteam_email %>. diff --git a/app/views/notify_templates/email/consent_school_subsequent_reminder_hpv.text.erb b/app/views/notify_templates/email/consent_school_subsequent_reminder_hpv.text.erb deleted file mode 100644 index 6a1a381c2b..0000000000 --- a/app/views/notify_templates/email/consent_school_subsequent_reminder_hpv.text.erb +++ /dev/null @@ -1,50 +0,0 @@ ---- -template_id: "5f70d21d-00b6-41e6-bdc9-e64455972b43" -template_name: consent_school_subsequent_reminder_hpv -subject: "There’s still time for your child to get their <%= vaccination %>" ---- -We’re coming to <%= location_name %> on <%= next_session_dates %> to give the <%= vaccination %>. - -If you want your child to be vaccinated, you need to give your consent. - -^ Do not reply to this email to tell us your decision. The link to the online consent form is below. - -<% if has_multiple_dates? %>We cannot say on which of the above dates your child will have the vaccination because of the number of pupils involved.<% end %> - -If you’ve already responded to the consent request, you can ignore this message. - -## What the vaccine is for - -The HPV vaccine helps protect against cancers caused by HPV, including: - -* cervical cancer -* some mouth and throat (head and neck) cancers -* some cancers of the anal and genital areas - -It also helps protect against genital warts. - -The HPV vaccine works best if it’s given before young people become sexually active. - -[Find out more about the HPV vaccine on NHS.UK](https://www.nhs.uk/conditions/vaccinations/hpv-human-papillomavirus-vaccine/) - -[Learn about the HPV vaccine on GOV.UK](https://www.gov.uk/government/publications/hpv-vaccine-vaccination-guide-leaflet) (with information available in different languages and alternative formats, including BSL and Braille) - -<%= talk_to_your_child_message %> - -## How to respond - -^ It’s important to let us know whether you do or do not want your child to have the vaccination. It will take less than 5 minutes to respond using the link below. - -[Respond to the consent request now](<%= consent_link %>) - -If you do not respond, you’ll get automatic reminders. Responding will stop reminders. - -If you cannot use the online form, you can respond over the phone using the contact details below. Replies to this email cannot be accepted as consent. - -## Your data - -By responding, you’re agreeing to your data being processed as set out in our [privacy notice](<%= team_privacy_notice_url %>). - -## Contact us - -Speak to a member of our team by calling <%= subteam_phone %>, or contact <%= subteam_email %>. diff --git a/app/views/notify_templates/email/consent_school_subsequent_reminder_mmr.text.erb b/app/views/notify_templates/email/consent_school_subsequent_reminder_mmr.text.erb deleted file mode 100644 index 04ce5544dc..0000000000 --- a/app/views/notify_templates/email/consent_school_subsequent_reminder_mmr.text.erb +++ /dev/null @@ -1,46 +0,0 @@ ---- -template_id: "5462c441-81c0-4ac0-821f-713b4178f8ba" -template_name: consent_school_subsequent_reminder_mmr -subject: "Please respond to our request for consent by <%= consent_deadline %>" ---- -We wrote to you recently about MMR catch-up vaccinations at <%= location_name %> on <%= next_session_dates %>. - -If you’re sure your child has had 2 doses, please confirm this using the consent request form. - -^ Do not reply to this email to tell us your decision. The link to the consent form is below. - -<% if has_multiple_dates? %>We cannot say on which of the above dates your child will have the vaccination because of the number of pupils involved.<% end %> - -If you’ve already responded to the consent request, you can ignore this message. - -## About the MMR vaccine - -The MMR vaccine protects against measles, mumps and rubella. Having 2 doses gives lasting protection against all 3 illnesses. If you’re not sure how many doses your child has had, having further doses will not cause any harm. - -Research has shown there is no link between the MMR vaccine and autism. - -[Find out more about the MMR vaccine on NHS.UK](https://www.nhs.uk/vaccinations/mmr-vaccine/) - -You can also find a range of [information about the vaccine on GOV.​UK](https://www.gov.uk/government/publications/mmr-for-all-general-leaflet), including in other languages. - -<%= talk_to_your_child_message %> - -## How to respond - -^ It’s important to let us know whether you do or do not want your child to have the vaccination. It will take less than 5 minutes to respond using the link below. - -[Respond to the consent request now](<%= consent_link %>) - -You need to respond by <%= consent_deadline %>. - -If you do not respond, you’ll get automatic reminders. Responding will stop reminders. - -If you cannot use the online form, you can respond over the phone using the contact details below. Replies to this email cannot be accepted as consent. - -## Your data - -By responding, you’re agreeing to your data being processed as set out in our [privacy notice](<%= team_privacy_notice_url %>). - -## Contact us - -Speak to a member of our team by calling <%= subteam_phone %>, or email <%= subteam_email %>. diff --git a/app/views/notify_templates/email/consent_school_subsequent_reminder_mmrv.text.erb b/app/views/notify_templates/email/consent_school_subsequent_reminder_mmrv.text.erb deleted file mode 100644 index 96895c7028..0000000000 --- a/app/views/notify_templates/email/consent_school_subsequent_reminder_mmrv.text.erb +++ /dev/null @@ -1,46 +0,0 @@ ---- -template_id: "fe47875a-a0a6-40d9-bd41-a411ebb31cff" -template_name: consent_school_subsequent_reminder_mmrv -subject: "Please respond to our request for consent by <%= consent_deadline %>" ---- -We wrote to you recently about <%= vaccine_and_dose %> catch-up vaccinations at <%= location_name %> on <%= next_session_dates %>. - -If you’re sure your child has had 2 doses, please confirm this using the consent request form. - -^ Do not reply to this email to tell us your decision. The link to the consent form is below. - -<% if has_multiple_dates? %>We cannot say on which of the above dates your child will have the vaccination because of the number of pupils involved.<% end %> - -If you’ve already responded to the consent request, you can ignore this message. - -## About the MMRV vaccine - -The MMRV vaccine protects against measles, mumps, rubella, and varicella (more commonly known as chickenpox). Having 2 doses gives lasting protection against all 4 illnesses. If you’re not sure how many doses your child has had, having further doses will not cause any harm. - -Research has shown there is no link between the MMRV vaccine and autism. - -[Find out more about the MMRV vaccine on NHS.UK](https://www.nhs.uk/vaccinations/mmrv-vaccine/) - -You can also find a range of [information about the vaccine on GOV.​UK](https://www.gov.uk/government/publications/mmrv-vaccination), including in other languages. - -<%= talk_to_your_child_message %> - -## How to respond - -^ It’s important to let us know whether you do or do not want your child to have the vaccination. It will take less than 5 minutes to respond using the link below. - -[Respond to the consent request now](<%= consent_link %>) - -You need to respond by <%= consent_deadline %>. - -If you do not respond, you’ll get automatic reminders. Responding will stop reminders. - -If you cannot use the online form, you can respond over the phone using the contact details below. Replies to this email cannot be accepted as consent. - -## Your data - -By responding, you’re agreeing to your data being processed as set out in our [privacy notice](<%= team_privacy_notice_url %>). - -## Contact us - -Speak to a member of our team by calling <%= subteam_phone %>, or email <%= subteam_email %>. diff --git a/app/views/patient_sessions/programmes/show.html.erb b/app/views/patient_sessions/programmes/show.html.erb index 1b177bb80a..a99cb772f3 100644 --- a/app/views/patient_sessions/programmes/show.html.erb +++ b/app/views/patient_sessions/programmes/show.html.erb @@ -6,12 +6,12 @@
+ <%= render AppPatientSessionProgrammeComponent.new(patient: @patient, session: @session, programme: @programme) %> + <%= render AppPatientSessionConsentComponent.new(patient: @patient, session: @session, programme: @programme) %> <%= render AppPatientSessionTriageComponent.new(patient: @patient, session: @session, programme: @programme, current_user:, triage_form: @triage_form) %> <%= render AppPatientSessionRecordComponent.new(patient: @patient, session: @session, programme: @programme, current_user:, vaccinate_form: @vaccinate_form) %> - - <%= render AppPatientSessionVaccinationComponent.new(patient: @patient, session: @session, programme: @programme) %>
diff --git a/app/views/patients/_header.html.erb b/app/views/patients/_header.html.erb index b7cfde8dc7..5751015c35 100644 --- a/app/views/patients/_header.html.erb +++ b/app/views/patients/_header.html.erb @@ -3,7 +3,7 @@ <% end %> <%= render AppActionListComponent.new do |action_list| %> - <% if @patient.archived?(team: current_team) && !current_team.has_national_reporting_access? %> + <% if @patient.archived?(team_id: current_team.id) && !current_team.has_national_reporting_access? %> <% action_list.with_item do %> <%= govuk_tag(text: "Archived", colour: "grey") %> <% end %> diff --git a/app/views/schools/patients/index.html.erb b/app/views/schools/patients/index.html.erb index 5f2db91d10..b96d20772e 100644 --- a/app/views/schools/patients/index.html.erb +++ b/app/views/schools/patients/index.html.erb @@ -9,17 +9,16 @@ <%= render "schools/header" %> <% if @location.gias_school? %> - <%= govuk_button_link_to "Import class lists", new_school_import_path(@location), secondary: true %> + <%= render AppActionLinkComponent.new( + href: new_school_import_path(@location), + text: "Import class lists", + ) %> <% elsif @location.generic_school? %> -
- <%= govuk_button_link_to "Send clinic invitations", - edit_school_invite_to_clinic_path(@location), - secondary: true %> - - <%= govuk_button_link_to "Download offline spreadsheet", - url_for(path_params: request.path_parameters, params: request.query_parameters, format: :xlsx), - secondary: true %> -
+ <%= render AppActionLinkComponent.new( + href: edit_school_invite_to_clinic_path(@location), + text: "Send clinic invitations", + class: "nhsuk-u-margin-right-4", + ) %> <% end %>
@@ -37,6 +36,10 @@
<%= render AppSearchResultsComponent.new(@pagy, label: "children") do %> + <%= govuk_button_link_to "Download offline spreadsheet", + url_for(path_params: request.path_parameters, params: request.query_parameters, format: :xlsx), + secondary: true %> + <% @patients.each do |patient| %> <%= render AppPatientSearchResultCardComponent.new( patient, diff --git a/app/views/schools/sessions/index.html.erb b/app/views/schools/sessions/index.html.erb index 7254fcab17..fc752475c1 100644 --- a/app/views/schools/sessions/index.html.erb +++ b/app/views/schools/sessions/index.html.erb @@ -9,7 +9,10 @@ <% content_for :page_title, "#{t("schools.sessions.title")} – #{@location.name}" %> <%= render "schools/header" %> -<%= govuk_button_link_to "Add a new session", new_session_path(school_id: @location.id), secondary: true %> +<%= render AppActionLinkComponent.new( + href: new_session_path(school_id: @location.id), + text: "Add a new session", + ) %> <% if @scheduled_sessions.present? %>

Scheduled sessions

diff --git a/app/views/sessions/_header.html.erb b/app/views/sessions/_header.html.erb index 0c90b72812..746d4dbb31 100644 --- a/app/views/sessions/_header.html.erb +++ b/app/views/sessions/_header.html.erb @@ -1,10 +1,12 @@
+ <%= render(AppFlashMessageComponent.new(flash: flash)) %> + <%= h1 session_title(@session), class: "nhsuk-u-margin-bottom-2" %>

- <%= format_year_groups(@session.year_groups) %> + <%= session_caption(@session) %>

diff --git a/app/views/sessions/index.html.erb b/app/views/sessions/index.html.erb index 1f55222dba..d426b77441 100644 --- a/app/views/sessions/index.html.erb +++ b/app/views/sessions/index.html.erb @@ -1,6 +1,9 @@ <%= h1 t(".title") %> -<%= govuk_button_link_to "Add a new session", new_session_path, secondary: true %> +<%= render AppActionLinkComponent.new( + href: new_session_path, + text: "Add a new session", + ) %>
diff --git a/app/views/sessions/record/show.html.erb b/app/views/sessions/record/show.html.erb index 527b0e4f0a..6d1d3e64c0 100644 --- a/app/views/sessions/record/show.html.erb +++ b/app/views/sessions/record/show.html.erb @@ -34,5 +34,7 @@
<% else %> +

Record vaccinations

+

You can record vaccinations when a session is in progress.

<% end %> diff --git a/bin/e2e b/bin/e2e index 410d0403c4..41ec35028a 100755 --- a/bin/e2e +++ b/bin/e2e @@ -2,11 +2,14 @@ set -euo pipefail # Rails -RAILS_ENV="end_to_end" -RAILS_PORT="4100" -HEALTH_CHECK_URL="http://localhost:${RAILS_PORT}/up" -MAVIS_TEST_REPO="${MAVIS_TEST_REPO:-"../manage-vaccinations-in-schools-testing"}" -PYTEST_ARGS=("-m" "not accessibility and not reporting and not imms_api and not pds_api") +rails_port="4100" +health_check_url="http://localhost:${rails_port}/up" +mavis_test_repo="${mavis_test_repo:-"../manage-vaccinations-in-schools-testing"}" +pytest_args=("-m" "not accessibility and not reporting and not imms_api and not pds_api") + +export RAILS_ENV="end_to_end" +export REDIS_URL="redis://localhost:6379/2" +export BASE_URL="http://localhost:${rails_port}" # Argument handling @@ -17,7 +20,7 @@ function print_help() { echo "run by default." echo "" echo "The pytest CLI command defaults to:" - echo " uv run pytest ${PYTEST_ARGS[*]} " + echo " uv run pytest ${pytest_args[*]} " echo "" echo "Options:" echo " --main Force sync testing repo to latest main branch. WARNING: this" @@ -36,35 +39,35 @@ if ! command -v uv >/dev/null 2>&1; then exit 1 fi -if [ ! -d "$MAVIS_TEST_REPO/.git" ]; then - echo "[e2e] ERROR: Testing repo not found at: $MAVIS_TEST_REPO" >&2 - echo " Clone the repo there or set the MAVIS_TEST_REPO" >&2 +if [ ! -d "$mavis_test_repo/.git" ]; then + echo "[e2e] ERROR: Testing repo not found at: $mavis_test_repo" >&2 + echo " Clone the repo there or set the mavis_test_repo" >&2 echo " environment variable to the path of your repo" >&2 exit 1 fi # Consume --main flag if present -USE_MAVIS_BRANCH=false +use_mavis_branch=false for arg in "$@"; do case "$arg" in --main) - USE_MAVIS_BRANCH=true + use_mavis_branch=true ;; -h|--help) print_help exit 0 ;; *) - PYTEST_ARGS+=("$arg") + pytest_args+=("$arg") ;; esac done # Update mavis testing repo -if [ "${USE_MAVIS_BRANCH:-false}" = true ]; then +if [ "${use_mavis_branch:-false}" = true ]; then echo "[e2e] Using latest main branch of testing repo." - pushd "$MAVIS_TEST_REPO" > /dev/null + pushd "$mavis_test_repo" > /dev/null git fetch origin git checkout main @@ -78,48 +81,48 @@ else echo "[e2e] Checking branch alignment with testing repo..." # Service (main) repo branch - MAVIS_REPO="$(git rev-parse --show-toplevel 2>/dev/null || echo "")" - MAVIS_BRANCH="unknown" - if [ -n "$MAVIS_REPO" ]; then - MAVIS_BRANCH="$(git -C "$MAVIS_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")" + mavis_repo="$(git rev-parse --show-toplevel 2>/dev/null || echo "")" + mavis_branch="unknown" + if [ -n "$mavis_repo" ]; then + mavis_branch="$(git -C "$mavis_repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")" fi # Testing repo branch - MAVIS_TEST_BRANCH="unknown" - if git -C "$MAVIS_TEST_REPO" rev-parse --git-dir >/dev/null 2>&1; then - MAVIS_TEST_BRANCH="$(git -C "$MAVIS_TEST_REPO" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")" + mavis_test_branch="unknown" + if git -C "$mavis_test_repo" rev-parse --git-dir >/dev/null 2>&1; then + mavis_test_branch="$(git -C "$mavis_test_repo" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "HEAD")" fi # If testing repo is on main, warn if it's behind origin/main - if [ "$MAVIS_TEST_BRANCH" = "main" ]; then + if [ "$mavis_test_branch" = "main" ]; then # Fetch without failing the whole script if it goes wrong - git -C "$MAVIS_TEST_REPO" fetch origin main >/dev/null 2>&1 || true + git -C "$mavis_test_repo" fetch origin main >/dev/null 2>&1 || true - LOCAL_MAIN_SHA="$(git -C "$MAVIS_TEST_REPO" rev-parse main 2>/dev/null || echo "")" - REMOTE_MAIN_SHA="$(git -C "$MAVIS_TEST_REPO" rev-parse origin/main 2>/dev/null || echo "")" + local_main_sha="$(git -C "$mavis_test_repo" rev-parse main 2>/dev/null || echo "")" + remote_main_sha="$(git -C "$mavis_test_repo" rev-parse origin/main 2>/dev/null || echo "")" - if [ -n "$LOCAL_MAIN_SHA" ] && [ -n "$REMOTE_MAIN_SHA" ] && [ "$LOCAL_MAIN_SHA" != "$REMOTE_MAIN_SHA" ]; then - AHEAD_BEHIND="$(git -C "$MAVIS_TEST_REPO" rev-list --left-right --count main...origin/main 2>/dev/null || echo "")" - set -- $AHEAD_BEHIND - LOCAL_AHEAD="$1" - LOCAL_BEHIND="$2" + if [ -n "$local_main_sha" ] && [ -n "$remote_main_sha" ] && [ "$local_main_sha" != "$remote_main_sha" ]; then + ahead_behind="$(git -C "$mavis_test_repo" rev-list --left-right --count main...origin/main 2>/dev/null || echo "")" + set -- $ahead_behind + local_ahead="$1" + local_behind="$2" # Warn if local main differs from remote - if [ -n "$LOCAL_BEHIND" ] && [ "$LOCAL_BEHIND" -gt 0 ] 2>/dev/null; then - echo "[e2e] !!! WARNING !!! Testing repo 'main' is behind origin/main by $LOCAL_BEHIND commit(s)." - echo "[e2e] Run 'git -C \"$MAVIS_TEST_REPO\" pull --ff-only origin main' " + if [ -n "$local_behind" ] && [ "$local_behind" -gt 0 ] 2>/dev/null; then + echo "[e2e] !!! WARNING !!! Testing repo 'main' is behind origin/main by $local_behind commit(s)." + echo "[e2e] Run 'git -C \"$mavis_test_repo\" pull --ff-only origin main' " echo "[e2e] or use --main to force-sync." - elif [ -n "$LOCAL_AHEAD" ] && [ "$LOCAL_AHEAD" -gt 0 ] 2>/dev/null; then - echo "[e2e] !!! WARNING !!! Testing repo 'main' is ahead of origin/main by $LOCAL_AHEAD commit(s)." - echo "[e2e] Run 'git -C \"$MAVIS_TEST_REPO\" pull --ff-only origin main' " + elif [ -n "$local_ahead" ] && [ "$local_ahead" -gt 0 ] 2>/dev/null; then + echo "[e2e] !!! WARNING !!! Testing repo 'main' is ahead of origin/main by $local_ahead commit(s)." + echo "[e2e] Run 'git -C \"$mavis_test_repo\" pull --ff-only origin main' " echo "[e2e] or use --main to force-sync." fi else echo "[e2e] Testing repo 'main' branch is up to date with origin/main." fi - elif [ "$MAVIS_BRANCH" != "$MAVIS_TEST_BRANCH" ]; then - echo "[e2e] !!! WARNING !!! Service repo branch ($MAVIS_BRANCH) " - echo "[e2e] does not match testing repo branch ($MAVIS_TEST_BRANCH)." + elif [ "$mavis_branch" != "$mavis_test_branch" ]; then + echo "[e2e] !!! WARNING !!! Service repo branch ($mavis_branch) " + echo "[e2e] does not match testing repo branch ($mavis_test_branch)." echo "[e2e] You may be running tests from a different branch than you desire." fi fi @@ -128,7 +131,7 @@ fi echo "[e2e] Preparing Rails test DB schema (RAILS_ENV=$RAILS_ENV)..." bin/bundle install --quiet -RAILS_ENV="$RAILS_ENV" bin/rails db:prepare +bin/rails db:prepare # Start Rails server in end_to_end environment @@ -137,43 +140,43 @@ gem install --silent foreman echo "[e2e] Starting end_to_end stack with foreman..." if command -v setsid >/dev/null 2>&1; then - START_FOREMAN=(setsid foreman start -f Procfile.e2e) + start_foreman=(setsid foreman start -f Procfile.e2e) else # macOS does not ship a setsid binary; use Perl to create a new session. - START_FOREMAN=(perl -MPOSIX -e 'POSIX::setsid() or die "setsid failed: $!"; exec @ARGV' foreman start -f Procfile.e2e) + start_foreman=(perl -MPOSIX -e 'POSIX::setsid() or die "setsid failed: $!"; exec @ARGV' foreman start -f Procfile.e2e) fi echo "[e2e] Starting rails server with foreman. Logs will be written to /tmp/e2e-foreman.log" -RAILS_ENV="$RAILS_ENV" "${START_FOREMAN[@]}" >/tmp/e2e-foreman.log 2>&1 & -E2E_PGID=$! +"${start_foreman[@]}" >/tmp/e2e-foreman.log 2>&1 & +e2e_pgid=$! cleanup() { echo "[e2e] Cleaning up end_to_end stack..." - if [[ -n "${E2E_PGID:-}" ]]; then - echo "[e2e] Stopping process group PGID=$E2E_PGID..." + if [[ -n "${e2e_pgid:-}" ]]; then + echo "[e2e] Stopping process group PGID=$e2e_pgid..." # Try INT (like Ctrl‑C) then TERM then KILL for the whole group - for SIG in INT TERM KILL; do + for sig in INT TERM KILL; do # If nothing in the group, stop - if ! kill -0 "-$E2E_PGID" 2>/dev/null; then - echo "[e2e] Process group $E2E_PGID already gone." + if ! kill -0 "-$e2e_pgid" 2>/dev/null; then + echo "[e2e] Process group $e2e_pgid already gone." break fi - echo "[e2e] Sending $SIG to process group $E2E_PGID..." - kill "-$SIG" "-$E2E_PGID" 2>/dev/null || true + echo "[e2e] Sending $sig to process group $e2e_pgid..." + kill "-$sig" "-$e2e_pgid" 2>/dev/null || true sleep 3 done fi - # Final safety net: ensure nothing is listening on $RAILS_PORT + # Final safety net: ensure nothing is listening on $rails_port if command -v lsof >/dev/null 2>&1; then - PIDS="$(lsof -ti tcp:"$RAILS_PORT" || true)" - if [ -n "$PIDS" ]; then - echo "[e2e] Forcing kill of processes on port $RAILS_PORT: $PIDS" - kill -KILL $PIDS 2>/dev/null || true + pids="$(lsof -ti tcp:"$rails_port" || true)" + if [ -n "$pids" ]; then + echo "[e2e] Forcing kill of processes on port $rails_port: $pids" + kill -KILL $pids 2>/dev/null || true fi fi } @@ -182,10 +185,10 @@ trap cleanup EXIT INT TERM # Wait for Rails health -echo "[e2e] Waiting for Rails to become healthy on $HEALTH_CHECK_URL" +echo "[e2e] Waiting for Rails to become healthy on $health_check_url" for i in {1..10}; do printf "." - if curl -fsS "$HEALTH_CHECK_URL" > /dev/null 2>&1; then + if curl -fsS "$health_check_url" > /dev/null 2>&1; then printf "\n[e2e] Rails is up.\n" break fi @@ -201,13 +204,16 @@ done # Run pytest via uv -pushd "$MAVIS_TEST_REPO" > /dev/null +pushd "$mavis_test_repo" > /dev/null echo "[e2e] Running end-to-end tests:" -echo "[e2e] BASE_URL=\"http://localhost:${RAILS_PORT}\" uv run pytest ${PYTEST_ARGS[*]}" -BASE_URL="http://localhost:${RAILS_PORT}" uv run pytest -m "not accessibility and not reporting and not imms_api and not pds_api" "${PYTEST_ARGS[@]}" -PYTEST_EXIT_CODE=$? +echo " export RAILS_ENV=\"$RAILS_ENV\"" +echo " export REDIS_URL=\"$REDIS_URL\"" +echo " export BASE_URL=\"$BASE_URL\"" +echo " uv run pytest ${pytest_args[*]}" +uv run pytest "${pytest_args[@]}" +pytest_exit_code=$? popd > /dev/null -exit $PYTEST_EXIT_CODE +exit $pytest_exit_code diff --git a/bin/mavis-server b/bin/mavis-server new file mode 100755 index 0000000000..3634642e10 --- /dev/null +++ b/bin/mavis-server @@ -0,0 +1,11 @@ +#/usr/bin/env sh + +set -eu + +bin_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +root_dir=$(CDPATH= cd -- "$bin_dir/.." && pwd) + +export PYTHONPATH=$root_dir/python + +exec python -m mavis.server "$@" + diff --git a/config/credentials/production.yml.enc b/config/credentials/production.yml.enc index c9d1144352..077d32b6bd 100644 --- a/config/credentials/production.yml.enc +++ b/config/credentials/production.yml.enc @@ -1 +1 @@ -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--rZwc3FVWEZM7jZo/--xx7VN1jWj/3wu+YHcsZZSw== \ No newline at end of file +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--QdRBn1/NPtPNGMHb--sD6DsVLuhIq3ie3oiDi41A== \ No newline at end of file diff --git a/config/feature_flags.yml b/config/feature_flags.yml index a057b178d5..6616779802 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -39,10 +39,15 @@ import_choose_academic_year: >- import_concurrency_per_server: >- Controls whether to limit the concurrency of the import jobs to be global or to be per-server. -import_search_pds: Perform PDS lookups as part of the patient import processing. - ops_tools: Enable the operational support tools; timeline and graph. +pds: Main switch for PDS integration. Turning this off will disable all PDS-related features. + +pds_enqueue_bulk_updates: >- + Whether to enqueue jobs which updates a large number of patients with the data from PDS in bulk. + +pds_search_during_import: Perform PDS lookups as part of the patient import processing. + sync_national_reporting_to_imms_api: >- Sync immunisations records uploaded as part of national reporting to the NHS Immunisations API. diff --git a/config/locales/en.yml b/config/locales/en.yml index 6992aba908..cda81162f7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -334,13 +334,13 @@ en: blank: Choose a programme activerecord: attributes: - careplus_export: + careplus_report: statuses: pending: Pending sending: Sending sent: Sent failed: Failed - careplus_export_vaccination_record: + careplus_report_vaccination_record: change_types: created: Created updated: Updated @@ -518,7 +518,7 @@ en: taken: This batch expiry date already exists for this batch number number: blank: Enter a batch number - careplus_export: + careplus_report: attributes: academic_year: blank: Enter an academic year @@ -532,7 +532,7 @@ en: blank: Enter a scheduled date status: inclusion: Choose a status - careplus_export_vaccination_record: + careplus_report_vaccination_record: attributes: change_type: inclusion: Choose a change type diff --git a/config/locales/status.en.yml b/config/locales/status.en.yml index ad11dda474..bee1d1d732 100644 --- a/config/locales/status.en.yml +++ b/config/locales/status.en.yml @@ -19,6 +19,8 @@ en: not_required: No consent needed refused: Consent refused no_contact_details: No contact details + request_not_scheduled: Request not scheduled + request_scheduled: Request scheduled colour: conflicts: orange follow_up_requested: orange @@ -31,6 +33,8 @@ en: not_required: grey refused: red no_contact_details: grey + request_not_scheduled: grey + request_scheduled: blue patient_specific_direction: label: added: PSD added diff --git a/config/settings.yml b/config/settings.yml index 8e71f0744d..9d20599200 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -42,8 +42,6 @@ immunisations_api: rate_limit_per_second: 20 pds: - enabled: true - enqueue_bulk_updates: true raise_unknown_gp_practice: true rate_limit_per_second: 50 @@ -59,6 +57,9 @@ reporting_api: client_id: <%= Rails.application.credentials.reporting_api&.client_id %> secret: <%= Rails.application.credentials.reporting_api&.secret %> +careplus: + base_url: https://careplus.syhapp.thirdparty.nhs.uk + # Set a value to override the default values set in config/initializers/devise.rb devise: timeout_in_seconds: diff --git a/config/settings/development.yml b/config/settings/development.yml index a1fd0be5a3..6dae3bdd51 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -18,8 +18,6 @@ nhs_api: disable_authentication: true pds: - enabled: false - enqueue_bulk_updates: false rate_limit_per_second: 5 splunk: @@ -28,3 +26,6 @@ splunk: reporting_api: client_app: root_url: http://localhost:4001 + +careplus: + base_url: http://localhost:8080 diff --git a/config/settings/end_to_end.yml b/config/settings/end_to_end.yml index f99d4efeaf..a5176c2925 100644 --- a/config/settings/end_to_end.yml +++ b/config/settings/end_to_end.yml @@ -18,8 +18,6 @@ nhs_api: disable_authentication: true pds: - enabled: false - enqueue_bulk_updates: false rate_limit_per_second: 5 splunk: diff --git a/config/settings/staging.yml b/config/settings/staging.yml index 5ca301704c..94b6fe0dbf 100644 --- a/config/settings/staging.yml +++ b/config/settings/staging.yml @@ -17,3 +17,6 @@ cis2: pds: raise_unknown_gp_practice: false rate_limit_per_second: 5 + +careplus: + base_url: <%= ENV.fetch("MOCK_CAREPLUS_URL", nil) %> diff --git a/config/settings/test.yml b/config/settings/test.yml index 4f20095643..12d13fa569 100644 --- a/config/settings/test.yml +++ b/config/settings/test.yml @@ -24,3 +24,6 @@ splunk: reporting_api: client_app: root_url: http://localhost:5001/ + +careplus: + base_url: http://localhost:8080 diff --git a/db/data/20260421140000_add_ops_support_team_and_enable_ops_tools.rb b/db/data/20260421140000_add_ops_support_team_and_enable_ops_tools.rb new file mode 100644 index 0000000000..c4f2306ec3 --- /dev/null +++ b/db/data/20260421140000_add_ops_support_team_and_enable_ops_tools.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class AddOpsSupportTeamAndEnableOpsTools < ActiveRecord::Migration[8.1] + def up + Flipper.enable(:ops_tools) + + return if Team.exists?(workgroup: CIS2Info::SUPPORT_WORKGROUP) + + Rake::Task['ops_support:seed'].execute + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/db/data_schema.rb b/db/data_schema.rb index 352616a9fe..74db247d39 100644 --- a/db/data_schema.rb +++ b/db/data_schema.rb @@ -1 +1 @@ -DataMigrate::Data.define(version: 2026_03_31_200000) +DataMigrate::Data.define(version: 2026_04_21_140000) diff --git a/db/migrate/20260407121005_add_careplus_credentials_to_teams.rb b/db/migrate/20260407121005_add_careplus_credentials_to_teams.rb new file mode 100644 index 0000000000..1d0db72723 --- /dev/null +++ b/db/migrate/20260407121005_add_careplus_credentials_to_teams.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddCareplusCredentialsToTeams < ActiveRecord::Migration[8.1] + def change + change_table :teams, bulk: true do |t| + t.string :careplus_namespace + t.string :careplus_username + t.string :careplus_password + end + end +end diff --git a/db/migrate/20260419122121_rename_careplus_exports_to_reports.rb b/db/migrate/20260419122121_rename_careplus_exports_to_reports.rb new file mode 100644 index 0000000000..4a2389b945 --- /dev/null +++ b/db/migrate/20260419122121_rename_careplus_exports_to_reports.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class RenameCareplusExportsToReports < ActiveRecord::Migration[8.0] + def change + rename_table :careplus_exports, :careplus_reports + + rename_table :careplus_export_vaccination_records, + :careplus_report_vaccination_records + rename_column :careplus_report_vaccination_records, + :careplus_export_id, + :careplus_report_id + end +end diff --git a/db/migrate/20260420101139_add_more_foreign_key_cascade_for_patient.rb b/db/migrate/20260420101139_add_more_foreign_key_cascade_for_patient.rb new file mode 100644 index 0000000000..af2585eb77 --- /dev/null +++ b/db/migrate/20260420101139_add_more_foreign_key_cascade_for_patient.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +class AddMoreForeignKeyCascadeForPatient < ActiveRecord::Migration[8.1] + TABLES_TO_CASCADE = %w[ + notify_log_entries + school_move_log_entries + patient_merge_log_entries + pds_search_results + patient_programme_vaccinations_searches + ].freeze + + def change + TABLES_TO_CASCADE.each do |table| + remove_foreign_key table, "patients" + add_foreign_key table, "patients", on_delete: :cascade, validate: false + end + + remove_foreign_key "access_log_entries", "patients" + end +end diff --git a/db/schema.rb b/db/schema.rb index dab53c97cd..6c33e2c2ee 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2026_04_07_120000) do +ActiveRecord::Schema[8.1].define(version: 2026_04_20_101139) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -102,17 +102,17 @@ t.index ["vaccine_id"], name: "index_batches_on_vaccine_id" end - create_table "careplus_export_vaccination_records", primary_key: ["careplus_export_id", "vaccination_record_id"], force: :cascade do |t| - t.bigint "careplus_export_id", null: false + create_table "careplus_report_vaccination_records", primary_key: ["careplus_report_id", "vaccination_record_id"], force: :cascade do |t| + t.bigint "careplus_report_id", null: false t.integer "change_type", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false t.bigint "vaccination_record_id", null: false - t.index ["careplus_export_id"], name: "idx_on_careplus_export_id_8ce4ed1ff0" - t.index ["vaccination_record_id"], name: "idx_on_vaccination_record_id_d4c93aefb7" + t.index ["careplus_report_id"], name: "idx_on_careplus_report_id_98876049c7" + t.index ["vaccination_record_id"], name: "idx_on_vaccination_record_id_e7f05454ab" end - create_table "careplus_exports", force: :cascade do |t| + create_table "careplus_reports", force: :cascade do |t| t.integer "academic_year", null: false t.datetime "created_at", null: false t.text "csv_data" @@ -126,10 +126,10 @@ t.integer "status", default: 0, null: false t.bigint "team_id", null: false t.datetime "updated_at", null: false - t.index ["programme_types"], name: "index_careplus_exports_on_programme_types", using: :gin - t.index ["status", "scheduled_at"], name: "index_careplus_exports_on_status_and_scheduled_at" - t.index ["team_id", "academic_year"], name: "index_careplus_exports_on_team_id_and_academic_year" - t.index ["team_id"], name: "index_careplus_exports_on_team_id" + t.index ["programme_types"], name: "index_careplus_reports_on_programme_types", using: :gin + t.index ["status", "scheduled_at"], name: "index_careplus_reports_on_status_and_scheduled_at" + t.index ["team_id", "academic_year"], name: "index_careplus_reports_on_team_id_and_academic_year" + t.index ["team_id"], name: "index_careplus_reports_on_team_id" end create_table "class_imports", force: :cascade do |t| @@ -904,8 +904,11 @@ end create_table "teams", force: :cascade do |t| + t.string "careplus_namespace" + t.string "careplus_password" t.string "careplus_staff_code" t.string "careplus_staff_type" + t.string "careplus_username" t.string "careplus_venue_code" t.datetime "created_at", null: false t.integer "days_before_consent_reminders", default: 7, null: false @@ -1075,7 +1078,6 @@ t.index ["upload_name"], name: "index_vaccines_on_upload_name", unique: true end - add_foreign_key "access_log_entries", "patients" add_foreign_key "access_log_entries", "users" add_foreign_key "archive_reasons", "patients" add_foreign_key "archive_reasons", "teams" @@ -1084,9 +1086,9 @@ add_foreign_key "attendance_records", "patients" add_foreign_key "batches", "teams" add_foreign_key "batches", "vaccines" - add_foreign_key "careplus_export_vaccination_records", "careplus_exports", on_delete: :cascade - add_foreign_key "careplus_export_vaccination_records", "vaccination_records" - add_foreign_key "careplus_exports", "teams" + add_foreign_key "careplus_report_vaccination_records", "careplus_reports", on_delete: :cascade + add_foreign_key "careplus_report_vaccination_records", "vaccination_records" + add_foreign_key "careplus_reports", "teams" add_foreign_key "class_imports", "locations" add_foreign_key "class_imports", "teams" add_foreign_key "class_imports", "users", column: "uploaded_by_user_id" @@ -1148,7 +1150,7 @@ add_foreign_key "notes", "users", column: "created_by_user_id" add_foreign_key "notify_log_entries", "consent_forms" add_foreign_key "notify_log_entries", "parents", on_delete: :nullify - add_foreign_key "notify_log_entries", "patients" + add_foreign_key "notify_log_entries", "patients", on_delete: :cascade, validate: false add_foreign_key "notify_log_entries", "users", column: "sent_by_user_id" add_foreign_key "notify_log_entry_programmes", "notify_log_entries", on_delete: :cascade add_foreign_key "parent_relationships", "parents" @@ -1157,10 +1159,10 @@ add_foreign_key "patient_changesets", "patients" add_foreign_key "patient_locations", "locations" add_foreign_key "patient_locations", "patients" - add_foreign_key "patient_merge_log_entries", "patients" + add_foreign_key "patient_merge_log_entries", "patients", on_delete: :cascade, validate: false add_foreign_key "patient_merge_log_entries", "users" add_foreign_key "patient_programme_statuses", "patients", on_delete: :cascade - add_foreign_key "patient_programme_vaccinations_searches", "patients" + add_foreign_key "patient_programme_vaccinations_searches", "patients", on_delete: :cascade, validate: false add_foreign_key "patient_registration_statuses", "patients", on_delete: :cascade add_foreign_key "patient_registration_statuses", "sessions", on_delete: :cascade add_foreign_key "patient_specific_directions", "patients" @@ -1171,13 +1173,13 @@ add_foreign_key "patient_teams", "teams", on_delete: :cascade add_foreign_key "patients", "locations", column: "gp_practice_id" add_foreign_key "patients", "locations", column: "school_id" - add_foreign_key "pds_search_results", "patients" + add_foreign_key "pds_search_results", "patients", on_delete: :cascade, validate: false add_foreign_key "pre_screenings", "locations" add_foreign_key "pre_screenings", "patients" add_foreign_key "pre_screenings", "users", column: "performed_by_user_id" add_foreign_key "reporting_api_one_time_tokens", "users" add_foreign_key "school_move_log_entries", "locations", column: "school_id" - add_foreign_key "school_move_log_entries", "patients" + add_foreign_key "school_move_log_entries", "patients", on_delete: :cascade, validate: false add_foreign_key "school_move_log_entries", "teams" add_foreign_key "school_move_log_entries", "users" add_foreign_key "school_moves", "locations", column: "school_id" diff --git a/db/seeds.rb b/db/seeds.rb index dfb3346afa..2b9420a501 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -66,7 +66,14 @@ def create_community_clinics(team) FactoryBot.create_list(:community_clinic, 5, team:) end -def create_session(user, team, programmes:, completed: false, year_groups: nil) +def create_session( + user, + team, + programmes:, + completed: false, + in_the_future: false, + year_groups: nil +) year_groups ||= programmes.flat_map(&:default_year_groups).uniq Batch.import!( @@ -79,7 +86,14 @@ def create_session(user, team, programmes:, completed: false, year_groups: nil) location = FactoryBot.create(:gias_school, team:, gias_year_groups: year_groups) - date = completed ? 1.week.ago.to_date : Date.current + date = + if completed + 1.week.ago.to_date + elsif in_the_future + 1.month.from_now.to_date + else + Date.current + end academic_year = AcademicYear.current @@ -146,13 +160,26 @@ def create_session(user, team, programmes:, completed: false, year_groups: nil) traits << :partially_vaccinated_triage_needed if programme.td_ipv? traits.each do |trait| + patient = + FactoryBot.create( + :patient, + trait, + programmes: [programme], + session:, + performed_by: user, + year_group:, + parents: [FactoryBot.create(:parent)] + ) + + next if in_the_future + FactoryBot.create( - :patient, - trait, - programmes: [programme], + :consent_notification, + :request, + patient:, session:, - performed_by: user, - year_group: + programmes: [programme], + sent_at: session.send_consent_requests_at ) end end @@ -232,12 +259,14 @@ def create_team_sessions(user, team) td_ipv = Programme.td_ipv # Flu-only sessions - create_session(user, team, programmes: [flu], completed: false) - create_session(user, team, programmes: [hpv], completed: true) + create_session(user, team, programmes: [flu]) + create_session(user, team, programmes: [flu], completed: true) + create_session(user, team, programmes: [flu], in_the_future: true) # HPV-only sessions - create_session(user, team, programmes: [hpv], completed: false) + create_session(user, team, programmes: [hpv]) create_session(user, team, programmes: [hpv], completed: true) + create_session(user, team, programmes: [hpv], in_the_future: true) # MenACWY and Td/IPV combined sessions create_session( diff --git a/docs/aws-setup.md b/docs/aws-setup.md index 856cd95825..38004c1e32 100644 --- a/docs/aws-setup.md +++ b/docs/aws-setup.md @@ -35,4 +35,4 @@ sso_registration_scopes = sso:account:access - Run `aws configure sso`. This will prompt you to log in to your AWS account and grant the necessary permissions for the CLI to access AWS services. When prompted for a region enter `eu-west-2` and for output format enter `json`. - Install the Session Manager plugin for the AWS CLI by following the instructions in the [AWS Systems Manager Session Manager documentation](https://docs.aws.amazon.com/systems-manager/latest/userguide/session-manager-working-with-install-plugin.html). - Run `aws sso login` to log in to your AWS account and establish a session. This will allow you to access AWS resources using the CLI. -- You should now be able to shell into a running service. The simplest way to do this is using the `script/shell.sh` script, e.g. `script/shell.sh qa`. +- You should now be able to shell into a running service. The simplest way to do this is using the `bin/mavis-server shell` command, e.g. `bin/mavis-server shell qa`. diff --git a/docs/managing-teams.md b/docs/managing-teams.md index 2af18b8fa4..0612724746 100644 --- a/docs/managing-teams.md +++ b/docs/managing-teams.md @@ -22,6 +22,9 @@ team: careplus_staff_code: # Staff code used in CarePlus exports careplus_staff_type: # Staff type used in CarePlus exports careplus_venue_code: # Venue code used in CarePlus exports + careplus_namespace: # Optional namespace for the CarePlus web service + careplus_username: # Optional username for the CarePlus web service + careplus_password: # Optional password for the CarePlus web service privacy_notice_url: # URL of a privacy notice shown to parents privacy_policy_url: # URL of a privacy policy shown to parents reply_to_id: # Optional GOV.UK Notify Reply-To UUID diff --git a/lib/tasks/ops_support.rake b/lib/tasks/ops_support.rake index faee6d1216..ec037469a4 100644 --- a/lib/tasks/ops_support.rake +++ b/lib/tasks/ops_support.rake @@ -8,15 +8,12 @@ namespace :ops_support do Team.find_or_create_by!( organisation:, + type: :support, name: "Operational Support Team", workgroup: CIS2Info::SUPPORT_WORKGROUP, - careplus_venue_code: "XXX", - email: "england.mavis@nhs.net", - phone: "01234 567890", - privacy_notice_url: "https://www.example.com/privacy", - privacy_policy_url: "https://www.example.com/privacy", days_before_consent_reminders: 0, - days_before_consent_requests: 0 + days_before_consent_requests: 0, + programmes: [] ) end end diff --git a/package.json b/package.json index 5d0fabd8c9..40befe49a5 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "jest-environment-jsdom": "^30.3.0", "jest-fetch-mock": "^3.0.3", "officecrypto-tool": "^0.0.19", - "prettier": "^3.8.1", + "prettier": "^3.8.3", "stylelint": "^16.26.1", "stylelint-config-gds": "^2.0.0", "stylelint-order": "^8.1.1" diff --git a/python/mavis/__init__.py b/python/mavis/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/mavis/server/__init__.py b/python/mavis/server/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/python/mavis/server/__main__.py b/python/mavis/server/__main__.py new file mode 100644 index 0000000000..9ae637f13c --- /dev/null +++ b/python/mavis/server/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() diff --git a/python/mavis/server/aws.py b/python/mavis/server/aws.py new file mode 100644 index 0000000000..f31e04330c --- /dev/null +++ b/python/mavis/server/aws.py @@ -0,0 +1,194 @@ +import json +import subprocess + +from .helpers import run_command + +REGION = "eu-west-2" +PRODUCTION_ENVS = {"production", "production-data-replication"} + + +def cluster(env): + return f"mavis-{env}" + + +def s3_bucket(env): + if env in PRODUCTION_ENVS: + return "mavis-filetransfer-production" + return "mavis-filetransfer-development" + + +def ensure_authenticated(exit_without_login=False): + """Check AWS auth; attempt SSO login if needed.""" + result = subprocess.run( + ["aws", "sts", "get-caller-identity"], + capture_output=True, + ) + if result.returncode == 0: + return + if exit_without_login: + raise RuntimeError( + "Not authenticated with AWS. Run 'aws sso login' and try again." + ) + print("Not authenticated with AWS. Attempting SSO login...") + login = subprocess.run(["aws", "sso", "login"]) + if login.returncode != 0: + raise RuntimeError("AWS SSO login failed.") + recheck = subprocess.run( + ["aws", "sts", "get-caller-identity"], + capture_output=True, + ) + if recheck.returncode != 0: + raise RuntimeError("Still not authenticated after SSO login.") + + +def aws_json(*cmd): + """Run an AWS CLI command and return parsed JSON output.""" + result = subprocess.run(["aws", *cmd], capture_output=True, text=True) + if result.returncode != 0: + raise RuntimeError(f"aws {' '.join(cmd)}:\n{result.stderr.strip()}") + return json.loads(result.stdout) + + +def resolve_task(env, task_id=None, task_ip=None, service=None): + """ + Resolve to (short_task_id, container_name). Three mutually exclusive modes: + + - task_id — validate the specific task is running + - task_ip — search all running tasks in the cluster for a matching IP + - service — return the first running task in the service; defaults to + mavis-{env}-ops, or mavis-{env}-web for data-replication envs + """ + cl = cluster(env) + + if task_id: + tasks = aws_json( + "ecs", + "describe-tasks", + "--region", + REGION, + "--cluster", + cl, + "--tasks", + task_id, + ).get("tasks", []) + if not tasks or tasks[0]["lastStatus"] != "RUNNING": + raise RuntimeError(f"Task {task_id} is not running in cluster {cl}") + return task_id, _application_container(tasks[0]) + + if task_ip: + task_arns = aws_json( + "ecs", + "list-tasks", + "--region", + REGION, + "--cluster", + cl, + "--desired-status", + "RUNNING", + ).get("taskArns", []) + if not task_arns: + raise RuntimeError(f"No running tasks found in cluster {cl}") + tasks = aws_json( + "ecs", + "describe-tasks", + "--region", + REGION, + "--cluster", + cl, + "--tasks", + *task_arns, + ).get("tasks", []) + for task in tasks: + if _task_private_ip(task) == task_ip: + return _short_id(task), _application_container(task) + raise RuntimeError(f"No running task with IP {task_ip} found in cluster {cl}") + + if not service: + service = _default_service(env) + + task_arns = aws_json( + "ecs", + "list-tasks", + "--region", + REGION, + "--cluster", + cl, + "--service-name", + service, + "--desired-status", + "RUNNING", + ).get("taskArns", []) + if not task_arns: + raise RuntimeError(f"No running tasks found in service {service}") + tasks = aws_json( + "ecs", + "describe-tasks", + "--region", + REGION, + "--cluster", + cl, + "--tasks", + *task_arns, + ).get("tasks", []) + for task in tasks: + container = _application_container(task) + if container: + return _short_id(task), container + raise RuntimeError( + f"No running tasks with an application container found in service {service}" + ) + + +def run_remote_command( + env, task_id, remote_command, container=None, replace_process=False +): + """Execute a command in an ECS task, returning the exit code.""" + command = [ + "aws", + "ecs", + "execute-command", + "--region", + REGION, + "--cluster", + cluster(env), + "--task", + task_id, + "--command", + remote_command, + "--interactive", + ] + if container: + command += ["--container", container] + return run_command(command, replace_process=replace_process) + + +# --- private helpers --- + + +def _default_service(env): + if env.endswith("data-replication"): + return f"mavis-{env}" + return f"mavis-{env}-ops" + + +def _short_id(task): + return task["taskArn"].split("/")[-1] + + +def _application_container(task): + for c in task.get("containers", []): + if ( + c.get("name") == "application" + and c.get("lastStatus") == "RUNNING" + and c.get("runtimeId") + ): + return c["name"] + return None + + +def _task_private_ip(task): + for attachment in task.get("attachments", []): + for detail in attachment.get("details", []): + if detail.get("name") == "privateIPv4Address": + return detail.get("value") + return None diff --git a/python/mavis/server/cli.py b/python/mavis/server/cli.py new file mode 100644 index 0000000000..3ae804d775 --- /dev/null +++ b/python/mavis/server/cli.py @@ -0,0 +1,19 @@ +import argparse + +from . import get_file, put_file, shell + + +def main(): + parser = argparse.ArgumentParser( + prog="mavis-server", + description="MAVIS server management CLI", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + get_file.register(subparsers) + put_file.register(subparsers) + shell.register(subparsers) + + args = parser.parse_args() + # TODO: Clean this error reporting up + args.func(args) diff --git a/python/mavis/server/get_file.py b/python/mavis/server/get_file.py new file mode 100644 index 0000000000..961d9472c7 --- /dev/null +++ b/python/mavis/server/get_file.py @@ -0,0 +1,96 @@ +import os +import secrets +import sys + +from . import aws +from .helpers import confirm_production, run_command + + +def register(subparsers): + parser = subparsers.add_parser( + "get-file", + help="Download a file from an ECS container to local", + description=( + "Download a file from inside an ECS container to a local path, " + "using S3 as an intermediary. The S3 object is always cleaned up." + ), + ) + parser.add_argument("env", help="Environment name (cluster will be mavis-ENV)") + parser.add_argument("remote_path", help="Path of the file inside the container") + parser.add_argument( + "local_path", + nargs="?", + default=None, + help="Local destination (file or directory). Defaults to tmp in the project root.", + ) + parser.add_argument("--service", help="Override the ECS service name") + parser.add_argument( + "--task-id", dest="task_id", help="Connect to a specific task by ID" + ) + parser.add_argument( + "--task-ip", + dest="task_ip", + help="Connect to a task by its private IPv4 address", + ) + parser.add_argument( + "-x", + "--exit-without-login", + dest="exit_without_login", + action="store_true", + help="Exit instead of prompting for AWS SSO login", + ) + parser.set_defaults(func=run) + + +def run(args): + env = args.env + + confirm_production(env) + aws.ensure_authenticated(exit_without_login=args.exit_without_login) + + task_id, container = aws.resolve_task( + env, task_id=args.task_id, task_ip=args.task_ip, service=args.service + ) + bucket = aws.s3_bucket(env) + key = f"temp-{secrets.token_hex(8)}" + s3_uri = f"s3://{bucket}/{key}" + + local_dest = _local_destination(args.remote_path, args.local_path) + + try: + upload_result = aws.run_remote_command( + env, + task_id, + f"aws s3 cp {args.remote_path} {s3_uri} --region {aws.REGION}", + container=container, + ) + if not upload_result: + sys.exit("Error: Failed to copy file from container to S3") + + download_result = run_command( + ["aws", "s3", "cp", s3_uri, local_dest, "--region", aws.REGION] + ) + if not download_result: + sys.exit("Error: Download from S3 failed with code") + finally: + run_command( + ["aws", "s3", "rm", s3_uri, "--region", aws.REGION], + ) + + print(f"File successfully downloaded to {local_dest}") + + +def _local_destination(remote_path, local_path): + """ + Resolve the local download destination. + + If local_path is given and is an existing directory, save as + /. If local_path is a file path + (or doesn't exist yet), use it as-is. Defaults to ./. + """ + filename = os.path.basename(remote_path.rstrip("/")) + if local_path is None: + return os.path.join(".", filename) + if os.path.isdir(local_path): + return os.path.join(local_path, filename) + return local_path diff --git a/python/mavis/server/helpers.py b/python/mavis/server/helpers.py new file mode 100644 index 0000000000..096cf7e828 --- /dev/null +++ b/python/mavis/server/helpers.py @@ -0,0 +1,26 @@ +import os +import sys +import subprocess + + +def confirm_production(env): + """Prompt for confirmation before operating on production.""" + if env != "production": + return + print("Warning: You are about to operate on PRODUCTION (not data-replication).") + answer = input("Type 'production' to continue: ").strip() + if answer != "production": + raise RuntimeError("Production confirmation failed") + + +def run_command(cmd, replace_process=False): + """Run command and print an error if it fails.""" + if replace_process: + os.execvp(cmd[0], cmd) + return_code = subprocess.run(cmd).returncode + if return_code != 0: + print( + f"Command failed with exit code '{return_code}':\n {' '.join(cmd)}", + file=sys.stderr, + ) + return return_code == 0 diff --git a/python/mavis/server/put_file.py b/python/mavis/server/put_file.py new file mode 100644 index 0000000000..73af567a2b --- /dev/null +++ b/python/mavis/server/put_file.py @@ -0,0 +1,83 @@ +import os +import secrets +import sys + +from . import aws +from .helpers import confirm_production, run_command + + +def register(subparsers): + parser = subparsers.add_parser( + "put-file", + help="Upload a local file to an ECS container", + description=( + "Upload a local file to a path inside an ECS container, " + "using S3 as an intermediary. The S3 object is always cleaned up." + ), + ) + parser.add_argument("env", help="Environment name (cluster will be mavis-ENV)") + parser.add_argument("local_file", help="Path to the local file to upload") + parser.add_argument( + "remote_path", + nargs="?", + default=None, + help="Destination path inside the container (defaults to /tmp/)", + ) + parser.add_argument("--service", help="Override the ECS service name") + parser.add_argument( + "--task-id", dest="task_id", help="Connect to a specific task by ID" + ) + parser.add_argument( + "--task-ip", + dest="task_ip", + help="Connect to a task by its private IPv4 address", + ) + parser.add_argument( + "-x", + "--exit-without-login", + dest="exit_without_login", + action="store_true", + help="Exit instead of prompting for AWS SSO login", + ) + parser.set_defaults(func=run) + + +def run(args): + env = args.env + + if not os.path.isfile(args.local_file): + sys.exit(f"Error: Local file not found: {args.local_file}") + + confirm_production(env) + aws.ensure_authenticated(exit_without_login=args.exit_without_login) + + remote_path = args.remote_path or f"/tmp/{os.path.basename(args.local_file)}" + + task_id, container = aws.resolve_task( + env, task_id=args.task_id, task_ip=args.task_ip, service=args.service + ) + bucket = aws.s3_bucket(env) + key = f"temp-{secrets.token_hex(8)}" + s3_uri = f"s3://{bucket}/{key}" + + upload_result = run_command( + ["aws", "s3", "cp", args.local_file, s3_uri, "--region", aws.REGION] + ) + if not upload_result != 0: + sys.exit("Error: Upload to S3 failed with code") + + try: + download_result = aws.run_remote_command( + env, + task_id, + f"aws s3 cp {s3_uri} {remote_path} --region {aws.REGION}", + container=container, + ) + finally: + run_command( + ["aws", "s3", "rm", s3_uri, "--region", aws.REGION], + ) + + if not download_result: + sys.exit("Error: Failed to copy file into container") + print("File successfully uploaded to container") diff --git a/python/mavis/server/shell.py b/python/mavis/server/shell.py new file mode 100644 index 0000000000..7aab0d417e --- /dev/null +++ b/python/mavis/server/shell.py @@ -0,0 +1,59 @@ +import sys + +from . import aws +from .helpers import confirm_production, run_command + + +def register(subparsers): + parser = subparsers.add_parser( + "shell", + help="Open an interactive shell in an ECS container", + description="Open an interactive bash shell in an ECS container.", + ) + parser.add_argument("env", help="Environment name (cluster will be mavis-ENV)") + parser.add_argument("--service", help="Override the ECS service name") + parser.add_argument( + "--task-id", dest="task_id", help="Connect to a specific task by ID" + ) + parser.add_argument( + "--task-ip", + dest="task_ip", + help="Connect to a task by its private IPv4 address", + ) + parser.add_argument( + "-x", + "--exit-without-login", + dest="exit_without_login", + action="store_true", + help="Exit instead of prompting for AWS SSO login", + ) + parser.set_defaults(func=run) + + +def run(args): + env = args.env + + confirm_production(env) + aws.ensure_authenticated(exit_without_login=args.exit_without_login) + + task_id, container = aws.resolve_task( + env, + task_id=args.task_id, + task_ip=args.task_ip, + service=args.service, + ) + + if not container: + sys.exit(f"Error: No running 'application' container found in task {task_id}") + + print( + f"Opening shell in task {task_id}" + + (f" (service {args.service})" if args.service else "") + ) + aws.run_remote_command( + env, + task_id, + "/rails/bin/docker-entrypoint /bin/bash", + container=container, + replace_process=True, + ) diff --git a/script/copy-file.sh b/script/copy-file.sh deleted file mode 100755 index f4b89be6c4..0000000000 --- a/script/copy-file.sh +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env bash - -usage() { - echo "Usage: $0 [OPTIONS] ENV LOCAL_FILE REMOTE_PATH" - echo "" - echo "Copy a local file to an ECS container via S3" - echo "" - echo "Arguments:" - echo " ENV Environment (cluster will be mavis-ENV)" - echo " LOCAL_FILE Path to local file to copy" - echo " REMOTE_PATH Destination path in container" - echo "" - echo "Options:" - echo " --task-id Task ID" - echo " --help Display this help message" - echo "" - echo "Examples:" - echo " $0 dev ./config.yml /tmp/config.yml" - echo " $0 production-data-replication --task-id abc123 ./example.txt example.txt" -} - -list_running_tasks() { - local service_name="$1" - if [ -n "$service_name" ]; then - aws ecs list-tasks --region "$region" --cluster "$cluster_name" --service-name "$service_name" --desired-status RUNNING | jq -r '.taskArns[]' - else - aws ecs list-tasks --region "$region" --cluster "$cluster_name" --desired-status RUNNING | jq -r '.taskArns[]' - fi -} - -if [ "$1" = "--help" ] || [ "$1" = "-h" ]; then - usage - exit 0 -fi - -region="eu-west-2" -env="" -service_name="" -task_id="" -local_file="" -remote_path="" - -# Parse arguments -while [[ $# -gt 0 ]]; do - case "$1" in - --task-id) - task_id="$2" - shift 2 - ;; - -h|--help) - usage - exit 0 - ;; - -*) - echo "Error: Invalid option $1" - usage - exit 1 - ;; - *) - if [ -z "$env" ]; then - env="$1" - elif [ -z "$local_file" ]; then - local_file="$1" - elif [ -z "$remote_path" ]; then - remote_path="$1" - else - echo "Error: Too many arguments" - usage - exit 1 - fi - shift - ;; - esac -done - -if [ -z "$env" ] || [ -z "$local_file" ] || [ -z "$remote_path" ]; then - echo "Error: Missing required arguments (ENV, LOCAL_FILE, REMOTE_PATH)" - usage - exit 1 -fi - -cluster_name="mavis-$env" -if [[ $env == 'production' || $env == 'production-data-replication' ]]; then - bucket_name="mavis-filetransfer-production" -else - bucket_name="mavis-filetransfer-development" -fi - -if [[ $task_id == "" && ($env == "qa" || $env == "production") ]]; then - echo "Copying file to ops service task." - service_name="mavis-$env-ops" - task_id=$(list_running_tasks "$service_name" | head -n 1 | awk -F'/' '{print $NF}') -elif [[ $task_id == "" && $env == "production-data-replication" ]]; then - echo "Copying file to data replication task." - service_name="mavis-production-data-replication" - task_id=$(list_running_tasks "$service_name" | head -n 1 | awk -F'/' '{print $NF}') -elif [[ $task_id == "" ]]; then - echo "ERROR Task ID not provided" - exit 1; -fi - -# Generate unique identifier for the S3 object to avoid conflicts -unique_id="temp-$RANDOM" - -echo "Uploading to S3 bucket: s3://$bucket_name/$unique_id" -aws s3 cp "$local_file" "s3://$bucket_name/$unique_id" -if [[ $? -ne 0 ]]; then - echo "Error: Failed to upload file to S3" - exit 1 -fi - -echo "Downloading from S3 to task $task_id and path: $remote_path " -aws ecs execute-command \ - --region "$region" \ - --cluster "$cluster_name" \ - --task "$task_id" \ - --command "aws s3 cp s3://$bucket_name/$unique_id $remote_path" \ - --interactive - -copy_exit_code=$? - -echo "Cleaning up S3 object" -aws s3 rm "s3://$bucket_name/$unique_id" --region "$region" &>/dev/null - -if [[ $copy_exit_code -eq 0 ]]; then - echo "" - echo "File successfully copied to container" -else - echo "" - echo "Error: Failed to copy file to container" - exit 1 -fi diff --git a/script/shell.sh b/script/shell.sh deleted file mode 100755 index d6cb0a3930..0000000000 --- a/script/shell.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bash - -usage() { - echo "Usage: $0 [--service SERVICE_NAME] [--task-id TASK_ID] [--task-ip TASK_IP] ENV" - echo "Options:" - echo " ENV Specify the environment (cluster will be mavis-ENV)" - echo " --service SERVICE_NAME Specify the service name (optional): Ignored if using --task-id or --task-ip" - echo " --task-id TASK_ID Specify the task ID directly (optional)" - echo " --task-ip TASK_IP Specify the task by its IP address (optional): Ignored if using --task-id" - echo " --help Display this help message" -} - -list_running_tasks() { - local service_name="$1" - if [ -n "$service_name" ]; then - aws ecs list-tasks --region "$region" --cluster "$cluster_name" --service-name "$service_name" --desired-status RUNNING | jq -r '.taskArns[]' - else - aws ecs list-tasks --region "$region" --cluster "$cluster_name" --desired-status RUNNING | jq -r '.taskArns[]' - fi -} - -describe_tasks() { - local task_arns="$1" - aws ecs describe-tasks --region "$region" --cluster "$cluster_name" --tasks $task_arns -} - -select_running_container() { - local task_data="$1" - echo "$task_data" | jq -r '.containers | map(select(.lastStatus == "RUNNING" and .name == "application"))[0].name' -} - -if [ "$1" = "--help" ]; then - usage - exit 0 -fi - -if [ $# -lt 1 ]; then - echo "Error: Environment is required" - usage - exit 1 -fi - -region="eu-west-2" -service_name="" -task_id="" -task_ip="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --service) - service_name="$2" - shift 2 - ;; - --task-id) - task_id="$2" - shift 2 - ;; - --task-ip) - task_ip="$2" - shift 2 - ;; - --exit-without-login|-x) - exit_without_login=true - shift - ;; - -h|--help) - usage - exit 0 - ;; - -*) - echo "Error: Invalid option $1" - usage - exit 1 - ;; - *) - env="$1" - shift - ;; - esac -done - -if [ -z "$env" ]; then - echo "Error: Environment cannot be empty" - usage - exit 1 -fi -if [ "$env" == "production" ]; then - echo "You are trying to shell into a production container NOT Data-Replication. If you wish to proceed type 'production':" - read -r confirm - if [ "$confirm" != "production" ]; then - echo "Validation failed. Exiting without shelling into production container." - exit 1 - fi -fi -#Check if env string ends with `data-replication` -if [ -z "$service_name" ] && [[ "$env" != *data-replication ]]; then - if [ "$env" == "qa" ] || [ "$env" == "production" ]; then - service_name="mavis-$env-ops" - else - service_name="mavis-$env-web" - fi -fi - -cluster_name="mavis-$env" - -aws sts get-caller-identity &>/dev/null -if [[ $? -ne 0 ]]; then - if [[ -z "$exit_without_login" ]]; then - aws sso login - if [[ $? -ne 0 ]]; then - echo "Error: AWS CLI SSO login failed. Please log in to your AWS account." - exit 1 - fi - else - echo "Error: AWS SSO login required. Please log in to your AWS account using 'aws sso login'." - exit 1 - fi -fi - -if [ -n "$task_id" ]; then - task_description=$(aws ecs describe-tasks --region "$region" --cluster "$cluster_name" --task "$task_id") - if [ -z "$task_description" ] || echo "$task_description" | jq -e '.tasks | length == 0' > /dev/null; then - echo "Task $task_id not found in cluster $cluster_name" - exit 1 - fi - task_status=$(echo "$task_description" | jq -r '.tasks[0].lastStatus') - if [ "$task_status" != "RUNNING" ]; then - echo "Task $task_id is not running (status: $task_status)" - exit 1 - fi - container_name=$(select_running_container "$(echo "$task_description" | jq '.tasks[0]')") - if [ -z "$container_name" ] || [ "$container_name" = "null" ]; then - echo "No running containers with valid runtimeId found in task $task_id" - exit 1 - fi -elif [ -n "$task_ip" ]; then - task_arns=$(list_running_tasks "$service_name") - if [ -z "$task_arns" ]; then - echo "No running tasks found in cluster $cluster_name" $([ -n "$service_name" ] && echo "for service $service_name") - exit 1 - fi - tasks_description=$(describe_tasks "$task_arns") - if [ -z "$tasks_description" ]; then - echo "Failed to describe tasks in cluster $cluster_name" - exit 1 - fi - task_id=$(echo "$tasks_description" | jq -r '.tasks[] | select(.attachments[]?.details[]? | select(.name=="privateIPv4Address") | .value == "'"$task_ip"'") | .taskArn | split("/") | .[-1]' | head -n1) - if [ -z "$task_id" ]; then - echo "No running task found with IP $task_ip in cluster $cluster_name" $([ -n "$service_name" ] && echo "for service $service_name") - exit 1 - fi - task_description=$(echo "$tasks_description" | jq '.tasks[] | select(.taskArn | endswith("'"$task_id"'"))') - container_name=$(select_running_container "$task_description") - if [ -z "$container_name" ] || [ "$container_name" = "null" ]; then - echo "No running containers with valid runtimeId found in task $task_id" - exit 1 - fi -else - task_arns=$(list_running_tasks "$service_name") - if [ -z "$task_arns" ]; then - echo "No running tasks found in cluster $cluster_name" $([ -n "$service_name" ] && echo "for service $service_name") - exit 1 - fi - tasks_description=$(describe_tasks "$task_arns") - if [ -z "$tasks_description" ]; then - echo "Failed to describe tasks in cluster $cluster_name" - exit 1 - fi - selected_task=$(echo "$tasks_description" | jq '.tasks | map(select(.containers | map(.lastStatus == "RUNNING" and .runtimeId != null) | any)) | .[0]') - if [ -z "$selected_task" ] || [ "$selected_task" = "null" ]; then - echo "No running tasks with running containers with valid runtimeId found in cluster $cluster_name" $([ -n "$service_name" ] && echo "for service $service_name") - exit 1 - fi - task_id=$(echo "$selected_task" | jq -r '.taskArn | split("/") | .[-1]') - container_name=$(select_running_container "$selected_task") -fi - -echo "Opening an interactive shell in task $task_id" of service "$service_name" -aws ecs execute-command --region "$region" \ - --cluster "$cluster_name" \ - --task "$task_id" \ - --container "$container_name" \ - --command "/rails/bin/docker-entrypoint /bin/bash" \ - --interactive diff --git a/spec/components/app_gillick_assessment_component_spec.rb b/spec/components/app_gillick_assessment_component_spec.rb index 8c0379a097..c00f7c39f7 100644 --- a/spec/components/app_gillick_assessment_component_spec.rb +++ b/spec/components/app_gillick_assessment_component_spec.rb @@ -15,13 +15,25 @@ let(:patient) { create(:patient) } let(:session) { create(:session, :today, programmes:) } - before { create(:gillick_assessment, :competent, patient:, session:) } + let(:date) { Date.current } + + before do + create(:gillick_assessment, :competent, patient:, session:, date:) + end context "with a nurse user" do before { stub_authorization(allowed: true) } - it { should have_link("Edit Gillick competence") } it { should have_heading("Gillick assessment") } + it { should have_link("Edit Gillick competence") } + it { should have_content("Child assessed as Gillick competent") } + + context "when the assessment is for a different day" do + let(:date) { Date.yesterday } + + it { should have_link("Assess Gillick competence") } + it { should_not have_content("Child assessed as Gillick competent") } + end end context "with an admin user" do diff --git a/spec/components/app_patient_session_consent_component_spec.rb b/spec/components/app_patient_session_consent_component_spec.rb index 41f470efc5..052f4cd88e 100644 --- a/spec/components/app_patient_session_consent_component_spec.rb +++ b/spec/components/app_patient_session_consent_component_spec.rb @@ -15,7 +15,7 @@ it { should_not have_content(/Consent (given|refused)/) } it { should_not have_css("details", text: /Consent (given|refused) by/) } it { should_not have_css("details", text: "Responses to health questions") } - it { should have_css("p", text: "No requests have been sent.") } + it { should have_css("p", text: "No consent request is scheduled") } it { should have_css("button", text: "Record a new consent response") } context "when session is not in progress" do diff --git a/spec/components/app_patient_session_programme_component_spec.rb b/spec/components/app_patient_session_programme_component_spec.rb new file mode 100644 index 0000000000..624b9ec784 --- /dev/null +++ b/spec/components/app_patient_session_programme_component_spec.rb @@ -0,0 +1,221 @@ +# frozen_string_literal: true + +describe AppPatientSessionProgrammeComponent do + subject(:rendered) { render_inline(component) } + + let(:component) { described_class.new(patient:, session:, programme:) } + + let(:programme) { Programme.flu } + let(:session) { create(:session, programmes: [programme]) } + let(:patient) { create(:patient, session:) } + + it { should have_css("a.nhsuk-action-link", text: "View child’s Flu record") } + + context "when due (nasal)" do + before do + create(:patient_programme_status, :due_nasal, patient:, programme:) + end + + it { should have_css("h4", text: "Flu:") } + it { should_not have_css("table") } + + it "shows ready to vaccinate details" do + expect(rendered).to have_text( + "#{patient.given_name} is ready to vaccinate (nasal spray only)." + ) + end + end + + context "when due (injection)" do + before do + create(:patient_programme_status, :due_injection, patient:, programme:) + end + + it "shows ready to vaccinate details without criteria label for flu injection" do + expect(rendered).to have_text( + "#{patient.given_name} is ready to vaccinate" + ) + end + end + + context "when vaccinated" do + before do + create(:patient_programme_status, :vaccinated_fully, patient:, programme:) + end + + context "with a known nurse" do + let!(:vaccination_record) do + create( + :vaccination_record, + :performed_by_not_user, + patient:, + programme:, + session: + ) + end + + it { should have_css("table") } + + it "shows vaccinated by nurse details" do + nurse = [ + vaccination_record.performed_by_given_name, + vaccination_record.performed_by_family_name + ].join(" ") + + expect(rendered).to have_text( + "#{patient.given_name} was vaccinated by #{nurse} on" + ) + end + end + + context "without a known nurse" do + before do + create( + :vaccination_record, + patient:, + programme:, + session:, + performed_by_given_name: nil, + performed_by_family_name: nil + ) + PatientStatusUpdater.call(patient:) + end + + it { should have_css("table") } + + it "shows vaccinated without nurse details" do + expect(rendered).to have_text("#{patient.given_name} was vaccinated on") + expect(rendered).not_to have_text("vaccinated by") + end + end + end + + context "when the child could not be vaccinated" do + before do + create( + :vaccination_record, + :not_administered, + patient:, + programme:, + session: + ) + PatientStatusUpdater.call(patient:) + end + + it { should have_css("table") } + + it "shows child unwell details" do + expect(rendered).to have_text("Child unwell on") + end + end + + context "when needs triage" do + before do + create(:patient_programme_status, :needs_triage, patient:, programme:) + end + + it { should have_css("h4", text: "Flu:") } + it { should_not have_css("table") } + + it "shows needs triage details" do + expect(rendered).to have_text( + "You need to decide if it’s safe to vaccinate #{patient.given_name}." + ) + end + end + + context "when triaged" do + let(:nurse) { create(:user) } + + context "safe to vaccinate" do + before do + create(:patient_programme_status, :due_injection, patient:, programme:) + create( + :triage, + :safe_to_vaccinate, + patient:, + programme:, + performed_by: nurse + ) + end + + it "shows ready to vaccinate details, not triage summary" do + expect(rendered).to have_text( + "#{patient.given_name} is ready to vaccinate" + ) + expect(rendered).not_to have_text("#{nurse.full_name} decided that") + end + end + + context "do not vaccinate" do + before do + create( + :patient_programme_status, + :cannot_vaccinate_do_not_vaccinate, + patient:, + programme: + ) + create( + :triage, + :do_not_vaccinate, + patient:, + programme:, + performed_by: nurse + ) + end + + it "shows triage summary" do + expect(rendered).to have_text( + "#{nurse.full_name} decided that #{patient.given_name} should not be vaccinated." + ) + end + end + + context "delay vaccination" do + let!(:triage) do + create( + :triage, + :delay_vaccination, + patient:, + programme:, + performed_by: nurse + ) + end + + before do + create( + :patient_programme_status, + :cannot_vaccinate_delay_vaccination, + patient:, + programme: + ) + end + + it "shows triage summary with delay date" do + expect(rendered).to have_text( + "#{nurse.full_name} decided that #{patient.given_name}’s vaccination should be delayed " \ + "until #{triage.delay_vaccination_until.to_fs(:long)}." + ) + end + end + + context "invite to clinic" do + before do + create(:patient_programme_status, :needs_triage, patient:, programme:) + create( + :triage, + :invite_to_clinic, + patient:, + programme:, + performed_by: nurse + ) + end + + it "shows triage summary" do + expect(rendered).to have_text( + "#{nurse.full_name} decided that #{patient.given_name}’s vaccination should take place at a clinic." + ) + end + end + end +end diff --git a/spec/components/app_patient_session_vaccination_component_spec.rb b/spec/components/app_patient_session_vaccination_component_spec.rb deleted file mode 100644 index 49c6ad71e6..0000000000 --- a/spec/components/app_patient_session_vaccination_component_spec.rb +++ /dev/null @@ -1,47 +0,0 @@ -# frozen_string_literal: true - -describe AppPatientSessionVaccinationComponent do - subject(:rendered) { render_inline(component) } - - let(:component) { described_class.new(patient:, session:, programme:) } - - let(:programme) { Programme.hpv } - let(:session) { create(:session, programmes: [programme]) } - let(:patient) { create(:patient, session:) } - - describe "#render?" do - subject { component.render? } - - it { should be(false) } - - context "with a vaccination record for the programme" do - before { create(:vaccination_record, patient:, programme:) } - - it { should be(true) } - end - - context "with a vaccination record for a different programme" do - before { create(:vaccination_record, patient:, programme: Programme.mmr) } - - it { should be(false) } - end - end - - context "with a vaccination record for the programme" do - before do - create(:vaccination_record, patient:, programme:) - PatientStatusUpdater.call(patient:) - end - - it { should have_text("HPV: Vaccinated") } - end - - context "with an unwell vaccination record for the programme" do - before do - create(:vaccination_record, :unwell, patient:, programme:) - PatientStatusUpdater.call(patient:) - end - - it { should have_text("HPV: Unable to vaccinate") } - end -end diff --git a/spec/components/app_patient_vaccination_table_component_spec.rb b/spec/components/app_patient_vaccination_table_component_spec.rb index 80842b7c8b..a49a038c11 100644 --- a/spec/components/app_patient_vaccination_table_component_spec.rb +++ b/spec/components/app_patient_vaccination_table_component_spec.rb @@ -4,13 +4,20 @@ subject { render_inline(component) } let(:component) do - described_class.new(patient, academic_year:, programme:, show_caption:) + described_class.new( + patient, + academic_year:, + programme:, + show_caption:, + show_details: + ) end let(:patient) { create(:patient) } let(:academic_year) { 2023 } let(:programme) { nil } let(:show_caption) { false } + let(:show_details) { true } it { should have_content("No vaccinations") } @@ -50,6 +57,15 @@ it { should have_content("Vaccinated") } it { should have_content("HPV") } + context "when show_details is false" do + let(:show_details) { false } + + it { should have_link("1 January 2024") } + it { should have_content("Vaccinated") } + it { should_not have_content("Test School") } + it { should_not have_content("Waterloo Road, London, SE1 8TY") } + end + context "when showing records from a specific programme" do let(:programme) { vaccination_record_programme } diff --git a/spec/components/app_session_actions_component_spec.rb b/spec/components/app_session_actions_component_spec.rb index ca1e790176..f7e60a9a85 100644 --- a/spec/components/app_session_actions_component_spec.rb +++ b/spec/components/app_session_actions_component_spec.rb @@ -14,7 +14,7 @@ create(:patient, nhs_number: nil, year_group:) end - let(:allowed_managed_consent_reminders) { true } + let(:allowed) { true } before do create( @@ -57,9 +57,9 @@ create(:consent_form, :recorded, session:) stub_authorization( - allowed: allowed_managed_consent_reminders, + allowed:, klass: SessionPolicy, - methods: %i[manage_consent_reminders?] + methods: %i[invite_to_clinic? manage_consent_reminders?] ) end @@ -82,11 +82,13 @@ it { should have_link("1 child for HPV") } it { should have_link("Send reminders") } + it { should have_link("Send clinic invitations") } - context "when not allowed to manage consent reminders" do - let(:allowed_managed_consent_reminders) { false } + context "when not allowed to send reminders or clinic invitations" do + let(:allowed) { false } it { should_not have_link("Send reminders") } + it { should_not have_link("Send clinic invitations") } end context "session requires no registration" do @@ -104,13 +106,4 @@ it { should_not have_text("Register attendance") } it { should_not have_text("Ready for vaccinator") } end - - context "when there are no action required" do - before do - PatientLocation.destroy_all - ConsentForm.destroy_all - end - - it { should have_text("No action required") } - end end diff --git a/spec/controllers/api/reporting/totals_controller_spec.rb b/spec/controllers/api/reporting/totals_controller_spec.rb index 969d80a533..6db08908db 100644 --- a/spec/controllers/api/reporting/totals_controller_spec.rb +++ b/spec/controllers/api/reporting/totals_controller_spec.rb @@ -252,13 +252,24 @@ create(:consent, :refused, patient: patient2, programme:, team:) PatientStatusUpdater.call(patient: patient2) - create( - :patient, - session:, - school:, - year_group: 8, - parents: [create(:parent)] - ) + patient3 = + create( + :patient, + session:, + school:, + year_group: 8, + parents: [create(:parent)] + ) + + [patient1, patient2, patient3].each do |patient| + create( + :consent_notification, + :request, + patient:, + session:, + programmes: [programme] + ) + end refresh_reporting_views! @@ -275,6 +286,77 @@ "consent_conflicts" => 0 ) end + + it "counts all no response consent statuses consistently in aggregate totals" do + team = Team.last + programme = Programme.hpv + team.programmes << programme + + session = create(:session, team:, programmes: [programme]) + + no_response_patient = + create(:patient, session:, parents: [create(:parent)]) + create( + :consent_notification, + :request, + patient: no_response_patient, + session:, + programmes: [programme] + ) + + create(:patient, session:, parents: [create(:parent, :non_contactable)]) + + request_scheduled_session = + create( + :session, + team:, + programmes: [programme], + send_consent_requests_at: Date.tomorrow + ) + create( + :patient, + session: request_scheduled_session, + parents: [create(:parent)] + ) + + create(:patient, session:, parents: [create(:parent)]) + + refused_patient = create(:patient, session:, parents: [create(:parent)]) + create(:consent, :refused, patient: refused_patient, programme:, team:) + + conflict_patient = create(:patient, session:) + parent1 = create(:parent) + parent2 = create(:parent) + create(:parent_relationship, patient: conflict_patient, parent: parent1) + create(:parent_relationship, patient: conflict_patient, parent: parent2) + create( + :consent, + :given, + patient: conflict_patient, + programme:, + team:, + parent: parent1 + ) + create( + :consent, + :refused, + patient: conflict_patient, + programme:, + team:, + parent: parent2 + ) + + PatientStatusUpdater.call + + refresh_reporting_views! + + get :index, params: { programme: "hpv" } + + expect(parsed_response["no_consent"]).to eq(6) + expect(parsed_response["consent_refused"]).to eq(1) + expect(parsed_response["consent_conflicts"]).to eq(1) + expect(parsed_response["consent_no_response"]).to eq(4) + end end describe "#index.csv" do @@ -1159,7 +1241,16 @@ def refresh_and_get_totals(programme_type: "hpv") end it "counts children with no consent response" do - create(:patient, session: hpv_session, parents: [create(:parent)]) + patient = + create(:patient, session: hpv_session, parents: [create(:parent)]) + + create( + :consent_notification, + :request, + patient:, + session: hpv_session, + programmes: [hpv_programme] + ) refresh_and_get_totals diff --git a/spec/controllers/api/testing/reporting_refresh_controller_spec.rb b/spec/controllers/api/testing/reporting_refresh_controller_spec.rb index d02156367b..5538fbdb72 100644 --- a/spec/controllers/api/testing/reporting_refresh_controller_spec.rb +++ b/spec/controllers/api/testing/reporting_refresh_controller_spec.rb @@ -7,5 +7,14 @@ get :create expect(response).to have_http_status(:accepted) end + + context "when wait=true" do + it "runs the refresh synchronously and responds with ok status" do + expect(ReportingAPI::RefreshJob).to receive(:perform_now) + expect(ReportingAPI::RefreshJob).not_to receive(:perform_later) + get :create, params: { wait: "true" } + expect(response).to have_http_status(:ok) + end + end end end diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb index 6a75ae8db3..a77629d0da 100644 --- a/spec/controllers/api/testing/teams_controller_spec.rb +++ b/spec/controllers/api/testing/teams_controller_spec.rb @@ -17,7 +17,7 @@ let(:cohort_import) do create( :cohort_import, - csv: fixture_file_upload("cohort_import/valid.csv"), + csv_data: file_fixture("cohort_import/valid.csv").read, team: ) end @@ -25,10 +25,8 @@ let(:immunisation_import) do create( :immunisation_import, - csv: - fixture_file_upload( - "immunisation_import/point_of_care/valid_hpv.csv" - ), + csv_data: + file_fixture("immunisation_import/point_of_care/valid_hpv.csv").read, team: ) end @@ -49,6 +47,7 @@ create(:session, team:, location: team.gias_schools.first, programmes:) process_and_approve_import(cohort_import) + immunisation_import.parse_rows! immunisation_import.process! Patient.find_each do |patient| diff --git a/spec/factories/access_log_entries.rb b/spec/factories/access_log_entries.rb index 55892c1dda..f480be0a4c 100644 --- a/spec/factories/access_log_entries.rb +++ b/spec/factories/access_log_entries.rb @@ -20,7 +20,6 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) # fk_rails_... (user_id => users.id) # FactoryBot.define do diff --git a/spec/factories/careplus_export_vaccination_records.rb b/spec/factories/careplus_report_vaccination_records.rb similarity index 56% rename from spec/factories/careplus_export_vaccination_records.rb rename to spec/factories/careplus_report_vaccination_records.rb index 7ca103b1d2..83a330e514 100644 --- a/spec/factories/careplus_export_vaccination_records.rb +++ b/spec/factories/careplus_report_vaccination_records.rb @@ -2,27 +2,27 @@ # == Schema Information # -# Table name: careplus_export_vaccination_records +# Table name: careplus_report_vaccination_records # # change_type :integer not null # created_at :datetime not null # updated_at :datetime not null -# careplus_export_id :bigint not null, primary key +# careplus_report_id :bigint not null, primary key # vaccination_record_id :bigint not null, primary key # # Indexes # -# idx_on_careplus_export_id_8ce4ed1ff0 (careplus_export_id) -# idx_on_vaccination_record_id_d4c93aefb7 (vaccination_record_id) +# idx_on_careplus_report_id_98876049c7 (careplus_report_id) +# idx_on_vaccination_record_id_e7f05454ab (vaccination_record_id) # # Foreign Keys # -# fk_rails_... (careplus_export_id => careplus_exports.id) ON DELETE => cascade +# fk_rails_... (careplus_report_id => careplus_reports.id) ON DELETE => cascade # fk_rails_... (vaccination_record_id => vaccination_records.id) # FactoryBot.define do - factory :careplus_export_vaccination_record do - careplus_export + factory :careplus_report_vaccination_record do + careplus_report vaccination_record change_type { :created } end diff --git a/spec/factories/careplus_exports.rb b/spec/factories/careplus_reports.rb similarity index 78% rename from spec/factories/careplus_exports.rb rename to spec/factories/careplus_reports.rb index 966fbbedba..8bac99bb55 100644 --- a/spec/factories/careplus_exports.rb +++ b/spec/factories/careplus_reports.rb @@ -2,7 +2,7 @@ # == Schema Information # -# Table name: careplus_exports +# Table name: careplus_reports # # id :bigint not null, primary key # academic_year :integer not null @@ -21,17 +21,17 @@ # # Indexes # -# index_careplus_exports_on_programme_types (programme_types) USING gin -# index_careplus_exports_on_status_and_scheduled_at (status,scheduled_at) -# index_careplus_exports_on_team_id (team_id) -# index_careplus_exports_on_team_id_and_academic_year (team_id,academic_year) +# index_careplus_reports_on_programme_types (programme_types) USING gin +# index_careplus_reports_on_status_and_scheduled_at (status,scheduled_at) +# index_careplus_reports_on_team_id (team_id) +# index_careplus_reports_on_team_id_and_academic_year (team_id,academic_year) # # Foreign Keys # # fk_rails_... (team_id => teams.id) # FactoryBot.define do - factory :careplus_export do + factory :careplus_report do transient { programmes { [Programme.sample] } } team { association(:team, programmes:) } @@ -44,7 +44,7 @@ trait :sent do status { :sent } sent_at { Time.current } - csv_filename { "careplus_export.csv" } + csv_filename { "careplus_report.csv" } csv_data { "col1,col2\nval1,val2" } end diff --git a/spec/factories/class_imports.rb b/spec/factories/class_imports.rb index 20bb182ef1..37723954bc 100644 --- a/spec/factories/class_imports.rb +++ b/spec/factories/class_imports.rb @@ -39,21 +39,43 @@ # FactoryBot.define do factory :class_import do - transient { session { association(:session) } } + transient do + session { association(:session) } + + # Can be used by the caller to pass in a file that simulates how it would + # come from the file upload field in the UI. + uploaded_csv_file { nil } + end academic_year { session.academic_year } location { session.location } team { session.team } uploaded_by + # Callers should use `csv_data` to set the CSV content, this is faster than + # using `uploaded_csv_file`. + csv_data do + "CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_DATE_OF_BIRTH\nJohn,Smith,2010-01-01\n" + end + csv_filename { csv_data && Faker::File.file_name(ext: "csv") } + rows_count { csv_data ? csv_data.lines.count - 1 : nil } + year_groups { session.year_groups } - csv_data { "my,csv\n" } - csv_filename { Faker::File.file_name(ext: "csv") } - rows_count { rand(100..1000) } + after(:build) do |import, evaluator| + if evaluator.uploaded_csv_file.present? + import.csv = + ActionDispatch::Http::UploadedFile.new( + tempfile: File.open(evaluator.uploaded_csv_file.path, "rb"), + filename: evaluator.uploaded_csv_file.original_filename, + type: evaluator.uploaded_csv_file.content_type || "text/csv" + ) + end + end trait :csv_removed do csv_data { nil } + csv_filename { Faker::File.file_name(ext: "csv") } csv_removed_at { Time.zone.now } end diff --git a/spec/factories/cohort_imports.rb b/spec/factories/cohort_imports.rb index 0199a4dd1a..3982ac0bbc 100644 --- a/spec/factories/cohort_imports.rb +++ b/spec/factories/cohort_imports.rb @@ -35,16 +35,40 @@ # FactoryBot.define do factory :cohort_import do + transient do + # Can be used by the caller to pass in a file that simulates how it would + # come from the file upload field in the UI. + uploaded_csv_file { nil } + end + team uploaded_by + # Callers should use `csv_data` to set the CSV content, this is faster than + # using `uploaded_csv_file`. + csv_data do + "CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_DATE_OF_BIRTH\nJohn,Smith,2010-01-01\n" + end + csv_filename { csv_data && Faker::File.file_name(ext: "csv") } + rows_count { csv_data ? csv_data.lines.count - 1 : nil } + academic_year { AcademicYear.pending } - csv_data { "my,csv\n" } - csv_filename { Faker::File.file_name(ext: "csv") } - rows_count { rand(100..1000) } + + after(:build) do |import, evaluator| + if evaluator.uploaded_csv_file.present? + file = evaluator.uploaded_csv_file + import.csv = + ActionDispatch::Http::UploadedFile.new( + tempfile: File.open(file.path, "rb"), + filename: evaluator.uploaded_csv_file.original_filename, + type: evaluator.uploaded_csv_file.content_type || "text/csv" + ) + end + end trait :csv_removed do csv_data { nil } + csv_filename { Faker::File.file_name(ext: "csv") } csv_removed_at { Time.zone.now } end diff --git a/spec/factories/immunisation_imports.rb b/spec/factories/immunisation_imports.rb index ed5099bd0b..0e2a8b16ec 100644 --- a/spec/factories/immunisation_imports.rb +++ b/spec/factories/immunisation_imports.rb @@ -34,17 +34,39 @@ # FactoryBot.define do factory :immunisation_import do + transient do + # Can be used by the caller to pass in a file that simulates how it would + # come from the file upload field in the UI. + uploaded_csv_file { nil } + end + team uploaded_by - csv_data { "my,csv\n" } - csv_filename { Faker::File.file_name(ext: "csv") } - rows_count { rand(100..1000) } + # Callers should use `csv_data` to set the CSV content, this is faster than + # using `uploaded_csv_file`. + csv_data do + "VACCINATED,VACCINE_GIVEN,DATE_OF_VACCINATION\nY,Gardasil9,2024-01-01\n" + end + csv_filename { csv_data && Faker::File.file_name(ext: "csv") } + rows_count { csv_data ? csv_data.lines.count - 1 : nil } type { team.type } + after(:build) do |import, evaluator| + if evaluator.uploaded_csv_file.present? + import.csv = + ActionDispatch::Http::UploadedFile.new( + tempfile: File.open(evaluator.uploaded_csv_file.path, "rb"), + filename: evaluator.uploaded_csv_file.original_filename, + type: evaluator.uploaded_csv_file.content_type || "text/csv" + ) + end + end + trait :csv_removed do csv_data { nil } + csv_filename { Faker::File.file_name(ext: "csv") } csv_removed_at { Time.zone.now } end diff --git a/spec/factories/notify_log_entries.rb b/spec/factories/notify_log_entries.rb index 8c98843eac..94f6dc5ed6 100644 --- a/spec/factories/notify_log_entries.rb +++ b/spec/factories/notify_log_entries.rb @@ -31,7 +31,7 @@ # # fk_rails_... (consent_form_id => consent_forms.id) # fk_rails_... (parent_id => parents.id) ON DELETE => nullify -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (sent_by_user_id => users.id) # FactoryBot.define do diff --git a/spec/factories/patient_merge_log_entries.rb b/spec/factories/patient_merge_log_entries.rb index 900817b26f..8af9a21d0d 100644 --- a/spec/factories/patient_merge_log_entries.rb +++ b/spec/factories/patient_merge_log_entries.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (user_id => users.id) # FactoryBot.define do diff --git a/spec/factories/patient_programme_vaccinations_searches.rb b/spec/factories/patient_programme_vaccinations_searches.rb index 4250c536cd..02415023bb 100644 --- a/spec/factories/patient_programme_vaccinations_searches.rb +++ b/spec/factories/patient_programme_vaccinations_searches.rb @@ -19,7 +19,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # FactoryBot.define do factory :patient_programme_vaccinations_search do diff --git a/spec/factories/pds_search_results.rb b/spec/factories/pds_search_results.rb index 9ba1b218d9..77b6a9a3f7 100644 --- a/spec/factories/pds_search_results.rb +++ b/spec/factories/pds_search_results.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # FactoryBot.define do diff --git a/spec/factories/school_move_log_entries.rb b/spec/factories/school_move_log_entries.rb index 217963aaa4..bdcf2d15b7 100644 --- a/spec/factories/school_move_log_entries.rb +++ b/spec/factories/school_move_log_entries.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (school_id => locations.id) # fk_rails_... (team_id => teams.id) # fk_rails_... (user_id => users.id) diff --git a/spec/factories/teams.rb b/spec/factories/teams.rb index 8db7861446..42833d9437 100644 --- a/spec/factories/teams.rb +++ b/spec/factories/teams.rb @@ -5,8 +5,11 @@ # Table name: teams # # id :bigint not null, primary key +# careplus_namespace :string +# careplus_password :string # careplus_staff_code :string # careplus_staff_type :string +# careplus_username :string # careplus_venue_code :string # days_before_consent_reminders :integer default(7), not null # days_before_consent_requests :integer default(21), not null @@ -90,9 +93,12 @@ end trait :with_careplus_enabled do + careplus_namespace { "MOCK" } careplus_staff_code { "LW5PM" } careplus_staff_type { "IN" } careplus_venue_code { identifier.to_s } + careplus_username { "careplus_user" } + careplus_password { "careplus_password" } end after(:create) do |team| diff --git a/spec/features/cli_pds_get_spec.rb b/spec/features/cli_pds_get_spec.rb index d8b2d794c2..1622cd1ca3 100644 --- a/spec/features/cli_pds_get_spec.rb +++ b/spec/features/cli_pds_get_spec.rb @@ -2,7 +2,7 @@ require_relative "../../app/lib/mavis_cli" -describe "mavis pds get" do +describe "mavis pds get", :pds do it "runs successfully" do given_the_request_is_stubbed diff --git a/spec/features/cli_pds_search_spec.rb b/spec/features/cli_pds_search_spec.rb index 61ab4bc96a..3f420c9207 100644 --- a/spec/features/cli_pds_search_spec.rb +++ b/spec/features/cli_pds_search_spec.rb @@ -2,7 +2,7 @@ require_relative "../../app/lib/mavis_cli" -describe "mavis pds search" do +describe "mavis pds search", :pds do it "runs successfully" do given_the_request_is_stubbed diff --git a/spec/features/cli_reports_export_automated_careplus_spec.rb b/spec/features/cli_reports_export_automated_careplus_spec.rb index 5933d5926f..52d3052ee3 100644 --- a/spec/features/cli_reports_export_automated_careplus_spec.rb +++ b/spec/features/cli_reports_export_automated_careplus_spec.rb @@ -23,7 +23,7 @@ expect(@output).to include( "No records found. No CarePlus report was created." ) - and_no_careplus_export_is_created + and_no_careplus_report_is_created expect(File.exist?(output_path)).to be(false) end end @@ -38,9 +38,9 @@ "--output=#{output_path}" ) - export = CareplusExport.last - expect(export.vaccination_records).to include(@vaccination_record) - expect(export.programme_types).to eq([@programme.type]) + report = CareplusReport.last + expect(report.vaccination_records).to include(@vaccination_record) + expect(report.programme_types).to eq([@programme.type]) and_the_output_file_is_written and_the_success_message_is_displayed end @@ -51,7 +51,7 @@ given_an_organisation_with_a_single_team given_a_vaccination_record_for_the_team - allow(CareplusExportVaccinationRecord).to receive(:insert_all!).and_raise( + allow(CareplusReportVaccinationRecord).to receive(:insert_all!).and_raise( ActiveRecord::ActiveRecordError ) @@ -63,7 +63,7 @@ ) end }.to raise_error(ActiveRecord::ActiveRecordError).and( - not_change(CareplusExport, :count) + not_change(CareplusReport, :count) ) end end @@ -76,7 +76,7 @@ then_the_error_output_includes( "Could not find organisation with ODS code 'UNKNOWN'" ) - and_no_careplus_export_is_created + and_no_careplus_report_is_created end end @@ -88,7 +88,7 @@ "--ods_code=#{@organisation.ods_code}" ) then_the_error_output_includes("has multiple teams") - and_no_careplus_export_is_created + and_no_careplus_report_is_created end end @@ -102,7 +102,7 @@ "--workgroup=#{@team.workgroup}", "--output=#{output_path}" ) - then_a_careplus_export_is_created_with(team: @team) + then_a_careplus_report_is_created_with(team: @team) end end @@ -123,7 +123,7 @@ "--academic_year=2024", "--output=#{output_path}" ) - then_a_careplus_export_is_created_with(academic_year: 2024) + then_a_careplus_report_is_created_with(academic_year: 2024) end end @@ -135,7 +135,7 @@ "--ods_code=#{@organisation.ods_code}" ) then_the_error_output_includes("does not have CarePlus enabled") - and_no_careplus_export_is_created + and_no_careplus_report_is_created end end @@ -195,8 +195,8 @@ def when_i_run_the_command_with_options_and_capture_error(*args) @error = capture_error { command(*args) } end - def then_a_careplus_export_is_created_with(**kwargs) - expect(CareplusExport.last).to have_attributes(**kwargs) + def then_a_careplus_report_is_created_with(**kwargs) + expect(CareplusReport.last).to have_attributes(**kwargs) end def and_the_output_file_is_written @@ -211,7 +211,7 @@ def then_the_error_output_includes(message) expect(@error).to include(message) end - def and_no_careplus_export_is_created - expect(CareplusExport.count).to eq(0) + def and_no_careplus_report_is_created + expect(CareplusReport.count).to eq(0) end end diff --git a/spec/features/cli_reports_send_to_careplus_spec.rb b/spec/features/cli_reports_send_to_careplus_spec.rb new file mode 100644 index 0000000000..92c9636ed9 --- /dev/null +++ b/spec/features/cli_reports_send_to_careplus_spec.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +require_relative "../../app/lib/mavis_cli" + +describe "mavis reports send-to-careplus" do + let(:csv_content) { "col1,col2\nval1,val2\n" } + let(:input_path) { Rails.root.join("tmp/test_careplus_input.csv").to_s } + + before { File.write(input_path, csv_content) } + after { FileUtils.rm_f(input_path) } + + context "when the input file does not exist" do + it "warns and does not make a request" do + stub_careplus_request + + when_i_run_the_command_and_capture_error("--input=/nonexistent/file.csv") + + then_the_error_output_includes("File not found: '/nonexistent/file.csv'") + and_no_request_was_made + end + end + + context "without --ods_code (fallback credentials)" do + context "when the request succeeds" do + it "prints the response body and a success message" do + stub_careplus_request(status: 200, body: "OK") + + when_i_run_the_command("--input=#{input_path}") + + then_the_output_includes("Success (HTTP 200)") + then_the_output_includes("OK") + end + + it "sends the correct request with fallback credentials and namespace" do + stub_careplus_request(status: 200, body: "") + + when_i_run_the_command("--input=#{input_path}") + + expect(WebMock).to have_requested(:post, default_endpoint).with( + headers: { + "Content-Type" => "text/xml; charset=utf-8" + }, + body: /col1,col2/ + ) + expect(WebMock).to have_requested(:post, default_endpoint).with( + body: /mavis_user/ + ) + expect(WebMock).to have_requested(:post, default_endpoint).with( + body: %r{careplus\.syhapp\.thirdparty\.nhs\.uk/MOCK/webservices} + ) + end + end + + context "when the request fails" do + it "warns with the status and response body" do + stub_careplus_request(status: 400, body: "Bad request") + + when_i_run_the_command_and_capture_error("--input=#{input_path}") + + then_the_error_output_includes("Request failed with HTTP 400") + then_the_error_output_includes("Bad request") + end + end + + context "when the CSV payload contains XML special characters" do + it "escapes them before embedding in the envelope" do + File.write(input_path, "name\n & \"School\"\n") + stub_careplus_request(status: 200, body: "") + + when_i_run_the_command("--input=#{input_path}") + + expect(WebMock).to have_requested(:post, default_endpoint).with( + body: /<Test> & "School"/ + ) + end + end + end + + context "with --ods_code (team credentials)" do + context "when the organisation does not exist" do + it "warns and does not make a request" do + when_i_run_the_command_and_capture_error( + "--input=#{input_path}", + "--ods_code=UNKNOWN" + ) + + then_the_error_output_includes( + "Could not find organisation with ODS code 'UNKNOWN'" + ) + and_no_request_was_made + end + end + + context "when the organisation has multiple teams and no workgroup is given" do + it "warns and does not make a request" do + given_an_organisation_with_multiple_teams + + when_i_run_the_command_and_capture_error( + "--input=#{input_path}", + "--ods_code=#{@organisation.ods_code}" + ) + + then_the_error_output_includes("has multiple teams") + and_no_request_was_made + end + end + + context "when the team has no credentials configured" do + it "warns and does not make a request" do + given_an_organisation_with_a_team_without_credentials + + when_i_run_the_command_and_capture_error( + "--input=#{input_path}", + "--ods_code=#{@organisation.ods_code}" + ) + + then_the_error_output_includes( + "does not have CarePlus credentials configured" + ) + and_no_request_was_made + end + end + + context "when the team has credentials configured" do + it "sends the correct request using the team's credentials and namespace, and prints a success message" do + given_an_organisation_with_a_single_team + stub_careplus_request(status: 200, body: "OK") + + when_i_run_the_command( + "--input=#{input_path}", + "--ods_code=#{@organisation.ods_code}" + ) + + expect(WebMock).to have_requested(:post, default_endpoint).with( + body: /careplus_user/ + ) + expect(WebMock).to have_requested(:post, default_endpoint).with( + body: %r{careplus\.syhapp\.thirdparty\.nhs\.uk/MOCK/webservices} + ) + then_the_output_includes("Success (HTTP 200)") + end + end + + context "when a workgroup is specified" do + it "sends the request using the matching team's credentials" do + given_an_organisation_with_multiple_teams + stub_careplus_request(status: 200, body: "") + + when_i_run_the_command( + "--input=#{input_path}", + "--ods_code=#{@organisation.ods_code}", + "--workgroup=#{@team.workgroup}" + ) + + expect(WebMock).to have_requested(:post, default_endpoint).with( + body: /careplus_user/ + ) + end + end + end + + private + + def default_endpoint + fallback_namespace = MavisCLI::Reports::SendToCareplus::FALLBACK_NAMESPACE + "#{Settings.careplus.base_url}/#{fallback_namespace}/soap.SchImms.cls" + end + + def stub_careplus_request(endpoint: default_endpoint, status: 200, body: "") + stub_request(:post, endpoint).to_return( + status:, + body:, + headers: { + "Content-Type" => "text/xml" + } + ) + end + + def command(*args) + Dry::CLI.new(MavisCLI).call( + arguments: ["reports", "send-to-careplus", *args] + ) + end + + def given_an_organisation_with_a_team_without_credentials + @organisation = create(:organisation) + create( + :team, + :with_careplus_enabled, + organisation: @organisation, + careplus_username: nil, + careplus_password: nil, + programmes: Programme.all + ) + end + + def given_an_organisation_with_a_single_team + @organisation = create(:organisation) + @team = + create( + :team, + :with_careplus_enabled, + organisation: @organisation, + programmes: Programme.all + ) + end + + def given_an_organisation_with_multiple_teams + @organisation = create(:organisation) + @team = + create( + :team, + :with_careplus_enabled, + organisation: @organisation, + programmes: Programme.all + ) + create( + :team, + :with_careplus_enabled, + organisation: @organisation, + programmes: Programme.all + ) + end + + def when_i_run_the_command(*args) + @output = capture_output { command(*args) } + end + + def when_i_run_the_command_and_capture_error(*args) + @error = capture_error { command(*args) } + end + + def then_the_output_includes(message) + expect(@output).to include(message) + end + + def then_the_error_output_includes(message) + expect(@error).to include(message) + end + + def and_no_request_was_made + expect(WebMock).not_to have_requested(:post, default_endpoint) + end +end diff --git a/spec/features/cli_teams_list_spec.rb b/spec/features/cli_teams_list_spec.rb index 8d1b6e0edd..678ddd7775 100644 --- a/spec/features/cli_teams_list_spec.rb +++ b/spec/features/cli_teams_list_spec.rb @@ -23,11 +23,18 @@ def given_a_couple_organisations_exist end def and_there_are_teams_in_the_organisations - @programme = Programme.sample + @programme = Programme.menacwy @team1 = create(:team, organisation: @organisation1, programmes: [@programme]) @team2 = create(:team, organisation: @organisation2, programmes: [@programme]) + @team_national_reporting = + create( + :team, + :national_reporting, + national_reporting_cut_off_date: 1.day.ago + ) + @team_support = create(:team, :support) end def when_i_run_the_list_teams_command @@ -46,11 +53,24 @@ def when_i_run_the_list_teams_command_with_an_ods_code def then_i_should_see_the_list_of_teams expect(@output).to include(@team1.name) + expect(@output).to include(@team1.type) expect(@output).to include(@organisation1.ods_code) expect(@output).to include(@team1.workgroup) + expect(@output).to include(@team2.name) + expect(@output).to include(@team2.type) expect(@output).to include(@organisation2.ods_code) expect(@output).to include(@team2.workgroup) + + expect(@output).to include(@team_national_reporting.name) + expect(@output).to include(@team_national_reporting.type) + expect(@output).to include( + @team_national_reporting.national_reporting_cut_off_date.to_s + ) + + expect(@output).to include(@team_support.name) + expect(@output).to include(@team_support.type) + expect(@output).to include(@programme.name).twice end diff --git a/spec/features/hpv_vaccination_administered_spec.rb b/spec/features/hpv_vaccination_administered_spec.rb index 481ce22fdc..0a34cbe960 100644 --- a/spec/features/hpv_vaccination_administered_spec.rb +++ b/spec/features/hpv_vaccination_administered_spec.rb @@ -274,7 +274,7 @@ def then_i_see_that_the_status_is_vaccinated end def and_i_see_the_vaccination_details - expect(page).to have_content("Vaccination records") + expect(page).to have_content("Vaccination outcomes") click_on Date.current.to_fs(:long) expect(page).to have_content("Vaccination details") diff --git a/spec/features/import_child_pds_lookup_extravaganza_spec.rb b/spec/features/import_child_pds_lookup_extravaganza_spec.rb index a92e61284a..1083c22ef8 100644 --- a/spec/features/import_child_pds_lookup_extravaganza_spec.rb +++ b/spec/features/import_child_pds_lookup_extravaganza_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Import child records" do +describe "Import child records", :pds do let(:today) { Time.zone.local(2025, 9, 1, 12, 0, 0) } around { |example| travel_to(today) { example.run } } @@ -278,7 +278,7 @@ def and_an_existing_patient_record_exists end def and_pds_lookups_dont_return_any_matches - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) csv = CSV.new( @@ -305,7 +305,7 @@ def and_pds_lookups_dont_return_any_matches end def and_pds_lookup_during_import_is_enabled - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_cascading_search( family_name: "Tweedle", diff --git a/spec/features/import_child_records_preparation_spec.rb b/spec/features/import_child_records_preparation_spec.rb index f531ca3ec7..e361c1d56a 100644 --- a/spec/features/import_child_records_preparation_spec.rb +++ b/spec/features/import_child_records_preparation_spec.rb @@ -107,7 +107,7 @@ def given_today_is_the_start_of_the_2024_25_preparation_period end def and_pds_lookup_during_import_is_enabled - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_search_to_return_a_patient( "9990000026", diff --git a/spec/features/import_child_records_spec.rb b/spec/features/import_child_records_spec.rb index 5bf1c00b17..46d7ea2fb3 100644 --- a/spec/features/import_child_records_spec.rb +++ b/spec/features/import_child_records_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Import child records" do +describe "Import child records", :pds do around { |example| travel_to(Date.new(2023, 5, 20)) { example.run } } scenario "User uploads a file" do @@ -128,7 +128,7 @@ def given_the_app_is_setup end def and_pds_lookup_during_import_is_enabled - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_search_to_return_a_patient( "9990000026", diff --git a/spec/features/import_child_records_with_duplicates_spec.rb b/spec/features/import_child_records_with_duplicates_spec.rb index 0d79e53d15..a863a9ce2e 100644 --- a/spec/features/import_child_records_with_duplicates_spec.rb +++ b/spec/features/import_child_records_with_duplicates_spec.rb @@ -155,7 +155,7 @@ def given_i_am_signed_in def and_pds_lookup_during_import_is_enabled return unless ENV["PDS_LOOKUP_DURING_IMPORT"] == "1" - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_search_to_return_a_patient( "9990000026", diff --git a/spec/features/import_child_records_with_twins_spec.rb b/spec/features/import_child_records_with_twins_spec.rb index cb5d2c4451..022d80e1ff 100644 --- a/spec/features/import_child_records_with_twins_spec.rb +++ b/spec/features/import_child_records_with_twins_spec.rb @@ -1,10 +1,10 @@ # frozen_string_literal: true -describe "Child record imports twins" do +describe "Child record imports twins", :pds do around { |example| travel_to(Date.new(2024, 12, 1)) { example.run } } - before { Flipper.enable(:import_search_pds) } - after { Flipper.disable(:import_search_pds) } + before { Flipper.enable(:pds_search_during_import) } + after { Flipper.disable(:pds_search_during_import) } scenario "User reviews and selects between duplicate records" do and_pds_lookup_during_import_returns_nhs_numbers diff --git a/spec/features/import_class_lists_move_spec.rb b/spec/features/import_class_lists_move_spec.rb index 810fc11825..e5344fbc96 100644 --- a/spec/features/import_class_lists_move_spec.rb +++ b/spec/features/import_class_lists_move_spec.rb @@ -172,7 +172,7 @@ def given_an_hpv_programme_is_underway end def and_pds_lookup_during_import_is_enabled - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_search_to_return_a_patient( "9990000026", diff --git a/spec/features/import_class_lists_spec.rb b/spec/features/import_class_lists_spec.rb index be5d58c6a3..50668d5cf5 100644 --- a/spec/features/import_class_lists_spec.rb +++ b/spec/features/import_class_lists_spec.rb @@ -87,7 +87,7 @@ def given_an_hpv_programme_is_underway end def and_pds_lookup_during_import_is_enabled - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_search_to_return_a_patient( "9990000026", diff --git a/spec/features/import_class_lists_with_duplicates_spec.rb b/spec/features/import_class_lists_with_duplicates_spec.rb index 22c5e3ef03..12625869aa 100644 --- a/spec/features/import_class_lists_with_duplicates_spec.rb +++ b/spec/features/import_class_lists_with_duplicates_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Class list imports duplicates" do +describe "Class list imports duplicates", :pds do around { |example| travel_to(Date.new(2023, 5, 20)) { example.run } } scenario "User reviews and selects between duplicate records" do @@ -97,7 +97,7 @@ def given_i_am_signed_in end def and_pds_lookup_during_import_is_enabled - Flipper.enable(:import_search_pds) + Flipper.enable(:pds_search_during_import) stub_pds_search_to_return_a_patient( "9990000018", diff --git a/spec/features/import_vaccination_records_national_reporting_spec.rb b/spec/features/import_vaccination_records_national_reporting_spec.rb index d94c376c7d..f862e9aa09 100644 --- a/spec/features/import_vaccination_records_national_reporting_spec.rb +++ b/spec/features/import_vaccination_records_national_reporting_spec.rb @@ -270,12 +270,12 @@ def and_the_patients_should_now_be_associated_with_the_team def and_the_newly_created_patients_should_be_archived new_patient = Patient.find_by(nhs_number: "9999075320") - expect(new_patient.archived?(team: @team)).to be true + expect(new_patient.archived?(team_id: @team.id)).to be true expect(new_patient.archive_reasons.first.type).to eq "immunisation_import" end def and_the_existing_patients_should_not_be_archived - expect(@existing_patient.archived?(team: @team)).to be false + expect(@existing_patient.archived?(team_id: @team.id)).to be false end def and_the_vaccination_records_are_sent_to_the_imms_api diff --git a/spec/features/import_vaccination_records_point_of_care_spec.rb b/spec/features/import_vaccination_records_point_of_care_spec.rb index 6a5c68cc85..d64fea61f2 100644 --- a/spec/features/import_vaccination_records_point_of_care_spec.rb +++ b/spec/features/import_vaccination_records_point_of_care_spec.rb @@ -164,12 +164,12 @@ def and_the_patients_should_now_be_associated_with_the_team def and_the_newly_created_patients_should_be_archived new_patient = Patient.find_by(nhs_number: "7420180008") - expect(new_patient.archived?(team: @team)).to be true + expect(new_patient.archived?(team_id: @team.id)).to be true expect(new_patient.archive_reasons.first.type).to eq "immunisation_import" end def and_the_existing_patients_should_not_be_archived - expect(@existing_patient.archived?(team: @team)).to be false + expect(@existing_patient.archived?(team_id: @team.id)).to be false end def when_i_go_back diff --git a/spec/features/important_notices_spec.rb b/spec/features/important_notices_spec.rb index 093420deef..044706c5c4 100644 --- a/spec/features/important_notices_spec.rb +++ b/spec/features/important_notices_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Important notices" do +describe "Important notices", :pds do around { |example| travel_to(Date.new(2023, 5, 20)) { example.run } } before { given_my_team_exists } diff --git a/spec/features/invalidate_consent_spec.rb b/spec/features/invalidate_consent_spec.rb index 41426170ff..c6fb260a36 100644 --- a/spec/features/invalidate_consent_spec.rb +++ b/spec/features/invalidate_consent_spec.rb @@ -159,7 +159,7 @@ def when_i_click_back end def and_i_am_not_able_to_record_a_vaccination - expect(page).to have_content("No response") + expect(page).to have_content("Request not scheduled") expect(page).not_to have_content("ready for their HPV vaccination?") end diff --git a/spec/features/menacwy_vaccination_administered_spec.rb b/spec/features/menacwy_vaccination_administered_spec.rb index 3d4672e665..e8006462a2 100644 --- a/spec/features/menacwy_vaccination_administered_spec.rb +++ b/spec/features/menacwy_vaccination_administered_spec.rb @@ -201,7 +201,7 @@ def then_i_see_that_the_status_is_vaccinated end def and_i_see_the_vaccination_details - expect(page).to have_content("Vaccination records") + expect(page).to have_content("Vaccination outcomes") click_on Date.current.to_fs(:long) expect(page).to have_content("Vaccination details") diff --git a/spec/features/parental_consent_create_patient_spec.rb b/spec/features/parental_consent_create_patient_spec.rb index cf712c1714..42d8c05cf7 100644 --- a/spec/features/parental_consent_create_patient_spec.rb +++ b/spec/features/parental_consent_create_patient_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Parental consent create patient" do +describe "Parental consent create patient", :pds do before { given_the_app_is_setup } around { |example| travel_to(Date.new(2025, 7, 31)) { example.run } } diff --git a/spec/features/parental_consent_manual_consent_reminders_send_spec.rb b/spec/features/parental_consent_manual_consent_reminders_send_spec.rb index de0752e190..57240c836e 100644 --- a/spec/features/parental_consent_manual_consent_reminders_send_spec.rb +++ b/spec/features/parental_consent_manual_consent_reminders_send_spec.rb @@ -53,6 +53,15 @@ def given_a_session_with_patients_having_no_consent_response programmes:, parents: [@parents[0]] ) + + create( + :consent_notification, + :request, + patient: @patient_with_no_response, + session: @session, + programmes: + ) + @another_patient_with_no_response = create( :patient, @@ -61,6 +70,15 @@ def given_a_session_with_patients_having_no_consent_response programmes:, parents: [@parents[1]] ) + + create( + :consent_notification, + :request, + patient: @another_patient_with_no_response, + session: @session, + programmes: + ) + @third_patient_with_a_response = create( :patient, @@ -144,16 +162,8 @@ def and_i_am_redirected_to_the_session_page end def and_emails_are_sent_to_parents - expect_email_to( - @parents[0].email, - :consent_school_initial_reminder_hpv, - :any - ) - expect_email_to( - @parents[1].email, - :consent_school_initial_reminder_hpv, - :any - ) + expect_email_to(@parents[0].email, :consent_school_reminder_hpv, :any) + expect_email_to(@parents[1].email, :consent_school_reminder_hpv, :any) expect(sms_deliveries).to include( matching_notify_sms( diff --git a/spec/features/parental_consent_send_request_spec.rb b/spec/features/parental_consent_send_request_spec.rb index 201037bc87..64d0d9b443 100644 --- a/spec/features/parental_consent_send_request_spec.rb +++ b/spec/features/parental_consent_send_request_spec.rb @@ -264,7 +264,9 @@ def when_i_go_to_a_patient_without_consent end def then_i_see_no_requests_sent - expect(page).to have_content("No requests have been sent.") + expect(page).to have_content( + /No requests have been sent|Request not scheduled/ + ) end def when_i_click_send_consent_request diff --git a/spec/features/patient_invalidation_spec.rb b/spec/features/patient_invalidation_spec.rb index a61f3cb213..d20e0946b0 100644 --- a/spec/features/patient_invalidation_spec.rb +++ b/spec/features/patient_invalidation_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe "Patient invalidation deletes vaccination record from API" do +describe "Patient invalidation deletes vaccination record from API", :pds do around { |example| travel_to(Date.new(2025, 8, 7)) { example.run } } scenario "PDS check invalidates patient and deletes vaccination record from API" do diff --git a/spec/features/scheduled_consent_requests_and_reminders_spec.rb b/spec/features/scheduled_consent_requests_and_reminders_spec.rb index 0b2ea4daff..f37a467820 100644 --- a/spec/features/scheduled_consent_requests_and_reminders_spec.rb +++ b/spec/features/scheduled_consent_requests_and_reminders_spec.rb @@ -12,21 +12,12 @@ ] end - let(:initial_reminder_templates) do + let(:reminder_templates) do %i[ - consent_school_initial_reminder_hpv - consent_school_initial_reminder_flu - consent_school_initial_reminder_mmr - consent_school_initial_reminder_doubles - ] - end - - let(:subsequent_reminder_templates) do - %i[ - consent_school_subsequent_reminder_hpv - consent_school_subsequent_reminder_flu - consent_school_subsequent_reminder_mmr - consent_school_subsequent_reminder_doubles + consent_school_reminder_hpv + consent_school_reminder_flu + consent_school_reminder_mmr + consent_school_reminder_doubles ] end @@ -67,10 +58,10 @@ then_all_four_parents_received_all_programme_consent_requests when_14_more_days_pass - then_all_four_parents_received_all_programme_initial_reminders + then_all_four_parents_received_all_programme_reminders when_7_more_days_pass - then_all_four_parents_received_all_programme_subsequent_reminders + then_all_four_parents_received_all_programme_reminders end def given_my_team_is_running_all_vaccination_programmes @@ -272,44 +263,13 @@ def then_all_four_parents_received_all_programme_consent_requests end end - def then_all_four_parents_received_all_programme_initial_reminders - EnqueueSchoolConsentRemindersJob.perform_now - perform_enqueued_jobs - Sidekiq::Job.drain_all - - parent_emails.each do |email| - initial_reminder_templates.each do |template| - expect(email_deliveries).to include( - matching_notify_email(to: email, template:) - ) - end - end - - parent_phones.each do |phone| - expect_sms_to(phone, :consent_school_reminder, :any) - end - - mmrv_parent_emails.each do |email| - expect(email_deliveries).to include( - matching_notify_email( - to: email, - template: :consent_school_initial_reminder_mmrv - ) - ) - end - - mmrv_parent_phones.each do |phone| - expect_sms_to(phone, :consent_school_reminder, :any) - end - end - - def then_all_four_parents_received_all_programme_subsequent_reminders + def then_all_four_parents_received_all_programme_reminders EnqueueSchoolConsentRemindersJob.perform_now perform_enqueued_jobs Sidekiq::Job.drain_all parent_emails.each do |email| - subsequent_reminder_templates.each do |template| + reminder_templates.each do |template| expect(email_deliveries).to include( matching_notify_email(to: email, template:) ) @@ -324,7 +284,7 @@ def then_all_four_parents_received_all_programme_subsequent_reminders expect(email_deliveries).to include( matching_notify_email( to: email, - template: :consent_school_subsequent_reminder_mmrv + template: :consent_school_reminder_mmrv ) ) end diff --git a/spec/features/sessions_clinic_spec.rb b/spec/features/sessions_clinic_spec.rb index b68de71ff6..cfc42cc840 100644 --- a/spec/features/sessions_clinic_spec.rb +++ b/spec/features/sessions_clinic_spec.rb @@ -103,7 +103,7 @@ def and_i_record_a_new_vaccination def then_i_see_the_community_clinic_session click_on "Back" - expect(page).to have_content("HPV community clinic on 18 February 2024") + expect(page).to have_content("HPV community clinic") expect(page).to have_content("18 February 2024") end diff --git a/spec/features/sessions_school_spec.rb b/spec/features/sessions_school_spec.rb index dd3a361fbc..b985855365 100644 --- a/spec/features/sessions_school_spec.rb +++ b/spec/features/sessions_school_spec.rb @@ -406,7 +406,7 @@ def when_i_save_the_session def then_i_should_see_the_session_details expect(page).to have_content(@location.name.to_s) - expect(page).to have_content("10 – 11 March 2024") + expect(page).to have_content("10 to 11 March 2024") end def when_the_parent_visits_the_consent_form diff --git a/spec/features/td_ipv_vaccination_administered_spec.rb b/spec/features/td_ipv_vaccination_administered_spec.rb index ac507462b5..6631494381 100644 --- a/spec/features/td_ipv_vaccination_administered_spec.rb +++ b/spec/features/td_ipv_vaccination_administered_spec.rb @@ -201,7 +201,7 @@ def then_i_see_that_the_status_is_vaccinated end def and_i_see_the_vaccination_details - expect(page).to have_content("Vaccination records") + expect(page).to have_content("Vaccination outcomes") click_on Date.current.to_fs(:long) expect(page).to have_content("Vaccination details") diff --git a/spec/features/triage_required_spec.rb b/spec/features/triage_required_spec.rb index f20e70772c..554d5ea3d5 100644 --- a/spec/features/triage_required_spec.rb +++ b/spec/features/triage_required_spec.rb @@ -334,7 +334,7 @@ def then_i_see_the_update_triage_link def and_i_see_the_safe_triage_decision expect(page).to have_content( - "#{@user.full_name} decided that #{@patient_triage_needed.full_name} is safe to vaccinate." + "#{@user.full_name} decided that #{@patient_triage_needed.given_name} is safe to vaccinate." ) end @@ -357,7 +357,7 @@ def and_i_see_the_safe_triage_decision_with_method(method) ) expect(page).to have_content( - "#{@user.full_name} decided that #{patient.full_name} is safe to vaccinate using the #{method} vaccine only." + "#{@user.full_name} decided that #{patient.given_name} is safe to vaccinate using the #{method} vaccine only." ) end diff --git a/spec/features/vaccination_offline_spec.rb b/spec/features/vaccination_offline_spec.rb index bfa452882c..1fb08f72e8 100644 --- a/spec/features/vaccination_offline_spec.rb +++ b/spec/features/vaccination_offline_spec.rb @@ -35,7 +35,8 @@ then_i_see_the_successful_import when_i_navigate_to_the_clinic_page then_i_see_the_uploaded_vaccination_outcomes_reflected_in_the_session - and_the_clinic_location_is_displayed + when_i_click_on_the_vaccination + then_the_clinic_location_is_displayed when_vaccination_confirmations_are_sent then_an_email_is_sent_to_the_parent_confirming_the_vaccination @@ -577,7 +578,15 @@ def then_i_see_the_uploaded_vaccination_outcomes_reflected_in_the_session expect(page).to have_content("Vaccinated") end - def and_the_clinic_location_is_displayed + def when_i_click_on_the_vaccination + click_on VaccinationRecord + .where(patient: @restricted_vaccinated_patient) + .sole + .performed_at + .to_fs(:long) + end + + def then_the_clinic_location_is_displayed expect(page).to have_content("Westfield Shopping Centre") end diff --git a/spec/features/vaccination_programmes_spec.rb b/spec/features/vaccination_programmes_spec.rb index 9657c12a1e..3208a6820e 100644 --- a/spec/features/vaccination_programmes_spec.rb +++ b/spec/features/vaccination_programmes_spec.rb @@ -110,7 +110,10 @@ def and_the_table_shows_other_eligible_vaccinations "td.nhsuk-table__cell", text: "Needs consent" ) - expect(row).to have_selector("td.nhsuk-table__cell", text: "No response") + expect(row).to have_selector( + "td.nhsuk-table__cell", + text: "Request not scheduled" + ) end expect(page).to have_selector( @@ -121,7 +124,10 @@ def and_the_table_shows_other_eligible_vaccinations "td.nhsuk-table__cell", text: "Needs consent" ) - expect(row).to have_selector("td.nhsuk-table__cell", text: "No response") + expect(row).to have_selector( + "td.nhsuk-table__cell", + text: "Request not scheduled" + ) end end diff --git a/spec/fixtures/files/class_import/empty.csv b/spec/fixtures/files/class_import/empty.csv new file mode 100644 index 0000000000..327b41bb42 --- /dev/null +++ b/spec/fixtures/files/class_import/empty.csv @@ -0,0 +1 @@ +PARENT_1_NAME,PARENT_1_RELATIONSHIP,PARENT_1_EMAIL,PARENT_1_PHONE,PARENT_2_NAME,PARENT_2_RELATIONSHIP,PARENT_2_EMAIL,PARENT_2_PHONE,CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_PREFERRED_FIRST_NAME,CHILD_DATE_OF_BIRTH,CHILD_YEAR_GROUP,CHILD_ADDRESS_LINE_1,CHILD_ADDRESS_LINE_2,CHILD_TOWN,CHILD_POSTCODE,CHILD_REGISTRATION,CHILD_NHS_NUMBER diff --git a/spec/fixtures/files/cohort_import/empty.csv b/spec/fixtures/files/cohort_import/empty.csv new file mode 100644 index 0000000000..cb38983d1c --- /dev/null +++ b/spec/fixtures/files/cohort_import/empty.csv @@ -0,0 +1 @@ +CHILD_SCHOOL_URN,PARENT_1_NAME,PARENT_1_RELATIONSHIP,PARENT_1_EMAIL,PARENT_1_PHONE,PARENT_2_NAME,PARENT_2_RELATIONSHIP,PARENT_2_EMAIL,PARENT_2_PHONE,CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_PREFERRED_GIVEN_NAME,CHILD_DATE_OF_BIRTH,CHILD_YEAR_GROUP,CHILD_ADDRESS_LINE_1,CHILD_ADDRESS_LINE_2,CHILD_TOWN,CHILD_POSTCODE,CHILD_NHS_NUMBER diff --git a/spec/fixtures/files/cohort_import/valid_with_bom.csv b/spec/fixtures/files/cohort_import/valid_with_bom.csv new file mode 100644 index 0000000000..43f8051f72 --- /dev/null +++ b/spec/fixtures/files/cohort_import/valid_with_bom.csv @@ -0,0 +1,2 @@ +CHILD_FIRST_NAME,CHILD_LAST_NAME,CHILD_DATE_OF_BIRTH,CHILD_POSTCODE,CHILD_SCHOOL_URN +Jennifer,Clarke,2010-01-01,SW1A 1AA,123456 \ No newline at end of file diff --git a/spec/fixtures/files/fhir/search_responses/bad_immunization_target.json b/spec/fixtures/files/fhir/search_responses/bad_immunization_target.json index e93dca2838..7833c98466 100644 --- a/spec/fixtures/files/fhir/search_responses/bad_immunization_target.json +++ b/spec/fixtures/files/fhir/search_responses/bad_immunization_target.json @@ -4,7 +4,7 @@ "link": [ { "relation": "self", - "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization?immunization+target=3IN1,MMR,MMRV,FLU,HPV,MENACWY&_include=Immunization:patient&patient.identifier=https://fhir.nhs.uk/Id/nhs-number|9793826983" + "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/FHIR/R4/Immunization?immunization+target=3IN1,MMR,MMRV,FLU,HPV,MENACWY&patient.identifier=https://fhir.nhs.uk/Id/nhs-number|9793826983" } ], "entry": [ diff --git a/spec/fixtures/files/fhir/search_responses/immunization_target_both.json b/spec/fixtures/files/fhir/search_responses/immunization_target_both.json index ee92683b63..81c4a13d9f 100644 --- a/spec/fixtures/files/fhir/search_responses/immunization_target_both.json +++ b/spec/fixtures/files/fhir/search_responses/immunization_target_both.json @@ -4,7 +4,7 @@ "link": [ { "relation": "self", - "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?-date.from=2025-08-01\u0026-date.to=2025-10-01\u0026-immunization.target=FLU,HPV,MENACWY,3IN1,MMR,MMRV\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357" + "url": "https://int.api.service.nhs.uk/immunisation-fhir-api/Immunization?-date.from=2025-08-01\u0026-date.to=2025-10-01\u0026-immunization.target=FLU,HPV,MENACWY,3IN1,MMR,MMRV\u0026immunization.target=FLU,HPV,MENACWY,3IN1,MMR,MMRV\u0026patient.identifier=https%3A%2F%2Ffhir.nhs.uk%2FId%2Fnhs-number%7C9449308357" } ], "entry": [ diff --git a/spec/fixtures/files/immunisation_import/national_reporting/valid_mixed_flu_hpv.csv b/spec/fixtures/files/immunisation_import/national_reporting/valid_mixed_flu_hpv.csv index 0388a8b212..5781aff71d 100644 --- a/spec/fixtures/files/immunisation_import/national_reporting/valid_mixed_flu_hpv.csv +++ b/spec/fixtures/files/immunisation_import/national_reporting/valid_mixed_flu_hpv.csv @@ -1,4 +1,4 @@ -ORGANISATION_CODE,SCHOOL_URN,SCHOOL_NAME,NHS_NUMBER,PERSON_FORENAME,PERSON_SURNAME,PERSON_DOB,PERSON_GENDER_CODE,PERSON_POSTCODE,VACCINATED,DATE_OF_VACCINATION,VACCINE_GIVEN,BATCH_NUMBER,BATCH_EXPIRY_DATE,ANATOMICAL_SITE,PERFORMING_PROFESSIONAL_FORENAME,PERFORMING_PROFESSIONAL_SURNAME,REASON_NOT_VACCINATED,DOSE_SEQUENCE,CONSENT_TYPE,LOCAL_PATIENT_ID,LOCAL_PATIENT_ID_URI,CARE_SETTING +ORGANISATION_CODE,SCHOOL_URN,SCHOOL_NAME,NHS_NUMBER,PERSON_FORENAME,PERSON_SURNAME,PERSON_DOB,PERSON_GENDER_CODE,PERSON_POSTCODE,VACCINATED,DATE_OF_VACCINATION,VACCINE_GIVEN,BATCH_NUMBER,BATCH_EXPIRY_DATE,ANATOMICAL_SITE,PERFORMING_PROFESSIONAL_FORENAME,PERFORMING_PROFESSIONAL_SURNAME,REASON_NOT_VACCINATED,DOSE_SEQUENCE,CONSENT_TYPE,LOCAL_PATIENT_ID,LOCAL_PATIENT_ID_URI,CARE_SETTING RYG,100000,Hogwarts,9449308357,Harry,Potter,20010101,male,AA11 1AA,Y,20251109,AstraZeneca Fluenz LAIV,ABC123,20251030,nasal,Albus,Dumbledore,,,Parental Consent,1234,ABCD, RYG,100000,,9999075320,Ron,Weasley,20010102,not knOWn,S1 1AA,,20251109,Gardasil,ABC123,20251030,right deltoid,,,,1,,1235,ABCE,community setting RYG,100000,,9990000018,Hermione,Granger,20010103,Female,S2 1AA,N,20251109,"","","","",,,,"",,1235,ABCE,"" diff --git a/spec/fixtures/files/immunisation_import/point_of_care/valid_with_bom.csv b/spec/fixtures/files/immunisation_import/point_of_care/valid_with_bom.csv new file mode 100644 index 0000000000..d460154b0b --- /dev/null +++ b/spec/fixtures/files/immunisation_import/point_of_care/valid_with_bom.csv @@ -0,0 +1,2 @@ +ORGANISATION_CODE,SCHOOL_URN,SCHOOL_NAME,NHS_NUMBER,PERSON_FORENAME,PERSON_SURNAME,PERSON_DOB,PERSON_GENDER_CODE,PERSON_POSTCODE,VACCINATED,DATE_OF_VACCINATION,PROGRAMME,VACCINE_GIVEN,BATCH_NUMBER,BATCH_EXPIRY_DATE,ANATOMICAL_SITE,PERFORMING_PROFESSIONAL_FORENAME,PERFORMING_PROFESSIONAL_SURNAME,REASON_NOT_VACCINATED,CONSENT_TYPE,LOCAL_PATIENT_ID,LOCAL_PATIENT_ID_URI +R1L,120026,shaftesbury junior school ,7420180008,Chyna,Pickle,20120912,Not Specified,LE3 2DA,Yes,20250514,Flu,,123013325,20220730,Left Buttock,Vaccinator1,Name1,,Parental Consent,LocalPatient1,www.LocalPatient1 \ No newline at end of file diff --git a/spec/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb index a54278cc08..42257ad86c 100644 --- a/spec/helpers/sessions_helper_spec.rb +++ b/spec/helpers/sessions_helper_spec.rb @@ -50,7 +50,7 @@ create(:session, dates: [Date.new(2025, 1, 1), Date.new(2025, 1, 2)]) end - it { should eq("1 – 2 January 2025") } + it { should eq("1 to 2 January 2025") } end context "with three dates" do @@ -65,7 +65,7 @@ ) end - it { should eq("1 – 3 January 2025 (3 dates)") } + it { should eq("1 to 3 January 2025 (3 dates)") } end context "with dates across multiple months" do @@ -73,7 +73,7 @@ create(:session, dates: [Date.new(2025, 1, 31), Date.new(2025, 2, 1)]) end - it { should eq("31 January – 1 February 2025") } + it { should eq("31 January to 1 February 2025") } end context "with dates across multiple years" do @@ -81,7 +81,7 @@ create(:session, dates: [Date.new(2025, 12, 31), Date.new(2026, 1, 1)]) end - it { should eq("31 December 2025 – 1 January 2026") } + it { should eq("31 December 2025 to 1 January 2026") } end end @@ -137,25 +137,12 @@ subject(:session_title) { helper.session_title(session) } let(:programmes) { [Programme.hpv, Programme.flu] } + let(:session) { create(:session, :unscheduled, programmes:, location:) } context "with a generic clinic location" do let(:location) { create(:generic_clinic, programmes:) } - context "when unscheduled" do - let(:session) { create(:session, :unscheduled, programmes:, location:) } - - it { should eq("Flu and HPV community clinic") } - end - - context "when scheduled" do - let(:session) { create(:session, :today, programmes:, location:) } - - it do - expect(session_title).to eq( - "Flu and HPV community clinic on #{Date.current.to_fs(:long)}" - ) - end - end + it { should eq("Flu and HPV community clinic") } end context "with a school location" do @@ -163,20 +150,29 @@ create(:gias_school, name: "Waterloo Road", programmes:) end - context "when unscheduled" do - let(:session) { create(:session, :unscheduled, programmes:, location:) } + it { should eq("Flu and HPV session at Waterloo Road") } + end + end - it { should eq("Flu and HPV session at Waterloo Road") } - end + describe "#session_caption" do + subject(:session_caption) { helper.session_caption(session) } - context "when scheduled" do - let(:session) { create(:session, :today, programmes:, location:) } + let(:programmes) { [Programme.hpv, Programme.flu] } + let(:location) { create(:gias_school, name: "Waterloo Road", programmes:) } + + context "when unscheduled" do + let(:session) { create(:session, :unscheduled, programmes:, location:) } + + it { should eq("Reception and years 1 to 11") } + end + + context "when scheduled" do + let(:session) { create(:session, :today, programmes:, location:) } - it do - expect(session_title).to eq( - "Flu and HPV session at Waterloo Road on #{Date.current.to_fs(:long)}" - ) - end + it do + expect(session_caption).to eq( + "#{Date.current.to_fs(:long)} – Reception and years 1 to 11" + ) end end end diff --git a/spec/jobs/bulk_remove_parent_relationships_job_spec.rb b/spec/jobs/bulk_remove_parent_relationships_job_spec.rb index 2b0c36afb4..7043750b9a 100644 --- a/spec/jobs/bulk_remove_parent_relationships_job_spec.rb +++ b/spec/jobs/bulk_remove_parent_relationships_job_spec.rb @@ -15,8 +15,8 @@ let(:team) { create(:team) } let(:file) { "valid.csv" } - let(:csv) { fixture_file_upload("class_import/#{file}") } - let(:import) { create(:class_import, csv:, team:) } + let(:csv_data) { file_fixture("class_import/#{file}").read } + let(:import) { create(:class_import, csv_data:, team:) } let(:user) { create(:user, team:) } diff --git a/spec/jobs/commit_patient_changesets_job_spec.rb b/spec/jobs/commit_patient_changesets_job_spec.rb index b2344f7d18..0ad569fbf4 100644 --- a/spec/jobs/commit_patient_changesets_job_spec.rb +++ b/spec/jobs/commit_patient_changesets_job_spec.rb @@ -9,11 +9,10 @@ let(:session) { create(:session, location:, programmes:, team:) } let(:file) { "valid.csv" } - let(:csv) { fixture_file_upload("class_import/#{file}") } - let(:import) { create(:class_import, csv:, session:, team:) } + let(:csv_data) { file_fixture("class_import/#{file}").read } + let(:import) { create(:class_import, csv_data:, session:, team:) } let!(:changesets) do - import.load_data! import.parse_rows! import.rows.each_with_index.map do |row, row_number| PatientChangeset.from_import_row(row:, import:, row_number:) diff --git a/spec/jobs/enqueue_update_patients_from_pds_job_spec.rb b/spec/jobs/enqueue_update_patients_from_pds_job_spec.rb index 3e7239af99..4f156a663f 100644 --- a/spec/jobs/enqueue_update_patients_from_pds_job_spec.rb +++ b/spec/jobs/enqueue_update_patients_from_pds_job_spec.rb @@ -14,7 +14,12 @@ end let!(:never_updated_patient) { create(:patient, updated_from_pds_at: nil) } - it "only queues jobs for the approriate patients" do + before do + Flipper.enable(:pds) + Flipper.enable(:pds_enqueue_bulk_updates) + end + + it "only queues jobs for the appropriate patients" do expect { perform_now }.to have_enqueued_job( PatientUpdateFromPDSJob ).exactly(4).times diff --git a/spec/jobs/patient_nhs_number_lookup_job_spec.rb b/spec/jobs/patient_nhs_number_lookup_job_spec.rb index 703eb0335e..0d90b780bc 100644 --- a/spec/jobs/patient_nhs_number_lookup_job_spec.rb +++ b/spec/jobs/patient_nhs_number_lookup_job_spec.rb @@ -5,7 +5,10 @@ let(:programme) { Programme.sample } - before { create(:gp_practice, ods_code: "H81109") } + before do + create(:gp_practice, ods_code: "H81109") + Flipper.enable(:pds) + end context "with an NHS number already" do let(:patient) { create(:patient, nhs_number: "0123456789") } diff --git a/spec/jobs/patient_update_from_pds_job_spec.rb b/spec/jobs/patient_update_from_pds_job_spec.rb index 2a49e71835..1f3f523d03 100644 --- a/spec/jobs/patient_update_from_pds_job_spec.rb +++ b/spec/jobs/patient_update_from_pds_job_spec.rb @@ -5,221 +5,195 @@ subject(:perform_now) { described_class.perform_now(patient) } - context "without an NHS number" do - let(:patient) { create(:patient, nhs_number: nil) } + context "when main switch is disabled" do + let!(:patient) { create(:patient, nhs_number: "9000000009") } - it "raises an error" do - expect { perform_now }.to raise_error( - PatientUpdateFromPDSJob::MissingNHSNumber - ) + it "makes no requests to PDS" do + expect(patient).not_to receive(:update_from_pds!) + # WebMock will raise an error if the request is made + perform_now end end - context "with an NHS number" do - before { create(:gp_practice, ods_code: "Y12345") } + context "when main switch is enabled" do + before { Flipper.enable(:pds) } - context "when the patient is valid" do - before do - stub_request( - :get, - Addressable::Template.new( - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/{nhs_number}" - ) - ).to_return( - body: file_fixture("pds/get-patient-response.json"), - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end - - let!(:patient) { create(:patient, nhs_number: "9000000009") } - - it "updates the patient details from PDS" do - expect(patient).to receive(:update_from_pds!) - perform_now - end + context "without an NHS number" do + let(:patient) { create(:patient, nhs_number: nil) } - it "doesn't change the NHS number" do - expect { perform_now }.not_to change(patient, :nhs_number) + it "raises an error" do + expect { perform_now }.to raise_error( + PatientUpdateFromPDSJob::MissingNHSNumber + ) end + end - it "doesn't delete the patient number" do - expect { perform_now }.not_to change(Patient, :count) - end + context "with an NHS number" do + before { create(:gp_practice, ods_code: "Y12345") } - it "doesn't queue a job to look up NHS number" do - expect { perform_now }.not_to have_enqueued_job(PDSCascadingSearchJob) - end - - context "when the patient is invalidated" do - let!(:patient) do - create(:patient, :invalidated, nhs_number: "9000000009") + context "when the patient is valid" do + before do + stub_request( + :get, + Addressable::Template.new( + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/{nhs_number}" + ) + ).to_return( + body: file_fixture("pds/get-patient-response.json"), + headers: { + "Content-Type" => "application/fhir+json" + } + ) end + let!(:patient) { create(:patient, nhs_number: "9000000009") } + it "updates the patient details from PDS" do expect(patient).to receive(:update_from_pds!) perform_now end - end - context "when the NHS number for the patient has changed" do - let!(:patient) { create(:patient, nhs_number: "0123456789") } + it "doesn't change the NHS number" do + expect { perform_now }.not_to change(patient, :nhs_number) + end - it "updates the NHS number" do - expect { perform_now }.to change(patient, :nhs_number).to( - "9000000009" - ) + it "doesn't delete the patient number" do + expect { perform_now }.not_to change(Patient, :count) end - context "when a patient already exists for the new NHS number" do - before { create(:patient, nhs_number: "9000000009") } + it "doesn't queue a job to look up NHS number" do + expect { perform_now }.not_to have_enqueued_job(PDSCascadingSearchJob) + end - it "deletes the patient without an NHS number" do - expect { perform_now }.to change(Patient, :count).by(-1) - expect { patient.reload }.to raise_error( - ActiveRecord::RecordNotFound - ) + context "when the patient is invalidated" do + let!(:patient) do + create(:patient, :invalidated, nhs_number: "9000000009") + end + + it "updates the patient details from PDS" do + expect(patient).to receive(:update_from_pds!) + perform_now end end - end - end - context "when the patient is invalid" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/invalid-patient-response.json"), - status: 404, - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end + context "when the NHS number for the patient has changed" do + let!(:patient) { create(:patient, nhs_number: "0123456789") } - let(:patient) { create(:patient, nhs_number: "9000000009") } + it "updates the NHS number" do + expect { perform_now }.to change(patient, :nhs_number).to( + "9000000009" + ) + end - it "marks the patient as invalid" do - expect(patient).to receive(:invalidate!) - perform_now - end + context "when a patient already exists for the new NHS number" do + before { create(:patient, nhs_number: "9000000009") } - it "queues a job to look up NHS number using PDS cascading search" do - expect { perform_now }.to have_enqueued_job(PDSCascadingSearchJob).with( - patient - ) + it "deletes the patient without an NHS number" do + expect { perform_now }.to change(Patient, :count).by(-1) + expect { patient.reload }.to raise_error( + ActiveRecord::RecordNotFound + ) + end + end + end end - end - context "when the NHS number is invalid" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/invalid-nhs-number-response.json"), - status: 400, - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end + context "when the patient is invalid" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/invalid-patient-response.json"), + status: 404, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end - let(:patient) { create(:patient, nhs_number: "9000000009") } + let(:patient) { create(:patient, nhs_number: "9000000009") } - it "marks the patient as invalid" do - expect(patient).to receive(:invalidate!) - perform_now - end + it "marks the patient as invalid" do + expect(patient).to receive(:invalidate!) + perform_now + end - it "doesn't remove the NHS number" do - expect { perform_now }.not_to change(patient, :nhs_number) + it "queues a job to look up NHS number using PDS cascading search" do + expect { perform_now }.to have_enqueued_job( + PDSCascadingSearchJob + ).with(patient) + end end - it "queues a job to look up NHS number using PDS cascading search" do - expect { perform_now }.to have_enqueued_job(PDSCascadingSearchJob).with( - patient - ) - end - end + context "when the NHS number is invalid" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/invalid-nhs-number-response.json"), + status: 400, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end - context "when the NHS number is not found" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/not-found-patient-response.json"), - status: 404, - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end + let(:patient) { create(:patient, nhs_number: "9000000009") } - let(:patient) { create(:patient, nhs_number: "9000000009") } + it "marks the patient as invalid" do + expect(patient).to receive(:invalidate!) + perform_now + end - it "doesn't mark the patient as invalid" do - expect(patient).not_to receive(:invalidate!) - perform_now - end + it "doesn't remove the NHS number" do + expect { perform_now }.not_to change(patient, :nhs_number) + end - it "removes the NHS number" do - expect { perform_now }.to change(patient, :nhs_number).to(nil) + it "queues a job to look up NHS number using PDS cascading search" do + expect { perform_now }.to have_enqueued_job( + PDSCascadingSearchJob + ).with(patient) + end end - it "queues a job to look up NHS number using PDS cascading search" do - expect { perform_now }.to have_enqueued_job(PDSCascadingSearchJob).with( - patient - ) - end - end - end + context "when the NHS number is not found" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/not-found-patient-response.json"), + status: 404, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end - context "when search_results are provided" do - let!(:patient) { create(:patient, nhs_number: nil) } - - let(:search_results) do - [ - { - step: "no_fuzzy_with_wildcard_family_name", - result: "one_match", - nhs_number: "9000000009", - created_at: Time.zone.now - }.with_indifferent_access, - { - step: "no_fuzzy_with_wildcard_given_name", - result: "one_match", - nhs_number: "9000000009", - created_at: 1.minute.ago - }.with_indifferent_access - ] - end + let(:patient) { create(:patient, nhs_number: "9000000009") } - before { stub_pds_get_nhs_number_to_return_a_patient("9000000009") } + it "doesn't mark the patient as invalid" do + expect(patient).not_to receive(:invalidate!) + perform_now + end - it "imports the search results for the patient" do - expect { described_class.perform_now(patient, search_results) }.to change( - PDSSearchResult, - :count - ).by(2) + it "removes the NHS number" do + expect { perform_now }.to change(patient, :nhs_number).to(nil) + end - created_results = PDSSearchResult.where(patient_id: patient.id) - expect(created_results.pluck(:step)).to match_array( - %w[no_fuzzy_with_wildcard_family_name no_fuzzy_with_wildcard_given_name] - ) - expect(created_results.pluck(:nhs_number)).to all(eq("9000000009")) + it "queues a job to look up NHS number using PDS cascading search" do + expect { perform_now }.to have_enqueued_job( + PDSCascadingSearchJob + ).with(patient) + end + end end - it "does not raise an error when NHS number is nil but search_results are present" do - expect { - described_class.perform_now(patient, search_results) - }.not_to raise_error - end + context "when search_results are provided" do + let!(:patient) { create(:patient, nhs_number: nil) } - context "with conflicting NHS numbers in search results" do let(:search_results) do [ { @@ -231,15 +205,57 @@ { step: "no_fuzzy_with_wildcard_given_name", result: "one_match", - nhs_number: "9000000018", + nhs_number: "9000000009", created_at: 1.minute.ago }.with_indifferent_access ] end - it "doesn't update the patient" do - expect(patient).not_to receive(:update_from_pds!) - described_class.perform_now(patient, search_results) + before { stub_pds_get_nhs_number_to_return_a_patient("9000000009") } + + it "imports the search results for the patient" do + expect { + described_class.perform_now(patient, search_results) + }.to change(PDSSearchResult, :count).by(2) + + created_results = PDSSearchResult.where(patient_id: patient.id) + expect(created_results.pluck(:step)).to match_array( + %w[ + no_fuzzy_with_wildcard_family_name + no_fuzzy_with_wildcard_given_name + ] + ) + expect(created_results.pluck(:nhs_number)).to all(eq("9000000009")) + end + + it "does not raise an error when NHS number is nil but search_results are present" do + expect { + described_class.perform_now(patient, search_results) + }.not_to raise_error + end + + context "with conflicting NHS numbers in search results" do + let(:search_results) do + [ + { + step: "no_fuzzy_with_wildcard_family_name", + result: "one_match", + nhs_number: "9000000009", + created_at: Time.zone.now + }.with_indifferent_access, + { + step: "no_fuzzy_with_wildcard_given_name", + result: "one_match", + nhs_number: "9000000018", + created_at: 1.minute.ago + }.with_indifferent_access + ] + end + + it "doesn't update the patient" do + expect(patient).not_to receive(:update_from_pds!) + described_class.perform_now(patient, search_results) + end end end end diff --git a/spec/jobs/process_consent_form_job_spec.rb b/spec/jobs/process_consent_form_job_spec.rb index 025d16216b..4face12d5f 100644 --- a/spec/jobs/process_consent_form_job_spec.rb +++ b/spec/jobs/process_consent_form_job_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe ProcessConsentFormJob do +describe ProcessConsentFormJob, :pds do subject(:perform) { described_class.new.perform(consent_form.id) } let(:team) { create(:team) } diff --git a/spec/jobs/process_patient_changeset_job_spec.rb b/spec/jobs/process_patient_changeset_job_spec.rb index c9fdcbc772..252c5cf58b 100644 --- a/spec/jobs/process_patient_changeset_job_spec.rb +++ b/spec/jobs/process_patient_changeset_job_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -describe ProcessPatientChangesetJob do +describe ProcessPatientChangesetJob, :pds do include ActiveJob::TestHelper let(:programme) { Programme.hpv } @@ -176,15 +176,15 @@ create_list(:patient_changeset, 4, import:, status: :processed) end - context "when import_search_pds flag is disabled" do + context "when pds_search_during_import flag is disabled" do it "doesn't change import status" do described_class.perform_now(patient_changeset.id) expect(import.reload.status).to eq("pending_import") end end - context "when import_search_pds flag is enabled" do - before { Flipper.enable(:import_search_pds) } + context "when pds_search_during_import flag is enabled" do + before { Flipper.enable(:pds_search_during_import) } it "marks import as low_pds_match_rate and stops" do described_class.perform_now(patient_changeset.id) diff --git a/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb b/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb index 265f5521e1..ba3254771b 100644 --- a/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb +++ b/spec/jobs/send_automatic_school_consent_reminders_job_spec.rb @@ -255,11 +255,13 @@ expect( consent_notifications.find_by!(patient: patient_not_sent_reminder) ).to be_initial_reminder + expect( consent_notifications.find_by!( patient: patient_not_sent_reminder_joined_after_first_date ) ).to be_initial_reminder + expect( consent_notifications.find_by!( patient: patient_with_initial_reminder_sent diff --git a/spec/lib/careplus/client_spec.rb b/spec/lib/careplus/client_spec.rb new file mode 100644 index 0000000000..632b6b80bb --- /dev/null +++ b/spec/lib/careplus/client_spec.rb @@ -0,0 +1,94 @@ +# frozen_string_literal: true + +describe Careplus::Client do + subject(:response) do + described_class.send_csv(username:, password:, namespace:, payload:) + end + + let(:base_url) { "http://test.careplus.example.com" } + let(:username) { "test_user" } + let(:password) { "test_password" } + let(:namespace) { "TEST" } + let(:payload) { "col1,col2\nval1,val2\n" } + let(:endpoint_path) { "/#{namespace}/soap.SchImms.cls" } + let(:full_url) { "#{base_url}#{endpoint_path}" } + + before do + allow(Settings.careplus).to receive(:base_url).and_return(base_url) + stub_request(:post, full_url).to_return( + status: 200, + body: "OK" + ) + end + + it "sends a POST request to the base URL with the endpoint path" do + response + expect(WebMock).to have_requested(:post, full_url) + end + + it "sets the Content-Type header" do + response + expect(WebMock).to have_requested(:post, full_url).with( + headers: { + "Content-Type" => "text/xml; charset=utf-8" + } + ) + end + + it "includes the username in the SOAP body" do + response + expect(WebMock).to have_requested(:post, full_url).with(body: /test_user/) + end + + it "includes the password in the SOAP body" do + response + expect(WebMock).to have_requested(:post, full_url).with( + body: /test_password/ + ) + end + + it "includes the CSV payload in the SOAP body" do + response + expect(WebMock).to have_requested(:post, full_url).with(body: /col1,col2/) + end + + it "uses the namespace in the SOAP target namespace URI" do + response + expect(WebMock).to have_requested(:post, full_url).with( + body: %r{careplus\.syhapp\.thirdparty\.nhs\.uk/TEST/webservices} + ) + end + + it "returns the HTTP response" do + expect(response).to be_a(Net::HTTPSuccess) + end + + context "when the CSV payload contains XML special characters" do + let(:payload) { "name\n & \"School\"\n" } + + it "HTML-escapes the payload before embedding it in the envelope" do + response + expect(WebMock).to have_requested(:post, full_url).with( + body: /<Test> & "School"/ + ) + end + end + + context "when base_url uses HTTPS" do + before do + allow(Settings.careplus).to receive(:base_url).and_return( + "https://careplus.example.com" + ) + stub_request( + :post, + "https://careplus.example.com#{endpoint_path}" + ).to_return(status: 200, body: "") + end + + it "makes the request over SSL" do + allow(Net::HTTP).to receive(:new).and_call_original + response + expect(Net::HTTP).to have_received(:new).with("careplus.example.com", 443) + end + end +end diff --git a/spec/lib/csv_parser/field_spec.rb b/spec/lib/csv_parser/field_spec.rb new file mode 100644 index 0000000000..219a81ea7e --- /dev/null +++ b/spec/lib/csv_parser/field_spec.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +describe CSVParser::Field do + describe "#to_date" do + subject(:to_date) { field.to_date } + + let(:field) { described_class.new(value, "A", 2, "date") } + + context "when value is nil" do + let(:value) { nil } + + it { should be_nil } + end + + context "when value is blank" do + let(:value) { "" } + + it { should be_nil } + end + + context "when value is not a date" do + let(:value) { "not a date" } + + it { should be_nil } + end + + context "with format DD/MM/YYYY" do + let(:value) { "01/02/2025" } + + it { should eq(Date.new(2025, 2, 1)) } + end + + context "with format YYYY-MM-DD" do + let(:value) { "2025-02-01" } + + it { should eq(Date.new(2025, 2, 1)) } + end + + context "with format YYYYMMDD" do + let(:value) { "20250201" } + + it { should eq(Date.new(2025, 2, 1)) } + end + + context "with format DD/MM/YY (2-digit year)" do + let(:value) { "01/02/25" } + + it { should be_nil } + end + + context "with format YY-MM-DD (2-digit year)" do + let(:value) { "25-02-01" } + + it { should be_nil } + end + + context "with format YYMMDD (2-digit year)" do + let(:value) { "250201" } + + it { should be_nil } + end + + context "with a 3-digit year" do + let(:value) { "01/02/999" } + + it { should be_nil } + end + + context "with an impossible date" do + let(:value) { "31/02/2025" } + + it { should be_nil } + end + + context "with an invalid month" do + let(:value) { "01/13/2025" } + + it { should be_nil } + end + end +end diff --git a/spec/lib/nhs/pds_spec.rb b/spec/lib/nhs/pds_spec.rb index 62547ff0bb..dbaff9e776 100644 --- a/spec/lib/nhs/pds_spec.rb +++ b/spec/lib/nhs/pds_spec.rb @@ -4,87 +4,87 @@ describe "#get_patient" do subject(:get_patient) { described_class.get_patient("9000000009") } - context "with pds.enabled is false" do - before { Settings.pds.enabled = false } - - after { Settings.reload! } - + context "when feature flag is disabled" do it { should be_nil } end - context "with a successful response" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/get-patient-response.json"), - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end - - it "sends a GET request to retrieve a patient by their NHS number" do - response = get_patient - expect(response.body).to include("id" => "9000000009") - end - end - - context "with an invalid NHS number" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/invalid-nhs-number-response.json"), - status: 400, - headers: { - "Content-Type" => "application/fhir+json" - } - ) + context "when feature flag is enabled" do + before { Flipper.enable(:pds) } + + context "with a successful response" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/get-patient-response.json"), + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "sends a GET request to retrieve a patient by their NHS number" do + response = get_patient + expect(response.body).to include("id" => "9000000009") + end end - it "raises an error" do - expect { get_patient }.to raise_error(NHS::PDS::InvalidNHSNumber) + context "with an invalid NHS number" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/invalid-nhs-number-response.json"), + status: 400, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "raises an error" do + expect { get_patient }.to raise_error(NHS::PDS::InvalidNHSNumber) + end end - end - context "with an invalidated resource response" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/invalid-patient-response.json"), - status: 404, - headers: { - "Content-Type" => "application/fhir+json" - } - ) + context "with an invalidated resource response" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/invalid-patient-response.json"), + status: 404, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "raises an error" do + expect { get_patient }.to raise_error(NHS::PDS::InvalidatedResource) + end end - it "raises an error" do - expect { get_patient }.to raise_error(NHS::PDS::InvalidatedResource) - end - end - - context "with a resource not found response" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" - ).to_return( - body: file_fixture("pds/not-found-patient-response.json"), - status: 404, - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end - - it "raises an error" do - expect { get_patient }.to raise_error(NHS::PDS::PatientNotFound) + context "with a resource not found response" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient/9000000009" + ).to_return( + body: file_fixture("pds/not-found-patient-response.json"), + status: 404, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "raises an error" do + expect { get_patient }.to raise_error(NHS::PDS::PatientNotFound) + end end end end @@ -98,96 +98,96 @@ ) end - context "with pds.enabled is false" do - before { Settings.pds.enabled = false } - - after { Settings.reload! } - + context "when feature flag is disabled" do it { should be_nil } end - context "with a successful response" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" - ).with( - query: { - birthdate: "eq1939-01-09", - family: "Lawman", - gender: "female" - } - ).to_return( - body: file_fixture("pds/search-patients-response.json"), - headers: { - "Content-Type" => "application/fhir+json" - } - ) + context "when feature flag is enabled" do + before { Flipper.enable(:pds) } + + context "with a successful response" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" + ).with( + query: { + birthdate: "eq1939-01-09", + family: "Lawman", + gender: "female" + } + ).to_return( + body: file_fixture("pds/search-patients-response.json"), + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "sends a GET request to with the provided attributes" do + response = search_patients + expect(response.body).to include("total" => 1) + end end - it "sends a GET request to with the provided attributes" do - response = search_patients - expect(response.body).to include("total" => 1) + context "with an invalid search data response" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" + ).with( + query: { + birthdate: "eq1939-01-09", + family: "Lawman", + gender: "female" + } + ).to_return( + body: file_fixture("pds/invalid-search-data.json"), + status: 400, + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "raises an error" do + expect { search_patients }.to raise_error(NHS::PDS::InvalidSearchData) + end end - end - context "with an invalid search data response" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" - ).with( - query: { - birthdate: "eq1939-01-09", - family: "Lawman", - gender: "female" - } - ).to_return( - body: file_fixture("pds/invalid-search-data.json"), - status: 400, - headers: { - "Content-Type" => "application/fhir+json" - } - ) + context "with a too many matches response" do + before do + stub_request( + :get, + "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" + ).with( + query: { + birthdate: "eq1939-01-09", + family: "Lawman", + gender: "female" + } + ).to_return( + body: file_fixture("pds/too-many-matches.json"), + headers: { + "Content-Type" => "application/fhir+json" + } + ) + end + + it "raises an error" do + expect { search_patients }.to raise_error(NHS::PDS::TooManyMatches) + end end - it "raises an error" do - expect { search_patients }.to raise_error(NHS::PDS::InvalidSearchData) + it "raises an error if an unrecognised attribute is provided" do + expect { + described_class.search_patients( + given: "Eldreda", + family_name: "Lawman", + date_of_birth: "1939-01-09" + ) + }.to raise_error("Unrecognised attributes: family_name, date_of_birth") end end - - context "with a too many matches response" do - before do - stub_request( - :get, - "https://sandbox.api.service.nhs.uk/personal-demographics/FHIR/R4/Patient" - ).with( - query: { - birthdate: "eq1939-01-09", - family: "Lawman", - gender: "female" - } - ).to_return( - body: file_fixture("pds/too-many-matches.json"), - headers: { - "Content-Type" => "application/fhir+json" - } - ) - end - - it "raises an error" do - expect { search_patients }.to raise_error(NHS::PDS::TooManyMatches) - end - end - - it "raises an error if an unrecognised attribute is provided" do - expect { - described_class.search_patients( - given: "Eldreda", - family_name: "Lawman", - date_of_birth: "1939-01-09" - ) - }.to raise_error("Unrecognised attributes: family_name, date_of_birth") - end end end diff --git a/spec/lib/notifier/patient_spec.rb b/spec/lib/notifier/patient_spec.rb index e8fb6b5ad2..06290f1252 100644 --- a/spec/lib/notifier/patient_spec.rb +++ b/spec/lib/notifier/patient_spec.rb @@ -14,7 +14,10 @@ let(:disease_types) { programmes.flat_map(&:disease_types).uniq.presence } let(:programme_types) { programmes.map(&:type) } let(:team) { create(:team, programmes:) } - let(:session) { create(:session, location:, programmes:, team:) } + let(:send_consent_requests_at) { nil } + let(:session) do + create(:session, location:, programmes:, team:, send_consent_requests_at:) + end let(:team_location) { session.team_location } context "with a session" do @@ -40,6 +43,38 @@ expect(consent_notification.sent_at).to eq(today) end + context "when the consent request was scheduled for the future" do + let(:send_consent_requests_at) { today + 1.day } + + it "updates the programme status after sending the request" do + travel_to(today) do + PatientStatusUpdater.call(patient:) + + expect( + patient.programme_status( + programmes.first, + academic_year: session.academic_year + ) + ).to be_needs_consent_request_scheduled + + notifier.send_consent_request(programmes, session:, sent_by:) + + expect(PatientStatusUpdaterJob).to have_enqueued_sidekiq_job( + patient.id + ) + + PatientStatusUpdaterJob.drain + + expect( + patient.programme_status( + programmes.first, + academic_year: session.academic_year + ).reload + ).to be_needs_consent_no_response + end + end + end + it "enqueues an email per parent" do expect { send_consent_request }.to have_delivered_email( :consent_school_request_hpv @@ -598,7 +633,7 @@ it "enqueues an email per parent with the correct args" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_hpv + :consent_school_reminder_hpv ).with( disease_types:, parent: parents.first, @@ -606,7 +641,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_email(:consent_school_initial_reminder_hpv).with( + ).and have_delivered_email(:consent_school_reminder_hpv).with( disease_types:, parent: parents.second, patient:, @@ -660,7 +695,7 @@ it "enqueues an email per parent" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_doubles + :consent_school_reminder_doubles ).twice end @@ -676,7 +711,7 @@ it "enqueues an email per parent" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_flu + :consent_school_reminder_flu ).twice end @@ -692,7 +727,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_mmr + :consent_school_reminder_mmr ).twice end @@ -709,7 +744,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_mmr + :consent_school_reminder_mmr ).twice end @@ -727,7 +762,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_mmrv + :consent_school_reminder_mmrv ).twice end @@ -744,7 +779,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_initial_reminder_mmrv + :consent_school_reminder_mmrv ).twice end @@ -801,7 +836,7 @@ it "enqueues an email per parent" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_hpv + :consent_school_reminder_hpv ).with( disease_types:, parent: parents.first, @@ -809,9 +844,7 @@ programme_types:, session:, sent_by: - ).and have_delivered_email( - :consent_school_subsequent_reminder_hpv - ).with( + ).and have_delivered_email(:consent_school_reminder_hpv).with( disease_types:, parent: parents.second, patient:, @@ -865,7 +898,7 @@ it "enqueues an email per parent" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_doubles + :consent_school_reminder_doubles ).twice end @@ -881,7 +914,7 @@ it "enqueues an email per parent" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_flu + :consent_school_reminder_flu ).twice end @@ -902,7 +935,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_mmr + :consent_school_reminder_mmr ).twice end @@ -919,7 +952,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_mmr + :consent_school_reminder_mmr ).twice end @@ -938,7 +971,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_mmrv + :consent_school_reminder_mmrv ).twice end @@ -955,7 +988,7 @@ it "enqueues an email" do expect { send_consent_reminder }.to have_delivered_email( - :consent_school_subsequent_reminder_mmrv + :consent_school_reminder_mmrv ).twice end diff --git a/spec/lib/patient_merger_spec.rb b/spec/lib/patient_merger_spec.rb index 4235f5f63b..3b0b5b4b46 100644 --- a/spec/lib/patient_merger_spec.rb +++ b/spec/lib/patient_merger_spec.rb @@ -346,7 +346,7 @@ it "removes the archive reasons from the patient" do expect { call }.to change(ArchiveReason, :count).by(-1) - expect(patient_to_keep.archived?(team:)).to be(false) + expect(patient_to_keep.archived?(team_id: team.id)).to be(false) end end @@ -362,7 +362,7 @@ it "removes the archive reason from the patient" do expect { call }.to change(ArchiveReason, :count).by(-1) - expect(patient_to_keep.archived?(team:)).to be(false) + expect(patient_to_keep.archived?(team_id: team.id)).to be(false) end end @@ -405,7 +405,7 @@ it "keeps the archive reason on the merged patient" do expect { call }.to change(ArchiveReason, :count).by(-1) - expect(patient_to_keep.archived?(team:)).to be(true) + expect(patient_to_keep.archived?(team_id: team.id)).to be(true) end end diff --git a/spec/lib/patient_programme_status_resolver_spec.rb b/spec/lib/patient_programme_status_resolver_spec.rb index a84d7e5cab..cddb6b5073 100644 --- a/spec/lib/patient_programme_status_resolver_spec.rb +++ b/spec/lib/patient_programme_status_resolver_spec.rb @@ -125,7 +125,7 @@ prefix: "MMR", text: "Needs consent", colour: "blue", - details_text: "No response" + details_text: "Request not scheduled" } ) end diff --git a/spec/lib/status_generator/consent_spec.rb b/spec/lib/status_generator/consent_spec.rb index c83380f173..8e9b6f0242 100644 --- a/spec/lib/status_generator/consent_spec.rb +++ b/spec/lib/status_generator/consent_spec.rb @@ -8,17 +8,54 @@ patient:, consents: patient.consents, vaccination_records: patient.vaccination_records, - parents: patient.parents + parents: patient.parents, + sessions: [session], + consent_notifications: + patient.consent_notifications.includes(session: :team_location) ) end let(:parents) { [create(:parent)] } let(:patient) { create(:patient, parents:) } let(:programme) { Programme.sample } + let(:send_consent_requests_at) { nil } + let(:session) do + create(:session, programmes: [programme], send_consent_requests_at:) + end describe "#status" do subject { generator.status } + before do + create( + :consent_notification, + :request, + patient:, + session:, + programmes: [programme] + ) + end + + context "when a request has not yet been sent" do + before { ConsentNotification.delete_all } + + context "with consent requests scheduled" do + let(:send_consent_requests_at) { 1.day.from_now } + + it { should be(:request_scheduled) } + end + + context "with consent requests not scheduled" do + it { should be(:request_not_scheduled) } + end + + context "when consent requests scheduled date has already passed" do + let(:send_consent_requests_at) { 1.day.ago.to_date } + + it { should be(:request_not_scheduled) } + end + end + context "with no contactable parents" do let(:parents) { [] } diff --git a/spec/lib/status_generator/programme_spec.rb b/spec/lib/status_generator/programme_spec.rb index 59143e9922..3154b4e983 100644 --- a/spec/lib/status_generator/programme_spec.rb +++ b/spec/lib/status_generator/programme_spec.rb @@ -8,21 +8,40 @@ patient:, patient_locations: patient.patient_locations.includes( - location: :location_programme_year_groups + location: [ + :location_programme_year_groups, + { team_locations: { sessions: :session_programme_year_groups } } + ] ), consents: patient.consents, triages: patient.triages, attendance_record: patient.attendance_records.first, vaccination_records: patient.vaccination_records.order_by_performed_at, - parents: patient.parents + parents: patient.parents, + consent_notifications: + patient.consent_notifications.includes(session: :team_location) ) end let(:programme) { Programme.sample } - let(:session) { create(:session, programmes: [programme]) } let(:patient) { create(:patient, session:, parents:) } let(:parents) { [create(:parent)] } let(:location) { create(:gias_school) } + let(:send_consent_requests_at) { nil } + + let(:session) do + create(:session, programmes: [programme], send_consent_requests_at:) + end + + before do + create( + :consent_notification, + :request, + patient:, + session:, + programmes: [programme] + ) + end context "when already vaccinated" do let(:programme) { Programme.hpv } @@ -386,6 +405,32 @@ its(:vaccine_methods) { should be_nil } its(:without_gelatine) { should be_nil } + context "when the only session is completed" do + let(:session) { create(:session, :completed, programmes: [programme]) } + + before { ConsentNotification.delete_all } + + its(:status) { should be(:needs_consent_request_not_scheduled) } + end + + context "when the patient only has a generic clinic location" do + let(:team) { create(:team, programmes: [programme]) } + let(:location) { create(:generic_clinic, team:) } + let(:session) do + create( + :session, + team:, + location:, + programmes: [programme], + send_consent_requests_at: Date.tomorrow + ) + end + + before { ConsentNotification.delete_all } + + its(:status) { should be(:needs_consent_request_not_scheduled) } + end + context "when there are no contact details for parents and no consent request has been sent" do let(:parents) { [create(:parent, :non_contactable)] } @@ -398,6 +443,55 @@ its(:status) { should be(:needs_consent_no_contact_details) } end + context "when a consent request is scheduled for a future session" do + let(:send_consent_requests_at) { Date.tomorrow } + + before { ConsentNotification.delete_all } + + its(:status) { should be(:needs_consent_request_scheduled) } + end + + context "when the patient moved from a school to home educated" do + let(:send_consent_requests_at) { Date.tomorrow } + let(:school_move) do + create( + :school_move, + :to_home_educated, + patient:, + team: session.team, + academic_year: AcademicYear.current + ) + end + + before do + ConsentNotification.delete_all + school_move.confirm! + end + + its(:status) { should be(:needs_consent_request_not_scheduled) } + end + + context "when a consent requests are not scheduled to go out in the future" do + let(:send_consent_requests_at) { nil } + + before { ConsentNotification.delete_all } + + its(:status) { should be(:needs_consent_request_not_scheduled) } + end + + context "when there is a future session for a different programme" do + before do + create( + :session, + programmes: [Programme.hpv], + team_location: session.team_location, + send_consent_requests_at: Date.tomorrow + ) + end + + its(:status) { should be(:needs_consent_no_response) } + end + context "with a multi-dose programme" do let(:programme) { Programme.mmr } diff --git a/spec/lib/status_generator/triage_spec.rb b/spec/lib/status_generator/triage_spec.rb index 801f2e22ec..308f1d4e28 100644 --- a/spec/lib/status_generator/triage_spec.rb +++ b/spec/lib/status_generator/triage_spec.rb @@ -9,7 +9,9 @@ consents: patient.consents, triages: patient.triages, vaccination_records: patient.vaccination_records, - parents: patient.parents + parents: patient.parents, + sessions: [], + consent_notifications: patient.consent_notifications.request ) end diff --git a/spec/lib/update_patients_from_pds_spec.rb b/spec/lib/update_patients_from_pds_spec.rb index ad2f8cbc09..827a5e39c9 100644 --- a/spec/lib/update_patients_from_pds_spec.rb +++ b/spec/lib/update_patients_from_pds_spec.rb @@ -6,32 +6,49 @@ let(:patients) { Patient.order(:created_at) } let(:queue) { :pds } - after { Settings.reload! } - before do create_list(:patient, 2) create_list(:patient, 2, nhs_number: nil) end - context "when disabled" do - before { Settings.pds.enqueue_bulk_updates = false } + it "queues no jobs" do + expect { call }.not_to have_enqueued_job + end + + context "when feature is enabled but not main switch" do + before { Flipper.enable(:pds_enqueue_bulk_updates) } it "queues no jobs" do expect { call }.not_to have_enqueued_job end end - it "queues PDSCascadingSearchJob for patients without an NHS number" do - expect { call }.to have_enqueued_job(PDSCascadingSearchJob) - .on_queue(:pds) - .exactly(2) - .times + context "when main switch is enabled but not feature" do + before { Flipper.enable(:pds) } + + it "queues no jobs" do + expect { call }.not_to have_enqueued_job + end end - it "queues a job for each patient with an NHS number" do - expect { call }.to have_enqueued_job(PatientUpdateFromPDSJob) - .on_queue(:pds) - .exactly(2) - .times + context "when main switch and feature is enabled" do + before do + Flipper.enable(:pds) + Flipper.enable(:pds_enqueue_bulk_updates) + end + + it "queues PDSCascadingSearchJob for patients without an NHS number" do + expect { call }.to have_enqueued_job(PDSCascadingSearchJob) + .on_queue(:pds) + .exactly(2) + .times + end + + it "queues a job for each patient with an NHS number" do + expect { call }.to have_enqueued_job(PatientUpdateFromPDSJob) + .on_queue(:pds) + .exactly(2) + .times + end end end diff --git a/spec/models/access_log_entry_spec.rb b/spec/models/access_log_entry_spec.rb index c9861ccc51..abb67611b2 100644 --- a/spec/models/access_log_entry_spec.rb +++ b/spec/models/access_log_entry_spec.rb @@ -20,7 +20,6 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) # fk_rails_... (user_id => users.id) # describe AccessLogEntry do diff --git a/spec/models/careplus_export_spec.rb b/spec/models/careplus_report_spec.rb similarity index 70% rename from spec/models/careplus_export_spec.rb rename to spec/models/careplus_report_spec.rb index 32baefb979..2f2ff41e2e 100644 --- a/spec/models/careplus_export_spec.rb +++ b/spec/models/careplus_report_spec.rb @@ -2,7 +2,7 @@ # == Schema Information # -# Table name: careplus_exports +# Table name: careplus_reports # # id :bigint not null, primary key # academic_year :integer not null @@ -21,30 +21,30 @@ # # Indexes # -# index_careplus_exports_on_programme_types (programme_types) USING gin -# index_careplus_exports_on_status_and_scheduled_at (status,scheduled_at) -# index_careplus_exports_on_team_id (team_id) -# index_careplus_exports_on_team_id_and_academic_year (team_id,academic_year) +# index_careplus_reports_on_programme_types (programme_types) USING gin +# index_careplus_reports_on_status_and_scheduled_at (status,scheduled_at) +# index_careplus_reports_on_team_id (team_id) +# index_careplus_reports_on_team_id_and_academic_year (team_id,academic_year) # # Foreign Keys # # fk_rails_... (team_id => teams.id) # -describe CareplusExport do - subject(:careplus_export) { build(:careplus_export) } +describe CareplusReport do + subject(:careplus_report) { build(:careplus_report) } describe "associations" do it { should belong_to(:team) } it do - expect(careplus_export).to have_many( - :careplus_export_vaccination_records + expect(careplus_report).to have_many( + :careplus_report_vaccination_records ).dependent(:destroy) end it do - expect(careplus_export).to have_many(:vaccination_records).through( - :careplus_export_vaccination_records + expect(careplus_report).to have_many(:vaccination_records).through( + :careplus_report_vaccination_records ) end end @@ -59,9 +59,9 @@ describe "date_from_must_precede_date_to" do context "when date_to is before date_from" do - subject(:careplus_export) do + subject(:careplus_report) do build( - :careplus_export, + :careplus_report, date_from: Date.current, date_to: Date.current - 1.day ) @@ -70,15 +70,15 @@ it { should be_invalid } it "adds an error on date_to" do - careplus_export.valid? - expect(careplus_export.errors[:date_to]).to be_present + careplus_report.valid? + expect(careplus_report.errors[:date_to]).to be_present end end context "when date_to equals date_from" do - subject(:careplus_export) do + subject(:careplus_report) do build( - :careplus_export, + :careplus_report, date_from: Date.current, date_to: Date.current ) @@ -94,10 +94,10 @@ subject { described_class.for_academic_year(AcademicYear.current) } let!(:matching) do - create(:careplus_export, academic_year: AcademicYear.current) + create(:careplus_report, academic_year: AcademicYear.current) end let!(:other) do - create(:careplus_export, academic_year: AcademicYear.current - 1) + create(:careplus_report, academic_year: AcademicYear.current - 1) end it { should include(matching) } @@ -108,17 +108,17 @@ subject { described_class.pending_send } let!(:due) do - create(:careplus_export, status: :pending, scheduled_at: 1.minute.ago) + create(:careplus_report, status: :pending, scheduled_at: 1.minute.ago) end let!(:future) do create( - :careplus_export, + :careplus_report, status: :pending, scheduled_at: 1.hour.from_now ) end let!(:already_sent) do - create(:careplus_export, :sent, scheduled_at: 1.minute.ago) + create(:careplus_report, :sent, scheduled_at: 1.minute.ago) end it { should include(due) } diff --git a/spec/models/careplus_export_vaccination_record_spec.rb b/spec/models/careplus_report_vaccination_record_spec.rb similarity index 59% rename from spec/models/careplus_export_vaccination_record_spec.rb rename to spec/models/careplus_report_vaccination_record_spec.rb index 119c03340b..41aff1d283 100644 --- a/spec/models/careplus_export_vaccination_record_spec.rb +++ b/spec/models/careplus_report_vaccination_record_spec.rb @@ -2,29 +2,29 @@ # == Schema Information # -# Table name: careplus_export_vaccination_records +# Table name: careplus_report_vaccination_records # # change_type :integer not null # created_at :datetime not null # updated_at :datetime not null -# careplus_export_id :bigint not null, primary key +# careplus_report_id :bigint not null, primary key # vaccination_record_id :bigint not null, primary key # # Indexes # -# idx_on_careplus_export_id_8ce4ed1ff0 (careplus_export_id) -# idx_on_vaccination_record_id_d4c93aefb7 (vaccination_record_id) +# idx_on_careplus_report_id_98876049c7 (careplus_report_id) +# idx_on_vaccination_record_id_e7f05454ab (vaccination_record_id) # # Foreign Keys # -# fk_rails_... (careplus_export_id => careplus_exports.id) ON DELETE => cascade +# fk_rails_... (careplus_report_id => careplus_reports.id) ON DELETE => cascade # fk_rails_... (vaccination_record_id => vaccination_records.id) # -describe CareplusExportVaccinationRecord do - subject(:record) { build(:careplus_export_vaccination_record) } +describe CareplusReportVaccinationRecord do + subject(:record) { build(:careplus_report_vaccination_record) } describe "associations" do - it { should belong_to(:careplus_export) } + it { should belong_to(:careplus_report) } it { should belong_to(:vaccination_record) } end diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 2896f135c7..dbaad17604 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -38,7 +38,9 @@ # fk_rails_... (uploaded_by_user_id => users.id) # describe ClassImport do - subject(:class_import) { create(:class_import, csv:, session:, team:) } + subject(:class_import) do + create(:class_import, csv_data:, uploaded_csv_file:, session:, team:) + end let(:programmes) { [Programme.hpv] } let(:team) { create(:team, programmes:) } @@ -46,48 +48,18 @@ let(:session) { create(:session, location:, programmes:, team:) } let(:file) { "valid.csv" } - let(:csv) { fixture_file_upload("class_import/#{file}") } - - it_behaves_like "a CSVImportable model" - - describe "#load_data!" do - subject(:load_data!) { class_import.load_data! } + let(:csv_source) { file_fixture("class_import/#{file}") } + let(:csv_data) { csv_source.read } + # Used by shared examples in CSVImportable to test setting csv from an uploaded file + let(:uploaded_csv_file) { nil } - before { load_data! } + # This is used by validation tests in the CSFVImportable shared specs. + let(:unsaved_import) { build(:class_import, csv_data:, session:, team:) } - describe "with malformed CSV" do - let(:file) { "malformed.csv" } - - it "is invalid" do - expect(class_import).to be_invalid - expect(class_import.errors[:csv]).to include(/correct format/) - end - end - - describe "with too many rows" do - let(:file) { "valid.csv" } - - before { stub_const("CSVImportable::MAX_CSV_ROWS", 2) } - - it "is invalid" do - expect(class_import).to be_invalid - expect(class_import.errors[:csv]).to include(/less than 2 rows/) - end - end - end + it_behaves_like "a CSVImportable model" describe "#parse_rows!" do - subject(:parse_rows!) { class_import.parse_rows! } - - before { parse_rows! } - - describe "with a BOM" do - let(:file) { "valid_with_bom.csv" } - - it "removes the BOM" do - expect(class_import).to be_valid - end - end + before { class_import.parse_rows! } describe "with invalid fields" do let(:file) { "invalid_fields.csv" } @@ -162,8 +134,6 @@ end describe "#process!" do - subject(:process!) { class_import.process! } - let(:file) { "valid.csv" } let(:configured_job) { instance_double(ActiveJob::ConfiguredJob) } @@ -172,14 +142,18 @@ queue: :imports ).and_return(configured_job) allow(configured_job).to receive(:perform_later) + + class_import.parse_rows! end - context "when import_search_pds flag is enabled" do - before { Flipper.enable(:import_search_pds) } - after { Flipper.disable(:import_search_pds) } + context "when pds_search_during_import flag is enabled" do + before do + Flipper.enable(:pds) + Flipper.enable(:pds_search_during_import) + end it "enqueues PDSCascadingSearchJob for each changeset with a postcode" do - process! + class_import.process! expect(configured_job).to have_received(:perform_later).exactly(3).times without_postcode = @@ -192,11 +166,11 @@ end end - context "when import_search_pds flag is disabled" do - before { Flipper.disable(:import_search_pds) } + context "when pds_search_during_import flag is disabled" do + before { Flipper.disable(:pds_search_during_import) } it "enqueues ReviewPatientChangesetJob for each changeset" do - expect { process! }.to have_enqueued_job( + expect { class_import.process! }.to have_enqueued_job( ReviewPatientChangesetJob ).exactly(4).times @@ -251,8 +225,6 @@ end describe "#validate_pds_match_rate!" do - subject(:validate_pds_match_rate!) { class_import.validate_pds_match_rate! } - context "when match rate is equal to threshold" do before do create_list( @@ -265,7 +237,7 @@ end it "does not mark as low_pds_match_rate" do - validate_pds_match_rate! + class_import.validate_pds_match_rate! expect(class_import.reload.status).not_to eq("low_pds_match_rate") end end @@ -282,7 +254,7 @@ end it "marks the import as low_pds_match_rate" do - validate_pds_match_rate! + class_import.validate_pds_match_rate! expect(class_import.reload.status).to eq("low_pds_match_rate") end end @@ -291,22 +263,18 @@ before { create_list(:patient_changeset, 5, import: class_import) } it "skips validation" do - validate_pds_match_rate! + class_import.validate_pds_match_rate! expect(class_import.reload.status).not_to eq("low_pds_match_rate") end end end describe "#validate_changeset_uniqueness!" do - subject(:validate_changeset_uniqueness!) do - class_import.validate_changeset_uniqueness! - end - context "when all rows are unique" do before { create_list(:patient_changeset, 3, import: class_import) } it "does not mark the import as changesets_are_invalid" do - validate_changeset_uniqueness! + class_import.validate_changeset_uniqueness! expect(class_import.reload.status).not_to eq("changesets_are_invalid") expect(class_import.serialized_errors).to be_nil.or eq({}) end @@ -340,7 +308,7 @@ end it "marks the import as changesets_are_invalid and records errors" do - validate_changeset_uniqueness! + class_import.validate_changeset_uniqueness! expect(class_import.reload.status).to eq("changesets_are_invalid") expect(class_import.serialized_errors.values.flatten).to include( @@ -357,7 +325,7 @@ end it "marks the import as changesets_are_invalid and includes Mavis duplicate error" do - validate_changeset_uniqueness! + class_import.validate_changeset_uniqueness! expect(class_import.reload.status).to eq("changesets_are_invalid") expect(class_import.serialized_errors.values.flatten).to include( diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index 332cb17f97..724679489e 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -34,50 +34,29 @@ # fk_rails_... (uploaded_by_user_id => users.id) # describe CohortImport do - subject(:cohort_import) { create(:cohort_import, csv:, team:) } + subject(:cohort_import) do + create(:cohort_import, csv_data:, team:, uploaded_csv_file:) + end let(:programmes) { [Programme.hpv] } let(:team) { create(:team, programmes:) } let(:file) { "valid.csv" } - let(:csv) { fixture_file_upload("cohort_import/#{file}") } - let(:academic_year) { AcademicYear.current } + let(:csv_source) { file_fixture("cohort_import/#{file}") } + let(:csv_data) { csv_source.read } + # Used by shared examples in CSVImportable to test setting csv from an uploaded file + let(:uploaded_csv_file) { nil } # Ensure location URN matches the URN in our fixture files let!(:location) { create(:gias_school, urn: "123456", team:) } # rubocop:disable RSpec/LetSetup - it_behaves_like "a CSVImportable model" - - describe "#load_data!" do - subject(:load_data!) { cohort_import.load_data! } - - before { load_data! } - - describe "with malformed CSV" do - let(:file) { "malformed.csv" } - - it "is invalid" do - expect(cohort_import).to be_invalid - expect(cohort_import.errors[:csv]).to include(/correct format/) - end - end + # This is used by validation tests in the CSFVImportable shared specs. + let(:unsaved_import) { build(:cohort_import, csv_data:, team:) } - describe "with too many rows" do - let(:file) { "valid.csv" } - - before { stub_const("CSVImportable::MAX_CSV_ROWS", 2) } - - it "is invalid" do - expect(cohort_import).to be_invalid - expect(cohort_import.errors[:csv]).to include(/less than 2 rows/) - end - end - end + it_behaves_like "a CSVImportable model" describe "#parse_rows!" do - subject(:parse_rows!) { cohort_import.parse_rows! } - - before { parse_rows! } + before { cohort_import.parse_rows! } describe "with invalid fields" do let(:file) { "invalid_fields.csv" } @@ -153,33 +132,9 @@ expect(cohort_import.errors.to_a[0]).to start_with("Row 2") end end - - describe "with a valid file using ISO-8859-1 encoding" do - let(:file) { "valid_iso_8859_1_encoding.csv" } - - let(:location) do - Location.find_by_urn_and_site("120026") || - create(:gias_school, urn: "120026", team:) - end - - it "is valid" do - expect(cohort_import).to be_valid - expect(cohort_import.rows.count).to eq(16) - end - end - - describe "with an invalid file using ISO-8859-1 encoding" do - let(:file) { "invalid_iso_8859_1_encoding.csv" } - - it "is invalid" do - expect(cohort_import).to be_invalid - end - end end describe "#process!" do - subject(:process!) { cohort_import.process! } - let(:configured_job) { instance_double(ActiveJob::ConfiguredJob) } let(:file) { "valid.csv" } @@ -188,25 +143,28 @@ queue: :imports ).and_return(configured_job) allow(configured_job).to receive(:perform_later) - end - context "when import_search_pds flag is enabled" do - before { Flipper.enable(:import_search_pds) } + cohort_import.parse_rows! + end - after { Flipper.disable(:import_search_pds) } + context "when pds_search_during_import flag is enabled" do + before do + Flipper.enable(:pds) + Flipper.enable(:pds_search_during_import) + end it "enqueues PDSCascadingSearchJob for each changeset" do - process! + cohort_import.process! expect(configured_job).to have_received(:perform_later).exactly(3).times end end - context "when import_search_pds flag is disabled" do - before { Flipper.disable(:import_search_pds) } + context "when pds_search_during_import flag is disabled" do + before { Flipper.disable(:pds_search_during_import) } it "enqueues ReviewPatientChangesetJob for each changeset" do - expect { process! }.to have_enqueued_job( + expect { cohort_import.process! }.to have_enqueued_job( ReviewPatientChangesetJob ).exactly(3).times end diff --git a/spec/models/immunisation_import_row_spec.rb b/spec/models/immunisation_import_row_spec.rb index e15288d61f..0b7805beab 100644 --- a/spec/models/immunisation_import_row_spec.rb +++ b/spec/models/immunisation_import_row_spec.rb @@ -446,7 +446,29 @@ end end - context "with an invalid patient date of birth" do + context "with a blank patient date of birth" do + let(:data) { { "PERSON_DOB" => "" } } + + it "has errors" do + expect(immunisation_import_row).to be_invalid + expect(immunisation_import_row.errors["PERSON_DOB"]).to eq( + ["Enter a date of birth."] + ) + end + end + + context "with an unparseable patient date of birth" do + let(:data) { { "PERSON_DOB" => "not-a-date" } } + + it "has errors" do + expect(immunisation_import_row).to be_invalid + expect(immunisation_import_row.errors["PERSON_DOB"]).to eq( + ["Enter a date of birth in the correct format."] + ) + end + end + + context "with a patient date of birth in the future" do let(:data) { { "PERSON_DOB" => "21000101" } } it "has errors" do @@ -457,6 +479,17 @@ end end + context "with a patient date of birth before 2000-01-01" do + let(:data) { { "PERSON_DOB" => "19991231" } } + + it "has errors" do + expect(immunisation_import_row).to be_invalid + expect(immunisation_import_row.errors["PERSON_DOB"]).to eq( + ["is too old to still be in school"] + ) + end + end + context "when recording offline in to a clinic" do let(:valid_clinic_data) do valid_data.merge( diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 2d9f9a808a..93546d82f0 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -35,7 +35,13 @@ describe ImmunisationImport do subject(:immunisation_import) do - create(:immunisation_import, team:, csv:, uploaded_by:) + create( + :immunisation_import, + team:, + csv_data:, + uploaded_by:, + uploaded_csv_file: + ) end before do @@ -56,44 +62,23 @@ let(:school) { create(:gias_school, urn: "123456") } let(:file) { "valid_flu.csv" } - let(:csv) { fixture_file_upload("immunisation_import/#{type}/#{file}") } + let(:csv_source) { file_fixture("immunisation_import/#{type}/#{file}") } + let(:csv_data) { csv_source.read } + # Used by shared examples in CSVImportable to test setting csv from an uploaded file + let(:uploaded_csv_file) { nil } let(:uploaded_by) { create(:user, team:) } let(:type) { "point_of_care" } - it_behaves_like "a CSVImportable model" - - describe "#load_data!" do - before { immunisation_import.load_data! } - - context "with malformed CSV" do - let(:file) { "malformed.csv" } - - it "is invalid" do - expect(immunisation_import).to be_invalid - expect(immunisation_import.errors[:csv]).to include(/correct format/) - end - end - - context "with empty CSV" do - let(:file) { "empty.csv" } - - it "is invalid" do - expect(immunisation_import).to be_invalid - expect(immunisation_import.errors[:csv]).to include(/one record/) - end - end - - context "with too many rows" do - let(:file) { "valid_flu.csv" } + # This is used by validation tests in the CSFVImportable shared specs. + let(:unsaved_import) do + build(:immunisation_import, team:, csv_data:, uploaded_by:) + end - before { stub_const("CSVImportable::MAX_CSV_ROWS", 2) } + it_behaves_like "a CSVImportable model" - it "is invalid" do - expect(immunisation_import).to be_invalid - expect(immunisation_import.errors[:csv]).to include(/less than 2 rows/) - end - end + describe "validations" do + subject { unsaved_import } context "with a duplicated row" do let(:file) { "duplicate_row.csv" } @@ -186,7 +171,6 @@ context "with a national reporting upload" do let(:type) { "national_reporting" } let(:file) { "valid_mixed_flu_hpv.csv" } - let(:test_date) { Date.new(2025, 12, 1) } it "populates the rows" do @@ -208,10 +192,19 @@ end describe "#process!" do - subject(:process!) { immunisation_import.process! } - around { |example| travel_to(Date.new(2025, 8, 1)) { example.run } } + before do + Flipper.enable(:pds) + Flipper.enable(:pds_enqueue_bulk_updates) + + immunisation_import.parse_rows! + end + + let(:duplicate_import) do + create(:immunisation_import, csv_data:, team:, uploaded_by:) + end + context "with an empty CSV file (no data rows)" do let(:programmes) { [Programme.flu] } let(:file) { "valid_flu.csv" } @@ -223,7 +216,7 @@ ) # rubocop:enable RSpec/SubjectStub - expect { process! }.not_to raise_error + expect { immunisation_import.process! }.not_to raise_error end end @@ -233,7 +226,7 @@ it "creates locations, patients, and vaccination records" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :processed_at).from(nil) .and change(immunisation_import.vaccination_records, :count).by(11) .and change(immunisation_import.patients, :count).by(11) @@ -251,7 +244,7 @@ end it "links the correct objects with each other" do - process! + immunisation_import.process! expect(VaccinationRecord.all.map(&:patient)).to match_array(Patient.all) @@ -263,30 +256,29 @@ it "stores statistics on the import" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :exact_duplicate_record_count).to(0) .and change(immunisation_import, :new_record_count).to(11) end it "ignores and counts duplicate records" do - create(:immunisation_import, csv:, team:, uploaded_by:).process! - csv.rewind + duplicate_import.parse_rows! + duplicate_import.process! - process! + immunisation_import.process! expect(immunisation_import.exact_duplicate_record_count).to eq(11) end it "enqueues jobs to look up missing NHS numbers" do - expect { process! }.to have_enqueued_job( + expect { immunisation_import.process! }.to have_enqueued_job( PDSCascadingSearchJob ).once.on_queue(:imports) end it "enqueues jobs to update from PDS" do - expect { process! }.to have_enqueued_job(PatientUpdateFromPDSJob) - .exactly(10) - .times - .on_queue(:imports) + expect { immunisation_import.process! }.to have_enqueued_job( + PatientUpdateFromPDSJob + ).exactly(10).times.on_queue(:imports) end end @@ -296,7 +288,7 @@ it "creates locations, patients, and vaccination records" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :processed_at).from(nil) .and change(immunisation_import.vaccination_records, :count).by(11) .and change(immunisation_import.patients, :count).by(10) @@ -315,30 +307,29 @@ it "stores statistics on the import" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :exact_duplicate_record_count).to(0) .and change(immunisation_import, :new_record_count).to(11) end it "ignores and counts duplicate records" do - create(:immunisation_import, csv:, team:, uploaded_by:).process! - csv.rewind + duplicate_import.parse_rows! + duplicate_import.process! - process! + immunisation_import.process! expect(immunisation_import.exact_duplicate_record_count).to eq(11) end it "enqueues jobs to look up missing NHS numbers" do - expect { process! }.to have_enqueued_job( + expect { immunisation_import.process! }.to have_enqueued_job( PDSCascadingSearchJob ).once.on_queue(:imports) end it "enqueues jobs to update from PDS" do - expect { process! }.to have_enqueued_job(PatientUpdateFromPDSJob) - .exactly(9) - .times - .on_queue(:imports) + expect { immunisation_import.process! }.to have_enqueued_job( + PatientUpdateFromPDSJob + ).exactly(9).times.on_queue(:imports) end end @@ -348,7 +339,7 @@ it "creates locations, patients, and vaccination records" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :processed_at).from(nil) .and change(immunisation_import.vaccination_records, :count).by(11) .and change(immunisation_import.patients, :count).by(10) @@ -367,30 +358,29 @@ it "stores statistics on the import" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :exact_duplicate_record_count).to(0) .and change(immunisation_import, :new_record_count).to(11) end it "ignores and counts duplicate records" do - create(:immunisation_import, csv:, team:, uploaded_by:).process! - csv.rewind + duplicate_import.parse_rows! + duplicate_import.process! - process! + immunisation_import.process! expect(immunisation_import.exact_duplicate_record_count).to eq(11) end it "enqueues jobs to look up missing NHS numbers" do - expect { process! }.to have_enqueued_job( + expect { immunisation_import.process! }.to have_enqueued_job( PDSCascadingSearchJob ).once.on_queue(:imports) end it "enqueues jobs to update from PDS" do - expect { process! }.to have_enqueued_job(PatientUpdateFromPDSJob) - .exactly(9) - .times - .on_queue(:imports) + expect { immunisation_import.process! }.to have_enqueued_job( + PatientUpdateFromPDSJob + ).exactly(9).times.on_queue(:imports) end end @@ -400,7 +390,7 @@ it "creates locations, patients, and vaccination records" do # stree-ignore - expect { process! } + expect { immunisation_import.process! } .to change(immunisation_import, :processed_at).from(nil) .and change(immunisation_import.vaccination_records, :count).by(4) .and change(immunisation_import.patients, :count).by(4) @@ -434,11 +424,16 @@ end it "doesn't create an additional patient" do - expect { process! }.to change(Patient, :count).by(10) + expect { immunisation_import.process! }.to change(Patient, :count).by( + 10 + ) end it "doesn't update the NHS number on the existing patient" do - expect { process! }.not_to change(patient, :nhs_number).from(nil) + expect { immunisation_import.process! }.not_to change( + patient, + :nhs_number + ).from(nil) end end @@ -458,7 +453,9 @@ end it "doesn't create an additional patient" do - expect { process! }.to change(Patient, :count).by(10) + expect { immunisation_import.process! }.to change(Patient, :count).by( + 10 + ) end end @@ -478,7 +475,7 @@ end it "ignores changes in the patient record" do - expect { process! }.not_to change(Patient, :count) + expect { immunisation_import.process! }.not_to change(Patient, :count) expect(existing_patient.reload.pending_changes).to be_empty end end @@ -488,11 +485,11 @@ let(:file) { "valid_duplicate_patient.csv" } it "only creates one patient record" do - expect { process! }.to change(Patient, :count).by(1) + expect { immunisation_import.process! }.to change(Patient, :count).by(1) end it "links both vaccination records to the same patient" do - process! + immunisation_import.process! patients = immunisation_import .vaccination_records @@ -507,11 +504,11 @@ let(:file) { "valid_duplicate_patient_no_nhs_number.csv" } it "only creates one patient record" do - expect { process! }.to change(Patient, :count).by(1) + expect { immunisation_import.process! }.to change(Patient, :count).by(1) end it "links both vaccination records to the same patient" do - process! + immunisation_import.process! patients = immunisation_import .vaccination_records @@ -523,13 +520,12 @@ end describe "#post_commit!" do - subject(:post_commit!) { immunisation_import.send(:post_commit!) } - let(:immunisation_import) do create( :immunisation_import, team:, - vaccination_records: [vaccination_record] + vaccination_records: [vaccination_record], + patients: [create(:patient)] ) end let(:session) { create(:session, location: school, programmes:) } @@ -537,18 +533,38 @@ create(:vaccination_record, programme: programmes.first, session:) end - before { Flipper.enable(:imms_api_sync_job) } + it "calls the PatientTeamUpdater with imported patients" do + expect(PatientTeamUpdater).to receive(:call).with( + patient_scope: immunisation_import.patients + ) + + immunisation_import.send :post_commit! + end + + it "calls the PatientStatusUpdater with imported patients" do + expect(PatientStatusUpdater).to receive(:call).with( + patient_scope: Patient.where(id: immunisation_import.patients.ids) + ) + + immunisation_import.send :post_commit! + end it "syncs the flu vaccination record to the NHS Immunisations API" do - expect { post_commit! }.to enqueue_sidekiq_job( + expect { immunisation_import.send :post_commit! }.to enqueue_sidekiq_job( SyncVaccinationRecordToNHSJob ).with(vaccination_record.id).once.on("immunisations_api_sync") end + + it "calls the AlreadyHadNotificationSender for the vaccination record" do + expect(AlreadyHadNotificationSender).to receive(:call).with( + vaccination_record: + ) + + immunisation_import.send :post_commit! + end end describe "#postprocess_rows!" do - subject(:postprocess_rows!) { immunisation_import.send(:postprocess_rows!) } - let(:immunisation_import) do create( :immunisation_import, @@ -566,7 +582,10 @@ let(:programmes) { [Programme.hpv] } it "doesn't create a next dose triage" do - expect { postprocess_rows! }.not_to change(Triage, :count) + expect { immunisation_import.send :postprocess_rows! }.not_to change( + Triage, + :count + ) end end @@ -574,16 +593,11 @@ let(:programmes) { [Programme.mmr] } it "creates a next dose triage" do - expect { postprocess_rows! }.to change(Triage, :count).by(1) + expect { immunisation_import.send :postprocess_rows! }.to change( + Triage, + :count + ).by(1) end end - - it "calls the AlreadyHadNotificationSender for the vaccination record" do - expect(AlreadyHadNotificationSender).to receive(:call).with( - vaccination_record: - ) - - postprocess_rows! - end end end diff --git a/spec/models/import/csv_data_spec.rb b/spec/models/import/csv_data_spec.rb new file mode 100644 index 0000000000..5c1409a55d --- /dev/null +++ b/spec/models/import/csv_data_spec.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +describe Import::CSVData do + subject(:csv_data) { described_class.new(data) } + + let(:data) { "FIRST_NAME,LAST_NAME\nJane,Doe\nJohn,Smith" } + + describe "#well_formed?" do + it { should be_well_formed } + + context "with malformed CSV" do + let(:data) do + File.read( + Rails.root.join("spec/fixtures/files/class_import/malformed.csv") + ) + end + + it { should_not be_well_formed } + end + + context "with nil data" do + let(:data) { nil } + + it { should be_well_formed } + end + end + + describe "#empty?" do + it { should_not be_empty } + + context "with only a header row and no data" do + let(:data) { "FIRST_NAME,LAST_NAME" } + + it { should be_empty } + end + + context "with nil data" do + let(:data) { nil } + + it { should be_empty } + end + + context "with malformed CSV" do + let(:data) do + File.read( + Rails.root.join("spec/fixtures/files/class_import/malformed.csv") + ) + end + + it { should be_empty } + end + end + + describe "#count" do + it { expect(csv_data.count).to eq(2) } + + context "with only a header row and no data" do + let(:data) { "FIRST_NAME,LAST_NAME" } + + it { expect(csv_data.count).to eq(0) } + end + + context "with nil data" do + let(:data) { nil } + + it { expect(csv_data.count).to eq(0) } + end + end + + describe "#has_instruction_row?" do + it { should_not have_instruction_row } + + context "when the first data row starts with 'Required:'" do + let(:data) do + File.read( + Rails.root.join( + "spec/fixtures/files/class_import/valid_instruction_row.csv" + ) + ) + end + + it { should have_instruction_row } + end + + context "when the first data row starts with 'Optional'" do + let(:data) do + "FIRST_NAME,LAST_NAME\nOptional: Free text,Optional: Free text\nJane,Doe" + end + + it { should have_instruction_row } + end + + context "when the first data row starts with 'Required' followed by a comma" do + let(:data) { "FIRST_NAME\nRequired,something\nJane" } + + it { should have_instruction_row } + end + + context "with nil data" do + let(:data) { nil } + + it { should_not have_instruction_row } + end + end + + describe "#records" do + it "returns an enumerator of the data rows" do + expect(csv_data.records.to_a.count).to eq(2) + end + + context "with trailing blank rows" do + let(:data) { "FIRST_NAME,LAST_NAME\nJane,Doe\n,\n," } + + it "strips the trailing blank rows" do + expect(csv_data.records.to_a.count).to eq(1) + end + end + + context "with an instruction row" do + let(:data) do + File.read( + Rails.root.join( + "spec/fixtures/files/class_import/valid_instruction_row.csv" + ) + ) + end + + it "skips the instruction row" do + expect(csv_data.records.to_a.count).to eq(1) + end + end + + context "with an instruction row and trailing blank rows" do + let(:data) do + "FIRST_NAME,LAST_NAME\nRequired: Free text,Required: Free text\nJane,Doe\n,\n," + end + + it "skips the instruction row and strips trailing blank rows" do + expect(csv_data.records.to_a.count).to eq(1) + end + end + + context "with blank rows in the middle" do + let(:data) { "FIRST_NAME,LAST_NAME\nJane,Doe\n,\nJohn,Smith" } + + it "preserves blank rows that are not trailing" do + expect(csv_data.records.to_a.count).to eq(3) + end + end + end +end diff --git a/spec/models/important_notice_spec.rb b/spec/models/important_notice_spec.rb index 519c75d3be..10212d7962 100644 --- a/spec/models/important_notice_spec.rb +++ b/spec/models/important_notice_spec.rb @@ -88,4 +88,22 @@ end end end + + describe "#can_dismiss?" do + subject(:can_dismiss) { important_notice.can_dismiss? } + + let(:important_notice) do + create(:important_notice, :invalidated, team_id: team.id, patient:) + end + + context "important notices for invalidated patients cannot be dismissed" do + it { should be(false) } + end + + context "important notices for invalidated patients can be dismissed when archived" do + before { create(:archive_reason, :moved_out_of_area, team:, patient:) } + + it { should be(true) } + end + end end diff --git a/spec/models/notify_log_entry_spec.rb b/spec/models/notify_log_entry_spec.rb index 6824c5af90..ce4302212e 100644 --- a/spec/models/notify_log_entry_spec.rb +++ b/spec/models/notify_log_entry_spec.rb @@ -31,7 +31,7 @@ # # fk_rails_... (consent_form_id => consent_forms.id) # fk_rails_... (parent_id => parents.id) ON DELETE => nullify -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (sent_by_user_id => users.id) # describe NotifyLogEntry do diff --git a/spec/models/patient_merge_log_entry_spec.rb b/spec/models/patient_merge_log_entry_spec.rb index 2f307ad651..e3bbbc62aa 100644 --- a/spec/models/patient_merge_log_entry_spec.rb +++ b/spec/models/patient_merge_log_entry_spec.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (user_id => users.id) # describe PatientMergeLogEntry do diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 12353c368f..108a27fd2b 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -730,7 +730,7 @@ end context "without preloading" do - subject(:archived?) { patient.archived?(team:) } + subject(:archived?) { patient.archived?(team_id: team.id) } include_examples "archived? behavior" end @@ -740,7 +740,7 @@ described_class .includes(:archive_reasons) .find(patient.id) - .archived?(team:) + .archived?(team_id: team.id) end include_examples "archived? behavior" @@ -908,6 +908,121 @@ it { should eq("JD") } end + describe "#can_self_consent_after_gillick_assessment?" do + subject(:can_self_consent_after_gillick_assessment) do + patient.can_self_consent_after_gillick_assessment?( + location: session.location, + programme_type: programme.type + ) + end + + let(:programme) { Programme.sample } + let(:session) { create(:session, programmes: [programme]) } + let(:patient) { create(:patient) } + + context "when patient has no Gillick assessments" do + it { should be(false) } + end + + context "when patient has a Gillick assessment for a different programme" do + before do + create( + :gillick_assessment, + :competent, + patient:, + programme_type: "hpv", + session: + ) + end + + let(:programme) { Programme.flu } + + it { should be(false) } + end + + context "when patient has a Gillick assessment for a different session" do + let(:other_session) { create(:session, programmes: [programme]) } + + before do + create( + :gillick_assessment, + :competent, + patient:, + programme_type: programme.type, + session: other_session + ) + end + + it { should be(false) } + end + + context "when patient has a Gillick assessment on a different date" do + before do + create( + :gillick_assessment, + :competent, + patient:, + programme_type: programme.type, + session:, + date: Date.yesterday + ) + end + + it { should be(false) } + end + + context "when patient has a not competent Gillick assessment" do + before do + create( + :gillick_assessment, + :not_competent, + patient:, + programme_type: programme.type, + session: + ) + end + + it { should be(false) } + end + + context "when patient has a competent Gillick assessment" do + before do + create( + :gillick_assessment, + :competent, + patient:, + programme_type: programme.type, + session: + ) + end + + it { should be(true) } + end + + context "when patient has multiple Gillick assessments" do + before do + create( + :gillick_assessment, + :not_competent, + patient:, + programme_type: programme.type, + session: + ) + create( + :gillick_assessment, + :competent, + patient:, + programme_type: programme.type, + session: + ) + end + + it "returns the result of the most recent assessment" do + expect(can_self_consent_after_gillick_assessment).to be(true) + end + end + end + describe "#has_patient_specific_direction?" do subject { patient.has_patient_specific_direction?(team:) } diff --git a/spec/models/pds/patient_spec.rb b/spec/models/pds/patient_spec.rb index d536ee5bcf..dd702ead4d 100644 --- a/spec/models/pds/patient_spec.rb +++ b/spec/models/pds/patient_spec.rb @@ -8,39 +8,39 @@ file_fixture("pds/get-patient-response-deceased.json").read end - context "with pds.enabled is false" do - before { Settings.pds.enabled = false } - - after { Settings.reload! } - + context "when feature flag is disabled" do it { should be_nil } end - context "when the patient exists" do - before do - allow(NHS::PDS).to receive(:get_patient).and_return( - instance_double( - Faraday::Response, - status: 200, - body: JSON.parse(json_response) + context "when feature flag is enabled" do + before { Flipper.enable(:pds) } + + context "when the patient exists" do + before do + allow(NHS::PDS).to receive(:get_patient).and_return( + instance_double( + Faraday::Response, + status: 200, + body: JSON.parse(json_response) + ) ) - ) - end - - it "calls get_patient on PDS library" do - find - expect(NHS::PDS).to have_received(:get_patient).with("9000000009") - end - - it "parses the patient information" do - expect(find).to have_attributes( - nhs_number: "9000000009", - family_name: "Smith", - date_of_birth: Date.new(2010, 10, 22), - date_of_death: Date.new(2010, 10, 22), - restricted: true, - gp_ods_code: "Y12345" - ) + end + + it "calls get_patient on PDS library" do + find + expect(NHS::PDS).to have_received(:get_patient).with("9000000009") + end + + it "parses the patient information" do + expect(find).to have_attributes( + nhs_number: "9000000009", + family_name: "Smith", + date_of_birth: Date.new(2010, 10, 22), + date_of_death: Date.new(2010, 10, 22), + restricted: true, + gp_ods_code: "Y12345" + ) + end end end end @@ -59,47 +59,47 @@ file_fixture("pds/search-patients-response.json").read end - context "with pds.enabled is false" do - before { Settings.pds.enabled = false } - - after { Settings.reload! } - + context "when feature flag is disabled" do it { should be_nil } end - context "when the patient exists" do - before do - allow(NHS::PDS).to receive(:search_patients).and_return( - instance_double( - Faraday::Response, - status: 200, - body: JSON.parse(json_response) + context "when feature flag is enabled" do + before { Flipper.enable(:pds) } + + context "when the patient exists" do + before do + allow(NHS::PDS).to receive(:search_patients).and_return( + instance_double( + Faraday::Response, + status: 200, + body: JSON.parse(json_response) + ) ) - ) - end - - it "calls find on PDS library" do - search - expect(NHS::PDS).to have_received(:search_patients).with( - { - "_history" => true, - "address-postalcode" => "SW11 1AA", - "birthdate" => "eq2020-01-01", - "family" => "Smith", - "given" => "John" - } - ) - end - - it "parses the patient information" do - expect(search).to have_attributes( - nhs_number: "9449306168", - family_name: "LAWMAN", - date_of_birth: Date.new(1939, 1, 9), - date_of_death: nil, - restricted: false, - gp_ods_code: "H81109" - ) + end + + it "calls find on PDS library" do + search + expect(NHS::PDS).to have_received(:search_patients).with( + { + "_history" => true, + "address-postalcode" => "SW11 1AA", + "birthdate" => "eq2020-01-01", + "family" => "Smith", + "given" => "John" + } + ) + end + + it "parses the patient information" do + expect(search).to have_attributes( + nhs_number: "9449306168", + family_name: "LAWMAN", + date_of_birth: Date.new(1939, 1, 9), + date_of_death: nil, + restricted: false, + gp_ods_code: "H81109" + ) + end end end end diff --git a/spec/models/pds_search_result_spec.rb b/spec/models/pds_search_result_spec.rb index cf2c4d3ff2..b2e3967b3d 100644 --- a/spec/models/pds_search_result_spec.rb +++ b/spec/models/pds_search_result_spec.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # describe PDSSearchResult, type: :model do subject(:pds_search_result) { build(:pds_search_result) } diff --git a/spec/models/school_move_log_entry_spec.rb b/spec/models/school_move_log_entry_spec.rb index b337e81a3b..bab814c40f 100644 --- a/spec/models/school_move_log_entry_spec.rb +++ b/spec/models/school_move_log_entry_spec.rb @@ -21,7 +21,7 @@ # # Foreign Keys # -# fk_rails_... (patient_id => patients.id) +# fk_rails_... (patient_id => patients.id) ON DELETE => cascade # fk_rails_... (school_id => locations.id) # fk_rails_... (team_id => teams.id) # fk_rails_... (user_id => users.id) diff --git a/spec/models/school_move_spec.rb b/spec/models/school_move_spec.rb index bedb2792d1..0800e3e270 100644 --- a/spec/models/school_move_spec.rb +++ b/spec/models/school_move_spec.rb @@ -132,19 +132,19 @@ shared_examples "unarchives the patient" do it "unarchives the patient" do - expect(patient.archived?(team:)).to be(true) + expect(patient.archived?(team_id: team.id)).to be(true) confirm! - expect(patient.archived?(team:)).to be(false) + expect(patient.archived?(team_id: team.id)).to be(false) end end shared_examples "archives the patient in the original team" do it "archives the patient in the original team" do - expect(patient.archived?(team:)).to be(false) - expect(patient.archived?(team: new_team)).to be(false) + expect(patient.archived?(team_id: team.id)).to be(false) + expect(patient.archived?(team_id: new_team.id)).to be(false) confirm! - expect(patient.archived?(team:)).to be(true) - expect(patient.archived?(team: new_team)).to be(false) + expect(patient.archived?(team_id: team.id)).to be(true) + expect(patient.archived?(team_id: new_team.id)).to be(false) end end diff --git a/spec/models/team_spec.rb b/spec/models/team_spec.rb index f94a828918..15cb91102f 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -5,8 +5,11 @@ # Table name: teams # # id :bigint not null, primary key +# careplus_namespace :string +# careplus_password :string # careplus_staff_code :string # careplus_staff_type :string +# careplus_username :string # careplus_venue_code :string # days_before_consent_reminders :integer default(7), not null # days_before_consent_requests :integer default(21), not null @@ -127,6 +130,66 @@ end end + describe ".has_careplus_credentials" do + subject(:has_careplus_credentials) do + described_class.has_careplus_credentials + end + + let!(:team_with_credentials) { create(:team, :with_careplus_enabled) } + + before do + create(:team, :with_careplus_enabled, careplus_username: nil) + create(:team, :with_careplus_enabled, careplus_password: nil) + create(:team, :with_careplus_enabled, careplus_namespace: nil) + end + + it "returns teams with CarePlus credentials configured" do + expect(has_careplus_credentials).to contain_exactly(team_with_credentials) + end + end + + describe ".careplus_enabled" do + subject(:careplus_enabled) { described_class.careplus_enabled } + + let!(:enabled_team) { create(:team, :with_careplus_enabled) } + + before do + create(:team, :with_careplus_enabled, careplus_staff_code: nil) + create(:team, :with_careplus_enabled, careplus_staff_type: nil) + create(:team, :with_careplus_enabled, careplus_venue_code: nil) + end + + it "returns teams with CarePlus export fields configured" do + expect(careplus_enabled).to contain_exactly(enabled_team) + end + end + + describe ".eligible_for_automated_careplus_reports" do + subject(:eligible_for_automated_careplus_reports) do + described_class.eligible_for_automated_careplus_reports + end + + let!(:eligible_team) { create(:team, :with_careplus_enabled) } + + before do + create(:team, :with_careplus_enabled, careplus_username: nil) + create(:team, :with_careplus_enabled, careplus_password: nil) + create(:team, :with_careplus_enabled, careplus_namespace: nil) + create( + :team, + careplus_username: "careplus_user", + careplus_password: "careplus_password", + careplus_namespace: "MOCK" + ) + end + + it "returns teams with CarePlus export fields and credentials configured" do + expect(eligible_for_automated_careplus_reports).to contain_exactly( + eligible_team + ) + end + end + describe "#careplus_enabled?" do subject(:careplus_enabled?) { team.careplus_enabled? } @@ -144,4 +207,37 @@ it { should be(false) } end end + + describe "#eligible_for_automated_careplus_reports?" do + subject(:eligible_for_automated_careplus_reports?) do + team.eligible_for_automated_careplus_reports? + end + + context "when CarePlus export fields and credentials are configured" do + let(:team) { create(:team, :with_careplus_enabled) } + + it { should be(true) } + end + + context "when CarePlus credentials are missing" do + let(:team) do + create(:team, :with_careplus_enabled, careplus_username: nil) + end + + it { should be(false) } + end + + context "when CarePlus export fields are missing" do + let(:team) do + create( + :team, + careplus_username: "careplus_user", + careplus_password: "careplus_password", + careplus_namespace: "MOCK" + ) + end + + it { should be(false) } + end + end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index eaaf403200..f7d37e4b3a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -108,26 +108,11 @@ end require "rspec/rails" # Add additional requires below this line. Rails is not loaded until this point! -require "capybara/cuprite" -require "capybara-screenshot/rspec" require "rack_session_access/capybara" require "console" Faker::Config.locale = "en-GB" -Capybara.register_driver(:cuprite_custom) do |app| - Capybara::Cuprite::Driver.new( - app, - inspector: ENV["DEBUG_TESTS"], - js_errors: true, - window_size: [1200, 800], - process_timeout: 30 - ) -end - -Capybara.asset_host = "http://localhost:4000" -Capybara.javascript_driver = :cuprite_custom - Console.logger.off! Capybara.server = :falcon @@ -186,14 +171,6 @@ config.filter_run_excluding :local_users - if ENV["CI"].blank? - begin - Ferrum::Browser.new - rescue Ferrum::BinaryNotFoundError - config.filter_run_excluding :js - end - end - config.infer_spec_type_from_file_location! config.define_derived_metadata(file_path: %r{/spec/components/}) do |metadata| @@ -212,6 +189,9 @@ config.before(:each, :js) { WebMock.allow_net_connect! } config.after(:each, :js) { WebMock.disable_net_connect! } + config.before(:each, :pds) { Flipper.enable(:pds) } + config.after(:each, :pds) { Flipper.disable(:pds) } + config.before do EmailDeliveryJob.deliveries.clear SMSDeliveryJob.deliveries.clear diff --git a/spec/support/capture_output.rb b/spec/support/capture_output.rb index 8fcf9755f3..cda6829bd1 100755 --- a/spec/support/capture_output.rb +++ b/spec/support/capture_output.rb @@ -16,6 +16,11 @@ def capture_output(input: nil) end stub_const("ProgressBar::Output::DEFAULT_OUTPUT_STREAM", output) + # Pretend that the terminal window is wide, so table cells aren't truncated. + allow(TableTennis::Util::Console).to receive(:winsize).and_return( + [48, 220] + ) + yield output.string diff --git a/spec/support/imports_helper.rb b/spec/support/imports_helper.rb index 98501d38fd..f7a1e31745 100644 --- a/spec/support/imports_helper.rb +++ b/spec/support/imports_helper.rb @@ -47,6 +47,7 @@ def perform_enqueued_jobs_while_exists(only:) # Process and approve an import programmatically (for job/unit specs) # This simulates the full import flow including review and approval def process_and_approve_import(import) + import.parse_rows! import.process! unless import.is_a?(ImmunisationImport) diff --git a/spec/support/shared_examples/a_csv_importable_model.rb b/spec/support/shared_examples/a_csv_importable_model.rb index 0d584514a1..1d4753815f 100644 --- a/spec/support/shared_examples/a_csv_importable_model.rb +++ b/spec/support/shared_examples/a_csv_importable_model.rb @@ -2,6 +2,8 @@ shared_examples_for "a CSVImportable model" do describe "validations" do + subject { unsaved_import } + it { should be_valid } it { should validate_presence_of(:csv_filename) } @@ -20,15 +22,55 @@ subject.update!(processed_at: Time.zone.now, status: :processed) }.to raise_error(/Count statistics must be set/) end + + describe "with malformed CSV" do + let(:file) { "malformed.csv" } + + it "is invalid" do + expect(subject).to be_invalid + expect(subject.errors[:csv]).to include(/correct format/) + end + end + + describe "with too many rows" do + before { stub_const("CSVImportable::MAX_CSV_ROWS", 2) } + + it "is invalid" do + expect(subject).to be_invalid + expect(subject.errors[:csv]).to include(/less than 2 rows/) + end + end + + context "with empty CSV" do + let(:file) { "empty.csv" } + + it "is invalid" do + expect(subject).to be_invalid + expect(subject.errors[:csv]).to include(/one record/) + end + end end describe "#csv=" do + let(:csv_data) { nil } + let(:uploaded_csv_file) { fixture_file_upload(csv_source) } + it "sets the data" do - expect(subject.csv_data).not_to be_empty + expect(subject.csv_data).to eq uploaded_csv_file.read end it "sets the filename" do - expect(subject.csv_filename).not_to be_empty + expect(subject.csv_filename).to eq uploaded_csv_file.original_filename + end + + context "with a payload with a BOM" do + # This requires that each test using these shared example have a file with + # a BOM in their fixtures directory + let(:file) { "valid_with_bom.csv" } + + it "results in a valid import" do + expect(subject).to be_valid + end end end @@ -49,8 +91,12 @@ describe "#process!" do let(:today) { Time.zone.local(2025, 6, 1) } - it "sets processed_at" do - if subject.is_a?(ImmunisationImport) + before { subject.parse_rows! } + + # TODO: Remove if ... when ImmunisationImport's implementation has been + # updated to match the others (i.e. it uses changesets) + if described_class <= ImmunisationImport + it "sets processed_at" do expect { travel_to(today) { subject.process! } }.to change( subject, :processed_at diff --git a/yarn.lock b/yarn.lock index 25e00fe3af..e8d66bb168 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3819,10 +3819,10 @@ postcss@^8.5.6, postcss@^8.5.8: picocolors "^1.1.1" source-map-js "^1.2.1" -prettier@^3.8.1: - version "3.8.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.1.tgz#edf48977cf991558f4fcbd8a3ba6015ba2a3a173" - integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg== +prettier@^3.8.3: + version "3.8.3" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.3.tgz#560f2de55bf01b4c0503bc629d5df99b9a1d09b0" + integrity sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw== pretty-format@30.0.5, pretty-format@^30.0.0: version "30.0.5"