From 90cd3bf8f9723aa67f3ac4031b28edb80ef250ec Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Mon, 13 Apr 2026 16:05:41 +0100 Subject: [PATCH 01/87] Fix layouts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ensure main doesn’t encompass navigation - Fix spacing on session layout --- .../components/_session-banner.scss | 13 ++++++++++- app/views/layouts/application.html.erb | 4 +--- app/views/layouts/default.html.erb | 4 ++-- app/views/layouts/session.html.erb | 22 ++++++++++--------- 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/components/_session-banner.scss b/app/assets/stylesheets/components/_session-banner.scss index 677c2fe5ac..26ae868566 100644 --- a/app/assets/stylesheets/components/_session-banner.scss +++ b/app/assets/stylesheets/components/_session-banner.scss @@ -31,7 +31,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/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..bac9b3124d 100644 --- a/app/views/layouts/session.html.erb +++ b/app/views/layouts/session.html.erb @@ -1,21 +1,23 @@ <% content_for :main_content do %> - <%= render "sessions/header" %> +
+ <%= render "sessions/header" %> -
- <%= yield :navigation %> +
+
+ <%= yield :navigation %> -
- <%= render(AppFlashMessageComponent.new(flash: flash)) %> + <%= render(AppFlashMessageComponent.new(flash: flash)) %> -
-
- <%= yield :before_content %> +
+
+ <%= yield :before_content %> - <%= yield %> + <%= yield %> +
-
+
<% end %> <%= render template: "layouts/application" %> From 50b0f1aa02d0b2e72c2b9890fc41cec84c395f7f Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Mon, 13 Apr 2026 16:13:31 +0100 Subject: [PATCH 02/87] Show notification above page heading within session banner component --- app/assets/stylesheets/components/_session-banner.scss | 1 + app/views/layouts/session.html.erb | 2 -- app/views/sessions/_header.html.erb | 2 ++ 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/components/_session-banner.scss b/app/assets/stylesheets/components/_session-banner.scss index 26ae868566..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"); } diff --git a/app/views/layouts/session.html.erb b/app/views/layouts/session.html.erb index bac9b3124d..e197c5a5b9 100644 --- a/app/views/layouts/session.html.erb +++ b/app/views/layouts/session.html.erb @@ -6,8 +6,6 @@
<%= yield :navigation %> - <%= render(AppFlashMessageComponent.new(flash: flash)) %> -
<%= yield :before_content %> diff --git a/app/views/sessions/_header.html.erb b/app/views/sessions/_header.html.erb index 0c90b72812..acda2bf567 100644 --- a/app/views/sessions/_header.html.erb +++ b/app/views/sessions/_header.html.erb @@ -1,6 +1,8 @@
+ <%= render(AppFlashMessageComponent.new(flash: flash)) %> + <%= h1 session_title(@session), class: "nhsuk-u-margin-bottom-2" %>

From 4c747792607468751a309e6741848d5b06a368ae Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Thu, 9 Apr 2026 11:19:22 +0100 Subject: [PATCH 03/87] Add columns for careplus namespace, username and password to team model * To be used for sending reports to the careplus service Jira-Issue: MAV-5294 --- app/models/onboarding.rb | 3 +++ app/models/team.rb | 5 +++++ ...0260407121005_add_careplus_credentials_to_teams.rb | 11 +++++++++++ db/schema.rb | 5 ++++- docs/managing-teams.md | 3 +++ spec/factories/teams.rb | 6 ++++++ spec/models/team_spec.rb | 3 +++ 7 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20260407121005_add_careplus_credentials_to_teams.rb 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/team.rb b/app/models/team.rb index 40ba86e39e..5790e9c6cd 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,8 @@ class Team < ApplicationRecord normalizes :email, with: EmailAddressNormaliser.new normalizes :phone, with: PhoneNumberNormaliser.new + encrypts :careplus_username, :careplus_password + enum :type, { point_of_care: 0, national_reporting: 1, support: 2 }, validate: true, 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/schema.rb b/db/schema.rb index dab53c97cd..a3a7e699e4 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_07_121005) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" enable_extension "pg_trgm" @@ -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 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/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/models/team_spec.rb b/spec/models/team_spec.rb index f94a828918..5f79359467 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 From 505c049eb3b8e1853b4346fe6c60afa13639e6e1 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 15 Apr 2026 16:11:53 +0100 Subject: [PATCH 04/87] Make `to_date` stricter; reject years with fewer than 4 digits This is instigated by an `ImmunisationImport` which silently failed because it parsed `04/02/12` as `4th Feb 0012`, which failed the patient validation on `date_of_birth`'s academic year > 1900. Jira-Issue: MAV-6541 --- app/lib/csv_parser.rb | 3 +- spec/lib/csv_parser/field_spec.rb | 81 +++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) create mode 100644 spec/lib/csv_parser/field_spec.rb 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/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 From bbdb68b6c3ddde729c163c47879d2d19071575ea Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Wed, 15 Apr 2026 16:11:53 +0100 Subject: [PATCH 05/87] Add `ImmsImport` validation error for too-old patients Without this, it's possible to enter a date of birth which will cause a validation error on the `Patient` model, because the `Patient`'s `birth_academic_year` will be < 1900. Jira-Issue: MAV-6541 --- app/models/immunisation_import_row.rb | 5 +++ spec/models/immunisation_import_row_spec.rb | 35 ++++++++++++++++++++- 2 files changed, 39 insertions(+), 1 deletion(-) 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/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( From 54660d3bcf2236c38f677139904803ff81bd641d Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Thu, 16 Apr 2026 11:04:13 +0100 Subject: [PATCH 06/87] Suppress warning for `immunization.target` mismatch 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. The unit tests should have been testing this, but the fixtures were set up incorrectly Jira-Issue: MAV-6065 --- app/lib/nhs/immunisations_api.rb | 5 +++++ .../files/fhir/search_responses/bad_immunization_target.json | 2 +- .../fhir/search_responses/immunization_target_both.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) 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/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": [ From 48c35b5f427b20a8d659bc0b819053e3043f767d Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 7 Apr 2026 14:47:55 +0100 Subject: [PATCH 07/87] Refactor consent contact options This refactors how the options for the "who" step of the draft consent form are determined to simplify the code, and allow for a more complex set up around self-consent. Jira-Issue: MAV-5915 --- app/controllers/draft_consents_controller.rb | 84 ++++++++++++++------ app/views/draft_consents/who.html.erb | 42 +++------- 2 files changed, 69 insertions(+), 57 deletions(-) diff --git a/app/controllers/draft_consents_controller.rb b/app/controllers/draft_consents_controller.rb index 10a125fbf2..c7c1c6b729 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,64 @@ 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 = [] + + is_gillick_competent = + @patient + .gillick_assessments + .order(created_at: :desc) + .for_session(@session) + .for_programme(@programme) + &.first + &.gillick_competent? + + if is_gillick_competent + @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 +293,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/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 %> From e3f6f4873c368d5d208512cf59597171786ba13a Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 7 Apr 2026 15:35:49 +0100 Subject: [PATCH 08/87] Add `can_self_consent_after_gillick_assessment?` This adds a method on the `Patient` model that can be used to determine if a child can self-consent, following a competent Gillick assessment. Jira-Issue: MAV-5915 --- app/controllers/draft_consents_controller.rb | 14 +-- app/models/patient.rb | 9 ++ spec/models/patient_spec.rb | 100 +++++++++++++++++++ 3 files changed, 113 insertions(+), 10 deletions(-) diff --git a/app/controllers/draft_consents_controller.rb b/app/controllers/draft_consents_controller.rb index c7c1c6b729..86fe1c8ab2 100644 --- a/app/controllers/draft_consents_controller.rb +++ b/app/controllers/draft_consents_controller.rb @@ -231,16 +231,10 @@ def is_who_step? = current_step == :who def set_new_or_existing_contact_options @new_or_existing_contact_options = [] - is_gillick_competent = - @patient - .gillick_assessments - .order(created_at: :desc) - .for_session(@session) - .for_programme(@programme) - &.first - &.gillick_competent? - - if is_gillick_competent + if @patient.can_self_consent_after_gillick_assessment?( + session: @session, + programme_type: @programme.type + ) @new_or_existing_contact_options << NewOrExistingContactOption.new( value: "patient", label: "Child (Gillick competent)" diff --git a/app/models/patient.rb b/app/models/patient.rb index 396918584e..b5f6c90be0 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -547,6 +547,15 @@ def show_year_group?(team:) end end + def can_self_consent_after_gillick_assessment?(session:, programme_type:) + gillick_assessments + .for_session(session) + .where(programme_type:) + .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/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 12353c368f..84b7aff821 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -908,6 +908,106 @@ 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?( + session:, + 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 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:) } From c7468c681c6f24cb82ae25823490160e4262dab0 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 14 Apr 2026 17:27:18 +0100 Subject: [PATCH 09/87] Only consider today's Gillick assessments as valid Gillick assessments are only considered valid for the day that they were recorded on. Outside of that, the child needs to be re-assessed before they can self-consent. Jira-Issue: MAV-5915 --- app/controllers/draft_consents_controller.rb | 2 +- app/models/patient.rb | 5 ++--- spec/models/patient_spec.rb | 17 ++++++++++++++++- 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/app/controllers/draft_consents_controller.rb b/app/controllers/draft_consents_controller.rb index 86fe1c8ab2..5b0448b672 100644 --- a/app/controllers/draft_consents_controller.rb +++ b/app/controllers/draft_consents_controller.rb @@ -232,7 +232,7 @@ def set_new_or_existing_contact_options @new_or_existing_contact_options = [] if @patient.can_self_consent_after_gillick_assessment?( - session: @session, + location: @session.location, programme_type: @programme.type ) @new_or_existing_contact_options << NewOrExistingContactOption.new( diff --git a/app/models/patient.rb b/app/models/patient.rb index b5f6c90be0..7362382625 100644 --- a/app/models/patient.rb +++ b/app/models/patient.rb @@ -547,10 +547,9 @@ def show_year_group?(team:) end end - def can_self_consent_after_gillick_assessment?(session:, programme_type:) + def can_self_consent_after_gillick_assessment?(location:, programme_type:) gillick_assessments - .for_session(session) - .where(programme_type:) + .where(location:, programme_type:, date: Date.current) .order(created_at: :desc) &.first &.gillick_competent? || false diff --git a/spec/models/patient_spec.rb b/spec/models/patient_spec.rb index 84b7aff821..d2155a0cb2 100644 --- a/spec/models/patient_spec.rb +++ b/spec/models/patient_spec.rb @@ -911,7 +911,7 @@ describe "#can_self_consent_after_gillick_assessment?" do subject(:can_self_consent_after_gillick_assessment) do patient.can_self_consent_after_gillick_assessment?( - session:, + location: session.location, programme_type: programme.type ) end @@ -956,6 +956,21 @@ 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( From 86f266c66b96955ed24535e2e69240a0d9f10e2c Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 11:51:38 +0100 Subject: [PATCH 10/87] Use smaller font size for filter card heading --- app/assets/stylesheets/components/_filters.scss | 2 ++ 1 file changed, 2 insertions(+) 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}; From 68bcfec099430db8d039cf8bcaa28f51924fe55c Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 12:46:39 +0100 Subject: [PATCH 11/87] Use small heading size for search result cards --- app/components/app_location_card_component.rb | 2 +- app/components/app_patient_search_result_card_component.rb | 2 +- .../app_patient_session_search_result_card_component.rb | 2 +- app/components/app_session_card_component.rb | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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_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_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_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 From 86e2f0f53d277ed0072707d347b8d1d25c078ffd Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 12:54:42 +0100 Subject: [PATCH 12/87] =?UTF-8?q?Use=20=E2=80=98to=E2=80=99=20for=20format?= =?UTF-8?q?ting=20session=20date=20range?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/helpers/sessions_helper.rb | 4 ++-- spec/features/sessions_school_spec.rb | 2 +- spec/helpers/sessions_helper_spec.rb | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 2abf45709d..75b99759b0 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 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/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb index a54278cc08..2be3353919 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 From be110e80c194793c9069718bfb6bcb4f15b53b3d Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 13:41:24 +0100 Subject: [PATCH 13/87] Move dates in session banner from title to caption --- app/helpers/sessions_helper.rb | 12 +++++-- app/views/sessions/_header.html.erb | 2 +- spec/features/sessions_clinic_spec.rb | 2 +- spec/helpers/sessions_helper_spec.rb | 48 ++++++++++++--------------- 4 files changed, 33 insertions(+), 31 deletions(-) diff --git a/app/helpers/sessions_helper.rb b/app/helpers/sessions_helper.rb index 75b99759b0..b6edeea650 100644 --- a/app/helpers/sessions_helper.rb +++ b/app/helpers/sessions_helper.rb @@ -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/views/sessions/_header.html.erb b/app/views/sessions/_header.html.erb index 0c90b72812..05487d2277 100644 --- a/app/views/sessions/_header.html.erb +++ b/app/views/sessions/_header.html.erb @@ -4,7 +4,7 @@ <%= h1 session_title(@session), class: "nhsuk-u-margin-bottom-2" %>

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

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/helpers/sessions_helper_spec.rb b/spec/helpers/sessions_helper_spec.rb index 2be3353919..42257ad86c 100644 --- a/spec/helpers/sessions_helper_spec.rb +++ b/spec/helpers/sessions_helper_spec.rb @@ -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 From 6ceb94c4013743c6871150ac663b239872bcdccb Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 13:52:49 +0100 Subject: [PATCH 14/87] =?UTF-8?q?Add=20missing=20=E2=80=98Record=20vaccina?= =?UTF-8?q?tions=E2=80=99=20heading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/sessions/record/show.html.erb | 2 ++ 1 file changed, 2 insertions(+) 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 %> From 5fd283f157e0282297937ffb1baa2c98ac119c2e Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 15:13:12 +0100 Subject: [PATCH 15/87] =?UTF-8?q?Use=20action=20link=20component=20for=20?= =?UTF-8?q?=E2=80=98Add=20a=20new=20session=E2=80=99=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/schools/sessions/index.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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

From 812b51182b5af2ed4154819abed11bd5ff16163d Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 15:13:37 +0100 Subject: [PATCH 16/87] =?UTF-8?q?Use=20action=20link=20component=20for=20?= =?UTF-8?q?=E2=80=98Import=20class=20lists=E2=80=99=20and=20rearrange=20cl?= =?UTF-8?q?inic=20links=20on=20school=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/schools/patients/index.html.erb | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) 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, From 972b9a78b94cc4fd3c221d573424a6e633a3f462 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 15:14:12 +0100 Subject: [PATCH 17/87] =?UTF-8?q?Use=20action=20link=20component=20for=20?= =?UTF-8?q?=E2=80=98Create=20new=20record=E2=80=99=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/consent_forms/search.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 %>
From 6e24b6bffafb84fbc7bad0e424288152591598a8 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 14 Apr 2026 12:17:19 +0100 Subject: [PATCH 18/87] Rename `import_search_pds` feature flag I've renamed this to have a prefix of `pds` as we're going to be adding two more feature flags both related to PDS so it makes sense to group them together. Jira-Issue: MAV-2354 --- app/jobs/process_patient_changeset_job.rb | 2 +- app/models/patient_import.rb | 4 ++-- config/feature_flags.yml | 4 ++-- .../import_child_pds_lookup_extravaganza_spec.rb | 4 ++-- spec/features/import_child_records_preparation_spec.rb | 2 +- spec/features/import_child_records_spec.rb | 2 +- .../import_child_records_with_duplicates_spec.rb | 2 +- spec/features/import_child_records_with_twins_spec.rb | 4 ++-- spec/features/import_class_lists_move_spec.rb | 2 +- spec/features/import_class_lists_spec.rb | 2 +- .../import_class_lists_with_duplicates_spec.rb | 2 +- spec/jobs/process_patient_changeset_job_spec.rb | 6 +++--- spec/models/class_import_spec.rb | 10 +++++----- spec/models/cohort_import_spec.rb | 10 +++++----- 14 files changed, 28 insertions(+), 28 deletions(-) diff --git a/app/jobs/process_patient_changeset_job.rb b/app/jobs/process_patient_changeset_job.rb index b708e42fcd..f58a50ba05 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_search_during_import) import.validate_pds_match_rate! return if import.low_pds_match_rate? end diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index 949c4ff0a6..93161a8edf 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -40,7 +40,7 @@ def process_import! PatientChangeset.from_import_row(row:, import: self, row_number:) end - if Flipper.enabled?(:import_search_pds) + if 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) @@ -158,7 +158,7 @@ def process_no_postcode_changesets(changesets) def enqueue_review_jobs(changesets) review_changesets = - if Flipper.enabled?(:import_search_pds) + if Flipper.enabled?(:pds_search_during_import) changesets.with_postcode else changesets diff --git a/config/feature_flags.yml b/config/feature_flags.yml index a057b178d5..d1848788a1 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -39,10 +39,10 @@ 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_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/spec/features/import_child_pds_lookup_extravaganza_spec.rb b/spec/features/import_child_pds_lookup_extravaganza_spec.rb index a92e61284a..9f1cecf06b 100644 --- a/spec/features/import_child_pds_lookup_extravaganza_spec.rb +++ b/spec/features/import_child_pds_lookup_extravaganza_spec.rb @@ -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..e92d7c5473 100644 --- a/spec/features/import_child_records_spec.rb +++ b/spec/features/import_child_records_spec.rb @@ -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..d9e48186ef 100644 --- a/spec/features/import_child_records_with_twins_spec.rb +++ b/spec/features/import_child_records_with_twins_spec.rb @@ -3,8 +3,8 @@ describe "Child record imports twins" 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..39f1f352a5 100644 --- a/spec/features/import_class_lists_with_duplicates_spec.rb +++ b/spec/features/import_class_lists_with_duplicates_spec.rb @@ -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/jobs/process_patient_changeset_job_spec.rb b/spec/jobs/process_patient_changeset_job_spec.rb index c9fdcbc772..a090a95779 100644 --- a/spec/jobs/process_patient_changeset_job_spec.rb +++ b/spec/jobs/process_patient_changeset_job_spec.rb @@ -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/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 2896f135c7..97e90c83f7 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -174,9 +174,9 @@ allow(configured_job).to receive(:perform_later) 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 { Flipper.enable(:pds_search_during_import) } + after { Flipper.disable(:pds_search_during_import) } it "enqueues PDSCascadingSearchJob for each changeset with a postcode" do process! @@ -192,8 +192,8 @@ 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( diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index 332cb17f97..e4485f6c32 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -190,10 +190,10 @@ allow(configured_job).to receive(:perform_later) 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) } - after { Flipper.disable(:import_search_pds) } + after { Flipper.disable(:pds_search_during_import) } it "enqueues PDSCascadingSearchJob for each changeset" do process! @@ -202,8 +202,8 @@ 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( From 4b9720f1bc3e961bbe261aac5a986a9928831786 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 15:15:07 +0100 Subject: [PATCH 19/87] Use action links and card action links on session overview page --- .../app_session_actions_component.rb | 8 +++-- .../app_session_buttons_component.rb | 29 ------------------- .../app_session_details_component.rb | 13 ++++++++- .../app_session_overview_component.rb | 16 ++-------- .../app_session_actions_component_spec.rb | 21 +++++--------- 5 files changed, 28 insertions(+), 59 deletions(-) delete mode 100644 app/components/app_session_buttons_component.rb 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_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/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 From 5d82e025f493c0a56a4234a1f583b460073483f8 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Fri, 20 Mar 2026 11:58:57 +0000 Subject: [PATCH 20/87] Amend AppPatientVaccinationTableComponent to show/hide extra details The national reporting teams uses the same vaccination table but requires more details on vaccination records like location and source, whereas full-fat Mavis doesn't need so much information. The new `show_details` arg controls whether to show or hide this info. --- ...tient_vaccination_table_component.html.erb | 34 +++++++++++-------- ...app_patient_vaccination_table_component.rb | 11 ++++-- ...atient_vaccination_table_component_spec.rb | 18 +++++++++- .../hpv_vaccination_administered_spec.rb | 2 +- .../menacwy_vaccination_administered_spec.rb | 2 +- .../td_ipv_vaccination_administered_spec.rb | 2 +- 6 files changed, 48 insertions(+), 21 deletions(-) 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/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/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/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/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") From c8f59bf3f8f05d7fb82dad434f7f4eafb1eb41bc Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 14 Apr 2026 13:01:33 +0100 Subject: [PATCH 21/87] Add `pds_enqueue_bulk_updates` feature flag This replaces the `enqueue_bulk_updates` setting on `Settings.pds` with a feature flag which makes it easier to be enable/disable the feature without needing an infrastructure deploy. Jira-Issue: MAV-2354 --- app/lib/update_patients_from_pds.rb | 4 +-- config/feature_flags.yml | 3 ++ config/settings.yml | 1 - config/settings/development.yml | 1 - config/settings/end_to_end.yml | 1 - ...queue_update_patients_from_pds_job_spec.rb | 4 ++- spec/lib/update_patients_from_pds_spec.rb | 36 +++++++++---------- spec/models/immunisation_import_spec.rb | 2 ++ 8 files changed, 26 insertions(+), 26 deletions(-) diff --git a/app/lib/update_patients_from_pds.rb b/app/lib/update_patients_from_pds.rb index 7f7e519816..08dd972536 100644 --- a/app/lib/update_patients_from_pds.rb +++ b/app/lib/update_patients_from_pds.rb @@ -26,7 +26,5 @@ def self.call(...) = new(...).call attr_reader :patients, :queue - def enqueue? - @enqueue ||= Settings.pds.enqueue_bulk_updates - end + def enqueue? = Flipper.enabled?(:pds_enqueue_bulk_updates) end diff --git a/config/feature_flags.yml b/config/feature_flags.yml index d1848788a1..9f5c5753a4 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -41,6 +41,9 @@ import_concurrency_per_server: >- ops_tools: Enable the operational support tools; timeline and graph. +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: >- diff --git a/config/settings.yml b/config/settings.yml index 8e71f0744d..9998bbd469 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -43,7 +43,6 @@ immunisations_api: pds: enabled: true - enqueue_bulk_updates: true raise_unknown_gp_practice: true rate_limit_per_second: 50 diff --git a/config/settings/development.yml b/config/settings/development.yml index a1fd0be5a3..b7df0b2403 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -19,7 +19,6 @@ nhs_api: pds: enabled: false - enqueue_bulk_updates: false rate_limit_per_second: 5 splunk: diff --git a/config/settings/end_to_end.yml b/config/settings/end_to_end.yml index f99d4efeaf..9de2793996 100644 --- a/config/settings/end_to_end.yml +++ b/config/settings/end_to_end.yml @@ -19,7 +19,6 @@ nhs_api: pds: enabled: false - enqueue_bulk_updates: false rate_limit_per_second: 5 splunk: 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..e7a5e6e910 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,9 @@ end let!(:never_updated_patient) { create(:patient, updated_from_pds_at: nil) } - it "only queues jobs for the approriate patients" do + before { Flipper.enable(:pds_enqueue_bulk_updates) } + + it "only queues jobs for the appropriate patients" do expect { perform_now }.to have_enqueued_job( PatientUpdateFromPDSJob ).exactly(4).times diff --git a/spec/lib/update_patients_from_pds_spec.rb b/spec/lib/update_patients_from_pds_spec.rb index ad2f8cbc09..edcf94e74d 100644 --- a/spec/lib/update_patients_from_pds_spec.rb +++ b/spec/lib/update_patients_from_pds_spec.rb @@ -6,32 +6,30 @@ 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 + it "queues no jobs" do + expect { call }.not_to have_enqueued_job 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 + context "when feature is enabled" do + before { Flipper.enable(:pds_enqueue_bulk_updates) } - 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 + 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/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 2d9f9a808a..8b17e33639 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -212,6 +212,8 @@ around { |example| travel_to(Date.new(2025, 8, 1)) { example.run } } + before { Flipper.enable(:pds_enqueue_bulk_updates) } + context "with an empty CSV file (no data rows)" do let(:programmes) { [Programme.flu] } let(:file) { "valid_flu.csv" } From fcb5ca0516a325b655eb32c4add5c21576d559cd Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 14 Apr 2026 15:52:26 +0100 Subject: [PATCH 22/87] Remove table override style --- .../stylesheets/vendor/nhsuk-frontend/overrides/_index.scss | 1 - .../stylesheets/vendor/nhsuk-frontend/overrides/_tables.scss | 4 ---- 2 files changed, 5 deletions(-) delete mode 100644 app/assets/stylesheets/vendor/nhsuk-frontend/overrides/_tables.scss 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; -} From 891a5a841eec4b340def75d1597f433f408cbb71 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Fri, 20 Mar 2026 13:25:26 +0000 Subject: [PATCH 23/87] Add AppPatientSessionProgrammeComponent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shows the programme status (heading with colour, contextual detail text, vaccination outcomes table, and action link) for a patient on the session patient show page. This card links to the respective programme tab in the child’s record. Eventually this will be the only card on the page with a coloured heading so various statuses (consent, triage, etc) do not compete for attention and the programme status for the child is reflected right at the top. MAV-4846 --- ...app_patient_session_programme_component.rb | 97 ++++++++++++++ ...p_patient_session_vaccination_component.rb | 51 ------- app/models/patient/programme_status.rb | 4 + .../patient_sessions/programmes/show.html.erb | 4 +- ...atient_session_programme_component_spec.rb | 126 ++++++++++++++++++ ...ient_session_vaccination_component_spec.rb | 47 ------- spec/features/vaccination_offline_spec.rb | 13 +- 7 files changed, 240 insertions(+), 102 deletions(-) create mode 100644 app/components/app_patient_session_programme_component.rb delete mode 100644 app/components/app_patient_session_vaccination_component.rb create mode 100644 spec/components/app_patient_session_programme_component_spec.rb delete mode 100644 spec/components/app_patient_session_vaccination_component_spec.rb 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..3ffcb0ddc2 --- /dev/null +++ b/app/components/app_patient_session_programme_component.rb @@ -0,0 +1,97 @@ +# 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 + + def heading + "#{resolver[:prefix]}: #{resolver[:text]}" + end + + def colour + resolver[:colour] + end + + def details + if 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? + "You need to decide if it’s safe to vaccinate." + else + resolver[:details_text] + end + 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_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/models/patient/programme_status.rb b/app/models/patient/programme_status.rb index 9009503317..fff4b87862 100644 --- a/app/models/patient/programme_status.rb +++ b/app/models/patient/programme_status.rb @@ -154,6 +154,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) } 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/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..ed513ee21d --- /dev/null +++ b/spec/components/app_patient_session_programme_component_spec.rb @@ -0,0 +1,126 @@ +# 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 safe to vaccinate decision details" do + expect(rendered).to have_text( + "You need to decide if it’s safe to vaccinate." + ) + 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/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 From 98b559a2966fca7f47c76aab2ff02181768e6460 Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 14 Apr 2026 14:14:50 +0100 Subject: [PATCH 24/87] Add `pds` main switch feature flag This replaces `Settings.pds.enabled` with a feature flag that we can use to quickly enable or disable any PDS related functionality without needing to re-deploy the application. Jira-Issue: MAV-2354 --- app/jobs/process_patient_changeset_job.rb | 2 +- app/lib/nhs/pds.rb | 6 +- app/lib/update_patients_from_pds.rb | 4 +- app/models/patient_import.rb | 4 +- config/feature_flags.yml | 2 + config/settings.yml | 1 - config/settings/development.yml | 1 - config/settings/end_to_end.yml | 1 - spec/features/cli_pds_get_spec.rb | 2 +- spec/features/cli_pds_search_spec.rb | 2 +- ...port_child_pds_lookup_extravaganza_spec.rb | 2 +- spec/features/import_child_records_spec.rb | 2 +- .../import_child_records_with_twins_spec.rb | 2 +- ...import_class_lists_with_duplicates_spec.rb | 2 +- spec/features/important_notices_spec.rb | 2 +- .../parental_consent_create_patient_spec.rb | 2 +- spec/features/patient_invalidation_spec.rb | 2 +- ...queue_update_patients_from_pds_job_spec.rb | 5 +- .../patient_nhs_number_lookup_job_spec.rb | 5 +- spec/jobs/patient_update_from_pds_job_spec.rb | 362 +++++++++--------- spec/jobs/process_consent_form_job_spec.rb | 2 +- .../process_patient_changeset_job_spec.rb | 2 +- spec/lib/nhs/pds_spec.rb | 304 +++++++-------- spec/lib/update_patients_from_pds_spec.rb | 21 +- spec/models/class_import_spec.rb | 6 +- spec/models/cohort_import_spec.rb | 7 +- spec/models/immunisation_import_spec.rb | 5 +- spec/models/pds/patient_spec.rb | 132 +++---- spec/spec_helper.rb | 3 + 29 files changed, 473 insertions(+), 420 deletions(-) diff --git a/app/jobs/process_patient_changeset_job.rb b/app/jobs/process_patient_changeset_job.rb index f58a50ba05..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?(:pds_search_during_import) + 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/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/update_patients_from_pds.rb b/app/lib/update_patients_from_pds.rb index 08dd972536..9312c67568 100644 --- a/app/lib/update_patients_from_pds.rb +++ b/app/lib/update_patients_from_pds.rb @@ -26,5 +26,7 @@ def self.call(...) = new(...).call attr_reader :patients, :queue - def enqueue? = Flipper.enabled?(:pds_enqueue_bulk_updates) + def enqueue? + Flipper.enabled?(:pds) && Flipper.enabled?(:pds_enqueue_bulk_updates) + end end diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index 93161a8edf..adcd362829 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -40,7 +40,7 @@ def process_import! PatientChangeset.from_import_row(row:, import: self, row_number:) end - if Flipper.enabled?(:pds_search_during_import) + 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) @@ -158,7 +158,7 @@ def process_no_postcode_changesets(changesets) def enqueue_review_jobs(changesets) review_changesets = - if Flipper.enabled?(:pds_search_during_import) + if Flipper.enabled?(:pds) && Flipper.enabled?(:pds_search_during_import) changesets.with_postcode else changesets diff --git a/config/feature_flags.yml b/config/feature_flags.yml index 9f5c5753a4..6616779802 100644 --- a/config/feature_flags.yml +++ b/config/feature_flags.yml @@ -41,6 +41,8 @@ import_concurrency_per_server: >- 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. diff --git a/config/settings.yml b/config/settings.yml index 9998bbd469..e7c2d41068 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -42,7 +42,6 @@ immunisations_api: rate_limit_per_second: 20 pds: - enabled: true raise_unknown_gp_practice: true rate_limit_per_second: 50 diff --git a/config/settings/development.yml b/config/settings/development.yml index b7df0b2403..2610d6c0f0 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -18,7 +18,6 @@ nhs_api: disable_authentication: true pds: - enabled: false rate_limit_per_second: 5 splunk: diff --git a/config/settings/end_to_end.yml b/config/settings/end_to_end.yml index 9de2793996..a5176c2925 100644 --- a/config/settings/end_to_end.yml +++ b/config/settings/end_to_end.yml @@ -18,7 +18,6 @@ nhs_api: disable_authentication: true pds: - enabled: false rate_limit_per_second: 5 splunk: 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/import_child_pds_lookup_extravaganza_spec.rb b/spec/features/import_child_pds_lookup_extravaganza_spec.rb index 9f1cecf06b..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 } } diff --git a/spec/features/import_child_records_spec.rb b/spec/features/import_child_records_spec.rb index e92d7c5473..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 diff --git a/spec/features/import_child_records_with_twins_spec.rb b/spec/features/import_child_records_with_twins_spec.rb index d9e48186ef..022d80e1ff 100644 --- a/spec/features/import_child_records_with_twins_spec.rb +++ b/spec/features/import_child_records_with_twins_spec.rb @@ -1,6 +1,6 @@ # 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(:pds_search_during_import) } diff --git a/spec/features/import_class_lists_with_duplicates_spec.rb b/spec/features/import_class_lists_with_duplicates_spec.rb index 39f1f352a5..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 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/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/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/jobs/enqueue_update_patients_from_pds_job_spec.rb b/spec/jobs/enqueue_update_patients_from_pds_job_spec.rb index e7a5e6e910..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,10 @@ end let!(:never_updated_patient) { create(:patient, updated_from_pds_at: nil) } - before { Flipper.enable(:pds_enqueue_bulk_updates) } + 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( 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 a090a95779..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 } 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/update_patients_from_pds_spec.rb b/spec/lib/update_patients_from_pds_spec.rb index edcf94e74d..827a5e39c9 100644 --- a/spec/lib/update_patients_from_pds_spec.rb +++ b/spec/lib/update_patients_from_pds_spec.rb @@ -15,9 +15,28 @@ expect { call }.not_to have_enqueued_job end - context "when feature is enabled" do + 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 + + 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 + + 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) diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 97e90c83f7..9f3d4d7bbe 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -175,8 +175,10 @@ end context "when pds_search_during_import flag is enabled" do - before { Flipper.enable(:pds_search_during_import) } - after { Flipper.disable(:pds_search_during_import) } + before do + Flipper.enable(:pds) + Flipper.enable(:pds_search_during_import) + end it "enqueues PDSCascadingSearchJob for each changeset with a postcode" do process! diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index e4485f6c32..f1925052ab 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -191,9 +191,10 @@ end context "when pds_search_during_import flag is enabled" do - before { Flipper.enable(:pds_search_during_import) } - - after { Flipper.disable(:pds_search_during_import) } + before do + Flipper.enable(:pds) + Flipper.enable(:pds_search_during_import) + end it "enqueues PDSCascadingSearchJob for each changeset" do process! diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 8b17e33639..12338708da 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -212,7 +212,10 @@ around { |example| travel_to(Date.new(2025, 8, 1)) { example.run } } - before { Flipper.enable(:pds_enqueue_bulk_updates) } + before do + Flipper.enable(:pds) + Flipper.enable(:pds_enqueue_bulk_updates) + end context "with an empty CSV file (no data rows)" do let(:programmes) { [Programme.flu] } 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/spec_helper.rb b/spec/spec_helper.rb index eaaf403200..2724048e15 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -212,6 +212,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 From b319dd6f188141fa0ab268af90b06dea350228d8 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Thu, 16 Apr 2026 14:47:47 +0100 Subject: [PATCH 25/87] Show triage decision in programme card details text When a triage has been performed, the programme card now shows a human-readable summary of the decision (e.g. "Joy, Nurse decided that Oliver is safe to vaccinate.") using the shared triage_summary helper. The delay vaccination case is updated to include the "until" date when present. --- ...app_patient_session_programme_component.rb | 16 +++- app/helpers/triages_helper.rb | 16 ++-- ...atient_session_programme_component_spec.rb | 80 ++++++++++++++++++- spec/features/triage_required_spec.rb | 4 +- 4 files changed, 105 insertions(+), 11 deletions(-) diff --git a/app/components/app_patient_session_programme_component.rb b/app/components/app_patient_session_programme_component.rb index 3ffcb0ddc2..cf33e0ee5d 100644 --- a/app/components/app_patient_session_programme_component.rb +++ b/app/components/app_patient_session_programme_component.rb @@ -34,6 +34,7 @@ def initialize(patient:, session:, programme:) attr_reader :patient, :session, :programme delegate :academic_year, to: :session + delegate :triage_summary, to: :helpers def heading "#{resolver[:prefix]}: #{resolver[:text]}" @@ -44,7 +45,9 @@ def colour end def details - if programme_status.due? + if latest_triage + triage_summary(latest_triage) + elsif programme_status.due? criteria_label = I18n.t( programme_status.vaccine_criteria.to_param, @@ -72,12 +75,21 @@ def details "#{patient.given_name} was vaccinated on #{record&.performed_at&.to_fs(:long)}." end elsif programme_status.needs_triage? - "You need to decide if it’s safe to vaccinate." + "You need to decide if it’s safe to vaccinate #{patient.given_name}." else resolver[:details_text] end 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 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/spec/components/app_patient_session_programme_component_spec.rb b/spec/components/app_patient_session_programme_component_spec.rb index ed513ee21d..33631007f9 100644 --- a/spec/components/app_patient_session_programme_component_spec.rb +++ b/spec/components/app_patient_session_programme_component_spec.rb @@ -117,10 +117,86 @@ it { should have_css("h4", text: "Flu:") } it { should_not have_css("table") } - it "shows safe to vaccinate decision details" do + it "shows needs triage details" do expect(rendered).to have_text( - "You need to decide if it’s safe to vaccinate." + "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( + :triage, + :safe_to_vaccinate, + patient:, + programme:, + performed_by: nurse + ) + end + + it "shows triage summary" do + expect(rendered).to have_text("#{nurse.full_name} decided that") + expect(rendered).to have_text("is safe to vaccinate") + end + end + + context "do not vaccinate" do + before do + 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 + + 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( + :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/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 From cfcfa5f8a1b27b54489820c0b710c7599eade92b Mon Sep 17 00:00:00 2001 From: James Mead Date: Tue, 14 Apr 2026 17:52:29 +0100 Subject: [PATCH 26/87] Simplify batching query in PatientStatusUpdater We've seen some evidence that the batching queries generated by `PatientStatusUpdater#update_programme_statuses!` are (at least sometimes) slow in production, potentially because of a bunch of sequential scans relating to JOINs generated by the call to `ActiveRecord::QueryMethods#includes`. The call to `#includes` is necessary in order to trigger eager loading of associations to avoid N+1 queries when calling `Patient::ProgrammeStatus#assign`. However, this is only relevant for the batch queries which make the model instances available within each batch; not for the batching queries which just work out which model IDs to include in a given batch. Thus we can simplify the batching queries without changing the overall behaviour by using `ActiveRecord::Batches#in_batches` [1] instead of `#find_in_batches` and moving the call to `#includes` onto the batch relation. Note that as per the `ActiveRecord::QueryMethods#includes` docs [2]: > A separate query is performed for each association, unless a join is required > by conditions. This means that the JOINs were only being generated on the batching queries when the patient scope included a condition, e.g. when associated with an `ImmunisationImport`. Given the instance of `PatientStatusUpdaterJob` scheduled overnight [3] uses the default arguments, its batching query is *unscoped* and thus it may not directly benefit from any performance improvements made by the changes in this commit. [1]: https://api.rubyonrails.org/v8.1.3/classes/ActiveRecord/Batches.html#method-i-in_batches [2]: https://api.rubyonrails.org/v8.1.3/classes/ActiveRecord/QueryMethods.html#method-i-includes [3]: https://github.com/NHSDigital/manage-vaccinations-in-schools/blob/6895ee514a018aaa507a460e332a32b3d9e8b6a1/config/sidekiq.yml#L70-L73 Co-authored-by: Chris Lowis Co-authored-by: Chris Roos --- app/lib/patient_status_updater.rb | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/app/lib/patient_status_updater.rb b/app/lib/patient_status_updater.rb index 2ea8d83efb..fd854262df 100644 --- a/app/lib/patient_status_updater.rb +++ b/app/lib/patient_status_updater.rb @@ -42,16 +42,18 @@ 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 + ).to_a + batch.each(&:assign) Patient::ProgrammeStatus.import!( From 6679a8336a17e9f24a3ef3d18ed88f63b919912e Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Tue, 14 Apr 2026 10:14:28 +0100 Subject: [PATCH 27/87] Pin all GitHub Actions to commit SHAs for security This reduces the risk of supply chain attacks where the tag points to a different commit implicitly without us being aware. Jira-Issue: MAV-5897 --- .github/workflows/build-and-push-image.yml | 16 +++++------ .github/workflows/create_dockerized_db.yml | 10 +++---- .../workflows/data-replication-pipeline.yml | 16 +++++------ .github/workflows/deploy-documentation.yml | 6 ++-- .github/workflows/deploy.yml | 28 +++++++++---------- .github/workflows/draft-new-release.yml | 2 +- .github/workflows/end-to-end-tests-aws.yml | 28 +++++++++---------- .github/workflows/end-to-end-tests-local.yml | 16 +++++------ .github/workflows/lint.yml | 8 +++--- .github/workflows/test.yml | 16 +++++------ .../update-version-numbers-slack.yml | 2 +- .yamllint.yaml | 4 ++- 12 files changed, 77 insertions(+), 75 deletions(-) diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index a6515f6f7c..8d536b64e9 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 - 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/create_dockerized_db.yml b/.github/workflows/create_dockerized_db.yml index ae5cb9b552..a4779e6447 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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 # 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..3f568e7320 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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..70d7869298 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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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..4b2a3ab7c7 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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..da97b90f43 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 - 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # 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@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 with: role-to-assume: arn:aws:iam::393416225559:role/GitHubAssuranceTestRole aws-region: eu-west-2 @@ -360,7 +360,7 @@ jobs: id-token: write steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@v6 + uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # 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..3a98aae7e0 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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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@94527f2e458b27549849d47d273a16bec83a01e9 # v7.6.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..cdd9c66ce2 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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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..e577305b40 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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.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/.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/* From c3b2bee0668e1b0f28cc58db5b8a013a6ae4acc7 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Wed, 8 Apr 2026 13:55:27 +0100 Subject: [PATCH 28/87] Use one template for all school consent reminders and subsequent reminders Previously, HPV, doubles, flu, MMR(V) used separate templates for the first reminder and for second-and-beyond reminders. Those templates had only minor differences, which made them harder to maintain and increased the risk that improvements made to one version would not be applied to the other. This change updates reminder template selection so school consent reminders always use the unified consent_school_reminder_* templates, regardless of whether the reminder is the first one or a later one. Jira-Issue: MAV-2841 --- app/lib/notifier/patient.rb | 10 +++- app/models/notify_log_entry.rb | 8 ++- ... consent_school_reminder_doubles.text.erb} | 2 +- ...b => consent_school_reminder_flu.text.erb} | 2 +- ...b => consent_school_reminder_hpv.text.erb} | 2 +- ...b => consent_school_reminder_mmr.text.erb} | 2 +- ... => consent_school_reminder_mmrv.text.erb} | 2 +- ...chool_subsequent_reminder_doubles.text.erb | 54 ----------------- ...nt_school_subsequent_reminder_flu.text.erb | 50 ---------------- ...nt_school_subsequent_reminder_hpv.text.erb | 50 ---------------- ...nt_school_subsequent_reminder_mmr.text.erb | 46 -------------- ...t_school_subsequent_reminder_mmrv.text.erb | 46 -------------- ...sent_manual_consent_reminders_send_spec.rb | 12 +--- ...led_consent_requests_and_reminders_spec.rb | 60 ++++--------------- ...matic_school_consent_reminders_job_spec.rb | 2 + spec/lib/notifier/patient_spec.rb | 34 +++++------ 16 files changed, 51 insertions(+), 331 deletions(-) rename app/views/notify_templates/email/{consent_school_initial_reminder_doubles.text.erb => consent_school_reminder_doubles.text.erb} (98%) rename app/views/notify_templates/email/{consent_school_initial_reminder_flu.text.erb => consent_school_reminder_flu.text.erb} (98%) rename app/views/notify_templates/email/{consent_school_initial_reminder_hpv.text.erb => consent_school_reminder_hpv.text.erb} (97%) rename app/views/notify_templates/email/{consent_school_initial_reminder_mmr.text.erb => consent_school_reminder_mmr.text.erb} (97%) rename app/views/notify_templates/email/{consent_school_initial_reminder_mmrv.text.erb => consent_school_reminder_mmrv.text.erb} (97%) delete mode 100644 app/views/notify_templates/email/consent_school_subsequent_reminder_doubles.text.erb delete mode 100644 app/views/notify_templates/email/consent_school_subsequent_reminder_flu.text.erb delete mode 100644 app/views/notify_templates/email/consent_school_subsequent_reminder_hpv.text.erb delete mode 100644 app/views/notify_templates/email/consent_school_subsequent_reminder_mmr.text.erb delete mode 100644 app/views/notify_templates/email/consent_school_subsequent_reminder_mmrv.text.erb diff --git a/app/lib/notifier/patient.rb b/app/lib/notifier/patient.rb index 9eee4c152d..812441971b 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 @@ -246,7 +248,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/models/notify_log_entry.rb b/app/models/notify_log_entry.rb index eb1bd91fd2..02d2fc1c34 100644 --- a/app/models/notify_log_entry.rb +++ b/app/models/notify_log_entry.rb @@ -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/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/spec/features/parental_consent_manual_consent_reminders_send_spec.rb b/spec/features/parental_consent_manual_consent_reminders_send_spec.rb index de0752e190..aed56010a1 100644 --- a/spec/features/parental_consent_manual_consent_reminders_send_spec.rb +++ b/spec/features/parental_consent_manual_consent_reminders_send_spec.rb @@ -144,16 +144,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/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/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/notifier/patient_spec.rb b/spec/lib/notifier/patient_spec.rb index e8fb6b5ad2..f698616220 100644 --- a/spec/lib/notifier/patient_spec.rb +++ b/spec/lib/notifier/patient_spec.rb @@ -598,7 +598,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 +606,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 +660,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 +676,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 +692,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 +709,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 +727,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 +744,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 +801,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 +809,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 +863,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 +879,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 +900,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 +917,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 +936,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 +953,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 From 901429cb0dd0bf2c2388e9c4dac3f4cb4c8cfc6b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:53:24 +0000 Subject: [PATCH 29/87] Bump falcon from 0.55.2 to 0.55.3 Bumps [falcon](https://github.com/socketry/falcon) from 0.55.2 to 0.55.3. - [Release notes](https://github.com/socketry/falcon/releases) - [Changelog](https://github.com/socketry/falcon/blob/main/releases.md) - [Commits](https://github.com/socketry/falcon/compare/v0.55.2...v0.55.3) --- updated-dependencies: - dependency-name: falcon dependency-version: 0.55.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e59652107d..b41f72be62 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -316,7 +316,7 @@ GEM railties (>= 6.1.0) faker (3.6.1) i18n (>= 1.8.11, < 2) - falcon (0.55.2) + falcon (0.55.3) async async-container (~> 0.20) async-http (~> 0.75) @@ -414,7 +414,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) @@ -571,13 +571,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) From 07a1956e9af4325d92cb5ded7f9f5daa495c7019 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:54:26 +0000 Subject: [PATCH 30/87] Bump parallel_tests from 5.6.0 to 5.7.0 Bumps [parallel_tests](https://github.com/grosser/parallel_tests) from 5.6.0 to 5.7.0. - [Changelog](https://github.com/grosser/parallel_tests/blob/master/CHANGELOG.md) - [Commits](https://github.com/grosser/parallel_tests/compare/v5.6.0...v5.7.0) --- updated-dependencies: - dependency-name: parallel_tests dependency-version: 5.7.0 dependency-type: direct:development update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index e59652107d..a7e9a99ea1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -552,8 +552,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) From e60cb2627c7dcfcee699a1bfaaf55c27fcbca2dc Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:53:15 +0000 Subject: [PATCH 31/87] Bump aws-actions/configure-aws-credentials Bumps [aws-actions/configure-aws-credentials](https://github.com/aws-actions/configure-aws-credentials) from a89a83ec143615402a01f672b6e172b7b1875000 to ec61189d14ec14c8efccab744f656cffd0e33f37. - [Release notes](https://github.com/aws-actions/configure-aws-credentials/releases) - [Changelog](https://github.com/aws-actions/configure-aws-credentials/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/configure-aws-credentials/compare/a89a83ec143615402a01f672b6e172b7b1875000...ec61189d14ec14c8efccab744f656cffd0e33f37) --- updated-dependencies: - dependency-name: aws-actions/configure-aws-credentials dependency-version: ec61189d14ec14c8efccab744f656cffd0e33f37 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/build-and-push-image.yml | 6 +++--- .github/workflows/create_dockerized_db.yml | 2 +- .github/workflows/data-replication-pipeline.yml | 4 ++-- .github/workflows/deploy.yml | 8 ++++---- .github/workflows/end-to-end-tests-aws.yml | 12 ++++++------ 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index 8d536b64e9..f2bdf4a0b4 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@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -108,7 +108,7 @@ jobs: - name: Write build SHA run: git rev-parse HEAD > public/sha - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: ${{ matrix.aws-role }} aws-region: eu-west-2 diff --git a/.github/workflows/create_dockerized_db.yml b/.github/workflows/create_dockerized_db.yml index a4779e6447..779dcc9b8a 100644 --- a/.github/workflows/create_dockerized_db.yml +++ b/.github/workflows/create_dockerized_db.yml @@ -65,7 +65,7 @@ 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@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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/data-replication-pipeline.yml b/.github/workflows/data-replication-pipeline.yml index 3f568e7320..75604ca7df 100644 --- a/.github/workflows/data-replication-pipeline.yml +++ b/.github/workflows/data-replication-pipeline.yml @@ -97,7 +97,7 @@ jobs: with: ref: ${{ env.git_ref_to_deploy }} - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: ${{ env.aws_role }} aws-region: eu-west-2 @@ -189,7 +189,7 @@ jobs: id-token: write steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + uses: aws-actions/configure-aws-credentials@ec61189d14ec14c8efccab744f656cffd0e33f37 # v6.1.0 with: role-to-assume: ${{ env.aws_role }} aws-region: eu-west-2 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4b2a3ab7c7..9716d5d539 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -114,7 +114,7 @@ jobs: with: ref: ${{ needs.validate-and-resolve-sha.outputs.git-sha }} - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -198,7 +198,7 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -332,7 +332,7 @@ jobs: fromJSON(format('["{0}"]', inputs.server_types)) }} steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -378,7 +378,7 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 diff --git a/.github/workflows/end-to-end-tests-aws.yml b/.github/workflows/end-to-end-tests-aws.yml index da97b90f43..f50c1493f7 100644 --- a/.github/workflows/end-to-end-tests-aws.yml +++ b/.github/workflows/end-to-end-tests-aws.yml @@ -26,7 +26,7 @@ jobs: application-image-git-ref: ${{ steps.check-image.outputs.GIT_REF_SHA }} steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -64,7 +64,7 @@ jobs: 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@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -91,7 +91,7 @@ jobs: db_git_ref_sha: ${{ steps.check-image.outputs.GIT_REF_SHA }} steps: - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -145,7 +145,7 @@ jobs: run_task_arn: ${{ steps.run-task.outputs.run-task-arn }} steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -218,7 +218,7 @@ jobs: container_ip: ${{ steps.compile-outputs.outputs.container_ip }} steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 @@ -360,7 +360,7 @@ jobs: id-token: write steps: - name: Configure AWS credentials - uses: aws-actions/configure-aws-credentials@a89a83ec143615402a01f672b6e172b7b1875000 # v6.1.0 + 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 From 268cb909c7a8b8a04fc213f8e4181af70d353f33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Apr 2026 21:54:04 +0000 Subject: [PATCH 32/87] Bump astral-sh/setup-uv Bumps [astral-sh/setup-uv](https://github.com/astral-sh/setup-uv) from 94527f2e458b27549849d47d273a16bec83a01e9 to 37802adc94f370d6bfd71619e3f0bf239e1f3b78. - [Release notes](https://github.com/astral-sh/setup-uv/releases) - [Commits](https://github.com/astral-sh/setup-uv/compare/94527f2e458b27549849d47d273a16bec83a01e9...37802adc94f370d6bfd71619e3f0bf239e1f3b78) --- updated-dependencies: - dependency-name: astral-sh/setup-uv dependency-version: 37802adc94f370d6bfd71619e3f0bf239e1f3b78 dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/end-to-end-tests-local.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-tests-local.yml b/.github/workflows/end-to-end-tests-local.yml index 3a98aae7e0..8eecef4cf8 100644 --- a/.github/workflows/end-to-end-tests-local.yml +++ b/.github/workflows/end-to-end-tests-local.yml @@ -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@94527f2e458b27549849d47d273a16bec83a01e9 # v7.6.0 + uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - name: Setup Playwright working-directory: ${{ env.MAVIS_TEST_REPO }} run: | From 47c46abae7565fa75d766f4bc06aabc18c1317a7 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Sun, 5 Apr 2026 13:11:13 +0000 Subject: [PATCH 33/87] Add python --- .gitignore | 4 ++++ .tool-versions | 1 + 2 files changed, 5 insertions(+) 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/.tool-versions b/.tool-versions index 453c61cede..7f758365ef 100644 --- a/.tool-versions +++ b/.tool-versions @@ -4,6 +4,7 @@ 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 shellcheck 0.11.0 From f3d337d4ba9f0ce0c4caf2f685a5e0321cf78eb4 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Sun, 5 Apr 2026 13:11:13 +0000 Subject: [PATCH 34/87] Add bin/mavis-server and shell sub-command The mavis-server cli command will be used to perform operations on Mavis servers, such as shelling into them as demonstrated in this change. This is designed to replace the various shell scripts we have, e.g. script/shell.sh --- bin/mavis-server | 11 ++ python/mavis/__init__.py | 0 python/mavis/server/__init__.py | 0 python/mavis/server/__main__.py | 4 + python/mavis/server/aws.py | 191 ++++++++++++++++++++++++++++++++ python/mavis/server/cli.py | 17 +++ python/mavis/server/helpers.py | 8 ++ python/mavis/server/shell.py | 57 ++++++++++ 8 files changed, 288 insertions(+) create mode 100755 bin/mavis-server create mode 100644 python/mavis/__init__.py create mode 100644 python/mavis/server/__init__.py create mode 100644 python/mavis/server/__main__.py create mode 100644 python/mavis/server/aws.py create mode 100644 python/mavis/server/cli.py create mode 100644 python/mavis/server/helpers.py create mode 100644 python/mavis/server/shell.py 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/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..f5518269c2 --- /dev/null +++ b/python/mavis/server/aws.py @@ -0,0 +1,191 @@ +import json +import subprocess + +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_command(env, task_id, command, container=None, interactive=True): + """Execute a command in an ECS task, returning the exit code.""" + cmd = [ + "aws", + "ecs", + "execute-command", + "--region", + REGION, + "--cluster", + cluster(env), + "--task", + task_id, + "--command", + command, + ] + if container: + cmd += ["--container", container] + if interactive: + cmd.append("--interactive") + return subprocess.run(cmd).returncode + + +# --- private helpers --- + + +def _default_service(env): + if env.endswith("data-replication"): + return f"mavis-{env}-web" + 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..eb0c2c5fac --- /dev/null +++ b/python/mavis/server/cli.py @@ -0,0 +1,17 @@ +import argparse + +from . import shell + + +def main(): + parser = argparse.ArgumentParser( + prog="mavis-server", + description="MAVIS server management CLI", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + shell.register(subparsers) + + args = parser.parse_args() + # TODO: Clean this error reporting up + args.func(args) diff --git a/python/mavis/server/helpers.py b/python/mavis/server/helpers.py new file mode 100644 index 0000000000..1a90bea2a7 --- /dev/null +++ b/python/mavis/server/helpers.py @@ -0,0 +1,8 @@ +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") diff --git a/python/mavis/server/shell.py b/python/mavis/server/shell.py new file mode 100644 index 0000000000..106c23c1e1 --- /dev/null +++ b/python/mavis/server/shell.py @@ -0,0 +1,57 @@ +import sys + +from . import aws +from .helpers import confirm_production + + +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 "") + ) + exit_code = aws.run_command( + env, task_id, "/rails/bin/docker-entrypoint /bin/bash", container=container + ) + + sys.exit(exit_code) From 05cc573b491b9a5c8dd19cc7e91abc706e5cc9b2 Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Fri, 17 Apr 2026 11:21:39 +0100 Subject: [PATCH 35/87] Add CLI tool which sends a given CSV to careplus service * Currently uses the mock service until access to the real service is granted * Sets the Careplus URL for each environment * Extracts logic to send requests into Careplus::Client Jira-Issue: MAV-5884 --- app/lib/careplus/client.rb | 64 +++++ app/lib/mavis_cli.rb | 1 + app/lib/mavis_cli/reports/send_to_careplus.rb | 109 ++++++++ config/settings.yml | 3 + config/settings/development.yml | 3 + config/settings/staging.yml | 3 + config/settings/test.yml | 3 + .../cli_reports_send_to_careplus_spec.rb | 244 ++++++++++++++++++ spec/lib/careplus/client_spec.rb | 94 +++++++ 9 files changed, 524 insertions(+) create mode 100644 app/lib/careplus/client.rb create mode 100644 app/lib/mavis_cli/reports/send_to_careplus.rb create mode 100644 spec/features/cli_reports_send_to_careplus_spec.rb create mode 100644 spec/lib/careplus/client_spec.rb 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/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/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/config/settings.yml b/config/settings.yml index e7c2d41068..9d20599200 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -57,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 2610d6c0f0..6dae3bdd51 100644 --- a/config/settings/development.yml +++ b/config/settings/development.yml @@ -26,3 +26,6 @@ splunk: reporting_api: client_app: root_url: http://localhost:4001 + +careplus: + base_url: http://localhost:8080 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/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/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 From f8b86f83191efe7cde1c8f573ebd0c575a326689 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Sun, 5 Apr 2026 13:12:30 +0000 Subject: [PATCH 36/87] Add mavis-server put-file subcommand Upload a local file to an ECS container via S3. --- python/mavis/server/cli.py | 3 +- python/mavis/server/put_file.py | 85 +++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 python/mavis/server/put_file.py diff --git a/python/mavis/server/cli.py b/python/mavis/server/cli.py index eb0c2c5fac..cb1de7b815 100644 --- a/python/mavis/server/cli.py +++ b/python/mavis/server/cli.py @@ -1,6 +1,6 @@ import argparse -from . import shell +from . import put_file, shell def main(): @@ -10,6 +10,7 @@ def main(): ) subparsers = parser.add_subparsers(dest="command", required=True) + put_file.register(subparsers) shell.register(subparsers) args = parser.parse_args() diff --git a/python/mavis/server/put_file.py b/python/mavis/server/put_file.py new file mode 100644 index 0000000000..3d24c1ac3d --- /dev/null +++ b/python/mavis/server/put_file.py @@ -0,0 +1,85 @@ +import os +import secrets +import subprocess +import sys + +from . import aws +from .helpers import confirm_production + + +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 = subprocess.run( + ["aws", "s3", "cp", args.local_file, s3_uri, "--region", aws.REGION] + ) + if upload.returncode != 0: + sys.exit("Error: Failed to upload file to S3") + + try: + exit_code = aws.run_command( + env, + task_id, + f"aws s3 cp {s3_uri} {remote_path} --region {aws.REGION}", + container=container, + ) + finally: + subprocess.run( + ["aws", "s3", "rm", s3_uri, "--region", aws.REGION], + capture_output=True, + ) + + if exit_code != 0: + sys.exit("Error: Failed to copy file into container") + print("File successfully uploaded to container") From 26e235def9033f9b8e0400d7c49223a29c128446 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Sun, 5 Apr 2026 13:12:34 +0000 Subject: [PATCH 37/87] Add mavis-server get-file subcommand Downloads a file from an ECS container to local via S3. --- python/mavis/server/cli.py | 3 +- python/mavis/server/get_file.py | 98 +++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 python/mavis/server/get_file.py diff --git a/python/mavis/server/cli.py b/python/mavis/server/cli.py index cb1de7b815..3ae804d775 100644 --- a/python/mavis/server/cli.py +++ b/python/mavis/server/cli.py @@ -1,6 +1,6 @@ import argparse -from . import put_file, shell +from . import get_file, put_file, shell def main(): @@ -10,6 +10,7 @@ def main(): ) subparsers = parser.add_subparsers(dest="command", required=True) + get_file.register(subparsers) put_file.register(subparsers) shell.register(subparsers) diff --git a/python/mavis/server/get_file.py b/python/mavis/server/get_file.py new file mode 100644 index 0000000000..adc142f26c --- /dev/null +++ b/python/mavis/server/get_file.py @@ -0,0 +1,98 @@ +import os +import secrets +import subprocess +import sys + +from . import aws +from .helpers import confirm_production + + +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: + exit_code = aws.run_command( + env, + task_id, + f"aws s3 cp {args.remote_path} {s3_uri} --region {aws.REGION}", + container=container, + ) + if exit_code != 0: + sys.exit("Error: Failed to copy file from container to S3") + + download = subprocess.run( + ["aws", "s3", "cp", s3_uri, local_dest, "--region", aws.REGION] + ) + if download.returncode != 0: + sys.exit("Error: Failed to download file from S3") + finally: + subprocess.run( + ["aws", "s3", "rm", s3_uri, "--region", aws.REGION], + capture_output=True, + ) + + 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 From 83ea7bf6824968f0032da3d5d5cebe9c4ae7c4d3 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 15 Apr 2026 16:22:30 +0100 Subject: [PATCH 38/87] Prevent ^C from exiting mavis-server shell ^C should be passed through to the connected shell, and not interrupt the running mavis-server process. --- python/mavis/server/aws.py | 7 ++++++- python/mavis/server/shell.py | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/python/mavis/server/aws.py b/python/mavis/server/aws.py index f5518269c2..48ae6c7f4d 100644 --- a/python/mavis/server/aws.py +++ b/python/mavis/server/aws.py @@ -1,3 +1,4 @@ +import os import json import subprocess @@ -137,7 +138,9 @@ def resolve_task(env, task_id=None, task_ip=None, service=None): ) -def run_command(env, task_id, command, container=None, interactive=True): +def run_command( + env, task_id, command, container=None, interactive=True, replace_process=False +): """Execute a command in an ECS task, returning the exit code.""" cmd = [ "aws", @@ -156,6 +159,8 @@ def run_command(env, task_id, command, container=None, interactive=True): cmd += ["--container", container] if interactive: cmd.append("--interactive") + if replace_process: + os.execvp(cmd[0], cmd) return subprocess.run(cmd).returncode diff --git a/python/mavis/server/shell.py b/python/mavis/server/shell.py index 106c23c1e1..21e619a37c 100644 --- a/python/mavis/server/shell.py +++ b/python/mavis/server/shell.py @@ -51,7 +51,11 @@ def run(args): + (f" (service {args.service})" if args.service else "") ) exit_code = aws.run_command( - env, task_id, "/rails/bin/docker-entrypoint /bin/bash", container=container + env, + task_id, + "/rails/bin/docker-entrypoint /bin/bash", + container=container, + replace_process=True, ) sys.exit(exit_code) From 556602d6cfcd766d55f81493365d515dc30012a7 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Wed, 15 Apr 2026 18:16:06 +0100 Subject: [PATCH 39/87] Remove script/shell.sh and script/copy-file.sh These are replaced with bin/mavis-server --- docs/aws-setup.md | 2 +- script/copy-file.sh | 132 ------------------------------- script/shell.sh | 184 -------------------------------------------- 3 files changed, 1 insertion(+), 317 deletions(-) delete mode 100755 script/copy-file.sh delete mode 100755 script/shell.sh 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/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 From 75393f81471d4ed0d3b49fe34ffae173a8ab4585 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 16 Apr 2026 09:06:19 +0100 Subject: [PATCH 40/87] Fix default service for data replication --- python/mavis/server/aws.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/mavis/server/aws.py b/python/mavis/server/aws.py index 48ae6c7f4d..fe942bb2b9 100644 --- a/python/mavis/server/aws.py +++ b/python/mavis/server/aws.py @@ -169,7 +169,7 @@ def run_command( def _default_service(env): if env.endswith("data-replication"): - return f"mavis-{env}-web" + return f"mavis-{env}" return f"mavis-{env}-ops" From f78e05d3a9d8f02e4a69ccdc0a7e53f67d70135d Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 16 Apr 2026 09:59:50 +0100 Subject: [PATCH 41/87] Improve error reporting --- python/mavis/server/aws.py | 20 +++++++++----------- python/mavis/server/get_file.py | 16 +++++++--------- python/mavis/server/helpers.py | 18 ++++++++++++++++++ python/mavis/server/put_file.py | 16 +++++++--------- python/mavis/server/shell.py | 6 ++---- 5 files changed, 43 insertions(+), 33 deletions(-) diff --git a/python/mavis/server/aws.py b/python/mavis/server/aws.py index fe942bb2b9..f31e04330c 100644 --- a/python/mavis/server/aws.py +++ b/python/mavis/server/aws.py @@ -1,7 +1,8 @@ -import os import json import subprocess +from .helpers import run_command + REGION = "eu-west-2" PRODUCTION_ENVS = {"production", "production-data-replication"} @@ -138,11 +139,11 @@ def resolve_task(env, task_id=None, task_ip=None, service=None): ) -def run_command( - env, task_id, command, container=None, interactive=True, replace_process=False +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.""" - cmd = [ + command = [ "aws", "ecs", "execute-command", @@ -153,15 +154,12 @@ def run_command( "--task", task_id, "--command", - command, + remote_command, + "--interactive", ] if container: - cmd += ["--container", container] - if interactive: - cmd.append("--interactive") - if replace_process: - os.execvp(cmd[0], cmd) - return subprocess.run(cmd).returncode + command += ["--container", container] + return run_command(command, replace_process=replace_process) # --- private helpers --- diff --git a/python/mavis/server/get_file.py b/python/mavis/server/get_file.py index adc142f26c..961d9472c7 100644 --- a/python/mavis/server/get_file.py +++ b/python/mavis/server/get_file.py @@ -1,10 +1,9 @@ import os import secrets -import subprocess import sys from . import aws -from .helpers import confirm_production +from .helpers import confirm_production, run_command def register(subparsers): @@ -59,24 +58,23 @@ def run(args): local_dest = _local_destination(args.remote_path, args.local_path) try: - exit_code = aws.run_command( + upload_result = aws.run_remote_command( env, task_id, f"aws s3 cp {args.remote_path} {s3_uri} --region {aws.REGION}", container=container, ) - if exit_code != 0: + if not upload_result: sys.exit("Error: Failed to copy file from container to S3") - download = subprocess.run( + download_result = run_command( ["aws", "s3", "cp", s3_uri, local_dest, "--region", aws.REGION] ) - if download.returncode != 0: - sys.exit("Error: Failed to download file from S3") + if not download_result: + sys.exit("Error: Download from S3 failed with code") finally: - subprocess.run( + run_command( ["aws", "s3", "rm", s3_uri, "--region", aws.REGION], - capture_output=True, ) print(f"File successfully downloaded to {local_dest}") diff --git a/python/mavis/server/helpers.py b/python/mavis/server/helpers.py index 1a90bea2a7..096cf7e828 100644 --- a/python/mavis/server/helpers.py +++ b/python/mavis/server/helpers.py @@ -1,3 +1,8 @@ +import os +import sys +import subprocess + + def confirm_production(env): """Prompt for confirmation before operating on production.""" if env != "production": @@ -6,3 +11,16 @@ def confirm_production(env): 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 index 3d24c1ac3d..73af567a2b 100644 --- a/python/mavis/server/put_file.py +++ b/python/mavis/server/put_file.py @@ -1,10 +1,9 @@ import os import secrets -import subprocess import sys from . import aws -from .helpers import confirm_production +from .helpers import confirm_production, run_command def register(subparsers): @@ -61,25 +60,24 @@ def run(args): key = f"temp-{secrets.token_hex(8)}" s3_uri = f"s3://{bucket}/{key}" - upload = subprocess.run( + upload_result = run_command( ["aws", "s3", "cp", args.local_file, s3_uri, "--region", aws.REGION] ) - if upload.returncode != 0: - sys.exit("Error: Failed to upload file to S3") + if not upload_result != 0: + sys.exit("Error: Upload to S3 failed with code") try: - exit_code = aws.run_command( + download_result = aws.run_remote_command( env, task_id, f"aws s3 cp {s3_uri} {remote_path} --region {aws.REGION}", container=container, ) finally: - subprocess.run( + run_command( ["aws", "s3", "rm", s3_uri, "--region", aws.REGION], - capture_output=True, ) - if exit_code != 0: + 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 index 21e619a37c..7aab0d417e 100644 --- a/python/mavis/server/shell.py +++ b/python/mavis/server/shell.py @@ -1,7 +1,7 @@ import sys from . import aws -from .helpers import confirm_production +from .helpers import confirm_production, run_command def register(subparsers): @@ -50,12 +50,10 @@ def run(args): f"Opening shell in task {task_id}" + (f" (service {args.service})" if args.service else "") ) - exit_code = aws.run_command( + aws.run_remote_command( env, task_id, "/rails/bin/docker-entrypoint /bin/bash", container=container, replace_process=True, ) - - sys.exit(exit_code) From c66b75728a3d2b0d0e0fa3a42f73a245e301c244 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Fri, 27 Mar 2026 14:35:17 +0000 Subject: [PATCH 42/87] Implement logic needs consent status request scheduled/not scheduled This adds the logic where if a child is eligible for the programme and hasn't been sent any consent requests and has parent contact details: - If part of a session where consent requests are scheduled to go out in the future, the programme status becomes `needs_consent_request_scheduled`. - If not assigned to a session with a request scheduled, the programme status becomes `needs_consent_request_not_scheduled`. Jira-Issue: MAV-5882 --- .../app_patient_search_form_component.rb | 6 +- ...patient_session_consent_component.html.erb | 4 + .../app_patient_session_consent_component.rb | 34 ++++-- .../app_patient_session_triage_component.rb | 26 ++++- app/forms/triage_form.rb | 4 +- app/lib/patient_status_updater.rb | 27 ++++- app/lib/status_generator/consent.rb | 45 +++++++- app/lib/status_generator/programme.rb | 52 +++++++-- app/lib/status_generator/triage.rb | 14 ++- app/models/draft_vaccination_record.rb | 8 +- app/models/patient/programme_status.rb | 26 ++++- app/models/reporting_api/total.rb | 28 +++-- config/locales/status.en.yml | 4 + db/seeds.rb | 49 +++++++-- ..._patient_session_consent_component_spec.rb | 2 +- .../api/reporting/totals_controller_spec.rb | 36 +++++-- spec/features/invalidate_consent_spec.rb | 2 +- ...sent_manual_consent_reminders_send_spec.rb | 18 ++++ .../parental_consent_send_request_spec.rb | 4 +- spec/features/vaccination_programmes_spec.rb | 10 +- .../patient_programme_status_resolver_spec.rb | 2 +- spec/lib/status_generator/consent_spec.rb | 39 ++++++- spec/lib/status_generator/programme_spec.rb | 100 +++++++++++++++++- spec/lib/status_generator/triage_spec.rb | 4 +- 24 files changed, 469 insertions(+), 75 deletions(-) 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_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_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/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/lib/patient_status_updater.rb b/app/lib/patient_status_updater.rb index fd854262df..687f954660 100644 --- a/app/lib/patient_status_updater.rb +++ b/app/lib/patient_status_updater.rb @@ -51,7 +51,13 @@ def update_programme_statuses! :patient_locations, :triages, :vaccination_records, - :parents + :parents, + :consent_notifications, + patient_locations: { + location: [ + { team_locations: { sessions: :session_programme_year_groups } } + ] + } ).to_a batch.each(&:assign) @@ -182,4 +188,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/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/patient/programme_status.rb b/app/models/patient/programme_status.rb index fff4b87862..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, @@ -209,7 +228,8 @@ def generator triages:, attendance_record:, vaccination_records:, - parents: + parents:, + consent_notifications: ) end end diff --git a/app/models/reporting_api/total.rb b/app/models/reporting_api/total.rb index b5f68a9411..fcbe259347 100644 --- a/app/models/reporting_api/total.rb +++ b/app/models/reporting_api/total.rb @@ -42,6 +42,20 @@ 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 scope :not_archived, -> { where(is_archived: false) } scope :vaccinated, @@ -70,15 +84,11 @@ 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 @@ -96,10 +106,10 @@ 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(",")})" select( "COUNT(DISTINCT patient_id) AS cohort", "COUNT(DISTINCT patient_id) FILTER (WHERE #{vaccinated_condition}) AS vaccinated", 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/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/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/controllers/api/reporting/totals_controller_spec.rb b/spec/controllers/api/reporting/totals_controller_spec.rb index 969d80a533..10ff4dd37b 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! @@ -1159,7 +1170,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/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/parental_consent_manual_consent_reminders_send_spec.rb b/spec/features/parental_consent_manual_consent_reminders_send_spec.rb index aed56010a1..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, 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/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/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 From 15be0ce7346813f791635cd1e2e3d3531f996940 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 16 Apr 2026 16:52:45 +0100 Subject: [PATCH 43/87] Use redis db 2 in end_to_end env This allows us to run the end_to_end stack while running development locally. --- bin/e2e | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bin/e2e b/bin/e2e index 410d0403c4..8fa2780e13 100755 --- a/bin/e2e +++ b/bin/e2e @@ -8,6 +8,8 @@ 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 REDIS_URL="redis://localhost:6379/2" + # Argument handling function print_help() { From 3c5cf558913e6110278761f02205daba3788cc46 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 17 Apr 2026 10:27:33 +0100 Subject: [PATCH 44/87] Stop yelling variable names in bin/e2e The convention is capitalised variable names are intended for environment variables that are exported and passed between processes. This change makes it clear that only RAILS_ENV and REDIS_URL are, in fact, env vars. --- bin/e2e | 126 ++++++++++++++++++++++++++++---------------------------- 1 file changed, 63 insertions(+), 63 deletions(-) diff --git a/bin/e2e b/bin/e2e index 8fa2780e13..c120b8d25c 100755 --- a/bin/e2e +++ b/bin/e2e @@ -2,12 +2,12 @@ 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" # Argument handling @@ -19,7 +19,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" @@ -38,35 +38,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 @@ -80,48 +80,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 @@ -130,7 +130,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 @@ -139,43 +139,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 } @@ -184,10 +184,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 @@ -203,13 +203,13 @@ 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 "[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=$? popd > /dev/null -exit $PYTEST_EXIT_CODE +exit $pytest_exit_code From 20b3a70a80fdd121b580f045bcfc3fd3ff33e6ec Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 17 Apr 2026 10:46:44 +0100 Subject: [PATCH 45/87] Add BASE_URL to exported env vars --- bin/e2e | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/bin/e2e b/bin/e2e index c120b8d25c..41ec35028a 100755 --- a/bin/e2e +++ b/bin/e2e @@ -9,6 +9,7 @@ pytest_args=("-m" "not accessibility and not reporting and not imms_api and not export RAILS_ENV="end_to_end" export REDIS_URL="redis://localhost:6379/2" +export BASE_URL="http://localhost:${rails_port}" # Argument handling @@ -206,8 +207,11 @@ done 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[@]}" +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 From 937425b024fa6fbbd7caf5d0fdbb900539f59fdd Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Thu, 16 Apr 2026 12:07:00 +0100 Subject: [PATCH 46/87] Sort `Vaccine`s alphabetically in upload help test This helps prevent the E2E tests from being flaky. This has already been implemented for the national reporting `Vaccine`s. --- app/components/app_import_format_details_component.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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) } [ { From 141fd148a14a9a0ab50c8b83896fa3501c971548 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 21:53:51 +0000 Subject: [PATCH 47/87] Bump prettier from 3.8.1 to 3.8.2 Bumps [prettier](https://github.com/prettier/prettier) from 3.8.1 to 3.8.2. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.1...3.8.2) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.8.2 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 5d0fabd8c9..cc4d107dae 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.2", "stylelint": "^16.26.1", "stylelint-config-gds": "^2.0.0", "stylelint-order": "^8.1.1" diff --git a/yarn.lock b/yarn.lock index 25e00fe3af..9d56d63095 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.2: + version "3.8.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.2.tgz#4f52e502193c9aa5b384c3d00852003e551bbd9f" + integrity sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q== pretty-format@30.0.5, pretty-format@^30.0.0: version "30.0.5" From 8e28a42c297e273190112193f89fab6d9add0ea6 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 22:33:52 +0000 Subject: [PATCH 48/87] Bump yard from 0.9.38 to 0.9.42 in the bundler group across 1 directory Bumps the bundler group with 1 update in the / directory: [yard](https://yardoc.org). Updates `yard` from 0.9.38 to 0.9.42 --- updated-dependencies: - dependency-name: yard dependency-version: 0.9.42 dependency-type: indirect dependency-group: bundler ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 4a86801c46..62fdce30ce 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -911,7 +911,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) From 44614ad00f87b3f341d210c6beec092ff46bce87 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 26 Mar 2026 14:23:27 +0000 Subject: [PATCH 49/87] Move load_data specs into shared spec Since they are shared behaviour ... Jira-Issue: MAV-6062 --- spec/fixtures/files/class_import/empty.csv | 1 + spec/fixtures/files/cohort_import/empty.csv | 1 + spec/models/class_import_spec.rb | 26 ---------------- spec/models/cohort_import_spec.rb | 26 ---------------- spec/models/immunisation_import_spec.rb | 29 ----------------- .../shared_examples/a_csv_importable_model.rb | 31 ++++++++++++++++++- 6 files changed, 32 insertions(+), 82 deletions(-) create mode 100644 spec/fixtures/files/class_import/empty.csv create mode 100644 spec/fixtures/files/cohort_import/empty.csv 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/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 9f3d4d7bbe..9a6cdda98e 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -50,32 +50,6 @@ it_behaves_like "a CSVImportable model" - describe "#load_data!" do - subject(:load_data!) { class_import.load_data! } - - before { load_data! } - - 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 - describe "#parse_rows!" do subject(:parse_rows!) { class_import.parse_rows! } diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index f1925052ab..fa892741dc 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -48,32 +48,6 @@ 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 - - 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 - describe "#parse_rows!" do subject(:parse_rows!) { cohort_import.parse_rows! } diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 12338708da..fe42ddafed 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -66,35 +66,6 @@ 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" } - - before { stub_const("CSVImportable::MAX_CSV_ROWS", 2) } - - it "is invalid" do - expect(immunisation_import).to be_invalid - expect(immunisation_import.errors[:csv]).to include(/less than 2 rows/) - end - end - context "with a duplicated row" do let(:file) { "duplicate_row.csv" } diff --git a/spec/support/shared_examples/a_csv_importable_model.rb b/spec/support/shared_examples/a_csv_importable_model.rb index 0d584514a1..7de30b6856 100644 --- a/spec/support/shared_examples/a_csv_importable_model.rb +++ b/spec/support/shared_examples/a_csv_importable_model.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true shared_examples_for "a CSVImportable model" do - describe "validations" do + describe "#load_data!" do + before { subject.load_data! } + it { should be_valid } it { should validate_presence_of(:csv_filename) } @@ -20,6 +22,33 @@ 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 From 534ea96c84d1282890ad34959135d83067d741fd Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Thu, 16 Apr 2026 10:28:24 +0100 Subject: [PATCH 50/87] Rotate GOV.UK Notify API key This rotates the API key we use in production as the old key was created 18 months ago and it's good practice to rotate regularly. Jira-Issue: MAV-6545 --- config/credentials/production.yml.enc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 @@ -My4OozZFPLCiEBMCtJR8ilAK1m78hZ+0JOuTV9LxF4AS1+Da41ivOo9J74cJHxbDcoScb0Mep6o6W96yetXq1dj3RGbQCVBngBCTOFwNE8ZJKZJ/eeiq5J4RKF14Gl3JhvRJm7aO5gzodAHi8AruodYQD2wah9Ithdp3k26RwRmrwZh58e1gLPIuK4ps05MmMfm7TKxxYMhg2Y/63thYxj+3frWWMYRivR2uNvaS4mo8eBHAP7uvqDnAh2Dt+lk3NQufkfmSRpoyAUM7G7EPGNnzGELXqjWAA7pEaYZKcXamZUoVXaN0qkzNU4lcpyDitdeOSNup5OmCQTIJxYCqmzUamGlXZ/ldKa6JT4EtPMYQuO3byyrOT6aN4LLCuDuQAt8JnYh7tilFlXjjvOEM0wp/JoNHOwzUyHlOVZGHtQe+sd47lolb3srwhGE3oLaruihd4XZ6YgcUNU3e756yIjeaUhgPgjQhYp20ipQANkXmYeyBwkdbka9jhxkRsXsJUOE9Yg5HmFfqqO0rXskcIVTIXaHtSjhffCGFC2EZ39xt843y/VJN+v2+jO1KvOUcAIfDMnE3R1+USzoOMdZhB6BwRkufXaB7dMz48mDUJsoRNcDziQt3n4fbzjWH+pFma0Evk274WVJ8kqYrWKROLWSBUUbxf+igCRLVdNmUJKJvHvl5ZhYJWJOKL87hur6Q7GY3H7oTFk0LJ8xbYAS8ezUZ0Bw5s6B9xNhFymbp/7ABS/07qZFlLX8pKSCHiZvr8/TfbxT6y8oX+WB/MIupF0GTy3KLb0JA22RSGtJotuxCHgym2CfzNppZE9naI5EVUNFRvnpNuSDlVNI+C078vlQIUpEQpfbqViv3F4cVMLKSbyApocvHHz+AKj5AADkOgczw2mkVYenWfHfVvAbKh2dHOm7cLEJ6xdyQjzyy3Eu360fqhs3WTm7eZCd4QWqxSK+1AMRWo1yAMS+fUgaxaD7Da0EthTzBctac/FjOZVCB+2fqOYnIQeZz7ntQsajkIxb1tjc3ZiIwQXn8MhK9zlkA6fjkaos/jXz/Y5O3EP3ZcHvpOI7+ahzjOzzg+xk7LyAjn4P8fP9lbLKt5DIkSsoqAkRrG04NibMgFuyV8h/iAXdWxk8ruyq7x+U0Ci5VvLilxXnvuyJCyS7sZarQeFARV0kEDxo3DUmXRFJngoKm1hzYuFggw3jD0Hmi8rhKDmJdiVn67KigOXaMY5M6TstO7B2c/k1xDTkE1U9HncN7HwEF8c6zjsY40aHSKjqv3qmF6Db7cc5sUzCsh/C4eNui0nnBjTiOwBoWr5MTsRdvRYOJn8Jb980UNUteNTzlJHuMhlfN+RoyLImj64LpJ3zGPhT1YMg08uO9sO5yEPRSEAwFZXT1CI92/pkc2XTrv2DODLrQFgtyISicr4e8sGtzIX/PX4pGFvZzQboDNenX877/j4UGUWQP7bjl3ECgOVepG8aGk+8hhqvldbrTNWiZB1lrTiUJTKc1cVfnu0n0KCO+Jwv8piBAeBQRfHqCpR/m5Z21lgopDcMN99aqHYKCuYosPwFndRkKG1q2YaJPUP4QM0wGxt6DMOYXpHPZYMfFGfhR4m6FL9kFXqOZe/TYUewPhI5e9594zEQgyP6NXWS131goJRySnWZb/dG4R56JBgeG6fvgBAJjO1G3ViP7LK35fbW+PvNn/SWw0CKQ22omGSw0eSqVZzZUuMuDEPkn1lky1AfSgpvG0m16xzABriE6aU8xXzMSoz2R0mFVOUTOhqR7HVncdiYTnqWoTyb0mYrfjgpv8tLyUfNLBXqDWIdinuD9W5MlWVUB7DVnKwbPRByfXvgFo/LLu5hUHXfZXxWNpn15qWOOanMJYb0XLZ82FsA6cIipkreLZYX8eGHj97t7U/BTTuj87ek2drctrgWRcQzn+Zr/2J3anj5JRm8h+c84TuoYWHSGZuaA4z1AjReCxzc9fU4dOGvjeDTaT+55ORuOer0XDguSdG8OTL7LBt/ijBHaRfswXGDSEaOq9CJXcuGeEpyUb/+BXbn+c7YDyLIMmZMrBgmnjWTBDgQJ32I/flSAsXLTBohmTv7cLCVDJ8rNPca51lx58M/tGZGmzrrfz7/4P+P1ggORO+n5Xdjmv3RXI9FBqBzsRcgnbh8x/RZpbVfnu4r1uJFIl5HYS/tJGYwM1tJx2qTr4v8uEHx0BHhw0n7fw+LUCvBVI/OrtIhYwiAx7WVhr9pctlmxItlFW4hil5ipLYMGK7xzaK90YeJoPhTMhZTRKMw7/4vxu5r6zIoO1cnjY6lpXSHEjXLuuwNUIgURNMWjDWcEHsEmxcEZ8z0Y1rK+yxmQRk5rSwta29wVHhQVZBvGJi40l0oF0eo90V5pY3z1hce9JdZvuMaqKjsUH1z33cedJF1X/UzdQto2aeiI/1JZ51jk/GMwDnIZ5pzwl/lSs3cefY7RkJTK8paIsX2iVs6uKYouF/7S98mLQ8cOEwrn/+2us5rAktK9kQKkHU/hQmcrfVmCqzOH1HVNw4gd25mjmrULp30THEYdnVg6tLZvEmGQy5QWzzS1kZSFWIrB9jodMGpfmJRrdVq98FFgtBJbgKMWasUALPPu/z602GPlN0xMfkly9j0ikZJQAa71bosb3PHlIGZ1vlNtMygKtXMQ5mkW2hYCf0yXARd9aya1gjmXrTMDhYuXJABdUpzsl9NfNrdfiAjTEfrnIpUDERWFhGAyQzAax+o+LyOFolOfsq84BNRkrkTMopbfzEsixFFlbfOksJkEJ0wv8t6ybnNmzmprmxVWm9SmSAykv4f0LtUMnE4ucSqyqivZFa4gkR/sgFo5agMpZOEE6QABOYqKGdEJ6zbL3YiMjrfl6a3LL/KoDwRieehELA5EXvYa3iyEhgOtc0wXD150UyxRjHX5j0Ax0ErZVfJfjYSoHD8GIq6TiDrk4bO5y3t05k5wQvs9TebkGZd2/lJvT2VTUNdq0giSbKhfINS2ipYPnDw4By5KXy7eTByXyNPpw4OTtP8hHdRGSy5RvK5oKDGrXYeNslKsg8KugVbb4duO2V/0P2O0PqX9GsI0GPEu0oElaey/ashGsudKB8pzE59/Ji3OTnloETzCw5QB2hidLJKhHs6jJG91m62OQpdI22pfathXYJgUIGiKnql/xS6SCOhctjq3yhp1eY/noYYXNtbbVObRJPuo6RXjrJNShfTg+PZ2l4UATMwndud/03n2yAWJ5crehA9ydu31mFnDEywHUO5oVdJ2Skiy7vv5isVKudE6qZ31VQGcpVPJxgozuTqgmh6szlO26HZlgwVQz1QLKV2fdxQf8vYnqa/v7c7gmw+a9U2xCslWx/qUQLZ4pMpyz7RY7qmQ9caWcAxxpLP1n+Ue96b1ZiyKUvCGWicLwak+fJov73gErWSuzsfffOxglM641pCbw1wtNPONnRhv+D/bomgF2pb/PYojJCqk9U5r16aLzuZKRKNdVtS6NiYpfVNv8pOEyxhH7PmIhxkSaaYsoldTRe7rDUO1+Cb04ygup0wbiI4eZuMzTgDVgs7cyqDwP+B4wotiYNYoZ+EyxMMgufDZURKHuzgqmOR6rLbvyFUnra54z5wM3RNirY2djyCsYgo/8PpclDHeDnO0oMSixwXQmQ5ofuil7r8UwaFoUlva6cix19KZRQxC3K44j7nKwRiBT7DsGf8s3o7wyYYdVwwLBoKsgwsFps7m5Oh3tHSyE0J6SGNybOv6Ezlyt9RpVYR9lnQdMxdmavk5b5ulFKO/OOcvEElrUaOmPUPifNQp4z/u0VmAWX0Ga5MTdnHxaTaDVnQxbUouwfTWEXQy1f67ELkSdMovgXL+MM+7wEx5Cs7OiigacnLEvohdaIIPF0ZKwRvkBNq3xtZhVxPRM2Z7PMsoz/cvpv9JKiF8CQOpsyySM0sTxbQwB6iMgOm/knyaeJ5na5tPcClfnPhMmRS885Gtmo2hUyxJ79vvcYzhVV0VwJqlEzUGrsqLh+zvZ+O3DacYcmlTVKf6LtXhlu4faS8W68BaeM9JqobMDeWmwFrReyVNatvOoKr2L19rT4O+j/pXrIlmlzIuXPzV6uPJhyyy6uuYxdLFIpAq2vaQoI6k3XSk0H+EPDhqdOKICOoof7Pxyu3I5viph3stxnugOuYw1zIACqow1YL5fKzFkMDJlCkketyhNYsPKaukgq671Vs7EuGT0WPOJ76d4mbkME3hyhqnrmHeNGuHjJDwP/xtJGLLmdhRthvi5MBgEoBmFNappwrITRw+lo3lx936aUG31Hd34+BnZfcIvX3L+bh+0n8AMtOn+KjX5QtI/QHCZwrQsEc4j48A7jR6GNxJ8k1FMILV55qyAX6b9LrlroUPNLwAzUuttiVbBkkp6x6h4lGmKCBPTUB+BFV3rmeuvk7C0Xg8/Vv5lTblngUmbKLoviRGOU7QkE64RCPProV53MHDRiAU3ClhYRf7f4jb3qkzug6yhgNAaz8ptMlzO1qj8lG6zYQ51PTAUzKRCt06k6dVtItaA/GFQ3asGDzzL4U7zz6PkUgJf5zLFYw+lLWZtZqpzr3bqq2KwN1/wOPscadYcil03bdlbuur5sPfP6vRz0b7+eUA/enduhOc3OZ/sSjARNnFBpk6jXq8NEtubCzzSdkKPHw+LnifGCMXyS2jmxGUcXjwm/CzmS7zvQHfpTBtecee34kiGBg+d1dObINsxxOxJlcoEpyo4cElMghZlmmGyCIOFpulGFN+1LWJ3kRLHsHsMtnuCB2bdN/Kxr6SuGWNzvobJlZC48no/dvnTEilYjP00a4Zd9g+NLmKzsBZQ0MNiTEMw1xBw8vzWiRd1yDR6DBaUtaps6RUFJaOlwBeue2+PIFZq4UEnLOLY1LGfEPyrr8+dc40NhutfVqTdErmZahGqdqnCZt+UcDvo34RTcTqhHS4OLQvoc37KdUnnv/mWCS0A6mpoPjLnt8k9sWajBnyB0ihRjQJzWm3pqUcb/q5tTmm0lE+BYrC5Tmkqw+MMV9BALbxa6jyGU7YO47yxMbHr0ZYmTBGdbffjteuu+PinH2/3MMwa/RaKxlbtamhgDZFO1yffXdp16bH7pwiriNRrCOsyFtmjBb9hBVAPg4XCxEIBOj2+YcG/9ZC6E8s9aGT0ODeTIbZ4cZ4wP0TngDv/lQpZNw7+hbobuvrkfcy7h34XYIyAis+S7NgMtPWXVw/DQbpoEQfrC+SR++VMG7GoCnqyN7lZm8C1uGwY60y0Wxt5e8RDbt+cHyb+Y4sFPuP56kOT8mL99xJAGNDfjVxihdITdEbaz513jR8fi5GxsUNRo0shJGyzgfaXy73HsqQEuS1e4AqJIMLTJbWeCejhGYx+56LaJ0upr5r2a8QIVTesu3eidXjTfusFKobl8GherqAgVKWI6Mp2QAyIrb+LY8UthPcEXbH3ZvYVzs0CgWAiVB4SGKuTXNLdh3suJR8rwusmhPdmK73kvRguCQRIGI2+bUCsX15QGHRk3OLyjxiqS4vF/Srq5kQW9YHGQiPs6ob4fC8S4BgWIdftDo13XCGbWdK4o6a5gJg6m9n4PbNjtdnyiIvLyY7fS+cSKEN3ct4x02YOHyN0lIU3TVpLb3OTpWatAQKdGS6C9paFzbZLoTX3lUOHgamm21aUTJ8XYlDW3F15OZ/VwP0gYrUsIjqsokR6g8PSY9ife4Aquit4wSwQg1F+JkartQCNOgxncgaGk4QCgsoaB6ZO9R/RQgAn7U/mnuGA+nF7TtoOnFrdTrKJC/9z5NZxZuRcHDHjF3TjyuAAtGWoBggWtiEADTpBLx4oo+KpBgasnianEXUS7qHOpy2MIDKlqq5yr0T1inbqvp9mOp8DVzhdtiYU6yiZ28aFFUjmkSW1jO3Fp5zzy8eLyc/VIcU9syi0BTAHqdMXs7z66ZWXenzAXCOkgKLLs2X6muVGVDg/y+3PF2AHJPBJZfWWSGCpxM+m5S5WP55BRYzRF3+GNr+wbFcAkCeEqB//qnZ5qYmWgor6Mclm0kL0xu8qEWspnQ2twyuBAXo1nA3//ZVVmV1QVTgIgZmMykWWQRdoh9OBqfELWrAHrAX0bG4NJx5ZE5ZoHl54ehz9bzEH3q48/8wEM64NWchbvrRAB6gJzCM6MJOTj/7PPraUjGNOfC7bStT8HgQepkCLnYCN5n2h7ZdurFSv6xboNZmHZa8a6f9pUenM6LRC2L9TR3i+wAUjwA+BYzWzM7XwZqtokZiyNR8eQgWNgBP3zIzNlqJnTqlNhEJLMzkICnEgkf3sXmtvkcARam3bExWw1epLI5dAMAbGZqGpCyT6fytx8MCYYXcMTKIUmTsuP5kQrO3vgNrxcNWH10r2lwzNNe7phZ4xfjEp0wRJ/y8CClvBzFlttbGQGsgr2+8M42swVsW2feH7vJdDIQF9EbD/yxcypt7FYUk1v9VftmJZ2+46t03jmsyYoO8DzLQXv9pcKzEKyObhpStJT54y78gvirDPwf6M5a9SFV7F8QCvPWCUAMDhe6P4n2QVgS39UqsBRV1YV9cXnlxB7zmsU8jHWMoCj3gEVyrqO9ioK8QAUnZV5/Th1zcsS2+Py0zOWiG/g3edxWe3bEOv5TybajBlD+9LsZzYed4mqqWHZxjLkv6tYGppddkO2mkISWj0fUetja9TczXuOMLBddRSAXefoEBCgXBe1zdsLae2ZSOw4B3movQdErH0ORPuaK/Wl/OARc826abI2JNz9ck6eJn/wrymAbcu0IBom+sR725lXar0LGc6pZB0AYE9KtfP3bRnNdNxunjDKnDfloNhboEQvC4O6K3Us3eC0XCygKepQanmo4XTM8tC/zfWoXyH26BeRiuE9497lqzN3OeKHyr35dfc+YLnTwSfkeGNTZ9rwZvN8Tf1fs5LU2iRzrCtZtAyI4dqxHIGBGRqbdLGEuV0yhkPUDyYaND66+Ifd5wVnJq1AplsxokSx8877uf+nKSaXj7R+jK9PyOLUNUwoDBcnQbwEebUOUupEOUQ6UoMY8khTPwZ/0dPHVzhUyFmBzuaZJRI1b6RajOn0SZ3fseRcTKXbBHVBksq1J3waXpkzceTvkNh94hdl7vXxb/hF17UhSukd/41zN9Vksfol+iHOagRAOij+EK0+vzyONOdBa9kmv26YXdE2q4auRUMpcRBYF8Sr5/TddXc4LTWnQO09TB5b2/rLBVDZ+ypvy/B+72nK/lS7UGJcm1AawxPcZ1qOuBs/wzwKX+N6T+BmCFUPm5mc9+VkeHfSw/WGfsNg23MeVt6vIp9ak/qyhvhs+t9pL4Ch+9Y+kWXuudiR4HIZ/T+GuHeF3kusfTNUeW+dcq31fS4mhQ//Qk9xUlxqRpU5L82wTeNo8Ntw6IuljBcT/lOuhwQiB91BlMM8RlykI0xhZO1fjxD0Q2YF411DY1fbGiqKupawuP7Als28uoK+k0DCu66H6J492uBqUBDTsHhFIAdkHB5tsCluF/UEXl8lpGGabLUVwOO2je0sLXEs3bjvPmnhqlWotd8WkmJE2gwk4kAMMqL/CF6XDsNvfWDmrxmVOn4IPESev+J7qjs3+175OHRksuJzPhULYT3rquvQsAXxQgIz0hD4ig6RkUKSfYNErD7ucQLm6hFO4nemWiRn3w1r+CUBC6/YtmvtfUEuLmuw2Gliom/TOybCTEgBIG/tsire0IG1Xh1oT01qzEbkPCeJpnrN/wg6S/tqiUiVXWKLxhtaC5C3EFmjS2kIlosbYN8aR4SC3uSYuTxjO4u4BNBKFF0EsS9NChxeL5AdUrXSwrTclASsa3SCbBUdiraYkFHF6Gb8YCqe6skjH7W0aJ8v2qxN7aTnK8ImVPXwZU2SSwb166nURZn5cTAMzAD6DvYciqfllM1c/FZn9O3Ds/ncUP8l86Qsv/Q1cWBjBo6qSdZfDaWxgbMzfFeAzuJwYwJYG2wIEC2ACIALcF50jZA/1BGwDseki1mG7mjOTxvm0buXRcuFoRNtb8X2SSpjl9tP59+ii3PPhthEB00eqiQKSjBmPMMZHo+nrxY7/53VnqsEDVOnj4wuPtd+VyEhvdWAVHdS8Jt52kzReVFnaZWMugxhbJL8aBbWu3q5XVt6xMSGNZbh4ymU+fNNNfCcU77aXM9uwJ1mgzTUt+RLdf+U9Mb+xzGj/aGopqIOd0aMsBVT59H8vya0h6l85AogGA5nnmmSBAg6voW1F6u2KZLu6XlTB6kzDbI7aBqBBJe6nqP6xCQ2mUF1v0kFb3XJc4P09KyA31tyxvgUnReRcREMJ++MSl3tGevWUoeaFmtOYGB/y4ZMl0/T/U+Um/Z/5K0OoDoROiwvWn/xb9kOgLRNtm9w4yVotd14SB7asCctrXWvvCRnv4NvaHfZqX3cAZhOd98bBDk3TpLhoVJi1KrywADfhq3aP0s36ZkI7w9orO5k9OeIhYuMkVHNihEs1Thh1V2H89kvq8V5+oC9qa8BY7p24u+EM1pS9BkzaEy8ZTtjijWKU9tFgNowmLO+IjScSMeP450P7YgNroch7igOTQFo0drdEYI+tBzxPeI7+vYkZXlE591JBEM8wmXk/1rUcjyaYDN+IMWJlNZOinxqtU5bIlBpWeNDGTR5X/dLpfTZnfA9mhvmC7iUWFvR+cRh8ZJyZcNREE9CUberII+OnC9pNIuGUB3uaGM+3PqtF/Pcajk4jzwu5P1PE0IREk1hjUH2OV8lLCmgcbU8JSJpDNMvXXFpVlV6bZdODlWfASI6sGrMOa6Al0eFUh2VkvPdxNJEyDMwQB3B/Y7dpAs63VjoZx27mVKFoJ9XKL3q9bR9q/NERBU44SVHo6veY5lan60ngMyZjnHj99o+7hPU7CX8yOWIuExzKBlJLfkYBpn4Ohmj19sOyNp9bX5wos8+TfKWPFTc9RlAluH18QfUX1h6DVvOJkKswhVP4a8FpdiiygBww8SS9U8Lry0eykaKCfAmxQ/9q6eN1lT0osBmoVZ6RW5e9EPSvRiNjgYDS+8a7JFwHPQnqaYR84C+AG0Y3hQEKeCHrC17R3YXoK/Ps0UTmnlys1hlUcygyWXZeyOF7qF2xtmB8Pvppe4Z/uuysw4wVEhLqylWFAa87dx0HBom1Hus62Nfjf5UbD7EtsSvscNj0ELJqDdInkXopN3+6AcgMLgHWz+v1k2EQOx/T5+VTW6BzBnfe4YhRjtm4ply/ibU757+ugOH4H3drx8giTnIGZT361eni3QM4rLf5yKVN39tx+qfRSk2pag33AO2DVlZPXn2u0Vp3BZIhKGseSS+4Rz4dKw74a1B2/iGkHNeICCMTcBuRl1cnPequbeXnb/7hr2zonQ1H2EIkaZUmFGPB9w2/N91OJiuMmsR6y7vNtg1IYNx8lvkSyNF8C4p8RajnP6VMbkhXHbbfjpfUb7lCyR9Hsmh6qp0bkqZwCF/flTYePBswOiFZuufCJwlAAl+i00GRG5hAfSaFWJC57KBZ4V97TcStm2YbxuEnzU2GxrWqp153hlvUWvalgiXbAWnWeB0SfFUtuIw3N+xMDBfx7L/qKNmpB45zQyQPih+fjC95WkXo3+mQuIswtyPjTlo/GavajnShBuxOarGw8KcdXGnxgKf4+ZZGORtKZ2KXstF8Tg32p2koq5N6kjoNd2JRaL1QCNyVJWm0icfwnQtKMvvkA47pRf15VAPV9ryiSWxZ0gJchBT4wBtlY0+7j/KUUJcPJgI+PBMhziRoPMLDfAHlGjU4+7vGSaDlvJcu5URNYAPJcNqWlgpfZtJKqaQQcbDvCTd8vhIHkICo7vg0OgB6XY5/YP7YbU0V++S3qQCqNYhK6Gp5Mr8/SEckhqzR0Qq1yLHZ1azAmWNwGAKGFzZdBG4wUBMST1Ci8ZtwVbtH8sTUPBAUzgAo5z2NUw81wUxp3oKVef4tEZ4KRYHEkbhMjhqCll7euFgQPp7AkN5QwgOUuXiEUMulKisqotK6wB67EAFLTqgLquT1WfCScZ+szbd1Z1UCY3eeKpyQNs/ZmdceAp30SuoyANPoorSIOpUz0FTauDagEjzI7Pq/cb7+dKkJ1gqpfJnciz1zL1i7wS9kD87Don6DJI8MrKgjp70m25zyIUyigmpztJqZyjpW3SOrOfQSQ30aKl3fZd84JiGCXI7xjYN7aKmjfQnjGSWV7WW414Cq8/i1212q9/vf+MnWdPV4BIlQ4C34R5UCd6BFLoHlKgyi8ApV+hzzZVE1oS9qkOQu0Bwff1USIRYEOms8AopSQLeVw2HxOHeelSOnl5RnL4TbhwbR306vk+hX8M+OGFr0u8Zk095t55rMfQ8B/9ffV5HJHp0W34BWnLGebNdSKHS9ls2O7w3eJC2fNo/xNc1zuN5os5iGkMZ/QtTAS5MIRlP8yKmz515L6mu0Oc13JBRVcYZCQibnZ8PUbxs613cQPIUuyOiU421Ln/IRBDuQ3/T6B5uW2+jV/OBnUNZt++1YNFKEEAFdpDmppEU+iioXJa10IAowLFgm746YC8DYodlFfuSIzBNADA5m6zTZ6xNn28fNyMzZ9GZUUxBaKamCR9kKaxtEh7bwq3IlwNVNCzJ8V5JQmIbhtVLx3F8A4kTr4TFIfPwynfssaCdQZEwlC2ZJqq+dX3zHeYcVPCASGlmI9oUh8DY2jgdIhMih1Ac908wy83zCos9RQA7Il+yjPm0bMWhQa16rdQA789of2YAFVOuK/n5jETKwQBqK4othqCmR0cTKSOMu77UNUdDoDmDpHneB49BWoU/Y6Nd2Fazpk9+9kn8OD6c/VIs4VXm4UokF0xlT3187NLfID3tAKHph8BL1itjGxTMzY/tTfEOJAVJ4KTKiTdgOagiwM9U4Xyt4e8zE1osdnq0yU3DON7eWNNae79pssVlyJJCcG0wXMjkjVbtihY3c+f9lQwPrO3YCN6NPgZQVMRXXhPm8=--rZwc3FVWEZM7jZo/--xx7VN1jWj/3wu+YHcsZZSw== \ No newline at end of file +/3dkUCvPiHufNEgFSw9me4Q59Pz+aIby/g+Mp9bHwcM8em0uD7Aw93FR4jlJf2lufWMO5mXDuWxBMPVQXGxPlQCLRt4EXrEN1MjY0Ndypme0fb8uSqfN6LRr/rUSOXtRp6Pn+S9aCjaMf2LbCiIEc4zQmaq7mfzeM6HunZ8aL9Ir3BVzy8Jm2bsCJVwk4Gk4dbM3jq52wF6qpcpzOhnmm9pEUm5vDg9SDZHMeOMst5LNkR/LWV2AMj7gyxN6DsNw8QBAbB1NhUvm85fMQabP6UIMVqEcRpeBzzEV5Q1PFoS3hEVX46LFQWtfKLAch8+IqH3WY1GCnvcMqbNGDl4m7DAxYfNj+tqyb3mgIQ5U/GAZYO5742ChE9Jx/zRosSNZpzIWb6sUFgvPbJ9Tfv7iEE/lpxOzYUFYhD/l5xB/N8DNvR2gcklurV3CoPSGca6uFNFn2RSA7PzJA/kE0y00aUgdbugUy3/iRQw129WNNVpV+ISHMlrumyWW5QOV1CiUbjk1yvMzHKPXyrHUm/aoIZjrLqRV/QAdfxIYWfOyHNGGQ40AtyT3ln1VUhu4dIvC1lO+68fUYxrm0UNWJaJmA+S1MyRB3PGQ3Y8j6fnzMa55W9QNyrqOO8iqs3Vrlb78MoFcZUDCQj4C/FrgTPDyapYyRR6JtMEVQsOd/UmNnsc3LD7d3SZrBcIoBjMhLwKKCCXQWhFoOZrhaxzYdIE6JHVVK93+YzOqUWmkoultggKf2PuSEsyUrG7FMZguXTvUclSqBNHx60jV0y72htTWDPKr1IuJ8/bypab25dho/w6Lev+b4nh8bN/Qsu20iig4l3N7xilVCKyAI7dLJl24JHofp9AwftWsXKkfITLCvMoOLSCz4qgFdf9zm/2Ysk+hEfsWpyeYHfANtWV6jHzE3X6VlwF9L5pTCAuRpQcMSR2e2QdIxtZ8YHXBCrzsEqrfIcGSY4LSrkQgCjZQc0XkWMtunbljhL2QKzvgqQXaEfM4BeEHYKq2EQeTX8qmrW0HZEFH1fczC/BiKZOMm2zzuAJyS8zCeDdzRZE87Gdqqx3TUt//W7e+oWNgYdYkNhVi4N3x+nm4tO4xZPoTgrBJEokv90tmzVSASLBn8WnRJVaoJXb29gi+OTzo/VA9cNW9vjBLV1YgUwrAa59eQagVtUIO7qQF1xBhEvfeU59VuwKHwhq/d8tfXskJ6PxKbZ+lF6/aVg0bMPqyqkNpvM+npKqkmUI1cRJXK4g5wCt0pPLsfg/WvRh+k0J3q4YyqCWdeTo6RpzyhZLC/dtnmetpJgC9VYjdt+HZmNiiCF77LiEgynlKyMR2G7QZ6rM5ibm/ZX+6dr4wgBRmejvT7UxdXrRFBShqqWP0c5ZA6mltkgkjBpBPH53Be36GwT1+jvAP7HQXT61Xzt3scbvEIdtVJrvbOpJUJJxplnryyUjxsBghQGEkuIfTs6BrspBMwBC5Sfs8/VokB8ib2GMb/bDIqNDIWEP4n32Al26VBp31hnACKnhX51+zc4uZRMQnfD/mKTKlM9RB34K5kH5U6p5nPfuQ3WV25r/ZTo7RC0VLFL1xECuZ37+/zVw9nttpgu7wHAlv5Dd1lfQkkjf4grITuI+blC4x78I6s+ZNxHAGbQS7AxqaprLpQptse2t1aUcaFAOAAZsRNzH2sPeu7Pyl08p6xPaG0tcUqqZSVj9ORpYnu2srBje3XKIMmgv9Ix2N7aLTZwQWO4s6cy+mCs9amcC3pK/7i8CFdMqV5QNp2KqcP8YUDdAFh1WcK4hJZ8skCUV7zFm+jufc4SkoF75Iy3x6kmHWFK5tMzXoYHq0NP6KwgJaw40GY1W99OdXkeFjCTrIow4bucR1QgmNvF6UHYfo1H96YYLgqKKNQAS1OcUEQDqDKfYRzNvI8TyKgKBFPToNtFE7pYcxVuymxwYG6oeku9GiuXo/HRDZ92rEYEfllZEnKiE2HsopNaIaeXGyrVyHYKhuPAKyZOohR/aaUbHqk+bly4SeD5myQdhBb6z03OChkJQNCexELcgOwe40cK41QLWk1i+PMZkDTO/B9ooXoXr6tioJgQhI1F8KeB/rH12epWeZ8+pc3a6dBLRW8jBuH+ZxtxTQNBptvHmXw8M4En84EI3dX7CntGlD8rU6JOpeDcBTThz++6VZ/LSYkbQt7MkZlZ4U3UqmvFqmP547PcBOz4iyDSHpZCfDQLQ9CJvsxtuwc92p55PwDpLWrw3ZjStBs6DsJOmyF0l5jVLaCJj0tmpinbjbcToPDVGGS3+LXArPRp+/9/telAYL81c90QE5hdJVmRiFRil/axi7hTs0aXuVKBCEyG75HonYl3eYYYFkMfaHifz7MSuv375ipy8aXP9Nqj9isSwSRLNLqgU5PJ/9b6zpso5eOpnczH7Rfj1Ad/JD4QOeJqP9BMnFKu2efmo0vADXmmqStWlkIoDmrRozEDTw+XtEpAvaf7OPmGKiTTO1nPBRuh8vgn15wyDUS71cBiIejQol6/6jq+jfh/0eSXEtJAyD24PWkrfqITqttyrgiTFLlwhtu71buJ4TKP5vGquLYNochQRIlbwKE8zNcG3HFDeVoOm5UYwBWhapM/p9wgX0N9bskf1DWjfvA2cJXt8wTXpBhfzY+LgyJsD0RvUGbdlknnr4Z8mJKMq4dfpS5PKGecK74RB15poDqINVkgLTIIlKK6b1yHZaIncb1LFJmth0S+KPdDLwLcSYpwdkz3vAXoZx/IIOhLjamQ1B2uzTyH3QQHTlxLuF9urxQ5BX8Bzl8Hp7qm6Pgl5MfrGItQCBf0W2AXq/AE0alyiuLcF6oBZnGoyzsCPAX0rNZa0UBF7y4oP1lpxwyogm8z4mSsW++Ex4/74O98kvsFYwVUUSlWhQuNVID5kEPMatM/jX9ngb8vKtwYVS8A+peSU6Kdn4FonXCWTKJrRwYA/fQYGLCgogw5UcJWa+flq2z4IHh8CUmVcF2YPcnOwKnaZpeltqJcxRamChZViEOY95yJZvqg1f3wxeWaOo+PzdK7ZK74JfBviKFFm1jHyVBc4OaNU8zDi4X7qmZtYefKE2CqojyP2liNBFu/KVY4YXoVi9ThLtcWigxKxanlgUmo2FGyRqZGXgYJBBbNe1rVBwixNAzXfZqX/VxTJ1iPo+JLnHQGyp1H/dFg8wHFhVyt7gVqmKF95/lUN4P2Ty69py/q0TKPHpKdctseuiown0ovItb6UVR+EpZTGEpK8WyjbUYdGJkkk8nombafVd3IQUnCNhly4qxFeMjK0Gc4KdSZhQrElzlRpF843tZ//zdB/MM71kVVQmOkmJHKVVauIoL+RhzxMCP//UEqBfIbLQOWPtPkpxJKheFXJbaYhMZRkyNIrJBznoeSBHusJ1qv/Yw097UdUs+5ipQEiWiNrGy2V6LGsdrCRR1nxTJCrHaY6eJH82n4yJCF/HyaVb+FfVKpf41ZenXwAC+K4Gq6/hhUFu4b/Uznk7+0ULpxkwAV9MbU7sTgqz/Ye6He7Q54eWRh0RnvJTKSvxf6F6/I6odAQKKSw4JbpRhHpSpFd4ai4fQfm3w2xiu6T6RGcy3VkWCnodC1Pir5xB4dE4Iu3uk8o/k94CKGqDGECLrZygc76IvlAWpBSscIy3u/DAvp75USpeQPTyi4b1Hp7p5dzVxplQgly1fC4XCaU7Bff5CtmpuJgBmGyaFMY9My659jj3shZmV7JnwvjC1MlmdlsChLJmskUgCiW/UG3xQc5rh3zT90/9PWO42Ut9u5gEikPguKW15R+b0Ax/7W9p1NLi9A8RRYniEoJls2xQEWuLSom1qi1+1fkTR5FDdMYyc3bmAtXWo6QnpepLZKXdP0kHoFHFUcJhNby5GPB8ApKmHOd0r02z8kfD3dDTCiKbasG88FeVADpwA1TpA8qT/nP8kVBMooZMBVUzCFY0k48zLWbHH5UqFPU/WWZnuzJfJhvPeVebMw59/PZl4fAYC1nuaNVDtGmOd8iVAR9Adc/QmCnrjB7bey1D0MGdMCcFiqND7wUzZunIFQspPzsgiP6P3K8/18Ir8CWdOu/y1QnFUJIcrRXzhfvfylMZ2heGMOZzeU0dot7VZckACXwGxwNWHRRikuSaHR5AMvku6RdSM/AwIo7iHktNyCMWkw63UUR+qDa1PzFYb6koucAvB+1qW+oy+Yi00r814bTcDW58XUUbT9UJ1qctONSWfLW3QPvQtJ2k4f1Ua9dKMjFYeRjtOnLAR/Onj8nR5iognwu+g9/QRGTtM5icasAvFnbRbOQqTgHaH+8VYcZcZ39Ag5xdRy1nYvLYL9rDR9a2pwKgz2WbrUBy9J+ESdoRBQIGgUnXUZcaC/5Ude/27BOrc5QFvIB4mY0NpQM1o1imTmRm7DkQWZhzRCZTBGRaGjFYJFXDkbZAvxpv2RGvKUg9kgcebm0gHCGwFCrGqYMfQeVMJ0XDamm1L0MD3oF/sIJuzveTJyB9/7XFKLP/xAyDc7d26pRv5XfXNry41zWrFrGJBQn5Z11BDKRMK23X5vxKW9xYn/Mj2eAQ9I1Y9msDElhN/ouFWT6nmDPvdLyMT3VfKUFs/MIYNZj2xcv9B6dzrdETDzrnxR91LNE3TlvidRz4OFYZKALBVZzph/l/yjmvKW1Gl9QvgLQ/TvGrvpjtPuDttOctyFA4YuwUsAiRHNUE3O5YgmuGSJ9Z/obosWAQw02ojcUZH4BcuzRLPeNdQCUarSmYrI2qcq8bIdlSByvvodUJ5/60LcRpYYIAAt2+n2YmguzAtldKgN1zx9sahqcHbK0Ou1wjjVcUtKw4aweGqT7rVO+yz+9ueEZDUnRTWeD6A/Bo2Jmxypqs+/iDKlwn3MMdQWgSqsLdRJbXsyyJa90mimhkeo5hDnmv50dDXb4IBAUz1QYB8WX/52TNezo/7+nfI5qHqchM3sfOaLsRysPcLcYPx4tdlfqf/CV50TRUTj2N0RbdtVoXsRfHf2KMoNF3p/tzXfhZdxHx2krAHBR/8hV9F1W65wwHjB/0k4iaCIONFKUeHJbLKjAxwGDBwlmCYJp6cwrSKxHyosFoGh/eS0HR246g5ohdpTBkFYp0d3WFBwUuU8G8eeRGCsH81pPcAu6BTdaRXZpsHb74aitMeG2xs3CETNPZc8hMCaRmGPDcAL1GKxuqlkV334PH3qu35Ht1a+0X6LjB/ao1j70GsccbHGtCnSnr7yz+lE4G1Lectt/re5rJ5hvNs9Hk6rP+BI9u45FWbndzVPH61oikkTTARKKfsT2vBQSMXXXV6BFc2pbxx7Y5B7EukqBpI+MO1KNhwKhjGvlHmZs2bgeEBFxK13M2kpfsmqs14NKgpQVspCKURjOQj7JBOykUjmeTuyhG18i1tflM5yPxPnN+hZCzd6v1hMftBikpp3w93UthraRQ1jUr/tT3dgtCDitYcPD9yvJkg42YAiCXKPjLk4Der63LvoTYHOt3sED3gq8/S4mMCFz3HhUeGd0EiBT6hIwOlmT2uviFlGlWfVX0GCgGRgejEs9OMLEAsgSeLSeWqKpx+O9VSySLdfXUGfKhaG1OwvMywdurCybMYDZHuLUXRtUmt+OLDrDwfrX0mea7+kDjXCj92kBtQzjkpCbaUhAe9uL3nZ2IB1A6sDhoZWG0JtkBNL5AglXs8jG+2GSjrviwllCR2w7ZByI6sEieJ6gAyh6lB1zi9WqPM2eg0TVZ5OBHVY8mX9LS0z3ad/7hh8ORVPKNqSQZB9Fp2vT+wx8xEsGYZ/660TxYHP/UacNd3qaf1DyZSxCfCQopJzY2YP411Ib9RRcr8tAwJ0ExBYHbnvdKJvBTDZUbJ1WzjioDUgzhLrgCzQe8JA8kGrPbjcBbs41VRciMEkMWSabYABwemJk/hpQQ3fkZ59VNkTDigde+g9FvIQ3HCkO0hoGIyIUVopUZgNCITgaPLLOKp7RM4vGy9X1rKoH2nJMDHhEOQrzP8ympxFYbxj9Sz/eWfkmZUoeVpxOvOvoRAlK1ZAcFB22LA8sv+6X2XhZBjVA0muNHwy5KdE11KVPNcxU6yWPYuLxFHZWZnH+jkaOMRP5wbV+CJ+T5N/LemfrpspPABz4uRXz1o/1ZWxGxWxN2xLE8UDWteR8nUpYsCd9PMcAvjTiJPfllsmd/UUI0vcxdcjpLO5z7YaELVLK08TU8m9xIpg6OM92GbwUAbKxsj/SEI13f9qjAnqBPwtLcZjqYrQuuq9qZJ23rUSQbnwlJ3AqYaoL5HZJX00Y3BZRgixoK65O0N9Yj/QyWJUi0dcSPi3doJT0heb2xH78sRa2fmynW+Izps9UNx8ph+8rVHM61piuALLMcfurbnZ0I7DGcUffJWH7GVS2rztmERkeY3ulpz8QWE+jBSJVOv03wYWtzZtARA3lFP6wSn3l/96A3+BM320zS6gBPIdJHkl7gSzaPTuecp/Y2cFaNRFmJDOtp2kwBZ3vW3R9KLeQhxx0g21b+TSGXv7DeHn/NCRu7kNXJm8d5F+tYjw8kElujAjEhvQES6L2f+xrnjLfY/k/Z5hG7Vs5NaudFZ+e9zX2GjmJ6lHBugry+fjK3MdBtzqv08Fn6/swUjuBWFGgTFQBXiTnkrZIBUi72XPKOus7E2yTVaNPW3BdKLaIhUiR+837nue1bP3tiP8g0OWf5+diuzHKj75HMQ2EwUCw69Kz42KP41Q/oXC+sp9TOBOpNs/d15M6T8eKlalXsE9Mq756oqC9Uf5XvU0av0OTCNh/RJXgu6U9cSITpEP8x/IWicEdi8h60Bur9AhA+tsQz0CQLDSBjdWlFTxoZ8R3WP+iR17MxPAiuTKGkgCQHLhZGthOZIWWNQxznkv9IARxzCxrDo2+wyE6TAXWwDq3FbgWJCWV+nPfPdulHslchtoe6n7urJ+7pFTmA+tdd2P29NeDcaADa8sc5eIsNSAAOnuN9lEqxCgv7H9+4uwefz3vIml1+6mLPi0t/9GhiA1vS/roma/TxBZuW3BvHPUr+8MS9/i+oCkYn4pkb7mYLryl7/azTJJKaGXYjkLZlYq42Qt6hDM6FJ1plKSC3Zf0MJKUwobLq/LLuIC6XuAV2aB/yFB3Uz+GOHnv7W7mNsc3AMSzDBaIzspUDkro+wB12yRADaSb9O9Y1mTy7F0kOeviERefLFz09VZ0P4LZXpxPADF74tdKsnXFUi+JTxe/rDoQ+FBvHPAco6xTAxysyvu2p/HrAVaouqJo+T4tUxw626W6nRyhTOX02hmcIe4N9KEcxz0T5bQh3L1OVtb5nMRvYN1WnKi+KqLa/TWe/njKikBvPbnDM41rqyMVLrq2i47zDZnegHPoWsD4Kn1dpstFVMJxE2XIsZYnfrP2Qn1pTAx0+0LsqTRgES2jRBMnlEFo7DvX0M+SE3qm34kqLhuWgo6mNRTDPyMmHz1oZpfQxt9Xe0bzrTFVO6m+/8nc8jnaAfSnZUL/ahn0mZVnfnDHXQDqjTcVYjAFMQWt2+rgLLTR6JoBZImXt062yZWqR/A6YbinI45icmkrXZT000PsKgFWSdkmlFcFfMsmMCFSe1Ij5IE5Y0qTsVpFgVcoOKjyugejWTG5/p8Zldr0dgc4Fm0dKZyXt5Tul6vf2U1lsxqz0n52gSrKzyXzfFSODDxGf3zyqL+dd8z+Vr44aVB55RR0poEVCIwN+vbnOYQSLT7rlCRTFssi+U4H2cf0FEh77dx15DxBCeXKmGAfWGR2gs99m2GpnptgS47JtS87Tb/c3C9CpkkbTdVWbAqCuKgS+UjMf3a0BB8m6SVW6E2RXJteb/BpWYtzL02VY+lUwrsMeKoe++XQhf2lWMF6pzaTuydpGMCA3O/bCj8jiS5aVJu4xmuO2fuTdRn/F5++ZqQnhdMaaVkPJVD14GFwmRBEfh+XUPgkvrB1MKyi7XxPqfILllJO9w0rbqreIuhch8R4npTW8qK1WzwhI3Wq+rQ4kWTygmtYrNGpxO+itWAtUE8GAQNPnEgwFqKwESHcPHlo8s92lHX6RmxTpGDNFkAxLkMfc8Mo8ORXyBnHYfLm+lHoMosdZ9l3AuuOtRL9XhXd2Z8zlZRTFOf5zzPVb2H8rFZ2spnkDxu6w3bg+TKd/vS2Oalym9lFUfpMoQbt9xkmjpyVFoffeCWWrL2ywTydvai71u3hErMhsHTQEpb3YUpx2JDi9eYX6Wk2+ckhhI7MdVVyMKQuj0qrWIlZXVKo5ySyEmwwX97X+H0JjA/M6PArILqZmIoH7CofS/8SBuj0BSuLkeEFG9W5H+SYsaWXIYGF3Kvr5aJufGMl9I0GDyT/hax9lyzXcOO6YRzeB7yTH7uTdyk80nXI9J/B8nKodXTt+FyqL7fxpdJiS6ACBLHpT07jgsPTCQL03w4ogWRhpm4lehaydzh8cuK7yD5QHaLvJdi5Zzd1eLJ4jqpKO1Fn8QahabLCAx+pX4wgvVINmt6X/zX9aCskEo8nMiQvXtgeDUD0Tb92kTSOFQ2U9aCr7MHYDJs4Mj11zRyS6/Tgzt10ejFA6tocq1vQ2R8YyiTQJLEG/KF/xm7Rp88eCMo6jwG9ollaC7rUpPyxj04XXNF0GxN69HEHvi3f0+pPrbSxXr6vm70zoffd45mNQShCdQ5yUlDp1pCMfGj2RfsSBCrOYoPNbmilHnPt9BWEHA2A4b6c3LpawiPu78e3PUxuLh17VZLnskpLGlFcBdO25XS0pUFKsHVZlTZetDS2yt5wABejb6NiQCJqR6fq5usUn0F16Ab1JElOWHLG/6ZUDOFcI5Yykftt+2cb6TGzQE5YsjMjMrMnzUZJdRFrJscRZcvcF0OiPVZoMpOEIzXeCsTrzOb03JVbriC8bU3zW0o2nbG1FbtpoP3FToNntlr8+DB0TUrfuO7IqTu430cKsDqZZkebYCAYM6ibQq+4tXUktMsr2JDpRV9scAciNOCOnaE8G/DRHVfP2FM36jYi+b3HwyENPzDskAqTTN7496a0cmnyUscma/2bTDLQ46YqEhm/Clx02mWCEJxSAYqv0FoZTcJxP8Fka8d1v93d/1TLqOQUe3ol7Iy7ABvqE1oxQCz0VjOzUKLkpLsfi903ZUZXZROV6IyqP5iQ/gYBMIi/vcAkRjVNIIHOqs0BJ9ka71HiUXaLcO0RXFzgKSOxLqRIGWiSjGRj1hRQlY5l7ktLJPodC9xWtCVyZ8sbx5j2LPacQC2ycCIN7srzKhGeNNEDKABIHLBWIena7UGl3MyqZv2y1iAtt0csT7WMoYlVFIk5z/k3xRDkJ6MwH8TVrtmakTZsV/19S6uJlzDbjc2noI2dr5VVlu8ZmbR6Z2OTcQ8IaO1/F0kLiYNtkmZKW2QlSdZhFYlKhh7sAM5Dz66UYw2soeUIRjpJk3UhsckHR+E+r643gbDk7uJauSsPTRrO7sOq8huW2wZCW3vXfbrD5R0jwM8k2R7dMpgGHYuxnA4gXY2yPFQn5Zrs+Eor4n3Be10qcdRXlPMlPrFzrRIZevY0zH7FbcX2F8fYP2VOxoI8u9IiTHnqo+WZcXW1HnX0NPf0THcG8EbO6gic0dtCr8MtJ+nxjBasovfl5iEnkrp5yyxUSADlzE4TcCgKbi/2YawitfC/nWAeWuo06AbpKyQl5xKrC8+8imhTCotgRlqEW6r8sZqoMTBjvT2qd2iriU8GhTpFrpWdcUwDVWocC2OKRCsLR2TrqOPyOcS0D+gAEmXsDMZQ4Sb8mrkNpRbCrIiIw+iuWf4eYOtxgaudXEM9RdMC4EnHDG5LfneFt92IkEZAhwK9esm/OshUt27PUinIw7kDI7jO7ZX36N7kgWaI7MUH4AMf39FxbX5YL+fx0SA3AQssmIIAJpnFu/OCSOWJT5soK9gr4uk26wPEh6S0ugamxO33KNFjRDXE1MELUlXv/aQ9RXO7zwfGi0lF2sHDgWDkHPB7o0j9dtDe2OSg1J2MCLy02dn6nKdb+QN5Taj2uKUEnVGOHkC/hLEhUFD2Zai1HVo7GStX/HK24zO4ToHrHlUaVwcI79YlvtTdknAb/XZCW95LQqugG9yenomiukD+Lc2IROXZwGS/8bvxYiuaOXc/7jMLzjosoL80cudoaiJ1hpf7AQwz6QqYKQyY13ZTNxneZ5vwNqnHypycXuw1upXDVWkjnKuSVq7OL8n5bKbIEtxPpRcQozIkPtLwUNAcVN5MWJ3814vwW0ZxUBf3ML2+WXuYn5UNrN4FXxB3De97Ol9b9Mh9YsXhTwxLS9+cAD4YrOZN4KCKdISTTjjMrInqRSWk8w4Xev6ZoJNalwJtehVOJLgV0ZUtER5WzUDLOqV4nh8ebyNufSPN3veVVOJcqm2+wEdsd++MGksgD4NZsyo6u6KofL9Z047YO4Sbwk5VZIoLVUggSuEOTGCDSQA7VxG1BUr3tG4y65GTuJT6aef77qlHxVZG/dzxUlHUefgPMkyOGv8pm9DqP0arTiTYxfFCtrwz1XU8pt9uPj46Xv2C2F23P6pL2AeGlgBxzqBeUlYVaVe0fW08vr9+u/RV4jgiaduyxHMvGSlUP3jaJt6GfYMK3Gk0wcVyVup00gsqM89pZS69gwJ0z/j+MoIs/fEquKdJ65MkX3WG9SjR2LzdvibEQ610qdqZdHbBMWW8wELwDrdKFvvuhorE/qu0udM8AhzkHnfc+XF6oSOhyaOer6W5pU2DsR11YNy+AVMksMysC5F5jvxxTlBvAHsPue/HaElSTxfMdEU9UQd2EC/cUIdJOfvVIV/v0lbd3NAORErYkOHGsZTMAH7MRSi/HN0TooIYEDDVJFgQZQmRok74tM1HtgofCqCzEM7K1tWaDEQI6RtK7UX5Iex/BHr6adF47lYY3HI4Y0dHZB8Bg2TPq9yABKLmoN4WXWHJQaStH5kf52xNVA1Hj2fCct0ubMcD1HzWthKrr/nSbmLNhNNU2Dqf/--QdRBn1/NPtPNGMHb--sD6DsVLuhIq3ie3oiDi41A== \ No newline at end of file From a5caac65f9ca7af5ccc2dad6ec180c7d7fc7e03c Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 9 Apr 2026 11:37:37 +0100 Subject: [PATCH 51/87] Remove ISO-8859-1 test from cohort_import_spec It is already tested as part of CSVParser and the setup is confusing in these this tests. It needs to know that the encoding is either detected as part of `csv=` through the bom (which we don't use here), or in CSVParser, which is where this test really belongs anyway. Jira-Issue: MAV-6062 --- spec/models/cohort_import_spec.rb | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index fa892741dc..89ba9e8323 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -127,28 +127,6 @@ 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 From 0d2615d7449b212b43e5bd6e59882edad6fada70 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 26 Mar 2026 14:23:27 +0000 Subject: [PATCH 52/87] Add Import::CSVData, refactor load_data! out Import::CSVData will be used to abstract certain operations out of CSVImportable, such as loading CSV data from user params. Jira-Issue: MAV-6063 --- app/controllers/class_imports_controller.rb | 1 - app/controllers/cohort_imports_controller.rb | 1 - .../immunisation_imports_controller.rb | 1 - app/models/concerns/csv_importable.rb | 99 +++++------- app/models/immunisation_import.rb | 3 +- app/models/import/csv_data.rb | 62 +++++++ app/models/patient_import.rb | 2 + .../api/testing/teams_controller_spec.rb | 8 +- spec/factories/class_imports.rb | 30 +++- spec/factories/cohort_imports.rb | 30 +++- spec/factories/immunisation_imports.rb | 28 +++- .../valid_mixed_flu_hpv.csv | 2 +- ...lk_remove_parent_relationships_job_spec.rb | 4 +- .../commit_patient_changesets_job_spec.rb | 5 +- spec/models/class_import_spec.rb | 14 +- spec/models/cohort_import_spec.rb | 11 +- spec/models/immunisation_import_spec.rb | 31 ++-- spec/models/import/csv_data_spec.rb | 151 ++++++++++++++++++ .../shared_examples/a_csv_importable_model.rb | 12 +- 19 files changed, 393 insertions(+), 102 deletions(-) create mode 100644 app/models/import/csv_data.rb create mode 100644 spec/models/import/csv_data_spec.rb 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/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/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 0b5c3ee620..6919bcdff9 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,31 +141,6 @@ 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 @@ -186,13 +172,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 +184,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 +199,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/immunisation_import.rb b/app/models/immunisation_import.rb index d4fac6250e..a16c3ea98f 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -66,8 +66,9 @@ def records_count 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) 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/patient_import.rb b/app/models/patient_import.rb index adcd362829..5890014d5f 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -179,6 +179,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/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb index 6a75ae8db3..c340ddcfe7 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 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/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/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/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 9a6cdda98e..be7bbb11c4 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,7 +48,11 @@ let(:session) { create(:session, location:, programmes:, team:) } let(:file) { "valid.csv" } - let(:csv) { fixture_file_upload("class_import/#{file}") } + let(:csv_data) { file_fixture("class_import/#{file}").read } + let(:uploaded_csv_file) { nil } + + # This is used by validation tests in the CSFVImportable shared specs. + let(:unsaved_import) { build(:class_import, csv_data:, session:, team:) } it_behaves_like "a CSVImportable model" @@ -56,6 +62,10 @@ before { parse_rows! } describe "with a BOM" do + let(:csv_data) { nil } + let(:uploaded_csv_file) do + fixture_file_upload("class_import/#{file}", filename: file) + end let(:file) { "valid_with_bom.csv" } it "removes the BOM" do diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index 89ba9e8323..7f4fffe916 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -34,18 +34,23 @@ # 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_data) { file_fixture("cohort_import/#{file}").read } + 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 + # This is used by validation tests in the CSFVImportable shared specs. + let(:unsaved_import) { build(:cohort_import, csv_data:, team:) } + it_behaves_like "a CSVImportable model" describe "#parse_rows!" do diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index fe42ddafed..89d9ac74db 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -35,7 +35,7 @@ describe ImmunisationImport do subject(:immunisation_import) do - create(:immunisation_import, team:, csv:, uploaded_by:) + create(:immunisation_import, team:, csv_data:, uploaded_by:) end before do @@ -56,15 +56,21 @@ let(:school) { create(:gias_school, urn: "123456") } let(:file) { "valid_flu.csv" } - let(:csv) { fixture_file_upload("immunisation_import/#{type}/#{file}") } + let(:csv_data) { file_fixture("immunisation_import/#{type}/#{file}").read } + let(:uploaded_csv_file) { nil } let(:uploaded_by) { create(:user, team:) } let(:type) { "point_of_care" } + # This is used by validation tests in the CSFVImportable shared specs. + let(:unsaved_import) do + build(:immunisation_import, team:, csv_data:, uploaded_by:) + end + it_behaves_like "a CSVImportable model" - describe "#load_data!" do - before { immunisation_import.load_data! } + describe "validations" do + subject { unsaved_import } context "with a duplicated row" do let(:file) { "duplicate_row.csv" } @@ -157,7 +163,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 @@ -188,6 +193,10 @@ Flipper.enable(:pds_enqueue_bulk_updates) 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" } @@ -245,8 +254,8 @@ 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! expect(immunisation_import.exact_duplicate_record_count).to eq(11) @@ -297,8 +306,8 @@ 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! expect(immunisation_import.exact_duplicate_record_count).to eq(11) @@ -349,8 +358,8 @@ 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! expect(immunisation_import.exact_duplicate_record_count).to eq(11) 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/support/shared_examples/a_csv_importable_model.rb b/spec/support/shared_examples/a_csv_importable_model.rb index 7de30b6856..1ff4d1b797 100644 --- a/spec/support/shared_examples/a_csv_importable_model.rb +++ b/spec/support/shared_examples/a_csv_importable_model.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true shared_examples_for "a CSVImportable model" do - describe "#load_data!" do - before { subject.load_data! } + describe "validations" do + subject { unsaved_import } it { should be_valid } @@ -51,6 +51,8 @@ end end + # TODO: This needs to set `csv`, it currently doesn't trigger it the way it's + # done here. describe "#csv=" do it "sets the data" do expect(subject.csv_data).not_to be_empty @@ -78,8 +80,10 @@ describe "#process!" do let(:today) { Time.zone.local(2025, 6, 1) } - it "sets processed_at" do - if subject.is_a?(ImmunisationImport) + # 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 From 0b9966010f4cf4cdd8028617cd585e412ccfa57d Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 10 Apr 2026 09:06:50 +0100 Subject: [PATCH 53/87] Make csv= specs actually test something Recent changes rewired specs to test import by setting `csv_data` instead of `csv` to isolate what's being tested, but that left `csv=` largely being untested. Jira-Issue: MAV-6063 --- .../files/cohort_import/valid_with_bom.csv | 2 ++ .../point_of_care/valid_with_bom.csv | 2 ++ spec/models/class_import_spec.rb | 16 +++------------- spec/models/cohort_import_spec.rb | 4 +++- spec/models/immunisation_import_spec.rb | 12 ++++++++++-- .../shared_examples/a_csv_importable_model.rb | 19 +++++++++++++++---- 6 files changed, 35 insertions(+), 20 deletions(-) create mode 100644 spec/fixtures/files/cohort_import/valid_with_bom.csv create mode 100644 spec/fixtures/files/immunisation_import/point_of_care/valid_with_bom.csv 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/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/models/class_import_spec.rb b/spec/models/class_import_spec.rb index be7bbb11c4..46280c945f 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -48,7 +48,9 @@ let(:session) { create(:session, location:, programmes:, team:) } let(:file) { "valid.csv" } - let(:csv_data) { file_fixture("class_import/#{file}").read } + 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 } # This is used by validation tests in the CSFVImportable shared specs. @@ -61,18 +63,6 @@ before { parse_rows! } - describe "with a BOM" do - let(:csv_data) { nil } - let(:uploaded_csv_file) do - fixture_file_upload("class_import/#{file}", filename: file) - end - let(:file) { "valid_with_bom.csv" } - - it "removes the BOM" do - expect(class_import).to be_valid - end - end - describe "with invalid fields" do let(:file) { "invalid_fields.csv" } diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index 7f4fffe916..01d987d426 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -42,7 +42,9 @@ let(:team) { create(:team, programmes:) } let(:file) { "valid.csv" } - let(:csv_data) { file_fixture("cohort_import/#{file}").read } + 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 diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 89d9ac74db..9f719f6398 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_data:, uploaded_by:) + create( + :immunisation_import, + team:, + csv_data:, + uploaded_by:, + uploaded_csv_file: + ) end before do @@ -56,7 +62,9 @@ let(:school) { create(:gias_school, urn: "123456") } let(:file) { "valid_flu.csv" } - let(:csv_data) { file_fixture("immunisation_import/#{type}/#{file}").read } + 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:) } diff --git a/spec/support/shared_examples/a_csv_importable_model.rb b/spec/support/shared_examples/a_csv_importable_model.rb index 1ff4d1b797..f872cc4773 100644 --- a/spec/support/shared_examples/a_csv_importable_model.rb +++ b/spec/support/shared_examples/a_csv_importable_model.rb @@ -51,15 +51,26 @@ end end - # TODO: This needs to set `csv`, it currently doesn't trigger it the way it's - # done here. 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 From 93345188f328bdfd44a71ee82b47800707054c0b Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 26 Mar 2026 14:23:27 +0000 Subject: [PATCH 54/87] Refactor rspec subjects to be import objects This changes import specs to not make test `subject` an action. This is stylistic, it can work either way, but considering how hard it's been to reason about the imports I thought it would be better to return to what's considered common convention to try to make the tests easier to understand. This became particularly apparent when a call to `parse_rows!` had to be added to tests (coming in a later change), meaning the `subject` would need a call to `parse_rows!`, or possibly `parse_rows!` added to the `before` block, but either way it was becoming un-idiomatic. Jira-Issue: MAV-6063 --- spec/models/class_import_spec.rb | 28 +++----- spec/models/cohort_import_spec.rb | 10 +-- spec/models/immunisation_import_spec.rb | 94 +++++++++++++------------ 3 files changed, 61 insertions(+), 71 deletions(-) diff --git a/spec/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 46280c945f..279c654b14 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -59,9 +59,7 @@ it_behaves_like "a CSVImportable model" describe "#parse_rows!" do - subject(:parse_rows!) { class_import.parse_rows! } - - before { parse_rows! } + before { class_import.parse_rows! } describe "with invalid fields" do let(:file) { "invalid_fields.csv" } @@ -136,8 +134,6 @@ end describe "#process!" do - subject(:process!) { class_import.process! } - let(:file) { "valid.csv" } let(:configured_job) { instance_double(ActiveJob::ConfiguredJob) } @@ -155,7 +151,7 @@ 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 = @@ -172,7 +168,7 @@ 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 @@ -227,8 +223,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( @@ -241,7 +235,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 @@ -258,7 +252,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 @@ -267,22 +261,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 @@ -316,7 +306,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( @@ -333,7 +323,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 01d987d426..a2ce7206f8 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -56,9 +56,7 @@ 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" } @@ -137,8 +135,6 @@ end describe "#process!" do - subject(:process!) { cohort_import.process! } - let(:configured_job) { instance_double(ActiveJob::ConfiguredJob) } let(:file) { "valid.csv" } @@ -156,7 +152,7 @@ end it "enqueues PDSCascadingSearchJob for each changeset" do - process! + cohort_import.process! expect(configured_job).to have_received(:perform_later).exactly(3).times end @@ -166,7 +162,7 @@ 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_spec.rb b/spec/models/immunisation_import_spec.rb index 9f719f6398..bf3fe8fd40 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -192,8 +192,6 @@ end describe "#process!" do - subject(:process!) { immunisation_import.process! } - around { |example| travel_to(Date.new(2025, 8, 1)) { example.run } } before do @@ -216,7 +214,7 @@ ) # rubocop:enable RSpec/SubjectStub - expect { process! }.not_to raise_error + expect { immunisation_import.process! }.not_to raise_error end end @@ -226,7 +224,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) @@ -244,7 +242,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) @@ -256,7 +254,7 @@ 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 @@ -265,21 +263,20 @@ 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 @@ -289,7 +286,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) @@ -308,7 +305,7 @@ 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 @@ -317,21 +314,20 @@ 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 @@ -341,7 +337,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) @@ -360,7 +356,7 @@ 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 @@ -369,21 +365,20 @@ 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 @@ -393,7 +388,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) @@ -427,11 +422,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 @@ -451,7 +451,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 @@ -471,7 +473,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 @@ -481,11 +483,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 @@ -500,11 +502,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 @@ -516,8 +518,6 @@ end describe "#post_commit!" do - subject(:post_commit!) { immunisation_import.send(:post_commit!) } - let(:immunisation_import) do create( :immunisation_import, @@ -533,15 +533,13 @@ before { Flipper.enable(:imms_api_sync_job) } 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 end describe "#postprocess_rows!" do - subject(:postprocess_rows!) { immunisation_import.send(:postprocess_rows!) } - let(:immunisation_import) do create( :immunisation_import, @@ -559,7 +557,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 @@ -567,7 +568,10 @@ 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 @@ -576,7 +580,7 @@ vaccination_record: ) - postprocess_rows! + immunisation_import.send :postprocess_rows! end end end From 5b2c54dc3252b6bbad2c39cff4fec510c3212928 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Thu, 26 Mar 2026 14:23:27 +0000 Subject: [PATCH 55/87] Remove parse_rows! call from process! This is to make it easier to reason about the code: when parse_rows! is explicit it's clear what stage of the process is executing when. Jira-Issue: MAV-6063 --- app/jobs/process_import_job.rb | 3 +++ app/models/concerns/csv_importable.rb | 5 ----- spec/controllers/api/testing/teams_controller_spec.rb | 1 + spec/models/class_import_spec.rb | 2 ++ spec/models/cohort_import_spec.rb | 2 ++ spec/models/immunisation_import_spec.rb | 2 ++ spec/support/imports_helper.rb | 1 + spec/support/shared_examples/a_csv_importable_model.rb | 2 ++ 8 files changed, 13 insertions(+), 5 deletions(-) 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/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 6919bcdff9..22a7fca418 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -146,11 +146,6 @@ def processed? end def process! - return if processed? - - parse_rows! if rows.nil? - return if invalid? - process_import! TeamCachedCounts.new(team).reset_import_issues! diff --git a/spec/controllers/api/testing/teams_controller_spec.rb b/spec/controllers/api/testing/teams_controller_spec.rb index c340ddcfe7..a77629d0da 100644 --- a/spec/controllers/api/testing/teams_controller_spec.rb +++ b/spec/controllers/api/testing/teams_controller_spec.rb @@ -47,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/models/class_import_spec.rb b/spec/models/class_import_spec.rb index 279c654b14..dbaad17604 100644 --- a/spec/models/class_import_spec.rb +++ b/spec/models/class_import_spec.rb @@ -142,6 +142,8 @@ queue: :imports ).and_return(configured_job) allow(configured_job).to receive(:perform_later) + + class_import.parse_rows! end context "when pds_search_during_import flag is enabled" do diff --git a/spec/models/cohort_import_spec.rb b/spec/models/cohort_import_spec.rb index a2ce7206f8..724679489e 100644 --- a/spec/models/cohort_import_spec.rb +++ b/spec/models/cohort_import_spec.rb @@ -143,6 +143,8 @@ queue: :imports ).and_return(configured_job) allow(configured_job).to receive(:perform_later) + + cohort_import.parse_rows! end context "when pds_search_during_import flag is enabled" do diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index bf3fe8fd40..36d74df56a 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -197,6 +197,8 @@ before do Flipper.enable(:pds) Flipper.enable(:pds_enqueue_bulk_updates) + + immunisation_import.parse_rows! end let(:duplicate_import) do 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 f872cc4773..1d4753815f 100644 --- a/spec/support/shared_examples/a_csv_importable_model.rb +++ b/spec/support/shared_examples/a_csv_importable_model.rb @@ -91,6 +91,8 @@ describe "#process!" do let(:today) { Time.zone.local(2025, 6, 1) } + 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 From 19092b2bbb84bf43c1b1796b77ca4637828df6e7 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Fri, 27 Mar 2026 18:53:21 +0000 Subject: [PATCH 56/87] Remove CSVImportable#process! The intention is to make the import processing simpler to understand. Since nearly all the processing happens in the sub-classes we just call that directly from the ProcessImportJob. Jira-Issue: MAV-6063 --- app/models/concerns/csv_importable.rb | 6 --- app/models/immunisation_import.rb | 56 ++++++++++++++------------- app/models/patient_import.rb | 6 ++- 3 files changed, 35 insertions(+), 33 deletions(-) diff --git a/app/models/concerns/csv_importable.rb b/app/models/concerns/csv_importable.rb index 22a7fca418..7fb7a6a63d 100644 --- a/app/models/concerns/csv_importable.rb +++ b/app/models/concerns/csv_importable.rb @@ -145,12 +145,6 @@ def processed? processed_at != nil end - def process! - 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) diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index a16c3ea98f..9db07b6eb6 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -64,6 +64,36 @@ 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! + UpdatePatientsFromPDS.call(patients, queue: :imports) + + TeamCachedCounts.new(team).reset_import_issues! + end + private # TODO: This is called by the `rows_are_valid` validation. Move it to it's own validation. @@ -125,32 +155,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 diff --git a/app/models/patient_import.rb b/app/models/patient_import.rb index 5890014d5f..fb31e1885c 100644 --- a/app/models/patient_import.rb +++ b/app/models/patient_import.rb @@ -34,7 +34,9 @@ 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:) @@ -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! From 97341edfa2e4ceb7b2304818e4bf648a12e48fe1 Mon Sep 17 00:00:00 2001 From: CodeCorvin Date: Thu, 29 Jan 2026 17:53:54 +0000 Subject: [PATCH 57/87] Enable option to dismiss important notices for archived patients --- app/models/concerns/patient_import_concern.rb | 3 ++- app/models/important_notice.rb | 3 ++- app/models/patient.rb | 6 +++--- app/models/patient_changeset.rb | 2 +- app/views/patients/_header.html.erb | 2 +- ...cination_records_national_reporting_spec.rb | 4 ++-- ...t_vaccination_records_point_of_care_spec.rb | 4 ++-- spec/lib/patient_merger_spec.rb | 6 +++--- spec/models/important_notice_spec.rb | 18 ++++++++++++++++++ spec/models/patient_spec.rb | 4 ++-- spec/models/school_move_spec.rb | 12 ++++++------ 11 files changed, 42 insertions(+), 22 deletions(-) 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/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/patient.rb b/app/models/patient.rb index 7362382625..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 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/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/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/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/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/patient_spec.rb b/spec/models/patient_spec.rb index d2155a0cb2..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" 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 From b7b85d5767fdcf1dc245db01cf371d919816810e Mon Sep 17 00:00:00 2001 From: Thomas Leese Date: Mon, 20 Apr 2026 11:47:26 +0100 Subject: [PATCH 58/87] Ensure Gillick assessment is only shown on day This is a follow up to https://github.com/NHSDigital/manage-vaccinations-in-schools/pull/6576 which missed that the UI would still show the Gillick assessment on a different day. Although the original bug has been fixed (self-consent cannot be given on a different day), the child would show as Gillick assessed still, which is not the case. Jira-Issue: MAV-6741 --- .../app_gillick_assessment_component.rb | 1 + .../app_gillick_assessment_component_spec.rb | 16 ++++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) 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/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 From 01c670a1e32830133ce43f56c99edd6ec0b1b020 Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Sun, 19 Apr 2026 17:17:37 +0100 Subject: [PATCH 59/87] Rename CareplusExport to CareplusReport * We want to avoid using 'exports'/'imports' going forward * 'Report' will be used in the UI * This aligns the model with the later UI changes Jira-Issue: MAV-5325 --- .../reports/export_automated_careplus.rb | 8 ++-- ...{careplus_export.rb => careplus_report.rb} | 16 +++---- ... => careplus_report_vaccination_record.rb} | 16 +++---- config/locales/en.yml | 8 ++-- ...2121_rename_careplus_exports_to_reports.rb | 13 ++++++ db/schema.rb | 26 +++++------ ...=> careplus_report_vaccination_records.rb} | 14 +++--- ...areplus_exports.rb => careplus_reports.rb} | 14 +++--- ..._reports_export_automated_careplus_spec.rb | 30 ++++++------- ...export_spec.rb => careplus_report_spec.rb} | 44 +++++++++---------- ...areplus_report_vaccination_record_spec.rb} | 16 +++---- 11 files changed, 109 insertions(+), 96 deletions(-) rename app/models/{careplus_export.rb => careplus_report.rb} (76%) rename app/models/{careplus_export_vaccination_record.rb => careplus_report_vaccination_record.rb} (52%) create mode 100644 db/migrate/20260419122121_rename_careplus_exports_to_reports.rb rename spec/factories/{careplus_export_vaccination_records.rb => careplus_report_vaccination_records.rb} (56%) rename spec/factories/{careplus_exports.rb => careplus_reports.rb} (78%) rename spec/models/{careplus_export_spec.rb => careplus_report_spec.rb} (70%) rename spec/models/{careplus_export_vaccination_record_spec.rb => careplus_report_vaccination_record_spec.rb} (59%) 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/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/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/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/schema.rb b/db/schema.rb index a3a7e699e4..bb784e5a79 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_121005) do +ActiveRecord::Schema[8.1].define(version: 2026_04_19_122121) 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| @@ -1087,9 +1087,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" 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/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/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 From fbb4174aeebfd52fd505fb32bfa406d23ebd5b39 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Mon, 20 Apr 2026 15:08:35 +0100 Subject: [PATCH 60/87] Fix missing details text for patients with keep-in-triage status When a patient's latest triage is keep_in_triage, triage_summary returns nil, and no details text is shown on the programme status card. We should instead show the standard needs_triage text here. --- app/components/app_patient_session_programme_component.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/components/app_patient_session_programme_component.rb b/app/components/app_patient_session_programme_component.rb index cf33e0ee5d..255d19df24 100644 --- a/app/components/app_patient_session_programme_component.rb +++ b/app/components/app_patient_session_programme_component.rb @@ -45,8 +45,8 @@ def colour end def details - if latest_triage - triage_summary(latest_triage) + if latest_triage && (summary = triage_summary(latest_triage)).present? + summary elsif programme_status.due? criteria_label = I18n.t( From 5f040e1f1c502f46c606a27675b8a480bfd5268b Mon Sep 17 00:00:00 2001 From: Jake Benilov Date: Mon, 20 Apr 2026 21:59:02 +0100 Subject: [PATCH 61/87] Support synchronous refresh in the testing refresh-reporting endpoint When wait=true is provided, run ReportingAPI::RefreshJob inline and respond with 200, so test callers can block until the materialised view is refreshed instead of polling. Default async behaviour is preserved. --- .../api/testing/reporting_refresh_controller.rb | 9 +++++++-- .../api/testing/reporting_refresh_controller_spec.rb | 9 +++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) 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/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 From 12b119e1ebd46ec5a3ba4e4b530170f24ddca715 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 20 Apr 2026 23:14:54 +0000 Subject: [PATCH 62/87] Bump rails_semantic_logger from 4.19.0 to 4.20.0 Bumps [rails_semantic_logger](https://github.com/reidmorrison/rails_semantic_logger) from 4.19.0 to 4.20.0. - [Commits](https://github.com/reidmorrison/rails_semantic_logger/compare/v4.19.0...v4.20.0) --- updated-dependencies: - dependency-name: rails_semantic_logger dependency-version: 4.20.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 62fdce30ce..6b79b1cc41 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -302,7 +302,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 @@ -427,7 +427,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 @@ -493,7 +493,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) @@ -642,7 +642,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 +656,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 +771,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) From 434a315444a007d49ced3375e35fdab8e31b3cac Mon Sep 17 00:00:00 2001 From: Steve Hook Date: Thu, 9 Apr 2026 10:09:18 +0100 Subject: [PATCH 63/87] Move already had notification sending to after commit `ImmunisationImport` was triggering the `AlreadyHadNotificationSender` within the transaction that wraps the import process. This could theoretically lead to issues with the email job getting queued in Sidekiq before the data has been committed to the database. This is a possible reason for some of the missing emails that have been reported in prod and test. Jira-Issue: MAV-3418 --- app/models/immunisation_import.rb | 8 ++++---- spec/models/immunisation_import_spec.rb | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 9db07b6eb6..6c77b1de2f 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -217,6 +217,10 @@ def postprocess_rows! PatientTeamUpdater.call(patient_scope: patients) PatientStatusUpdater.call(patient_scope: Patient.where(id: patients.ids)) + end + + def post_commit! + vaccination_records.sync_all_to_nhs_immunisations_api vaccination_records .includes(:patient, :team, :subteam) @@ -224,8 +228,4 @@ def postprocess_rows! AlreadyHadNotificationSender.call(vaccination_record:) end end - - def post_commit! - vaccination_records.sync_all_to_nhs_immunisations_api - end end diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 36d74df56a..28ef43ce7a 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -539,6 +539,14 @@ 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 @@ -576,13 +584,5 @@ ).by(1) end end - - it "calls the AlreadyHadNotificationSender for the vaccination record" do - expect(AlreadyHadNotificationSender).to receive(:call).with( - vaccination_record: - ) - - immunisation_import.send :postprocess_rows! - end end end From 81463cbd3a11f38a0f4bfc254c4075d3f6ddf802 Mon Sep 17 00:00:00 2001 From: Joshua Frost Date: Tue, 21 Apr 2026 09:28:10 +0100 Subject: [PATCH 64/87] Add careplus_enabled, has_careplus_credentials, eligible_for_automated_careplus_reports scope/predicaates for teams --- app/models/team.rb | 22 ++++++++++ spec/models/team_spec.rb | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 115 insertions(+) diff --git a/app/models/team.rb b/app/models/team.rb index 5790e9c6cd..8b7adec608 100644 --- a/app/models/team.rb +++ b/app/models/team.rb @@ -85,6 +85,23 @@ class Team < ApplicationRecord 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, @@ -154,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/spec/models/team_spec.rb b/spec/models/team_spec.rb index 5f79359467..15cb91102f 100644 --- a/spec/models/team_spec.rb +++ b/spec/models/team_spec.rb @@ -130,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? } @@ -147,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 From 1c2f98826b9c5b3aed3c6c9454a0a5e89d075a92 Mon Sep 17 00:00:00 2001 From: Paul Robert Lloyd Date: Tue, 21 Apr 2026 11:54:01 +0100 Subject: [PATCH 65/87] =?UTF-8?q?Use=20action=20link=20component=20for=20?= =?UTF-8?q?=E2=80=98Add=20a=20new=20session=E2=80=99=20link?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/views/sessions/index.html.erb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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", + ) %>
From 701fff9611b9add9bfce85a87af58e98f569fba6 Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Tue, 21 Apr 2026 10:49:21 +0100 Subject: [PATCH 66/87] Add `nr_cut_off_date` and `type` to `bin/mavis teams list` This improves the CLI commands output, and makes it easier to debug potential issues arising in relation to national reporting teams. --- app/lib/mavis_cli/teams/list.rb | 15 ++++++++++++--- spec/features/cli_teams_list_spec.rb | 22 +++++++++++++++++++++- spec/support/capture_output.rb | 5 +++++ 3 files changed, 38 insertions(+), 4 deletions(-) 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/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/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 From 4605aaa7285645d2e067fb1800b8f5c184c738e6 Mon Sep 17 00:00:00 2001 From: Lakshmi Murugappan Date: Tue, 21 Apr 2026 10:59:13 +0100 Subject: [PATCH 67/87] Show triage summary only when programme status reflects a triage decision Rather than checking triage methods directly, gate the triage summary on programme status so that a refused consent always takes precedence over any existing triage. The summary now shows for `cannot_vaccinate_do_not_vaccinate` and `cannot_vaccinate_delay_vaccination` statuses, and for `needs_triage` when the triage outcome is `invite_to_clinic`. --- ...app_patient_session_programme_component.rb | 15 +++++++++-- ...atient_session_programme_component_spec.rb | 25 ++++++++++++++++--- 2 files changed, 35 insertions(+), 5 deletions(-) diff --git a/app/components/app_patient_session_programme_component.rb b/app/components/app_patient_session_programme_component.rb index 255d19df24..fdaa6750c1 100644 --- a/app/components/app_patient_session_programme_component.rb +++ b/app/components/app_patient_session_programme_component.rb @@ -45,7 +45,8 @@ def colour end def details - if latest_triage && (summary = triage_summary(latest_triage)).present? + if triage_driven_cannot_vaccinate? && latest_triage && + (summary = triage_summary(latest_triage)).present? summary elsif programme_status.due? criteria_label = @@ -75,12 +76,22 @@ def details "#{patient.given_name} was vaccinated on #{record&.performed_at&.to_fs(:long)}." end elsif programme_status.needs_triage? - "You need to decide if it’s safe to vaccinate #{patient.given_name}." + 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( diff --git a/spec/components/app_patient_session_programme_component_spec.rb b/spec/components/app_patient_session_programme_component_spec.rb index 33631007f9..624b9ec784 100644 --- a/spec/components/app_patient_session_programme_component_spec.rb +++ b/spec/components/app_patient_session_programme_component_spec.rb @@ -129,6 +129,7 @@ context "safe to vaccinate" do before do + create(:patient_programme_status, :due_injection, patient:, programme:) create( :triage, :safe_to_vaccinate, @@ -138,14 +139,22 @@ ) end - it "shows triage summary" do - expect(rendered).to have_text("#{nurse.full_name} decided that") - expect(rendered).to have_text("is safe to vaccinate") + 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, @@ -173,6 +182,15 @@ ) 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 " \ @@ -183,6 +201,7 @@ context "invite to clinic" do before do + create(:patient_programme_status, :needs_triage, patient:, programme:) create( :triage, :invite_to_clinic, From 05ca8e6d063ac290784edfa14d1da69e12968a9f Mon Sep 17 00:00:00 2001 From: Sam Coy Date: Tue, 21 Apr 2026 14:37:44 +0100 Subject: [PATCH 68/87] Enable ops tools This adds a data migration which enables the ops tools in (and adjusts the rake tasks which onboards the ops team to align with recent schema changes). --- ...0_add_ops_support_team_and_enable_ops_tools.rb | 15 +++++++++++++++ db/data_schema.rb | 2 +- lib/tasks/ops_support.rake | 9 +++------ 3 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 db/data/20260421140000_add_ops_support_team_and_enable_ops_tools.rb 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/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 From dc6da91c1386c0a5ec3a394cd7c7fc3f7ebf2c36 Mon Sep 17 00:00:00 2001 From: James Mead Date: Thu, 16 Apr 2026 17:04:55 +0100 Subject: [PATCH 69/87] Remove redundant methods in PatientStatusUpdater The `#patient_statuses_to_import` method has not been needed since `#update_consent_statuses!` was removed in this commit [1]. The removal of `#patient_statuses_to_import` means that `#programme_types_per_year_group` is in turn no longer needed. [1]: https://github.com/NHSDigital/manage-vaccinations-in-schools/commit/76507b1d62dee5e41316b569b07be09772ab2a82 Co-authored-by: Chris Lowis --- app/lib/patient_status_updater.rb | 30 ------------------------------ 1 file changed, 30 deletions(-) diff --git a/app/lib/patient_status_updater.rb b/app/lib/patient_status_updater.rb index 687f954660..7380d53711 100644 --- a/app/lib/patient_status_updater.rb +++ b/app/lib/patient_status_updater.rb @@ -106,23 +106,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) @@ -161,19 +144,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 From b4a19b7b08527b43b07e67b035e47051ee8e660a Mon Sep 17 00:00:00 2001 From: James Mead Date: Mon, 20 Apr 2026 16:17:27 +0100 Subject: [PATCH 70/87] Simplify another query in PatientStatusUpdater This brings the batching queries in `PatientStatusUpdater#update_registration_statuses!` into line with the batching queries in `PatientStatusUpdater#update_programme_statuses!` after the improvements that we made to the latter in #6593. In essence it should avoid a bunch of unnecessary JOINs in the batching query which might be slowing it down at least in some circumstances. Note that we haven't moved the call to `ActiveRecord::QueryMethods#joins` inside the call to `ActiveRecord::Batches#in_batches`, because these INNER JOINs will be constraining the `Patient::RegistrationStatus` records returned. Co-authored-by: Chris Lowis --- app/lib/patient_status_updater.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/lib/patient_status_updater.rb b/app/lib/patient_status_updater.rb index 7380d53711..b9102b0871 100644 --- a/app/lib/patient_status_updater.rb +++ b/app/lib/patient_status_updater.rb @@ -92,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!( From 70677862acffdb2661061ea79000d6d8fc61afb3 Mon Sep 17 00:00:00 2001 From: James Mead Date: Tue, 21 Apr 2026 15:22:43 +0100 Subject: [PATCH 71/87] No need to set imms_api_sync_job in spec The `sync_all_to_nhs_immunisations_api` scope always calls `SyncVaccinationRecordToNHSJob.perform_bulk`, whether or not the `imms_api_sync_job` feature flag is set, so there's no need to set it in a `before` block in the `ImmunisationImport` spec. Co-authored-by: Chris Lowis --- spec/models/immunisation_import_spec.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 28ef43ce7a..626dd478f9 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -532,8 +532,6 @@ create(:vaccination_record, programme: programmes.first, session:) end - before { Flipper.enable(:imms_api_sync_job) } - it "syncs the flu vaccination record to the NHS Immunisations API" do expect { immunisation_import.send :post_commit! }.to enqueue_sidekiq_job( SyncVaccinationRecordToNHSJob From 70d2da245cdc37f603abc0bef42ea4a4be6a07df Mon Sep 17 00:00:00 2001 From: James Mead Date: Tue, 21 Apr 2026 15:01:21 +0100 Subject: [PATCH 72/87] Ensure ImmunisationImport calls Patient Updaters We're about to make some changes in where `PatientTeamUpdater.call` and `PatientStatusUpdater.call` are called from `ImmunisationImport`. This adds some test coverage to make sure we don't accidentally stop calling them. Co-authored_by: Chris Lowis --- spec/models/immunisation_import_spec.rb | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/spec/models/immunisation_import_spec.rb b/spec/models/immunisation_import_spec.rb index 626dd478f9..93546d82f0 100644 --- a/spec/models/immunisation_import_spec.rb +++ b/spec/models/immunisation_import_spec.rb @@ -524,7 +524,8 @@ create( :immunisation_import, team:, - vaccination_records: [vaccination_record] + vaccination_records: [vaccination_record], + patients: [create(:patient)] ) end let(:session) { create(:session, location: school, programmes:) } @@ -532,6 +533,22 @@ create(:vaccination_record, programme: programmes.first, session:) end + 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 { immunisation_import.send :post_commit! }.to enqueue_sidekiq_job( SyncVaccinationRecordToNHSJob From 5ab5fdcf47ceff1f152d724d22162b063c680702 Mon Sep 17 00:00:00 2001 From: James Mead Date: Tue, 21 Apr 2026 10:42:20 +0100 Subject: [PATCH 73/87] Move post-commit operations -> ImmunisationImport#post_commit! The name of this method suggests it's purpose is to execute code against the data committed by the transaction in `ImmunisationImport#process!`. It wasn't obvious to us why some operations were inside this method and some were not. This commit moves all the code after the transaction into `ImmunisationImport#post_commit!`. Co-authored-by: Chris Lowis --- app/models/immunisation_import.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 6c77b1de2f..9c5b5d74d6 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -89,9 +89,6 @@ def process! end post_commit! - UpdatePatientsFromPDS.call(patients, queue: :imports) - - TeamCachedCounts.new(team).reset_import_issues! end private @@ -227,5 +224,9 @@ def post_commit! .find_each do |vaccination_record| AlreadyHadNotificationSender.call(vaccination_record:) end + + UpdatePatientsFromPDS.call(patients, queue: :imports) + + TeamCachedCounts.new(team).reset_import_issues! end end From 50b02d6469ce83036840d3545f0921d13144bd13 Mon Sep 17 00:00:00 2001 From: James Mead Date: Mon, 20 Apr 2026 17:19:51 +0100 Subject: [PATCH 74/87] Move calls to Patient Updaters outside transaction Previously `PatientTeamUpdater.call` & `PatientStatusUpdater.call` were being called from `ImmunisationImport#postprocess_rows!` which was inside the transaction initiated in `ImmunisationImport#process!`. Since both of these Updaters are just updating a set of records that are effectively a "cache", they don't need to run inside the transaction. The `PatientStatusUpdater` in particular can end up UPSERTing a significant number of records, so it seems sensible to do that outside the transaction to reduce the time the transaction takes and reduces the number of records locked by the transaction. This should reduce the chances of other transactions being blocked by an `ImmunisationImport` transaction. The reason the transaction is needed in `ImmunisationImport#process!` is that it's UPSERTing into a bunch of related tables and JOIN tables via `ImmunisationImport#bulk_import` and if an exception occurs in the middle of this, we need to rollback to a consistent state. In contrast the Updaters are UPSERTing records into the "cache" tables and if an exception occurs in the middle of this, there's no need to rollback; the `ImmunisationImport` will fail and when it is eventually run again, the Updaters will be run again and the "cache" will be fully updated. So there's no need wrap them in a transaction of their own. Co-authored-by: Chris Lowis --- app/models/immunisation_import.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/immunisation_import.rb b/app/models/immunisation_import.rb index 9c5b5d74d6..51601a3e86 100644 --- a/app/models/immunisation_import.rb +++ b/app/models/immunisation_import.rb @@ -211,12 +211,12 @@ 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)) - end - def post_commit! vaccination_records.sync_all_to_nhs_immunisations_api vaccination_records From fd7c15e81623cdba3f62e9318c109b0dd159996d Mon Sep 17 00:00:00 2001 From: Alistair White-Horne Date: Mon, 20 Apr 2026 11:18:49 +0100 Subject: [PATCH 75/87] Add "on delete, cascade" to some `Patient` foreign keys This change will make it simpler to delete patients; these foreign key relationships will no longer need to be manually deleted. These objects are considered to be part of the "mechanics" of how the service works, and are only created as part of under-the-hood processes; users don't know that these relationships exist. Many other FK relationships have been left without cascade. These relationships link to objects which the user has created, meaning that they likely need some more careful handling when deleting. `validate: false` is used so that this change doesn't cause a full table lock. This will require a second migration in a subsequent release to validate these foreign keys `access_log_entries`'s FK validation is removed, but not cascaded. It was decided that, because this is a type of audit, these objects should be preserved, and the `patient_id` should be kept, in the same style as the `Audited::Audit`s Jira-Issue: MAV-7064 --- app/models/access_log_entry.rb | 1 - app/models/notify_log_entry.rb | 2 +- app/models/patient_merge_log_entry.rb | 2 +- .../patient_programme_vaccinations_search.rb | 2 +- app/models/pds_search_result.rb | 2 +- app/models/school_move_log_entry.rb | 2 +- ...dd_more_foreign_key_cascade_for_patient.rb | 20 +++++++++++++++++++ db/schema.rb | 13 ++++++------ spec/factories/access_log_entries.rb | 1 - spec/factories/notify_log_entries.rb | 2 +- spec/factories/patient_merge_log_entries.rb | 2 +- ...patient_programme_vaccinations_searches.rb | 2 +- spec/factories/pds_search_results.rb | 2 +- spec/factories/school_move_log_entries.rb | 2 +- spec/models/access_log_entry_spec.rb | 1 - spec/models/notify_log_entry_spec.rb | 2 +- spec/models/patient_merge_log_entry_spec.rb | 2 +- spec/models/pds_search_result_spec.rb | 2 +- spec/models/school_move_log_entry_spec.rb | 2 +- 19 files changed, 40 insertions(+), 24 deletions(-) create mode 100644 db/migrate/20260420101139_add_more_foreign_key_cascade_for_patient.rb 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/notify_log_entry.rb b/app/models/notify_log_entry.rb index 02d2fc1c34..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 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/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/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 bb784e5a79..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_19_122121) 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" @@ -1078,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" @@ -1151,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" @@ -1160,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" @@ -1174,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/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/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/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/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/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) From 90a236b3c4c89e45e493d2e916e0fc7fff646e34 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Tue, 21 Apr 2026 16:57:14 +0100 Subject: [PATCH 76/87] Update patient status after sending consent requests Ensure programme status is refreshed once consent requests have been sent. Without this, patients can remain in an out-of-date status after requests go out, which leads to inaccurate status information in the UI and incorrect results when filtering by patient status. Jira-Issue: MAV-7076 --- app/lib/notifier/patient.rb | 2 ++ spec/lib/notifier/patient_spec.rb | 37 ++++++++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/lib/notifier/patient.rb b/app/lib/notifier/patient.rb index 812441971b..29d1d51930 100644 --- a/app/lib/notifier/patient.rb +++ b/app/lib/notifier/patient.rb @@ -237,6 +237,8 @@ def send_consent_notification( SMSDeliveryJob.perform_later(sms_template, **params) end + PatientStatusUpdaterJob.perform_async(patient.id) + consent_notification end diff --git a/spec/lib/notifier/patient_spec.rb b/spec/lib/notifier/patient_spec.rb index f698616220..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 From 0f43de10a9ba4a0de6946e83cc754a7c4fc0985a Mon Sep 17 00:00:00 2001 From: John Henderson Date: Tue, 21 Apr 2026 16:30:25 +0100 Subject: [PATCH 77/87] Fix: reporting app was showing inconsistent child counts in the consent summary The total in the top-row red "No consent recorded" box no longer matched the sum of the white boxes beneath it after introducing the newer consent statuses no_contact_details, request_scheduled and request_not_scheduled. This fixes this by including those status enums inconsent_no_response_count query. Jira-Issue: MAV-7075 --- app/models/reporting_api/total.rb | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/models/reporting_api/total.rb b/app/models/reporting_api/total.rb index fcbe259347..1883b5d567 100644 --- a/app/models/reporting_api/total.rb +++ b/app/models/reporting_api/total.rb @@ -57,6 +57,13 @@ class ReportingAPI::Total < ApplicationRecord 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, -> do @@ -92,7 +99,9 @@ def self.no_consent_count 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 From 55c57be824069b67540c226fcf7aa050dce25c66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 21 Apr 2026 21:54:28 +0000 Subject: [PATCH 78/87] Bump faker from 3.6.1 to 3.7.1 Bumps [faker](https://github.com/faker-ruby/faker) from 3.6.1 to 3.7.1. - [Release notes](https://github.com/faker-ruby/faker/releases) - [Changelog](https://github.com/faker-ruby/faker/blob/main/CHANGELOG.md) - [Commits](https://github.com/faker-ruby/faker/compare/v3.6.1...v3.7.1) --- updated-dependencies: - dependency-name: faker dependency-version: 3.7.1 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 6b79b1cc41..aae93887d4 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -314,7 +314,7 @@ 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.3) async From 3a313afbdbc8e00109e41fdd2d473d3dad31bcb1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 05:36:33 +0000 Subject: [PATCH 79/87] Bump capybara_accessible_selectors from `e204122` to `d518ee5` Bumps [capybara_accessible_selectors](https://github.com/citizensadvice/capybara_accessible_selectors) from `e204122` to `d518ee5`. - [Release notes](https://github.com/citizensadvice/capybara_accessible_selectors/releases) - [Commits](https://github.com/citizensadvice/capybara_accessible_selectors/compare/e204122d3949530828ba8ea52e33409d86f72679...d518ee5271f60e18a555434160ae02b74e534046) --- updated-dependencies: - dependency-name: capybara_accessible_selectors dependency-version: d518ee5271f60e18a555434160ae02b74e534046 dependency-type: direct:development ... Signed-off-by: dependabot[bot] --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index aae93887d4..9390a1ef31 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) From cf369b730da6cc5c7734764b77397d59cfd99878 Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Tue, 21 Apr 2026 15:14:17 +0100 Subject: [PATCH 80/87] Update to ruby 4.0.3 --- .ruby-version | 2 +- .tool-versions | 2 +- Dockerfile | 2 +- Gemfile.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 7f758365ef..81e3b2884f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -6,6 +6,6 @@ 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/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.lock b/Gemfile.lock index 9390a1ef31..67e73e3bb7 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1038,7 +1038,7 @@ DEPENDENCIES with_advisory_lock RUBY VERSION - ruby 4.0.2 + ruby 4.0.3 BUNDLED WITH 4.0.8 From 5e877ea49940005865b3fe990fab79eef81f528b Mon Sep 17 00:00:00 2001 From: Misha Gorodnitzky Date: Tue, 21 Apr 2026 15:14:17 +0100 Subject: [PATCH 81/87] Update to latest ruby/setup-ruby workflow step Required for ruby 4.0.3 support. --- .github/workflows/create_dockerized_db.yml | 2 +- .github/workflows/deploy-documentation.yml | 2 +- .github/workflows/end-to-end-tests-local.yml | 4 ++-- .github/workflows/lint.yml | 2 +- .github/workflows/test.yml | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/create_dockerized_db.yml b/.github/workflows/create_dockerized_db.yml index 779dcc9b8a..f4fda46249 100644 --- a/.github/workflows/create_dockerized_db.yml +++ b/.github/workflows/create_dockerized_db.yml @@ -56,7 +56,7 @@ jobs: sleep 2 done ' - - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Populate database for testing diff --git a/.github/workflows/deploy-documentation.yml b/.github/workflows/deploy-documentation.yml index 70d7869298..9c227712ad 100644 --- a/.github/workflows/deploy-documentation.yml +++ b/.github/workflows/deploy-documentation.yml @@ -19,7 +19,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true diff --git a/.github/workflows/end-to-end-tests-local.yml b/.github/workflows/end-to-end-tests-local.yml index 8eecef4cf8..b6793a5f6c 100644 --- a/.github/workflows/end-to-end-tests-local.yml +++ b/.github/workflows/end-to-end-tests-local.yml @@ -31,7 +31,7 @@ jobs: node-version-file: .tool-versions cache: yarn - name: Setup Ruby on base branch - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Install JS dependencies on base branch @@ -48,7 +48,7 @@ jobs: node-version-file: .tool-versions cache: yarn - name: Setup Ruby on head branch - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Install JS dependencies on head branch diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index cdd9c66ce2..149a4985c7 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index e577305b40..7e8a2a8a19 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -30,7 +30,7 @@ jobs: with: node-version-file: .tool-versions cache: yarn - - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Precompile assets @@ -67,7 +67,7 @@ jobs: with: node-version-file: .tool-versions cache: yarn - - uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 # v1.301.0 + - uses: ruby/setup-ruby@60ecfba8750476ff216b59eee3b88218bb5111cc # v1.303.0 with: bundler-cache: true - name: Check seeds run From 0a95de742523ccacc4bb288c9d11d7da0ddcb143 Mon Sep 17 00:00:00 2001 From: James Mead Date: Wed, 22 Apr 2026 13:03:38 +0100 Subject: [PATCH 82/87] Remove unused Cuprite-related code The JavaScript driver for Capybara was being configured to use Cuprite to drive a browser for JS-enabled specs. However, there are no such specs at the moment and it's not obvious that there have ever been any. When we added `js: true` to an existing feature spec and ran it, we saw an exception, so it's not obvious that it was even working. The feature specs currently use the default Capybara driver which is rack-test. Since this doesn't drive a browser, it doesn't support screenshots, so we removed the `capybara-screenshot` gem as well. `Capybara.asset_host` is only relevant for browser-based specs and so it doesn't need to be set any more. The code that was checking for the browser binary by rescuing `Ferrum::BinaryNotFoundError` is no longer needed, because Ferrum is the lower-level gem used by Cuprite. Currently if a JS-enabled spec is needed it's added to the e2e specs [1] which use Python & Playwright. If we ever need to add JS-enabled specs to this repo, it would probably make sense to use the Playwright driver for Capybara for consistency with the e2e specs. [1]: https://github.com/NHSDigital/manage-vaccinations-in-schools-testing/ Co-authored-by: Chris Lowis --- Gemfile | 2 -- Gemfile.lock | 20 -------------------- spec/spec_helper.rb | 23 ----------------------- 3 files changed, 45 deletions(-) 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 67e73e3bb7..5db0fb4bdb 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) @@ -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) @@ -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) @@ -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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 2724048e15..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| From cc2d8632620a9db474af1b7657f3a348b9803863 Mon Sep 17 00:00:00 2001 From: John Henderson Date: Wed, 22 Apr 2026 13:59:44 +0100 Subject: [PATCH 83/87] Fix reporting API consent no-response totals Follow up to 0f43de10, which fixed the model count but not the aggregate query used by the reporting API. Update the aggregate totals to include the full no-response status set so the breakdown matches the no consent recorded total. Add regression coverage for the aggregate path. Jira-Issue: MAV-7075 --- app/models/reporting_api/total.rb | 4 +- .../api/reporting/totals_controller_spec.rb | 71 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/app/models/reporting_api/total.rb b/app/models/reporting_api/total.rb index 1883b5d567..4a699f3980 100644 --- a/app/models/reporting_api/total.rb +++ b/app/models/reporting_api/total.rb @@ -119,13 +119,15 @@ def self.with_aggregate_metrics "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/spec/controllers/api/reporting/totals_controller_spec.rb b/spec/controllers/api/reporting/totals_controller_spec.rb index 10ff4dd37b..6db08908db 100644 --- a/spec/controllers/api/reporting/totals_controller_spec.rb +++ b/spec/controllers/api/reporting/totals_controller_spec.rb @@ -286,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 From 5061280d92d997507d0a350a20325e2ac88f1328 Mon Sep 17 00:00:00 2001 From: Zoltan Antal Date: Mon, 20 Apr 2026 12:13:05 +0200 Subject: [PATCH 84/87] Update MAVIS-test end-to-end-tests workflow input variables --- .github/workflows/call-end-to-end-tests.yml | 8 ++++++-- .github/workflows/continuous-deployment.yml | 3 ++- .github/workflows/end-to-end-tests-aws.yml | 3 ++- 3 files changed, 10 insertions(+), 4 deletions(-) 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/end-to-end-tests-aws.yml b/.github/workflows/end-to-end-tests-aws.yml index f50c1493f7..47a91e2f95 100644 --- a/.github/workflows/end-to-end-tests-aws.yml +++ b/.github/workflows/end-to-end-tests-aws.yml @@ -349,7 +349,8 @@ jobs: contents: write id-token: write with: - cross_service_tests: false + fhir_api_tests: false + reporting_tests: false github_ref: ${{ needs.find-correct-test-branch.outputs.test_branch }} endpoint: http://${{ needs.wait-for-task-stability.outputs.container_ip }}:4000 stop-docker-environment: From 91c7252dba7495f4d2ef94540243a7f451676246 Mon Sep 17 00:00:00 2001 From: Zoltan Antal Date: Mon, 20 Apr 2026 12:13:37 +0200 Subject: [PATCH 85/87] Enable reporting tests for AWS PR E2E workflow --- .github/workflows/end-to-end-tests-aws.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/end-to-end-tests-aws.yml b/.github/workflows/end-to-end-tests-aws.yml index 47a91e2f95..35032036a3 100644 --- a/.github/workflows/end-to-end-tests-aws.yml +++ b/.github/workflows/end-to-end-tests-aws.yml @@ -350,7 +350,7 @@ jobs: id-token: write with: fhir_api_tests: false - reporting_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: From 524127e52c80fff68d50e40254b617b516899f66 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:53:23 +0000 Subject: [PATCH 86/87] Bump aws-actions/amazon-ecr-login from 2.1.2 to 2.1.3 Bumps [aws-actions/amazon-ecr-login](https://github.com/aws-actions/amazon-ecr-login) from 2.1.2 to 2.1.3. - [Release notes](https://github.com/aws-actions/amazon-ecr-login/releases) - [Changelog](https://github.com/aws-actions/amazon-ecr-login/blob/main/CHANGELOG.md) - [Commits](https://github.com/aws-actions/amazon-ecr-login/compare/f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78...376925c9d111252e87ae59691e5a442dd100ef6a) --- updated-dependencies: - dependency-name: aws-actions/amazon-ecr-login dependency-version: 2.1.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/build-and-push-image.yml | 2 +- .github/workflows/create_dockerized_db.yml | 2 +- .github/workflows/end-to-end-tests-aws.yml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-and-push-image.yml b/.github/workflows/build-and-push-image.yml index f2bdf4a0b4..71a94d524e 100644 --- a/.github/workflows/build-and-push-image.yml +++ b/.github/workflows/build-and-push-image.yml @@ -114,7 +114,7 @@ jobs: aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 + uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0 # yamllint disable rule:line-length diff --git a/.github/workflows/create_dockerized_db.yml b/.github/workflows/create_dockerized_db.yml index f4fda46249..cb12738052 100644 --- a/.github/workflows/create_dockerized_db.yml +++ b/.github/workflows/create_dockerized_db.yml @@ -71,7 +71,7 @@ jobs: aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 + 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/end-to-end-tests-aws.yml b/.github/workflows/end-to-end-tests-aws.yml index 35032036a3..28c9740c4d 100644 --- a/.github/workflows/end-to-end-tests-aws.yml +++ b/.github/workflows/end-to-end-tests-aws.yml @@ -70,7 +70,7 @@ jobs: aws-region: eu-west-2 - name: Login to ECR id: login-ecr - uses: aws-actions/amazon-ecr-login@f2e9fc6c2b355c1890b65e6f6f0e2ac3e6e22f78 # v2.1.2 + uses: aws-actions/amazon-ecr-login@376925c9d111252e87ae59691e5a442dd100ef6a # v2.1.3 - name: Build and push mavis/development docker image # yamllint disable rule:line-length run: | From 7783da9adec8bbd572e868785b9c5119b68c511b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 21:53:44 +0000 Subject: [PATCH 87/87] Bump prettier from 3.8.2 to 3.8.3 Bumps [prettier](https://github.com/prettier/prettier) from 3.8.2 to 3.8.3. - [Release notes](https://github.com/prettier/prettier/releases) - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) - [Commits](https://github.com/prettier/prettier/compare/3.8.2...3.8.3) --- updated-dependencies: - dependency-name: prettier dependency-version: 3.8.3 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index cc4d107dae..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.2", + "prettier": "^3.8.3", "stylelint": "^16.26.1", "stylelint-config-gds": "^2.0.0", "stylelint-order": "^8.1.1" diff --git a/yarn.lock b/yarn.lock index 9d56d63095..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.2: - version "3.8.2" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.8.2.tgz#4f52e502193c9aa5b384c3d00852003e551bbd9f" - integrity sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q== +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"